@plainconceptsplatform/workflows 0.5.1 → 0.6.1

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 (58) hide show
  1. package/dist/action-validation.test.d.ts +1 -0
  2. package/dist/action-validation.test.js +87 -0
  3. package/dist/catalog-installation.d.ts +30 -0
  4. package/dist/catalog-installation.js +422 -0
  5. package/dist/catalog-installation.test.d.ts +1 -0
  6. package/dist/catalog-installation.test.js +485 -0
  7. package/dist/catalog-listing.d.ts +13 -0
  8. package/dist/catalog-listing.js +70 -0
  9. package/dist/catalog-listing.test.d.ts +1 -0
  10. package/dist/catalog-listing.test.js +150 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +218 -0
  13. package/dist/index.test.d.ts +1 -0
  14. package/dist/index.test.js +273 -0
  15. package/dist/repository-inspection.d.ts +23 -0
  16. package/dist/repository-inspection.js +113 -0
  17. package/dist/repository-inspection.test.d.ts +1 -0
  18. package/dist/repository-inspection.test.js +77 -0
  19. package/dist/route-processing.d.ts +6 -0
  20. package/dist/route-processing.js +149 -0
  21. package/dist/route-processing.test.d.ts +1 -0
  22. package/dist/route-processing.test.js +283 -0
  23. package/dist/stack-defaults.d.ts +11 -0
  24. package/dist/stack-defaults.js +104 -0
  25. package/dist/stack-defaults.test.d.ts +1 -0
  26. package/dist/stack-defaults.test.js +266 -0
  27. package/dist/tui.d.ts +25 -0
  28. package/dist/tui.js +287 -0
  29. package/dist/tui.test.d.ts +1 -0
  30. package/dist/tui.test.js +249 -0
  31. package/dist/workflow-catalog.d.ts +25 -0
  32. package/dist/workflow-catalog.js +55 -0
  33. package/dist/workflow-catalog.test.d.ts +1 -0
  34. package/dist/workflow-catalog.test.js +29 -0
  35. package/loops/actions/add-issue-labels/action.yml +2 -2
  36. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  37. package/loops/actions/classify-route/action.yml +7 -0
  38. package/loops/actions/classify-route/classify-route.sh +20 -5
  39. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  40. package/loops/actions/identify-gate-subject/action.yml +3 -1
  41. package/loops/actions/remove-issue-labels/action.yml +4 -2
  42. package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
  43. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  44. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +300 -16
  45. package/loops/scripts/compile-agent-workflows.mjs +96 -1
  46. package/loops/scripts/merge-changelog.mjs +76 -0
  47. package/loops/templates/agentics/actionlint.yaml +13 -0
  48. package/loops/templates/agentics/agentics-checks.yml +203 -86
  49. package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
  50. package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
  51. package/loops/templates/opencode/opencode.ci.json +2 -2
  52. package/loops/templates/opencode/opencode.ci.json.md +4 -1
  53. package/loops/workflows/agent-apply-review.md +5 -1
  54. package/loops/workflows/agent-implement.md +105 -3
  55. package/loops/workflows/agent-merge-gate.md +139 -25
  56. package/loops/workflows/agent-refine.md +26 -2
  57. package/loops/workflows/work-router.yml +227 -51
  58. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyFilter, cancelSelection, clearFilter, createSelectionState, filterItems, fuzzyMatch, getItemsToInstall, moveCursorDown, moveCursorUp, submitSelection, toggleSelection, } from "./tui.js";
