@plainconceptsplatform/workflows 0.6.1 → 0.16.2
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/README.md +107 -88
- package/dist/catalog-installation.d.ts +39 -2
- package/dist/catalog-installation.js +172 -109
- package/dist/index.js +128 -73
- package/dist/package-baseline.d.ts +25 -0
- package/dist/package-baseline.js +138 -0
- package/dist/route-processing.d.ts +0 -2
- package/dist/route-processing.js +22 -91
- package/dist/stack-defaults.js +18 -12
- package/dist/tui.js +27 -43
- package/dist/worker-env.d.ts +46 -0
- package/dist/worker-env.js +179 -0
- package/dist/workflow-catalog.d.ts +4 -2
- package/dist/workflow-catalog.js +5 -3
- package/loops/actions/add-issue-labels/action.yml +20 -0
- package/loops/actions/audit-close/action.yml +180 -128
- package/loops/actions/classify-route/classify-route.sh +8 -2
- package/loops/actions/housekeeping/action.yml +251 -0
- package/loops/actions/merge-agent-pr/action.yml +13 -0
- package/loops/actions/report-workflow-errors/action.yml +414 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +13 -1
- package/loops/actions/validate-triage-output/action.yml +1 -1
- package/loops/actions/validate-triage-output/validate-triage-output.sh +9 -5
- package/loops/actions/verify-composite-actions/verify-composite-actions.sh +53 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +911 -170
- package/loops/templates/agentics/agentics-error-report.yml +98 -0
- package/loops/templates/opencode/opencode.ci.json +1 -1
- package/loops/workflows/agent-apply-review.md +438 -469
- package/loops/workflows/agent-audit.md +200 -213
- package/loops/workflows/agent-implement.md +591 -640
- package/loops/workflows/agent-merge-gate.md +826 -844
- package/loops/workflows/agent-refine.md +590 -633
- package/loops/workflows/agent-release.md +244 -258
- package/loops/workflows/agent-triage.md +476 -447
- package/loops/workflows/authorize-bot-work.yml +26 -6
- package/loops/workflows/shared/platform-defaults.md +18 -1
- package/loops/workflows/work-router.yml +1185 -1038
- package/package.json +9 -8
- package/dist/action-validation.test.d.ts +0 -1
- package/dist/action-validation.test.js +0 -87
- package/dist/catalog-installation.test.d.ts +0 -1
- package/dist/catalog-installation.test.js +0 -485
- package/dist/catalog-listing.test.d.ts +0 -1
- package/dist/catalog-listing.test.js +0 -150
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -273
- package/dist/repository-inspection.test.d.ts +0 -1
- package/dist/repository-inspection.test.js +0 -77
- package/dist/route-processing.test.d.ts +0 -1
- package/dist/route-processing.test.js +0 -283
- package/dist/stack-defaults.test.d.ts +0 -1
- package/dist/stack-defaults.test.js +0 -266
- package/dist/tui.test.d.ts +0 -1
- package/dist/tui.test.js +0 -249
- package/dist/workflow-catalog.test.d.ts +0 -1
- package/dist/workflow-catalog.test.js +0 -29
- package/loops/actions/stale-recovery/action.yml +0 -288
- package/loops/actions/update-changelog/action.yml +0 -113
package/dist/tui.test.js
DELETED
|
@@ -1,249 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,288 +0,0 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/stale-recovery/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
-
name: Recover stale agent work
|
|
3
|
-
description: Reconcile Refine, Implement, and Merge Gate claims whose bot-working label has outlived the staleness threshold, and dispatch merge-gate for bot PRs with failed CI.
|
|
4
|
-
inputs:
|
|
5
|
-
token:
|
|
6
|
-
description: GitHub token with issues write and pull-requests read access.
|
|
7
|
-
required: true
|
|
8
|
-
stale-threshold-hours:
|
|
9
|
-
description: Hours after which a bot-working claim is stale. Invalid or empty values use 2.
|
|
10
|
-
required: false
|
|
11
|
-
default: ''
|
|
12
|
-
refine-marker:
|
|
13
|
-
description: Marker identifying the comment a Refine run posts when it asks the author a question.
|
|
14
|
-
required: false
|
|
15
|
-
default: '<!-- agent-refine -->'
|
|
16
|
-
working-label:
|
|
17
|
-
description: Label marking an issue the bot currently owns.
|
|
18
|
-
required: false
|
|
19
|
-
default: bot-working
|
|
20
|
-
refine-label:
|
|
21
|
-
description: Label marking an issue queued for refinement.
|
|
22
|
-
required: false
|
|
23
|
-
default: refine
|
|
24
|
-
direct-label:
|
|
25
|
-
description: Label marking an issue with a free-form direct instruction.
|
|
26
|
-
required: false
|
|
27
|
-
default: direct
|
|
28
|
-
implement-label:
|
|
29
|
-
description: Label marking an issue queued for implementation.
|
|
30
|
-
required: false
|
|
31
|
-
default: implement
|
|
32
|
-
review-label:
|
|
33
|
-
description: Label marking an issue that needs a human.
|
|
34
|
-
required: false
|
|
35
|
-
default: review
|
|
36
|
-
ci-workflow-name:
|
|
37
|
-
description: Name of the CI workflow whose failure should trigger a merge-gate dispatch.
|
|
38
|
-
required: false
|
|
39
|
-
default: 'App: CI'
|
|
40
|
-
router-workflow-name:
|
|
41
|
-
description: Name of the work router workflow to dispatch.
|
|
42
|
-
required: false
|
|
43
|
-
default: 'All Work Router'
|
|
44
|
-
runs:
|
|
45
|
-
using: composite
|
|
46
|
-
steps:
|
|
47
|
-
- name: Reconcile stale claims
|
|
48
|
-
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
49
|
-
env:
|
|
50
|
-
STALE_THRESHOLD_HOURS: ${{ inputs.stale-threshold-hours }}
|
|
51
|
-
REFINE_MARKER: ${{ inputs.refine-marker }}
|
|
52
|
-
WORKING_LABEL: ${{ inputs.working-label }}
|
|
53
|
-
REFINE_LABEL: ${{ inputs.refine-label }}
|
|
54
|
-
DIRECT_LABEL: ${{ inputs.direct-label }}
|
|
55
|
-
IMPLEMENT_LABEL: ${{ inputs.implement-label }}
|
|
56
|
-
REVIEW_LABEL: ${{ inputs.review-label }}
|
|
57
|
-
CI_WORKFLOW_NAME: ${{ inputs.ci-workflow-name }}
|
|
58
|
-
ROUTER_WORKFLOW_NAME: ${{ inputs.router-workflow-name }}
|
|
59
|
-
with:
|
|
60
|
-
github-token: ${{ inputs.token }}
|
|
61
|
-
script: |
|
|
62
|
-
const WORKING = process.env.WORKING_LABEL;
|
|
63
|
-
const REVIEW = process.env.REVIEW_LABEL;
|
|
64
|
-
const MARKER = process.env.REFINE_MARKER;
|
|
65
|
-
const CI_WORKFLOW_NAME = process.env.CI_WORKFLOW_NAME;
|
|
66
|
-
const ROUTER_WORKFLOW_NAME = process.env.ROUTER_WORKFLOW_NAME;
|
|
67
|
-
|
|
68
|
-
const parsedHours = Number.parseInt(process.env.STALE_THRESHOLD_HOURS, 10);
|
|
69
|
-
const hours = Number.isInteger(parsedHours) && parsedHours > 0 ? parsedHours : 2;
|
|
70
|
-
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
|
|
71
|
-
|
|
72
|
-
core.info(`Stale threshold: ${hours}h (cutoff ${cutoff.toISOString()})`);
|
|
73
|
-
|
|
74
|
-
const staleIssuesLabelled = async (label) => {
|
|
75
|
-
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
|
76
|
-
...context.repo,
|
|
77
|
-
state: 'open',
|
|
78
|
-
labels: `${WORKING},${label}`,
|
|
79
|
-
per_page: 100,
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
return issues.filter((issue) => new Date(issue.updated_at) < cutoff);
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const removeWorking = async (issueNumber) => {
|
|
86
|
-
try {
|
|
87
|
-
await github.rest.issues.removeLabel({
|
|
88
|
-
...context.repo,
|
|
89
|
-
issue_number: issueNumber,
|
|
90
|
-
name: WORKING,
|
|
91
|
-
});
|
|
92
|
-
} catch (error) {
|
|
93
|
-
if (error.status !== 404) throw error;
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
// Open bot pull requests keep work in flight. GraphQL exposes authorship and closing
|
|
98
|
-
// references together, so use `__typename` instead of matching a login.
|
|
99
|
-
const openPullRequests = [];
|
|
100
|
-
|
|
101
|
-
for (let cursor = null; ; ) {
|
|
102
|
-
const page = await github.graphql(
|
|
103
|
-
`query($owner: String!, $name: String!, $cursor: String) {
|
|
104
|
-
repository(owner: $owner, name: $name) {
|
|
105
|
-
pullRequests(states: OPEN, first: 100, after: $cursor) {
|
|
106
|
-
pageInfo { hasNextPage endCursor }
|
|
107
|
-
nodes {
|
|
108
|
-
number
|
|
109
|
-
updatedAt
|
|
110
|
-
author { __typename }
|
|
111
|
-
closingIssuesReferences(first: 10) {
|
|
112
|
-
nodes { number labels(first: 20) { nodes { name } } }
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}`,
|
|
118
|
-
{ owner: context.repo.owner, name: context.repo.repo, cursor },
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const { nodes, pageInfo } = page.repository.pullRequests;
|
|
122
|
-
openPullRequests.push(...nodes);
|
|
123
|
-
|
|
124
|
-
if (!pageInfo.hasNextPage) break;
|
|
125
|
-
cursor = pageInfo.endCursor;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const botPullRequests = openPullRequests.filter(
|
|
129
|
-
(pr) => pr.author?.__typename === 'Bot',
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const issuesWithOpenBotPr = new Set(
|
|
133
|
-
botPullRequests.flatMap((pr) => pr.closingIssuesReferences.nodes.map((n) => n.number)),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const summary = {
|
|
137
|
-
refineEscalated: 0,
|
|
138
|
-
refineAwaitingAuthor: 0,
|
|
139
|
-
implementReleased: 0,
|
|
140
|
-
directReleased: 0,
|
|
141
|
-
mergeGateReleased: 0,
|
|
142
|
-
mergeGateDispatched: 0,
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
for (const issue of await staleIssuesLabelled(process.env.REFINE_LABEL)) {
|
|
146
|
-
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
147
|
-
...context.repo,
|
|
148
|
-
issue_number: issue.number,
|
|
149
|
-
per_page: 100,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
const lastMarker = comments.filter((c) => (c.body ?? '').includes(MARKER)).pop();
|
|
153
|
-
const lastHuman = comments.filter((c) => c.user?.type !== 'Bot').pop();
|
|
154
|
-
|
|
155
|
-
// The bot asked a question nobody has answered yet. Escalating would drop the
|
|
156
|
-
// question on the floor, so leave the claim in place.
|
|
157
|
-
if (lastMarker && !(lastHuman && new Date(lastHuman.created_at) > new Date(lastMarker.created_at))) {
|
|
158
|
-
core.info(`#${issue.number}: awaiting author reply, left alone`);
|
|
159
|
-
summary.refineAwaitingAuthor += 1;
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
await removeWorking(issue.number);
|
|
164
|
-
await github.rest.issues.addLabels({
|
|
165
|
-
...context.repo,
|
|
166
|
-
issue_number: issue.number,
|
|
167
|
-
labels: [REVIEW],
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
core.info(`#${issue.number}: escalated to ${REVIEW}`);
|
|
171
|
-
summary.refineEscalated += 1;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
for (const issue of await staleIssuesLabelled(process.env.IMPLEMENT_LABEL)) {
|
|
175
|
-
if (issuesWithOpenBotPr.has(issue.number)) {
|
|
176
|
-
core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
await removeWorking(issue.number);
|
|
181
|
-
core.info(`#${issue.number}: released`);
|
|
182
|
-
summary.implementReleased += 1;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
for (const issue of await staleIssuesLabelled(process.env.DIRECT_LABEL)) {
|
|
186
|
-
if (issuesWithOpenBotPr.has(issue.number)) {
|
|
187
|
-
core.info(`#${issue.number}: open bot pull request still in flight, left alone`);
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await removeWorking(issue.number);
|
|
192
|
-
await github.rest.issues.addLabels({
|
|
193
|
-
...context.repo,
|
|
194
|
-
issue_number: issue.number,
|
|
195
|
-
labels: [REVIEW],
|
|
196
|
-
});
|
|
197
|
-
core.info(`#${issue.number}: direct released, escalated to ${REVIEW}`);
|
|
198
|
-
summary.directReleased += 1;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
for (const pr of botPullRequests.filter((p) => new Date(p.updatedAt) < cutoff)) {
|
|
202
|
-
for (const issue of pr.closingIssuesReferences.nodes) {
|
|
203
|
-
if (!issue.labels.nodes.some((label) => label.name === WORKING)) {
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
await removeWorking(issue.number);
|
|
208
|
-
core.info(`PR #${pr.number} / issue #${issue.number}: released`);
|
|
209
|
-
summary.mergeGateReleased += 1;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// The workflow_run trigger does not fire for PR-triggered CI completions on
|
|
214
|
-
// feature branches. This polls every stale-recovery cycle (2h) for bot PRs
|
|
215
|
-
// whose CI concluded failure and dispatches the merge-gate, which is the same
|
|
216
|
-
// thing the workflow_run trigger would have done. A run already in progress
|
|
217
|
-
// for the same PR is skipped by the router's concurrency group.
|
|
218
|
-
const ciWorkflow = await github.rest.actions.getWorkflow({
|
|
219
|
-
...context.repo,
|
|
220
|
-
workflow_id: CI_WORKFLOW_NAME,
|
|
221
|
-
}).catch(() => null);
|
|
222
|
-
|
|
223
|
-
if (ciWorkflow) {
|
|
224
|
-
for (const pr of botPullRequests) {
|
|
225
|
-
const runs = await github.rest.actions.listWorkflowRuns({
|
|
226
|
-
...context.repo,
|
|
227
|
-
workflow_id: CI_WORKFLOW_NAME,
|
|
228
|
-
branch: pr.number.toString(),
|
|
229
|
-
status: 'completed',
|
|
230
|
-
per_page: 1,
|
|
231
|
-
}).catch(() => ({ data: { workflow_runs: [] } }));
|
|
232
|
-
|
|
233
|
-
const latestRun = runs.data.workflow_runs[0];
|
|
234
|
-
if (!latestRun || latestRun.conclusion !== 'failure') continue;
|
|
235
|
-
|
|
236
|
-
// Skip if the issue already carries review (the agent already gave up).
|
|
237
|
-
const issueNumbers = pr.closingIssuesReferences.nodes.map((n) => n.number);
|
|
238
|
-
const hasReview = await Promise.all(
|
|
239
|
-
issueNumbers.map(async (n) => {
|
|
240
|
-
const labels = await github.rest.issues.listLabelsOnIssue({
|
|
241
|
-
...context.repo,
|
|
242
|
-
issue_number: n,
|
|
243
|
-
}).catch(() => ({ data: [] }));
|
|
244
|
-
return labels.data.some((l) => l.name === REVIEW);
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
if (hasReview.some(Boolean)) continue;
|
|
248
|
-
|
|
249
|
-
const routerWorkflow = await github.rest.actions.getWorkflow({
|
|
250
|
-
...context.repo,
|
|
251
|
-
workflow_id: ROUTER_WORKFLOW_NAME,
|
|
252
|
-
}).catch(() => null);
|
|
253
|
-
|
|
254
|
-
if (routerWorkflow) {
|
|
255
|
-
// Resolve the linked issue number for the dispatch.
|
|
256
|
-
const linkedIssue = issueNumbers[0];
|
|
257
|
-
if (!linkedIssue) continue;
|
|
258
|
-
|
|
259
|
-
await github.rest.actions.createWorkflowDispatch({
|
|
260
|
-
...context.repo,
|
|
261
|
-
workflow_id: ROUTER_WORKFLOW_NAME,
|
|
262
|
-
ref: 'main',
|
|
263
|
-
inputs: {
|
|
264
|
-
operation: 'merge-gate',
|
|
265
|
-
'pr-number': pr.number.toString(),
|
|
266
|
-
'ci-conclusion': 'failure',
|
|
267
|
-
'ci-run-id': latestRun.id.toString(),
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
core.info(`PR #${pr.number}: dispatched merge-gate for failed CI (run ${latestRun.id})`);
|
|
272
|
-
summary.mergeGateDispatched += 1;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
await core.summary
|
|
278
|
-
.addHeading('Stale recovery', 2)
|
|
279
|
-
.addList([
|
|
280
|
-
`Threshold: ${hours} hours (cutoff ${cutoff.toISOString()})`,
|
|
281
|
-
`Refine escalated to ${REVIEW}: ${summary.refineEscalated}`,
|
|
282
|
-
`Refine awaiting author: ${summary.refineAwaitingAuthor}`,
|
|
283
|
-
`Implement released: ${summary.implementReleased}`,
|
|
284
|
-
`Direct released: ${summary.directReleased}`,
|
|
285
|
-
`Merge Gate released: ${summary.mergeGateReleased}`,
|
|
286
|
-
`Merge Gate dispatched for failed CI: ${summary.mergeGateDispatched}`,
|
|
287
|
-
])
|
|
288
|
-
.write();
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-changelog/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
-
name: Update changelog
|
|
3
|
-
description: Prepend a changelog entry for a landed issue and push it to the default branch.
|
|
4
|
-
|
|
5
|
-
inputs:
|
|
6
|
-
token:
|
|
7
|
-
description: Token with contents write on the repository.
|
|
8
|
-
required: true
|
|
9
|
-
issue-number:
|
|
10
|
-
description: Issue the landed commit implements.
|
|
11
|
-
required: true
|
|
12
|
-
commit-sha:
|
|
13
|
-
description: Commit the work landed as. Its subject becomes the summary.
|
|
14
|
-
required: true
|
|
15
|
-
changelog-path:
|
|
16
|
-
description: Path to changelog.json.
|
|
17
|
-
required: false
|
|
18
|
-
default: apps/web/src/shared/data/changelog.json
|
|
19
|
-
max-entries:
|
|
20
|
-
description: Newest entries to keep.
|
|
21
|
-
required: false
|
|
22
|
-
default: "20"
|
|
23
|
-
|
|
24
|
-
runs:
|
|
25
|
-
using: composite
|
|
26
|
-
steps:
|
|
27
|
-
- name: Prepend the entry and push
|
|
28
|
-
shell: bash
|
|
29
|
-
env:
|
|
30
|
-
GH_TOKEN: ${{ inputs.token }}
|
|
31
|
-
REPO: ${{ github.repository }}
|
|
32
|
-
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
33
|
-
COMMIT_SHA: ${{ inputs.commit-sha }}
|
|
34
|
-
CHANGELOG_PATH: ${{ inputs.changelog-path }}
|
|
35
|
-
MAX_ENTRIES: ${{ inputs.max-entries }}
|
|
36
|
-
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
37
|
-
run: |
|
|
38
|
-
set -euo pipefail
|
|
39
|
-
|
|
40
|
-
# This used to be part of the implement prompt. It is deterministic work, and leaving it
|
|
41
|
-
# to the agent made every implement touch the same file: two runs whose branches were cut
|
|
42
|
-
# before the other merged conflicted here, produced correct code, and still failed to open
|
|
43
|
-
# a pull request. Doing it after the merge means exactly one writer, always against a
|
|
44
|
-
# current default branch.
|
|
45
|
-
|
|
46
|
-
host="${GITHUB_SERVER_URL#https://}"
|
|
47
|
-
git config user.name "github-actions[bot]"
|
|
48
|
-
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
49
|
-
git remote set-url origin "https://x-access-token:${GH_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
|
|
50
|
-
|
|
51
|
-
git fetch --quiet origin "$DEFAULT_BRANCH"
|
|
52
|
-
git checkout --quiet -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
|
|
53
|
-
|
|
54
|
-
if [ ! -f "$CHANGELOG_PATH" ]; then
|
|
55
|
-
echo "::notice::$CHANGELOG_PATH does not exist; nothing to update."
|
|
56
|
-
exit 0
|
|
57
|
-
fi
|
|
58
|
-
|
|
59
|
-
# Idempotent: a re-run, a retry, or a second call for the same commit must not add a
|
|
60
|
-
# duplicate row.
|
|
61
|
-
short_sha="${COMMIT_SHA:0:7}"
|
|
62
|
-
if jq -e --arg c "$short_sha" '.changes[]? | select(.commit == $c)' "$CHANGELOG_PATH" >/dev/null 2>&1; then
|
|
63
|
-
echo "::notice::$short_sha is already in the changelog."
|
|
64
|
-
exit 0
|
|
65
|
-
fi
|
|
66
|
-
|
|
67
|
-
title=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq '.title')
|
|
68
|
-
|
|
69
|
-
# The commit subject carries what actually changed. Strip the conventional-commit prefix
|
|
70
|
-
# and the trailing issue reference so the summary reads as prose rather than as a commit.
|
|
71
|
-
subject=$(git log -1 --format=%s "$COMMIT_SHA")
|
|
72
|
-
summary=$(printf '%s' "$subject" \
|
|
73
|
-
| sed -E 's/^[a-z]+(\([^)]*\))?!?: *//' \
|
|
74
|
-
| sed -E 's/ *\(#[0-9]+\) *$//')
|
|
75
|
-
[ -n "$summary" ] || summary="$title"
|
|
76
|
-
# Capitalise, because a commit subject conventionally starts lowercase and this is read
|
|
77
|
-
# by people rather than by tooling.
|
|
78
|
-
summary="$(printf '%s' "${summary:0:1}" | tr '[:lower:]' '[:upper:]')${summary:1}"
|
|
79
|
-
|
|
80
|
-
timestamp=$(git log -1 --format=%cI "$COMMIT_SHA")
|
|
81
|
-
|
|
82
|
-
tmp=$(mktemp)
|
|
83
|
-
jq --argjson n "$MAX_ENTRIES" \
|
|
84
|
-
--arg ts "$timestamp" \
|
|
85
|
-
--argjson issue "$ISSUE_NUMBER" \
|
|
86
|
-
--arg title "$title" \
|
|
87
|
-
--arg summary "$summary" \
|
|
88
|
-
--arg commit "$short_sha" \
|
|
89
|
-
'.changes = ([{timestamp: $ts, issue: $issue, title: $title, summary: $summary, commit: $commit}] + (.changes // []))[:$n]' \
|
|
90
|
-
"$CHANGELOG_PATH" > "$tmp"
|
|
91
|
-
mv "$tmp" "$CHANGELOG_PATH"
|
|
92
|
-
|
|
93
|
-
if git diff --quiet -- "$CHANGELOG_PATH"; then
|
|
94
|
-
echo "::notice::changelog unchanged."
|
|
95
|
-
exit 0
|
|
96
|
-
fi
|
|
97
|
-
|
|
98
|
-
git add -- "$CHANGELOG_PATH"
|
|
99
|
-
git commit --quiet -m "docs(changelog): record #${ISSUE_NUMBER}"
|
|
100
|
-
|
|
101
|
-
# The default branch may have moved while this ran. Rebase and retry rather than fail:
|
|
102
|
-
# only this action writes the file, so a rebase here cannot produce a content conflict.
|
|
103
|
-
for attempt in 1 2 3; do
|
|
104
|
-
if git push --quiet origin "$DEFAULT_BRANCH"; then
|
|
105
|
-
echo "Recorded #${ISSUE_NUMBER} at ${short_sha} in the changelog."
|
|
106
|
-
exit 0
|
|
107
|
-
fi
|
|
108
|
-
echo "::notice::push rejected, rebasing (attempt ${attempt})"
|
|
109
|
-
git fetch --quiet origin "$DEFAULT_BRANCH"
|
|
110
|
-
git rebase --quiet "origin/$DEFAULT_BRANCH" || { git rebase --abort || true; break; }
|
|
111
|
-
done
|
|
112
|
-
|
|
113
|
-
echo "::warning::Could not push the changelog entry for #${ISSUE_NUMBER}."
|