From 8b240a42b1b9d4212ce35421a40f873f3dcdb27f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 17 Aug 2026 18:34:20 -0700 Subject: [PATCH] fix: only skip CVE remediation for open PRs --- .github/scripts/filter-unlinked-cve-issues.jq | 11 +-- .github/scripts/find-unlinked-cve-issues.sh | 52 ++++++++++++ .github/scripts/test-cve-remediation.sh | 81 +++++++++++++++++-- .github/workflows/_cve-remediation.yml | 10 ++- .github/workflows/cve-remediation.yml | 2 +- 5 files changed, 139 insertions(+), 17 deletions(-) diff --git a/.github/scripts/filter-unlinked-cve-issues.jq b/.github/scripts/filter-unlinked-cve-issues.jq index 932b5125b..57c9aa706 100644 --- a/.github/scripts/filter-unlinked-cve-issues.jq +++ b/.github/scripts/filter-unlinked-cve-issues.jq @@ -1,14 +1,15 @@ -def has_linked_github_pr: +def has_open_linked_github_pr: any( - .attachments.nodes[]?.url?; - type == "string" - and test("^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+(?:[/?#].*)?$") + .attachments.nodes[]?; + (.url? | type == "string" + and test("^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+(?:[/?#].*)?$")) + and .githubPrState? == "open" ); [ .[] | select(any(.labels.nodes[]?; .name == "CVE")) - | select(has_linked_github_pr | not) + | select(has_open_linked_github_pr | not) | { id, identifier, diff --git a/.github/scripts/find-unlinked-cve-issues.sh b/.github/scripts/find-unlinked-cve-issues.sh index 02eb2fa4a..2bff289f1 100755 --- a/.github/scripts/find-unlinked-cve-issues.sh +++ b/.github/scripts/find-unlinked-cve-issues.sh @@ -103,4 +103,56 @@ while true; do fi done +github_pr_states='[]' +while IFS= read -r pr_url; do + if [[ ! "$pr_url" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)([/?#].*)?$ ]]; then + echo "Could not parse linked GitHub pull request URL: $pr_url" >&2 + exit 1 + fi + + owner="${BASH_REMATCH[1]}" + repository="${BASH_REMATCH[2]}" + pull_number="${BASH_REMATCH[3]}" + if ! pr_state=$(gh api "repos/$owner/$repository/pulls/$pull_number" --jq '.state'); then + echo "Could not fetch linked GitHub pull request: $pr_url" >&2 + exit 1 + fi + + if [[ "$pr_state" != "open" && "$pr_state" != "closed" ]]; then + echo "GitHub returned an unexpected state for $pr_url: $pr_state" >&2 + exit 1 + fi + + github_pr_states=$(jq -cn \ + --argjson states "$github_pr_states" \ + --arg url "$pr_url" \ + --arg state "$pr_state" \ + '$states + [{url: $url, state: $state}]') +done < <(jq -r ' + [ + .[] + | select(any(.labels.nodes[]?; .name == "CVE")) + | .attachments.nodes[]?.url? + | strings + | select(test("^https://github\\.com/[^/]+/[^/]+/pull/[0-9]+(?:[/?#].*)?$")) + ] + | unique[] +' <<<"$all_issues") + +all_issues=$(jq -cn \ + --argjson issues "$all_issues" \ + --argjson states "$github_pr_states" ' + ($states | map({key: .url, value: .state}) | from_entries) as $states_by_url + | $issues + | map( + .attachments.nodes |= map( + if $states_by_url[.url] != null then + . + {githubPrState: $states_by_url[.url]} + else + . + end + ) + ) + ') + jq -c -f "$FILTER" <<<"$all_issues" diff --git a/.github/scripts/test-cve-remediation.sh b/.github/scripts/test-cve-remediation.sh index 5fb79acd8..fcaf431e9 100755 --- a/.github/scripts/test-cve-remediation.sh +++ b/.github/scripts/test-cve-remediation.sh @@ -60,22 +60,22 @@ ISSUES='[ { "id": "issue-2", "identifier": "SOU-2", - "title": "[sourcebot-dev/example] CVE-2: linked in this repository", + "title": "[sourcebot-dev/example] CVE-2: open PR linked in this repository", "url": "https://linear.app/sourcebot/issue/SOU-2/test", "priority": 2, "state": {"name": "In Progress", "type": "started"}, "labels": {"nodes": [{"name": "CVE"}]}, - "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/pull/42"}]} + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/pull/42", "githubPrState": "open"}]} }, { "id": "issue-3", "identifier": "SOU-3", - "title": "[sourcebot-dev/example] CVE-3: linked in a companion repository", + "title": "[sourcebot-dev/example] CVE-3: closed PR linked in a companion repository", "url": "https://linear.app/sourcebot/issue/SOU-3/test", "priority": 1, "state": {"name": "Todo", "type": "unstarted"}, "labels": {"nodes": [{"name": "CVE"}]}, - "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/companion/pull/9/files"}]} + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/companion/pull/9/files", "githubPrState": "closed"}]} }, { "id": "issue-4", @@ -100,6 +100,15 @@ ISSUES='[ ]' EXPECTED='[ + { + "id": "issue-3", + "identifier": "SOU-3", + "title": "[sourcebot-dev/example] CVE-3: closed PR linked in a companion repository", + "url": "https://linear.app/sourcebot/issue/SOU-3/test", + "priority": 1, + "status": "Todo", + "statusType": "unstarted" + }, { "id": "issue-5", "identifier": "SOU-5", @@ -121,14 +130,15 @@ EXPECTED='[ ]' assert_json \ - "keeps only CVEs without a linked GitHub pull request and sorts by priority" \ + "keeps CVEs without an open linked GitHub pull request and sorts by priority" \ "$(jq -c -f "$FILTER" <<<"$ISSUES")" \ "$EXPECTED" FAKE_CURL_DIR=$(mktemp -d) FAKE_CURL_COUNT=$(mktemp) FAKE_CURL_PAYLOAD_DIR=$(mktemp -d) -trap 'rm -rf "$FAKE_CURL_DIR" "$FAKE_CURL_PAYLOAD_DIR"; rm -f "$FAKE_CURL_COUNT"' EXIT +FAKE_GH_LOG=$(mktemp) +trap 'rm -rf "$FAKE_CURL_DIR" "$FAKE_CURL_PAYLOAD_DIR"; rm -f "$FAKE_CURL_COUNT" "$FAKE_GH_LOG"' EXIT printf '0\n' > "$FAKE_CURL_COUNT" cat > "$FAKE_CURL_DIR/curl" <<'EOF' @@ -181,12 +191,22 @@ elif ((count == 2)); then { "id": "page-1-linked", "identifier": "SOU-21", - "title": "[sourcebot-dev/example] CVE-21: linked", + "title": "[sourcebot-dev/example] CVE-21: open PR linked", "url": "https://linear.app/sourcebot/issue/SOU-21/test", "priority": 1, "state": {"name": "Backlog", "type": "backlog"}, "labels": {"nodes": [{"name": "CVE"}]}, "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/pull/21"}]} + }, + { + "id": "page-1-closed-pr", + "identifier": "SOU-23", + "title": "[sourcebot-dev/example] CVE-23: closed PR linked", + "url": "https://linear.app/sourcebot/issue/SOU-23/test", + "priority": 2, + "state": {"name": "Backlog", "type": "backlog"}, + "labels": {"nodes": [{"name": "CVE"}]}, + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/companion/pull/23/files"}]} } ], "pageInfo": {"hasNextPage": true, "endCursor": "next-page"} @@ -220,10 +240,38 @@ printf '200' EOF chmod +x "$FAKE_CURL_DIR/curl" +cat > "$FAKE_CURL_DIR/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1" != "api" ]]; then + echo "Unexpected gh command: $*" >&2 + exit 1 +fi + +endpoint="$2" +printf '%s\n' "$endpoint" >> "$FAKE_GH_LOG" +case "$endpoint" in + repos/sourcebot-dev/example/pulls/21) + printf 'open\n' + ;; + repos/sourcebot-dev/companion/pulls/23) + printf 'closed\n' + ;; + *) + echo "Unexpected GitHub API endpoint: $endpoint" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$FAKE_CURL_DIR/gh" + DISCOVERED=$( PATH="$FAKE_CURL_DIR:$PATH" \ FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \ FAKE_CURL_PAYLOAD_DIR="$FAKE_CURL_PAYLOAD_DIR" \ + FAKE_GH_LOG="$FAKE_GH_LOG" \ + GH_TOKEN="test-token" \ LINEAR_API_KEY="test-key" \ LINEAR_TEAM_ID="team-key" \ LINEAR_GRAPHQL_ATTEMPTS=1 \ @@ -240,6 +288,15 @@ EXPECTED_DISCOVERED='[ "status": "Todo", "statusType": "unstarted" }, + { + "id": "page-1-closed-pr", + "identifier": "SOU-23", + "title": "[sourcebot-dev/example] CVE-23: closed PR linked", + "url": "https://linear.app/sourcebot/issue/SOU-23/test", + "priority": 2, + "status": "Backlog", + "statusType": "backlog" + }, { "id": "page-1-unlinked", "identifier": "SOU-20", @@ -251,6 +308,10 @@ EXPECTED_DISCOVERED='[ } ]' assert_json "paginates Linear results and filters before invoking Claude" "$DISCOVERED" "$EXPECTED_DISCOVERED" +assert_json \ + "checks every unique linked GitHub pull request state" \ + "$(jq -Rsc 'split("\n") | map(select(length > 0))' "$FAKE_GH_LOG")" \ + '["repos/sourcebot-dev/companion/pulls/23","repos/sourcebot-dev/example/pulls/21"]' assert_json \ "resolves the configured Linear team identifier" \ "$(jq -c '.variables.teamId' "$FAKE_CURL_PAYLOAD_DIR/1.json")" \ @@ -275,6 +336,12 @@ assert_workflow_contains \ assert_workflow_contains \ "only invokes Claude when discovery found work" \ "if: needs.discover.outputs.has_issues == 'true'" +assert_workflow_contains \ + "grants discovery read access to pull request state" \ + 'pull-requests: read' +assert_workflow_contains \ + "authenticates GitHub API requests with the workflow token" \ + 'GH_TOKEN: ${{ github.token }}' assert_workflow_contains \ "passes only Linear UUIDs between jobs to avoid secret redaction" \ "issue_ids=\$(jq -c 'map(.id)'" diff --git a/.github/workflows/_cve-remediation.yml b/.github/workflows/_cve-remediation.yml index dd7edf32a..2751a48fa 100644 --- a/.github/workflows/_cve-remediation.yml +++ b/.github/workflows/_cve-remediation.yml @@ -14,7 +14,7 @@ on: type: string default: '' max_issues: - description: Maximum number of unlinked CVEs to pass to Claude in one run. + description: Maximum number of CVEs without an open linked PR to pass to Claude in one run. required: false type: number default: 50 @@ -28,10 +28,11 @@ on: jobs: discover: - name: Find unlinked CVEs + name: Find CVEs without an open PR runs-on: ubuntu-latest permissions: contents: read + pull-requests: read outputs: has_issues: ${{ steps.discover.outputs.has_issues }} issue_ids: ${{ steps.discover.outputs.issue_ids }} @@ -49,9 +50,10 @@ jobs: path: .cve-remediation-workflow persist-credentials: false - - name: Find open CVEs without a linked PR + - name: Find open CVEs without an open linked PR id: discover env: + GH_TOKEN: ${{ github.token }} LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} REPOSITORY: ${{ github.repository }} @@ -82,7 +84,7 @@ jobs: { echo "## CVE remediation discovery" echo - echo "Found **$total_issue_count** open CVE(s) for \`$REPOSITORY\` without a linked GitHub PR." + echo "Found **$total_issue_count** open CVE(s) for \`$REPOSITORY\` without an open linked GitHub PR." if ((total_issue_count > issue_count)); then echo "This run will process the first **$issue_count** by Linear priority." fi diff --git a/.github/workflows/cve-remediation.yml b/.github/workflows/cve-remediation.yml index e0985cf92..1a2c4e120 100644 --- a/.github/workflows/cve-remediation.yml +++ b/.github/workflows/cve-remediation.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: max_issues: - description: Maximum number of unlinked CVEs to process. + description: Maximum number of CVEs without an open linked PR to process. required: false type: number default: 50