Skip to content

Commit ab64775

Browse files
committed
fix: retry transient Linear triage responses
1 parent 1074f1e commit ab64775

4 files changed

Lines changed: 137 additions & 33 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ -z "${LINEAR_API_KEY:-}" ]]; then
5+
echo "LINEAR_API_KEY is required" >&2
6+
exit 1
7+
fi
8+
9+
payload=$(cat)
10+
attempts="${LINEAR_GRAPHQL_ATTEMPTS:-4}"
11+
retry_delay="${LINEAR_GRAPHQL_RETRY_DELAY_SECONDS:-2}"
12+
endpoint="${LINEAR_GRAPHQL_ENDPOINT:-https://api.linear.app/graphql}"
13+
14+
for ((attempt = 1; attempt <= attempts; attempt++)); do
15+
response_file=$(mktemp)
16+
http_code=""
17+
18+
if http_code=$(curl \
19+
--silent \
20+
--show-error \
21+
--output "$response_file" \
22+
--write-out '%{http_code}' \
23+
--connect-timeout 10 \
24+
--max-time 45 \
25+
-X POST "$endpoint" \
26+
-H "Content-Type: application/json" \
27+
-H "Authorization: $LINEAR_API_KEY" \
28+
-d "$payload"); then
29+
response=$(<"$response_file")
30+
rm -f "$response_file"
31+
32+
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]] && jq -e . >/dev/null 2>&1 <<<"$response"; then
33+
printf '%s' "$response"
34+
exit 0
35+
fi
36+
37+
if [[ "$http_code" =~ ^(408|429|5[0-9][0-9])$ ]]; then
38+
echo "Linear GraphQL returned transient HTTP $http_code (attempt $attempt/$attempts)." >&2
39+
elif jq -e . >/dev/null 2>&1 <<<"$response"; then
40+
# Preserve structured non-retryable errors so the workflow can report
41+
# the GraphQL response rather than replacing it with a transport error.
42+
printf '%s' "$response"
43+
exit 0
44+
else
45+
echo "Linear GraphQL returned a non-JSON response (HTTP $http_code, attempt $attempt/$attempts)." >&2
46+
fi
47+
else
48+
curl_status=$?
49+
response=$(<"$response_file")
50+
rm -f "$response_file"
51+
echo "Linear GraphQL request failed (curl $curl_status, HTTP ${http_code:-unknown}, attempt $attempt/$attempts)." >&2
52+
fi
53+
54+
if ((attempt < attempts)); then
55+
sleep "$retry_delay"
56+
fi
57+
done
58+
59+
echo "Linear GraphQL did not return a valid JSON response after $attempts attempts." >&2
60+
exit 1

.github/scripts/test-vulnerability-triage.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ set -euo pipefail
55
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq"
77
CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq"
8+
LINEAR_REQUEST="$SCRIPT_DIR/linear-graphql-request.sh"
89
WORKFLOW_FILE="$SCRIPT_DIR/../workflows/vulnerability-triage.yml"
910
PREFIX="[sourcebot-dev/example]"
1011

