@brainervirus/workit-core 8.10.0 → 8.12.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/package.json +1 -1
- package/skills/workit-fanout/references/brief.md +23 -8
- package/skills/workit-fanout/references/orchestration.md +9 -4
- package/src/autonomy.ts +6 -2
- package/src/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- package/src/core/route-intent.ts +155 -27
- package/src/core/workspaces.ts +46 -3
- package/src/fanout-brief.ts +66 -13
- package/src/forge/exec.ts +8 -2
- package/src/forge/github.ts +27 -6
- package/src/forge/gitlab.ts +29 -5
- package/src/forge/pr-ops.ts +125 -10
- package/src/forge/types.ts +13 -1
- package/src/git/ops.ts +360 -10
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +13 -6
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
package/src/core/route-intent.ts
CHANGED
|
@@ -1,37 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inWorkitWorkspace, rawInvocations } from "../hooks/raw-git";
|
|
5
|
+
import type { ShellDialect } from "../hooks/shell-words";
|
|
1
6
|
import { validateBranchNameFor, type BranchNamePolicyCheck } from "./branch";
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
/** A name a shell cannot settle before running (`$X`, `` `cmd` ``): never judged. */
|
|
9
|
+
const UNKNOWABLE = /[$`*?[\]{}~]/u;
|
|
10
|
+
|
|
11
|
+
const SWITCH_CREATE = new Set(["-c", "--create", "-C", "--force-create", "--orphan"]);
|
|
12
|
+
const CHECKOUT_CREATE = new Set(["-b", "-B", "--orphan"]);
|
|
13
|
+
const WORKTREE_CREATE = new Set(["-b", "-B"]);
|
|
14
|
+
/** `git branch` options that list, delete or configure: no branch is created. */
|
|
15
|
+
const BRANCH_NO_CREATE = new Set(
|
|
16
|
+
["-d", "-D", "--delete", "-l", "--list", "-a", "--all", "-r", "--remotes", "--show-current"]
|
|
17
|
+
.concat(["-v", "-vv", "--verbose", "--contains", "--no-contains", "--merged", "--no-merged"])
|
|
18
|
+
.concat(["--points-at", "--sort", "--format", "--column", "--no-column", "-u"])
|
|
19
|
+
.concat(["--set-upstream-to", "--unset-upstream", "--edit-description", "-i", "--ignore-case"]),
|
|
20
|
+
);
|
|
21
|
+
/** `git branch -m|-c [<old>] <new>`: a rename or copy creates `<new>`. */
|
|
22
|
+
const BRANCH_RENAME = new Set(["-m", "-M", "--move", "-c", "-C", "--copy"]);
|
|
23
|
+
|
|
24
|
+
/** The value of `flag` in `args` (`-c x`, `-cx`, `--create=x`), or null. */
|
|
25
|
+
const flagValue = (args: readonly string[], flags: ReadonlySet<string>): string | null => {
|
|
26
|
+
for (let index = 1; index < args.length; index++) {
|
|
27
|
+
const arg = args[index];
|
|
28
|
+
if (arg === "--") return null;
|
|
29
|
+
if (flags.has(arg)) return args[index + 1] ?? null;
|
|
30
|
+
const eq = arg.indexOf("=");
|
|
31
|
+
if (arg.startsWith("--") && eq > 0 && flags.has(arg.slice(0, eq))) return arg.slice(eq + 1);
|
|
32
|
+
// An attached short value: `-cchore/x`.
|
|
33
|
+
if (!arg.startsWith("--") && arg.length > 2 && flags.has(arg.slice(0, 2))) return arg.slice(2);
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** `git worktree add` options that take a value. */
|
|
39
|
+
const WORKTREE_VALUES = new Set(["--reason"]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `git worktree add [-b|-B <b>] <path> [<commit-ish>]`: the -b name, or, with
|
|
43
|
+
* only a path (no -b, -B, --detach or commit-ish), the branch git names after
|
|
44
|
+
* the path's last component.
|
|
45
|
+
*/
|
|
46
|
+
const worktreeTarget = (args: readonly string[]): string | null => {
|
|
47
|
+
const named = flagValue(args, WORKTREE_CREATE);
|
|
48
|
+
if (named !== null) return named;
|
|
49
|
+
const positionals: string[] = [];
|
|
50
|
+
for (let index = 1; index < args.length; index++) {
|
|
51
|
+
const arg = args[index];
|
|
52
|
+
if (arg === "--detach" || arg === "-d") return null;
|
|
53
|
+
if (WORKTREE_VALUES.has(arg)) index++;
|
|
54
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
55
|
+
}
|
|
56
|
+
if (positionals.length !== 1) return null;
|
|
57
|
+
const base = positionals[0].split(/[\\/]/u).findLast(Boolean) ?? "";
|
|
58
|
+
return base && base !== "." && base !== ".." ? base : null;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The branch `git branch …` creates (plain, rename or copy), or null. */
|
|
62
|
+
const branchCommandTarget = (args: readonly string[]): string | null => {
|
|
63
|
+
let rename = false;
|
|
64
|
+
const positionals: string[] = [];
|
|
65
|
+
for (let index = 1; index < args.length; index++) {
|
|
66
|
+
const arg = args[index];
|
|
67
|
+
if (arg === "--") {
|
|
68
|
+
positionals.push(...args.slice(index + 1));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
const flag = arg.split("=", 1)[0];
|
|
72
|
+
if (BRANCH_NO_CREATE.has(flag)) return null;
|
|
73
|
+
if (BRANCH_RENAME.has(flag)) rename = true;
|
|
74
|
+
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
75
|
+
}
|
|
76
|
+
if (positionals.length < 1 || positionals.length > 2) return null;
|
|
77
|
+
return rename ? (positionals.at(-1) ?? null) : positionals[0];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The branch one git invocation creates: `switch -c|-C|--create <b>`,
|
|
82
|
+
* `checkout -b|-B <b>`, `branch [-m|-c] <b> [start]` and `worktree add -b|-B
|
|
83
|
+
* <b>`, wherever the option stands. `args` start at the subcommand (after
|
|
84
|
+
* git's global options). Null when it creates none.
|
|
85
|
+
*/
|
|
86
|
+
export const gitBranchCreation = (args: readonly string[]): string | null => {
|
|
87
|
+
const [sub, second] = args;
|
|
16
88
|
const target =
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
89
|
+
sub === "switch"
|
|
90
|
+
? flagValue(args, SWITCH_CREATE)
|
|
91
|
+
: sub === "checkout"
|
|
92
|
+
? flagValue(args, CHECKOUT_CREATE)
|
|
93
|
+
: sub === "branch"
|
|
94
|
+
? branchCommandTarget(args)
|
|
95
|
+
: sub === "worktree" && second === "add"
|
|
96
|
+
? worktreeTarget(args.slice(1))
|
|
97
|
+
: null;
|
|
98
|
+
return target && !target.startsWith("-") && !UNKNOWABLE.test(target) ? target : null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type ShellBranchTarget = {
|
|
102
|
+
branch: string;
|
|
103
|
+
/** The directory git runs in, relative to the hook's cwd; undefined when unknowable. */
|
|
104
|
+
dir: string | null | undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every branch a shell command creates, through the raw-git tokenizer: `&&`,
|
|
109
|
+
* `;` and `|` chains, `cd`/`pushd` and `git -C`, env prefixes and wrappers,
|
|
110
|
+
* quoted names and `bash -c` scripts.
|
|
111
|
+
*/
|
|
112
|
+
export const shellBranchTargets = (
|
|
113
|
+
command: string,
|
|
114
|
+
dialect: ShellDialect = "posix",
|
|
115
|
+
): ShellBranchTarget[] => {
|
|
116
|
+
if (!/\bgit\b/u.test(command)) return [];
|
|
117
|
+
const found: ShellBranchTarget[] = [];
|
|
118
|
+
for (const invocation of rawInvocations(command, dialect)) {
|
|
119
|
+
if (invocation.tool !== "git") continue;
|
|
120
|
+
const branch = gitBranchCreation(invocation.args);
|
|
121
|
+
if (branch) found.push({ branch, dir: invocation.dir });
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
29
124
|
};
|
|
30
125
|
|
|
126
|
+
/** The first branch a shell command creates, or null. */
|
|
127
|
+
export const shellBranchTarget = (command: string): string | null =>
|
|
128
|
+
shellBranchTargets(command)[0]?.branch ?? null;
|
|
129
|
+
|
|
130
|
+
/** The branch already exists in `dir`: resetting or checking it out is not a new name. */
|
|
131
|
+
const localBranchExists = (dir: string, branch: string): boolean =>
|
|
132
|
+
spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
|
|
133
|
+
cwd: dir,
|
|
134
|
+
stdio: "ignore",
|
|
135
|
+
timeout: 5_000,
|
|
136
|
+
}).status === 0;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The branch policy verdict for every new branch a shell command creates,
|
|
140
|
+
* each checked in the directory git runs in (a linked worktree resolves to
|
|
141
|
+
* its main checkout's workspace). A command that runs in the hook's cwd is
|
|
142
|
+
* judged there. One that changes directory is judged only when that
|
|
143
|
+
* directory is known, already exists and is in a Workit workspace: a scratch
|
|
144
|
+
* repository (`cd "$(mktemp -d)"`, `git init x && cd x`, a fresh clone) is
|
|
145
|
+
* not the cwd workspace's business. A name that already exists locally is
|
|
146
|
+
* skipped (`git branch -f legacy HEAD`). Null when no new branch is judged;
|
|
147
|
+
* the first violation otherwise.
|
|
148
|
+
*/
|
|
31
149
|
export const shellBranchPolicyViolation = (
|
|
32
150
|
root: string,
|
|
33
151
|
command: string,
|
|
152
|
+
dialect: ShellDialect = "posix",
|
|
34
153
|
): BranchNamePolicyCheck | null => {
|
|
35
|
-
|
|
36
|
-
|
|
154
|
+
let judged = false;
|
|
155
|
+
for (const target of shellBranchTargets(command, dialect)) {
|
|
156
|
+
if (target.dir === undefined) continue;
|
|
157
|
+
const dir = target.dir === null ? root : path.resolve(root, target.dir);
|
|
158
|
+
if (target.dir !== null && (!existsSync(dir) || !inWorkitWorkspace(dir))) continue;
|
|
159
|
+
if (localBranchExists(dir, target.branch)) continue;
|
|
160
|
+
judged = true;
|
|
161
|
+
const check = validateBranchNameFor(dir, target.branch);
|
|
162
|
+
if (!check.ok) return check;
|
|
163
|
+
}
|
|
164
|
+
return judged ? { ok: true } : null;
|
|
37
165
|
};
|
package/src/core/workspaces.ts
CHANGED
|
@@ -143,6 +143,12 @@ const workspaceConfigSchema = z
|
|
|
143
143
|
defaultEndpoint: z.unknown().optional(),
|
|
144
144
|
/** How a normal-risk behavior change is verified (S17); same rules as grants. */
|
|
145
145
|
verification: z.enum(["self", "independent"]).optional(),
|
|
146
|
+
/**
|
|
147
|
+
* Whether commits get the `Workit-Session:` trailer (default true). With
|
|
148
|
+
* `false`, `workit git commit` and the session hook write none and
|
|
149
|
+
* authorship lives in the ledger only (`commit.recorded`).
|
|
150
|
+
*/
|
|
151
|
+
trailers: z.boolean().optional(),
|
|
146
152
|
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
147
153
|
autonomy: z
|
|
148
154
|
.object(
|
|
@@ -633,8 +639,14 @@ const runtimeWorkspaceIndexSchema = z
|
|
|
633
639
|
/** The workspaces.json entry matching cwd, checked only for name/glob (D17). */
|
|
634
640
|
const matchRuntimeWorkspace = (
|
|
635
641
|
cwd: string,
|
|
636
|
-
|
|
637
|
-
|
|
642
|
+
dir: string = configDir(),
|
|
643
|
+
): {
|
|
644
|
+
file: string;
|
|
645
|
+
selected: Record<string, unknown> & { name: string };
|
|
646
|
+
/** Every entry whose glob matches cwd, the selected one included. */
|
|
647
|
+
matches: Array<Record<string, unknown> & { name: string; glob: string }>;
|
|
648
|
+
} | null => {
|
|
649
|
+
const file = path.join(dir, "workspaces.json");
|
|
638
650
|
let raw: string;
|
|
639
651
|
try {
|
|
640
652
|
raw = readFileSync(file, "utf8");
|
|
@@ -666,7 +678,7 @@ const matchRuntimeWorkspace = (
|
|
|
666
678
|
const matches = matchingEntries(cwd, entries ?? []);
|
|
667
679
|
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
668
680
|
const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
|
|
669
|
-
return selected ? { file, selected } : null;
|
|
681
|
+
return selected ? { file, selected, matches } : null;
|
|
670
682
|
};
|
|
671
683
|
|
|
672
684
|
/**
|
|
@@ -683,6 +695,37 @@ export const resolveRuntimeReleaseTracks = (
|
|
|
683
695
|
: null;
|
|
684
696
|
};
|
|
685
697
|
|
|
698
|
+
/**
|
|
699
|
+
* Whether commits in cwd's workspace carry the `Workit-Session:` trailer.
|
|
700
|
+
* The selected workspace's own `trailers` wins; unset, it inherits from the
|
|
701
|
+
* nearest enclosing workspace that also matches cwd (a repo workspace nested
|
|
702
|
+
* in `work`), and defaults to true. Read leniently: no workspace, or an
|
|
703
|
+
* unreadable workspaces.json, keeps the trailer. `from` names the entry the
|
|
704
|
+
* value came from (null for the default).
|
|
705
|
+
*/
|
|
706
|
+
export const workspaceTrailers = (
|
|
707
|
+
cwd: string,
|
|
708
|
+
dir?: string,
|
|
709
|
+
): { enabled: boolean; workspace: string | null; from: string | null } => {
|
|
710
|
+
let matched: ReturnType<typeof matchRuntimeWorkspace>;
|
|
711
|
+
try {
|
|
712
|
+
matched = matchRuntimeWorkspace(cwd, dir);
|
|
713
|
+
} catch {
|
|
714
|
+
return { enabled: true, workspace: null, from: null };
|
|
715
|
+
}
|
|
716
|
+
if (!matched) return { enabled: true, workspace: null, from: null };
|
|
717
|
+
const { selected, matches } = matched;
|
|
718
|
+
const enclosing = matches
|
|
719
|
+
.filter((entry) => entry !== selected)
|
|
720
|
+
.toSorted((a, b) => globSpecificity(b.glob) - globSpecificity(a.glob));
|
|
721
|
+
const source = [selected, ...enclosing].find((entry) => typeof entry.trailers === "boolean");
|
|
722
|
+
return {
|
|
723
|
+
enabled: source?.trailers !== false,
|
|
724
|
+
workspace: selected.name,
|
|
725
|
+
from: source?.name ?? null,
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
|
|
686
729
|
const resolveRuntimeWorkspaceCandidate = (
|
|
687
730
|
cwd: string,
|
|
688
731
|
kind: RuntimeWorkspacePolicy,
|
package/src/fanout-brief.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// `<branch>-try<n>` (refused when another slice has that branch), worktree
|
|
8
8
|
// `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
|
|
9
9
|
// contain `+`, so no attempt collides with another slice's path or session.
|
|
10
|
+
// The lead's host (WORKIT_HOST, or its own session variable) picks the identity
|
|
11
|
+
// and git rules: Claude Code workers use the WORKIT_SESSION_ID in their env (the lead's: SubagentStart issues ids only to judges) and plain
|
|
12
|
+
// git; OpenCode, Pi and Codex workers export nothing (the host derives the id);
|
|
13
|
+
// Cursor, CI and plain terminals export `<lead>-w-<slice>`.
|
|
10
14
|
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
11
15
|
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
12
16
|
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
@@ -36,7 +40,8 @@ export type BriefOutcome = {
|
|
|
36
40
|
tier: string;
|
|
37
41
|
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
38
42
|
scratch: string;
|
|
39
|
-
|
|
43
|
+
/** The id a worker exports, or null when its host issues or derives it. */
|
|
44
|
+
session: string | null;
|
|
40
45
|
standing: Array<{ id: string; what: string }>;
|
|
41
46
|
text: string;
|
|
42
47
|
};
|
|
@@ -49,15 +54,61 @@ const REPORT = [
|
|
|
49
54
|
" anything out of scope as a follow-up, not a diff.",
|
|
50
55
|
];
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
/**
|
|
58
|
+
* How a worker's identity is settled on the lead's host (host-session.ts,
|
|
59
|
+
* docs/agents/hosts.md):
|
|
60
|
+
* - `issued`: Claude Code. SubagentStart issues ids only to verifiers and
|
|
61
|
+
* reviewers, so an implementer keeps the lead's WORKIT_SESSION_ID from its
|
|
62
|
+
* env; worktree isolation refuses every `workit git` form, so it uses
|
|
63
|
+
* plain git and never mints an id;
|
|
64
|
+
* - `derived`: OpenCode, Pi, Codex. The worker's shell carries the host's own
|
|
65
|
+
* session id, which exporting WORKIT_SESSION_ID cannot replace;
|
|
66
|
+
* - `declared`: Cursor, CI, a plain terminal. Nothing vouches for an id, so
|
|
67
|
+
* the brief declares `<lead>-w-<slice>`.
|
|
68
|
+
*/
|
|
69
|
+
export type BriefIdentity = "issued" | "derived" | "declared";
|
|
70
|
+
|
|
71
|
+
const identityOf = (host: string | null): BriefIdentity => {
|
|
72
|
+
const family = host?.replace(/_(?:cli|desktop)$/, "") ?? "";
|
|
73
|
+
if (family === "claude_code") return "issued";
|
|
74
|
+
if (family === "opencode" || family === "pi" || family === "codex") return "derived";
|
|
75
|
+
return "declared";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const identityLines = (identity: BriefIdentity, session: string): string[] => {
|
|
79
|
+
if (identity === "issued")
|
|
80
|
+
return [
|
|
81
|
+
" Session: your env already has WORKIT_SESSION_ID (the lead's id; SubagentStart issues ids only to verifiers and reviewers). Use it as is, in your Workit-Session trailer and workit commands; never export or hand-mint one.",
|
|
82
|
+
];
|
|
83
|
+
if (identity === "derived")
|
|
84
|
+
return [
|
|
85
|
+
" Session: your host already sets WORKIT_SESSION_ID in your shell; export nothing and never hand-mint one.",
|
|
86
|
+
];
|
|
87
|
+
return [` export WORKIT_SESSION_ID=${session}`];
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const rules = (
|
|
91
|
+
identity: BriefIdentity,
|
|
92
|
+
mode: BriefMode,
|
|
93
|
+
branch: string,
|
|
94
|
+
base: string,
|
|
95
|
+
): string[] => {
|
|
96
|
+
const plain = identity === "issued";
|
|
97
|
+
let first = ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`;
|
|
98
|
+
if (mode === "resume")
|
|
99
|
+
first = ` 1. First command: \`git switch ${branch}\`, then continue from its head.`;
|
|
100
|
+
else if (plain)
|
|
101
|
+
first = ` 1. First command: \`git fetch origin && git switch -c ${branch} origin/${base}\` (plain git: worktree isolation refuses every \`workit git\` form).`;
|
|
102
|
+
return [
|
|
103
|
+
"RULES:",
|
|
104
|
+
first,
|
|
105
|
+
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
106
|
+
plain
|
|
107
|
+
? ' 3. Commit with plain `git commit -m <msg> --trailer "Workit-Session: <the WORKIT_SESSION_ID in your env>" --trailer "<your host attribution trailer>" -- <paths in SCOPE>`; push only if this brief says so.'
|
|
108
|
+
: " 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
|
|
109
|
+
" 4. Never record a verdict on your own work.",
|
|
110
|
+
];
|
|
111
|
+
};
|
|
61
112
|
|
|
62
113
|
/** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
|
|
63
114
|
const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
|
|
@@ -119,7 +170,9 @@ export function renderBrief(
|
|
|
119
170
|
id: row.id,
|
|
120
171
|
what: String(row.what),
|
|
121
172
|
}));
|
|
122
|
-
const
|
|
173
|
+
const identity = identityOf(input.actor.host);
|
|
174
|
+
const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
175
|
+
const session = identity === "declared" ? minted : null;
|
|
123
176
|
const fanIn =
|
|
124
177
|
plan.fanIn === "integration"
|
|
125
178
|
? [
|
|
@@ -156,8 +209,8 @@ export function renderBrief(
|
|
|
156
209
|
...(standing.length
|
|
157
210
|
? standing.map((order) => ` - ${order.what}`)
|
|
158
211
|
: [" - none recorded beyond this brief"]),
|
|
159
|
-
|
|
160
|
-
...rules(mode, branch, slice.base),
|
|
212
|
+
...identityLines(identity, minted),
|
|
213
|
+
...rules(identity, mode, branch, slice.base),
|
|
161
214
|
];
|
|
162
215
|
return {
|
|
163
216
|
ok: true,
|
package/src/forge/exec.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type ForgeRunner = (
|
|
|
31
31
|
timeoutMs: number;
|
|
32
32
|
/** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
|
|
33
33
|
token?: string;
|
|
34
|
+
/** Written to stdin (a JSON body for `api --input -`); argv has a size limit. */
|
|
35
|
+
input?: string;
|
|
34
36
|
},
|
|
35
37
|
) => CliRun;
|
|
36
38
|
|
|
@@ -90,7 +92,9 @@ export function systemRunner(
|
|
|
90
92
|
timeout: options.timeoutMs,
|
|
91
93
|
killSignal: "SIGKILL",
|
|
92
94
|
windowsHide: true,
|
|
93
|
-
|
|
95
|
+
...(options.input === undefined
|
|
96
|
+
? { stdio: ["ignore", "pipe", "pipe"] as const }
|
|
97
|
+
: { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
|
|
94
98
|
});
|
|
95
99
|
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
96
100
|
return {
|
|
@@ -248,8 +252,10 @@ export function apiWrite<T>(
|
|
|
248
252
|
args: readonly string[],
|
|
249
253
|
what: string,
|
|
250
254
|
timeoutMs: number = FORGE_TIMEOUTS.api,
|
|
255
|
+
/** A request body sent on stdin (pair it with `--input -`). */
|
|
256
|
+
input?: string,
|
|
251
257
|
): ForgeResult<T> {
|
|
252
|
-
const run = runner(bin, args, { timeoutMs });
|
|
258
|
+
const run = runner(bin, args, input === undefined ? { timeoutMs } : { timeoutMs, input });
|
|
253
259
|
if (run.status !== 0) {
|
|
254
260
|
const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
|
|
255
261
|
const reason = forgeReason(run);
|
package/src/forge/github.ts
CHANGED
|
@@ -27,7 +27,7 @@ const CONTEXTS = `contexts(first: 100, after: $contexts) { pageInfo { hasNextPag
|
|
|
27
27
|
|
|
28
28
|
export const PR_STATUS_QUERY = `query($owner: String!, $name: String!, $number: Int!, $threads: String, $contexts: String) { repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_FIELDS} ${THREADS} commits(last: 1) { nodes { commit { oid statusCheckRollup { state ${CONTEXTS} } } } } } } }`;
|
|
29
29
|
|
|
30
|
-
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
30
|
+
export const FIND_PR_QUERY = `query($owner: String!, $name: String!, $head: String!) { repository(owner: $owner, name: $name) { pullRequests(headRefName: $head, first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { number url state baseRefName headRefName headRefOid headRepositoryOwner { login } } } } }`;
|
|
31
31
|
|
|
32
32
|
const READY_MUTATION = `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
33
33
|
const DRAFT_MUTATION = `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`;
|
|
@@ -351,6 +351,7 @@ export function createGitHubForge(options: {
|
|
|
351
351
|
number: number;
|
|
352
352
|
url: string;
|
|
353
353
|
state: string;
|
|
354
|
+
baseRefName?: string;
|
|
354
355
|
headRefName: string;
|
|
355
356
|
headRefOid: string | null;
|
|
356
357
|
headRepositoryOwner: { login: string } | null;
|
|
@@ -365,11 +366,21 @@ export function createGitHubForge(options: {
|
|
|
365
366
|
const ours = nodes.filter(
|
|
366
367
|
(node) =>
|
|
367
368
|
node.headRefName === head &&
|
|
369
|
+
(!match.base || node.baseRefName === undefined || node.baseRefName === match.base) &&
|
|
368
370
|
((match.owner !== null &&
|
|
369
371
|
node.headRepositoryOwner?.login.toLowerCase() === match.owner.toLowerCase()) ||
|
|
370
372
|
(match.sha !== null && node.headRefOid === match.sha)),
|
|
371
373
|
);
|
|
372
|
-
const
|
|
374
|
+
const open = ours.filter((node) => node.state === "OPEN");
|
|
375
|
+
// No base named and open PRs into several bases (a git-flow release
|
|
376
|
+
// into main and its back-merge into develop): never guess.
|
|
377
|
+
if (!match.base && new Set(open.map((node) => node.baseRefName)).size > 1)
|
|
378
|
+
return failure(
|
|
379
|
+
"blocked",
|
|
380
|
+
`ambiguous_pr: ${head} has open pull requests ${open.map((node) => `#${node.number} -> ${node.baseRefName}`).join(", ")}`,
|
|
381
|
+
`pass --pr <n>, e.g. --pr ${open[0].number}`,
|
|
382
|
+
);
|
|
383
|
+
const pick = open[0] ?? ours[0];
|
|
373
384
|
if (!pick) return success(null);
|
|
374
385
|
const ref: PrRef = {
|
|
375
386
|
number: pick.number,
|
|
@@ -517,6 +528,7 @@ export function createGitHubForge(options: {
|
|
|
517
528
|
},
|
|
518
529
|
|
|
519
530
|
merge(number, mergeOptions) {
|
|
531
|
+
const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
|
|
520
532
|
// sha= is GitHub's atomic head guard: 409 when the head moved.
|
|
521
533
|
const merged = apiWrite<{ sha?: string; merged?: boolean; message?: string }>(
|
|
522
534
|
runner,
|
|
@@ -527,12 +539,21 @@ export function createGitHubForge(options: {
|
|
|
527
539
|
"-X",
|
|
528
540
|
"PUT",
|
|
529
541
|
`repos/${repo}/pulls/${number}/merge`,
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
`merge_method=${mergeOptions.method}`,
|
|
542
|
+
...(squash
|
|
543
|
+
? ["--input", "-"]
|
|
544
|
+
: ["-f", `sha=${mergeOptions.sha}`, "-f", `merge_method=${mergeOptions.method}`]),
|
|
534
545
|
],
|
|
535
546
|
`merge of pull request #${number}`,
|
|
547
|
+
undefined,
|
|
548
|
+
// The squash message rides stdin: argv has a size limit (E2BIG).
|
|
549
|
+
squash
|
|
550
|
+
? JSON.stringify({
|
|
551
|
+
sha: mergeOptions.sha,
|
|
552
|
+
merge_method: mergeOptions.method,
|
|
553
|
+
commit_title: squash.subject,
|
|
554
|
+
commit_message: squash.body,
|
|
555
|
+
})
|
|
556
|
+
: undefined,
|
|
536
557
|
);
|
|
537
558
|
if (!merged.ok) return merged;
|
|
538
559
|
if (merged.data.merged === false)
|
package/src/forge/gitlab.ts
CHANGED
|
@@ -327,10 +327,18 @@ export function createGitLabForge(options: {
|
|
|
327
327
|
const matches = list.data.filter(
|
|
328
328
|
(mr) =>
|
|
329
329
|
mr.source_branch === head &&
|
|
330
|
+
(!match.base || mr.target_branch === undefined || mr.target_branch === match.base) &&
|
|
330
331
|
((match.projectId !== null && mr.source_project_id === match.projectId) ||
|
|
331
332
|
(match.sha !== null && mr.sha === match.sha)),
|
|
332
333
|
);
|
|
333
|
-
const
|
|
334
|
+
const open = matches.filter((mr) => mr.state === "opened");
|
|
335
|
+
if (!match.base && new Set(open.map((mr) => mr.target_branch)).size > 1)
|
|
336
|
+
return failure(
|
|
337
|
+
"blocked",
|
|
338
|
+
`ambiguous_pr: ${head} has open merge requests ${open.map((mr) => `!${mr.iid} -> ${mr.target_branch}`).join(", ")}`,
|
|
339
|
+
`pass --pr <n>, e.g. --pr ${open[0].iid}`,
|
|
340
|
+
);
|
|
341
|
+
const pick = open[0] ?? matches[0];
|
|
334
342
|
return success(
|
|
335
343
|
pick
|
|
336
344
|
? {
|
|
@@ -548,6 +556,7 @@ export function createGitLabForge(options: {
|
|
|
548
556
|
},
|
|
549
557
|
|
|
550
558
|
merge(iid, mergeOptions) {
|
|
559
|
+
const squash = mergeOptions.method === "squash" ? mergeOptions.squashMessage : undefined;
|
|
551
560
|
// GitLab's merge method is a project setting; squash is per request.
|
|
552
561
|
if (mergeOptions.method === "rebase")
|
|
553
562
|
return failure(
|
|
@@ -565,12 +574,27 @@ export function createGitLabForge(options: {
|
|
|
565
574
|
"-X",
|
|
566
575
|
"PUT",
|
|
567
576
|
`${project}/merge_requests/${iid}/merge`,
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
577
|
+
...(squash
|
|
578
|
+
? ["--input", "-"]
|
|
579
|
+
: [
|
|
580
|
+
"-f",
|
|
581
|
+
`sha=${mergeOptions.sha}`,
|
|
582
|
+
"-F",
|
|
583
|
+
`squash=${mergeOptions.method === "squash" ? "true" : "false"}`,
|
|
584
|
+
]),
|
|
572
585
|
],
|
|
573
586
|
`merge of merge request !${iid}`,
|
|
587
|
+
undefined,
|
|
588
|
+
// The squash message rides stdin: argv has a size limit (E2BIG).
|
|
589
|
+
squash
|
|
590
|
+
? JSON.stringify({
|
|
591
|
+
sha: mergeOptions.sha,
|
|
592
|
+
squash: true,
|
|
593
|
+
squash_commit_message: squash.body
|
|
594
|
+
? `${squash.subject}\n\n${squash.body}`
|
|
595
|
+
: squash.subject,
|
|
596
|
+
})
|
|
597
|
+
: undefined,
|
|
574
598
|
);
|
|
575
599
|
if (!merged.ok) return merged;
|
|
576
600
|
const value = merged.data;
|