|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 5 | +FILTER="$SCRIPT_DIR/filter-unlinked-cve-issues.jq" |
| 6 | +DISCOVERY_SCRIPT="$SCRIPT_DIR/find-unlinked-cve-issues.sh" |
| 7 | +WORKFLOW_FILE="$SCRIPT_DIR/../workflows/_cve-remediation.yml" |
| 8 | + |
| 9 | +assert_json() { |
| 10 | + local description="$1" |
| 11 | + local actual="$2" |
| 12 | + local expected="$3" |
| 13 | + |
| 14 | + if ! jq -e --argjson expected "$expected" '. == $expected' <<<"$actual" >/dev/null; then |
| 15 | + echo "FAIL: $description" |
| 16 | + echo "Expected: $expected" |
| 17 | + echo "Actual: $actual" |
| 18 | + exit 1 |
| 19 | + fi |
| 20 | +} |
| 21 | + |
| 22 | +assert_workflow_contains() { |
| 23 | + local description="$1" |
| 24 | + local expected="$2" |
| 25 | + |
| 26 | + if ! grep -Fq -- "$expected" "$WORKFLOW_FILE"; then |
| 27 | + echo "FAIL: $description" |
| 28 | + echo "Expected workflow to contain: $expected" |
| 29 | + exit 1 |
| 30 | + fi |
| 31 | +} |
| 32 | + |
| 33 | +ISSUES='[ |
| 34 | + { |
| 35 | + "id": "issue-1", |
| 36 | + "identifier": "SOU-1", |
| 37 | + "title": "[sourcebot-dev/example] CVE-1: no pull request", |
| 38 | + "url": "https://linear.app/sourcebot/issue/SOU-1/test", |
| 39 | + "priority": 3, |
| 40 | + "state": {"name": "Backlog", "type": "backlog"}, |
| 41 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 42 | + "attachments": {"nodes": []} |
| 43 | + }, |
| 44 | + { |
| 45 | + "id": "issue-2", |
| 46 | + "identifier": "SOU-2", |
| 47 | + "title": "[sourcebot-dev/example] CVE-2: linked in this repository", |
| 48 | + "url": "https://linear.app/sourcebot/issue/SOU-2/test", |
| 49 | + "priority": 2, |
| 50 | + "state": {"name": "In Progress", "type": "started"}, |
| 51 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 52 | + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/pull/42"}]} |
| 53 | + }, |
| 54 | + { |
| 55 | + "id": "issue-3", |
| 56 | + "identifier": "SOU-3", |
| 57 | + "title": "[sourcebot-dev/example] CVE-3: linked in a companion repository", |
| 58 | + "url": "https://linear.app/sourcebot/issue/SOU-3/test", |
| 59 | + "priority": 1, |
| 60 | + "state": {"name": "Todo", "type": "unstarted"}, |
| 61 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 62 | + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/companion/pull/9/files"}]} |
| 63 | + }, |
| 64 | + { |
| 65 | + "id": "issue-4", |
| 66 | + "identifier": "SOU-4", |
| 67 | + "title": "[sourcebot-dev/example] ordinary maintenance", |
| 68 | + "url": "https://linear.app/sourcebot/issue/SOU-4/test", |
| 69 | + "priority": 1, |
| 70 | + "state": {"name": "Backlog", "type": "backlog"}, |
| 71 | + "labels": {"nodes": [{"name": "Maintenance"}]}, |
| 72 | + "attachments": {"nodes": []} |
| 73 | + }, |
| 74 | + { |
| 75 | + "id": "issue-5", |
| 76 | + "identifier": "SOU-5", |
| 77 | + "title": "[sourcebot-dev/example] CVE-5: non-PR GitHub attachment", |
| 78 | + "url": "https://linear.app/sourcebot/issue/SOU-5/test", |
| 79 | + "priority": 2, |
| 80 | + "state": {"name": "Backlog", "type": "backlog"}, |
| 81 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 82 | + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/issues/5"}]} |
| 83 | + } |
| 84 | +]' |
| 85 | + |
| 86 | +EXPECTED='[ |
| 87 | + { |
| 88 | + "id": "issue-5", |
| 89 | + "identifier": "SOU-5", |
| 90 | + "title": "[sourcebot-dev/example] CVE-5: non-PR GitHub attachment", |
| 91 | + "url": "https://linear.app/sourcebot/issue/SOU-5/test", |
| 92 | + "priority": 2, |
| 93 | + "status": "Backlog", |
| 94 | + "statusType": "backlog" |
| 95 | + }, |
| 96 | + { |
| 97 | + "id": "issue-1", |
| 98 | + "identifier": "SOU-1", |
| 99 | + "title": "[sourcebot-dev/example] CVE-1: no pull request", |
| 100 | + "url": "https://linear.app/sourcebot/issue/SOU-1/test", |
| 101 | + "priority": 3, |
| 102 | + "status": "Backlog", |
| 103 | + "statusType": "backlog" |
| 104 | + } |
| 105 | +]' |
| 106 | + |
| 107 | +assert_json \ |
| 108 | + "keeps only CVEs without a linked GitHub pull request and sorts by priority" \ |
| 109 | + "$(jq -c -f "$FILTER" <<<"$ISSUES")" \ |
| 110 | + "$EXPECTED" |
| 111 | + |
| 112 | +FAKE_CURL_DIR=$(mktemp -d) |
| 113 | +FAKE_CURL_COUNT=$(mktemp) |
| 114 | +FAKE_CURL_PAYLOAD_DIR=$(mktemp -d) |
| 115 | +trap 'rm -rf "$FAKE_CURL_DIR" "$FAKE_CURL_PAYLOAD_DIR"; rm -f "$FAKE_CURL_COUNT"' EXIT |
| 116 | +printf '0\n' > "$FAKE_CURL_COUNT" |
| 117 | + |
| 118 | +cat > "$FAKE_CURL_DIR/curl" <<'EOF' |
| 119 | +#!/usr/bin/env bash |
| 120 | +set -euo pipefail |
| 121 | +
|
| 122 | +output_file="" |
| 123 | +payload="" |
| 124 | +while (($# > 0)); do |
| 125 | + case "$1" in |
| 126 | + --output) |
| 127 | + output_file="$2" |
| 128 | + shift 2 |
| 129 | + ;; |
| 130 | + -d) |
| 131 | + payload="$2" |
| 132 | + shift 2 |
| 133 | + ;; |
| 134 | + *) |
| 135 | + shift |
| 136 | + ;; |
| 137 | + esac |
| 138 | +done |
| 139 | +
|
| 140 | +count=$(( $(<"$FAKE_CURL_COUNT") + 1 )) |
| 141 | +printf '%s\n' "$count" > "$FAKE_CURL_COUNT" |
| 142 | +printf '%s' "$payload" > "$FAKE_CURL_PAYLOAD_DIR/$count.json" |
| 143 | +
|
| 144 | +if ((count == 1)); then |
| 145 | + body='{ |
| 146 | + "data": { |
| 147 | + "issues": { |
| 148 | + "nodes": [ |
| 149 | + { |
| 150 | + "id": "page-1-unlinked", |
| 151 | + "identifier": "SOU-20", |
| 152 | + "title": "[sourcebot-dev/example] CVE-20: unlinked", |
| 153 | + "url": "https://linear.app/sourcebot/issue/SOU-20/test", |
| 154 | + "priority": 3, |
| 155 | + "state": {"name": "Backlog", "type": "backlog"}, |
| 156 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 157 | + "attachments": {"nodes": []} |
| 158 | + }, |
| 159 | + { |
| 160 | + "id": "page-1-linked", |
| 161 | + "identifier": "SOU-21", |
| 162 | + "title": "[sourcebot-dev/example] CVE-21: linked", |
| 163 | + "url": "https://linear.app/sourcebot/issue/SOU-21/test", |
| 164 | + "priority": 1, |
| 165 | + "state": {"name": "Backlog", "type": "backlog"}, |
| 166 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 167 | + "attachments": {"nodes": [{"url": "https://github.com/sourcebot-dev/example/pull/21"}]} |
| 168 | + } |
| 169 | + ], |
| 170 | + "pageInfo": {"hasNextPage": true, "endCursor": "next-page"} |
| 171 | + } |
| 172 | + } |
| 173 | + }' |
| 174 | +else |
| 175 | + body='{ |
| 176 | + "data": { |
| 177 | + "issues": { |
| 178 | + "nodes": [ |
| 179 | + { |
| 180 | + "id": "page-2-unlinked", |
| 181 | + "identifier": "SOU-22", |
| 182 | + "title": "[sourcebot-dev/example] CVE-22: urgent and unlinked", |
| 183 | + "url": "https://linear.app/sourcebot/issue/SOU-22/test", |
| 184 | + "priority": 1, |
| 185 | + "state": {"name": "Todo", "type": "unstarted"}, |
| 186 | + "labels": {"nodes": [{"name": "CVE"}]}, |
| 187 | + "attachments": {"nodes": []} |
| 188 | + } |
| 189 | + ], |
| 190 | + "pageInfo": {"hasNextPage": false, "endCursor": null} |
| 191 | + } |
| 192 | + } |
| 193 | + }' |
| 194 | +fi |
| 195 | +
|
| 196 | +printf '%s' "$body" > "$output_file" |
| 197 | +printf '200' |
| 198 | +EOF |
| 199 | +chmod +x "$FAKE_CURL_DIR/curl" |
| 200 | + |
| 201 | +DISCOVERED=$( |
| 202 | + PATH="$FAKE_CURL_DIR:$PATH" \ |
| 203 | + FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \ |
| 204 | + FAKE_CURL_PAYLOAD_DIR="$FAKE_CURL_PAYLOAD_DIR" \ |
| 205 | + LINEAR_API_KEY="test-key" \ |
| 206 | + LINEAR_TEAM_ID="team-id" \ |
| 207 | + LINEAR_GRAPHQL_ATTEMPTS=1 \ |
| 208 | + REPOSITORY="sourcebot-dev/example" \ |
| 209 | + "$DISCOVERY_SCRIPT" |
| 210 | +) |
| 211 | +EXPECTED_DISCOVERED='[ |
| 212 | + { |
| 213 | + "id": "page-2-unlinked", |
| 214 | + "identifier": "SOU-22", |
| 215 | + "title": "[sourcebot-dev/example] CVE-22: urgent and unlinked", |
| 216 | + "url": "https://linear.app/sourcebot/issue/SOU-22/test", |
| 217 | + "priority": 1, |
| 218 | + "status": "Todo", |
| 219 | + "statusType": "unstarted" |
| 220 | + }, |
| 221 | + { |
| 222 | + "id": "page-1-unlinked", |
| 223 | + "identifier": "SOU-20", |
| 224 | + "title": "[sourcebot-dev/example] CVE-20: unlinked", |
| 225 | + "url": "https://linear.app/sourcebot/issue/SOU-20/test", |
| 226 | + "priority": 3, |
| 227 | + "status": "Backlog", |
| 228 | + "statusType": "backlog" |
| 229 | + } |
| 230 | +]' |
| 231 | +assert_json "paginates Linear results and filters before invoking Claude" "$DISCOVERED" "$EXPECTED_DISCOVERED" |
| 232 | +assert_json \ |
| 233 | + "queries Linear with the current repository title prefix" \ |
| 234 | + "$(jq -c '.variables | {teamId, titlePrefix, after}' "$FAKE_CURL_PAYLOAD_DIR/1.json")" \ |
| 235 | + '{"teamId":"team-id","titlePrefix":"[sourcebot-dev/example]","after":null}' |
| 236 | +assert_json \ |
| 237 | + "passes the Linear cursor to the next page" \ |
| 238 | + "$(jq -c '.variables.after' "$FAKE_CURL_PAYLOAD_DIR/2.json")" \ |
| 239 | + '"next-page"' |
| 240 | + |
| 241 | +assert_workflow_contains \ |
| 242 | + "uses the deterministic discovery script before Claude" \ |
| 243 | + '.cve-remediation-workflow/.github/scripts/find-unlinked-cve-issues.sh' |
| 244 | +assert_workflow_contains \ |
| 245 | + "only invokes Claude when discovery found work" \ |
| 246 | + "if: needs.discover.outputs.has_issues == 'true'" |
| 247 | +assert_workflow_contains \ |
| 248 | + "uses Linear's read-only MCP endpoint" \ |
| 249 | + 'https://mcp.linear.app/mcp/readonly' |
| 250 | +assert_workflow_contains \ |
| 251 | + "ignores repository-provided MCP servers" \ |
| 252 | + '--strict-mcp-config' |
| 253 | +assert_workflow_contains \ |
| 254 | + "loads the CVE system prompt" \ |
| 255 | + '--append-system-prompt-file' |
| 256 | + |
| 257 | +echo "All CVE remediation tests passed." |
0 commit comments