@plainconceptsplatform/workflows 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/catalog-installation.d.ts +12 -0
  2. package/dist/catalog-installation.js +81 -0
  3. package/dist/catalog-installation.test.d.ts +1 -0
  4. package/dist/catalog-installation.test.js +85 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.js +57 -0
  7. package/dist/repository-inspection.d.ts +23 -0
  8. package/dist/repository-inspection.js +91 -0
  9. package/dist/repository-state.d.ts +18 -0
  10. package/dist/repository-state.js +77 -0
  11. package/dist/repository-state.test.d.ts +1 -0
  12. package/dist/repository-state.test.js +96 -0
  13. package/dist/workflow-catalog.d.ts +17 -0
  14. package/dist/workflow-catalog.js +39 -0
  15. package/dist/workflow-catalog.test.d.ts +1 -0
  16. package/dist/workflow-catalog.test.js +14 -0
  17. package/loops/actions/add-issue-labels/action.yml +29 -0
  18. package/loops/actions/agent-output.cjs +16 -0
  19. package/loops/actions/apply-agent-bundle/action.yml +23 -0
  20. package/loops/actions/apply-agent-bundle/apply-bundle.sh +45 -0
  21. package/loops/actions/apply-agent-comments/action.yml +41 -0
  22. package/loops/actions/apply-agent-labels/action.yml +54 -0
  23. package/loops/actions/apply-agent-output/action.yml +107 -0
  24. package/loops/actions/audit-close/action.yml +103 -0
  25. package/loops/actions/classify-route/action.yml +90 -0
  26. package/loops/actions/classify-route/classify-route.sh +172 -0
  27. package/loops/actions/cleanup-artifacts/action.yml +64 -0
  28. package/loops/actions/close-agent-issues/action.yml +42 -0
  29. package/loops/actions/create-agent-issues/action.yml +51 -0
  30. package/loops/actions/create-issue-comment/action.yml +28 -0
  31. package/loops/actions/download-agent-output/action.yml +52 -0
  32. package/loops/actions/identify-gate-subject/action.yml +96 -0
  33. package/loops/actions/link-pr-to-issue/action.yml +39 -0
  34. package/loops/actions/list-open-issues/action.yml +32 -0
  35. package/loops/actions/load-issue-context/action.yml +42 -0
  36. package/loops/actions/merge-agent-pr/action.yml +35 -0
  37. package/loops/actions/push-agent-branch/action.yml +44 -0
  38. package/loops/actions/remove-issue-labels/action.yml +34 -0
  39. package/loops/actions/stale-recovery/action.yml +287 -0
  40. package/loops/actions/update-agent-issues/action.yml +57 -0
  41. package/loops/actions/validate-refine-output/action.yml +43 -0
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +46 -0
  43. package/loops/actions/verify-composite-actions/action.yml +8 -0
  44. package/loops/actions/verify-composite-actions/verify-composite-actions.sh +50 -0
  45. package/loops/actions/verify-refine-output/action.yml +8 -0
  46. package/loops/actions/verify-refine-output/verify-refine-output.sh +65 -0
  47. package/loops/actions/verify-route-matrix/action.yml +8 -0
  48. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +212 -0
  49. package/loops/aw/repo-config.md +12 -0
  50. package/loops/scripts/compile-agent-workflows.mjs +13 -0
  51. package/loops/workflows/agent-apply-review.md +372 -0
  52. package/loops/workflows/agent-audit.md +197 -0
  53. package/loops/workflows/agent-direct.md +362 -0
  54. package/loops/workflows/agent-implement.md +329 -0
  55. package/loops/workflows/agent-merge-gate.md +470 -0
  56. package/loops/workflows/agent-propose.md +342 -0
  57. package/loops/workflows/agent-refine.md +338 -0
  58. package/loops/workflows/shared/opencode-ci.md +26 -0
  59. package/loops/workflows/shared/platform-defaults.md +14 -0
  60. package/loops/workflows/work-router.yml +391 -0
  61. package/package.json +28 -0
