From d9757cc8bd4551fa8eac900b5ddf860c673a30aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 13 Jul 2026 08:39:01 +0200 Subject: [PATCH 1/4] add action for creating alert issues --- actions/alert-issue/action.yml | 262 +++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 actions/alert-issue/action.yml diff --git a/actions/alert-issue/action.yml b/actions/alert-issue/action.yml new file mode 100644 index 0000000..42adb22 --- /dev/null +++ b/actions/alert-issue/action.yml @@ -0,0 +1,262 @@ +name: Alert Issue +description: | + Creates or updates a release-blocker labeled GitHub issue for CI failures. + Deduplicates by title prefix + labels on open issues only — repeats as a + comment instead of creating a duplicate. Optionally adds the issue to the + current sprint iteration (env-gated). + +inputs: + source: + description: 'Alert source identifier (rehearsal, e2e, forward-port)' + required: true + repo: + description: 'Target repo for the issue (full org/repo, e.g. defguard/defguard)' + required: true + branch: + description: 'Branch that failed' + required: true + failure_summary: + description: 'Human-readable failure description for the issue title and body' + required: true + run_link: + description: 'URL to the failed workflow run' + required: true + assignees: + description: 'Comma-separated GitHub usernames to assign' + required: false + default: '' + extra_labels: + description: 'Additional comma-separated labels beyond release-blocker and source' + required: false + default: '' + +outputs: + issue_number: + description: 'The GitHub issue number' + value: ${{ steps.find_or_create.outputs.issue_number }} + issue_url: + description: 'The GitHub issue URL' + value: ${{ steps.find_or_create.outputs.issue_url }} + created: + description: 'Whether a new issue was created (true) or an existing one was updated (false)' + value: ${{ steps.find_or_create.outputs.created }} + +runs: + using: composite + steps: + # ── Step 1: Find existing open issue, or create / comment ────────────── + - name: Find or create alert issue + id: find_or_create + shell: bash + env: + GH_TOKEN: ${{ env.ALERT_TOKEN }} + run: | + set -euo pipefail + + SOURCE='${{ inputs.source }}' + REPO='${{ inputs.repo }}' + BRANCH='${{ inputs.branch }}' + SUMMARY='${{ inputs.failure_summary }}' + RUN_LINK='${{ inputs.run_link }}' + EXTRA_LABELS='${{ inputs.extra_labels }}' + + # ── Search for an existing OPEN issue (closed = resolved episode) ── + title_prefix="[${SOURCE}] ${BRANCH}:" + existing=$(gh issue list \ + --repo "${REPO}" \ + --label 'release-blocker' \ + --label "${SOURCE}" \ + --search "\"${title_prefix}\" in:title" \ + --json number --jq '.[0].number // ""' 2>/dev/null || true) + + now=$(date -u +'%Y-%m-%d %H:%M UTC') + + if [ -z "${existing}" ]; then + # ── No open issue → create a new one ────────────────────────────── + labels="release-blocker,${SOURCE}" + if [ -n "${EXTRA_LABELS}" ]; then + labels="${labels},${EXTRA_LABELS}" + fi + + printf -v body '### %s failed on `%s` `%s`\n\n**Run:** %s\n**When:** %s\n**Summary:** %s\n\n\n' \ + "${SOURCE}" "${REPO}" "${BRANCH}" "${RUN_LINK}" "${now}" "${SUMMARY}" \ + "${SOURCE}" "${REPO}" "${BRANCH}" + + new_issue_url=$(gh issue create \ + --repo "${REPO}" \ + --title "${title_prefix} ${SUMMARY}" \ + --label "${labels}" \ + --body "${body}") + + issue_number=$(echo "${new_issue_url}" | grep -oP '\d+$' || true) + issue_url="${new_issue_url}" + created='true' + + echo "::notice::Created issue #${issue_number} in ${REPO}" + else + # ── Open issue exists → comment, never re-open ──────────────────── + issue_number="${existing}" + issue_url="https://github.com/${REPO}/issues/${issue_number}" + created='false' + + printf -v comment_body '**Recurrence:** %s — %s\n**Summary:** %s\n' \ + "${RUN_LINK}" "${now}" "${SUMMARY}" + + gh issue comment "${issue_number}" \ + --repo "${REPO}" \ + --body "${comment_body}" + + echo "::notice::Commented on existing issue #${issue_number} in ${REPO}" + fi + + # ── Outputs ──────────────────────────────────────────────────────── + echo "issue_number=${issue_number}" >> "${GITHUB_OUTPUT}" + echo "issue_url=${issue_url}" >> "${GITHUB_OUTPUT}" + echo "created=${created}" >> "${GITHUB_OUTPUT}" + + # ── Step 2: Assign users (if requested) ───────────────────────────────── + - name: Assign users + if: inputs.assignees != '' + shell: bash + env: + GH_TOKEN: ${{ env.ALERT_TOKEN }} + run: | + set -euo pipefail + gh issue edit '${{ steps.find_or_create.outputs.issue_number }}' \ + --repo '${{ inputs.repo }}' \ + --add-assignee '${{ inputs.assignees }}' + + # ── Step 3: Add to current sprint (env-gated) ─────────────────────────── + - name: Add to current sprint + shell: bash + env: + GH_TOKEN: ${{ env.ALERT_TOKEN }} + run: | + set -euo pipefail + + # ── Guard: skip if SPRINT_PROJECT_NUMBER is not configured ───────── + SPRINT_PROJECT_NUMBER='${{ env.SPRINT_PROJECT_NUMBER }}' + if [ -z "${SPRINT_PROJECT_NUMBER}" ]; then + echo "Skipping sprint-add: SPRINT_PROJECT_NUMBER env var not set" + exit 0 + fi + + REPO='${{ inputs.repo }}' + ISSUE_NUMBER='${{ steps.find_or_create.outputs.issue_number }}' + PROJECT_NUMBER="${SPRINT_PROJECT_NUMBER}" + + ITERATION_FIELD='${{ env.SPRINT_ITERATION_FIELD }}' + if [ -z "${ITERATION_FIELD}" ]; then + ITERATION_FIELD='Sprint' + fi + + # ── Extract org from repo input ──────────────────────────────────── + ORG="${REPO%%/*}" + + # ── Query project for iteration field and current iterations ─────── + echo "::group::Querying project #${PROJECT_NUMBER} for iteration field '${ITERATION_FIELD}'" + project_json=$(gh api graphql -f query=' + query($org:String!, $projectNumber:Int!, $fieldName:String!) { + organization(login:$org) { + projectV2(number:$projectNumber) { + id + field(name:$fieldName) { + ... on ProjectV2IterationField { + id + configuration { + iterations { + id + title + startDate + duration + } + } + } + } + } + } + } + ' -F org="${ORG}" -F projectNumber="${PROJECT_NUMBER}" -F fieldName="${ITERATION_FIELD}") + + project_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.id') + field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.field.id // ""') + + if [ -z "${field_id}" ] || [ "${field_id}" = "null" ]; then + echo "::endgroup::" + echo "::warning::Iteration field '${ITERATION_FIELD}' not found on project #${PROJECT_NUMBER} — skipping sprint-add" + exit 0 + fi + + echo "Project ID: ${project_id}" + echo "Field ID: ${field_id}" + echo "::endgroup::" + + # ── Resolve current iteration (date-range match) ─────────────────── + today=$(date -u +%Y-%m-%d) + today_epoch=$(date -d "${today}" +%s) + + iterations_json=$(echo "${project_json}" | jq -c '.data.organization.projectV2.field.configuration.iterations[]? // empty') + current_iter="" + + if [ -n "${iterations_json}" ]; then + while IFS= read -r iter; do + start_date=$(echo "${iter}" | jq -r '.startDate') + duration=$(echo "${iter}" | jq -r '.duration') + iter_title=$(echo "${iter}" | jq -r '.title') + + start_epoch=$(date -d "${start_date}" +%s) + end_epoch=$((start_epoch + duration * 86400)) + + if [ "${today_epoch}" -ge "${start_epoch}" ] && [ "${today_epoch}" -lt "${end_epoch}" ]; then + current_iter=$(echo "${iter}" | jq -r '.id') + echo "::notice::Matched current iteration: '${iter_title}' (${start_date}, ${duration}d)" + break + fi + done <<< "${iterations_json}" + fi + + if [ -z "${current_iter}" ]; then + echo "::warning::No iteration contains today (${today}) — skipping sprint-add" + exit 0 + fi + + # ── Get issue node ID ────────────────────────────────────────────── + issue_node_id=$(gh issue view "${ISSUE_NUMBER}" \ + --repo "${REPO}" \ + --json id --jq '.id') + echo "Issue node ID: ${issue_node_id}" + + # ── Add issue to project ─────────────────────────────────────────── + echo "::group::Adding issue to project" + item_json=$(gh api graphql -f query=' + mutation($projectId:ID!, $contentId:ID!) { + addProjectV2ItemById(input:{projectId:$projectId, contentId:$contentId}) { + item { id } + } + } + ' -F projectId="${project_id}" -F contentId="${issue_node_id}") + + item_id=$(echo "${item_json}" | jq -r '.data.addProjectV2ItemById.item.id') + echo "Project item ID: ${item_id}" + echo "::endgroup::" + + # ── Set iteration field ──────────────────────────────────────────── + echo "::group::Setting iteration to current sprint" + gh api graphql -f query=' + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $iterationId:String!) { + updateProjectV2ItemFieldValue(input:{ + projectId:$projectId + itemId:$itemId + fieldId:$fieldId + value:{iterationId:$iterationId} + }) { + projectV2Item { id } + } + } + ' -F projectId="${project_id}" \ + -F itemId="${item_id}" \ + -F fieldId="${field_id}" \ + -F iterationId="${current_iter}" + + echo "::notice::Issue #${ISSUE_NUMBER} added to current sprint" + echo "::endgroup::" From 92f8bccc21057b38ce753289ba63e49faf6e5a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 13 Jul 2026 08:57:20 +0200 Subject: [PATCH 2/4] remove source label --- actions/alert-issue/action.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/actions/alert-issue/action.yml b/actions/alert-issue/action.yml index 42adb22..fb0e41e 100644 --- a/actions/alert-issue/action.yml +++ b/actions/alert-issue/action.yml @@ -1,9 +1,9 @@ name: Alert Issue description: | Creates or updates a release-blocker labeled GitHub issue for CI failures. - Deduplicates by title prefix + labels on open issues only — repeats as a - comment instead of creating a duplicate. Optionally adds the issue to the - current sprint iteration (env-gated). + Deduplicates by title prefix on open issues only — repeats as a comment + instead of creating a duplicate. Optionally adds the issue to the current + sprint iteration (env-gated). inputs: source: @@ -26,7 +26,7 @@ inputs: required: false default: '' extra_labels: - description: 'Additional comma-separated labels beyond release-blocker and source' + description: 'Additional comma-separated labels beyond release-blocker' required: false default: '' @@ -65,7 +65,6 @@ runs: existing=$(gh issue list \ --repo "${REPO}" \ --label 'release-blocker' \ - --label "${SOURCE}" \ --search "\"${title_prefix}\" in:title" \ --json number --jq '.[0].number // ""' 2>/dev/null || true) @@ -73,7 +72,7 @@ runs: if [ -z "${existing}" ]; then # ── No open issue → create a new one ────────────────────────────── - labels="release-blocker,${SOURCE}" + labels='release-blocker' if [ -n "${EXTRA_LABELS}" ]; then labels="${labels},${EXTRA_LABELS}" fi From d233a8fa003a11dc30fb34af0e3bab585d0d0fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 13 Jul 2026 09:02:10 +0200 Subject: [PATCH 3/4] set issue status to Development ready --- actions/alert-issue/action.yml | 58 +++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/actions/alert-issue/action.yml b/actions/alert-issue/action.yml index fb0e41e..36c377f 100644 --- a/actions/alert-issue/action.yml +++ b/actions/alert-issue/action.yml @@ -152,14 +152,14 @@ runs: # ── Extract org from repo input ──────────────────────────────────── ORG="${REPO%%/*}" - # ── Query project for iteration field and current iterations ─────── - echo "::group::Querying project #${PROJECT_NUMBER} for iteration field '${ITERATION_FIELD}'" + # ── Query project for iteration field and Status field ─────── + echo "::group::Querying project #${PROJECT_NUMBER} for iteration field '${ITERATION_FIELD}' and Status field" project_json=$(gh api graphql -f query=' - query($org:String!, $projectNumber:Int!, $fieldName:String!) { + query($org:String!, $projectNumber:Int!, $iterationFieldName:String!) { organization(login:$org) { projectV2(number:$projectNumber) { id - field(name:$fieldName) { + iterationField: field(name:$iterationFieldName) { ... on ProjectV2IterationField { id configuration { @@ -172,29 +172,38 @@ runs: } } } + statusField: field(name:"Status") { + ... on ProjectV2SingleSelectField { + id + options { + id + name + } + } + } } } } - ' -F org="${ORG}" -F projectNumber="${PROJECT_NUMBER}" -F fieldName="${ITERATION_FIELD}") + ' -F org="${ORG}" -F projectNumber="${PROJECT_NUMBER}" -F iterationFieldName="${ITERATION_FIELD}") project_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.id') - field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.field.id // ""') + iteration_field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.iterationField.id // ""') - if [ -z "${field_id}" ] || [ "${field_id}" = "null" ]; then + if [ -z "${iteration_field_id}" ] || [ "${iteration_field_id}" = "null" ]; then echo "::endgroup::" echo "::warning::Iteration field '${ITERATION_FIELD}' not found on project #${PROJECT_NUMBER} — skipping sprint-add" exit 0 fi - echo "Project ID: ${project_id}" - echo "Field ID: ${field_id}" + echo "Project ID: ${project_id}" + echo "Iteration field: ${iteration_field_id}" echo "::endgroup::" # ── Resolve current iteration (date-range match) ─────────────────── today=$(date -u +%Y-%m-%d) today_epoch=$(date -d "${today}" +%s) - iterations_json=$(echo "${project_json}" | jq -c '.data.organization.projectV2.field.configuration.iterations[]? // empty') + iterations_json=$(echo "${project_json}" | jq -c '.data.organization.projectV2.iterationField.configuration.iterations[]? // empty') current_iter="" if [ -n "${iterations_json}" ]; then @@ -254,8 +263,35 @@ runs: } ' -F projectId="${project_id}" \ -F itemId="${item_id}" \ - -F fieldId="${field_id}" \ + -F fieldId="${iteration_field_id}" \ -F iterationId="${current_iter}" echo "::notice::Issue #${ISSUE_NUMBER} added to current sprint" echo "::endgroup::" + + # ── Set Status to "Development ready" ──────────────────────────── + status_field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.id // ""') + dev_ready_opt=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.options[]? | select(.name == "Development ready") | .id // ""') + + if [ -n "${status_field_id}" ] && [ "${status_field_id}" != "null" ] && [ -n "${dev_ready_opt}" ] && [ "${dev_ready_opt}" != "null" ]; then + echo "::group::Setting status to 'Development ready'" + gh api graphql -f query=' + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) { + updateProjectV2ItemFieldValue(input:{ + projectId:$projectId + itemId:$itemId + fieldId:$fieldId + value:{singleSelectOptionId:$optionId} + }) { + projectV2Item { id } + } + } + ' -F projectId="${project_id}" \ + -F itemId="${item_id}" \ + -F fieldId="${status_field_id}" \ + -F optionId="${dev_ready_opt}" + echo "::notice::Status set to 'Development ready'" + echo "::endgroup::" + else + echo "::warning::Status field or 'Development ready' option not found on project #${PROJECT_NUMBER} — skipping status set" + fi From dd1ecec098c50d5b402ea6646bb91d9fb8893560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Mon, 13 Jul 2026 09:03:38 +0200 Subject: [PATCH 4/4] set status only for new issue --- actions/alert-issue/action.yml | 52 ++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/actions/alert-issue/action.yml b/actions/alert-issue/action.yml index 36c377f..c5efe60 100644 --- a/actions/alert-issue/action.yml +++ b/actions/alert-issue/action.yml @@ -269,29 +269,33 @@ runs: echo "::notice::Issue #${ISSUE_NUMBER} added to current sprint" echo "::endgroup::" - # ── Set Status to "Development ready" ──────────────────────────── - status_field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.id // ""') - dev_ready_opt=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.options[]? | select(.name == "Development ready") | .id // ""') - - if [ -n "${status_field_id}" ] && [ "${status_field_id}" != "null" ] && [ -n "${dev_ready_opt}" ] && [ "${dev_ready_opt}" != "null" ]; then - echo "::group::Setting status to 'Development ready'" - gh api graphql -f query=' - mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) { - updateProjectV2ItemFieldValue(input:{ - projectId:$projectId - itemId:$itemId - fieldId:$fieldId - value:{singleSelectOptionId:$optionId} - }) { - projectV2Item { id } + # ── Set Status to "Development ready" (new issues only) ────────── + ISSUE_CREATED='${{ steps.find_or_create.outputs.created }}' + + if [ "${ISSUE_CREATED}" = 'true' ]; then + status_field_id=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.id // ""') + dev_ready_opt=$(echo "${project_json}" | jq -r '.data.organization.projectV2.statusField.options[]? | select(.name == "Development ready") | .id // ""') + + if [ -n "${status_field_id}" ] && [ "${status_field_id}" != "null" ] && [ -n "${dev_ready_opt}" ] && [ "${dev_ready_opt}" != "null" ]; then + echo "::group::Setting status to 'Development ready'" + gh api graphql -f query=' + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $optionId:String!) { + updateProjectV2ItemFieldValue(input:{ + projectId:$projectId + itemId:$itemId + fieldId:$fieldId + value:{singleSelectOptionId:$optionId} + }) { + projectV2Item { id } + } } - } - ' -F projectId="${project_id}" \ - -F itemId="${item_id}" \ - -F fieldId="${status_field_id}" \ - -F optionId="${dev_ready_opt}" - echo "::notice::Status set to 'Development ready'" - echo "::endgroup::" - else - echo "::warning::Status field or 'Development ready' option not found on project #${PROJECT_NUMBER} — skipping status set" + ' -F projectId="${project_id}" \ + -F itemId="${item_id}" \ + -F fieldId="${status_field_id}" \ + -F optionId="${dev_ready_opt}" + echo "::notice::Status set to 'Development ready'" + echo "::endgroup::" + else + echo "::warning::Status field or 'Development ready' option not found on project #${PROJECT_NUMBER} — skipping status set" + fi fi