3
+ function makeEntry(name, kind, installed = false) {
4
+ return {
5
+ kind,
6
+ name,
7
+ description: `${name} description`,
8
+ file: `${name}.md`,
9
+ installed,
10
+ };
11
+ }
12
+ function makeEntries(installed = []) {
13
+ const routes = ["refine", "implement", "triage", "apply-review", "merge-gate", "audit"];
14
+ const templates = ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "opencode.ci.json"];
15
+ return [
16
+ ...routes.map((name) => makeEntry(name, "route", installed.includes(name))),
17
+ ...templates.map((name) => makeEntry(name, "template", installed.includes(name))),
18
+ ];
19
+ }
20
+ // Counts derive from the fixture, so adding or removing a route above cannot silently
21
+ // desynchronise five assertions again.
22
+ const FIXTURE_COUNT = makeEntries().length;
23
+ describe("createSelectionState", () => {
24
+ it("returns all items as visible initially", () => {
25
+ const entries = makeEntries();
26
+ const state = createSelectionState(entries);
27
+ expect(state.allItems).toHaveLength(FIXTURE_COUNT);
28
+ expect(state.visibleItems).toHaveLength(FIXTURE_COUNT);
29
+ });
30
+ it("pre-selects installed items", () => {
31
+ const entries = makeEntries(["refine", "agentics-checks"]);
32
+ const state = createSelectionState(entries);
33
+ expect(state.selected.has("refine")).toBe(true);
34
+ expect(state.selected.has("agentics-checks")).toBe(true);
35
+ expect(state.selected.has("implement")).toBe(false);
36
+ });
37
+ it("starts with no filter and cursor at 0", () => {
38
+ const state = createSelectionState(makeEntries());
39
+ expect(state.filter).toBe("");
40
+ expect(state.cursor).toBe(0);
41
+ expect(state.status).toBe("selecting");
42
+ });
43
+ it("pre-selects nothing when nothing is installed", () => {
44
+ const state = createSelectionState(makeEntries());
45
+ expect(state.selected.size).toBe(0);
46
+ });
47
+ });
48
+ describe("fuzzyMatch", () => {
49
+ it("returns true for empty query", () => {
50
+ expect(fuzzyMatch("anything", "")).toBe(true);
51
+ });
52
+ it("matches exact string", () => {
53
+ expect(fuzzyMatch("refine", "refine")).toBe(true);
54
+ });
55
+ it("matches subsequence characters", () => {
56
+ expect(fuzzyMatch("refine", "rfe")).toBe(true);
57
+ });
58
+ it("matches non-contiguous characters", () => {
59
+ expect(fuzzyMatch("app-ci-dotnet-next", "acdn")).toBe(true);
60
+ });
61
+ it("is case-insensitive", () => {
62
+ expect(fuzzyMatch("Refine", "REFINE")).toBe(true);
63
+ expect(fuzzyMatch("REFINE", "refine")).toBe(true);
64
+ });
65
+ it("returns false when characters are not in order", () => {
66
+ expect(fuzzyMatch("refine", "efinr")).toBe(false);
67
+ });
68
+ it("returns false when query has characters not in text", () => {
69
+ expect(fuzzyMatch("refine", "xyz")).toBe(false);
70
+ });
71
+ it("matches against full name + description with combined filter", () => {
72
+ const text = "agentics-checks Agentics checks: verifies generated agent lockfiles";
73
+ expect(fuzzyMatch(text, "acch")).toBe(true);
74
+ });
75
+ });
76
+ describe("filterItems", () => {
77
+ const entries = makeEntries();
78
+ it("returns all items when filter is empty", () => {
79
+ expect(filterItems(entries, "")).toHaveLength(FIXTURE_COUNT);
80
+ });
81
+ it("filters by name", () => {
82
+ const result = filterItems(entries, "refine");
83
+ expect(result).toHaveLength(1);
84
+ expect(result[0].name).toBe("refine");
85
+ });
86
+ it("filters by description", () => {
87
+ const result = filterItems(entries, "description");
88
+ expect(result).toHaveLength(FIXTURE_COUNT);
89
+ });
90
+ it("filters by fuzzy subsequence over name and description", () => {
91
+ const result = filterItems(entries, "acdn");
92
+ const names = result.map((e) => e.name);
93
+ expect(names).toContain("app-ci-dotnet-next");
94
+ });
95
+ it("returns empty when nothing matches", () => {
96
+ expect(filterItems(entries, "zzzzz")).toHaveLength(0);
97
+ });
98
+ });
99
+ describe("applyFilter", () => {
100
+ it("updates the filter and visibleItems", () => {
101
+ const state = createSelectionState(makeEntries());
102
+ const next = applyFilter(state, "refine");
103
+ expect(next.filter).toBe("refine");
104
+ expect(next.visibleItems.length).toBe(1);
105
+ expect(next.visibleItems[0].name).toBe("refine");
106
+ });
107
+ it("clamps cursor when result shrinks", () => {
108
+ const entries = makeEntries();
109
+ let state = createSelectionState(entries);
110
+ state = { ...state, cursor: 10 };
111
+ state = applyFilter(state, "ci");
112
+ expect(state.cursor).toBeLessThanOrEqual(Math.max(0, state.visibleItems.length - 1));
113
+ expect(state.cursor).toBeLessThan(state.visibleItems.length);
114
+ });
115
+ it("sets cursor to 0 when no results match", () => {
116
+ const state = applyFilter(createSelectionState(makeEntries()), "zzz");
117
+ expect(state.visibleItems).toHaveLength(0);
118
+ expect(state.cursor).toBe(0);
119
+ });
120
+ it("preserves selected set when filtering", () => {
121
+ const entries = makeEntries(["refine"]);
122
+ const state = applyFilter(createSelectionState(entries), "ref");
123
+ expect(state.selected.has("refine")).toBe(true);
124
+ });
125
+ });
126
+ describe("clearFilter", () => {
127
+ it("resets filter to empty and restores all items", () => {
128
+ let state = applyFilter(createSelectionState(makeEntries()), "ref");
129
+ state = clearFilter(state);
130
+ expect(state.filter).toBe("");
131
+ expect(state.visibleItems).toHaveLength(FIXTURE_COUNT);
132
+ });
133
+ });
134
+ describe("moveCursorUp / moveCursorDown", () => {
135
+ it("moves cursor down by one", () => {
136
+ const state = createSelectionState(makeEntries());
137
+ const next = moveCursorDown(state);
138
+ expect(next.cursor).toBe(1);
139
+ });
140
+ it("wraps cursor to top from bottom", () => {
141
+ const entries = makeEntries();
142
+ let state = createSelectionState(entries);
143
+ state = { ...state, cursor: entries.length - 1 };
144
+ state = moveCursorDown(state);
145
+ expect(state.cursor).toBe(0);
146
+ });
147
+ it("moves cursor up by one", () => {
148
+ const entries = makeEntries();
149
+ let state = createSelectionState(entries);
150
+ state = { ...state, cursor: 3 };
151
+ state = moveCursorUp(state);
152
+ expect(state.cursor).toBe(2);
153
+ });
154
+ it("wraps cursor to bottom from top", () => {
155
+ const state = moveCursorUp(createSelectionState(makeEntries()));
156
+ expect(state.cursor).toBe(FIXTURE_COUNT - 1);
157
+ });
158
+ it("does not move when list is empty", () => {
159
+ const entries = [];
160
+ const state = createSelectionState(entries);
161
+ expect(moveCursorUp(state).cursor).toBe(0);
162
+ expect(moveCursorDown(state).cursor).toBe(0);
163
+ });
164
+ it("navigates within filtered results", () => {
165
+ let state = createSelectionState(makeEntries());
166
+ state = applyFilter(state, "ci");
167
+ state = moveCursorDown(state);
168
+ expect(state.cursor).toBe(1);
169
+ expect(state.cursor).toBeLessThan(state.visibleItems.length);
170
+ });
171
+ });
172
+ describe("toggleSelection", () => {
173
+ it("selects an unselected item at cursor", () => {
174
+ const state = createSelectionState(makeEntries());
175
+ expect(state.selected.has("refine")).toBe(false);
176
+ const next = toggleSelection(state);
177
+ expect(next.selected.has("refine")).toBe(true);
178
+ });
179
+ it("deselects a selected item at cursor", () => {
180
+ const entries = makeEntries(["refine"]);
181
+ const state = createSelectionState(entries);
182
+ expect(state.selected.has("refine")).toBe(true);
183
+ const next = toggleSelection(state);
184
+ expect(next.selected.has("refine")).toBe(false);
185
+ });
186
+ it("toggles the item at cursor position within filtered list", () => {
187
+ let state = createSelectionState(makeEntries());
188
+ state = applyFilter(state, "ci");
189
+ state = moveCursorDown(state);
190
+ const entryAtCursor = state.visibleItems[state.cursor];
191
+ const next = toggleSelection(state);
192
+ expect(next.selected.has(entryAtCursor.name)).toBe(true);
193
+ });
194
+ it("does nothing when list is empty", () => {
195
+ const state = createSelectionState([]);
196
+ const next = toggleSelection(state);
197
+ expect(next.selected.size).toBe(0);
198
+ });
199
+ it("does not change other selections", () => {
200
+ const entries = makeEntries(["refine", "audit"]);
201
+ const state = createSelectionState(entries);
202
+ const next = toggleSelection(state);
203
+ expect(next.selected.has("refine")).toBe(false);
204
+ expect(next.selected.has("audit")).toBe(true);
205
+ });
206
+ });
207
+ describe("submitSelection / cancelSelection", () => {
208
+ it("sets status to submitting", () => {
209
+ const state = submitSelection(createSelectionState(makeEntries()));
210
+ expect(state.status).toBe("submitting");
211
+ });
212
+ it("sets status to cancelled", () => {
213
+ const state = cancelSelection(createSelectionState(makeEntries()));
214
+ expect(state.status).toBe("cancelled");
215
+ });
216
+ });
217
+ describe("getItemsToInstall", () => {
218
+ it("returns empty when nothing is selected", () => {
219
+ const state = createSelectionState(makeEntries());
220
+ expect(getItemsToInstall(state)).toHaveLength(0);
221
+ });
222
+ it("returns only selected items that are not already installed", () => {
223
+ const entries = makeEntries(["refine"]);
224
+ let state = createSelectionState(entries);
225
+ state = { ...state, cursor: 1 };
226
+ state = toggleSelection(state);
227
+ const items = getItemsToInstall(state);
228
+ const names = items.map((e) => e.name);
229
+ expect(names).toContain("implement");
230
+ expect(names).not.toContain("refine");
231
+ });
232
+ it("returns already-installed items when force is true", () => {
233
+ const entries = makeEntries(["refine"]);
234
+ let state = createSelectionState(entries);
235
+ const items = getItemsToInstall(state, true);
236
+ const names = items.map((e) => e.name);
237
+ expect(names).toContain("refine");
238
+ });
239
+ it("includes both routes and templates", () => {
240
+ let state = createSelectionState(makeEntries());
241
+ state = toggleSelection(state);
242
+ state = { ...state, cursor: 8 };
243
+ state = toggleSelection(state);
244
+ const items = getItemsToInstall(state);
245
+ const kinds = items.map((e) => e.kind);
246
+ expect(kinds).toContain("route");
247
+ expect(kinds).toContain("template");
248
+ });
249
+ });
@@ -0,0 +1,25 @@
1
+ export declare const routeNames: readonly ["refine", "implement", "triage", "apply-review", "merge-gate", "audit"];
2
+ export type RouteName = (typeof routeNames)[number];
3
+ export interface WorkflowRoute {
4
+ readonly name: RouteName;
5
+ readonly worker: string;
6
+ readonly description: string;
7
+ readonly defaultEnabled: boolean;
8
+ }
9
+ export declare const workflowRoutes: readonly WorkflowRoute[];
10
+ export declare const packageOwnedTargets: readonly [".github/actions", ".github/workflows/agent-*.md", ".github/workflows/shared/platform-defaults.md", ".github/workflows/shared/opencode-ci.md", ".github/workflows/work-router.yml", "scripts/compile-agent-workflows.mjs", "opencode.ci.json"];
11
+ export interface MandatoryFile {
12
+ readonly source: string;
13
+ readonly target: string;
14
+ }
15
+ export declare const mandatoryFiles: readonly MandatoryFile[];
16
+ export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
17
+ export declare const templateNames: readonly ["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"];
18
+ export type TemplateName = (typeof templateNames)[number];
19
+ export interface CatalogTemplate {
20
+ readonly name: TemplateName;
21
+ readonly file: string;
22
+ readonly description: string;
23
+ readonly target?: string;
24
+ }
25
+ export declare const catalogTemplates: readonly CatalogTemplate[];
@@ -0,0 +1,55 @@
1
+ export const routeNames = [
2
+ "refine",
3
+ "implement",
4
+ "triage",
5
+ "apply-review",
6
+ "merge-gate",
7
+ "audit",
8
+ ];
9
+ export const workflowRoutes = [
10
+ { name: "refine", worker: "agent-refine.md", description: "Refines an issue into a user story, on a first pass or after the author has answered the bot's questions.", defaultEnabled: true },
11
+ { name: "implement", worker: "agent-implement.md", description: "Implements an issue and opens a pull request. Stops there: the merge decision belongs to the merge gate.", defaultEnabled: true },
12
+ { name: "triage", worker: "agent-triage.md", description: "Triages issues opened by outside collaborators: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting). Loops up to 3 rounds. Passes to refine or blocks.", defaultEnabled: true },
13
+ { name: "apply-review", worker: "agent-apply-review.md", description: "Applies reviewer feedback to an open pull request the bot authored, then pushes the fixes to the same branch.", defaultEnabled: true },
14
+ { name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to a bot-authored pull request once CI has reported: merge, hand to a human, or fix CI.", defaultEnabled: true },
15
+ { name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a single issue that Refine then sizes and splits.", defaultEnabled: true },
16
+ ];
17
+ export const packageOwnedTargets = [
18
+ ".github/actions",
19
+ ".github/workflows/agent-*.md",
20
+ ".github/workflows/shared/platform-defaults.md",
21
+ ".github/workflows/shared/opencode-ci.md",
22
+ ".github/workflows/work-router.yml",
23
+ "scripts/compile-agent-workflows.mjs",
24
+ "opencode.ci.json",
25
+ ];
26
+ export const mandatoryFiles = [
27
+ { source: "templates/opencode/opencode.ci.json", target: "opencode.ci.json" },
28
+ { source: "scripts/compile-agent-workflows.mjs", target: "scripts/compile-agent-workflows.mjs" },
29
+ ];
30
+ export const generatedConsumerTargets = [
31
+ ".github/workflows/agent-*.lock.yml",
32
+ ".github/aw/actions-lock.json",
33
+ ];
34
+ export const templateNames = [
35
+ "agentics-checks",
36
+ "agentics-maintenance",
37
+ "app-ci-dotnet-next",
38
+ "app-ci-node-monorepo",
39
+ "bug-report",
40
+ "feature-request",
41
+ "github-release",
42
+ "opencode.ci.json",
43
+ "visual-evidence",
44
+ ];
45
+ export const catalogTemplates = [
46
+ { name: "agentics-checks", file: "agentics-checks.yml", description: "Agentics checks: verifies generated agent lockfiles, actionlint, and compile on PRs touching workflow files." },
47
+ { name: "agentics-maintenance", file: "agentics-maintenance.yml", description: "Agentic maintenance: scheduled daily maintenance workflow for keeping workflows and actions up to date." },
48
+ { name: "app-ci-dotnet-next", file: "app-ci-dotnet-next.yml", description: "App CI pipeline for a .NET + Next.js monorepo: build, test, and lint on PRs and schedule." },
49
+ { name: "app-ci-node-monorepo", file: "app-ci-node-monorepo.yml", description: "App CI pipeline for a Node monorepo: build, test, and lint on PRs and schedule." },
50
+ { name: "bug-report", file: "bug_report.yml", description: "Bug report issue template: what happened, repro steps, expected behavior, acceptance criteria, environment, logs.", target: ".github/ISSUE_TEMPLATE/bug_report.yml" },
51
+ { name: "feature-request", file: "feature_request.yml", description: "Feature request issue template scoped to small, well-scoped improvements (Small/Medium only; large work belongs in a planning issue).", target: ".github/ISSUE_TEMPLATE/feature_request.yml" },
52
+ { name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
53
+ { name: "opencode.ci.json", file: "opencode.ci.json", description: "Standalone OpenCode CI config: plainconcepts provider, GLM model registration, ci-workflow-agent, and LSP defaults for consumer repositories." },
54
+ { name: "visual-evidence", file: "visual-evidence.yml", description: "Visual evidence: captures screenshots of UI changes on bot-authored PRs by reading the capturePlan left by the agent in evidence.json and executing it on a runner with Docker and Chrome access." },
55
+ ];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { catalogTemplates, generatedConsumerTargets, packageOwnedTargets, routeNames, templateNames, workflowRoutes, } from "./workflow-catalog.js";
3
+ describe("workflow catalog", () => {
4
+ it("assigns one worker to each route", () => {
5
+ expect(workflowRoutes.map((route) => route.name)).toEqual(routeNames);
6
+ expect(new Set(workflowRoutes.map((route) => route.worker)).size).toBe(workflowRoutes.length);
7
+ });
8
+ it("gives every route a non-empty description", () => {
9
+ for (const route of workflowRoutes) {
10
+ expect(route.description.length).toBeGreaterThan(0);
11
+ }
12
+ });
13
+ it("keeps generated files outside package ownership", () => {
14
+ const packageTargets = new Set(packageOwnedTargets);
15
+ for (const target of generatedConsumerTargets) {
16
+ expect(packageTargets.has(target)).toBe(false);
17
+ }
18
+ });
19
+ it("lists supported optional templates", () => {
20
+ expect(templateNames).toEqual(["agentics-checks", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "visual-evidence"]);
21
+ });
22
+ it("gives every catalog template a non-empty description and file", () => {
23
+ expect(catalogTemplates.map((template) => template.name)).toEqual([...templateNames]);
24
+ expect(new Set(catalogTemplates.map((template) => template.file)).size).toBe(catalogTemplates.length);
25
+ for (const template of catalogTemplates) {
26
+ expect(template.description.length).toBeGreaterThan(0);
27
+ }
28
+ });
29
+ });
@@ -9,7 +9,7 @@ inputs:
9
9
  description: Issue or pull request number.
10
10
  required: true
11
11
  labels:
12
- description: Newline-delimited labels to add.
12
+ description: Labels to add, one per line. A comma-separated list is accepted as well.
13
13
  required: true
14
14
  runs:
15
15
  using: composite
@@ -22,7 +22,7 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- const labels = process.env.LABELS.split(/\r?\n/).filter(Boolean);
25
+ const labels = process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean);
26
26
  await github.rest.issues.addLabels({
27
27
  ...context.repo,
28
28
  issue_number: Number(process.env.ISSUE_NUMBER),
@@ -40,7 +40,20 @@ git fetch --no-tags "$BUNDLE_FILE" "$BUNDLE_REF"
40
40
  [ "$(git cat-file -t "$BUNDLE_TIP")" = commit ] || fail "Git bundle ref must point directly to a commit"
41
41
  [ "$(git rev-parse FETCH_HEAD)" = "$BUNDLE_TIP" ] || fail "Fetched bundle commit did not match listed bundle ref"
42
42
 
43
- git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP" || fail "Git bundle cannot fast-forward $TARGET_BRANCH"
43
+ # Fast-forward only, deliberately: a push that is not one would discard somebody's commits.
44
+ #
45
+ # "Cannot fast-forward" said nothing about why, and there are two reasons: the agent
46
+ # rewrote history (a rebase, an amend, a reset), or somebody pushed to the branch while it
47
+ # was working. Both mean the same thing to git, that the branch carries commits the bundle
48
+ # does not, so the applier cannot tell them apart and does not try. It prints the commits
49
+ # that are missing instead: their authors and dates answer the question immediately.
50
+ if ! git merge-base --is-ancestor "$TARGET_TIP" "$BUNDLE_TIP"; then
51
+ echo "::error::Nothing was applied. $TARGET_BRANCH is at $TARGET_TIP and the bundle is at $BUNDLE_TIP, which does not contain it, so pushing would drop the commits below. Either the agent rewrote history, which this push refuses, or the branch moved while it was working, in which case nothing is lost and the gate only needs running again on the current head."
52
+ echo "On $TARGET_BRANCH but missing from the bundle:"
53
+ git --no-pager log --format=" %h %ad %an %s" --date=short "$BUNDLE_TIP..$TARGET_TIP" |
54
+ head -20 || true
55
+ exit 1
56
+ fi
44
57
  git switch --detach "$TARGET_TIP"
45
58
  git merge --ff-only "$BUNDLE_TIP"
46
59
  git push origin "HEAD:refs/heads/$TARGET_BRANCH"
@@ -24,6 +24,12 @@ outputs:
24
24
  ci-run-id:
25
25
  description: Workflow run ID of that CI run.
26
26
  value: ${{ steps.classify.outputs.ci-run-id }}
27
+ merge-gate-attempts:
28
+ description: Failed merge-gate attempts already made against this CI verdict.
29
+ value: ${{ steps.classify.outputs.merge-gate-attempts }}
30
+ implement-attempts:
31
+ description: Implement runs already made for this issue that died before producing an answer.
32
+ value: ${{ steps.classify.outputs.implement-attempts }}
27
33
  refine-mode:
28
34
  description: first or rerefine.
29
35
  value: ${{ steps.classify.outputs.refine-mode }}
@@ -60,6 +66,7 @@ runs:
60
66
  INPUT_MODE: ${{ github.event.inputs.mode }}
61
67
  INPUT_CI_CONCLUSION: ${{ github.event.inputs.ci-conclusion }}
62
68
  INPUT_CI_RUN_ID: ${{ github.event.inputs.ci-run-id }}
69
+ INPUT_ATTEMPTS_SO_FAR: ${{ github.event.inputs.attempts_so_far }}
63
70
  INPUT_TRIGGER_KIND: ${{ github.event.inputs.trigger-kind }}
64
71
  run: |
65
72
  set -euo pipefail
@@ -23,7 +23,7 @@ is_issue_number() {
23
23
 
24
24
  classify_route() {
25
25
  local route="none" error=""
26
- local issue_number="" pr_number="" ci_conclusion="" ci_run_id=""
26
+ local issue_number="" pr_number="" ci_conclusion="" ci_run_id="" merge_gate_attempts="0" implement_attempts="0"
27
27
  local refine_mode="" triage_mode="" trigger_kind=""
28
28
 
29
29
  case "${EVENT:-}" in
@@ -100,8 +100,14 @@ classify_route() {
100
100
 
101
101
  issue_comment)
102
102
  if [ "${COMMENT_ON_PR:-false}" = "true" ]; then
103
- route="apply-review"
104
- pr_number="${EVENT_ISSUE_NUMBER:-}"
103
+ if [ "${COMMENT_SENDER_TYPE:-}" = "Bot" ]; then
104
+ # Every App-token comment on a pull request is an issue_comment event, and the
105
+ # workers comment on pull requests they own. None of that is reviewer feedback.
106
+ error="comment authored by a bot"
107
+ else
108
+ route="apply-review"
109
+ pr_number="${EVENT_ISSUE_NUMBER:-}"
110
+ fi
105
111
  elif [ "${ISSUE_STATE:-}" = "closed" ]; then
106
112
  # A closing comment on a refine-labelled issue used to start a full re-refine, which
107
113
  # held a runner for 35 minutes and filed split children under an already-shut parent.
@@ -144,8 +150,10 @@ classify_route() {
144
150
 
145
151
  workflow_run)
146
152
  # Only a FAILED CI run on an attached pull request auto-dispatches the gate. A green
147
- # run reaches merge through app-ci's dispatch-merge-gate and the reconcile belt, so
148
- # routing success here would double-fire the gate for every passing pull request.
153
+ # run reaches the gate through the consumer CI's dispatch-merge-gate job and the
154
+ # reconcile belt, so routing success here would double-fire the gate for every passing
155
+ # pull request. GitHub delivers workflow_run only for CI runs whose actor is a human;
156
+ # a bot pull request's CI never arrives here at all, and the same two paths cover it.
149
157
  if [ "${RUN_CONCLUSION:-}" != "failure" ]; then
150
158
  error="CI concluded '${RUN_CONCLUSION:-}'; the gate auto-triggers only on failure"
151
159
  elif is_issue_number "${RUN_PR_NUMBER:-}"; then
@@ -178,6 +186,10 @@ classify_route() {
178
186
  issue_number="${INPUT_ISSUE_NUMBER}"
179
187
  if [ "$OPERATION" = "refine" ]; then
180
188
  refine_mode="${INPUT_MODE:-first}"
189
+ else
190
+ # The implement worker re-dispatches itself when a run dies before doing any work,
191
+ # and carries the count so the budget is bounded.
192
+ implement_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
181
193
  fi
182
194
  else
183
195
  error="operation '${OPERATION}' needs a positive issue-number, got '${INPUT_ISSUE_NUMBER:-}'"
@@ -206,6 +218,7 @@ classify_route() {
206
218
  pr_number="${INPUT_PR_NUMBER}"
207
219
  ci_conclusion="${INPUT_CI_CONCLUSION:-}"
208
220
  ci_run_id="${INPUT_CI_RUN_ID:-}"
221
+ merge_gate_attempts="${INPUT_ATTEMPTS_SO_FAR:-0}"
209
222
  else
210
223
  error="operation 'merge-gate' needs a positive pr-number, got '${INPUT_PR_NUMBER:-}'"
211
224
  fi
@@ -237,6 +250,8 @@ issue-number=${issue_number}
237
250
  pr-number=${pr_number}
238
251
  ci-conclusion=${ci_conclusion}
239
252
  ci-run-id=${ci_run_id}
253
+ merge-gate-attempts=${merge_gate_attempts}
254
+ implement-attempts=${implement_attempts}
240
255
  refine-mode=${refine_mode}
241
256
  triage-mode=${triage_mode}
242
257
  trigger-kind=${trigger_kind}
@@ -36,17 +36,32 @@ runs:
36
36
  const failed = [];
37
37
 
38
38
  for (const artifact of expired) {
39
- try {
40
- await github.rest.actions.deleteArtifact({
41
- ...context.repo,
42
- artifact_id: artifact.id,
43
- });
39
+ // GitHub answers a delete with a 5xx now and then. Three tries, because the
40
+ // alternative is a red daily run over one artifact that tomorrow's run would
41
+ // have removed anyway.
42
+ let lastError;
43
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
44
+ try {
45
+ await github.rest.actions.deleteArtifact({
46
+ ...context.repo,
47
+ artifact_id: artifact.id,
48
+ });
44
49
 
45
- core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
46
- deleted += 1;
47
- } catch (error) {
48
- core.warning(`Could not delete ${artifact.name} (${artifact.id}): ${error.message}`);
49
- failed.push(artifact.name);
50
+ core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
51
+ deleted += 1;
52
+ lastError = undefined;
53
+ break;
54
+ } catch (error) {
55
+ lastError = error;
56
+ if (attempt < 3) await new Promise((wait) => setTimeout(wait, attempt * 2000));
57
+ }
58
+ }
59
+
60
+ if (lastError) {
61
+ core.warning(
62
+ `Could not delete ${artifact.name} (${artifact.id}) after 3 tries: ${lastError.message}`,
63
+ );
64
+ failed.push({ name: artifact.name, status: lastError.status });
50
65
  }
51
66
  }
52
67
 
@@ -60,6 +75,17 @@ runs:
60
75
  ])
61
76
  .write();
62
77
 
63
- if (failed.length > 0) {
64
- core.setFailed(`${failed.length} artifact(s) could not be deleted: ${failed.join(', ')}`);
78
+ // Deleting nothing at all while expired artifacts exist is a permission or
79
+ // configuration problem, and has to be loud. One stubborn artifact among many is
80
+ // the API having a bad minute: tomorrow's run picks it up, and failing here only
81
+ // teaches people to ignore a red cleanup. A 4xx is never transient, so it fails
82
+ // whatever else succeeded.
83
+ const names = failed.map((entry) => entry.name).join(', ');
84
+ const structural = failed.some((entry) => entry.status && entry.status < 500);
85
+ if (failed.length > 0 && (deleted === 0 || structural)) {
86
+ core.setFailed(`${failed.length} artifact(s) could not be deleted: ${names}`);
87
+ } else if (failed.length > 0) {
88
+ core.warning(
89
+ `${failed.length} artifact(s) survived this run and will be retried tomorrow: ${names}`,
90
+ );
65
91
  }
@@ -96,8 +96,10 @@ runs:
96
96
  ci_run_id=""
97
97
  if [ -z "$ci_conclusion" ]; then
98
98
  head_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
99
+ # A run keeps the name it had when it ran, so a repository whose CI was renamed still
100
+ # carries runs under the old casing. Fold the name before comparing.
99
101
  ci_run="$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
100
- --jq '[.workflow_runs[] | select(.name == "App: CI" or .name == "CI")] | sort_by(.created_at) | last')"
102
+ --jq '[.workflow_runs[] | select((.name | ascii_downcase) == "app: ci" or (.name | ascii_downcase) == "ci")] | sort_by(.created_at) | last')"
101
103
 
102
104
  if [ -n "$ci_run" ] && [ "$ci_run" != "null" ]; then
103
105
  ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$ci_run")"
@@ -9,7 +9,7 @@ inputs:
9
9
  description: Issue or pull request number.
10
10
  required: true
11
11
  labels:
12
- description: Newline-delimited labels to remove.
12
+ description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
13
  required: true
14
14
  runs:
15
15
  using: composite
@@ -22,7 +22,9 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- for (const label of process.env.LABELS.split(/\r?\n/).filter(Boolean)) {
25
+ // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
+ // was removed: a 404, swallowed below, and neither label ever came off.
27
+ for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
26
28
  try {
27
29
  await github.rest.issues.removeLabel({
28
30
  ...context.repo,
@@ -30,8 +30,17 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
30
30
  (.body | type == "string") and (.body | test("[^[:space:]]")) and
31
31
  (.title | type == "string") and (.title | test("[^[:space:]]")))] | length;
32
32
 
33
+ # Signals about the run rather than about the issue. A worker reports these alongside
34
+ # its real work, and they say nothing about where that work was aimed, so they are not
35
+ # counted when deciding whether a run stayed on its own issue. Counting them meant a run
36
+ # that refined the issue perfectly and also noted a difficulty was judged invalid and
37
+ # its work discarded.
38
+ def is_run_signal:
39
+ .type == "report_incomplete" or .type == "missing_tool"
40
+ or .type == "missing_data" or .type == "noop";
41
+
33
42
  def has_only_source_items:
34
- all(.items[];
43
+ all(.items[] | select(is_run_signal | not);
35
44
  (
36
45
  .type == "update_issue" and
37
46
  (.item_number == null or (.item_number | tostring) == $issue)
@@ -42,6 +51,12 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
42
51
  ) or
43
52
  .type == "create_issue");
44
53
 
54
+ # The agent saying it did not finish. Survivable when it left real work behind, never
55
+ # when all it left was a comment: a failed run must not be read as a question, or the
56
+ # loop waits for an answer to something nobody asked.
57
+ def reported_incomplete:
58
+ any(.items[]; .type == "report_incomplete");
59
+
45
60
  def has_clarification:
46
61
  any(.items[]; .type == "add_comment" and (.item_number | tostring) == $issue and
47
62
  (.body | type == "string") and
@@ -54,7 +69,8 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
54
69
  if child_count >= 2 and has_replacement_body and has_only_source_items then "split"
55
70
  elif child_count > 0 then "invalid"
56
71
  elif has_replacement_body and has_only_source_items then "complete"
57
- elif has_clarification and (has_update | not) and has_only_source_items then "questions"
72
+ elif has_clarification and (has_update | not) and has_only_source_items
73
+ and (reported_incomplete | not) then "questions"
58
74
  else "invalid"
59
75
  end
60
76
  ' "$output_file"