Skip to content

Commit a48cc01

Browse files
committed
feat: route CVEs by repository in Linear
1 parent 8be882c commit a48cc01

1 file changed

Lines changed: 52 additions & 14 deletions

File tree

.github/workflows/vulnerability-triage.yml

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ on:
5353
required: false
5454
type: boolean
5555
default: true
56+
linear_state_name:
57+
description: 'Linear workflow state for newly created or reopened issues.'
58+
required: false
59+
type: string
60+
default: 'Triage'
61+
linear_cycle_id:
62+
description: 'Linear cycle UUID to assign. Takes precedence over linear_use_active_cycle.'
63+
required: false
64+
type: string
65+
default: ''
66+
linear_use_active_cycle:
67+
description: 'Assign issues to the Linear team active cycle when linear_cycle_id is empty.'
68+
required: false
69+
type: boolean
70+
default: false
5671
secrets:
5772
LINEAR_API_KEY:
5873
required: true
@@ -550,13 +565,17 @@ jobs:
550565
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
551566
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
552567
REPOSITORY: ${{ github.repository }}
568+
LINEAR_STATE_NAME: ${{ inputs.linear_state_name || 'Triage' }}
553569
run: |
554570
set -euo pipefail
555-
# Resolve team UUID + the "CVE" label, "Triage" state, and API key owner once,
556-
# and expose them as outputs so the issue-creation step can reuse them.
557-
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 } }'
558-
METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \
559-
'{query: $query, variables: {teamId: $teamId}}')
571+
# Resolve team UUID, labels, configured state, active cycle, and API key
572+
# owner once, then expose them for issue creation and reopening.
573+
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 } }'
574+
METADATA_PAYLOAD=$(jq -n \
575+
--arg query "$METADATA_QUERY" \
576+
--arg teamId "$LINEAR_TEAM_ID" \
577+
--arg stateName "$LINEAR_STATE_NAME" \
578+
'{query: $query, variables: {teamId: $teamId, stateName: $stateName}}')
560579
METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
561580
-H "Content-Type: application/json" \
562581
-H "Authorization: $LINEAR_API_KEY" \
@@ -565,6 +584,7 @@ jobs:
565584
TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty')
566585
LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty')
567586
STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty')
587+
ACTIVE_CYCLE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.activeCycle.id // empty')
568588
VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty')
569589
570590
if [ -z "$TEAM_UUID" ]; then
@@ -603,6 +623,7 @@ jobs:
603623
echo "label_id=$LABEL_ID"
604624
echo "repo_label_id=$REPO_LABEL_ID"
605625
echo "state_id=$STATE_ID"
626+
echo "active_cycle_id=$ACTIVE_CYCLE_ID"
606627
echo "viewer_id=$VIEWER_ID"
607628
} >> "$GITHUB_OUTPUT"
608629
@@ -682,19 +703,27 @@ jobs:
682703
LABEL_ID: ${{ steps.match.outputs.label_id }}
683704
REPO_LABEL_ID: ${{ steps.match.outputs.repo_label_id }}
684705
STATE_ID: ${{ steps.match.outputs.state_id }}
706+
ACTIVE_CYCLE_ID: ${{ steps.match.outputs.active_cycle_id }}
685707
VIEWER_ID: ${{ steps.match.outputs.viewer_id }}
686708
CONFIGURED_ASSIGNEE_ID: ${{ inputs.linear_assignee_id }}
687709
ASSIGN_TO_API_KEY_OWNER: ${{ inputs.assign_to_api_key_owner }}
710+
LINEAR_STATE_NAME: ${{ inputs.linear_state_name || 'Triage' }}
711+
CONFIGURED_CYCLE_ID: ${{ inputs.linear_cycle_id }}
712+
USE_ACTIVE_CYCLE: ${{ inputs.linear_use_active_cycle }}
688713
run: |
689714
set -uo pipefail
690-
# Team UUID, the "CVE" + repository labels, "Triage" state, and assignee
691-
# metadata were already resolved by the "Match existing Linear issues" step.
715+
# Team UUID, labels, state, cycle, and assignee metadata were already
716+
# resolved by the "Match existing Linear issues" step.
692717
STRUCTURED_OUTPUT=$(cat findings.json)
693718
694719
TARGET_ASSIGNEE_ID="$CONFIGURED_ASSIGNEE_ID"
695720
if [ -z "$TARGET_ASSIGNEE_ID" ] && [ "$ASSIGN_TO_API_KEY_OWNER" = "true" ]; then
696721
TARGET_ASSIGNEE_ID="$VIEWER_ID"
697722
fi
723+
TARGET_CYCLE_ID="$CONFIGURED_CYCLE_ID"
724+
if [ -z "$TARGET_CYCLE_ID" ] && [ "$USE_ACTIVE_CYCLE" = "true" ]; then
725+
TARGET_CYCLE_ID="$ACTIVE_CYCLE_ID"
726+
fi
698727
699728
if [ -z "$LABEL_ID" ]; then
700729
echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label."
@@ -703,11 +732,14 @@ jobs:
703732
echo "::warning::Could not resolve '$REPOSITORY' repository label. Creating issues without it."
704733
fi
705734
if [ -z "$STATE_ID" ]; then
706-
echo "::warning::Could not find 'Triage' state in Linear team. Using default state."
735+
echo "::warning::Could not find '$LINEAR_STATE_NAME' state in Linear team. Using default state."
707736
fi
708737
if [ -z "$TARGET_ASSIGNEE_ID" ]; then
709738
echo "Issues will be created or reopened unassigned."
710739
fi
740+
if [ "$USE_ACTIVE_CYCLE" = "true" ] && [ -z "$TARGET_CYCLE_ID" ]; then
741+
echo "::warning::Could not resolve an active Linear cycle. Issues will be created without a cycle."
742+
fi
711743
712744
# Map severity to Linear priority
713745
severity_to_priority() {
@@ -731,7 +763,7 @@ jobs:
731763
# Write CVEs to temp file so the while loop doesn't run in a pipe subshell
732764
echo "$STRUCTURED_OUTPUT" | jq -c '.cves[]' > /tmp/cves.jsonl
733765
734-
MUTATION='mutation CreateIssue($teamId: String!, $title: String!, $description: String, $priority: Int, $labelIds: [String!], $stateId: String, $assigneeId: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId, assigneeId: $assigneeId }) { success issue { id identifier url } } }'
766+
MUTATION='mutation CreateIssue($teamId: String!, $title: String!, $description: String, $priority: Int, $labelIds: [String!], $stateId: String, $assigneeId: String, $cycleId: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId, assigneeId: $assigneeId, cycleId: $cycleId }) { success issue { id identifier url } } }'
735767
736768
while IFS= read -r cve; do
737769
CVE_ID=$(echo "$cve" | jq -r '.cveId')
@@ -752,16 +784,16 @@ jobs:
752784
fi
753785
754786
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "true" ]; then
755-
# Reopen the closed issue by setting its state back to Triage
787+
# Reopen the closed issue using the configured repository routing.
756788
echo "Found closed Linear issue $LINEAR_IDENTIFIER for $CVE_ID ($LINEAR_URL) — will attempt to reopen"
757789
if [ -z "$STATE_ID" ]; then
758-
echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no Triage state found. Skipping."
759-
echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no Triage state to reopen" >> "$GITHUB_STEP_SUMMARY"
790+
echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no '$LINEAR_STATE_NAME' state found. Skipping."
791+
echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no '$LINEAR_STATE_NAME' state to reopen" >> "$GITHUB_STEP_SUMMARY"
760792
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
761793
continue
762794
fi
763795
764-
REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!, $assigneeId: String) { issueUpdate(id: $issueId, input: { stateId: $stateId, assigneeId: $assigneeId }) { success issue { id identifier url } } }'
796+
REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!, $assigneeId: String, $cycleId: String) { issueUpdate(id: $issueId, input: { stateId: $stateId, assigneeId: $assigneeId, cycleId: $cycleId }) { success issue { id identifier url } } }'
765797
REOPEN_VARIABLES=$(jq -n \
766798
--arg issueId "$LINEAR_ISSUE_ID" \
767799
--arg stateId "$STATE_ID" \
@@ -771,6 +803,9 @@ jobs:
771803
else
772804
REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq '. + {assigneeId: null}')
773805
fi
806+
if [ -n "$TARGET_CYCLE_ID" ]; then
807+
REOPEN_VARIABLES=$(echo "$REOPEN_VARIABLES" | jq --arg cid "$TARGET_CYCLE_ID" '. + {cycleId: $cid}')
808+
fi
774809
REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}')
775810
776811
REOPEN_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
@@ -782,7 +817,7 @@ jobs:
782817
REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty')
783818
if [ -n "$REOPEN_URL" ]; then
784819
echo "Reopened Linear issue $REOPEN_IDENTIFIER for $CVE_ID: $REOPEN_URL"
785-
echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
820+
echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved to $LINEAR_STATE_NAME)" >> "$GITHUB_STEP_SUMMARY"
786821
REOPENED_COUNT=$((REOPENED_COUNT + 1))
787822
else
788823
echo "::error::Failed to reopen Linear issue $LINEAR_IDENTIFIER for $CVE_ID"
@@ -818,6 +853,9 @@ jobs:
818853
else
819854
VARIABLES=$(echo "$VARIABLES" | jq '. + {assigneeId: null}')
820855
fi
856+
if [ -n "$TARGET_CYCLE_ID" ]; then
857+
VARIABLES=$(echo "$VARIABLES" | jq --arg cid "$TARGET_CYCLE_ID" '. + {cycleId: $cid}')
858+
fi
821859
822860
PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{query: $query, variables: $vars}')
823861

0 commit comments

Comments
 (0)