@@ -0,0 +1,29 @@
1
+ name: Add issue labels
2
+ description: Add one or more labels to an issue or pull request.
3
+ inputs:
4
+ token:
5
+ description: GitHub token used by github-script.
6
+ required: true
7
+ issue-number:
8
+ description: Issue or pull request number.
9
+ required: true
10
+ labels:
11
+ description: Newline-delimited labels to add.
12
+ required: true
13
+ runs:
14
+ using: composite
15
+ steps:
16
+ - name: Add labels
17
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
18
+ env:
19
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
20
+ LABELS: ${{ inputs.labels }}
21
+ with:
22
+ github-token: ${{ inputs.token }}
23
+ script: |
24
+ const labels = process.env.LABELS.split(/\r?\n/).filter(Boolean);
25
+ await github.rest.issues.addLabels({
26
+ ...context.repo,
27
+ issue_number: Number(process.env.ISSUE_NUMBER),
28
+ labels,
29
+ });
@@ -0,0 +1,16 @@
1
+ const fs = require('fs');
2
+
3
+ // Every apply-agent-* action reads the same artifact the same way: a missing file means the
4
+ // agent produced nothing, which is a normal outcome rather than a failure.
5
+ function readAgentItems(outputFile, type) {
6
+ if (!outputFile || !fs.existsSync(outputFile)) {
7
+ return [];
8
+ }
9
+
10
+ const parsed = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
11
+ const items = Array.isArray(parsed.items) ? parsed.items : [];
12
+
13
+ return type ? items.filter((item) => item.type === type) : items;
14
+ }
15
+
16
+ module.exports = { readAgentItems };
@@ -0,0 +1,23 @@
1
+ name: Apply agent bundle
2
+ description: Fast-forward an intended branch to the single commit exported by a verified git bundle.
3
+ inputs:
4
+ bundle-file:
5
+ description: Path to the git bundle from the agent artifact.
6
+ required: true
7
+ target-branch:
8
+ description: Branch that must fast-forward to the bundle commit.
9
+ required: true
10
+ base-branch:
11
+ description: Existing branch used only when target-branch does not exist.
12
+ required: false
13
+ default: ''
14
+ runs:
15
+ using: composite
16
+ steps:
17
+ - name: Fast-forward branch from bundle
18
+ shell: bash
19
+ env:
20
+ BUNDLE_FILE: ${{ inputs.bundle-file }}
21
+ TARGET_BRANCH: ${{ inputs.target-branch }}
22
+ BASE_BRANCH: ${{ inputs.base-branch }}
23
+ run: bash "$GITHUB_ACTION_PATH/apply-bundle.sh" "$BUNDLE_FILE" "$TARGET_BRANCH" "$BASE_BRANCH"
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BUNDLE_FILE=$1
5
+ TARGET_BRANCH=$2
6
+ BASE_BRANCH=${3:-}
7
+
8
+ fail() {
9
+ echo "::error::$1"
10
+ exit 1
11
+ }
12
+
13
+ [ -f "$BUNDLE_FILE" ] || fail "Git bundle not found: $BUNDLE_FILE"
14
+ git check-ref-format --branch "$TARGET_BRANCH" >/dev/null || fail "Invalid target branch: $TARGET_BRANCH"
15
+
16
+ if [ -n "$BASE_BRANCH" ]; then
17
+ git check-ref-format --branch "$BASE_BRANCH" >/dev/null || fail "Invalid base branch: $BASE_BRANCH"
18
+ fi
19
+
20
+ git bundle verify "$BUNDLE_FILE"
21
+ mapfile -t BUNDLE_HEADS < <(git bundle list-heads "$BUNDLE_FILE")
22
+ [ "${#BUNDLE_HEADS[@]}" -eq 1 ] || fail "Git bundle must expose exactly one ref"
23
+
24
+ read -r BUNDLE_TIP BUNDLE_REF <<< "${BUNDLE_HEADS[0]}"
25
+ if [ -z "$BUNDLE_TIP" ] || [ -z "$BUNDLE_REF" ]; then
26
+ fail "Git bundle ref is invalid"
27
+ fi
28
+
29
+ if git ls-remote --exit-code --heads origin "refs/heads/$TARGET_BRANCH" >/dev/null; then
30
+ git fetch --no-tags origin "refs/heads/$TARGET_BRANCH:refs/remotes/origin/$TARGET_BRANCH"
31
+ TARGET_TIP=$(git rev-parse "refs/remotes/origin/$TARGET_BRANCH")
32
+ else
33
+ [ -n "$BASE_BRANCH" ] || fail "Target branch does not exist and no base branch was supplied"
34
+ git fetch --no-tags origin "refs/heads/$BASE_BRANCH:refs/remotes/origin/$BASE_BRANCH"
35
+ TARGET_TIP=$(git rev-parse "refs/remotes/origin/$BASE_BRANCH")
36
+ fi
37
+
38
+ git fetch --no-tags "$BUNDLE_FILE" "$BUNDLE_REF"
39
+ [ "$(git cat-file -t "$BUNDLE_TIP")" = commit ] || fail "Git bundle ref must point directly to a commit"
40
+ [ "$(git rev-parse FETCH_HEAD)" = "$BUNDLE_TIP" ] || fail "Fetched bundle commit did not match listed bundle ref"
41
+
42
+ git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP" || fail "Git bundle cannot fast-forward $TARGET_BRANCH"
43
+ git switch --detach "$TARGET_TIP"
44
+ git merge --ff-only "$BUNDLE_TIP"
45
+ git push origin "HEAD:refs/heads/$TARGET_BRANCH"
@@ -0,0 +1,41 @@
1
+ name: Apply agent comments
2
+ description: Post every add_comment item from agent_output.json.
3
+ inputs:
4
+ output-file:
5
+ description: Path to agent_output.json.
6
+ required: true
7
+ token:
8
+ description: GitHub token with issues:write and pull-requests:write.
9
+ required: true
10
+ runs:
11
+ using: composite
12
+ steps:
13
+ - name: Post comments
14
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
15
+ env:
16
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
17
+ OUTPUT_FILE: ${{ inputs.output-file }}
18
+ with:
19
+ github-token: ${{ inputs.token }}
20
+ script: |
21
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
22
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'add_comment');
23
+
24
+ let posted = 0;
25
+
26
+ for (const item of items) {
27
+ if (!item.item_number) {
28
+ core.warning(`add_comment item has no item_number, skipping: ${JSON.stringify(item)}`);
29
+ continue;
30
+ }
31
+
32
+ await github.rest.issues.createComment({
33
+ ...context.repo,
34
+ issue_number: Number(item.item_number),
35
+ body: item.body,
36
+ });
37
+
38
+ posted += 1;
39
+ }
40
+
41
+ core.info(`Posted ${posted} comment(s).`);
@@ -0,0 +1,54 @@
1
+ name: Apply agent labels
2
+ description: Apply every add_labels and remove_labels item from agent_output.json.
3
+ inputs:
4
+ output-file:
5
+ description: Path to agent_output.json.
6
+ required: true
7
+ token:
8
+ description: GitHub token with issues:write.
9
+ required: true
10
+ runs:
11
+ using: composite
12
+ steps:
13
+ - name: Apply label changes
14
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
15
+ env:
16
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
17
+ OUTPUT_FILE: ${{ inputs.output-file }}
18
+ with:
19
+ github-token: ${{ inputs.token }}
20
+ script: |
21
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
22
+ const items = readAgentItems(process.env.OUTPUT_FILE)
23
+ .filter((item) => item.type === 'add_labels' || item.type === 'remove_labels');
24
+
25
+ let added = 0;
26
+ let removed = 0;
27
+
28
+ for (const item of items) {
29
+ const labels = (item.labels ?? []).map((label) => label.name).filter(Boolean);
30
+
31
+ if (!item.item_number || labels.length === 0) {
32
+ core.warning(`${item.type} item is not actionable, skipping: ${JSON.stringify(item)}`);
33
+ continue;
34
+ }
35
+
36
+ const issue_number = Number(item.item_number);
37
+
38
+ if (item.type === 'add_labels') {
39
+ await github.rest.issues.addLabels({ ...context.repo, issue_number, labels });
40
+ added += labels.length;
41
+ continue;
42
+ }
43
+
44
+ for (const name of labels) {
45
+ try {
46
+ await github.rest.issues.removeLabel({ ...context.repo, issue_number, name });
47
+ removed += 1;
48
+ } catch (error) {
49
+ if (error.status !== 404) throw error;
50
+ }
51
+ }
52
+ }
53
+
54
+ core.info(`Added ${added} label(s), removed ${removed}.`);
@@ -0,0 +1,107 @@
1
+ name: Apply agent output
2
+ description: Download the agent artifact and apply its safe outputs in one fixed order, so every worker concludes the same way.
3
+ inputs:
4
+ token:
5
+ description: GitHub token the writes are made with.
6
+ required: true
7
+ artifact-name:
8
+ description: >-
9
+ Name of the agent artifact. Callers pass the activation job's artifact_prefix output
10
+ followed by "agent"; gh-aw derives that prefix from the workflow's inputs, so it is
11
+ non-empty for every workflow_call worker and the bare name never resolves.
12
+ required: true
13
+ merge-pull-request:
14
+ description: Apply merge_pull_request items.
15
+ required: false
16
+ default: 'false'
17
+ push-to-branch:
18
+ description: Apply push_to_pull_request_branch items.
19
+ required: false
20
+ default: 'false'
21
+ close-issues:
22
+ description: Apply close_issue items.
23
+ required: false
24
+ default: 'false'
25
+ create-issues:
26
+ description: Apply create_issue items.
27
+ required: false
28
+ default: 'false'
29
+ update-issues:
30
+ description: Apply update_issue items.
31
+ required: false
32
+ default: 'false'
33
+ apply-labels:
34
+ description: Apply add_labels and remove_labels items.
35
+ required: false
36
+ default: 'true'
37
+ fallback-issue-number:
38
+ description: Issue an update_issue item targets when it names none.
39
+ required: false
40
+ default: ''
41
+ outputs:
42
+ item-count:
43
+ description: Number of items the agent produced.
44
+ value: ${{ steps.output.outputs.item-count }}
45
+ first-issue-number:
46
+ description: Issue number of the first issue created, or empty.
47
+ value: ${{ steps.create-issues.outputs.first-issue-number }}
48
+ runs:
49
+ using: composite
50
+ steps:
51
+ - name: Download agent output
52
+ id: output
53
+ uses: ./.github/actions/download-agent-output
54
+ with:
55
+ artifact-name: ${{ inputs.artifact-name }}
56
+
57
+ - name: Merge pull request
58
+ if: inputs.merge-pull-request == 'true' && steps.output.outputs.item-count != '0'
59
+ uses: ./.github/actions/merge-agent-pr
60
+ with:
61
+ output-file: ${{ steps.output.outputs.output-file }}
62
+ token: ${{ inputs.token }}
63
+
64
+ - name: Push to pull request branch
65
+ if: inputs.push-to-branch == 'true' && steps.output.outputs.item-count != '0'
66
+ uses: ./.github/actions/push-agent-branch
67
+ with:
68
+ output-file: ${{ steps.output.outputs.output-file }}
69
+ bundle-file: ${{ steps.output.outputs.bundle-file }}
70
+ token: ${{ inputs.token }}
71
+
72
+ - name: Close issues
73
+ if: inputs.close-issues == 'true' && steps.output.outputs.item-count != '0'
74
+ uses: ./.github/actions/close-agent-issues
75
+ with:
76
+ output-file: ${{ steps.output.outputs.output-file }}
77
+ token: ${{ inputs.token }}
78
+
79
+ - name: Update issues
80
+ if: inputs.update-issues == 'true' && steps.output.outputs.item-count != '0'
81
+ uses: ./.github/actions/update-agent-issues
82
+ with:
83
+ output-file: ${{ steps.output.outputs.output-file }}
84
+ token: ${{ inputs.token }}
85
+ fallback-issue-number: ${{ inputs.fallback-issue-number }}
86
+
87
+ - name: Create issues
88
+ id: create-issues
89
+ if: inputs.create-issues == 'true' && steps.output.outputs.item-count != '0'
90
+ uses: ./.github/actions/create-agent-issues
91
+ with:
92
+ output-file: ${{ steps.output.outputs.output-file }}
93
+ token: ${{ inputs.token }}
94
+
95
+ - name: Post agent comments
96
+ if: steps.output.outputs.item-count != '0'
97
+ uses: ./.github/actions/apply-agent-comments
98
+ with:
99
+ output-file: ${{ steps.output.outputs.output-file }}
100
+ token: ${{ inputs.token }}
101
+
102
+ - name: Apply agent label changes
103
+ if: inputs.apply-labels == 'true' && steps.output.outputs.item-count != '0'
104
+ uses: ./.github/actions/apply-agent-labels
105
+ with:
106
+ output-file: ${{ steps.output.outputs.output-file }}
107
+ token: ${{ inputs.token }}
@@ -0,0 +1,103 @@
1
+ name: Close audit reports
2
+ description: Close open audit reports once every issue they reference is closed.
3
+ inputs:
4
+ token:
5
+ description: GitHub token with issues write access.
6
+ required: true
7
+ runs:
8
+ using: composite
9
+ steps:
10
+ - name: Close every report whose referenced issues are all closed
11
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
12
+ with:
13
+ github-token: ${{ inputs.token }}
14
+ script: |
15
+ const reports = await github.paginate(github.rest.issues.listForRepo, {
16
+ ...context.repo,
17
+ state: 'open',
18
+ labels: 'audit',
19
+ per_page: 100,
20
+ });
21
+
22
+ if (reports.length === 0) {
23
+ core.info('No open audit reports.');
24
+ return;
25
+ }
26
+
27
+ // Skip `owner/repo#12` and `abc#12`: only a bare `#12` references this repository.
28
+ const referencedIssues = (body) => {
29
+ const matches = (body ?? '').matchAll(/(?:^|[^A-Za-z0-9_/])#(\d+)/g);
30
+ return [...new Set([...matches].map((match) => Number(match[1])))];
31
+ };
32
+
33
+ let closed = 0;
34
+ let keptOpen = 0;
35
+
36
+ for (const report of reports.sort((a, b) => a.number - b.number)) {
37
+ const refs = referencedIssues(report.body).filter((n) => n !== report.number);
38
+
39
+ const stillOpen = [];
40
+ const unknown = [];
41
+ let closedRefs = 0;
42
+ let resolved = 0;
43
+
44
+ for (const ref of refs) {
45
+ let state;
46
+
47
+ try {
48
+ ({ data: { state } } = await github.rest.issues.get({
49
+ ...context.repo,
50
+ issue_number: ref,
51
+ }));
52
+ } catch (error) {
53
+ if (error.status === 404) {
54
+ core.info(`#${report.number}: ignoring #${ref}, not an issue in this repository`);
55
+ continue;
56
+ }
57
+ unknown.push(ref);
58
+ resolved += 1;
59
+ continue;
60
+ }
61
+
62
+ resolved += 1;
63
+ if (state === 'closed') closedRefs += 1;
64
+ else stillOpen.push(ref);
65
+ }
66
+
67
+ if (stillOpen.length > 0) {
68
+ core.info(`#${report.number}: kept open, still-open references: ${stillOpen.join(', ')}`);
69
+ keptOpen += 1;
70
+ continue;
71
+ }
72
+
73
+ if (unknown.length > 0) {
74
+ core.info(`#${report.number}: kept open, undetermined references: ${unknown.join(', ')}`);
75
+ keptOpen += 1;
76
+ continue;
77
+ }
78
+
79
+ const reason = resolved === 0
80
+ ? 'This report references no issues.'
81
+ : `All ${closedRefs} referenced issue(s) are closed.`;
82
+
83
+ await github.rest.issues.createComment({
84
+ ...context.repo,
85
+ issue_number: report.number,
86
+ body: `Closed automatically by \`Work Router: audit-close\`. ${reason}`,
87
+ });
88
+
89
+ await github.rest.issues.update({
90
+ ...context.repo,
91
+ issue_number: report.number,
92
+ state: 'closed',
93
+ state_reason: 'completed',
94
+ });
95
+
96
+ core.info(`#${report.number}: closed. ${reason}`);
97
+ closed += 1;
98
+ }
99
+
100
+ await core.summary
101
+ .addHeading('Audit close', 2)
102
+ .addList([`Reports closed: ${closed}`, `Reports left open: ${keptOpen}`])
103
+ .write();
@@ -0,0 +1,90 @@
1
+ name: Classify route
2
+ description: Classify the triggering event into exactly one route, and resolve the issue a gated pull request closes.
3
+ inputs:
4
+ token:
5
+ description: GitHub token with pull-requests read access.
6
+ required: true
7
+ outputs:
8
+ route:
9
+ description: The single route this event selects, or `none`.
10
+ value: ${{ steps.classify.outputs.route }}
11
+ issue-number:
12
+ description: Issue number the route operates on.
13
+ value: ${{ steps.classify.outputs.issue-number }}
14
+ pr-number:
15
+ description: Pull request number the route operates on.
16
+ value: ${{ steps.classify.outputs.pr-number }}
17
+ linked-issue:
18
+ description: Issue the pull request closes. Empty for routes that do not target one.
19
+ value: ${{ steps.linked-issue.outputs.issue }}
20
+ ci-conclusion:
21
+ description: Conclusion reported by the CI run that triggered a merge-gate route.
22
+ value: ${{ steps.classify.outputs.ci-conclusion }}
23
+ ci-run-id:
24
+ description: Workflow run ID of that CI run.
25
+ value: ${{ steps.classify.outputs.ci-run-id }}
26
+ refine-mode:
27
+ description: first or rerefine.
28
+ value: ${{ steps.classify.outputs.refine-mode }}
29
+ direct-mode:
30
+ description: first or continue.
31
+ value: ${{ steps.classify.outputs.direct-mode }}
32
+ trigger-kind:
33
+ description: scheduled or manual.
34
+ value: ${{ steps.classify.outputs.trigger-kind }}
35
+ runs:
36
+ using: composite
37
+ steps:
38
+ - name: Classify the event
39
+ id: classify
40
+ shell: bash
41
+ env:
42
+ EVENT: ${{ github.event_name }}
43
+ ACTION: ${{ github.event.action }}
44
+ LABEL: ${{ github.event.label.name }}
45
+ ISSUE_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
46
+ EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
47
+ EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
48
+ COMMENT_ON_PR: ${{ github.event.issue.pull_request != '' }}
49
+ COMMENT_SENDER_TYPE: ${{ github.event.comment.user.type }}
50
+ RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
51
+ RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
52
+ RUN_ID: ${{ github.event.workflow_run.id }}
53
+ SCHEDULE: ${{ github.event.schedule }}
54
+ OPERATION: ${{ github.event.inputs.operation }}
55
+ INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue-number }}
56
+ INPUT_PR_NUMBER: ${{ github.event.inputs.pr-number }}
57
+ INPUT_MODE: ${{ github.event.inputs.mode }}
58
+ INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
59
+ INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
60
+ INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
61
+ run: |
62
+ set -euo pipefail
63
+
64
+ bash "${GITHUB_ACTION_PATH}/classify-route.sh" | tee "$RUNNER_TEMP/route.env"
65
+ cat "$RUNNER_TEMP/route.env" >> "$GITHUB_OUTPUT"
66
+
67
+ route="$(sed -n 's/^route=//p' "$RUNNER_TEMP/route.env")"
68
+ reason="$(sed -n 's/^error=//p' "$RUNNER_TEMP/route.env")"
69
+
70
+ if [ "$route" = "none" ] && [ -n "$reason" ]; then
71
+ echo "::notice::No route for this event: $reason"
72
+ fi
73
+
74
+ - name: Resolve the issue the pull request closes
75
+ id: linked-issue
76
+ if: steps.classify.outputs.route == 'merge-gate' || steps.classify.outputs.route == 'apply-review'
77
+ shell: bash
78
+ env:
79
+ GH_TOKEN: ${{ inputs.token }}
80
+ REPO: ${{ github.repository }}
81
+ PR_NUMBER: ${{ steps.classify.outputs.pr-number }}
82
+ run: |
83
+ set -euo pipefail
84
+
85
+ issue="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
86
+ --json closingIssuesReferences \
87
+ --jq '.closingIssuesReferences[0].number // empty')"
88
+
89
+ echo "issue=${issue}" >> "$GITHUB_OUTPUT"
90
+ echo "Pull request #${PR_NUMBER} closes issue #${issue:-<none>}"
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env bash
2
+ # Classify one GitHub event into exactly one route. Pure: no network, no gh calls, so
3
+ # verify-route-matrix.sh can source this file and exercise the same code the router runs.
4
+ #
5
+ # Reads the event facts from the environment and writes `key=value` lines to stdout.
6
+ # The caller appends them to $GITHUB_OUTPUT.
7
+
8
+ set -euo pipefail
9
+
10
+ readonly AUDIT_CRON="17 1 * * 1"
11
+ readonly AUDIT_CLOSE_CRON="43 3 * * *"
12
+ readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
13
+ readonly STALE_RECOVERY_CRON="0 */2 * * *"
14
+ # Daily, but it proposes far less often than daily: the worker holds one open
15
+ # proposal at a time and skips while that slot is filled. The cron is a heartbeat,
16
+ # the queue is the pacing.
17
+ readonly PROPOSE_CRON="29 7 * * *"
18
+
19
+ has_label() {
20
+ jq -e --arg name "$1" 'index($name)' >/dev/null 2>&1 <<<"${ISSUE_LABELS:-[]}"
21
+ }
22
+
23
+ is_issue_number() {
24
+ [[ "${1:-}" =~ ^[1-9][0-9]*$ ]]
25
+ }
26
+
27
+ classify_route() {
28
+ local route="none" error=""
29
+ local issue_number="" pr_number="" ci_conclusion="" ci_run_id=""
30
+ local refine_mode="" direct_mode="" trigger_kind=""
31
+
32
+ case "${EVENT:-}" in
33
+ issues)
34
+ if [ "${ACTION:-}" = "labeled" ]; then
35
+ case "${LABEL:-}" in
36
+ refine)
37
+ route="refine"
38
+ refine_mode="first"
39
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
40
+ ;;
41
+ implement)
42
+ route="implement"
43
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
44
+ ;;
45
+ direct)
46
+ route="direct"
47
+ direct_mode="first"
48
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
49
+ ;;
50
+ esac
51
+ fi
52
+ ;;
53
+
54
+ issue_comment)
55
+ if [ "${COMMENT_ON_PR:-false}" = "true" ]; then
56
+ route="apply-review"
57
+ pr_number="${EVENT_ISSUE_NUMBER:-}"
58
+ elif [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
59
+ error="comment authored by a bot"
60
+ elif has_label direct; then
61
+ route="direct"
62
+ direct_mode="continue"
63
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
64
+ elif ! has_label refine; then
65
+ error="issue does not carry the refine or direct label"
66
+ else
67
+ route="refine"
68
+ refine_mode="rerefine"
69
+ issue_number="${EVENT_ISSUE_NUMBER:-}"
70
+ fi
71
+ ;;
72
+
73
+ pull_request_review_comment | pull_request_review)
74
+ route="apply-review"
75
+ pr_number="${EVENT_PR_NUMBER:-}"
76
+ ;;
77
+
78
+ pull_request_target)
79
+ route="bot-approve"
80
+ ;;
81
+
82
+ workflow_run)
83
+ if is_issue_number "${RUN_PR_NUMBER:-}"; then
84
+ route="merge-gate"
85
+ pr_number="${RUN_PR_NUMBER}"
86
+ ci_conclusion="${RUN_CONCLUSION:-}"
87
+ ci_run_id="${RUN_ID:-}"
88
+ else
89
+ error="CI run has no attached pull request"
90
+ fi
91
+ ;;
92
+
93
+ schedule)
94
+ trigger_kind="scheduled"
95
+ case "${SCHEDULE:-}" in
96
+ "$AUDIT_CRON") route="audit" ;;
97
+ "$AUDIT_CLOSE_CRON") route="audit-close" ;;
98
+ "$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
99
+ "$STALE_RECOVERY_CRON") route="stale-recovery" ;;
100
+ "$PROPOSE_CRON") route="propose" ;;
101
+ *) error="no route for cron '${SCHEDULE:-}'" ;;
102
+ esac
103
+ ;;
104
+
105
+ workflow_dispatch)
106
+ trigger_kind="manual"
107
+ case "${OPERATION:-}" in
108
+ refine | implement | direct)
109
+ if is_issue_number "${INPUT_ISSUE_NUMBER:-}"; then
110
+ route="${OPERATION}"
111
+ issue_number="${INPUT_ISSUE_NUMBER}"
112
+ if [ "$OPERATION" = "refine" ]; then
113
+ refine_mode="${INPUT_MODE:-first}"
114
+ elif [ "$OPERATION" = "direct" ]; then
115
+ direct_mode="${INPUT_MODE:-first}"
116
+ fi
117
+ else
118
+ error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
119
+ fi
120
+ ;;
121
+ apply-review)
122
+ if is_issue_number "${INPUT_PR_NUMBER:-}"; then
123
+ route="apply-review"
124
+ pr_number="${INPUT_PR_NUMBER}"
125
+ else
126
+ error="operation 'apply-review' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
127
+ fi
128
+ ;;
129
+ merge-gate)
130
+ if is_issue_number "${INPUT_PR_NUMBER:-}"; then
131
+ route="merge-gate"
132
+ pr_number="${INPUT_PR_NUMBER}"
133
+ ci_conclusion="${INPUT_CI_CONCLUSION:-}"
134
+ ci_run_id="${INPUT_CI_RUN_ID:-}"
135
+ else
136
+ error="operation 'merge-gate' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
137
+ fi
138
+ ;;
139
+ audit | propose)
140
+ route="${OPERATION}"
141
+ trigger_kind="${INPUT_TRIGGER_KIND:-manual}"
142
+ ;;
143
+ audit-close | cleanup-artifacts | stale-recovery | validate)
144
+ route="${OPERATION}"
145
+ ;;
146
+ *)
147
+ error="unknown operation '${OPERATION:-}'"
148
+ ;;
149
+ esac
150
+ ;;
151
+
152
+ *)
153
+ error="unsupported event '${EVENT:-}'"
154
+ ;;
155
+ esac
156
+
157
+ cat <<EOF
158
+ route=${route}
159
+ issue-number=${issue_number}
160
+ pr-number=${pr_number}
161
+ ci-conclusion=${ci_conclusion}
162
+ ci-run-id=${ci_run_id}
163
+ refine-mode=${refine_mode}
164
+ direct-mode=${direct_mode}
165
+ trigger-kind=${trigger_kind}
166
+ error=${error}
167
+ EOF
168
+ }
169
+
170
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
171
+ classify_route
172
+ fi