@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,23 @@
|
|
|
1
|
+
export type RepositoryVisibility = "public" | "private";
|
|
2
|
+
export type VisibilitySource = "argument" | "environment" | "github" | "fallback";
|
|
3
|
+
export interface StackHints {
|
|
4
|
+
readonly packageJson: boolean;
|
|
5
|
+
readonly pnpmLockfile: boolean;
|
|
6
|
+
readonly solutionFiles: readonly string[];
|
|
7
|
+
readonly openSpec: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface RepositoryInspection {
|
|
10
|
+
readonly repositoryPath: string;
|
|
11
|
+
readonly existingAgentWorkflows: readonly string[];
|
|
12
|
+
readonly stackHints: StackHints;
|
|
13
|
+
}
|
|
14
|
+
export interface VisibilityResolution {
|
|
15
|
+
readonly value: RepositoryVisibility;
|
|
16
|
+
readonly source: VisibilitySource;
|
|
17
|
+
}
|
|
18
|
+
export interface CommandRunner {
|
|
19
|
+
readonly run: (command: string, arguments_: readonly string[], cwd: string) => Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
export declare function inspectRepository(repositoryPath: string): Promise<RepositoryInspection>;
|
|
22
|
+
export declare function resolveVisibility(repositoryPath: string, override: RepositoryVisibility | undefined, environment?: NodeJS.ProcessEnv, commandRunner?: CommandRunner): Promise<VisibilityResolution>;
|
|
23
|
+
export declare function parseVisibility(value: string | undefined): RepositoryVisibility | undefined;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { access, readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const defaultCommandRunner = {
|
|
8
|
+
async run(command, arguments_, cwd) {
|
|
9
|
+
const { stdout } = await execFileAsync(command, arguments_, { cwd, windowsHide: true });
|
|
10
|
+
return stdout;
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
export async function inspectRepository(repositoryPath) {
|
|
14
|
+
const workflowsPath = join(repositoryPath, ".github", "workflows");
|
|
15
|
+
const workflowEntries = await readDirectoryNames(workflowsPath);
|
|
16
|
+
const solutionFiles = await findSolutionFiles(repositoryPath);
|
|
17
|
+
return {
|
|
18
|
+
repositoryPath,
|
|
19
|
+
existingAgentWorkflows: workflowEntries.filter((entry) => /^agent-.*\.md$/i.test(entry)).sort(),
|
|
20
|
+
stackHints: {
|
|
21
|
+
packageJson: await pathExists(join(repositoryPath, "package.json")),
|
|
22
|
+
pnpmLockfile: await pathExists(join(repositoryPath, "pnpm-lock.yaml")),
|
|
23
|
+
solutionFiles,
|
|
24
|
+
openSpec: await isDirectory(join(repositoryPath, "openspec")),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export async function resolveVisibility(repositoryPath, override, environment = process.env, commandRunner = defaultCommandRunner) {
|
|
29
|
+
if (override !== undefined)
|
|
30
|
+
return { value: override, source: "argument" };
|
|
31
|
+
const environmentOverride = parseVisibility(environment.PLATFORM_WORKFLOWS_VISIBILITY);
|
|
32
|
+
if (environmentOverride !== undefined)
|
|
33
|
+
return { value: environmentOverride, source: "environment" };
|
|
34
|
+
try {
|
|
35
|
+
const output = await commandRunner.run("gh", ["repo", "view", "--json", "visibility"], repositoryPath);
|
|
36
|
+
const visibility = parseVisibilityFromGitHub(output);
|
|
37
|
+
if (visibility !== undefined)
|
|
38
|
+
return { value: visibility, source: "github" };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// GitHub CLI is optional. Initializing a local repository must still work offline.
|
|
42
|
+
}
|
|
43
|
+
return { value: "private", source: "fallback" };
|
|
44
|
+
}
|
|
45
|
+
export function parseVisibility(value) {
|
|
46
|
+
return value === "public" || value === "private" ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
async function findSolutionFiles(repositoryPath) {
|
|
49
|
+
const results = [];
|
|
50
|
+
await scanForSlnx(repositoryPath, "", results, 0);
|
|
51
|
+
return results.sort();
|
|
52
|
+
}
|
|
53
|
+
const MAX_DEPTH = 5;
|
|
54
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "out", "build", ".turbo", ".cache"]);
|
|
55
|
+
async function scanForSlnx(root, relativePath, results, depth) {
|
|
56
|
+
if (depth >= MAX_DEPTH)
|
|
57
|
+
return;
|
|
58
|
+
const currentPath = join(root, relativePath);
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await readdir(currentPath, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (SKIP_DIRS.has(entry.name))
|
|
69
|
+
continue;
|
|
70
|
+
await scanForSlnx(root, join(relativePath, entry.name), results, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
else if (entry.isFile() && entry.name.endsWith(".slnx")) {
|
|
73
|
+
results.push(relativePath === "" ? entry.name : join(relativePath, entry.name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function pathExists(path) {
|
|
78
|
+
try {
|
|
79
|
+
await access(path, constants.F_OK);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function isDirectory(path) {
|
|
87
|
+
try {
|
|
88
|
+
return (await stat(path)).isDirectory();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function readDirectoryNames(path) {
|
|
95
|
+
try {
|
|
96
|
+
return await readdir(path);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function parseVisibilityFromGitHub(output) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(output);
|
|
105
|
+
if (typeof parsed !== "object" || parsed === null || !("visibility" in parsed))
|
|
106
|
+
return undefined;
|
|
107
|
+
const { visibility } = parsed;
|
|
108
|
+
return typeof visibility === "string" ? parseVisibility(visibility.toLowerCase()) : undefined;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { inspectRepository, resolveVisibility } from "./repository-inspection.js";
|
|
6
|
+
import { run } from "./index.js";
|
|
7
|
+
const temporaryDirectories = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })));
|
|
10
|
+
});
|
|
11
|
+
describe("repository inspection", () => {
|
|
12
|
+
it("finds agent workflows and supported stack markers", async () => {
|
|
13
|
+
const repositoryPath = await createRepository({
|
|
14
|
+
".github/workflows/agent-refine.md": "# Refine",
|
|
15
|
+
".github/workflows/agent-custom.md": "# Custom",
|
|
16
|
+
".github/workflows/other.md": "# Other",
|
|
17
|
+
"package.json": "{}",
|
|
18
|
+
"pnpm-lock.yaml": "lockfileVersion: '9.0'",
|
|
19
|
+
"apps/api/Contoso.slnx": "<Solution />",
|
|
20
|
+
"openspec/changes/.gitkeep": "",
|
|
21
|
+
});
|
|
22
|
+
await expect(inspectRepository(repositoryPath)).resolves.toMatchObject({
|
|
23
|
+
existingAgentWorkflows: ["agent-custom.md", "agent-refine.md"],
|
|
24
|
+
stackHints: {
|
|
25
|
+
packageJson: true,
|
|
26
|
+
pnpmLockfile: true,
|
|
27
|
+
solutionFiles: [join("apps", "api", "Contoso.slnx")],
|
|
28
|
+
openSpec: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it("uses an explicit visibility before environment or GitHub", async () => {
|
|
33
|
+
const runner = { run: async () => '{"visibility":"public"}' };
|
|
34
|
+
await expect(resolveVisibility("repo", "private", { PLATFORM_WORKFLOWS_VISIBILITY: "public" }, runner)).resolves.toEqual({
|
|
35
|
+
value: "private",
|
|
36
|
+
source: "argument",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
it("falls back to private when GitHub CLI is unavailable", async () => {
|
|
40
|
+
const runner = { run: async () => Promise.reject(new Error("missing gh")) };
|
|
41
|
+
await expect(resolveVisibility("repo", undefined, {}, runner)).resolves.toEqual({ value: "private", source: "fallback" });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("CLI commands", () => {
|
|
45
|
+
it("keeps init available without writing configuration", async () => {
|
|
46
|
+
const repositoryPath = await createRepository({ "package.json": "{}" });
|
|
47
|
+
const output = captureConsole("log");
|
|
48
|
+
await expect(run(["init", "--visibility", "public"], repositoryPath)).resolves.toBe(0);
|
|
49
|
+
expect(output.calls).toHaveLength(1);
|
|
50
|
+
await expect(import("node:fs/promises").then(({ access }) => access(join(repositoryPath, ".github/workflows/shared/repo-config.md")))).rejects.toThrow();
|
|
51
|
+
output.restore();
|
|
52
|
+
});
|
|
53
|
+
it("accepts update as an add alias", async () => {
|
|
54
|
+
const repositoryPath = await createRepository({});
|
|
55
|
+
const error = captureConsole("error");
|
|
56
|
+
await expect(run(["update", "--invalid"], repositoryPath)).resolves.toBe(1);
|
|
57
|
+
expect(error.calls).toEqual(["Unknown option: --invalid"]);
|
|
58
|
+
error.restore();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
function captureConsole(method) {
|
|
62
|
+
const original = console[method];
|
|
63
|
+
const calls = [];
|
|
64
|
+
console[method] = (message) => calls.push(message);
|
|
65
|
+
return { calls, restore: () => { console[method] = original; } };
|
|
66
|
+
}
|
|
67
|
+
async function createRepository(files) {
|
|
68
|
+
const repositoryPath = await mkdtemp(join(tmpdir(), "workflows-"));
|
|
69
|
+
temporaryDirectories.push(repositoryPath);
|
|
70
|
+
await Promise.all(Object.entries(files).map(async ([relativePath, content]) => {
|
|
71
|
+
const path = join(repositoryPath, relativePath);
|
|
72
|
+
const { mkdir } = await import("node:fs/promises");
|
|
73
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
74
|
+
await writeFile(path, content, "utf8");
|
|
75
|
+
}));
|
|
76
|
+
return repositoryPath;
|
|
77
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type RouteName } from "./workflow-catalog.js";
|
|
2
|
+
export declare function stripRouteFromRouter(yaml: string, route: RouteName): string;
|
|
3
|
+
export declare function stripRouteFromClassifier(shell: string, route: RouteName): string;
|
|
4
|
+
export declare function addRouteExclusion(matrix: string, route: RouteName): string;
|
|
5
|
+
export declare function processRoutes(files: Map<string, string>, selectedRoutes: readonly RouteName[]): Map<string, string>;
|
|
6
|
+
export declare function excludedWorkerFiles(selectedRoutes: readonly RouteName[]): Set<string>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { routeNames, workflowRoutes } from "./workflow-catalog.js";
|
|
2
|
+
const routeCrons = {
|
|
3
|
+
audit: "17 1 * * 1",
|
|
4
|
+
};
|
|
5
|
+
export function stripRouteFromRouter(yaml, route) {
|
|
6
|
+
let result = yaml;
|
|
7
|
+
const cron = routeCrons[route];
|
|
8
|
+
if (cron !== undefined) {
|
|
9
|
+
result = result.replace(new RegExp(`^ - cron: "${escapeRegex(cron)}"\\n`, "gm"), "");
|
|
10
|
+
}
|
|
11
|
+
result = removeJobBlock(result, `call-${route}`);
|
|
12
|
+
result = result.replace(new RegExp(`^ - ${escapeRegex(route)}\\n`, "gm"), "");
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
export function stripRouteFromClassifier(shell, route) {
|
|
16
|
+
const constName = route.replace(/-/g, "_").toUpperCase() + "_CRON";
|
|
17
|
+
let result = shell;
|
|
18
|
+
result = result.replace(new RegExp(`^readonly ${constName}="[^"]*"\\n`, "gm"), "");
|
|
19
|
+
const cron = routeCrons[route];
|
|
20
|
+
if (cron !== undefined) {
|
|
21
|
+
result = result.replace(new RegExp(`\\s*"\\$${constName}"\\) route="${escapeRegex(route)}" ;;\\n`, "g"), "");
|
|
22
|
+
}
|
|
23
|
+
result = result.replace(new RegExp(` \\| ${escapeRegex(route)}\\)`, "g"), ")");
|
|
24
|
+
result = result.replace(new RegExp(`^(\\s+)${escapeRegex(route)} \\| `, "gm"), "$1");
|
|
25
|
+
if (!result.includes(`| ${route}`) && !result.includes(`${route} |`)) {
|
|
26
|
+
result = removeAloneDispatchCase(result, route);
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
function removeAloneDispatchCase(shell, route) {
|
|
31
|
+
const lines = shell.split("\n");
|
|
32
|
+
const pattern = new RegExp(`^(\\s+)${escapeRegex(route)}\\)\\s*$`);
|
|
33
|
+
const result = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < lines.length) {
|
|
36
|
+
const match = lines[i].match(pattern);
|
|
37
|
+
if (match) {
|
|
38
|
+
const indent = match[1];
|
|
39
|
+
i++;
|
|
40
|
+
while (i < lines.length) {
|
|
41
|
+
if (new RegExp(`^${escapeRegex(indent)};;\\s*$`).test(lines[i])) {
|
|
42
|
+
i++;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result.push(lines[i]);
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result.join("\n");
|
|
54
|
+
}
|
|
55
|
+
export function addRouteExclusion(matrix, route) {
|
|
56
|
+
if (matrix.includes(`excluded route '${route}'`))
|
|
57
|
+
return matrix;
|
|
58
|
+
let result = matrix;
|
|
59
|
+
result = result.replace(new RegExp(` ${escapeRegex(route)} `, "g"), " ");
|
|
60
|
+
const exclusionBlock = `\necho "── Excluded routes ──────────────────────────────────────────────────────"\nif ! grep -q "route == '${route}'" "$ROUTER_YML"; then\n PASS=$((PASS + 1))\n echo " ${route} correctly excluded from work-router.yml"\nelse\n FAIL=$((FAIL + 1))\n echo "FAIL: excluded route '${route}' is still in work-router.yml" >&2\nfi\n`;
|
|
61
|
+
result = result.replace(/(\necho\nif \[ "\$FAIL" -eq 0 \])/, `${exclusionBlock}$1`);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export function processRoutes(files, selectedRoutes) {
|
|
65
|
+
const excludedRoutes = routeNames.filter((r) => !selectedRoutes.includes(r));
|
|
66
|
+
if (excludedRoutes.length === 0)
|
|
67
|
+
return files;
|
|
68
|
+
const result = new Map(files);
|
|
69
|
+
for (const route of excludedRoutes) {
|
|
70
|
+
const routerKey = findFileKey(result, "work-router.yml");
|
|
71
|
+
if (routerKey !== undefined) {
|
|
72
|
+
result.set(routerKey, stripRouteFromRouter(result.get(routerKey), route));
|
|
73
|
+
}
|
|
74
|
+
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
75
|
+
if (matrixKey !== undefined) {
|
|
76
|
+
result.set(matrixKey, addRouteExclusion(result.get(matrixKey), route));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const matrixKey = findFileKey(result, "verify-route-matrix.sh");
|
|
80
|
+
if (matrixKey !== undefined) {
|
|
81
|
+
result.set(matrixKey, createRouteMatrix(selectedRoutes));
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
function createRouteMatrix(selectedRoutes) {
|
|
86
|
+
const selected = selectedRoutes.join(" ");
|
|
87
|
+
const excluded = routeNames.filter((route) => !selectedRoutes.includes(route)).join(" ");
|
|
88
|
+
return `#!/usr/bin/env bash
|
|
89
|
+
set -euo pipefail
|
|
90
|
+
|
|
91
|
+
HERE="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
92
|
+
ROUTER_YML="\${HERE}/../../workflows/work-router.yml"
|
|
93
|
+
CLASSIFIER="\${HERE}/../classify-route/classify-route.sh"
|
|
94
|
+
|
|
95
|
+
bash -n "$CLASSIFIER"
|
|
96
|
+
|
|
97
|
+
for route in ${selected}; do
|
|
98
|
+
grep -q "route == '$route'" "$ROUTER_YML" || {
|
|
99
|
+
echo "FAIL: selected route '$route' has no router job" >&2
|
|
100
|
+
exit 1
|
|
101
|
+
}
|
|
102
|
+
done
|
|
103
|
+
|
|
104
|
+
for route in ${excluded}; do
|
|
105
|
+
if grep -q "route == '$route'" "$ROUTER_YML"; then
|
|
106
|
+
echo "FAIL: excluded route '$route' remains in router" >&2
|
|
107
|
+
exit 1
|
|
108
|
+
fi
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
echo "Route matrix: selected routes valid"
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
114
|
+
export function excludedWorkerFiles(selectedRoutes) {
|
|
115
|
+
return new Set(workflowRoutes
|
|
116
|
+
.filter((route) => !selectedRoutes.includes(route.name))
|
|
117
|
+
.map((route) => route.worker));
|
|
118
|
+
}
|
|
119
|
+
function removeJobBlock(yaml, jobName) {
|
|
120
|
+
const lines = yaml.split("\n");
|
|
121
|
+
const startPattern = new RegExp(`^ ${escapeRegex(jobName)}:`);
|
|
122
|
+
const result = [];
|
|
123
|
+
let skipping = false;
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (skipping) {
|
|
126
|
+
if (/^ \S/.test(line) || /^[^\s]/.test(line)) {
|
|
127
|
+
skipping = false;
|
|
128
|
+
result.push(line);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (startPattern.test(line)) {
|
|
132
|
+
skipping = true;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
result.push(line);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result.join("\n");
|
|
139
|
+
}
|
|
140
|
+
function findFileKey(files, endsWith) {
|
|
141
|
+
for (const key of files.keys()) {
|
|
142
|
+
if (key.endsWith(endsWith))
|
|
143
|
+
return key;
|
|
144
|
+
}
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
function escapeRegex(str) {
|
|
148
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
149
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { addRouteExclusion, excludedWorkerFiles, processRoutes, stripRouteFromClassifier, stripRouteFromRouter, } from "./route-processing.js";
|
|
3
|
+
import { routeNames } from "./workflow-catalog.js";
|
|
4
|
+
const ROUTER_YAML = `# header
|
|
5
|
+
name: "All Work Router"
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: "17 1 * * 1"
|
|
10
|
+
- cron: "43 3 * * *"
|
|
11
|
+
- cron: "0 6 * * *"
|
|
12
|
+
- cron: "*/5 * * * *"
|
|
13
|
+
- cron: "0 */2 * * *"
|
|
14
|
+
|
|
15
|
+
workflow_dispatch:
|
|
16
|
+
inputs:
|
|
17
|
+
operation:
|
|
18
|
+
description: "Operation to run"
|
|
19
|
+
required: true
|
|
20
|
+
type: choice
|
|
21
|
+
options:
|
|
22
|
+
- refine
|
|
23
|
+
- implement
|
|
24
|
+
- triage
|
|
25
|
+
- apply-review
|
|
26
|
+
- merge-gate
|
|
27
|
+
- audit
|
|
28
|
+
- audit-close
|
|
29
|
+
- cleanup-artifacts
|
|
30
|
+
- reconcile-bot-pr-runs
|
|
31
|
+
- stale-recovery
|
|
32
|
+
- validate
|
|
33
|
+
|
|
34
|
+
jobs:
|
|
35
|
+
call-refine:
|
|
36
|
+
needs: [classify, authorize]
|
|
37
|
+
if: needs.classify.outputs.route == 'refine' && needs.authorize.outputs.trusted == 'true'
|
|
38
|
+
uses: ./.github/workflows/agent-refine.lock.yml
|
|
39
|
+
secrets:
|
|
40
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
41
|
+
|
|
42
|
+
call-implement:
|
|
43
|
+
needs: [classify, authorize]
|
|
44
|
+
if: needs.classify.outputs.route == 'implement' && needs.authorize.outputs.trusted == 'true'
|
|
45
|
+
uses: ./.github/workflows/agent-implement.lock.yml
|
|
46
|
+
secrets:
|
|
47
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
48
|
+
|
|
49
|
+
call-triage:
|
|
50
|
+
needs: [classify, authorize]
|
|
51
|
+
if: needs.classify.outputs.route == 'triage' && needs.authorize.outputs.is_outside_collaborator == 'true'
|
|
52
|
+
uses: ./.github/workflows/agent-triage.lock.yml
|
|
53
|
+
secrets:
|
|
54
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
55
|
+
|
|
56
|
+
call-audit:
|
|
57
|
+
needs: classify
|
|
58
|
+
if: needs.classify.outputs.route == 'audit'
|
|
59
|
+
uses: ./.github/workflows/agent-audit.lock.yml
|
|
60
|
+
secrets:
|
|
61
|
+
OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}
|
|
62
|
+
|
|
63
|
+
audit-close:
|
|
64
|
+
needs: classify
|
|
65
|
+
if: needs.classify.outputs.route == 'audit-close'
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
`;
|
|
68
|
+
const CLASSIFIER_SH = `#!/usr/bin/env bash
|
|
69
|
+
set -euo pipefail
|
|
70
|
+
|
|
71
|
+
readonly AUDIT_CRON="17 1 * * 1"
|
|
72
|
+
readonly AUDIT_CLOSE_CRON="43 3 * * *"
|
|
73
|
+
readonly CLEANUP_ARTIFACTS_CRON="0 6 * * *"
|
|
74
|
+
readonly RECONCILE_BOT_PR_RUNS_CRON="*/5 * * * *"
|
|
75
|
+
readonly STALE_RECOVERY_CRON="0 */2 * * *"
|
|
76
|
+
|
|
77
|
+
classify_route() {
|
|
78
|
+
local route="none" error=""
|
|
79
|
+
|
|
80
|
+
case "\${EVENT:-}" in
|
|
81
|
+
schedule)
|
|
82
|
+
trigger_kind="scheduled"
|
|
83
|
+
case "\${SCHEDULE:-}" in
|
|
84
|
+
"\$AUDIT_CRON") route="audit" ;;
|
|
85
|
+
"\$AUDIT_CLOSE_CRON") route="audit-close" ;;
|
|
86
|
+
"\$CLEANUP_ARTIFACTS_CRON") route="cleanup-artifacts" ;;
|
|
87
|
+
"\$RECONCILE_BOT_PR_RUNS_CRON") route="reconcile-bot-pr-runs" ;;
|
|
88
|
+
"\$STALE_RECOVERY_CRON") route="stale-recovery" ;;
|
|
89
|
+
*) error="no route for cron '\${SCHEDULE:-}'" ;;
|
|
90
|
+
esac
|
|
91
|
+
;;
|
|
92
|
+
|
|
93
|
+
workflow_dispatch)
|
|
94
|
+
trigger_kind="manual"
|
|
95
|
+
case "\${OPERATION:-}" in
|
|
96
|
+
refine | implement)
|
|
97
|
+
route="\${OPERATION}"
|
|
98
|
+
;;
|
|
99
|
+
apply-review)
|
|
100
|
+
route="apply-review"
|
|
101
|
+
;;
|
|
102
|
+
merge-gate)
|
|
103
|
+
route="merge-gate"
|
|
104
|
+
;;
|
|
105
|
+
audit)
|
|
106
|
+
route="\${OPERATION}"
|
|
107
|
+
trigger_kind="\${INPUT_TRIGGER_KIND:-manual}"
|
|
108
|
+
;;
|
|
109
|
+
audit-close | cleanup-artifacts | reconcile-bot-pr-runs | stale-recovery | validate)
|
|
110
|
+
route="\${OPERATION}"
|
|
111
|
+
;;
|
|
112
|
+
*)
|
|
113
|
+
error="unknown operation '\${OPERATION:-}'"
|
|
114
|
+
;;
|
|
115
|
+
esac
|
|
116
|
+
;;
|
|
117
|
+
esac
|
|
118
|
+
|
|
119
|
+
cat <<EOF
|
|
120
|
+
route=\${route}
|
|
121
|
+
error=\${error}
|
|
122
|
+
EOF
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if [ "\${BASH_SOURCE[0]}" = "\$0" ]; then
|
|
126
|
+
classify_route
|
|
127
|
+
fi
|
|
128
|
+
`;
|
|
129
|
+
const MATRIX_SH = `#!/usr/bin/env bash
|
|
130
|
+
set -euo pipefail
|
|
131
|
+
|
|
132
|
+
echo "── Router wiring ─────────────────────────────────────────────────────────"
|
|
133
|
+
for route in refine implement triage apply-review merge-gate audit bot-approve \\
|
|
134
|
+
audit-close cleanup-artifacts reconcile-bot-pr-runs stale-recovery validate; do
|
|
135
|
+
if grep -q "route == '\${route}'" "\$ROUTER_YML"; then
|
|
136
|
+
PASS=\$((PASS + 1))
|
|
137
|
+
else
|
|
138
|
+
FAIL=\$((FAIL + 1))
|
|
139
|
+
echo "FAIL: work-router.yml has no job for route '\${route}'" >&2
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
|
|
143
|
+
while read -r operation; do
|
|
144
|
+
if grep -q "route == '\${operation}'" "\$ROUTER_YML"; then
|
|
145
|
+
PASS=\$((PASS + 1))
|
|
146
|
+
else
|
|
147
|
+
FAIL=\$((FAIL + 1))
|
|
148
|
+
echo "FAIL: dispatch operation '\${operation}' has no job in work-router.yml" >&2
|
|
149
|
+
fi
|
|
150
|
+
done < <(sed -n '/^ operation:/,/^ issue-number:/p' "\$ROUTER_YML" | sed -n 's/^ - //p')
|
|
151
|
+
|
|
152
|
+
echo
|
|
153
|
+
if [ "\$FAIL" -eq 0 ]; then
|
|
154
|
+
echo "Route matrix: \${PASS} passed"
|
|
155
|
+
else
|
|
156
|
+
echo "Route matrix: \${PASS} passed, \${FAIL} FAILED" >&2
|
|
157
|
+
fi
|
|
158
|
+
|
|
159
|
+
exit \$((FAIL > 0))
|
|
160
|
+
`;
|
|
161
|
+
describe("stripRouteFromRouter", () => {
|
|
162
|
+
it("removes the audit cron entry", () => {
|
|
163
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
164
|
+
expect(result).not.toContain('cron: "17 1 * * 1"');
|
|
165
|
+
expect(result).toContain('cron: "43 3 * * *"');
|
|
166
|
+
});
|
|
167
|
+
it("removes the call-audit job block", () => {
|
|
168
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
169
|
+
expect(result).not.toContain("call-audit");
|
|
170
|
+
expect(result).toContain("call-refine");
|
|
171
|
+
});
|
|
172
|
+
it("preserves the audit-close job when removing audit", () => {
|
|
173
|
+
const result = stripRouteFromRouter(ROUTER_YAML, "audit");
|
|
174
|
+
expect(result).toContain("audit-close");
|
|
175
|
+
});
|
|
176
|
+
it("does not modify the yaml when stripping a route that has no cron", () => {
|
|
177
|
+
const yamlWithoutCron = ROUTER_YAML.replace(/ - cron: "17 1 \* \* 1"\n/, "");
|
|
178
|
+
const result = stripRouteFromRouter(yamlWithoutCron, "audit");
|
|
179
|
+
expect(result).not.toContain("call-audit");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe("stripRouteFromClassifier", () => {
|
|
183
|
+
it("removes the AUDIT_CRON constant", () => {
|
|
184
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
|
|
185
|
+
expect(result).not.toContain('readonly AUDIT_CRON');
|
|
186
|
+
expect(result).toContain('readonly AUDIT_CLOSE_CRON');
|
|
187
|
+
});
|
|
188
|
+
it("removes the audit schedule case", () => {
|
|
189
|
+
const result = stripRouteFromClassifier(CLASSIFIER_SH, "audit");
|
|
190
|
+
expect(result).not.toContain('"$AUDIT_CRON") route="audit"');
|
|
191
|
+
expect(result).toContain('"$AUDIT_CLOSE_CRON") route="audit-close"');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
describe("addRouteExclusion", () => {
|
|
195
|
+
it("adds an exclusion assertion for the route", () => {
|
|
196
|
+
const result = addRouteExclusion(MATRIX_SH, "audit");
|
|
197
|
+
expect(result).toContain("excluded route 'audit'");
|
|
198
|
+
expect(result).toContain("audit correctly excluded from work-router.yml");
|
|
199
|
+
});
|
|
200
|
+
it("does not add the exclusion twice", () => {
|
|
201
|
+
const once = addRouteExclusion(MATRIX_SH, "audit");
|
|
202
|
+
const twice = addRouteExclusion(once, "audit");
|
|
203
|
+
const matchCount = (twice.match(/excluded route 'audit'/g) ?? []).length;
|
|
204
|
+
expect(matchCount).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
describe("processRoutes", () => {
|
|
208
|
+
it("returns the same map when all routes are selected", () => {
|
|
209
|
+
const files = new Map([
|
|
210
|
+
["work-router.yml", ROUTER_YAML],
|
|
211
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
212
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
213
|
+
]);
|
|
214
|
+
const result = processRoutes(files, [...routeNames]);
|
|
215
|
+
expect(result).toBe(files);
|
|
216
|
+
});
|
|
217
|
+
it("strips every worker route when no routes are selected", () => {
|
|
218
|
+
const files = new Map([
|
|
219
|
+
[".github/workflows/work-router.yml", ROUTER_YAML],
|
|
220
|
+
[".github/actions/classify-route/classify-route.sh", CLASSIFIER_SH],
|
|
221
|
+
[".github/actions/verify-route-matrix/verify-route-matrix.sh", MATRIX_SH],
|
|
222
|
+
]);
|
|
223
|
+
const result = processRoutes(files, []);
|
|
224
|
+
const router = result.get(".github/workflows/work-router.yml");
|
|
225
|
+
expect(router).not.toContain("call-audit");
|
|
226
|
+
expect(router).not.toContain("- refine");
|
|
227
|
+
const classifier = result.get(".github/actions/classify-route/classify-route.sh");
|
|
228
|
+
expect(classifier).toBe(CLASSIFIER_SH);
|
|
229
|
+
const matrix = result.get(".github/actions/verify-route-matrix/verify-route-matrix.sh");
|
|
230
|
+
expect(matrix).toContain("Route matrix: selected routes valid");
|
|
231
|
+
expect(matrix).toContain("for route in refine implement triage apply-review merge-gate audit");
|
|
232
|
+
});
|
|
233
|
+
it("strips audit from all three files when unselected", () => {
|
|
234
|
+
const files = new Map([
|
|
235
|
+
["work-router.yml", ROUTER_YAML],
|
|
236
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
237
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
238
|
+
]);
|
|
239
|
+
const selectedRoutes = routeNames.filter((r) => r !== "audit");
|
|
240
|
+
const result = processRoutes(files, selectedRoutes);
|
|
241
|
+
const router = result.get("work-router.yml");
|
|
242
|
+
expect(router).not.toContain("call-audit");
|
|
243
|
+
expect(router).not.toContain('cron: "17 1 * * 1"');
|
|
244
|
+
const classifier = result.get("classify-route.sh");
|
|
245
|
+
expect(classifier).toBe(CLASSIFIER_SH);
|
|
246
|
+
});
|
|
247
|
+
it("processes multiple excluded routes at once", () => {
|
|
248
|
+
const files = new Map([
|
|
249
|
+
["work-router.yml", ROUTER_YAML],
|
|
250
|
+
["classify-route.sh", CLASSIFIER_SH],
|
|
251
|
+
["verify-route-matrix.sh", MATRIX_SH],
|
|
252
|
+
]);
|
|
253
|
+
const selectedRoutes = ["refine", "implement"];
|
|
254
|
+
const result = processRoutes(files, selectedRoutes);
|
|
255
|
+
const router = result.get("work-router.yml");
|
|
256
|
+
expect(router).toContain("call-refine");
|
|
257
|
+
expect(router).toContain("call-implement");
|
|
258
|
+
expect(router).not.toContain("call-audit");
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe("excludedWorkerFiles", () => {
|
|
262
|
+
it("returns worker files for unselected routes", () => {
|
|
263
|
+
const selectedRoutes = ["refine", "implement"];
|
|
264
|
+
const excluded = excludedWorkerFiles(selectedRoutes);
|
|
265
|
+
expect(excluded.has("agent-refine.md")).toBe(false);
|
|
266
|
+
expect(excluded.has("agent-implement.md")).toBe(false);
|
|
267
|
+
expect(excluded.has("agent-audit.md")).toBe(true);
|
|
268
|
+
});
|
|
269
|
+
it("returns an empty set when all routes are selected", () => {
|
|
270
|
+
const excluded = excludedWorkerFiles([...routeNames]);
|
|
271
|
+
expect(excluded.size).toBe(0);
|
|
272
|
+
});
|
|
273
|
+
it("returns all worker files when no routes are selected", () => {
|
|
274
|
+
const excluded = excludedWorkerFiles([]);
|
|
275
|
+
expect(excluded.size).toBe(6);
|
|
276
|
+
expect(excluded.has("agent-refine.md")).toBe(true);
|
|
277
|
+
expect(excluded.has("agent-implement.md")).toBe(true);
|
|
278
|
+
expect(excluded.has("agent-triage.md")).toBe(true);
|
|
279
|
+
expect(excluded.has("agent-apply-review.md")).toBe(true);
|
|
280
|
+
expect(excluded.has("agent-merge-gate.md")).toBe(true);
|
|
281
|
+
expect(excluded.has("agent-audit.md")).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { RepositoryInspection } from "./repository-inspection.js";
|
|
2
|
+
export interface StackDefaults {
|
|
3
|
+
readonly verifyCommands: string;
|
|
4
|
+
readonly repoRulesBase: string;
|
|
5
|
+
readonly hasDotnet: boolean;
|
|
6
|
+
readonly hasNodeOnly: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function generateStackDefaults(inspection: RepositoryInspection): StackDefaults;
|
|
9
|
+
export declare function injectStackEnv(content: string, defaults: StackDefaults): string;
|
|
10
|
+
export declare function generateOpencodeCi(baseContent: string, inspection: RepositoryInspection): string;
|
|
11
|
+
export declare function generateOpencodeConfig(baseContent: string, inspection: RepositoryInspection): string;
|