@@ -242,11 +242,17 @@ jobs:
242242 triage :
243243 name : Linear Triage
244244 needs : [scan, check-alerts]
245+ # Run whenever there is scan/alert data to triage, OR force_analysis is set. We
246+ # also run on the canonical repo even when the scans come back clean: the triage
247+ # job reconciles existing Linear issues against the current findings and closes
248+ # any whose vulnerability is no longer present, which only happens if the job runs.
245249 if : >-
246250 always() && !cancelled() && (
247251 needs.scan.outputs.has_vulnerabilities == 'true' ||
248252 needs.check-alerts.outputs.has_alerts == 'true' ||
249- inputs.force_analysis == true
253+ inputs.force_analysis == true ||
254+ github.repository == 'sourcebot-dev/sourcebot' ||
255+ inputs.image != ''
250256 )
251257 runs-on : ubuntu-latest
252258 steps :
@@ -545,7 +551,7 @@ jobs:
545551 set -euo pipefail
546552 # Resolve team UUID + the "CVE" label, "Triage" state, and API key owner once,
547553 # and expose them as outputs so the issue-creation step can reuse them.
548- METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } } viewer { id } }'
554+ METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } doneStates: states(filter: { type: { eq: "completed" } }) { nodes { id position } } } viewer { id } }'
549555 METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \
550556 '{query: $query, variables: {teamId: $teamId}}')
551557 METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
@@ -556,6 +562,9 @@ jobs:
556562 TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty')
557563 LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty')
558564 STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty')
565+ # Lowest-position completed state is the team's canonical "Done"; used to
566+ # auto-close issues whose vulnerability is no longer reported.
567+ DONE_STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.doneStates.nodes | sort_by(.position) | .[0].id // empty')
559568 VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty')
560569
561570 if [ -z "$TEAM_UUID" ]; then
@@ -594,6 +603,7 @@ jobs:
594603 echo "label_id=$LABEL_ID"
595604 echo "repo_label_id=$REPO_LABEL_ID"
596605 echo "state_id=$STATE_ID"
606+ echo "done_state_id=$DONE_STATE_ID"
597607 echo "viewer_id=$VIEWER_ID"
598608 } >> "$GITHUB_OUTPUT"
599609
@@ -827,4 +837,128 @@ jobs:
827837 if [ "$FAILED_COUNT" -gt 0 ]; then
828838 echo "::error::Failed to create $FAILED_COUNT Linear issue(s)"
829839 exit 1
840+ fi
841+
842+ - name : Close resolved Linear issues
843+ # Only reconcile when both scanners succeeded — a failed scan produces an empty
844+ # findings set, which would otherwise look like "everything is resolved" and
845+ # close every open issue. Skipped/failed scans leave existing issues untouched.
846+ if : inputs.dry_run != true && needs.scan.result == 'success' && needs.check-alerts.result == 'success'
847+ env :
848+ LINEAR_API_KEY : ${{ secrets.LINEAR_API_KEY }}
849+ REPOSITORY : ${{ github.repository }}
850+ DONE_STATE_ID : ${{ steps.match.outputs.done_state_id }}
851+ run : |
852+ set -uo pipefail
853+ # Auto-close pipeline-created issues whose vulnerability is no longer reported by
854+ # any scanner. Issues created by this workflow all carry a "[<repository>]" title
855+ # prefix and embed their finding id in the title, so we fetch every open issue with
856+ # that prefix and close any whose id is absent from the current findings set. This
857+ # keeps resolved vulnerabilities (e.g. a CVE fixed by a merged upgrade) from
858+ # lingering as open, SLA-breaching issues.
859+ if [ -z "$DONE_STATE_ID" ]; then
860+ echo "::warning::Could not resolve a completed (Done) workflow state. Skipping auto-close."
861+ echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY"
862+ echo "" >> "$GITHUB_STEP_SUMMARY"
863+ echo "Skipped — no completed state found in the Linear team." >> "$GITHUB_STEP_SUMMARY"
864+ exit 0
865+ fi
866+
867+ # Current finding ids. An open issue is considered resolved when none of these
868+ # ids appears in its title (mirrors how the match step locates issues by id).
869+ jq -r '.cves[].cveId' findings.json > /tmp/current-ids.txt
870+ CURRENT_COUNT=$(wc -l < /tmp/current-ids.txt | tr -d ' ')
871+ echo "Reconciling against $CURRENT_COUNT current finding id(s)."
872+
873+ PREFIX="[$REPOSITORY]"
874+ SEARCH_QUERY='query($prefix: String!, $after: String) { issues(first: 100, after: $after, filter: { title: { startsWith: $prefix }, state: { type: { nin: ["completed", "canceled"] } } }) { nodes { id identifier url title } pageInfo { hasNextPage endCursor } } }'
875+
876+ echo '[]' > /tmp/open-issues.json
877+ AFTER=""
878+ while true; do
879+ if [ -z "$AFTER" ]; then
880+ VARS=$(jq -n --arg prefix "$PREFIX" '{prefix: $prefix}')
881+ else
882+ VARS=$(jq -n --arg prefix "$PREFIX" --arg after "$AFTER" '{prefix: $prefix, after: $after}')
883+ fi
884+ PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}')
885+ RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
886+ -H "Content-Type: application/json" \
887+ -H "Authorization: $LINEAR_API_KEY" \
888+ -d "$PAYLOAD")
889+
890+ if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then
891+ echo "::warning::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .'). Skipping auto-close."
892+ exit 0
893+ fi
894+
895+ PAGE=$(echo "$RESPONSE" | jq '.data.issues.nodes')
896+ jq -s '.[0] + .[1]' /tmp/open-issues.json <(echo "$PAGE") > /tmp/open-issues.tmp && mv /tmp/open-issues.tmp /tmp/open-issues.json
897+
898+ HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.hasNextPage')
899+ if [ "$HAS_NEXT" != "true" ]; then
900+ break
901+ fi
902+ AFTER=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.endCursor')
903+ done
904+
905+ OPEN_COUNT=$(jq 'length' /tmp/open-issues.json)
906+ echo "Found $OPEN_COUNT open issue(s) with prefix '$PREFIX'."
907+
908+ echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY"
909+ echo "" >> "$GITHUB_STEP_SUMMARY"
910+
911+ CLOSE_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }'
912+
913+ CLOSED_COUNT=0
914+ FAILED_COUNT=0
915+
916+ jq -c '.[]' /tmp/open-issues.json > /tmp/open-issues.jsonl
917+ while IFS= read -r issue; do
918+ ISSUE_ID=$(echo "$issue" | jq -r '.id')
919+ ISSUE_IDENTIFIER=$(echo "$issue" | jq -r '.identifier')
920+ ISSUE_URL=$(echo "$issue" | jq -r '.url')
921+ ISSUE_TITLE=$(echo "$issue" | jq -r '.title')
922+
923+ # Keep the issue open if any current finding id is present in its title.
924+ STILL_PRESENT=false
925+ while IFS= read -r id; do
926+ [ -n "$id" ] || continue
927+ case "$ISSUE_TITLE" in
928+ *"$id"*) STILL_PRESENT=true; break ;;
929+ esac
930+ done < /tmp/current-ids.txt
931+
932+ if [ "$STILL_PRESENT" = "true" ]; then
933+ continue
934+ fi
935+
936+ echo "Closing $ISSUE_IDENTIFIER — vulnerability no longer reported ($ISSUE_URL)"
937+ CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}')
938+ CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}')
939+ CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
940+ -H "Content-Type: application/json" \
941+ -H "Authorization: $LINEAR_API_KEY" \
942+ -d "$CLOSE_PAYLOAD")
943+
944+ if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then
945+ echo "- Closed [$ISSUE_IDENTIFIER]($ISSUE_URL) — vulnerability no longer reported" >> "$GITHUB_STEP_SUMMARY"
946+ CLOSED_COUNT=$((CLOSED_COUNT + 1))
947+ else
948+ echo "::error::Failed to close $ISSUE_IDENTIFIER"
949+ echo "$CLOSE_RESPONSE" | jq .
950+ echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY"
951+ FAILED_COUNT=$((FAILED_COUNT + 1))
952+ fi
953+ done < /tmp/open-issues.jsonl
954+
955+ echo "" >> "$GITHUB_STEP_SUMMARY"
956+ echo "**Summary:** Closed $CLOSED_COUNT resolved issue(s), failed $FAILED_COUNT." >> "$GITHUB_STEP_SUMMARY"
957+ if [ "$CLOSED_COUNT" -eq 0 ] && [ "$FAILED_COUNT" -eq 0 ]; then
958+ echo "No resolved issues to close." >> "$GITHUB_STEP_SUMMARY"
959+ fi
960+
961+ if [ "$FAILED_COUNT" -gt 0 ]; then
962+ echo "::error::Failed to close $FAILED_COUNT Linear issue(s)"
963+ exit 1
830964 fi
0 commit comments