@@ -36,6 +37,55 @@ assert_workflow_contains "checks out assets from the called workflow repository"
3637
assert_workflow_contains "pins assets to the called workflow revision" 'ref: ${{ job.workflow_sha }}'
3738
assert_workflow_contains "uses the shared match filter" '-f .vulnerability-triage-workflow/.github/scripts/match-vulnerability-issue.jq'
3839
assert_workflow_contains "uses the shared classification filter" '-f .vulnerability-triage-workflow/.github/scripts/classify-vulnerability-issues.jq'
40+
assert_workflow_contains "uses the retrying Linear GraphQL client" '.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh'
41+
42+
FAKE_CURL_DIR=$(mktemp -d)
43+
FAKE_CURL_COUNT=$(mktemp)
44+
trap 'rm -rf "$FAKE_CURL_DIR"; rm -f "$FAKE_CURL_COUNT"' EXIT
45+
printf '0\n' > "$FAKE_CURL_COUNT"
46+
47+
cat > "$FAKE_CURL_DIR/curl" <<'EOF'
48+
#!/usr/bin/env bash
49+
set -euo pipefail
50+
51+
output_file=""
52+
while (($# > 0)); do
53+
case "$1" in
54+
--output)
55+
output_file="$2"
56+
shift 2
57+
;;
58+
*)
59+
shift
60+
;;
61+
esac
62+
done
63+
64+
count=$(( $(<"$FAKE_CURL_COUNT") + 1 ))
65+
printf '%s\n' "$count" > "$FAKE_CURL_COUNT"
66+
if ((count < 3)); then
67+
printf 'Bad Gateway' > "$output_file"
68+
printf '502'
69+
else
70+
printf '{"data":{"ok":true}}' > "$output_file"
71+
printf '200'
72+
fi
73+
EOF
74+
chmod +x "$FAKE_CURL_DIR/curl"
75+
76+
LINEAR_RESPONSE=$(
77+
PATH="$FAKE_CURL_DIR:$PATH" \
78+
FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \
79+
LINEAR_API_KEY="test-key" \
80+
LINEAR_GRAPHQL_ATTEMPTS=3 \
81+
LINEAR_GRAPHQL_RETRY_DELAY_SECONDS=0 \
82+
"$LINEAR_REQUEST" <<<'{"query":"query { viewer { id } }"}'
83+
)
84+
assert_json "retries non-JSON Linear responses" "$LINEAR_RESPONSE" '{"data":{"ok":true}}'
85+
if [[ "$(<"$FAKE_CURL_COUNT")" != "3" ]]; then
86+
echo "FAIL: expected Linear request helper to retry twice"
87+
exit 1
88+
fi
3989

