vouchington-tooling 0.42.0 → 0.44.0
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 +45 -1
- package/dist/cli/commands/fresh-base.d.mts +6 -0
- package/dist/cli/commands/fresh-base.mjs +14 -0
- package/dist/cli/index.mjs +3 -0
- package/dist/cli/parse-fresh-base.d.mts +2 -0
- package/dist/cli/parse-fresh-base.mjs +15 -0
- package/dist/cli/parse.d.mts +5 -0
- package/dist/cli/parse.mjs +3 -0
- package/dist/cli/usage.d.mts +1 -1
- package/dist/cli/usage.mjs +3 -0
- package/dist/fresh-base/classify.d.mts +21 -0
- package/dist/fresh-base/classify.mjs +29 -0
- package/dist/fresh-base/index.d.mts +4 -0
- package/dist/fresh-base/index.mjs +2 -0
- package/dist/fresh-base/run.d.mts +25 -0
- package/dist/fresh-base/run.mjs +66 -0
- package/dist/gh-cli/closing-refs.d.mts +24 -0
- package/dist/gh-cli/closing-refs.mjs +37 -0
- package/dist/gh-cli/index.d.mts +3 -0
- package/dist/gh-cli/index.mjs +2 -0
- package/dist/gh-cli/non-closing-refs.d.mts +11 -0
- package/dist/gh-cli/non-closing-refs.mjs +24 -0
- package/dist/gh-cli/pr-create.d.mts +2 -0
- package/dist/gh-cli/pr-create.mjs +5 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +3 -0
- package/dist/mcp-server/context.d.mts +11 -0
- package/dist/mcp-server/dispatch.d.mts +8 -0
- package/dist/mcp-server/dispatch.mjs +7 -0
- package/dist/mcp-server/handlers-pr.d.mts +4 -0
- package/dist/mcp-server/handlers-pr.mjs +143 -0
- package/dist/mcp-server/harness.test-helpers.d.mts +8 -1
- package/dist/mcp-server/harness.test-helpers.mjs +3 -1
- package/dist/mcp-server/pr-body-file.d.mts +4 -0
- package/dist/mcp-server/pr-body-file.mjs +22 -0
- package/dist/mcp-server/pr-body.d.mts +20 -0
- package/dist/mcp-server/pr-body.mjs +47 -0
- package/dist/mcp-server/pr-defaults.d.mts +16 -0
- package/dist/mcp-server/pr-defaults.mjs +35 -0
- package/dist/mcp-server/pr-gh.d.mts +16 -0
- package/dist/mcp-server/pr-gh.mjs +56 -0
- package/dist/mcp-server/pr-input.d.mts +26 -0
- package/dist/mcp-server/pr-input.mjs +62 -0
- package/dist/mcp-server/pr-journal.d.mts +12 -0
- package/dist/mcp-server/pr-journal.mjs +86 -0
- package/dist/mcp-server/pr-live.d.mts +24 -0
- package/dist/mcp-server/pr-live.mjs +49 -0
- package/dist/mcp-server/pr-tools.test-helpers.d.mts +54 -0
- package/dist/mcp-server/pr-tools.test-helpers.mjs +67 -0
- package/dist/mcp-server/tools-pr.d.mts +2 -0
- package/dist/mcp-server/tools-pr.mjs +69 -0
- package/dist/mcp-server/tools.mjs +2 -0
- package/dist/pull-request-diff/diff-size.d.mts +29 -0
- package/dist/pull-request-diff/diff-size.mjs +35 -0
- package/dist/pull-request-diff/index.d.mts +2 -0
- package/dist/pull-request-diff/index.mjs +1 -0
- package/dist/retrospective-distill/args.d.mts +13 -0
- package/dist/retrospective-distill/args.mjs +27 -0
- package/dist/retrospective-distill/checkpoint-entry.d.mts +2 -0
- package/dist/retrospective-distill/checkpoint-entry.mjs +11 -0
- package/dist/retrospective-distill/fixtures.test-helpers.d.mts +3 -0
- package/dist/retrospective-distill/fixtures.test-helpers.mjs +21 -0
- package/dist/retrospective-distill/flag-args.d.mts +5 -0
- package/dist/retrospective-distill/flag-args.mjs +31 -0
- package/dist/retrospective-distill/index.d.mts +10 -0
- package/dist/retrospective-distill/index.mjs +6 -0
- package/dist/retrospective-distill/partition-records.d.mts +6 -0
- package/dist/retrospective-distill/partition-records.mjs +49 -0
- package/dist/retrospective-distill/retrospective-entry.d.mts +3 -0
- package/dist/retrospective-distill/retrospective-entry.mjs +4 -0
- package/dist/retrospective-distill/run.d.mts +5 -0
- package/dist/retrospective-distill/run.mjs +28 -0
- package/dist/retrospective-distill/session-types.d.mts +17 -0
- package/dist/retrospective-distill/session-types.mjs +1 -0
- package/dist/retrospective-distill/shape.d.mts +29 -0
- package/dist/retrospective-distill/shape.mjs +111 -0
- package/docs/mcp-server.md +80 -6
- package/package.json +18 -3
- package/skills/agent-workflow/SKILL.md +20 -0
- package/skills/agent-workflow/references/four-refs.md +74 -0
- package/skills/blackboard/SKILL.md +21 -1
- package/skills/github-issue/SKILL.md +6 -0
- package/skills/manifest.json +11 -5
- package/skills/postgres-node-performance-tuning/SKILL.md +2 -0
- package/skills/postgres-node-performance-tuning/references/performance-patterns.md +76 -0
- package/skills/postgres-node-performance-tuning/references/reading-plans.md +111 -0
- package/skills/postgres-schema-design/references/relations.md +18 -0
- package/skills/pr-description/SKILL.md +8 -0
- package/skills/retrospective-distill/SKILL.md +67 -4
- package/skills/revisit-followups/SKILL.md +17 -0
- package/skills/stacked-prs/SKILL.md +47 -16
- package/skills/stacked-prs/references/drain.md +168 -0
- package/skills/stacked-prs/references/gh-stack.md +143 -0
|
@@ -5,6 +5,7 @@ import { journalAppend, journalEntries } from "./handlers-journal.mjs";
|
|
|
5
5
|
import { retrospectiveCheck, retrospectiveSave } from "./handlers-retrospective.mjs";
|
|
6
6
|
import { outboxFlush, outboxStatus } from "./handlers-outbox.mjs";
|
|
7
7
|
import { sessionArchive, sessionEnsure, snapshotExport } from "./handlers-session.mjs";
|
|
8
|
+
import { handlePrCreate, handlePrUpdate, handlePrValidate } from "./handlers-pr.mjs";
|
|
8
9
|
import { TOOLS } from "./tools.mjs";
|
|
9
10
|
import { resolveWorktree } from "./worktree.mjs";
|
|
10
11
|
const HANDLERS = {
|
|
@@ -15,6 +16,9 @@ const HANDLERS = {
|
|
|
15
16
|
session_ensure: sessionEnsure,
|
|
16
17
|
retrospective_check: retrospectiveCheck,
|
|
17
18
|
retrospective_save: retrospectiveSave,
|
|
19
|
+
pr_validate: handlePrValidate,
|
|
20
|
+
pr_create: handlePrCreate,
|
|
21
|
+
pr_update: handlePrUpdate,
|
|
18
22
|
snapshot_export: snapshotExport,
|
|
19
23
|
session_archive: sessionArchive,
|
|
20
24
|
};
|
|
@@ -55,6 +59,9 @@ export async function callTool(name, rawArguments, environment) {
|
|
|
55
59
|
worktree,
|
|
56
60
|
env: environment.env,
|
|
57
61
|
launchRoot: environment.launchRoot,
|
|
62
|
+
runGit: environment.runGit,
|
|
63
|
+
...(environment.runGh === undefined ? {} : { runGh: environment.runGh }),
|
|
64
|
+
...(environment.device === undefined ? {} : { device: environment.device }),
|
|
58
65
|
...(environment.spawnPolicy === undefined ? {} : { spawnPolicy: environment.spawnPolicy }),
|
|
59
66
|
// The client resolves from this package's install, like the SDK, never from the worktree.
|
|
60
67
|
dependencies: {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { hostname, userInfo } from 'node:os';
|
|
2
|
+
import { validateGitHubBodyLength } from "../gh-cli/body-length.mjs";
|
|
3
|
+
import { createPullRequest } from "../gh-cli/pr-create.mjs";
|
|
4
|
+
import { assertPolicyAllowed } from "./context.mjs";
|
|
5
|
+
import { runValidator } from "./policy-run.mjs";
|
|
6
|
+
import { renderPullRequestBody, worktreeLabel } from "./pr-body.mjs";
|
|
7
|
+
import { pullRequestNumber, withBodyFile } from "./pr-body-file.mjs";
|
|
8
|
+
import { pullRequestDefaults } from "./pr-defaults.mjs";
|
|
9
|
+
import { createGhRunner, ghEnvironment } from "./pr-gh.mjs";
|
|
10
|
+
import { checkTitleLength, positiveInteger, readPullRequestInput, } from "./pr-input.mjs";
|
|
11
|
+
import { preserveShepherdJournal } from "./pr-journal.mjs";
|
|
12
|
+
import { assertJournalUnchanged, credentials, liveBase, livePullRequest, redactCredentials, } from "./pr-live.mjs";
|
|
13
|
+
import { parseWorktreeList } from "./worktree.mjs";
|
|
14
|
+
function gitIn(context) {
|
|
15
|
+
return (args) => context.runGit(['-C', context.worktree, ...args]);
|
|
16
|
+
}
|
|
17
|
+
function ghIn(context) {
|
|
18
|
+
const run = context.runGh ?? createGhRunner(context.env);
|
|
19
|
+
const env = ghEnvironment(context.env);
|
|
20
|
+
return (args) => run(args, { cwd: context.worktree, env });
|
|
21
|
+
}
|
|
22
|
+
async function currentBranch(git) {
|
|
23
|
+
const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
24
|
+
if (branch === 'HEAD')
|
|
25
|
+
throw new Error('the worktree has a detached HEAD: check out a branch');
|
|
26
|
+
return branch;
|
|
27
|
+
}
|
|
28
|
+
async function label(context, git) {
|
|
29
|
+
const listing = await git(['worktree', 'list', '--porcelain', '-z']);
|
|
30
|
+
return worktreeLabel(context.worktree, parseWorktreeList(listing)[0]);
|
|
31
|
+
}
|
|
32
|
+
function readPr(args, required) {
|
|
33
|
+
if (args.pr === undefined && !required)
|
|
34
|
+
return undefined;
|
|
35
|
+
return positiveInteger(args.pr, 'pr');
|
|
36
|
+
}
|
|
37
|
+
function policyFor(context) {
|
|
38
|
+
return {
|
|
39
|
+
worktree: context.worktree,
|
|
40
|
+
env: context.env,
|
|
41
|
+
...(context.spawnPolicy === undefined ? {} : { spawn: context.spawnPolicy }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Renders the body, preserves the live journal for an update, and runs the policy validator. */
|
|
45
|
+
async function prepare(args, context, pr) {
|
|
46
|
+
assertPolicyAllowed(context);
|
|
47
|
+
// No server credential may reach the policy or gh, whatever the caller wrote: every field is
|
|
48
|
+
// redacted before anything is rendered or forwarded.
|
|
49
|
+
const secrets = credentials(context.env);
|
|
50
|
+
const given = redactCredentials(readPullRequestInput(args), secrets);
|
|
51
|
+
checkTitleLength(given.title);
|
|
52
|
+
const git = gitIn(context);
|
|
53
|
+
const head = await currentBranch(git);
|
|
54
|
+
// Branch names are refs passed to gh as is: redacting one would name a branch that does not exist.
|
|
55
|
+
if (secrets.some((secret) => head.includes(secret)))
|
|
56
|
+
throw new Error('the worktree branch name contains a server credential: rename the branch');
|
|
57
|
+
if (secrets.some((secret) => args.base?.includes(secret)))
|
|
58
|
+
throw new Error('base contains a server credential: use another base branch');
|
|
59
|
+
const live = pr === undefined ? undefined : await livePullRequest(ghIn(context), pr);
|
|
60
|
+
if (live !== undefined && live.headRefName !== head)
|
|
61
|
+
throw new Error(`pull request ${pr} has head ${live.headRefName}, not the worktree branch ${head}`);
|
|
62
|
+
const base = live === undefined
|
|
63
|
+
? given.base
|
|
64
|
+
: liveBase(pr, live, redactCredentials(args.base, secrets));
|
|
65
|
+
const input = { ...given, base };
|
|
66
|
+
if (head === base)
|
|
67
|
+
throw new Error(`head branch ${head} must differ from base ${base}`);
|
|
68
|
+
const device = (context.device ?? (() => ({ user: userInfo().username, host: hostname() })))();
|
|
69
|
+
let body = renderPullRequestBody(input, {
|
|
70
|
+
sessionId: context.sessionId,
|
|
71
|
+
...device,
|
|
72
|
+
worktree: await label(context, git),
|
|
73
|
+
});
|
|
74
|
+
if (live !== undefined)
|
|
75
|
+
body = preserveShepherdJournal(body, live.body);
|
|
76
|
+
body = redactCredentials(body, secrets);
|
|
77
|
+
const length = validateGitHubBodyLength(body);
|
|
78
|
+
if (!length.ok)
|
|
79
|
+
throw new Error(`the rendered body has ${length.characterCount} characters; GitHub allows ${length.maxCharacterCount}`);
|
|
80
|
+
const report = await runValidator({
|
|
81
|
+
...policyFor(context),
|
|
82
|
+
export: 'validatePullRequest',
|
|
83
|
+
input: {
|
|
84
|
+
title: input.title,
|
|
85
|
+
body,
|
|
86
|
+
base: input.base,
|
|
87
|
+
head,
|
|
88
|
+
mode: input.mode,
|
|
89
|
+
...(pr === undefined ? {} : { pr }),
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const errors = 'errors' in report ? report.errors : [];
|
|
93
|
+
const warnings = 'warnings' in report ? report.warnings : [];
|
|
94
|
+
return {
|
|
95
|
+
input,
|
|
96
|
+
head,
|
|
97
|
+
body,
|
|
98
|
+
errors,
|
|
99
|
+
warnings,
|
|
100
|
+
...(live === undefined ? {} : { liveBody: live.body }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function rejectErrors(prepared) {
|
|
104
|
+
if (prepared.errors.length)
|
|
105
|
+
throw new Error(`policy rejected the pull request:\n- ${prepared.errors.join('\n- ')}`);
|
|
106
|
+
}
|
|
107
|
+
export const handlePrValidate = async (args, context) => {
|
|
108
|
+
const { body, errors, warnings } = await prepare(args, context, readPr(args, false));
|
|
109
|
+
return { body, errors, warnings };
|
|
110
|
+
};
|
|
111
|
+
export const handlePrCreate = async (args, context) => {
|
|
112
|
+
const prepared = await prepare(args, context, undefined);
|
|
113
|
+
rejectErrors(prepared);
|
|
114
|
+
const { input, head, body } = prepared;
|
|
115
|
+
const defaults = await pullRequestDefaults(policyFor(context), input);
|
|
116
|
+
const url = await withBodyFile(body, (bodyFile) => createPullRequest({ runGh: ghIn(context), runGit: gitIn(context) }, {
|
|
117
|
+
bodyFile,
|
|
118
|
+
draft: true,
|
|
119
|
+
title: input.title,
|
|
120
|
+
base: input.base,
|
|
121
|
+
head,
|
|
122
|
+
labels: defaults.labels,
|
|
123
|
+
...(defaults.milestone === undefined ? {} : { milestone: defaults.milestone }),
|
|
124
|
+
...(defaults.project === undefined ? {} : { projects: [defaults.project] }),
|
|
125
|
+
}));
|
|
126
|
+
return { number: pullRequestNumber(url), url, warnings: prepared.warnings };
|
|
127
|
+
};
|
|
128
|
+
export const handlePrUpdate = async (args, context) => {
|
|
129
|
+
const pr = readPr(args, true);
|
|
130
|
+
const prepared = await prepare(args, context, pr);
|
|
131
|
+
rejectErrors(prepared);
|
|
132
|
+
await assertJournalUnchanged(ghIn(context), pr, prepared.liveBody, prepared.input.base);
|
|
133
|
+
const url = await withBodyFile(prepared.body, async (bodyFile) => (await ghIn(context)([
|
|
134
|
+
'pr',
|
|
135
|
+
'edit',
|
|
136
|
+
String(pr),
|
|
137
|
+
'--title',
|
|
138
|
+
prepared.input.title,
|
|
139
|
+
'--body-file',
|
|
140
|
+
bodyFile,
|
|
141
|
+
])).trim());
|
|
142
|
+
return { number: pr, url, warnings: prepared.warnings };
|
|
143
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { type ToolCallResult } from './dispatch.mts';
|
|
1
|
+
import { type ServerEnvironment, type ToolCallResult } from './dispatch.mts';
|
|
2
2
|
import { type FakeBlackboard, type FakeOptions } from './fake-blackboard.test-helpers.mts';
|
|
3
3
|
import { type RepoFixture } from './git-fixture.test-helpers.mts';
|
|
4
|
+
import type { GhRunner } from './pr-gh.mts';
|
|
4
5
|
import type { PolicySpawner } from './policy-spawn.mts';
|
|
5
6
|
/** A fresh pair of real repositories for every test. */
|
|
6
7
|
export declare function useRepoFixture(): () => RepoFixture;
|
|
@@ -14,6 +15,12 @@ export declare function harness(fixture: RepoFixture, options?: FakeOptions & {
|
|
|
14
15
|
env?: NodeJS.ProcessEnv;
|
|
15
16
|
launchRoot?: string | undefined;
|
|
16
17
|
spawnPolicy?: PolicySpawner;
|
|
18
|
+
runGit?: ServerEnvironment['runGit'];
|
|
19
|
+
runGh?: GhRunner;
|
|
20
|
+
device?: () => {
|
|
21
|
+
user: string;
|
|
22
|
+
host: string;
|
|
23
|
+
};
|
|
17
24
|
}): Harness;
|
|
18
25
|
export declare function textOf(result: ToolCallResult): string;
|
|
19
26
|
export declare function jsonOf(result: ToolCallResult): Record<string, unknown>;
|
|
@@ -22,9 +22,11 @@ export function harness(fixture, options = {}) {
|
|
|
22
22
|
const environment = {
|
|
23
23
|
launchRoot: 'launchRoot' in options ? options.launchRoot : fixture.main,
|
|
24
24
|
env: options.env ?? BLACKBOARD_ENV,
|
|
25
|
-
runGit: runIsolatedGit,
|
|
25
|
+
runGit: options.runGit ?? runIsolatedGit,
|
|
26
26
|
blackboard: fake.dependencies,
|
|
27
27
|
...(options.spawnPolicy === undefined ? {} : { spawnPolicy: options.spawnPolicy }),
|
|
28
|
+
...(options.runGh === undefined ? {} : { runGh: options.runGh }),
|
|
29
|
+
...(options.device === undefined ? {} : { device: options.device }),
|
|
28
30
|
};
|
|
29
31
|
return { fake, call: (name, args) => callTool(name, args, environment) };
|
|
30
32
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Writes the body to a private temp file for the duration of `use`, then removes it. */
|
|
2
|
+
export declare function withBodyFile<T>(body: string, use: (file: string) => Promise<T>): Promise<T>;
|
|
3
|
+
/** The pull request number at the end of the URL `gh pr create` prints. */
|
|
4
|
+
export declare function pullRequestNumber(url: string): number;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Writes the body to a private temp file for the duration of `use`, then removes it. */
|
|
5
|
+
export async function withBodyFile(body, use) {
|
|
6
|
+
const directory = await mkdtemp(join(tmpdir(), 'vouchington-pr-'));
|
|
7
|
+
try {
|
|
8
|
+
const file = join(directory, 'body.md');
|
|
9
|
+
await writeFile(file, body, { mode: 0o600 });
|
|
10
|
+
return await use(file);
|
|
11
|
+
}
|
|
12
|
+
finally {
|
|
13
|
+
await rm(directory, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The pull request number at the end of the URL `gh pr create` prints. */
|
|
17
|
+
export function pullRequestNumber(url) {
|
|
18
|
+
const match = /\/pull\/(\d+)\s*$/.exec(url);
|
|
19
|
+
if (!match)
|
|
20
|
+
throw new Error(`could not read the pull request number from: ${url}`);
|
|
21
|
+
return Number(match[1]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PullRequestInput } from './pr-input.mts';
|
|
2
|
+
export type PullRequestProvenance = {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
/** The operating system user name. */
|
|
5
|
+
user: string;
|
|
6
|
+
/** The host name; lowercased when rendered. */
|
|
7
|
+
host: string;
|
|
8
|
+
/** The checkout label, see {@link worktreeLabel}. */
|
|
9
|
+
worktree: string;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The pull request body: the Summary, Impact, Related issues and Details sections followed by the
|
|
13
|
+
* provenance lines. Pure; the caller supplies the device and worktree facts.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderPullRequestBody(input: PullRequestInput, provenance: PullRequestProvenance): string;
|
|
16
|
+
/**
|
|
17
|
+
* The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
|
|
18
|
+
* worktree, else the directory name.
|
|
19
|
+
*/
|
|
20
|
+
export declare function worktreeLabel(worktree: string, mainWorktree: string | undefined): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
function relatedLine(issue) {
|
|
3
|
+
const verb = issue.relation === 'closes' ? 'Closes' : 'Refs';
|
|
4
|
+
return `- ${verb} #${issue.number} — ${issue.reason}`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The pull request body: the Summary, Impact, Related issues and Details sections followed by the
|
|
8
|
+
* provenance lines. Pure; the caller supplies the device and worktree facts.
|
|
9
|
+
*/
|
|
10
|
+
export function renderPullRequestBody(input, provenance) {
|
|
11
|
+
const related = input.relatedIssues.length ? input.relatedIssues.map(relatedLine) : ['- None'];
|
|
12
|
+
const agent = input.model === undefined ? input.agent : `${input.agent} (${input.model})`;
|
|
13
|
+
return [
|
|
14
|
+
'## Summary',
|
|
15
|
+
'',
|
|
16
|
+
input.summary,
|
|
17
|
+
'',
|
|
18
|
+
'## Impact',
|
|
19
|
+
'',
|
|
20
|
+
input.impact,
|
|
21
|
+
'',
|
|
22
|
+
'## Related issues',
|
|
23
|
+
'',
|
|
24
|
+
...related,
|
|
25
|
+
'',
|
|
26
|
+
'## Details',
|
|
27
|
+
'',
|
|
28
|
+
input.details.join('\n\n'),
|
|
29
|
+
'',
|
|
30
|
+
`Workspace setup: ${input.workspaceSetup}`,
|
|
31
|
+
`Agent: ${agent} session ${provenance.sessionId}`,
|
|
32
|
+
`Device: ${provenance.user}@${provenance.host.toLowerCase()}`,
|
|
33
|
+
`Worktree: ${provenance.worktree}`,
|
|
34
|
+
'',
|
|
35
|
+
].join('\n');
|
|
36
|
+
}
|
|
37
|
+
const MARKER = '/worktrees/';
|
|
38
|
+
/**
|
|
39
|
+
* The worktree label: the path after `/worktrees/` when present, `main` for the repository's main
|
|
40
|
+
* worktree, else the directory name.
|
|
41
|
+
*/
|
|
42
|
+
export function worktreeLabel(worktree, mainWorktree) {
|
|
43
|
+
const marker = worktree.indexOf(MARKER);
|
|
44
|
+
if (marker !== -1)
|
|
45
|
+
return worktree.slice(marker + MARKER.length);
|
|
46
|
+
return worktree === mainWorktree ? 'main' : basename(worktree);
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type PolicyRun } from './policy-run.mts';
|
|
2
|
+
import type { RelatedIssue } from './pr-input.mts';
|
|
3
|
+
export type PullRequestDefaults = {
|
|
4
|
+
labels: string[];
|
|
5
|
+
milestone?: string;
|
|
6
|
+
project?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
|
|
10
|
+
* checkout declares no policy or the policy lacks the export.
|
|
11
|
+
*/
|
|
12
|
+
export declare function pullRequestDefaults(policy: Omit<PolicyRun, 'export' | 'input'>, input: {
|
|
13
|
+
title: string;
|
|
14
|
+
base: string;
|
|
15
|
+
relatedIssues: RelatedIssue[];
|
|
16
|
+
}): Promise<PullRequestDefaults>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { runPolicyExport } from "./policy-run.mjs";
|
|
2
|
+
function optionalName(value, field) {
|
|
3
|
+
if (value === undefined || value === null)
|
|
4
|
+
return undefined;
|
|
5
|
+
if (typeof value !== 'string' || !value.trim())
|
|
6
|
+
throw new Error(`policy pullRequestDefaults ${field} must be a non-empty string`);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Labels, milestone and project from the policy's `pullRequestDefaults` export; empty when the
|
|
11
|
+
* checkout declares no policy or the policy lacks the export.
|
|
12
|
+
*/
|
|
13
|
+
export async function pullRequestDefaults(policy, input) {
|
|
14
|
+
const { title, base, relatedIssues } = input;
|
|
15
|
+
const outcome = await runPolicyExport({
|
|
16
|
+
...policy,
|
|
17
|
+
export: 'pullRequestDefaults',
|
|
18
|
+
input: { title, base, relatedIssues },
|
|
19
|
+
});
|
|
20
|
+
if (outcome.status !== 'ok')
|
|
21
|
+
return { labels: [] };
|
|
22
|
+
const result = (outcome.result ?? {});
|
|
23
|
+
if (typeof result !== 'object' || Array.isArray(result))
|
|
24
|
+
throw new Error('policy pullRequestDefaults must return an object');
|
|
25
|
+
const labels = result.labels ?? [];
|
|
26
|
+
if (!Array.isArray(labels) || labels.some((label) => typeof label !== 'string' || !label.trim()))
|
|
27
|
+
throw new Error('policy pullRequestDefaults labels must be an array of non-empty strings');
|
|
28
|
+
const milestone = optionalName(result.milestone, 'milestone');
|
|
29
|
+
const project = optionalName(result.project, 'project');
|
|
30
|
+
return {
|
|
31
|
+
labels: labels,
|
|
32
|
+
...(milestone === undefined ? {} : { milestone }),
|
|
33
|
+
...(project === undefined ? {} : { project }),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type GhRunner = (args: string[], options: {
|
|
2
|
+
cwd: string;
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
}) => Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
|
|
7
|
+
* gh runs with the target worktree as cwd, where a relative path could name a checkout file.
|
|
8
|
+
*/
|
|
9
|
+
export declare function locateGh(env: NodeJS.ProcessEnv, fallbacks?: readonly string[]): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* The environment gh runs in: the server's, without the blackboard credentials, and without
|
|
12
|
+
* GH_REPO, which would point gh at a repository other than the validated worktree's.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ghEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
15
|
+
/** A runner that locates gh once, on first use, from the server's environment. */
|
|
16
|
+
export declare function createGhRunner(serverEnv: NodeJS.ProcessEnv, locate?: (env: NodeJS.ProcessEnv) => Promise<string>): GhRunner;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { access, stat } from 'node:fs/promises';
|
|
4
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const FALLBACKS = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh'];
|
|
8
|
+
const MAX_BUFFER_BYTES = 16_000_000;
|
|
9
|
+
/** The longest one gh call may run before it is killed. */
|
|
10
|
+
const GH_TIMEOUT_MS = 120_000;
|
|
11
|
+
async function isExecutableFile(path) {
|
|
12
|
+
try {
|
|
13
|
+
await access(path, constants.X_OK);
|
|
14
|
+
return (await stat(path)).isFile();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `gh` on the server's PATH, else a common absolute location. Relative PATH entries are skipped:
|
|
22
|
+
* gh runs with the target worktree as cwd, where a relative path could name a checkout file.
|
|
23
|
+
*/
|
|
24
|
+
export async function locateGh(env, fallbacks = FALLBACKS) {
|
|
25
|
+
const onPath = (env.PATH ?? '')
|
|
26
|
+
.split(delimiter)
|
|
27
|
+
.filter((directory) => isAbsolute(directory))
|
|
28
|
+
.map((directory) => join(directory, 'gh'));
|
|
29
|
+
for (const candidate of [...onPath, ...fallbacks])
|
|
30
|
+
if (await isExecutableFile(candidate))
|
|
31
|
+
return candidate;
|
|
32
|
+
throw new Error("gh CLI not found on PATH; install gh or add it to the server's PATH");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The environment gh runs in: the server's, without the blackboard credentials, and without
|
|
36
|
+
* GH_REPO, which would point gh at a repository other than the validated worktree's.
|
|
37
|
+
*/
|
|
38
|
+
export function ghEnvironment(env) {
|
|
39
|
+
const { AGENT_BLACKBOARD_URL: _url, AGENT_BLACKBOARD_TOKEN: _token, GH_REPO: _repo, ...rest } = env;
|
|
40
|
+
return rest;
|
|
41
|
+
}
|
|
42
|
+
/** A runner that locates gh once, on first use, from the server's environment. */
|
|
43
|
+
export function createGhRunner(serverEnv, locate = locateGh) {
|
|
44
|
+
let located;
|
|
45
|
+
return async (args, options) => {
|
|
46
|
+
located ??= locate(serverEnv);
|
|
47
|
+
const { stdout } = await execFileAsync(await located, args, {
|
|
48
|
+
cwd: options.cwd,
|
|
49
|
+
env: options.env,
|
|
50
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
51
|
+
timeout: GH_TIMEOUT_MS,
|
|
52
|
+
killSignal: 'SIGKILL',
|
|
53
|
+
});
|
|
54
|
+
return stdout;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Args } from './args.mts';
|
|
2
|
+
declare const PR_MODES: readonly ["interactive", "autonomous"];
|
|
3
|
+
export type RelatedIssue = {
|
|
4
|
+
number: number;
|
|
5
|
+
relation: 'closes' | 'refs';
|
|
6
|
+
reason: string;
|
|
7
|
+
};
|
|
8
|
+
export type PullRequestInput = {
|
|
9
|
+
title: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
impact: string;
|
|
12
|
+
relatedIssues: RelatedIssue[];
|
|
13
|
+
details: string[];
|
|
14
|
+
base: string;
|
|
15
|
+
agent: string;
|
|
16
|
+
model?: string;
|
|
17
|
+
mode: (typeof PR_MODES)[number];
|
|
18
|
+
workspaceSetup: string;
|
|
19
|
+
};
|
|
20
|
+
/** A positive integer argument, such as a pull request number or an issue number. */
|
|
21
|
+
export declare function positiveInteger(value: unknown, label: string): number;
|
|
22
|
+
/** Rechecked after redaction, which can lengthen a title. */
|
|
23
|
+
export declare function checkTitleLength(title: string): void;
|
|
24
|
+
/** Reads and validates the structured pull request fields shared by the three pr_* tools. */
|
|
25
|
+
export declare function readPullRequestInput(args: Args): PullRequestInput;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { optionalString, rejectUnknown, requireObject, requiredChoice, requiredString, requiredStringArray, } from "./args.mjs";
|
|
2
|
+
const PR_MODES = ['interactive', 'autonomous'];
|
|
3
|
+
const RELATIONS = ['closes', 'refs'];
|
|
4
|
+
const MAX_TITLE_CHARACTERS = 256;
|
|
5
|
+
/** A positive integer argument, such as a pull request number or an issue number. */
|
|
6
|
+
export function positiveInteger(value, label) {
|
|
7
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1)
|
|
8
|
+
throw new Error(`${label} must be a positive integer`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function readRelatedIssues(args) {
|
|
12
|
+
const value = args.relatedIssues;
|
|
13
|
+
if (!Array.isArray(value))
|
|
14
|
+
throw new Error('relatedIssues is required and must be an array');
|
|
15
|
+
return value.map((item, index) => {
|
|
16
|
+
const issue = requireObject(item);
|
|
17
|
+
rejectUnknown(issue, ['number', 'relation', 'reason']);
|
|
18
|
+
return {
|
|
19
|
+
number: positiveInteger(issue.number, `relatedIssues[${index}].number`),
|
|
20
|
+
relation: requiredChoice(issue, 'relation', RELATIONS),
|
|
21
|
+
reason: requiredString(issue, 'reason'),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function readDetails(args) {
|
|
26
|
+
const details = requiredStringArray(args, 'details');
|
|
27
|
+
details.forEach((detail, index) => {
|
|
28
|
+
if (!detail.trim())
|
|
29
|
+
throw new Error(`details[${index}] must not be empty`);
|
|
30
|
+
});
|
|
31
|
+
return details;
|
|
32
|
+
}
|
|
33
|
+
/** Rechecked after redaction, which can lengthen a title. */
|
|
34
|
+
export function checkTitleLength(title) {
|
|
35
|
+
if (Array.from(title).length > MAX_TITLE_CHARACTERS)
|
|
36
|
+
throw new Error(`title must be at most ${MAX_TITLE_CHARACTERS} characters`);
|
|
37
|
+
}
|
|
38
|
+
function singleLine(args, key) {
|
|
39
|
+
const value = requiredString(args, key);
|
|
40
|
+
if (/[\r\n]/.test(value))
|
|
41
|
+
throw new Error(`${key} must be a single line`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/** Reads and validates the structured pull request fields shared by the three pr_* tools. */
|
|
45
|
+
export function readPullRequestInput(args) {
|
|
46
|
+
const title = singleLine(args, 'title');
|
|
47
|
+
checkTitleLength(title);
|
|
48
|
+
const model = args.model === undefined ? undefined : singleLine(args, 'model');
|
|
49
|
+
return {
|
|
50
|
+
title,
|
|
51
|
+
summary: requiredString(args, 'summary'),
|
|
52
|
+
impact: requiredString(args, 'impact'),
|
|
53
|
+
relatedIssues: readRelatedIssues(args),
|
|
54
|
+
details: readDetails(args),
|
|
55
|
+
base: optionalString(args, 'base') ?? 'main',
|
|
56
|
+
agent: singleLine(args, 'agent'),
|
|
57
|
+
...(model === undefined ? {} : { model }),
|
|
58
|
+
mode: requiredChoice(args, 'mode', PR_MODES),
|
|
59
|
+
// It renders on its own provenance line; a newline would forge the lines after it.
|
|
60
|
+
workspaceSetup: singleLine(args, 'workspaceSetup'),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
|
|
3
|
+
* "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
|
|
4
|
+
* end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
|
|
5
|
+
* one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findShepherdJournal(body: string): string | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
|
|
10
|
+
* their own, and must not leave a code fence open: the journal would be hidden inside it.
|
|
11
|
+
*/
|
|
12
|
+
export declare function preserveShepherdJournal(rendered: string, live: string): string;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const DETAILS_OPEN = /<details(?:\s[^>]*)?>/gi;
|
|
2
|
+
const DETAILS_CLOSE = /<\/details>/gi;
|
|
3
|
+
const SUMMARY = '<summary>Shepherd Journal</summary>';
|
|
4
|
+
/** An ATX H2 "Shepherd Journal", with optional indent and closing hashes. */
|
|
5
|
+
const LEGACY = /^ {0,3}##[ \t]+Shepherd[ \t]+Journal(?:[ \t]+#+)?[ \t]*$/;
|
|
6
|
+
/** A real ATX H2: `##` then whitespace or the end of the line (so not `###` or `##text`). */
|
|
7
|
+
const H2 = /^ {0,3}##(?:[ \t]|$)/;
|
|
8
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
9
|
+
/** A closing fence carries nothing but whitespace after its marker. */
|
|
10
|
+
const CLOSING_FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
11
|
+
const count = (line, expression) => [...line.matchAll(expression)].length;
|
|
12
|
+
/**
|
|
13
|
+
* The lines with every fenced code block blanked, so journal-shaped examples there never match,
|
|
14
|
+
* and whether the text ends inside a fence that never closed.
|
|
15
|
+
*/
|
|
16
|
+
function outsideFences(lines) {
|
|
17
|
+
let fence;
|
|
18
|
+
const visible = lines.map((line) => {
|
|
19
|
+
if (fence === undefined) {
|
|
20
|
+
const marker = FENCE.exec(line)?.[1];
|
|
21
|
+
if (marker === undefined)
|
|
22
|
+
return line;
|
|
23
|
+
fence = marker;
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
const marker = CLOSING_FENCE.exec(line)?.[1];
|
|
27
|
+
if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length)
|
|
28
|
+
fence = undefined;
|
|
29
|
+
return '';
|
|
30
|
+
});
|
|
31
|
+
return { visible, open: fence !== undefined };
|
|
32
|
+
}
|
|
33
|
+
function detailsStarts(lines) {
|
|
34
|
+
return lines.flatMap((line, index) => line.trim() === SUMMARY && lines[index - 1]?.trim() === '<details>' ? [index] : []);
|
|
35
|
+
}
|
|
36
|
+
/** Depth is tracked on the fence-blanked lines; the block is sliced from the original ones. */
|
|
37
|
+
function detailsBlock(lines, visible, start) {
|
|
38
|
+
let depth = 1;
|
|
39
|
+
for (const [index, line] of visible.entries()) {
|
|
40
|
+
if (index < start)
|
|
41
|
+
continue;
|
|
42
|
+
depth += count(line, DETAILS_OPEN) - count(line, DETAILS_CLOSE);
|
|
43
|
+
if (depth <= 0)
|
|
44
|
+
return lines.slice(start - 1, index + 1);
|
|
45
|
+
}
|
|
46
|
+
// Treating an unclosed journal as absent would let an update overwrite its history.
|
|
47
|
+
throw new Error('the Shepherd Journal <details> block is never closed; repair it by hand');
|
|
48
|
+
}
|
|
49
|
+
function legacySection(lines, visible, start) {
|
|
50
|
+
const next = visible.findIndex((line, index) => index > start && H2.test(line));
|
|
51
|
+
const section = lines.slice(start, next === -1 ? lines.length : next);
|
|
52
|
+
while (section.at(-1)?.trim() === '')
|
|
53
|
+
section.pop();
|
|
54
|
+
return section;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The Shepherd Journal of a pull request body, verbatim: a `<details>` block whose summary is
|
|
58
|
+
* "Shepherd Journal", else a legacy `## Shepherd Journal` section running to the next H2 or the
|
|
59
|
+
* end. Examples inside fenced code blocks are ignored. Undefined when the body has none; more than
|
|
60
|
+
* one candidate, or an unclosed one, is ambiguous and throws rather than guessing.
|
|
61
|
+
*/
|
|
62
|
+
export function findShepherdJournal(body) {
|
|
63
|
+
const lines = body.replaceAll('\r\n', '\n').split('\n');
|
|
64
|
+
const { visible } = outsideFences(lines);
|
|
65
|
+
const details = detailsStarts(visible);
|
|
66
|
+
const legacy = visible.flatMap((line, index) => (LEGACY.test(line) ? [index] : []));
|
|
67
|
+
if (details.length + legacy.length > 1)
|
|
68
|
+
throw new Error('the pull request body has more than one Shepherd Journal; resolve it by hand');
|
|
69
|
+
if (details.length === 1)
|
|
70
|
+
return detailsBlock(lines, visible, details[0]).join('\n');
|
|
71
|
+
return legacy.length === 1 ? legacySection(lines, visible, legacy[0]).join('\n') : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Appends the live body's journal to `rendered`. The rendered fields must not carry a journal of
|
|
75
|
+
* their own, and must not leave a code fence open: the journal would be hidden inside it.
|
|
76
|
+
*/
|
|
77
|
+
export function preserveShepherdJournal(rendered, live) {
|
|
78
|
+
if (findShepherdJournal(rendered) !== undefined)
|
|
79
|
+
throw new Error('the pull request fields contain a Shepherd Journal; the server preserves the live one');
|
|
80
|
+
const journal = findShepherdJournal(live);
|
|
81
|
+
if (journal === undefined)
|
|
82
|
+
return rendered;
|
|
83
|
+
if (outsideFences(rendered.replaceAll('\r\n', '\n').split('\n')).open)
|
|
84
|
+
throw new Error('the pull request fields leave a code fence open; close it');
|
|
85
|
+
return `${rendered.trimEnd()}\n\n${journal}\n`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RunTextCommand } from '../gh-cli/exec.mts';
|
|
2
|
+
export type LivePullRequest = {
|
|
3
|
+
body: string;
|
|
4
|
+
headRefName: string;
|
|
5
|
+
baseRefName: string;
|
|
6
|
+
state: string;
|
|
7
|
+
};
|
|
8
|
+
/** The live pull request as gh reports it, rejected unless it is open. */
|
|
9
|
+
export declare function livePullRequest(gh: RunTextCommand, pr: number): Promise<LivePullRequest>;
|
|
10
|
+
/**
|
|
11
|
+
* The base to validate an existing pull request against: its live base. An explicit `base` that
|
|
12
|
+
* differs is rejected, because `gh pr edit` here changes only the title and body.
|
|
13
|
+
*/
|
|
14
|
+
export declare function liveBase(pr: number, live: LivePullRequest, given: string | undefined): string;
|
|
15
|
+
/**
|
|
16
|
+
* Re-reads the live pull request just before the edit and aborts if its Shepherd Journal or base
|
|
17
|
+
* changed since the body was prepared and validated, so an entry appended in between is never
|
|
18
|
+
* overwritten and a retargeted PR never gets a body validated against its old base.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertJournalUnchanged(gh: RunTextCommand, pr: number, preparedFrom: string, base: string): Promise<void>;
|
|
21
|
+
/** The server's credential values that must never reach the policy or GitHub, longest first. */
|
|
22
|
+
export declare function credentials(env: NodeJS.ProcessEnv): string[];
|
|
23
|
+
/** Replaces every server credential in every string of a JSON-shaped value. */
|
|
24
|
+
export declare function redactCredentials<T>(value: T, secrets: string[]): T;
|