diff --git a/.github/scripts/classify-vulnerability-issues.jq b/.github/scripts/classify-vulnerability-issues.jq new file mode 100644 index 000000000..61f725ab6 --- /dev/null +++ b/.github/scripts/classify-vulnerability-issues.jq @@ -0,0 +1,20 @@ +def finding_id($prefix): + if ((.title // "") | startswith($prefix + " ")) then + (.title | ltrimstr($prefix + " ") | split(": ")) + | if length > 1 then .[0] else "" end + else + "" + end; + +($findings[0].cves | map(.cveId)) as $currentIds +| map( + . + { findingId: finding_id($prefix) } + | .findingId as $findingId + | if .findingId == "" then + . + { action: "ignore" } + elif ($currentIds | index($findingId)) != null then + . + { action: "keep" } + else + . + { action: "close" } + end + ) diff --git a/.github/scripts/match-vulnerability-issue.jq b/.github/scripts/match-vulnerability-issue.jq new file mode 100644 index 000000000..0030cd349 --- /dev/null +++ b/.github/scripts/match-vulnerability-issue.jq @@ -0,0 +1,32 @@ +def finding_id($prefix): + if ((.title // "") | startswith($prefix + " ")) then + (.title | ltrimstr($prefix + " ") | split(": ")) + | if length > 1 then .[0] else "" end + else + "" + end; + +[ + .data.issues.nodes[]? + | select(finding_id($prefix) == $findingId) +] as $matches +| ($matches | map(select(.state.type != "completed" and .state.type != "canceled" and .state.type != "duplicate")) | .[0]) as $open +| ($matches | map(select(.state.type == "completed" or .state.type == "canceled")) | .[0]) as $reopenable +| ($open // $reopenable // $matches[0]) as $chosen +| if $chosen == null then + { + linearIssueExists: false, + linearIssueId: "", + linearIssueIdentifier: "", + linearIssueUrl: "", + linearIssueClosed: false + } + else + { + linearIssueExists: true, + linearIssueId: $chosen.id, + linearIssueIdentifier: $chosen.identifier, + linearIssueUrl: $chosen.url, + linearIssueClosed: (($chosen.state.type == "completed") or ($chosen.state.type == "canceled")) + } + end diff --git a/.github/scripts/test-vulnerability-triage.sh b/.github/scripts/test-vulnerability-triage.sh new file mode 100755 index 000000000..7a4f078ec --- /dev/null +++ b/.github/scripts/test-vulnerability-triage.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq" +CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq" +PREFIX="[sourcebot-dev/example]" + +assert_json() { + local description="$1" + local actual="$2" + local expected="$3" + + if ! jq -e --argjson expected "$expected" '. == $expected' <<<"$actual" >/dev/null; then + echo "FAIL: $description" + echo "Expected: $expected" + echo "Actual: $actual" + exit 1 + fi +} + +match() { + local finding_id="$1" + jq -c --arg prefix "$PREFIX" --arg findingId "$finding_id" -f "$MATCH_FILTER" +} + +EMPTY_MATCH='{"linearIssueExists":false,"linearIssueId":"","linearIssueIdentifier":"","linearIssueUrl":"","linearIssueClosed":false}' +OPEN_MATCH='{"linearIssueExists":true,"linearIssueId":"open-id","linearIssueIdentifier":"SOU-2","linearIssueUrl":"https://linear.app/SOU-2","linearIssueClosed":false}' +CLOSED_MATCH='{"linearIssueExists":true,"linearIssueId":"closed-id","linearIssueIdentifier":"SOU-1","linearIssueUrl":"https://linear.app/SOU-1","linearIssueClosed":true}' + +NO_ISSUES='{"data":{"issues":{"nodes":[]}}}' +assert_json "creates when no Linear issue exists" "$(match CVE-2026-123 <<<"$NO_ISSUES")" "$EMPTY_MATCH" + +ISSUES='{ + "data": { + "issues": { + "nodes": [ + { + "id": "closed-id", + "identifier": "SOU-1", + "url": "https://linear.app/SOU-1", + "title": "[sourcebot-dev/example] CVE-2026-123: old finding", + "state": {"type": "completed"} + }, + { + "id": "open-id", + "identifier": "SOU-2", + "url": "https://linear.app/SOU-2", + "title": "[sourcebot-dev/example] CVE-2026-123: current finding", + "state": {"type": "started"} + }, + { + "id": "prefix-collision", + "identifier": "SOU-3", + "url": "https://linear.app/SOU-3", + "title": "[sourcebot-dev/example] CVE-2026-1234: different finding", + "state": {"type": "started"} + }, + { + "id": "other-repo", + "identifier": "SOU-4", + "url": "https://linear.app/SOU-4", + "title": "[sourcebot-dev/other] CVE-2026-123: different repository", + "state": {"type": "started"} + }, + { + "id": "duplicate-id", + "identifier": "SOU-5", + "url": "https://linear.app/SOU-5", + "title": "[sourcebot-dev/example] CVE-2026-123: duplicate marker", + "state": {"type": "duplicate"} + } + ] + } + } +}' +assert_json "deduplicates by exact finding id and prefers an open issue" "$(match CVE-2026-123 <<<"$ISSUES")" "$OPEN_MATCH" +assert_json "does not confuse an id with a longer id" "$(match CVE-2026-12 <<<"$ISSUES")" "$EMPTY_MATCH" + +CLOSED_ONLY="$(jq '.data.issues.nodes |= map(select(.id == "closed-id"))' <<<"$ISSUES")" +assert_json "reopens a closed issue when the finding returns" "$(match CVE-2026-123 <<<"$CLOSED_ONLY")" "$CLOSED_MATCH" + +DUPLICATE_ONLY="$(jq '.data.issues.nodes |= map(select(.id == "duplicate-id"))' <<<"$ISSUES")" +DUPLICATE_MATCH='{"linearIssueExists":true,"linearIssueId":"duplicate-id","linearIssueIdentifier":"SOU-5","linearIssueUrl":"https://linear.app/SOU-5","linearIssueClosed":false}' +assert_json "does not reopen a duplicate marker" "$(match CVE-2026-123 <<<"$DUPLICATE_ONLY")" "$DUPLICATE_MATCH" + +FINDINGS='{ + "cves": [ + {"cveId": "CVE-2026-123"}, + {"cveId": "GHSA-abcd-efgh-ijkl"}, + {"cveId": "codeql:js/example-rule"} + ] +}' +OPEN_ISSUES='[ + { + "id": "keep-cve", + "title": "[sourcebot-dev/example] CVE-2026-123: still present" + }, + { + "id": "keep-codeql", + "title": "[sourcebot-dev/example] codeql:js/example-rule: still present" + }, + { + "id": "close-cve", + "title": "[sourcebot-dev/example] CVE-2025-999: resolved" + }, + { + "id": "close-prefix-collision", + "title": "[sourcebot-dev/example] CVE-2026-12: resolved" + }, + { + "id": "ignore-other-repo", + "title": "[sourcebot-dev/other] CVE-2025-999: unrelated" + }, + { + "id": "ignore-unmanaged", + "title": "[sourcebot-dev/example] maintenance without a finding delimiter" + } +]' + +CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" +assert_json "keeps every exact current finding" "$(jq -c '[.[] | select(.action == "keep") | .id]' <<<"$CLASSIFIED")" '["keep-cve","keep-codeql"]' +assert_json "closes only resolved managed findings" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED")" '["close-cve","close-prefix-collision"]' +assert_json "ignores unrelated titles and repositories" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED")" '["ignore-other-repo","ignore-unmanaged"]' + +EMPTY_FINDINGS='{"cves":[]}' +CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")" +assert_json "closes all managed issues when a complete scan is clean" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED_EMPTY")" '["keep-cve","keep-codeql","close-cve","close-prefix-collision"]' + +echo "All vulnerability triage reconciliation tests passed." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d52e3147a..440452df2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,17 @@ on: jobs: + vulnerability-triage: + name: Vulnerability triage reconciliation + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Test reconciliation behavior + run: .github/scripts/test-vulnerability-triage.sh + test: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index ed71df08c..fb9af3189 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -161,6 +161,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail HAS_ALERTS=false # Check Dependabot alerts (requires DEPENDABOT_PAT) @@ -179,10 +180,12 @@ jobs: HAS_ALERTS=true fi else - echo "::warning::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Is DEPENDABOT_PAT configured?" + echo "::error::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Reconciliation requires a complete alert snapshot." + exit 1 fi else - echo "::warning::DEPENDABOT_PAT not configured. Skipping Dependabot alert check." + echo "::error::No token is available for Dependabot alerts. Reconciliation requires a complete alert snapshot." + exit 1 fi # Check CodeQL alerts (uses GITHUB_TOKEN with security-events: read) @@ -202,7 +205,8 @@ jobs: elif [ "$CODEQL_STATUS" = "404" ]; then echo "CodeQL is not enabled for this repository. Skipping." else - echo "::warning::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS)" + echo "::error::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS). Reconciliation requires a complete alert snapshot." + exit 1 fi echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT" @@ -268,12 +272,14 @@ jobs: triage: name: Linear Triage needs: [scan, check-alerts] + # A clean scan still needs reconciliation so resolved Linear issues can be + # closed. Never reconcile after a failed scanner job: missing data must not + # be interpreted as an empty vulnerability set. if: >- - always() && !cancelled() && ( - needs.scan.outputs.has_vulnerabilities == 'true' || - needs.check-alerts.outputs.has_alerts == 'true' || - inputs.force_analysis == true - ) + always() && + !cancelled() && + needs.check-alerts.result == 'success' && + (needs.scan.result == 'success' || needs.scan.result == 'skipped') runs-on: ubuntu-latest steps: - name: Checkout repository @@ -305,10 +311,10 @@ jobs: env: DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT || secrets.GITHUB_TOKEN }} run: | + set -euo pipefail if [ -z "$DEPENDABOT_PAT" ]; then - echo "::warning::DEPENDABOT_PAT not configured. Writing empty Dependabot alerts." - echo "[]" > dependabot-alerts.json - exit 0 + echo "::error::No token is available for Dependabot alerts. Refusing to reconcile an incomplete snapshot." + exit 1 fi ALL_ALERTS="[]" @@ -323,10 +329,9 @@ jobs: echo "Dependabot API response: HTTP $HTTP_CODE" if [ "$HTTP_CODE" != "200" ]; then - echo "::warning::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Writing empty results." + echo "::error::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Refusing to reconcile an incomplete snapshot." echo "Response body: $(cat /tmp/dependabot-body.json | head -c 500)" - echo "[]" > dependabot-alerts.json - exit 0 + exit 1 fi BODY=$(cat /tmp/dependabot-body.json) @@ -384,6 +389,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail ALL_ALERTS="[]" PAGE=1 @@ -403,9 +409,8 @@ jobs: fi if [ "$HTTP_CODE" != "200" ]; then - echo "::warning::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Writing empty results." - echo "[]" > codeql-alerts.json - exit 0 + echo "::error::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Refusing to reconcile an incomplete snapshot." + exit 1 fi COUNT=$(echo "$BODY" | jq 'length') @@ -568,9 +573,10 @@ jobs: LINEAR_STATE_NAME: ${{ inputs.linear_state_name || 'Triage' }} run: | set -euo pipefail - # Resolve team UUID, labels, configured state, active cycle, and API key - # owner once, then expose them for issue creation and reopening. - METADATA_QUERY='query($teamId: String!, $stateName: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: $stateName } }) { nodes { id } } activeCycle { id } } viewer { id } }' + # Resolve team UUID, labels, configured/open and completed states, + # active cycle, and API key owner once. The completed state is used to + # close issues whose finding is absent from the complete scanner snapshot. + METADATA_QUERY='query($teamId: String!, $stateName: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: $stateName } }) { nodes { id } } doneStates: states(filter: { type: { eq: "completed" } }) { nodes { id position } } activeCycle { id } } viewer { id } }' METADATA_PAYLOAD=$(jq -n \ --arg query "$METADATA_QUERY" \ --arg teamId "$LINEAR_TEAM_ID" \ @@ -581,9 +587,15 @@ jobs: -H "Authorization: $LINEAR_API_KEY" \ -d "$METADATA_PAYLOAD") + if [ "$(echo "$METADATA_RESPONSE" | jq 'has("errors") or (.data.team == null)')" = "true" ]; then + echo "::error::Could not load Linear team metadata: $(echo "$METADATA_RESPONSE" | jq -c '.errors // .')" + exit 1 + fi + TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty') LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty') STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty') + DONE_STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.doneStates.nodes | sort_by(.position) | .[0].id // empty') ACTIVE_CYCLE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.activeCycle.id // empty') VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty') @@ -623,46 +635,44 @@ jobs: echo "label_id=$LABEL_ID" echo "repo_label_id=$REPO_LABEL_ID" echo "state_id=$STATE_ID" + echo "done_state_id=$DONE_STATE_ID" echo "active_cycle_id=$ACTIVE_CYCLE_ID" echo "viewer_id=$VIEWER_ID" } >> "$GITHUB_OUTPUT" - # For each finding, search Linear for an existing issue whose title contains the - # finding id, then scope to this repo via the "[]" title prefix and - # prefer an open issue over a closed one. The team is intentionally NOT part of - # the GraphQL filter: its `id.eq` expects an `ID`, and binding our `String` - # team-UUID variable there is a type error that makes every search return null. - # Repo-prefix scoping below is the authoritative filter anyway. - SEARCH_QUERY='query($text: String!) { issues(filter: { title: { contains: $text } }) { nodes { id identifier url title state { type } } } }' + # Search by the exact workflow-managed title prefix. The local jq + # filter repeats the exact-id check and prefers an open issue over a + # completed/canceled one, while preserving Duplicate as a terminal + # deduplication marker. + SEARCH_QUERY='query($titlePrefix: String!, $teamId: ID!) { issues(filter: { title: { startsWith: $titlePrefix }, team: { id: { eq: $teamId } } }) { nodes { id identifier url title state { type } } } }' echo '[]' > /tmp/matched.json jq -c '.cves[]' findings-base.json > /tmp/findings.jsonl while IFS= read -r finding; do CVE_ID=$(echo "$finding" | jq -r '.cveId') - VARS=$(jq -n --arg text "$CVE_ID" '{text: $text}') + TITLE_PREFIX="[$REPOSITORY] $CVE_ID:" + VARS=$(jq -n \ + --arg titlePrefix "$TITLE_PREFIX" \ + --arg teamId "$TEAM_UUID" \ + '{titlePrefix: $titlePrefix, teamId: $teamId}') PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}') RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $LINEAR_API_KEY" \ -d "$PAYLOAD") - # Surface query failures instead of silently treating them as "no match" - # (which would create a duplicate issue). + # A failed search is not "no match": treating it as one could create + # a duplicate issue, so stop before any Linear mutations. if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then - echo "::warning::Linear search for $CVE_ID failed: $(echo "$RESPONSE" | jq -c '.errors // .')" + echo "::error::Linear search for $CVE_ID failed: $(echo "$RESPONSE" | jq -c '.errors // .')" + exit 1 fi - SELECTED=$(echo "$RESPONSE" | jq --arg prefix "[$REPOSITORY]" ' - [.data.issues.nodes[]? | select(.title | startswith($prefix))] as $matches | - ($matches | map(select(.state.type != "completed" and .state.type != "canceled")) | .[0]) as $open | - ($open // $matches[0]) as $chosen | - if $chosen == null then - {linearIssueExists: false, linearIssueId: "", linearIssueIdentifier: "", linearIssueUrl: "", linearIssueClosed: false} - else - {linearIssueExists: true, linearIssueId: $chosen.id, linearIssueIdentifier: $chosen.identifier, linearIssueUrl: $chosen.url, linearIssueClosed: (($chosen.state.type == "completed") or ($chosen.state.type == "canceled"))} - end - ') + SELECTED=$(echo "$RESPONSE" | jq \ + --arg prefix "[$REPOSITORY]" \ + --arg findingId "$CVE_ID" \ + -f .github/scripts/match-vulnerability-issue.jq) MERGED=$(echo "$finding" "$SELECTED" | jq -s '.[0] + .[1]') jq --argjson item "$MERGED" '. + [$item]' /tmp/matched.json > /tmp/matched.tmp && mv /tmp/matched.tmp /tmp/matched.json @@ -885,3 +895,112 @@ jobs: echo "::error::Failed to create $FAILED_COUNT Linear issue(s)" exit 1 fi + + - name: Close resolved Linear issues + if: inputs.dry_run != true + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + REPOSITORY: ${{ github.repository }} + TEAM_UUID: ${{ steps.match.outputs.team_uuid }} + DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }} + run: | + set -euo pipefail + # The job only reaches this step after every configured scanner + # produced a complete snapshot. Pipeline-managed issues use the exact + # "[repository] finding-id: title" convention, so an open issue can be + # closed when its exact finding id is absent from findings.json. + if [ -z "$DONE_STATE_ID" ]; then + echo "::error::Could not resolve a completed Linear workflow state." + exit 1 + fi + + PREFIX="[$REPOSITORY]" + SEARCH_QUERY='query($prefix: String!, $teamId: ID!, $after: String) { issues(first: 100, after: $after, filter: { title: { startsWith: $prefix }, team: { id: { eq: $teamId } }, state: { type: { nin: ["completed", "canceled", "duplicate"] } } }) { nodes { id identifier url title } pageInfo { hasNextPage endCursor } } }' + + echo '[]' > /tmp/open-issues.json + AFTER="" + while true; do + if [ -z "$AFTER" ]; then + VARS=$(jq -n --arg prefix "$PREFIX" --arg teamId "$TEAM_UUID" '{prefix: $prefix, teamId: $teamId}') + else + VARS=$(jq -n \ + --arg prefix "$PREFIX" \ + --arg teamId "$TEAM_UUID" \ + --arg after "$AFTER" \ + '{prefix: $prefix, teamId: $teamId, after: $after}') + fi + PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}') + RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$PAYLOAD") + + if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then + echo "::error::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .')" + exit 1 + fi + + echo "$RESPONSE" | jq '.data.issues.nodes' > /tmp/open-issues-page.json + jq -s '.[0] + .[1]' /tmp/open-issues.json /tmp/open-issues-page.json > /tmp/open-issues.tmp + mv /tmp/open-issues.tmp /tmp/open-issues.json + + HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + AFTER=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.endCursor') + done + + jq \ + --arg prefix "$PREFIX" \ + --slurpfile findings findings.json \ + -f .github/scripts/classify-vulnerability-issues.jq \ + /tmp/open-issues.json > /tmp/classified-issues.json + + CURRENT_COUNT=$(jq '.cves | length' findings.json) + OPEN_COUNT=$(jq 'length' /tmp/open-issues.json) + CLOSE_COUNT=$(jq '[.[] | select(.action == "close")] | length' /tmp/classified-issues.json) + echo "Reconciling $OPEN_COUNT open managed issue(s) against $CURRENT_COUNT current finding(s); $CLOSE_COUNT issue(s) are resolved." + + echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + CLOSE_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }' + CLOSED_COUNT=0 + FAILED_COUNT=0 + + jq -c '.[] | select(.action == "close")' /tmp/classified-issues.json > /tmp/issues-to-close.jsonl + while IFS= read -r issue; do + ISSUE_ID=$(echo "$issue" | jq -r '.id') + ISSUE_IDENTIFIER=$(echo "$issue" | jq -r '.identifier') + ISSUE_URL=$(echo "$issue" | jq -r '.url') + FINDING_ID=$(echo "$issue" | jq -r '.findingId') + + CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}') + CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}') + CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$CLOSE_PAYLOAD") + + if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then + echo "Closed $ISSUE_IDENTIFIER — $FINDING_ID is no longer reported" + echo "- Closed [$ISSUE_IDENTIFIER]($ISSUE_URL) — **$FINDING_ID** is no longer reported" >> "$GITHUB_STEP_SUMMARY" + CLOSED_COUNT=$((CLOSED_COUNT + 1)) + else + echo "::error::Failed to close $ISSUE_IDENTIFIER" + echo "$CLOSE_RESPONSE" | jq . + echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY" + FAILED_COUNT=$((FAILED_COUNT + 1)) + fi + done < /tmp/issues-to-close.jsonl + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Summary:** Closed $CLOSED_COUNT resolved issue(s), failed $FAILED_COUNT." >> "$GITHUB_STEP_SUMMARY" + if [ "$CLOSED_COUNT" -eq 0 ] && [ "$FAILED_COUNT" -eq 0 ]; then + echo "No resolved issues to close." >> "$GITHUB_STEP_SUMMARY" + fi + + if [ "$FAILED_COUNT" -gt 0 ]; then + exit 1 + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 33793889c..92fdc34be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Vulnerability triage now keeps Linear issues synchronized with current security findings. + ## [5.1.4] - 2026-07-24 ### Fixed