4090
match() {
4191
local finding_id="$1"

.github/workflows/vulnerability-triage.yml

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -618,10 +618,8 @@ jobs:
618618
--arg teamId "$LINEAR_TEAM_ID" \
619619
--arg stateName "$LINEAR_STATE_NAME" \
620620
'{query: $query, variables: {teamId: $teamId, stateName: $stateName}}')
621-
METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
622-
-H "Content-Type: application/json" \
623-
-H "Authorization: $LINEAR_API_KEY" \
624-
-d "$METADATA_PAYLOAD")
621+
METADATA_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
622+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$METADATA_PAYLOAD")
625623
626624
if [ "$(echo "$METADATA_RESPONSE" | jq 'has("errors") or (.data.team == null)')" = "true" ]; then
627625
echo "::error::Could not load Linear team metadata: $(echo "$METADATA_RESPONSE" | jq -c '.errors // .')"
@@ -645,21 +643,17 @@ jobs:
645643
REPO_LABEL_QUERY='query($teamId: String!, $name: String!) { team(id: $teamId) { labels(filter: { name: { eq: $name } }) { nodes { id } } } }'
646644
REPO_LABEL_PAYLOAD=$(jq -n --arg query "$REPO_LABEL_QUERY" --arg teamId "$TEAM_UUID" --arg name "$REPOSITORY" \
647645
'{query: $query, variables: {teamId: $teamId, name: $name}}')
648-
REPO_LABEL_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
649-
-H "Content-Type: application/json" \
650-
-H "Authorization: $LINEAR_API_KEY" \
651-
-d "$REPO_LABEL_PAYLOAD")
646+
REPO_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
647+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$REPO_LABEL_PAYLOAD")
652648
REPO_LABEL_ID=$(echo "$REPO_LABEL_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty')
653649
654650
if [ -z "$REPO_LABEL_ID" ]; then
655651
echo "No '$REPOSITORY' label in Linear team — creating it."
656652
CREATE_LABEL_MUTATION='mutation($teamId: String!, $name: String!) { issueLabelCreate(input: { teamId: $teamId, name: $name }) { success issueLabel { id } } }'
657653
CREATE_LABEL_PAYLOAD=$(jq -n --arg query "$CREATE_LABEL_MUTATION" --arg teamId "$TEAM_UUID" --arg name "$REPOSITORY" \
658654
'{query: $query, variables: {teamId: $teamId, name: $name}}')
659-
CREATE_LABEL_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
660-
-H "Content-Type: application/json" \
661-
-H "Authorization: $LINEAR_API_KEY" \
662-
-d "$CREATE_LABEL_PAYLOAD")
655+
CREATE_LABEL_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
656+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CREATE_LABEL_PAYLOAD")
663657
REPO_LABEL_ID=$(echo "$CREATE_LABEL_RESPONSE" | jq -r '.data.issueLabelCreate.issueLabel.id // empty')
664658
if [ -z "$REPO_LABEL_ID" ]; then
665659
echo "::warning::Could not create '$REPOSITORY' label: $(echo "$CREATE_LABEL_RESPONSE" | jq -c '.errors // .')"
@@ -693,10 +687,8 @@ jobs:
693687
--arg teamId "$TEAM_UUID" \
694688
'{titlePrefix: $titlePrefix, teamId: $teamId}')
695689
PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}')
696-
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
697-
-H "Content-Type: application/json" \
698-
-H "Authorization: $LINEAR_API_KEY" \
699-
-d "$PAYLOAD")
690+
RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
691+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD")
700692
701693
# A failed search is not "no match": treating it as one could create
702694
# a duplicate issue, so stop before any Linear mutations.
@@ -854,10 +846,13 @@ jobs:
854846
fi
855847
REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}')
856848
857-
REOPEN_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
858-
-H "Content-Type: application/json" \
859-
-H "Authorization: $LINEAR_API_KEY" \
860-
-d "$REOPEN_PAYLOAD")
849+
if ! REOPEN_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
850+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$REOPEN_PAYLOAD"); then
851+
echo "::error::Linear request failed while reopening $LINEAR_IDENTIFIER for $CVE_ID"
852+
echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY"
853+
FAILED_COUNT=$((FAILED_COUNT + 1))
854+
continue
855+
fi
861856
862857
REOPEN_URL=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.url // empty')
863858
REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty')
@@ -905,10 +900,13 @@ jobs:
905900
906901
PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{query: $query, variables: $vars}')
907902
908-
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
909-
-H "Content-Type: application/json" \
910-
-H "Authorization: $LINEAR_API_KEY" \
911-
-d "$PAYLOAD")
903+
if ! RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
904+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD"); then
905+
echo "::error::Linear request failed while creating an issue for $CVE_ID"
906+
echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY"
907+
FAILED_COUNT=$((FAILED_COUNT + 1))
908+
continue
909+
fi
912910
913911
ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty')
914912
ISSUE_IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.identifier // empty')
@@ -966,10 +964,8 @@ jobs:
966964
'{prefix: $prefix, teamId: $teamId, after: $after}')
967965
fi
968966
PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}')
969-
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
970-
-H "Content-Type: application/json" \
971-
-H "Authorization: $LINEAR_API_KEY" \
972-
-d "$PAYLOAD")
967+
RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
968+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$PAYLOAD")
973969
974970
if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then
975971
echo "::error::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .')"
@@ -1014,10 +1010,8 @@ jobs:
10141010
10151011
CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}')
10161012
CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}')
1017-
CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
1018-
-H "Content-Type: application/json" \
1019-
-H "Authorization: $LINEAR_API_KEY" \
1020-
-d "$CLOSE_PAYLOAD")
1013+
CLOSE_RESPONSE=$(LINEAR_API_KEY="$LINEAR_API_KEY" \
1014+
.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh <<<"$CLOSE_PAYLOAD")
10211015
10221016
if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then
10231017
echo "Closed $ISSUE_IDENTIFIER — $FINDING_ID is no longer reported"

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Vulnerability triage now keeps Linear issues synchronized with current security findings.
1212

1313
### Fixed
14-
- Fixed vulnerability triage for reusable-workflow callers and repositories without CodeQL. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515)
14+
- Fixed vulnerability triage for reusable-workflow callers, repositories without CodeQL, and transient non-JSON Linear API responses. [#1515](https://github.com/sourcebot-dev/sourcebot/pull/1515)
1515

1616
## [5.1.4] - 2026-07-24
1717

0 commit comments

Comments
 (0)