@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.
- package/dist/action-validation.test.d.ts +1 -0
- package/dist/action-validation.test.js +87 -0
- package/dist/catalog-installation.d.ts +30 -0
- package/dist/catalog-installation.js +422 -0
- package/dist/catalog-installation.test.d.ts +1 -0
- package/dist/catalog-installation.test.js +485 -0
- package/dist/catalog-listing.d.ts +13 -0
- package/dist/catalog-listing.js +70 -0
- package/dist/catalog-listing.test.d.ts +1 -0
- package/dist/catalog-listing.test.js +150 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +218 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +273 -0
- package/dist/repository-inspection.d.ts +23 -0
- package/dist/repository-inspection.js +113 -0
- package/dist/repository-inspection.test.d.ts +1 -0
- package/dist/repository-inspection.test.js +77 -0
- package/dist/route-processing.d.ts +6 -0
- package/dist/route-processing.js +149 -0
- package/dist/route-processing.test.d.ts +1 -0
- package/dist/route-processing.test.js +283 -0
- package/dist/stack-defaults.d.ts +11 -0
- package/dist/stack-defaults.js +104 -0
- package/dist/stack-defaults.test.d.ts +1 -0
- package/dist/stack-defaults.test.js +266 -0
- package/dist/tui.d.ts +25 -0
- package/dist/tui.js +287 -0
- package/dist/tui.test.d.ts +1 -0
- package/dist/tui.test.js +249 -0
- package/dist/workflow-catalog.d.ts +25 -0
- package/dist/workflow-catalog.js +55 -0
- package/dist/workflow-catalog.test.d.ts +1 -0
- package/dist/workflow-catalog.test.js +29 -0
- package/loops/actions/add-issue-labels/action.yml +2 -2
- package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
- package/loops/actions/classify-route/action.yml +7 -0
- package/loops/actions/classify-route/classify-route.sh +20 -5
- package/loops/actions/cleanup-artifacts/action.yml +38 -12
- package/loops/actions/identify-gate-subject/action.yml +3 -1
- package/loops/actions/remove-issue-labels/action.yml +4 -2
- package/loops/actions/validate-refine-output/validate-refine-output.sh +18 -2
- package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +300 -16
- package/loops/scripts/compile-agent-workflows.mjs +96 -1
- package/loops/scripts/merge-changelog.mjs +76 -0
- package/loops/templates/agentics/actionlint.yaml +13 -0
- package/loops/templates/agentics/agentics-checks.yml +203 -86
- package/loops/templates/ci/app-ci-dotnet-next.yml +64 -2
- package/loops/templates/ci/app-ci-node-monorepo.yml +52 -0
- package/loops/templates/opencode/opencode.ci.json +2 -2
- package/loops/templates/opencode/opencode.ci.json.md +4 -1
- package/loops/workflows/agent-apply-review.md +5 -1
- package/loops/workflows/agent-implement.md +105 -3
- package/loops/workflows/agent-merge-gate.md +139 -25
- package/loops/workflows/agent-refine.md +26 -2
- package/loops/workflows/work-router.yml +227 -51
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/tui.test.js
ADDED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
148
|
-
# routing success here would double-fire the gate for every passing
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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 == "
|
|
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:
|
|
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
|
-
|
|
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
|
|
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"
|