@intentius/chant 0.124.5 → 0.124.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/gate-resume.d.ts +32 -1
- package/dist/cli/handlers/gate-resume.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +7 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/op/activities/forge-pull-request.d.ts +49 -0
- package/dist/op/activities/forge-pull-request.d.ts.map +1 -0
- package/dist/op/activities/lexicon-upgrade.d.ts +7 -0
- package/dist/op/activities/lexicon-upgrade.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +6 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -1
- package/dist/op/activities/reconcile.d.ts.map +1 -1
- package/dist/op/gate-point-run.d.ts +1 -1
- package/dist/op/gate-point-run.d.ts.map +1 -1
- package/dist/op/gate-point.d.ts +6 -0
- package/dist/op/gate-point.d.ts.map +1 -1
- package/dist/op/gate-resume.d.ts +2 -31
- package/dist/op/gate-resume.d.ts.map +1 -1
- package/dist/op/gate-run-locator.d.ts +40 -0
- package/dist/op/gate-run-locator.d.ts.map +1 -0
- package/dist/point-decision.d.ts +63 -1
- package/dist/point-decision.d.ts.map +1 -1
- package/dist/pr-forge.d.ts +72 -7
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +15 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/gate-resume.ts +84 -11
- package/src/cli/main.ts +4 -2
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/lifecycle/rollback.ts +10 -2
- package/src/op/activities/forge-pull-request.test.ts +91 -0
- package/src/op/activities/forge-pull-request.ts +98 -0
- package/src/op/activities/lexicon-upgrade.test.ts +58 -0
- package/src/op/activities/lexicon-upgrade.ts +34 -8
- package/src/op/activities/propose-upgrade.ts +13 -0
- package/src/op/activities/reconcile.ts +10 -2
- package/src/op/gate-point-ci.e2e.test.ts +39 -2
- package/src/op/gate-point-resume.e2e.test.ts +203 -0
- package/src/op/gate-point-run.ts +3 -0
- package/src/op/gate-point.ts +11 -0
- package/src/op/gate-resume.ts +5 -95
- package/src/op/gate-run-locator.ts +105 -0
- package/src/point-decision-bundle.test.ts +1 -0
- package/src/point-decision.ts +97 -1
- package/src/pr-forge-bundle.test.ts +1 -1
- package/src/pr-forge.test.ts +154 -0
- package/src/pr-forge.ts +179 -12
- package/src/workspace/points-cli.ts +7 -2
- package/src/workspace/records-cli.ts +38 -7
- package/src/workspace/records.schema.json +4 -0
package/src/pr-forge.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* What chant and the products built on it ask of a forge: keep one comment
|
|
3
3
|
* (a merge request note on GitLab) on a pull request up to date, set a commit
|
|
4
4
|
* status, keep one tracking issue in the repository, list who approved a pull
|
|
5
|
-
* request,
|
|
5
|
+
* request, find the pull request that merged a commit, and open or edit a
|
|
6
|
+
* pull request (a merge request on GitLab) for a branch.
|
|
6
7
|
*
|
|
7
8
|
* The pull-request loop (#3183) uses the note, the status and the review
|
|
8
9
|
* calls. `reconcilePr`'s `comment` and `issue` finding modes post through the
|
|
@@ -21,6 +22,10 @@
|
|
|
21
22
|
* `gitlab:<login>`, or `<forge>@<host>:<login>` off the forge's default host.
|
|
22
23
|
* The apply compares that string with the approval's `resolvedBy`.
|
|
23
24
|
*
|
|
25
|
+
* Opening a pull request goes over REST too (#3798): `gh pr` speaks GitHub's
|
|
26
|
+
* GraphQL, which Forgejo does not serve, so chant's own pull-request paths
|
|
27
|
+
* open theirs through {@link PrForge.openPullRequest} on Forgejo and GitLab.
|
|
28
|
+
*
|
|
24
29
|
* Like `./pr-loop.ts`, this bundles without the TypeScript toolchain (#3421):
|
|
25
30
|
* it imports nothing but `node:fs`, to read a CI job's event payload.
|
|
26
31
|
* `./pr-forge-bundle.test.ts` holds it to that.
|
|
@@ -41,7 +46,13 @@ export type ForgeFetch = (
|
|
|
41
46
|
export interface ForgeStatus {
|
|
42
47
|
/** The status's name, such as `chant/plan` or `terragucci/plan`. */
|
|
43
48
|
context: string;
|
|
44
|
-
|
|
49
|
+
/**
|
|
50
|
+
* GitLab's own states, plus GitHub's `failure` and `error`. Each forge gets
|
|
51
|
+
* the nearest state it has ({@link statusStateFor}): `running` is `pending`
|
|
52
|
+
* on GitHub and Forgejo, `canceled` is `error` there, and `failure` and
|
|
53
|
+
* `error` are `failed` on GitLab.
|
|
54
|
+
*/
|
|
55
|
+
state: ForgeStatusState;
|
|
45
56
|
/** Cut to what the forge keeps: 140 characters on GitHub and Forgejo, 255 on GitLab. */
|
|
46
57
|
description: string;
|
|
47
58
|
/** The pipeline run, linked from the status. */
|
|
@@ -50,6 +61,30 @@ export interface ForgeStatus {
|
|
|
50
61
|
pipelineId?: number;
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/** A commit status's state, as a caller names it. */
|
|
65
|
+
export type ForgeStatusState = "pending" | "running" | "success" | "failed" | "failure" | "canceled" | "error";
|
|
66
|
+
|
|
67
|
+
/** Every {@link ForgeStatusState}, for a caller that reads one from its own input. */
|
|
68
|
+
export const FORGE_STATUS_STATES: readonly ForgeStatusState[] = ["pending", "running", "success", "failed", "failure", "canceled", "error"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What each forge is sent for a {@link ForgeStatusState} (#3834). GitHub
|
|
72
|
+
* keeps `pending`, `success`, `failure` and `error`; Forgejo the same and
|
|
73
|
+
* `warning`; GitLab `pending`, `running`, `success`, `failed` and `canceled`.
|
|
74
|
+
* A state the forge lacks becomes its nearest one.
|
|
75
|
+
*/
|
|
76
|
+
const STATUS_STATE: Record<"github" | "gitlab", Record<ForgeStatusState, string>> = {
|
|
77
|
+
github: { pending: "pending", running: "pending", success: "success", failed: "failure", failure: "failure", canceled: "error", error: "error" },
|
|
78
|
+
gitlab: { pending: "pending", running: "running", success: "success", failed: "failed", failure: "failed", canceled: "canceled", error: "failed" },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** The state `kind` is sent for `state`. Forgejo takes GitHub's. */
|
|
82
|
+
export function statusStateFor(kind: ForgeKind, state: ForgeStatusState): string {
|
|
83
|
+
const mapped = STATUS_STATE[kind === "gitlab" ? "gitlab" : "github"][state];
|
|
84
|
+
if (mapped === undefined) throw new Error(`a commit status state is one of ${FORGE_STATUS_STATES.join(", ")}, not ${JSON.stringify(state)}`);
|
|
85
|
+
return mapped;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* How a comment or an issue is told to be the caller's own. By default the
|
|
55
90
|
* body starts with the marker, chant's recipe since #2291, and anyone may
|
|
@@ -59,12 +94,15 @@ export interface MarkerMatch {
|
|
|
59
94
|
/** `prefix` (the default): the body starts with the marker. `anywhere`: the body carries it. */
|
|
60
95
|
match?: "prefix" | "anywhere";
|
|
61
96
|
/**
|
|
62
|
-
* Only a comment or issue written by this login
|
|
63
|
-
* GitHub and Forgejo, `author.username` on
|
|
64
|
-
* A merge request's author can write a note
|
|
65
|
-
* product that reads its own notes back names
|
|
97
|
+
* Only a comment or issue written by this login, or by any of these
|
|
98
|
+
* logins, counts: `user.login` on GitHub and Forgejo, `author.username` on
|
|
99
|
+
* GitLab, compared without case. A merge request's author can write a note
|
|
100
|
+
* carrying any marker, so a product that reads its own notes back names
|
|
101
|
+
* its bot here. A list is for a bot whose login the job cannot know in
|
|
102
|
+
* advance, such as `gitea-actions` or `forgejo-actions` on Forgejo (#3834).
|
|
103
|
+
* An empty list matches nobody.
|
|
66
104
|
*/
|
|
67
|
-
author?: string;
|
|
105
|
+
author?: string | readonly string[];
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/** What {@link PrForge.upsertComment} did. */
|
|
@@ -146,6 +184,34 @@ export interface IssueResult {
|
|
|
146
184
|
created?: string;
|
|
147
185
|
}
|
|
148
186
|
|
|
187
|
+
/** A pull request (merge request) for {@link PrForge.openPullRequest} to open. */
|
|
188
|
+
export interface PullRequestSpec {
|
|
189
|
+
/** The branch with the change, in the same repository. */
|
|
190
|
+
head: string;
|
|
191
|
+
/** The branch it merges into. Default: the repository's default branch, read from the forge. */
|
|
192
|
+
base?: string;
|
|
193
|
+
title: string;
|
|
194
|
+
body: string;
|
|
195
|
+
/**
|
|
196
|
+
* Open it as a draft: GitHub's `draft`, GitLab's `Draft: ` title prefix,
|
|
197
|
+
* Forgejo's `WIP: ` title prefix (it has no draft field).
|
|
198
|
+
*/
|
|
199
|
+
draft?: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A pull request (merge request) as the forge answered it. */
|
|
203
|
+
export interface PullRequest {
|
|
204
|
+
/** The pull request's number, or the merge request's iid. */
|
|
205
|
+
number: number;
|
|
206
|
+
url?: string;
|
|
207
|
+
title: string;
|
|
208
|
+
body: string;
|
|
209
|
+
/** Its head (source) branch. */
|
|
210
|
+
head: string;
|
|
211
|
+
/** Its base (target) branch. */
|
|
212
|
+
base?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
149
215
|
export interface PrForge {
|
|
150
216
|
kind: ForgeKind;
|
|
151
217
|
/** Lower-cased, such as `github.com`. */
|
|
@@ -208,6 +274,22 @@ export interface PrForge {
|
|
|
208
274
|
approvers(pr: number): Promise<string[]>;
|
|
209
275
|
/** The merged pull request that produced `sha`, or null. */
|
|
210
276
|
pullRequestFor(sha: string): Promise<number | null>;
|
|
277
|
+
/**
|
|
278
|
+
* Open a pull request (a merge request on GitLab) from `head` into `base`,
|
|
279
|
+
* or into the default branch when `base` is not given. Over REST on every
|
|
280
|
+
* forge, so it works on Forgejo, where `gh pr create` cannot (#3798).
|
|
281
|
+
*/
|
|
282
|
+
openPullRequest(spec: PullRequestSpec): Promise<PullRequest>;
|
|
283
|
+
/**
|
|
284
|
+
* The OPEN pull request whose head is branch `head` in this repository (a
|
|
285
|
+
* fork's branch of the same name is not it), or undefined. The oldest when
|
|
286
|
+
* there are several.
|
|
287
|
+
*/
|
|
288
|
+
findPullRequest(head: string): Promise<PullRequest | undefined>;
|
|
289
|
+
/** Change an open pull request's title or body. */
|
|
290
|
+
editPullRequest(pr: number, change: { title?: string; body?: string }): Promise<PullRequest | undefined>;
|
|
291
|
+
/** Add labels to a pull request by name. Forgejo needs them to exist; GitHub and GitLab make them. */
|
|
292
|
+
addLabels(pr: number, labels: readonly string[]): Promise<void>;
|
|
211
293
|
}
|
|
212
294
|
|
|
213
295
|
/** A GitHub or Forgejo client. */
|
|
@@ -301,8 +383,15 @@ interface Threads {
|
|
|
301
383
|
const carries = (body: string, marker: string, match: MarkerMatch["match"]): boolean =>
|
|
302
384
|
match === "anywhere" ? body.includes(marker) : body.startsWith(marker);
|
|
303
385
|
|
|
386
|
+
/** Whether `author` is one of `want`, without case. No `want` (or `""`) takes anyone. */
|
|
387
|
+
const authorIn = (want: MarkerMatch["author"], author: string | undefined): boolean => {
|
|
388
|
+
if (want === undefined || want === "") return true;
|
|
389
|
+
const logins = (typeof want === "string" ? [want] : want).map((l) => l.toLowerCase());
|
|
390
|
+
return author !== undefined && logins.includes(author.toLowerCase());
|
|
391
|
+
};
|
|
392
|
+
|
|
304
393
|
const ownedBy = (marker: string, opts: MarkerMatch) => (t: Thread): boolean =>
|
|
305
|
-
carries(t.body, marker, opts.match) && (
|
|
394
|
+
carries(t.body, marker, opts.match) && authorIn(opts.author, t.author);
|
|
306
395
|
|
|
307
396
|
function checkMarker(what: string, marker: string, body: string, match: MarkerMatch["match"]): void {
|
|
308
397
|
if (!marker) throw new Error(`the ${what} needs a marker to be found again`);
|
|
@@ -420,6 +509,21 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
420
509
|
return { id, body: str(v.body) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
421
510
|
};
|
|
422
511
|
const threads = (items: unknown[]): Thread[] => items.map(thread).filter((t): t is Thread => t !== undefined);
|
|
512
|
+
const pull = (v: unknown): PullRequest | undefined => {
|
|
513
|
+
if (!isObject(v)) return undefined;
|
|
514
|
+
const number = num(v.number);
|
|
515
|
+
const head = isObject(v.head) ? str(v.head.ref) : undefined;
|
|
516
|
+
if (number === undefined) return undefined;
|
|
517
|
+
const base = isObject(v.base) ? str(v.base.ref) : undefined;
|
|
518
|
+
const url = str(v.html_url);
|
|
519
|
+
return { number, title: str(v.title) ?? "", body: str(v.body) ?? "", head: head ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
|
|
520
|
+
};
|
|
521
|
+
/** A pull request whose head branch lives in this repository, not a fork. */
|
|
522
|
+
const fromHere = (v: unknown, head: string): boolean => {
|
|
523
|
+
if (!isObject(v) || !isObject(v.head) || v.head.ref !== head) return false;
|
|
524
|
+
const full = isObject(v.head.repo) ? str(v.head.repo.full_name) : undefined;
|
|
525
|
+
return full === undefined || full.toLowerCase() === opts.repo.toLowerCase();
|
|
526
|
+
};
|
|
423
527
|
const t: Threads = {
|
|
424
528
|
comments: async (pr) => threads(await api.list(`${repo}/issues/${pr}/comments`, pageParams, pageSize)),
|
|
425
529
|
addComment: async (pr, body) => thread(await api.call("POST", `${repo}/issues/${pr}/comments`, { body })),
|
|
@@ -445,7 +549,7 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
445
549
|
...threadCalls(t),
|
|
446
550
|
async setStatus(sha, status) {
|
|
447
551
|
await api.call("POST", `${repo}/statuses/${sha}`, {
|
|
448
|
-
state: status.state,
|
|
552
|
+
state: statusStateFor(kind, status.state),
|
|
449
553
|
context: status.context,
|
|
450
554
|
description: status.description.slice(0, GITHUB_DESCRIPTION),
|
|
451
555
|
...(status.url ? { target_url: status.url } : {}),
|
|
@@ -483,9 +587,44 @@ function githubShaped(kind: "github" | "forgejo", opts: GithubForgeOptions): Git
|
|
|
483
587
|
const pick = (exact ?? merged[0]) as Record<string, unknown> | undefined;
|
|
484
588
|
return pick && typeof pick.number === "number" ? pick.number : null;
|
|
485
589
|
},
|
|
590
|
+
async openPullRequest(spec) {
|
|
591
|
+
const base = spec.base ?? (await defaultBranchOf(api, repo, "default_branch"));
|
|
592
|
+
const title = spec.draft && kind === "forgejo" ? `WIP: ${spec.title}` : spec.title;
|
|
593
|
+
const made = await api.call("POST", `${repo}/pulls`, {
|
|
594
|
+
title,
|
|
595
|
+
body: spec.body,
|
|
596
|
+
head: spec.head,
|
|
597
|
+
base,
|
|
598
|
+
...(spec.draft && kind === "github" ? { draft: true } : {}),
|
|
599
|
+
});
|
|
600
|
+
return pull(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
|
|
601
|
+
},
|
|
602
|
+
async findPullRequest(head) {
|
|
603
|
+
// GitHub filters by `owner:branch` server-side; Forgejo's list takes no
|
|
604
|
+
// head filter, so every open page is read and matched here.
|
|
605
|
+
const owner = opts.repo.split("/")[0];
|
|
606
|
+
const query = kind === "github" ? `?state=open&head=${encodeURIComponent(`${owner}:${head}`)}` : "?state=open";
|
|
607
|
+
const open = (await api.list(`${repo}/pulls${query}`, pageParams, pageSize)).filter((p) => fromHere(p, head));
|
|
608
|
+
return open.map(pull).filter((p): p is PullRequest => p !== undefined).sort((a, b) => a.number - b.number)[0];
|
|
609
|
+
},
|
|
610
|
+
async editPullRequest(pr, change) {
|
|
611
|
+
return pull(await api.call("PATCH", `${repo}/pulls/${pr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { body: change.body } : {}) }));
|
|
612
|
+
},
|
|
613
|
+
async addLabels(pr, labels) {
|
|
614
|
+
if (labels.length === 0) return;
|
|
615
|
+
await api.call("POST", `${repo}/issues/${pr}/labels`, { labels: [...labels] });
|
|
616
|
+
},
|
|
486
617
|
};
|
|
487
618
|
}
|
|
488
619
|
|
|
620
|
+
/** The repository's default branch, from `GET` on the repository (`/repos/{repo}`, `/projects/{id}`). */
|
|
621
|
+
async function defaultBranchOf(api: Transport, path: string, field: string): Promise<string> {
|
|
622
|
+
const r = await api.call("GET", path);
|
|
623
|
+
const branch = isObject(r) ? str(r[field]) : undefined;
|
|
624
|
+
if (!branch) throw new Error(`${path} names no default branch to open the pull request against; pass base`);
|
|
625
|
+
return branch;
|
|
626
|
+
}
|
|
627
|
+
|
|
489
628
|
/** A GitHub client (github.com or GitHub Enterprise Server). */
|
|
490
629
|
export function githubForge(opts: GithubForgeOptions): GithubShapedForge {
|
|
491
630
|
return githubShaped("github", opts);
|
|
@@ -515,8 +654,6 @@ export interface GitlabForgeOptions {
|
|
|
515
654
|
fetch?: ForgeFetch;
|
|
516
655
|
}
|
|
517
656
|
|
|
518
|
-
const GITLAB_STATE: Record<ForgeStatus["state"], string> = { pending: "pending", success: "success", failure: "failed" };
|
|
519
|
-
|
|
520
657
|
/**
|
|
521
658
|
* A GitLab client. Writing notes needs an access token with the `api` scope
|
|
522
659
|
* on most instances: the job token reaches only the endpoints the instance's
|
|
@@ -545,6 +682,14 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
545
682
|
return { id, body: str(v.description) ?? "", ...(created ? { created } : {}), ...(str(v.title) !== undefined ? { title: str(v.title) } : {}), ...(author ? { author } : {}), ...(url ? { url } : {}) };
|
|
546
683
|
};
|
|
547
684
|
const notes = `${project}/merge_requests`;
|
|
685
|
+
const mergeRequest = (v: unknown): PullRequest | undefined => {
|
|
686
|
+
if (!isObject(v)) return undefined;
|
|
687
|
+
const number = num(v.iid);
|
|
688
|
+
if (number === undefined) return undefined;
|
|
689
|
+
const base = str(v.target_branch);
|
|
690
|
+
const url = str(v.web_url);
|
|
691
|
+
return { number, title: str(v.title) ?? "", body: str(v.description) ?? "", head: str(v.source_branch) ?? "", ...(base ? { base } : {}), ...(url ? { url } : {}) };
|
|
692
|
+
};
|
|
548
693
|
const t: Threads = {
|
|
549
694
|
comments: async (mr) =>
|
|
550
695
|
(await api.list(`${notes}/${mr}/notes`, pageParams, 100))
|
|
@@ -577,7 +722,7 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
577
722
|
async setStatus(sha, status) {
|
|
578
723
|
try {
|
|
579
724
|
await api.call("POST", `${project}/statuses/${sha}`, {
|
|
580
|
-
state:
|
|
725
|
+
state: statusStateFor("gitlab", status.state),
|
|
581
726
|
name: status.context,
|
|
582
727
|
description: status.description.slice(0, GITLAB_DESCRIPTION),
|
|
583
728
|
...(status.url ? { target_url: status.url } : {}),
|
|
@@ -606,6 +751,28 @@ export function gitlabForge(opts: GitlabForgeOptions): PrForge {
|
|
|
606
751
|
?? mrs.find((m) => isObject(m) && m.state === "merged");
|
|
607
752
|
return isObject(merged) && typeof merged.iid === "number" ? merged.iid : null;
|
|
608
753
|
},
|
|
754
|
+
async openPullRequest(spec) {
|
|
755
|
+
const base = spec.base ?? (await defaultBranchOf(api, project, "default_branch"));
|
|
756
|
+
const title = spec.draft ? `Draft: ${spec.title}` : spec.title;
|
|
757
|
+
const made = await api.call("POST", notes, { source_branch: spec.head, target_branch: base, title, description: spec.body });
|
|
758
|
+
return mergeRequest(made) ?? { number: 0, title, body: spec.body, head: spec.head, base };
|
|
759
|
+
},
|
|
760
|
+
async findPullRequest(head) {
|
|
761
|
+
const open = await api.list(`${notes}?state=opened&source_branch=${encodeURIComponent(head)}`, pageParams, 100);
|
|
762
|
+
return open
|
|
763
|
+
// A fork's merge request into this project has another source project.
|
|
764
|
+
.filter((m) => isObject(m) && m.source_branch === head && (m.source_project_id === undefined || m.source_project_id === m.project_id))
|
|
765
|
+
.map(mergeRequest)
|
|
766
|
+
.filter((m): m is PullRequest => m !== undefined)
|
|
767
|
+
.sort((a, b) => a.number - b.number)[0];
|
|
768
|
+
},
|
|
769
|
+
async editPullRequest(mr, change) {
|
|
770
|
+
return mergeRequest(await api.call("PUT", `${notes}/${mr}`, { ...(change.title !== undefined ? { title: change.title } : {}), ...(change.body !== undefined ? { description: change.body } : {}) }));
|
|
771
|
+
},
|
|
772
|
+
async addLabels(mr, labels) {
|
|
773
|
+
if (labels.length === 0) return;
|
|
774
|
+
await api.call("PUT", `${notes}/${mr}`, { add_labels: labels.join(",") });
|
|
775
|
+
},
|
|
609
776
|
};
|
|
610
777
|
}
|
|
611
778
|
|
|
@@ -250,7 +250,7 @@ export function responseAsk(response: unknown): ModelAsk {
|
|
|
250
250
|
const USAGE = [
|
|
251
251
|
"chant workspace points [--open] [--kind <kind file>] [--at <rev>] [--json]",
|
|
252
252
|
"chant workspace points ask <point> --inputs <file|-|json> [--candidates <file|-|json>] [--response <file>] [--subject <id>] [--kind <kind file>] [--dry-run]",
|
|
253
|
-
"chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--dry-run]",
|
|
253
|
+
"chant workspace points answer <id> --answer <value> --by <name> [--by <name>...] [--note <text>] [--relayed-by <principal>] [--kind <kind file>] [--resume] [--dry-run]",
|
|
254
254
|
"chant workspace points retract <id> --by <name> [--by <name>...] [--note <text>] [--kind <kind file>] [--dry-run]",
|
|
255
255
|
].join("\n");
|
|
256
256
|
|
|
@@ -311,7 +311,12 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
|
|
|
311
311
|
if (!id) return print(usage("answer", "answer needs the question's id"));
|
|
312
312
|
if (args.answer === undefined) return print(usage("answer", "--answer <value> is required"));
|
|
313
313
|
if (!args.bys || args.bys.length === 0) return print(usage("answer", "--by <name> is required, once for each person who answered"));
|
|
314
|
-
|
|
314
|
+
const answered = await answerPoint({ cwd, id, answer: args.answer, by: args.bys, note: args.note, relayedBy: args.relayedBy, kind: args.kind, dryRun: args.dryRun });
|
|
315
|
+
const code = print(answered);
|
|
316
|
+
// --resume (#3835): start the job waiting at the gate the question names, as `chant approve --resume` does.
|
|
317
|
+
if (code !== 0 || args.dryRun || !(args.resumeRun || args.resume !== undefined) || !("question" in answered)) return code;
|
|
318
|
+
const { resumeAfterAnswer } = await import("../cli/handlers/gate-resume");
|
|
319
|
+
return resumeAfterAnswer(answered.question, cwd);
|
|
315
320
|
}
|
|
316
321
|
if (verb === "retract") {
|
|
317
322
|
const id = args.extraPositional2;
|
|
@@ -99,6 +99,13 @@ export interface RecordsQuery {
|
|
|
99
99
|
workGaps?: boolean;
|
|
100
100
|
/** List only the records that are modified or new against `HEAD` (#3160). Needs git and a working-tree read. */
|
|
101
101
|
uncommitted?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* For an answer kind read in the working tree (#3833): fetch `chant/lifecycle`
|
|
104
|
+
* first, best effort, as `points answer` does, so an answer held there by
|
|
105
|
+
* another clone or a CI job is listed. The command sets it; other readers
|
|
106
|
+
* read the local ledger as it is.
|
|
107
|
+
*/
|
|
108
|
+
fetchLedger?: boolean;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/**
|
|
@@ -146,6 +153,14 @@ export type RecordView = RecordEntry & {
|
|
|
146
153
|
* steward keeps on the lifecycle ledger (#2786) laid over it.
|
|
147
154
|
*/
|
|
148
155
|
answers?: WorkAnswer[];
|
|
156
|
+
/**
|
|
157
|
+
* For a record of an answer kind (one with an `answers` block) read in the
|
|
158
|
+
* working tree (#3833): where the record is held on the lifecycle ledger,
|
|
159
|
+
* `chant/lifecycle:<path>`, as `points` names it for a question, or null
|
|
160
|
+
* when it is the working tree's file. A record held on the ledger has no
|
|
161
|
+
* `worktree` or `lastWrite`, since the checkout doesn't hold it.
|
|
162
|
+
*/
|
|
163
|
+
ledger?: string | null;
|
|
149
164
|
};
|
|
150
165
|
|
|
151
166
|
/** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
|
|
@@ -398,7 +413,19 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
398
413
|
// A revision has no working tree to hold anything uncommitted; the CLI and the MCP tool refuse the pair first.
|
|
399
414
|
if (query.uncommitted && query.at !== undefined) throw new Error("--uncommitted reads the working tree and takes no --at");
|
|
400
415
|
try {
|
|
401
|
-
|
|
416
|
+
// An answer kind's records include the answers held on the lifecycle ledger (#2786, #3815), as `points` lists them (#3833).
|
|
417
|
+
let heldOnLedger: Map<string, string> | undefined;
|
|
418
|
+
const { loaded, root, top, at, workspaceRoot, tree, result } = await readRecordsFor({
|
|
419
|
+
...query,
|
|
420
|
+
overlay: async (base, { loaded: l, root: r }) => {
|
|
421
|
+
if (!l.kind.answers) return base;
|
|
422
|
+
const { readLedgerAnswers, refreshLedger, withLedgerAnswers } = await import("./answers-ledger");
|
|
423
|
+
if (query.fetchLedger) await refreshLedger(l.file);
|
|
424
|
+
const ledger = await readLedgerAnswers({ file: l.file, name: l.kind.name, dirRel: relative(r, l.dir).split(sep).join("/") || "." });
|
|
425
|
+
heldOnLedger = new Map([...ledger.byPath.values()].map((a) => [a.path, a.ledger]));
|
|
426
|
+
return withLedgerAnswers(base, ledger);
|
|
427
|
+
},
|
|
428
|
+
});
|
|
402
429
|
// Provenance, judged by the policy at base and never by the tree read (#2547).
|
|
403
430
|
const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
|
|
404
431
|
const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
|
|
@@ -426,6 +453,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
426
453
|
...(seals ? authorSeal(loaded.kind, r, recordPolicy, seals.checkRecordSeal) : {}),
|
|
427
454
|
});
|
|
428
455
|
}
|
|
456
|
+
if (heldOnLedger) for (const r of records) r.ledger = heldOnLedger.get(r.path) ?? null;
|
|
429
457
|
if (loaded.kind.approval && !loaded.kind.work && top) {
|
|
430
458
|
const decided = decidedCommits(top, at ?? "HEAD", records.map((r) => r.path), loaded.kind);
|
|
431
459
|
for (const r of records) r.decidedIn = decided.get(r.path) ?? null;
|
|
@@ -461,13 +489,15 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
461
489
|
if (at === null && top) {
|
|
462
490
|
const { head, ...rest } = readCheckoutHead(top, query.base);
|
|
463
491
|
const dir = relative(root, loaded.dir).split(sep).join("/") || ".";
|
|
492
|
+
// A record held on the ledger is not the checkout's: it has no worktree state, and is never uncommitted (#3833).
|
|
493
|
+
const inTree = records.filter((r) => !r.ledger);
|
|
464
494
|
const { states, deleted } = worktreeStates(top, head, dir, records.map((r) => r.path), new RegExp(loaded.kind.location.match));
|
|
465
|
-
for (const r of
|
|
495
|
+
for (const r of inTree) r.worktree = states.get(r.path) ?? "new";
|
|
466
496
|
const lastWrite = lastWriteReader(top);
|
|
467
|
-
for (const r of
|
|
497
|
+
for (const r of inTree) r.lastWrite = lastWrite(r.path, readTextOrNull(join(top, ...r.path.split("/"))));
|
|
468
498
|
checkout = { branch: rest.branch, head, base: rest.base, baseFrom: rest.baseFrom, deleted };
|
|
469
499
|
if (query.uncommitted) {
|
|
470
|
-
listed =
|
|
500
|
+
listed = inTree.filter((r) => r.worktree !== "committed");
|
|
471
501
|
summary = { ...summary, total: listed.length, valid: listed.filter((r) => r.valid).length, invalid: listed.filter((r) => !r.valid).length };
|
|
472
502
|
}
|
|
473
503
|
} else if (query.uncommitted && !top) {
|
|
@@ -664,7 +694,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
|
|
|
664
694
|
else console.log(formatSince(since));
|
|
665
695
|
return "error" in since ? 1 : 0;
|
|
666
696
|
}
|
|
667
|
-
const doc = await queryRecords({ kind: args.kind, current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd() });
|
|
697
|
+
const doc = await queryRecords({ kind: args.kind, current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd(), fetchLedger: true });
|
|
668
698
|
if (args.json) {
|
|
669
699
|
console.log(JSON.stringify(doc, null, 2));
|
|
670
700
|
} else if ("error" in doc) {
|
|
@@ -728,7 +758,7 @@ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number>
|
|
|
728
758
|
return 1;
|
|
729
759
|
}
|
|
730
760
|
if (args.since !== undefined) return runDeclaredSince(declared, args);
|
|
731
|
-
const set = await queryDeclaredRecords(declared, { current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd() });
|
|
761
|
+
const set = await queryDeclaredRecords(declared, { current: args.current, uncommitted: args.uncommitted, at: args.at, base: args.base, cwd: process.cwd(), fetchLedger: true });
|
|
732
762
|
if (args.json) console.log(JSON.stringify(set, null, 2));
|
|
733
763
|
for (const doc of args.json ? [] : set.kinds) {
|
|
734
764
|
if ("error" in doc) {
|
|
@@ -827,7 +857,8 @@ function formatRecords(
|
|
|
827
857
|
const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
|
|
828
858
|
const sealed = r.attested === true ? ` sealed by ${(r.data?.[RECORD_SEAL_FIELD] as { signer: string }).signer}` : "";
|
|
829
859
|
const uncommitted = r.worktree === "new" || r.worktree === "modified" ? ` (${r.worktree}, uncommitted)` : "";
|
|
830
|
-
|
|
860
|
+
const ledger = r.ledger ? ` (held on ${r.ledger})` : "";
|
|
861
|
+
lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${remediated}${attested}${sealed}${uncommitted}${ledger}${flag}`);
|
|
831
862
|
if (r.ready !== undefined) {
|
|
832
863
|
const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
833
864
|
const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
|
|
@@ -327,6 +327,10 @@
|
|
|
327
327
|
"type": "array",
|
|
328
328
|
"items": { "$ref": "#/$defs/workAnswer" }
|
|
329
329
|
},
|
|
330
|
+
"ledger": {
|
|
331
|
+
"type": ["string", "null"],
|
|
332
|
+
"description": "Added in contract 1 by #3833, on every record of an answer kind (one with an answers block) read in the working tree; absent under --at. Where the record is held on the lifecycle ledger, chant/lifecycle:<path on the branch>, as chant workspace points names it for a question, or null when it is the working tree's file. An answer held on the ledger (a question a steward asked, or one asked with --store ledger) reads as if it sat in the kind's directory and wins over a file of the same name; it has no worktree or lastWrite and is never listed by --uncommitted. The command fetches chant/lifecycle first, best effort."
|
|
333
|
+
},
|
|
330
334
|
"lease": {
|
|
331
335
|
"description": "Added in contract 1 by #2732, on every record of a work kind with an id, read in the working tree (absent under --at). The item's active work lease (chant workspace work claim), or null when nobody holds one live. Read from the local lease refs, refs/chant/lease/[_members/<member>/]work/<id> and the remote's as last fetched, of the ledger owning the work kind file; records never fetches.",
|
|
332
336
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workLease" }]
|