@plainconceptsplatform/workflows 1.12.0 → 1.13.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/index.js +18 -5
- package/dist/template-bundles.js +18 -4
- package/dist/workflow-catalog.d.ts +3 -1
- package/dist/workflow-catalog.js +3 -1
- package/loops/actions/file-audit-issues/action.yml +193 -0
- package/loops/actions/file-audit-issues/audit-issues.cjs +245 -0
- package/loops/actions/issue-size.cjs +36 -0
- package/loops/actions/measure-issue-size/action.yml +47 -0
- package/loops/actions/merge-dependabot/action.yml +62 -0
- package/loops/actions/merge-dependabot/merge-dependabot.mjs +373 -0
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +101 -0
- package/loops/docs/diagrams.md +20 -13
- package/loops/templates/ci/app-ci-dotnet-next.yml +4 -0
- package/loops/templates/ci/app-ci-node-monorepo.yml +5 -1
- package/loops/templates/dependabot-merge/.github/workflows/dependabot-merge.yml +80 -0
- package/loops/workflows/agent-audit.md +46 -29
- package/loops/workflows/agent-docs-audit.md +44 -18
- package/loops/workflows/agent-refine.md +17 -30
- package/loops/workflows/work-router.yml +23 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { inspectRepository, parseVisibility, resolveVisibility } from "./repository-inspection.js";
|
|
3
|
-
import { catalogSourcePath, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
3
|
+
import { catalogSourcePath, exists, installCatalog, installedRoutes, installTemplate, isTemplateName, removeRouteFiles } from "./catalog-installation.js";
|
|
4
4
|
import { installBundle, installedBundles, isBundleTemplate } from "./template-bundles.js";
|
|
5
5
|
import { formatCatalog, listCatalog, searchCatalog } from "./catalog-listing.js";
|
|
6
6
|
import { packageVersion } from "./package-baseline.js";
|
|
7
7
|
import { routeNames, templateNames } from "./workflow-catalog.js";
|
|
8
8
|
import { runInteractive } from "./tui.js";
|
|
9
9
|
import { realpathSync } from "node:fs";
|
|
10
|
-
import { resolve } from "node:path";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
const HELP_TEXT = `Workflows CLI install and update Plain Concepts Platform agentic workflows.
|
|
13
13
|
|
|
@@ -125,9 +125,19 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
125
125
|
const sourcePath = catalogSourcePath();
|
|
126
126
|
// The installed set is always part of the target set: adding a route never drops another,
|
|
127
127
|
// and a plain update refreshes exactly what is there, bundle templates included. A bundle
|
|
128
|
-
// nobody installed is never added by an update
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
// nobody installed is never added by an update, and a repository holding only bundles gets
|
|
129
|
+
// no router from one.
|
|
130
|
+
const installedRouteSet = await installedRoutes(repositoryPath);
|
|
131
|
+
const routerInstalled = await exists(join(repositoryPath, ".github", "workflows", "work-router.yml"));
|
|
132
|
+
const bundlesOnly = command === "update" && routes.length === 0 && template === undefined &&
|
|
133
|
+
installedRouteSet.length === 0 && !routerInstalled && (await installedBundles(repositoryPath, sourcePath)).length > 0;
|
|
134
|
+
if (bundlesOnly) {
|
|
135
|
+
for (const name of await installedBundles(repositoryPath, sourcePath)) {
|
|
136
|
+
bundles.push(await installBundle(repositoryPath, name, sourcePath, { force, dryRun }));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else if (routes.length > 0 || template === undefined) {
|
|
140
|
+
const selectedRoutes = unionRoutes(routes, installedRouteSet);
|
|
131
141
|
catalog = await installCatalog(repositoryPath, { force, dryRun, selectedRoutes, inspection, baseline });
|
|
132
142
|
allConflicts.push(...catalog.conflicts);
|
|
133
143
|
allInstalled.push(...catalog.installed);
|
|
@@ -137,6 +147,9 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
if (template !== undefined && isBundleTemplate(template)) {
|
|
150
|
+
if (template === "dependabot-merge" && routerInstalled) {
|
|
151
|
+
console.error("warning: the work router already merges Dependabot pull requests in its maintenance run; this workflow does the same work twice.");
|
|
152
|
+
}
|
|
140
153
|
bundles.push(await installBundle(repositoryPath, template, sourcePath, { force, dryRun }));
|
|
141
154
|
}
|
|
142
155
|
else if (template !== undefined) {
|
package/dist/template-bundles.js
CHANGED
|
@@ -30,13 +30,13 @@ export async function installedBundles(repositoryPath, sourcePath) {
|
|
|
30
30
|
}
|
|
31
31
|
export async function installBundle(repositoryPath, template, sourcePath, options = {}) {
|
|
32
32
|
const version = options.packageVersion ?? await packageVersion();
|
|
33
|
-
const
|
|
34
|
-
const targets =
|
|
33
|
+
const files = await bundleFiles(sourcePath, template);
|
|
34
|
+
const targets = files.map((file) => file.target);
|
|
35
35
|
const changes = [];
|
|
36
36
|
const writes = [];
|
|
37
|
-
for (const target of
|
|
37
|
+
for (const { source, target } of files) {
|
|
38
38
|
const current = await readOptional(join(repositoryPath, target));
|
|
39
|
-
const text = stampVersion(normalizeEol(await readFile(
|
|
39
|
+
const text = stampVersion(normalizeEol(await readFile(source, "utf8")), version);
|
|
40
40
|
const recorded = current === undefined ? undefined : installedVersion(current);
|
|
41
41
|
const base = recorded === undefined ? { target } : { target, installedVersion: recorded };
|
|
42
42
|
if (current === text) {
|
|
@@ -89,6 +89,20 @@ function bundleRoot(sourcePath, template) {
|
|
|
89
89
|
throw new Error(`${template} is not a bundle template`);
|
|
90
90
|
return join(sourcePath, "templates", entry.directory);
|
|
91
91
|
}
|
|
92
|
+
// The bundle's own files, then the actions it borrows from the catalog. Only the former decide whether
|
|
93
|
+
// it is installed: a router consumer carries the same actions without having asked for the bundle.
|
|
94
|
+
async function bundleFiles(sourcePath, template) {
|
|
95
|
+
const root = bundleRoot(sourcePath, template);
|
|
96
|
+
const own = (await bundleTargets(sourcePath, template)).map((target) => ({ source: join(root, target), target }));
|
|
97
|
+
const borrowed = [];
|
|
98
|
+
for (const action of catalogTemplates.find((item) => item.name === template)?.actions ?? []) {
|
|
99
|
+
const directory = join(sourcePath, "actions", action);
|
|
100
|
+
for (const file of (await readdir(directory)).sort()) {
|
|
101
|
+
borrowed.push({ source: join(directory, file), target: `.github/actions/${action}/${file}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...own, ...borrowed].sort((left, right) => (left.target < right.target ? -1 : left.target > right.target ? 1 : 0));
|
|
105
|
+
}
|
|
92
106
|
async function bundleTargets(sourcePath, template) {
|
|
93
107
|
const root = bundleRoot(sourcePath, template);
|
|
94
108
|
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
@@ -19,7 +19,7 @@ export interface MandatoryFile {
|
|
|
19
19
|
}
|
|
20
20
|
export declare const mandatoryFiles: readonly MandatoryFile[];
|
|
21
21
|
export declare const generatedConsumerTargets: readonly [".github/workflows/agent-*.lock.yml", ".github/aw/actions-lock.json"];
|
|
22
|
-
export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
|
|
22
|
+
export declare const templateNames: readonly ["agentics-app-errors", "agentics-checks", "agentics-error-report", "agentics-maintenance", "app-ci-dotnet-next", "app-ci-node-monorepo", "bug-report", "dependabot-merge", "feature-request", "github-release", "opencode.ci.json", "platform-template", "quality-gates"];
|
|
23
23
|
export type TemplateName = (typeof templateNames)[number];
|
|
24
24
|
export interface CatalogTemplate {
|
|
25
25
|
readonly name: TemplateName;
|
|
@@ -30,6 +30,8 @@ export interface CatalogTemplate {
|
|
|
30
30
|
readonly directory?: string;
|
|
31
31
|
/** Several files under `templates/<directory>/`, package-managed and refreshed by `update` where installed. */
|
|
32
32
|
readonly bundle?: boolean;
|
|
33
|
+
/** Actions from `actions/` a bundle installs under `.github/actions/`, for a repository without the router. */
|
|
34
|
+
readonly actions?: readonly string[];
|
|
33
35
|
}
|
|
34
36
|
export declare const catalogTemplates: readonly CatalogTemplate[];
|
|
35
37
|
/** Bundle templates: several package-managed files, refreshed by `update` where installed. */
|
package/dist/workflow-catalog.js
CHANGED
|
@@ -14,7 +14,7 @@ export const workflowRoutes = [
|
|
|
14
14
|
{ name: "triage", worker: "agent-triage.md", description: "Triages issues opened from outside the organisation: runs 10 checks (template, security, size, danger, duplicates, clarity, reproducibility, acceptance, cross-cutting, product scope). Loops up to 3 rounds. Passes to refine, asks the author for more, parks out-of-scope work open for a maintainer, or closes what is genuinely rejected.", defaultEnabled: true },
|
|
15
15
|
{ 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 },
|
|
16
16
|
{ name: "merge-gate", worker: "agent-merge-gate.md", description: "Decides what happens to the pull request the merge train hands it, the bot's or a person's: repair it, merge it, or hand it to a human.", defaultEnabled: true },
|
|
17
|
-
{ name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a
|
|
17
|
+
{ name: "audit", worker: "agent-audit.md", description: "Read-only repository audit. Finds 5-7 problems, scores each 1-10, and files a tracking report plus one story issue per top finding, each sized for Refine.", defaultEnabled: true },
|
|
18
18
|
{ name: "docs-audit", worker: "agent-docs-audit.md", description: "Read-only documentation audit, every fortnight. Checks the documentation against the code and the Platform standard, and files one issue that Refine then sizes and splits, only when something is stale.", defaultEnabled: false },
|
|
19
19
|
{ name: "release", worker: "agent-release.md", description: "Writes release notes from the commit log, then a deterministic job opens a version-bump pull request with auto-merge; once it merges, release-tag.yml tags the commit and publishes the GitHub Release. Manual dispatch only (operation=release).", defaultEnabled: true, companions: ["release-tag.yml"] },
|
|
20
20
|
];
|
|
@@ -57,6 +57,7 @@ export const templateNames = [
|
|
|
57
57
|
"app-ci-dotnet-next",
|
|
58
58
|
"app-ci-node-monorepo",
|
|
59
59
|
"bug-report",
|
|
60
|
+
"dependabot-merge",
|
|
60
61
|
"feature-request",
|
|
61
62
|
"github-release",
|
|
62
63
|
"opencode.ci.json",
|
|
@@ -71,6 +72,7 @@ export const catalogTemplates = [
|
|
|
71
72
|
{ 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." },
|
|
72
73
|
{ 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." },
|
|
73
74
|
{ 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" },
|
|
75
|
+
{ name: "dependabot-merge", file: ".github/workflows/dependabot-merge.yml", directory: "dependabot-merge", bundle: true, actions: ["merge-dependabot"], description: "Dependabot auto-merge for a repository without the work router: every six hours, squash-merges the Dependabot pull requests whose checks are all green and asks Dependabot to rebase the stale ones. Deterministic, no model, never runs pull request code. Works with GITHUB_TOKEN; the Platform App token is used when its secrets exist. The router does the same in its maintenance run." },
|
|
74
76
|
{ 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" },
|
|
75
77
|
{ name: "github-release", file: "github-release.yml", description: "Publishes or updates a GitHub Release with generated notes whenever a v* tag is pushed." },
|
|
76
78
|
{ 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." },
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/file-audit-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: File audit issues
|
|
3
|
+
description: >-
|
|
4
|
+
File an audit as a tracking report plus one issue per story. Every body is measured with refine's
|
|
5
|
+
size rule first and trimmed to fit, the stories get the work labels and link back to the report,
|
|
6
|
+
and the report lists them. Re-running it reuses what an earlier attempt already filed.
|
|
7
|
+
inputs:
|
|
8
|
+
output-file:
|
|
9
|
+
description: Path to agent_output.json.
|
|
10
|
+
required: true
|
|
11
|
+
token:
|
|
12
|
+
description: GitHub token with issues:write. An App token, so the story labels reach the router.
|
|
13
|
+
required: true
|
|
14
|
+
report-labels:
|
|
15
|
+
description: Labels for the report, one per line. Never a work label.
|
|
16
|
+
required: false
|
|
17
|
+
default: |-
|
|
18
|
+
audit
|
|
19
|
+
bug
|
|
20
|
+
story-labels:
|
|
21
|
+
description: Labels for each story, one per line. The work label goes last.
|
|
22
|
+
required: false
|
|
23
|
+
default: |-
|
|
24
|
+
bug
|
|
25
|
+
refine
|
|
26
|
+
max-body-chars:
|
|
27
|
+
description: Largest body any filed issue may have, measured as refine measures it.
|
|
28
|
+
required: true
|
|
29
|
+
max-work-units:
|
|
30
|
+
description: Most bullet lines any filed issue may have, measured as refine measures it.
|
|
31
|
+
required: true
|
|
32
|
+
max-stories:
|
|
33
|
+
description: Most stories filed from one audit.
|
|
34
|
+
required: false
|
|
35
|
+
default: '3'
|
|
36
|
+
run-url:
|
|
37
|
+
description: This run, cited in a body that had to be trimmed.
|
|
38
|
+
required: false
|
|
39
|
+
default: ''
|
|
40
|
+
report-title:
|
|
41
|
+
description: Pattern, case-insensitive, of the report's title. Every other issue the agent filed is a story.
|
|
42
|
+
required: false
|
|
43
|
+
default: '^audit\b'
|
|
44
|
+
outputs:
|
|
45
|
+
report-number:
|
|
46
|
+
description: The report's issue number, or empty when the agent filed nothing.
|
|
47
|
+
value: ${{ steps.file.outputs.report-number }}
|
|
48
|
+
story-numbers:
|
|
49
|
+
description: Space-separated numbers of the stories filed or reused.
|
|
50
|
+
value: ${{ steps.file.outputs.story-numbers }}
|
|
51
|
+
runs:
|
|
52
|
+
using: composite
|
|
53
|
+
steps:
|
|
54
|
+
- name: File the report and its stories
|
|
55
|
+
id: file
|
|
56
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
57
|
+
env:
|
|
58
|
+
AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
|
|
59
|
+
ISSUE_SIZE_LIB: ${{ github.action_path }}/../issue-size.cjs
|
|
60
|
+
AUDIT_ISSUES_LIB: ${{ github.action_path }}/audit-issues.cjs
|
|
61
|
+
OUTPUT_FILE: ${{ inputs.output-file }}
|
|
62
|
+
REPORT_LABELS: ${{ inputs.report-labels }}
|
|
63
|
+
STORY_LABELS: ${{ inputs.story-labels }}
|
|
64
|
+
MAX_BODY_CHARS: ${{ inputs.max-body-chars }}
|
|
65
|
+
MAX_WORK_UNITS: ${{ inputs.max-work-units }}
|
|
66
|
+
MAX_STORIES: ${{ inputs.max-stories }}
|
|
67
|
+
RUN_URL: ${{ inputs.run-url }}
|
|
68
|
+
REPORT_TITLE: ${{ inputs.report-title }}
|
|
69
|
+
with:
|
|
70
|
+
github-token: ${{ inputs.token }}
|
|
71
|
+
script: |
|
|
72
|
+
const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
|
|
73
|
+
const { measureIssue, sizeVerdict } = require(process.env.ISSUE_SIZE_LIB);
|
|
74
|
+
const audit = require(process.env.AUDIT_ISSUES_LIB);
|
|
75
|
+
|
|
76
|
+
const list = (value) => String(value ?? '').split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean);
|
|
77
|
+
const reportLabels = list(process.env.REPORT_LABELS);
|
|
78
|
+
const storyLabels = list(process.env.STORY_LABELS);
|
|
79
|
+
const limits = { maxBodyChars: Number(process.env.MAX_BODY_CHARS), maxWorkUnits: Number(process.env.MAX_WORK_UNITS) };
|
|
80
|
+
if (!(limits.maxBodyChars > 0 && limits.maxWorkUnits > 0)) {
|
|
81
|
+
core.setFailed(`Invalid size limits: ${JSON.stringify(limits)}`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const plan = audit.planAuditIssues(readAgentItems(process.env.OUTPUT_FILE, 'create_issue'), {
|
|
86
|
+
limits,
|
|
87
|
+
maxStories: Number(process.env.MAX_STORIES) || 3,
|
|
88
|
+
runUrl: process.env.RUN_URL,
|
|
89
|
+
reportPattern: new RegExp(process.env.REPORT_TITLE || '^audit\\b', 'i'),
|
|
90
|
+
});
|
|
91
|
+
if (!plan) {
|
|
92
|
+
core.info('The agent filed no issue.');
|
|
93
|
+
core.setOutput('report-number', '');
|
|
94
|
+
core.setOutput('story-numbers', '');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The pre-flight: nothing is filed unless every body, rendered with the widest issue
|
|
99
|
+
// number, passes the same rule the refine guard applies.
|
|
100
|
+
const widest = audit.PLACEHOLDER_NUMBER;
|
|
101
|
+
const checks = [
|
|
102
|
+
{ title: plan.report.title, body: audit.renderReport(plan.report.body, plan.stories.map((s) => ({ number: widest, title: s.title, tracked: true }))) },
|
|
103
|
+
...plan.stories.map((s) => ({ title: s.title, body: audit.renderStory(s.body, widest) })),
|
|
104
|
+
];
|
|
105
|
+
for (const check of checks) {
|
|
106
|
+
const verdict = sizeVerdict(measureIssue(check.title, check.body), limits);
|
|
107
|
+
if (verdict.tooBig) {
|
|
108
|
+
core.setFailed(`"${check.title}" would be refused by refine (${verdict.reason}); nothing was filed.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const open = (await github.paginate(github.rest.issues.listForRepo, { ...context.repo, state: 'open', per_page: 100 }))
|
|
114
|
+
.filter((issue) => !issue.pull_request);
|
|
115
|
+
const byTitle = new Map(open.map((issue) => [audit.titleKey(issue.title), issue]));
|
|
116
|
+
const labelNames = (issue) => issue.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
|
117
|
+
|
|
118
|
+
for (const name of new Set([...reportLabels, ...storyLabels])) {
|
|
119
|
+
await github.rest.issues.createLabel({ ...context.repo, name, color: 'ededed' })
|
|
120
|
+
.catch((error) => { if (error.status !== 422) throw error; });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A re-run finds the report it filed before: same title, carrying the report labels.
|
|
124
|
+
let report = byTitle.get(audit.titleKey(plan.report.title));
|
|
125
|
+
if (report && reportLabels.every((label) => labelNames(report).includes(label))) {
|
|
126
|
+
core.info(`Reusing the report #${report.number}.`);
|
|
127
|
+
} else {
|
|
128
|
+
({ data: report } = await github.rest.issues.create({
|
|
129
|
+
...context.repo,
|
|
130
|
+
title: plan.report.title,
|
|
131
|
+
body: audit.renderReport(plan.report.body, []),
|
|
132
|
+
}));
|
|
133
|
+
core.info(`Filed the report #${report.number}.`);
|
|
134
|
+
}
|
|
135
|
+
const missing = reportLabels.filter((label) => !labelNames(report).includes(label));
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
await github.rest.issues.addLabels({ ...context.repo, issue_number: report.number, labels: missing });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const links = [];
|
|
141
|
+
const marker = audit.reportMarker(report.number);
|
|
142
|
+
for (const story of plan.stories) {
|
|
143
|
+
const existing = byTitle.get(audit.titleKey(story.title));
|
|
144
|
+
if (existing && !(existing.body ?? '').includes(marker)) {
|
|
145
|
+
// Already tracked by an issue this audit did not file: point at it, file nothing.
|
|
146
|
+
core.info(`"${story.title}" is already tracked by #${existing.number}.`);
|
|
147
|
+
links.push({ number: existing.number, title: story.title, tracked: true });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
let issue = existing;
|
|
151
|
+
if (!issue) {
|
|
152
|
+
({ data: issue } = await github.rest.issues.create({
|
|
153
|
+
...context.repo,
|
|
154
|
+
title: story.title,
|
|
155
|
+
body: audit.renderStory(story.body, report.number),
|
|
156
|
+
}));
|
|
157
|
+
core.info(`Filed #${issue.number}: ${story.title}${story.trimmed ? ' (trimmed)' : ''}`);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues', {
|
|
161
|
+
...context.repo,
|
|
162
|
+
issue_number: report.number,
|
|
163
|
+
sub_issue_id: issue.id,
|
|
164
|
+
});
|
|
165
|
+
} catch (error) {
|
|
166
|
+
// The report's checklist below links it either way; 422 is "already a sub-issue".
|
|
167
|
+
if (error.status !== 422) core.warning(`Could not link #${issue.number} under #${report.number}: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
// Last, so refine starts on a story whose body and link are already in place. A story an
|
|
170
|
+
// earlier attempt labelled is left alone: adding `refine` again would start refine again.
|
|
171
|
+
if (storyLabels.length > 0 && labelNames(issue).length === 0) {
|
|
172
|
+
await github.rest.issues.addLabels({ ...context.repo, issue_number: issue.number, labels: storyLabels });
|
|
173
|
+
}
|
|
174
|
+
links.push({ number: issue.number, title: story.title, tracked: false });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await github.rest.issues.update({
|
|
178
|
+
...context.repo,
|
|
179
|
+
issue_number: report.number,
|
|
180
|
+
body: audit.renderReport(plan.report.body, links),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
for (const title of plan.dropped) core.warning(`Not filed, over the ${process.env.MAX_STORIES} story limit: ${title}`);
|
|
184
|
+
core.setOutput('report-number', String(report.number));
|
|
185
|
+
core.setOutput('story-numbers', links.filter((link) => !link.tracked).map((link) => link.number).join(' '));
|
|
186
|
+
await core.summary
|
|
187
|
+
.addHeading('Audit filed', 2)
|
|
188
|
+
.addList([
|
|
189
|
+
`Report #${report.number}${plan.report.trimmed ? ' (trimmed to fit)' : ''}`,
|
|
190
|
+
...links.map((link) => `#${link.number} ${link.title}${link.tracked ? ' (already tracked)' : ''}`),
|
|
191
|
+
...plan.dropped.map((title) => `Not filed: ${title}`),
|
|
192
|
+
])
|
|
193
|
+
.write();
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/file-audit-issues/audit-issues.cjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// What the audit files, decided apart from the API calls so it can be tested on its own. Pure: no
|
|
5
|
+
// network, no clock. The audit used to file one issue holding every finding and three stories,
|
|
6
|
+
// labelled `refine`; refine's size gate refused every one. Now the report is a tracking issue that
|
|
7
|
+
// refine never sees, each story is its own issue, and every body is measured with refine's own
|
|
8
|
+
// rule before anything is filed: a body over the limits is trimmed here, never filed to be refused.
|
|
9
|
+
|
|
10
|
+
const { measureIssue, sizeVerdict } = require('../issue-size.cjs');
|
|
11
|
+
|
|
12
|
+
// Wide enough for any issue number, so a body measured with it only shrinks once the real one is in.
|
|
13
|
+
const PLACEHOLDER_NUMBER = 9999999;
|
|
14
|
+
const MAX_TITLE = 200;
|
|
15
|
+
const STORY_HEADING = /^(#{1,6})\s+(.*\S)\s*$/;
|
|
16
|
+
// The story section of the old single-issue shape: "## Top 3 — To Implement", "## Section 2 ...".
|
|
17
|
+
const STORY_SECTION = /^(#{1,6})\s+(?:.*\bto implement\b|section 2\b)/i;
|
|
18
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
19
|
+
|
|
20
|
+
const bytes = (text) => Buffer.byteLength(text, 'utf8');
|
|
21
|
+
const units = (text) => measureIssue('', text).units;
|
|
22
|
+
const titleKey = (title) => String(title ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
23
|
+
const cleanTitle = (title) => {
|
|
24
|
+
const flat = String(title ?? '').replace(/\s+/g, ' ').trim();
|
|
25
|
+
return flat.length > MAX_TITLE ? `${flat.slice(0, MAX_TITLE - 1)}…` : flat;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function reportMarker(number) {
|
|
29
|
+
return `<!-- audit-report: ${number} -->`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderStory(body, reportNumber) {
|
|
33
|
+
return `${body.replace(/\s+$/, '')}\n\nFound by the audit report #${reportNumber}.\n${reportMarker(reportNumber)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderReport(body, links) {
|
|
37
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
38
|
+
if (links.length === 0) return trimmed;
|
|
39
|
+
const lines = links.map((link) => `- [ ] #${link.number} ${link.title}${link.tracked ? ' (already tracked)' : ''}`);
|
|
40
|
+
return `${trimmed}\n\n## To implement\n\n${lines.join('\n')}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The longest prefix of `text`, whole lines, that fits the budget. An open code fence is closed, so
|
|
44
|
+
// a cut never turns the rest of the issue into code. A first line too long on its own is cut short.
|
|
45
|
+
function trimLines(text, budget) {
|
|
46
|
+
const lines = text.split('\n');
|
|
47
|
+
const kept = [];
|
|
48
|
+
let inFence = false;
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const opensOrCloses = FENCE.test(line);
|
|
51
|
+
const nextInFence = opensOrCloses ? !inFence : inFence;
|
|
52
|
+
const candidate = [...kept, line, ...(nextInFence ? ['```'] : [])].join('\n');
|
|
53
|
+
if (bytes(candidate) > budget.bytes || units(candidate) > budget.units) {
|
|
54
|
+
// A bullet cut short is still a bullet, so it is only kept while a unit is left for it.
|
|
55
|
+
if (kept.length === 0 && units(line) <= budget.units) {
|
|
56
|
+
const chars = [...line];
|
|
57
|
+
while (chars.length > 0 && bytes(`${chars.join('')}…`) > budget.bytes) chars.pop();
|
|
58
|
+
if (chars.length > 0) kept.push(`${chars.join('')}…`);
|
|
59
|
+
}
|
|
60
|
+
if (inFence) kept.push('```');
|
|
61
|
+
return { text: kept.join('\n'), trimmed: true };
|
|
62
|
+
}
|
|
63
|
+
kept.push(line);
|
|
64
|
+
inFence = nextInFence;
|
|
65
|
+
}
|
|
66
|
+
return { text: kept.join('\n'), trimmed: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function trimNote(runUrl) {
|
|
70
|
+
return runUrl
|
|
71
|
+
? `_Trimmed to fit refine's size limits. The full text is in the audit run: ${runUrl}_`
|
|
72
|
+
: "_Trimmed to fit refine's size limits._";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Fits `body` so that title + body + suffix passes the limits, trimming from the end.
|
|
76
|
+
function fitBody(title, body, suffix, limits, runUrl) {
|
|
77
|
+
const fits = (text) => !sizeVerdict(measureIssue(title, `${text}${suffix}`), limits).tooBig;
|
|
78
|
+
if (fits(body)) return { body, trimmed: false };
|
|
79
|
+
const note = `\n\n${trimNote(runUrl)}`;
|
|
80
|
+
const overhead = measureIssue(title, `${note}${suffix}`);
|
|
81
|
+
const budget = { bytes: limits.maxBodyChars - overhead.chars - 1, units: limits.maxWorkUnits - overhead.units };
|
|
82
|
+
const { text } = trimLines(body, budget);
|
|
83
|
+
const trimmedBody = `${text}${note}`;
|
|
84
|
+
return { body: fits(trimmedBody) ? trimmedBody : trimNote(runUrl), trimmed: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Splits markdown at headings (only those `depth` deep, when given) into [preamble, ...sections].
|
|
88
|
+
function splitAtHeadings(body, depth = 0) {
|
|
89
|
+
const blocks = [];
|
|
90
|
+
let current = [];
|
|
91
|
+
let inFence = false;
|
|
92
|
+
for (const line of body.split('\n')) {
|
|
93
|
+
if (FENCE.test(line)) inFence = !inFence;
|
|
94
|
+
const heading = !inFence && STORY_HEADING.exec(line);
|
|
95
|
+
if (heading && (!depth || heading[1].length === depth) && current.length > 0) {
|
|
96
|
+
blocks.push(current.join('\n'));
|
|
97
|
+
current = [];
|
|
98
|
+
}
|
|
99
|
+
current.push(line);
|
|
100
|
+
}
|
|
101
|
+
blocks.push(current.join('\n'));
|
|
102
|
+
return blocks;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The old single-issue shape carried its stories under a "To implement" heading. Lifts that section
|
|
106
|
+
// out of the report and returns its subsections as stories, so a report written the old way still
|
|
107
|
+
// becomes a report plus stories rather than one issue refine refuses.
|
|
108
|
+
function extractStorySection(body) {
|
|
109
|
+
const lines = body.split('\n');
|
|
110
|
+
let start = -1;
|
|
111
|
+
let level = 0;
|
|
112
|
+
let inFence = false;
|
|
113
|
+
for (const [index, line] of lines.entries()) {
|
|
114
|
+
if (FENCE.test(line)) inFence = !inFence;
|
|
115
|
+
if (inFence) continue;
|
|
116
|
+
const match = STORY_SECTION.exec(line);
|
|
117
|
+
if (match) {
|
|
118
|
+
start = index;
|
|
119
|
+
level = match[1].length;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (start === -1) return { report: body, stories: [] };
|
|
124
|
+
|
|
125
|
+
let end = lines.length;
|
|
126
|
+
inFence = false;
|
|
127
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
128
|
+
if (FENCE.test(lines[index])) inFence = !inFence;
|
|
129
|
+
const heading = !inFence && STORY_HEADING.exec(lines[index]);
|
|
130
|
+
if (heading && heading[1].length <= level) {
|
|
131
|
+
end = index;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Stories are the shallowest headings inside the section; deeper ones belong to a story.
|
|
137
|
+
const section = lines.slice(start + 1, end).join('\n');
|
|
138
|
+
let fenced = false;
|
|
139
|
+
const depths = section.split('\n')
|
|
140
|
+
.map((line) => ((fenced = FENCE.test(line) ? !fenced : fenced) ? null : STORY_HEADING.exec(line)))
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.map((match) => match[1].length);
|
|
143
|
+
const depth = depths.length > 0 ? Math.min(...depths) : 0;
|
|
144
|
+
const stories = splitAtHeadings(section, depth)
|
|
145
|
+
.map((block) => {
|
|
146
|
+
const [first, ...rest] = block.split('\n');
|
|
147
|
+
const heading = STORY_HEADING.exec(first);
|
|
148
|
+
if (!heading || heading[1].length !== depth) return null;
|
|
149
|
+
const title = heading[2].replace(/^(story\s+\w+|\d+[.)])\s*[—–:-]\s*/i, '');
|
|
150
|
+
return { title, body: rest.join('\n').trim() };
|
|
151
|
+
})
|
|
152
|
+
.filter((story) => story && story.title && story.body);
|
|
153
|
+
|
|
154
|
+
const report = [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\s+$/, '');
|
|
155
|
+
return { report, stories };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Shares a budget among blocks so short ones keep everything and long ones split what is left.
|
|
159
|
+
function shareBudget(sizes, total) {
|
|
160
|
+
const shares = new Array(sizes.length).fill(0);
|
|
161
|
+
let remaining = Math.max(0, total);
|
|
162
|
+
const order = sizes.map((size, index) => ({ size, index })).sort((a, b) => a.size - b.size);
|
|
163
|
+
order.forEach(({ size, index }, position) => {
|
|
164
|
+
const fair = Math.floor(remaining / (order.length - position));
|
|
165
|
+
shares[index] = Math.min(size, fair);
|
|
166
|
+
remaining -= shares[index];
|
|
167
|
+
});
|
|
168
|
+
return shares;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fits the report by trimming every finding to a share of the budget, so all of them stay listed
|
|
172
|
+
// with their heading and first lines rather than the last ones vanishing.
|
|
173
|
+
function fitReport(title, body, suffix, limits, runUrl) {
|
|
174
|
+
const fits = (text) => !sizeVerdict(measureIssue(title, `${text}${suffix}`), limits).tooBig;
|
|
175
|
+
if (fits(body)) return { body, trimmed: false };
|
|
176
|
+
const note = `\n\n${trimNote(runUrl)}`;
|
|
177
|
+
const blocks = splitAtHeadings(body);
|
|
178
|
+
const overhead = measureIssue(title, `${note}${suffix}`);
|
|
179
|
+
const separators = 2 * Math.max(0, blocks.length - 1);
|
|
180
|
+
const byteShares = shareBudget(blocks.map(bytes), limits.maxBodyChars - overhead.chars - separators - 1);
|
|
181
|
+
const unitShares = shareBudget(blocks.map(units), limits.maxWorkUnits - overhead.units);
|
|
182
|
+
const compact = blocks.map((block, index) => trimLines(block, { bytes: byteShares[index], units: unitShares[index] }).text.trimEnd())
|
|
183
|
+
.filter((block) => block.length > 0)
|
|
184
|
+
.join('\n\n');
|
|
185
|
+
const trimmedBody = `${compact}${note}`;
|
|
186
|
+
if (fits(trimmedBody)) return { body: trimmedBody, trimmed: true };
|
|
187
|
+
return fitBody(title, compact, suffix, limits, runUrl);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// `items` are the agent's create_issue items. The report is the one whose title matches
|
|
191
|
+
// `reportPattern` ("Audit: ..." by default, "Docs audit: ..." for docs-audit), or the first; the
|
|
192
|
+
// others are stories. Returns bodies that pass `limits` once rendered with any issue
|
|
193
|
+
// number, which is the refine guard's check run before filing instead of after.
|
|
194
|
+
function planAuditIssues(items, options) {
|
|
195
|
+
const { limits, maxStories = 3, runUrl = '', reportPattern = /^audit\b/i } = options;
|
|
196
|
+
const usable = items.filter((item) => item && String(item.title ?? '').trim());
|
|
197
|
+
if (usable.length === 0) return null;
|
|
198
|
+
|
|
199
|
+
const reportIndex = Math.max(0, usable.findIndex((item) => reportPattern.test(String(item.title).trim())));
|
|
200
|
+
const reportItem = usable[reportIndex];
|
|
201
|
+
const lifted = extractStorySection(String(reportItem.body ?? ''));
|
|
202
|
+
const separate = usable.filter((_, index) => index !== reportIndex)
|
|
203
|
+
.map((item) => ({ title: item.title, body: String(item.body ?? '').trim() }));
|
|
204
|
+
|
|
205
|
+
// Separate stories win; the lifted section is only used when the agent filed the old shape.
|
|
206
|
+
const candidates = (separate.length > 0 ? separate : lifted.stories)
|
|
207
|
+
.map((story) => ({ title: cleanTitle(story.title), body: story.body }))
|
|
208
|
+
.filter((story) => story.title && story.body);
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const unique = candidates.filter((story) => {
|
|
211
|
+
const key = titleKey(story.title);
|
|
212
|
+
if (seen.has(key)) return false;
|
|
213
|
+
seen.add(key);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
const dropped = unique.slice(maxStories).map((story) => story.title);
|
|
217
|
+
|
|
218
|
+
const stories = unique.slice(0, maxStories).map((story) => {
|
|
219
|
+
const suffix = renderStory('', PLACEHOLDER_NUMBER);
|
|
220
|
+
const fitted = fitBody(story.title, story.body, suffix, limits, runUrl);
|
|
221
|
+
return { title: story.title, body: fitted.body, trimmed: fitted.trimmed };
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const reportTitle = cleanTitle(reportItem.title);
|
|
225
|
+
const links = stories.map((story) => ({ number: PLACEHOLDER_NUMBER, title: story.title, tracked: true }));
|
|
226
|
+
const suffix = renderReport('', links);
|
|
227
|
+
const fitted = fitReport(reportTitle, lifted.report.trim(), suffix, limits, runUrl);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
report: { title: reportTitle, body: fitted.body, trimmed: fitted.trimmed, lifted: lifted.stories.length },
|
|
231
|
+
stories,
|
|
232
|
+
dropped,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = {
|
|
237
|
+
PLACEHOLDER_NUMBER,
|
|
238
|
+
extractStorySection,
|
|
239
|
+
planAuditIssues,
|
|
240
|
+
renderReport,
|
|
241
|
+
renderStory,
|
|
242
|
+
reportMarker,
|
|
243
|
+
titleKey,
|
|
244
|
+
trimLines,
|
|
245
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Managed by @plainconceptsplatform/workflows. Source: loops/actions/issue-size.cjs. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Refine's size gate, as one rule both sides read: the refine guard measures an issue with it,
|
|
5
|
+
// and the audit measures every issue it files with it before filing. While the audit had no way to
|
|
6
|
+
// ask, every report it filed from 2026-09-21 on was over the gate and was refused.
|
|
7
|
+
//
|
|
8
|
+
// The numbers match what the guard's shell counted: `wc -c` (UTF-8 bytes) of the title, a blank
|
|
9
|
+
// line and the body with trailing newlines dropped, and one work unit per `-` or `*` bullet line.
|
|
10
|
+
|
|
11
|
+
const BULLET = /^[ \t\r\v\f]*[-*] /;
|
|
12
|
+
|
|
13
|
+
function issueText(title, body) {
|
|
14
|
+
return `${title ?? ''}\n\n${body ?? ''}`.replace(/\n+$/, '');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function measureIssue(title, body) {
|
|
18
|
+
const text = issueText(title, body);
|
|
19
|
+
return {
|
|
20
|
+
chars: Buffer.byteLength(text, 'utf8'),
|
|
21
|
+
units: text.split('\n').filter((line) => BULLET.test(line)).length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sizeVerdict(size, limits) {
|
|
26
|
+
const reasons = [];
|
|
27
|
+
if (size.chars > limits.maxBodyChars) reasons.push(`body ${size.chars} chars > ${limits.maxBodyChars}`);
|
|
28
|
+
if (size.units > limits.maxWorkUnits) reasons.push(`work units ${size.units} > ${limits.maxWorkUnits}`);
|
|
29
|
+
return { tooBig: reasons.length > 0, reason: reasons.join('; ') };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fitsLimits(title, body, limits) {
|
|
33
|
+
return !sizeVerdict(measureIssue(title, body), limits).tooBig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { BULLET, measureIssue, sizeVerdict, fitsLimits };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Managed by @plainconceptsplatform/workflows. Source: loops/actions/measure-issue-size/action.yml. Update with workflows update --force; consumer edits may be overwritten.
|
|
2
|
+
name: Measure issue size
|
|
3
|
+
description: >-
|
|
4
|
+
Measure an issue against refine's size limits with the rule in issue-size.cjs, the same rule the
|
|
5
|
+
audit applies before it files anything.
|
|
6
|
+
inputs:
|
|
7
|
+
token:
|
|
8
|
+
description: GitHub token with issues:read.
|
|
9
|
+
required: true
|
|
10
|
+
issue-number:
|
|
11
|
+
description: The issue to measure.
|
|
12
|
+
required: true
|
|
13
|
+
max-body-chars:
|
|
14
|
+
description: Largest title-plus-body size, in UTF-8 bytes, that is not too big.
|
|
15
|
+
required: true
|
|
16
|
+
max-work-units:
|
|
17
|
+
description: Most bullet lines that are not too big.
|
|
18
|
+
required: true
|
|
19
|
+
outputs:
|
|
20
|
+
too-big:
|
|
21
|
+
description: true when either limit is exceeded.
|
|
22
|
+
value: ${{ steps.measure.outputs.too-big }}
|
|
23
|
+
reason:
|
|
24
|
+
description: Which limits were exceeded, empty when none.
|
|
25
|
+
value: ${{ steps.measure.outputs.reason }}
|
|
26
|
+
runs:
|
|
27
|
+
using: composite
|
|
28
|
+
steps:
|
|
29
|
+
- name: Measure the issue
|
|
30
|
+
id: measure
|
|
31
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
32
|
+
env:
|
|
33
|
+
ISSUE_SIZE_LIB: ${{ github.action_path }}/../issue-size.cjs
|
|
34
|
+
ISSUE_NUMBER: ${{ inputs.issue-number }}
|
|
35
|
+
MAX_BODY_CHARS: ${{ inputs.max-body-chars }}
|
|
36
|
+
MAX_WORK_UNITS: ${{ inputs.max-work-units }}
|
|
37
|
+
with:
|
|
38
|
+
github-token: ${{ inputs.token }}
|
|
39
|
+
script: |
|
|
40
|
+
const { measureIssue, sizeVerdict } = require(process.env.ISSUE_SIZE_LIB);
|
|
41
|
+
const { data: issue } = await github.rest.issues.get({ ...context.repo, issue_number: Number(process.env.ISSUE_NUMBER) });
|
|
42
|
+
const limits = { maxBodyChars: Number(process.env.MAX_BODY_CHARS), maxWorkUnits: Number(process.env.MAX_WORK_UNITS) };
|
|
43
|
+
const size = measureIssue(issue.title, issue.body);
|
|
44
|
+
const verdict = sizeVerdict(size, limits);
|
|
45
|
+
core.setOutput('too-big', String(verdict.tooBig));
|
|
46
|
+
core.setOutput('reason', verdict.reason);
|
|
47
|
+
core.notice(`measured ${size.chars} chars, ${size.units} work units (limits: ${limits.maxBodyChars} chars, ${limits.maxWorkUnits} units) -> ${verdict.tooBig}`);
|