@brainervirus/workit-core 8.1.0 → 8.3.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-ship/SKILL.md +14 -14
- package/src/core/task-store.ts +4 -2
- package/src/core/workspaces.ts +84 -38
- package/src/forge/exec.ts +72 -3
- package/src/forge/github.ts +143 -0
- package/src/forge/gitlab.ts +167 -0
- package/src/forge/pr-edit.ts +291 -0
- package/src/forge/pr-ops.ts +103 -17
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/forge/types.ts +32 -0
- package/src/git/ops.ts +3 -0
- package/src/git/rev.ts +10 -5
- package/src/hooks/handle.ts +33 -7
- package/src/hooks/hosts/claude-code.ts +12 -2
- package/src/hooks/hosts/codex.ts +33 -8
- package/src/hooks/hosts/cursor.ts +10 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -1
- package/src/hooks/raw-git.ts +612 -0
- package/src/hooks/shell-words.ts +128 -0
- package/src/hooks/write-gate.ts +1 -78
- package/src/host-session.ts +53 -0
- package/src/ledger.ts +17 -3
package/src/forge/gitlab.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ForgePrStatus,
|
|
12
12
|
type ForgeResult,
|
|
13
13
|
type ForgeThread,
|
|
14
|
+
type PrMeta,
|
|
14
15
|
type PrState,
|
|
15
16
|
} from "./types";
|
|
16
17
|
|
|
@@ -22,6 +23,8 @@ type GlUser = { username?: string; bot?: boolean } | null;
|
|
|
22
23
|
|
|
23
24
|
type GlMr = {
|
|
24
25
|
iid: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
reviewers?: Array<{ id: number }>;
|
|
25
28
|
web_url: string;
|
|
26
29
|
state: string;
|
|
27
30
|
draft?: boolean;
|
|
@@ -169,6 +172,20 @@ const safeUrl = (value: string | null): string | null => (value ? redactText(val
|
|
|
169
172
|
const isBot = (author: GlUser): boolean =>
|
|
170
173
|
author?.bot === true || /(?:^project_\d+_bot|bot$|\[bot\]$)/iu.test(author?.username ?? "");
|
|
171
174
|
|
|
175
|
+
/** GitLab's draft markers at the start of an MR title. */
|
|
176
|
+
const DRAFT_PREFIX = /^(?:\s*(?:\[draft\]|\(draft\)|draft:)\s*)+/iu;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `title` with GitLab's draft markers (`Draft:`, `[Draft]`, `(Draft)`,
|
|
180
|
+
* repeated) replaced by one `Draft: ` or removed (GitLab keeps draft in the
|
|
181
|
+
* title). Null when nothing but markers is left.
|
|
182
|
+
*/
|
|
183
|
+
const draftTitle = (title: string, draft: boolean): string | null => {
|
|
184
|
+
const bare = title.replace(DRAFT_PREFIX, "").trim();
|
|
185
|
+
if (!bare) return null;
|
|
186
|
+
return draft ? `Draft: ${bare}` : bare;
|
|
187
|
+
};
|
|
188
|
+
|
|
172
189
|
// Statuses where GitLab has not finished computing mergeability.
|
|
173
190
|
const UNSETTLED = new Set(["checking", "unchecked", "preparing", "approvals_syncing"]);
|
|
174
191
|
|
|
@@ -233,6 +250,26 @@ export function createGitLabForge(options: {
|
|
|
233
250
|
return tip.ok && typeof tip.data.commit?.id === "string" ? tip.data.commit.id : null;
|
|
234
251
|
};
|
|
235
252
|
|
|
253
|
+
const getMr = (iid: number): ForgeResult<GlMr> =>
|
|
254
|
+
apiJson<GlMr>(
|
|
255
|
+
runner,
|
|
256
|
+
"glab",
|
|
257
|
+
apiHost,
|
|
258
|
+
api(`${project}/merge_requests/${iid}`),
|
|
259
|
+
`merge request !${iid}`,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const putMr = (iid: number, fields: string[], what: string): ForgeResult<void> => {
|
|
263
|
+
const updated = apiWrite<unknown>(
|
|
264
|
+
runner,
|
|
265
|
+
"glab",
|
|
266
|
+
apiHost,
|
|
267
|
+
["api", "-X", "PUT", `${project}/merge_requests/${iid}`, ...fields],
|
|
268
|
+
`${what} of merge request !${iid}`,
|
|
269
|
+
);
|
|
270
|
+
return updated.ok ? success(undefined) : updated;
|
|
271
|
+
};
|
|
272
|
+
|
|
236
273
|
return {
|
|
237
274
|
kind: "gitlab",
|
|
238
275
|
apiHost,
|
|
@@ -552,5 +589,135 @@ export function createGitLabForge(options: {
|
|
|
552
589
|
);
|
|
553
590
|
return updated.ok ? success(undefined) : updated;
|
|
554
591
|
},
|
|
592
|
+
|
|
593
|
+
prMeta(iid) {
|
|
594
|
+
const mr = getMr(iid);
|
|
595
|
+
if (!mr.ok) return mr;
|
|
596
|
+
const value = mr.data;
|
|
597
|
+
const meta: PrMeta = {
|
|
598
|
+
number: value.iid,
|
|
599
|
+
url: value.web_url,
|
|
600
|
+
state: mrState(value.state),
|
|
601
|
+
draft: value.draft ?? value.work_in_progress ?? false,
|
|
602
|
+
title: value.title ?? "",
|
|
603
|
+
base: value.target_branch,
|
|
604
|
+
headBranch: value.source_branch,
|
|
605
|
+
};
|
|
606
|
+
return success(meta);
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
setDraft(iid, draft) {
|
|
610
|
+
const mr = getMr(iid);
|
|
611
|
+
if (!mr.ok) return mr;
|
|
612
|
+
const title = draftTitle(mr.data.title ?? "", draft);
|
|
613
|
+
if (title === null)
|
|
614
|
+
return failure(
|
|
615
|
+
"invalid_input",
|
|
616
|
+
`merge request !${iid} has no title besides its draft marker`,
|
|
617
|
+
);
|
|
618
|
+
return putMr(iid, ["-f", `title=${title}`], `draft state`);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
editPr(iid, edit) {
|
|
622
|
+
const mr = getMr(iid);
|
|
623
|
+
if (!mr.ok) return mr;
|
|
624
|
+
const fields: string[] = [];
|
|
625
|
+
// A new title keeps the MR's draft state (it lives in the title).
|
|
626
|
+
if (edit.title !== undefined) {
|
|
627
|
+
const title = draftTitle(edit.title, mr.data.draft ?? mr.data.work_in_progress ?? false);
|
|
628
|
+
if (title === null)
|
|
629
|
+
return failure("invalid_input", "--title must have text besides a draft marker");
|
|
630
|
+
fields.push("-f", `title=${title}`);
|
|
631
|
+
}
|
|
632
|
+
if (edit.body !== undefined) fields.push("-f", `description=${edit.body}`);
|
|
633
|
+
if (edit.base !== undefined) fields.push("-f", `target_branch=${edit.base}`);
|
|
634
|
+
if (edit.addLabels.length) fields.push("-f", `add_labels=${edit.addLabels.join(",")}`);
|
|
635
|
+
if (edit.removeLabels.length)
|
|
636
|
+
fields.push("-f", `remove_labels=${edit.removeLabels.join(",")}`);
|
|
637
|
+
if (edit.addReviewers.length) {
|
|
638
|
+
// reviewer_ids replaces the list: keep the current reviewers.
|
|
639
|
+
const ids = new Set((mr.data.reviewers ?? []).map((reviewer) => reviewer.id));
|
|
640
|
+
for (const username of edit.addReviewers) {
|
|
641
|
+
const users = apiJson<Array<{ id?: number; username?: string }>>(
|
|
642
|
+
runner,
|
|
643
|
+
"glab",
|
|
644
|
+
apiHost,
|
|
645
|
+
api(`users?username=${encodeURIComponent(username)}`),
|
|
646
|
+
`user ${username}`,
|
|
647
|
+
);
|
|
648
|
+
if (!users.ok) return users;
|
|
649
|
+
const user = Array.isArray(users.data)
|
|
650
|
+
? users.data.find((entry) => entry.username?.toLowerCase() === username.toLowerCase())
|
|
651
|
+
: undefined;
|
|
652
|
+
if (typeof user?.id !== "number")
|
|
653
|
+
return failure("not_found", `GitLab user ${username} was not found`);
|
|
654
|
+
ids.add(user.id);
|
|
655
|
+
}
|
|
656
|
+
// One JSON array: glab rejects `reviewer_ids[]=` keys in a JSON body.
|
|
657
|
+
fields.push("-F", `reviewer_ids=${JSON.stringify([...ids])}`);
|
|
658
|
+
}
|
|
659
|
+
return fields.length ? putMr(iid, fields, "edit") : success(undefined);
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
replyThread(iid, thread, body) {
|
|
663
|
+
const note = apiWrite<{ id?: number }>(
|
|
664
|
+
runner,
|
|
665
|
+
"glab",
|
|
666
|
+
apiHost,
|
|
667
|
+
[
|
|
668
|
+
"api",
|
|
669
|
+
"-X",
|
|
670
|
+
"POST",
|
|
671
|
+
`${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}/notes`,
|
|
672
|
+
"-f",
|
|
673
|
+
`body=${body}`,
|
|
674
|
+
],
|
|
675
|
+
`reply on merge request !${iid}`,
|
|
676
|
+
);
|
|
677
|
+
if (!note.ok) return note;
|
|
678
|
+
return success({ url: null });
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
resolveThread(iid, thread) {
|
|
682
|
+
const resolved = apiWrite<{ notes?: GlNote[] }>(
|
|
683
|
+
runner,
|
|
684
|
+
"glab",
|
|
685
|
+
apiHost,
|
|
686
|
+
[
|
|
687
|
+
"api",
|
|
688
|
+
"-X",
|
|
689
|
+
"PUT",
|
|
690
|
+
`${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}`,
|
|
691
|
+
"-F",
|
|
692
|
+
"resolved=true",
|
|
693
|
+
],
|
|
694
|
+
`resolve on merge request !${iid}`,
|
|
695
|
+
);
|
|
696
|
+
if (!resolved.ok) return resolved;
|
|
697
|
+
const open = (resolved.data.notes ?? []).some(
|
|
698
|
+
(note) => note.resolvable === true && note.resolved !== true,
|
|
699
|
+
);
|
|
700
|
+
return open
|
|
701
|
+
? failure("failed", `discussion ${thread} on merge request !${iid} is still unresolved`)
|
|
702
|
+
: success(undefined);
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
threadState(iid, thread) {
|
|
706
|
+
const discussion = apiJson<GlDiscussion>(
|
|
707
|
+
runner,
|
|
708
|
+
"glab",
|
|
709
|
+
apiHost,
|
|
710
|
+
api(`${project}/merge_requests/${iid}/discussions/${encodeURIComponent(thread)}`),
|
|
711
|
+
`discussion ${thread}`,
|
|
712
|
+
);
|
|
713
|
+
if (!discussion.ok) return discussion.code === "not_found" ? success(null) : discussion;
|
|
714
|
+
const notes = Array.isArray(discussion.data.notes) ? discussion.data.notes : [];
|
|
715
|
+
if (!notes.some((note) => note.resolvable === true)) return success(null);
|
|
716
|
+
return success(
|
|
717
|
+
notes.some((note) => note.resolvable === true && note.resolved !== true)
|
|
718
|
+
? "open"
|
|
719
|
+
: "resolved",
|
|
720
|
+
);
|
|
721
|
+
},
|
|
555
722
|
};
|
|
556
723
|
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// `workit pr ready|edit|threads|reply` (audit M13): the PR lifecycle steps
|
|
2
|
+
// `pr status` prescribes, so an agent never reaches for gh/glab directly.
|
|
3
|
+
//
|
|
4
|
+
// Every write needs the `pr` grant and an open PR/MR. `edit --base` retargets
|
|
5
|
+
// only to the branch's default target or to a branch the workspace policy does
|
|
6
|
+
// not protect, so a feature PR is never pointed at a production branch by an
|
|
7
|
+
// edit. `reply` and `resolve` act only on a thread `pr status` lists as
|
|
8
|
+
// unresolved on that PR, so a stray id never lands on another PR.
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { requireGrant } from "../autonomy";
|
|
11
|
+
import { isProtectedTarget } from "../core/branch";
|
|
12
|
+
import { recordedBaseKey, workspaceReleaseTracks } from "../core/release-tracks";
|
|
13
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
14
|
+
import { GIT_TIMEOUTS } from "../git/rev";
|
|
15
|
+
import { listStacks } from "../stack";
|
|
16
|
+
import { checkNames } from "./pr-ops";
|
|
17
|
+
import { selectPr } from "./report";
|
|
18
|
+
import type { ResolvedForge } from "./resolve";
|
|
19
|
+
import {
|
|
20
|
+
failure,
|
|
21
|
+
success,
|
|
22
|
+
type ForgeResult,
|
|
23
|
+
type ForgeThread,
|
|
24
|
+
type PrEdit,
|
|
25
|
+
type PrMeta,
|
|
26
|
+
} from "./types";
|
|
27
|
+
|
|
28
|
+
const label = (resolved: ResolvedForge, number: number): string =>
|
|
29
|
+
`${resolved.forge.kind === "github" ? "PR #" : "MR !"}${number}`;
|
|
30
|
+
|
|
31
|
+
/** The grant, the PR number and its metadata; refuses a closed or merged PR. */
|
|
32
|
+
function openPr(cwd: string, resolved: ResolvedForge, pr: number | null): ForgeResult<PrMeta> {
|
|
33
|
+
const grant = requireGrant(cwd, "pr");
|
|
34
|
+
if (!grant.allowed) return failure("blocked", grant.error, grant.unblock);
|
|
35
|
+
const number = selectPr(cwd, resolved, { pr });
|
|
36
|
+
if (!number.ok) return number;
|
|
37
|
+
const meta = resolved.forge.prMeta(number.data);
|
|
38
|
+
if (!meta.ok) return meta;
|
|
39
|
+
if (meta.data.state !== "open")
|
|
40
|
+
return failure(
|
|
41
|
+
"blocked",
|
|
42
|
+
`${meta.data.state}: ${label(resolved, meta.data.number)} is ${meta.data.state}`,
|
|
43
|
+
`workit pr status --pr ${meta.data.number}`,
|
|
44
|
+
);
|
|
45
|
+
return meta;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ReadyOutcome = { number: number; url: string; draft: boolean; changed: boolean };
|
|
49
|
+
|
|
50
|
+
/** Mark a draft ready for review, or (`undo`) convert it back to a draft; verified after. */
|
|
51
|
+
export function readyPullRequest(
|
|
52
|
+
cwd: string,
|
|
53
|
+
resolved: ResolvedForge,
|
|
54
|
+
input: { pr: number | null; undo: boolean },
|
|
55
|
+
): ForgeResult<ReadyOutcome> {
|
|
56
|
+
const meta = openPr(cwd, resolved, input.pr);
|
|
57
|
+
if (!meta.ok) return meta;
|
|
58
|
+
const { number, url } = meta.data;
|
|
59
|
+
const want = input.undo;
|
|
60
|
+
if (meta.data.draft === want) return success({ number, url, draft: want, changed: false });
|
|
61
|
+
const set = resolved.forge.setDraft(number, want);
|
|
62
|
+
if (!set.ok) return set;
|
|
63
|
+
const after = resolved.forge.prMeta(number);
|
|
64
|
+
if (!after.ok) return after;
|
|
65
|
+
if (after.data.draft !== want)
|
|
66
|
+
return failure(
|
|
67
|
+
"failed",
|
|
68
|
+
`ready_unverified: the forge accepted the change but ${label(resolved, number)} still reads ${after.data.draft ? "draft" : "ready"}`,
|
|
69
|
+
`workit pr status --pr ${number}`,
|
|
70
|
+
);
|
|
71
|
+
return success({ number, url, draft: want, changed: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type EditOutcome = {
|
|
75
|
+
number: number;
|
|
76
|
+
url: string;
|
|
77
|
+
title: string;
|
|
78
|
+
base: string;
|
|
79
|
+
/** What was asked for, by field (title, body, base, labels, reviewers). */
|
|
80
|
+
changed: string[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
84
|
+
const run = spawnSync("git", args, {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
timeout: GIT_TIMEOUTS.local,
|
|
88
|
+
killSignal: "SIGKILL",
|
|
89
|
+
});
|
|
90
|
+
return run.status === 0 ? run.stdout.trim() || null : null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where `pr edit --base` may point the PR: the head branch's default target
|
|
95
|
+
* (from the release track that owns the PR's current base, else the head's
|
|
96
|
+
* own track; an undetermined line refuses), or the head's stack parent —
|
|
97
|
+
* its recorded `workitBase` or its parent entry in a stack. A protected
|
|
98
|
+
* branch other than the default target never qualifies.
|
|
99
|
+
*/
|
|
100
|
+
function checkBase(cwd: string, meta: PrMeta, base: string): ForgeResult<void> {
|
|
101
|
+
const head = meta.headBranch;
|
|
102
|
+
if (!base || /\s/u.test(base) || base.startsWith("refs/") || base.startsWith("-"))
|
|
103
|
+
return failure("invalid_input", `--base must be a plain branch name, not "${base}"`);
|
|
104
|
+
if (base === head) return failure("invalid_input", `${head} cannot be its own base`);
|
|
105
|
+
const read = workspaceReleaseTracks(cwd);
|
|
106
|
+
if (read.error) return failure("blocked", read.error, "fix the workspace's releaseTracks");
|
|
107
|
+
const owners = read.tracks.filter((track) =>
|
|
108
|
+
[
|
|
109
|
+
track.integrationBranch,
|
|
110
|
+
track.productionBranch,
|
|
111
|
+
track.baseBranch,
|
|
112
|
+
track.pullRequestTarget,
|
|
113
|
+
].includes(meta.base),
|
|
114
|
+
);
|
|
115
|
+
const config = vcsConfig("resolve", cwd, {
|
|
116
|
+
branch: head,
|
|
117
|
+
...(owners.length === 1 ? { track: owners[0].name } : {}),
|
|
118
|
+
});
|
|
119
|
+
if (config.ok === false)
|
|
120
|
+
return failure("blocked", String(config.error), "fix ~/.config/workit/vcs.json");
|
|
121
|
+
const blocking = config.releaseTrack?.blocking;
|
|
122
|
+
if (blocking)
|
|
123
|
+
return failure(
|
|
124
|
+
"blocked",
|
|
125
|
+
String(blocking),
|
|
126
|
+
"retarget it on the forge yourself, or fix WORKFLOW_RELEASE_TRACK",
|
|
127
|
+
);
|
|
128
|
+
const target = String(config.defaultTargetBranch ?? "") || null;
|
|
129
|
+
if (base === target) return success(undefined);
|
|
130
|
+
const instead = target
|
|
131
|
+
? `workit pr edit --pr ${meta.number} --base ${target} # or retarget it on the forge yourself`
|
|
132
|
+
: "retarget it on the forge yourself";
|
|
133
|
+
if (isProtectedTarget(cwd, base))
|
|
134
|
+
return failure(
|
|
135
|
+
"blocked",
|
|
136
|
+
`protected_base: ${base} is protected by the workspace branch policy and is not ${head}'s default target${target ? ` (${target})` : ""}; workit never retargets a PR onto it`,
|
|
137
|
+
instead,
|
|
138
|
+
);
|
|
139
|
+
const stacks = listStacks(cwd);
|
|
140
|
+
// Only the branch directly below the head: a descendant or sibling would
|
|
141
|
+
// make a cycle or cross stacks.
|
|
142
|
+
const parents = new Set<string>(
|
|
143
|
+
stacks.ok
|
|
144
|
+
? stacks.data.flatMap((stack) =>
|
|
145
|
+
stack.branches.filter((entry) => entry.branch === head).map((entry) => entry.parent),
|
|
146
|
+
)
|
|
147
|
+
: [],
|
|
148
|
+
);
|
|
149
|
+
const recorded = git(cwd, ["config", "--get", recordedBaseKey(head)]);
|
|
150
|
+
if (base === recorded || parents.has(base)) return success(undefined);
|
|
151
|
+
return failure(
|
|
152
|
+
"blocked",
|
|
153
|
+
`not_stack_parent: ${base} is neither ${head}'s default target${target ? ` (${target})` : ""} nor its stack parent; workit retargets only to those`,
|
|
154
|
+
instead,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function editPullRequest(
|
|
159
|
+
cwd: string,
|
|
160
|
+
resolved: ResolvedForge,
|
|
161
|
+
input: { pr: number | null; edit: PrEdit },
|
|
162
|
+
): ForgeResult<EditOutcome> {
|
|
163
|
+
const { edit } = input;
|
|
164
|
+
const changed = [
|
|
165
|
+
...(edit.title !== undefined ? ["title"] : []),
|
|
166
|
+
...(edit.body !== undefined ? ["body"] : []),
|
|
167
|
+
...(edit.base !== undefined ? ["base"] : []),
|
|
168
|
+
...(edit.addLabels.length || edit.removeLabels.length ? ["labels"] : []),
|
|
169
|
+
...(edit.addReviewers.length ? ["reviewers"] : []),
|
|
170
|
+
];
|
|
171
|
+
if (!changed.length) return failure("invalid_input", "nothing to edit");
|
|
172
|
+
if (edit.title !== undefined && !edit.title.trim())
|
|
173
|
+
return failure("invalid_input", "--title must not be empty");
|
|
174
|
+
const bad = checkNames(
|
|
175
|
+
resolved.forge.kind,
|
|
176
|
+
resolved.forge.repo,
|
|
177
|
+
[...edit.addLabels, ...edit.removeLabels],
|
|
178
|
+
edit.addReviewers,
|
|
179
|
+
);
|
|
180
|
+
if (bad) return failure("invalid_input", bad);
|
|
181
|
+
|
|
182
|
+
const meta = openPr(cwd, resolved, input.pr);
|
|
183
|
+
if (!meta.ok) return meta;
|
|
184
|
+
const { number } = meta.data;
|
|
185
|
+
if (edit.base !== undefined) {
|
|
186
|
+
const allowed = checkBase(cwd, meta.data, edit.base);
|
|
187
|
+
if (!allowed.ok) return allowed;
|
|
188
|
+
}
|
|
189
|
+
const done = resolved.forge.editPr(number, edit);
|
|
190
|
+
if (!done.ok) return done;
|
|
191
|
+
const after = resolved.forge.prMeta(number);
|
|
192
|
+
if (!after.ok) return after;
|
|
193
|
+
if (edit.base !== undefined && after.data.base !== edit.base)
|
|
194
|
+
return failure(
|
|
195
|
+
"failed",
|
|
196
|
+
`edit_unverified: ${label(resolved, number)} still targets ${after.data.base}, not ${edit.base}`,
|
|
197
|
+
`workit pr status --pr ${number}`,
|
|
198
|
+
);
|
|
199
|
+
return success({
|
|
200
|
+
number,
|
|
201
|
+
url: after.data.url,
|
|
202
|
+
title: after.data.title,
|
|
203
|
+
base: after.data.base,
|
|
204
|
+
changed,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export type ThreadsOutcome = {
|
|
209
|
+
number: number;
|
|
210
|
+
url: string;
|
|
211
|
+
threads: ForgeThread[];
|
|
212
|
+
truncated: boolean;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** The PR's unresolved review threads (the ones `pr status` counts). Read-only. */
|
|
216
|
+
export function listThreads(
|
|
217
|
+
cwd: string,
|
|
218
|
+
resolved: ResolvedForge,
|
|
219
|
+
input: { pr: number | null },
|
|
220
|
+
): ForgeResult<ThreadsOutcome> {
|
|
221
|
+
const number = selectPr(cwd, resolved, { pr: input.pr });
|
|
222
|
+
if (!number.ok) return number;
|
|
223
|
+
const status = resolved.forge.prStatus(number.data);
|
|
224
|
+
if (!status.ok) return status;
|
|
225
|
+
return success({
|
|
226
|
+
number: status.data.number,
|
|
227
|
+
url: status.data.url,
|
|
228
|
+
threads: status.data.threads,
|
|
229
|
+
truncated: status.data.truncated,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export type ReplyOutcome = {
|
|
234
|
+
number: number;
|
|
235
|
+
thread: string;
|
|
236
|
+
replied: boolean;
|
|
237
|
+
replyUrl: string | null;
|
|
238
|
+
resolved: boolean;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/** Reply to and/or resolve one unresolved thread of the PR. */
|
|
242
|
+
export function replyToThread(
|
|
243
|
+
cwd: string,
|
|
244
|
+
resolved: ResolvedForge,
|
|
245
|
+
input: { pr: number | null; thread: string; body: string | null; resolve: boolean },
|
|
246
|
+
): ForgeResult<ReplyOutcome> {
|
|
247
|
+
if (input.body === null && !input.resolve)
|
|
248
|
+
return failure("invalid_input", "pass --body-file <f> and/or --resolve");
|
|
249
|
+
if (input.body !== null && !input.body.trim())
|
|
250
|
+
return failure("invalid_input", "the reply body is empty");
|
|
251
|
+
const meta = openPr(cwd, resolved, input.pr);
|
|
252
|
+
if (!meta.ok) return meta;
|
|
253
|
+
const { number } = meta.data;
|
|
254
|
+
const status = resolved.forge.prStatus(number);
|
|
255
|
+
if (!status.ok) return status;
|
|
256
|
+
const listed = status.data.threads.some((thread) => thread.id === input.thread);
|
|
257
|
+
// A list cut at the page cap may miss the thread: ask for it directly.
|
|
258
|
+
const open =
|
|
259
|
+
listed ||
|
|
260
|
+
(status.data.truncated &&
|
|
261
|
+
(() => {
|
|
262
|
+
const state = resolved.forge.threadState(number, input.thread);
|
|
263
|
+
return state.ok && state.data === "open";
|
|
264
|
+
})());
|
|
265
|
+
if (!open)
|
|
266
|
+
return failure(
|
|
267
|
+
"not_found",
|
|
268
|
+
`thread ${input.thread} is not an unresolved thread of ${label(resolved, number)}`,
|
|
269
|
+
`workit pr threads --pr ${number}`,
|
|
270
|
+
);
|
|
271
|
+
let replyUrl: string | null = null;
|
|
272
|
+
if (input.body !== null) {
|
|
273
|
+
const replied = resolved.forge.replyThread(number, input.thread, input.body);
|
|
274
|
+
if (!replied.ok) return replied;
|
|
275
|
+
replyUrl = replied.data.url;
|
|
276
|
+
}
|
|
277
|
+
if (input.resolve) {
|
|
278
|
+
const done = resolved.forge.resolveThread(number, input.thread);
|
|
279
|
+
if (!done.ok)
|
|
280
|
+
return input.body === null
|
|
281
|
+
? done
|
|
282
|
+
: failure(done.code, `replied, but not resolved: ${done.error}`, done.unblock);
|
|
283
|
+
}
|
|
284
|
+
return success({
|
|
285
|
+
number,
|
|
286
|
+
thread: input.thread,
|
|
287
|
+
replied: input.body !== null,
|
|
288
|
+
replyUrl,
|
|
289
|
+
resolved: input.resolve,
|
|
290
|
+
});
|
|
291
|
+
}
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -59,6 +59,9 @@ export type CreateInput = {
|
|
|
59
59
|
body: string;
|
|
60
60
|
draft: boolean;
|
|
61
61
|
actor: LedgerActor;
|
|
62
|
+
/** Applied after the PR is opened or found (`--label`, `--reviewer`). */
|
|
63
|
+
labels?: string[];
|
|
64
|
+
reviewers?: string[];
|
|
62
65
|
};
|
|
63
66
|
|
|
64
67
|
export type CreateOutcome = {
|
|
@@ -86,10 +89,14 @@ const createNext = (cwd: string): string => {
|
|
|
86
89
|
return `a non-author verifies the head (workit-review); the endpoint is ${endpoint}, so ${AFTER_REVIEW[endpoint]}`;
|
|
87
90
|
};
|
|
88
91
|
|
|
92
|
+
/** Workit's own commit trailer: provenance for the ledger, not PR prose. */
|
|
93
|
+
const SESSION_TRAILER = /^Workit-Session:.*$/gimu;
|
|
94
|
+
|
|
89
95
|
/**
|
|
90
96
|
* `--fill`: the title and body from the branch's commits on top of the base
|
|
91
97
|
* (fetched first). One commit gives its subject and body; several give the
|
|
92
|
-
* oldest subject and
|
|
98
|
+
* oldest subject and every subject with its body, like `gh pr create
|
|
99
|
+
* --fill-verbose`. `Workit-Session:` trailers are left out of the body.
|
|
93
100
|
*/
|
|
94
101
|
export function fillFromCommits(
|
|
95
102
|
cwd: string,
|
|
@@ -122,7 +129,7 @@ export function fillFromCommits(
|
|
|
122
129
|
.filter(Boolean)
|
|
123
130
|
.map((entry) => {
|
|
124
131
|
const [subject = "", body = ""] = entry.split("\x1f");
|
|
125
|
-
return { subject: subject.trim(), body: body.trim() };
|
|
132
|
+
return { subject: subject.trim(), body: body.replace(SESSION_TRAILER, "").trim() };
|
|
126
133
|
});
|
|
127
134
|
if (commits.length === 0)
|
|
128
135
|
return failure(
|
|
@@ -133,10 +140,49 @@ export function fillFromCommits(
|
|
|
133
140
|
if (commits.length === 1) return success({ title: commits[0].subject, body: commits[0].body });
|
|
134
141
|
return success({
|
|
135
142
|
title: commits[0].subject,
|
|
136
|
-
body: commits
|
|
143
|
+
body: commits
|
|
144
|
+
.map((commit) =>
|
|
145
|
+
[`- ${commit.subject}`, ...(commit.body ? ["", indent(commit.body), ""] : [])].join("\n"),
|
|
146
|
+
)
|
|
147
|
+
.join("\n")
|
|
148
|
+
.trim(),
|
|
137
149
|
});
|
|
138
150
|
}
|
|
139
151
|
|
|
152
|
+
const indent = (text: string): string =>
|
|
153
|
+
text
|
|
154
|
+
.split("\n")
|
|
155
|
+
.map((line) => (line ? ` ${line}` : line))
|
|
156
|
+
.join("\n");
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Labels and reviewers a PR write may send, checked before any forge call
|
|
160
|
+
* (so `pr create --label/--reviewer` never fails after the PR is open):
|
|
161
|
+
* comma-free names (GitLab joins labels with commas), GitLab reviewers are
|
|
162
|
+
* usernames, and a GitHub `org/team` belongs to the repository's owner.
|
|
163
|
+
*/
|
|
164
|
+
export function checkNames(
|
|
165
|
+
kind: "github" | "gitlab",
|
|
166
|
+
repo: string,
|
|
167
|
+
labels: readonly string[],
|
|
168
|
+
reviewers: readonly string[],
|
|
169
|
+
): string | null {
|
|
170
|
+
const bad = [...labels, ...reviewers].find(
|
|
171
|
+
(value) => !value.trim() || value.includes(",") || value !== value.trim(),
|
|
172
|
+
);
|
|
173
|
+
if (bad !== undefined) return `invalid label or reviewer "${bad}"`;
|
|
174
|
+
const owner = repo.split("/")[0]?.toLowerCase() ?? "";
|
|
175
|
+
for (const reviewer of reviewers) {
|
|
176
|
+
if (!reviewer.includes("/")) continue;
|
|
177
|
+
if (kind === "gitlab") return `GitLab reviewers are usernames, not teams ("${reviewer}")`;
|
|
178
|
+
const [org = "", team = "", ...rest] = reviewer.split("/");
|
|
179
|
+
if (!team || rest.length) return `invalid team reviewer "${reviewer}" (use <org>/<team>)`;
|
|
180
|
+
if (org.toLowerCase() !== owner)
|
|
181
|
+
return `team ${reviewer} is not in ${owner}; GitHub only requests teams of the repository's owner`;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
140
186
|
/** The pushed head of the current branch, bound before any forge write. */
|
|
141
187
|
export function boundSource(
|
|
142
188
|
cwd: string,
|
|
@@ -193,6 +239,13 @@ export async function createPullRequest(
|
|
|
193
239
|
input: CreateInput,
|
|
194
240
|
sleep: Sleep,
|
|
195
241
|
): Promise<ForgeResult<CreateOutcome>> {
|
|
242
|
+
const names = checkNames(
|
|
243
|
+
resolved.forge.kind,
|
|
244
|
+
resolved.forge.repo,
|
|
245
|
+
input.labels ?? [],
|
|
246
|
+
input.reviewers ?? [],
|
|
247
|
+
);
|
|
248
|
+
if (names) return failure("invalid_input", names);
|
|
196
249
|
const grant = requireGrant(cwd, "pr");
|
|
197
250
|
if (!grant.allowed) return grantBlocked(grant);
|
|
198
251
|
const source = boundSource(cwd, resolved);
|
|
@@ -242,6 +295,21 @@ export async function createPullRequest(
|
|
|
242
295
|
repo: resolved.forge.repo,
|
|
243
296
|
created,
|
|
244
297
|
});
|
|
298
|
+
const labels = input.labels ?? [];
|
|
299
|
+
const reviewers = input.reviewers ?? [];
|
|
300
|
+
if (labels.length || reviewers.length) {
|
|
301
|
+
const edited = resolved.forge.editPr(ref.number, {
|
|
302
|
+
addLabels: labels,
|
|
303
|
+
removeLabels: [],
|
|
304
|
+
addReviewers: reviewers,
|
|
305
|
+
});
|
|
306
|
+
if (!edited.ok)
|
|
307
|
+
return failure(
|
|
308
|
+
edited.code,
|
|
309
|
+
`${noun(resolved)} #${ref.number} is open (${ref.url}), but its labels or reviewers were not set: ${edited.error}`,
|
|
310
|
+
`workit pr edit --pr ${ref.number}${labels.map((name) => ` --add-label ${name}`).join("")}${reviewers.map((name) => ` --add-reviewer ${name}`).join("")}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
245
313
|
return success({
|
|
246
314
|
number: ref.number,
|
|
247
315
|
url: ref.url,
|
|
@@ -351,19 +419,37 @@ const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeRes
|
|
|
351
419
|
refusal,
|
|
352
420
|
});
|
|
353
421
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
};
|
|
422
|
+
/**
|
|
423
|
+
* The command (or step) that clears a `next` blocker, for `pr status` and a
|
|
424
|
+
* `pr merge` refusal; null for READY and the terminal states.
|
|
425
|
+
*/
|
|
426
|
+
export function nextHint(next: string, number: number): string | null {
|
|
427
|
+
const pr = `--pr ${number}`;
|
|
428
|
+
switch (next) {
|
|
429
|
+
case "RESOLVE_CONFLICTS":
|
|
430
|
+
return "rebase onto the base, resolve, then workit git push --force-with-lease";
|
|
431
|
+
case "REBASE":
|
|
432
|
+
return "rebase onto the base, then workit git push --force-with-lease";
|
|
433
|
+
case "RESOLVE_THREADS":
|
|
434
|
+
return `workit pr threads ${pr}; fix or answer each: workit pr reply ${pr} --thread <id> --body-file <f> [--resolve]`;
|
|
435
|
+
case "FIX_CI":
|
|
436
|
+
return "fix the failing checks (workit pr status shows the log tails), push, then workit ci wait";
|
|
437
|
+
case "WAITING_CI":
|
|
438
|
+
return "workit ci wait";
|
|
439
|
+
case "ADDRESS_REVIEW":
|
|
440
|
+
return `address the requested changes; answer threads with workit pr reply ${pr} --thread <id> --body-file <f>`;
|
|
441
|
+
case "REVIEW":
|
|
442
|
+
return `get the required review (workit pr edit ${pr} --add-reviewer <login>)`;
|
|
443
|
+
case "MARK_READY":
|
|
444
|
+
return `workit pr ready ${pr}`;
|
|
445
|
+
case "IN_MERGE_QUEUE":
|
|
446
|
+
return "the PR is already in the merge queue";
|
|
447
|
+
case "NOT_MERGEABLE":
|
|
448
|
+
return "workit pr status # the forge reports it cannot merge yet";
|
|
449
|
+
default:
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
367
453
|
|
|
368
454
|
export async function mergePullRequest(
|
|
369
455
|
cwd: string,
|
|
@@ -406,7 +492,7 @@ export async function mergePullRequest(
|
|
|
406
492
|
if (doc.next !== "READY")
|
|
407
493
|
return refuse(
|
|
408
494
|
`not_ready: ${label} is ${doc.next} (${doc.blockers.join(", ") || doc.next.toLowerCase()})`,
|
|
409
|
-
|
|
495
|
+
nextHint(doc.next, doc.number) ?? "workit pr status",
|
|
410
496
|
{ reason: "not_ready", next: doc.next, blockers: doc.blockers },
|
|
411
497
|
);
|
|
412
498
|
|
package/src/forge/report.ts
CHANGED
|
@@ -473,14 +473,15 @@ export function waitVerdict(
|
|
|
473
473
|
return { state: "waiting", reason: "head_mismatch" };
|
|
474
474
|
const checks = doc.checks.state;
|
|
475
475
|
if (checks === "failing") return { state: "failed", reason: "checks_failing" };
|
|
476
|
-
|
|
477
|
-
if (checks
|
|
478
|
-
if (doc.conflicts)
|
|
476
|
+
// Before "pending": CI never runs on a conflicting PR, so waiting cannot help (M8).
|
|
477
|
+
if (doc.conflicts && checks !== "passing")
|
|
479
478
|
return {
|
|
480
479
|
state: "blocked",
|
|
481
480
|
reason: "conflicts",
|
|
482
481
|
unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
|
|
483
482
|
};
|
|
483
|
+
if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
|
|
484
|
+
if (checks === "passing") return { state: "ready", reason: "checks_passing" };
|
|
484
485
|
const ciRequired =
|
|
485
486
|
doc.checks.missingRequired === null ||
|
|
486
487
|
doc.mergeState === "ci_must_pass" ||
|