dw-sdlc 0.15.2 → 0.17.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.
@@ -0,0 +1,105 @@
1
+ // The candidates for the session after this one, as `dw-sdlc recap` reads them: the open issues an agent may
2
+ // start, from one GraphQL request that also asks which issues the branch's pull request closes.
3
+ import { isObject, parseObject } from "./json-text.js";
4
+ import { CannotRun, gh, said } from "./pr/read.js";
5
+ /** The label that marks an issue as ready for an agent to start. */
6
+ export const READY_LABEL = "ready-for-agent";
7
+ /** How many open issues the one read holds, oldest first, and how many issues the pull request closes. */
8
+ const ISSUES_READ = 100;
9
+ const CLOSING_READ = 25;
10
+ const QUERY = `query Backlog($owner: String!, $name: String!, $number: Int!, $closing: Boolean!) {
11
+ repository(owner: $owner, name: $name) {
12
+ pullRequest(number: $number) @include(if: $closing) { closingIssuesReferences(first: ${CLOSING_READ}) { nodes { number } } }
13
+ issues(first: ${ISSUES_READ}, states: OPEN, labels: ["${READY_LABEL}"], orderBy: { field: CREATED_AT, direction: ASC }) {
14
+ nodes { number title url assignees { totalCount } issueDependenciesSummary { blockedBy } parent { number } }
15
+ }
16
+ }
17
+ }`;
18
+ const REMEDY = "check gh auth status and the network, then call again";
19
+ function malformed(field) {
20
+ return new CannotRun(`the backlog reply does not parse: ${field}`, "gh answered a shape dw-sdlc does not read; report it with the output of gh --version");
21
+ }
22
+ function nodesOf(value, field) {
23
+ if (!isObject(value) || !Array.isArray(value.nodes)) {
24
+ throw malformed(field);
25
+ }
26
+ return value.nodes;
27
+ }
28
+ function numberOf(value, field) {
29
+ if (!isObject(value) || typeof value.number !== "number") {
30
+ throw malformed(field);
31
+ }
32
+ return value.number;
33
+ }
34
+ /** An issue node as a candidate, or null when it has an assignee or an open blocker. */
35
+ function candidateOf(node) {
36
+ if (!isObject(node) || typeof node.title !== "string" || typeof node.url !== "string") {
37
+ throw malformed("issues.nodes");
38
+ }
39
+ const { assignees, issueDependenciesSummary: dependencies, parent } = node;
40
+ if (!isObject(assignees) || typeof assignees.totalCount !== "number") {
41
+ throw malformed("issues.nodes.assignees");
42
+ }
43
+ if (!isObject(dependencies) || typeof dependencies.blockedBy !== "number") {
44
+ throw malformed("issues.nodes.issueDependenciesSummary");
45
+ }
46
+ if (assignees.totalCount > 0 || dependencies.blockedBy > 0) {
47
+ return null;
48
+ }
49
+ const number = numberOf(node, "issues.nodes.number");
50
+ return { number, title: node.title, url: node.url, parent: parent === null ? null : numberOf(parent, "parent") };
51
+ }
52
+ function repositoryOf(stdout) {
53
+ const data = parseObject(stdout)?.data;
54
+ const repository = isObject(data) ? data.repository : undefined;
55
+ if (!isObject(repository)) {
56
+ throw malformed("data.repository");
57
+ }
58
+ return repository;
59
+ }
60
+ /**
61
+ * The open issues labelled ready-for-agent with no assignee and no open blocker, read from `repo`, or from the
62
+ * repository gh resolves when it is null. The open sub-issues of an issue pull request `pr` closes come
63
+ * first, then the rest, each part in issue order; the issues it closes are never candidates.
64
+ */
65
+ function nextCandidates(pr) {
66
+ const [owner, name] = pr === null ? ["{owner}", "{repo}"] : pr.repo.split("/");
67
+ const reply = gh([
68
+ "api",
69
+ "graphql",
70
+ "-f",
71
+ `query=${QUERY}`,
72
+ "-F",
73
+ `owner=${owner ?? ""}`,
74
+ "-F",
75
+ `name=${name ?? ""}`,
76
+ "-F",
77
+ `number=${pr?.number ?? 0}`,
78
+ "-F",
79
+ `closing=${pr !== null}`
80
+ ]);
81
+ if (reply.status !== 0) {
82
+ throw new CannotRun(`gh could not read the backlog: ${said(reply.stderr)}`, REMEDY);
83
+ }
84
+ const repository = repositoryOf(reply.stdout);
85
+ const closes = new Set(pr === null
86
+ ? []
87
+ : nodesOf(isObject(repository.pullRequest) ? repository.pullRequest.closingIssuesReferences : undefined, "pullRequest").map((node) => numberOf(node, "closingIssuesReferences.nodes")));
88
+ const candidates = nodesOf(repository.issues, "issues")
89
+ .map(candidateOf)
90
+ .filter((candidate) => candidate !== null && !closes.has(candidate.number))
91
+ .toSorted((one, other) => one.number - other.number);
92
+ const underClosed = (candidate) => candidate.parent !== null && closes.has(candidate.parent);
93
+ return [...candidates.filter(underClosed), ...candidates.filter((candidate) => !underClosed(candidate))];
94
+ }
95
+ export function readNext(pr) {
96
+ try {
97
+ return { candidates: nextCandidates(pr) };
98
+ }
99
+ catch (error) {
100
+ if (error instanceof CannotRun) {
101
+ return { unread: error.message };
102
+ }
103
+ throw error;
104
+ }
105
+ }
package/dist/report.d.ts CHANGED
@@ -4,10 +4,16 @@ export type Failure = {
4
4
  rule: string;
5
5
  message: string;
6
6
  remedy: string;
7
+ /** What the repository owner wrote to try, printed as `hint: <text>` under the failure line. */
8
+ hint?: string;
9
+ /** The Disable directive a `disable-directives` failure names, as written. */
10
+ directive?: string;
7
11
  };
12
+ /** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
13
+ export declare function hintLines({ hint }: Failure): string[];
8
14
  /** `file:line: rule: message — remedy`, without the location where the failure has none. */
9
15
  export declare function failureLine({ file, line, rule, message, remedy }: Failure): string;
10
16
  /** `::error file=…,line=…,title=<rule>::<message> — <remedy>`, an annotation on the file where there is one. */
11
17
  export declare function annotation({ file, line, rule, message, remedy }: Failure): string;
12
- /** The failure as JSON: every key present, null where it has no file or line. */
13
- export declare function failureJson({ file, line, rule, message, remedy }: Failure): Record<string, unknown>;
18
+ /** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
19
+ export declare function failureJson({ file, line, rule, message, remedy, directive }: Failure): Record<string, unknown>;
package/dist/report.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // A failure of the Gate as every consumer reads it: one line naming the file and line where there is
2
2
  // one, the rule, what is wrong and the remedy; the same as a GitHub Actions workflow annotation; the
3
3
  // same as JSON.
4
+ /** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
5
+ export function hintLines({ hint }) {
6
+ return hint === undefined ? [] : [`hint: ${hint}`];
7
+ }
4
8
  /** `file:line: rule: message — remedy`, without the location where the failure has none. */
5
9
  export function failureLine({ file, line, rule, message, remedy }) {
6
10
  const where = file === undefined ? "" : `${file}${line === undefined ? "" : `:${line}`}: `;
@@ -23,7 +27,8 @@ export function annotation({ file, line, rule, message, remedy }) {
23
27
  ];
24
28
  return `::error ${properties.join(",")}::${escapeData(`${message} — ${remedy}`)}`;
25
29
  }
26
- /** The failure as JSON: every key present, null where it has no file or line. */
27
- export function failureJson({ file, line, rule, message, remedy }) {
28
- return { file: file ?? null, line: line ?? null, rule, message, remedy };
30
+ /** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
31
+ export function failureJson({ file, line, rule, message, remedy, directive }) {
32
+ const json = { file: file ?? null, line: line ?? null, rule, message, remedy };
33
+ return directive === undefined ? json : { ...json, directive };
29
34
  }
@@ -0,0 +1,31 @@
1
+ /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
2
+ export declare const NAME: RegExp;
3
+ /** The most seconds a start looks for the new tab in the window, beside the read-back. */
4
+ export declare const TAB_WAIT = 10;
5
+ /** The id of the herdr space the terminal `id` sits in, if it sits in one. */
6
+ export declare function spaceOf(id: string): string | undefined;
7
+ export type Launch = {
8
+ name: string;
9
+ /** The `role` label of the new terminal. */
10
+ role: "worker" | "lead";
11
+ /** The herdr space of the caller, which the new terminal joins; none leaves the tab alone. */
12
+ space: string | undefined;
13
+ /** The folder it starts in. */
14
+ cwd: string;
15
+ /** The program and its arguments, its first message last. */
16
+ argv: string[];
17
+ /** The most seconds the read-back waits. */
18
+ wait: number;
19
+ };
20
+ export type Launched = {
21
+ address: string;
22
+ tab: "none" | "renamed" | "kept";
23
+ reason: string | undefined;
24
+ };
25
+ /**
26
+ * Runs `hc run` once for `launch`, outliving its caller, then names the tab and reads the terminal back side
27
+ * by side. It never sends the first message again; `reason` says why the session did not take it.
28
+ */
29
+ export declare function launch(command: string, { name, role, space, cwd, argv, wait }: Launch): Promise<Launched>;
30
+ /** The line that says a started session did not take its first message, and how to recover. */
31
+ export declare function undelivered(command: string, name: string, address: string, reason: string, message: string): string;
@@ -0,0 +1,100 @@
1
+ // What `worker start` and `lead start` share: a session started in a herdr terminal of its own beside the
2
+ // caller, its tab named, and the terminal read back until the session shows it took its first message.
3
+ import { setTimeout as sleep } from "node:timers/promises";
4
+ import { isObject } from "../json-text.js";
5
+ import { describe, hcObject, Refusal } from "./herdr.js";
6
+ import { nameTab } from "./tabs.js";
7
+ /** hc names a terminal with 1-32 of a-z, 0-9, - and _. */
8
+ export const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
9
+ /** The most seconds a start looks for the new tab in the window, beside the read-back. */
10
+ export const TAB_WAIT = 10;
11
+ /** What a session reports once it has taken its first message. */
12
+ const WORKING = new Set(["working", "blocked", "done"]);
13
+ /** How long the read-back waits between two reads of the terminal. */
14
+ const POLL_MS = 1000;
15
+ /** The status a session reports, if it has reported one. */
16
+ function reportedStatus(terminal) {
17
+ const { status } = terminal;
18
+ return isObject(status) && typeof status.state === "string" ? status.state : undefined;
19
+ }
20
+ /** Why the session, called `who`, can no longer take its first message, or undefined while it still may. */
21
+ function failure(terminal, who) {
22
+ const { first_message: first, state } = terminal;
23
+ if (isObject(first) && first.sent === false) {
24
+ return typeof first.reason === "string" ? first.reason : "herdr gave no reason";
25
+ }
26
+ if (state !== "live") {
27
+ return "the terminal has finished";
28
+ }
29
+ return reportedStatus(terminal) === "error" ? `the ${who} reports its status as error` : undefined;
30
+ }
31
+ /** Reads the terminal until the session shows work, for up to `wait` seconds; why it did not, or undefined once it did. */
32
+ async function delivery(address, wait, who) {
33
+ const deadline = Date.now() + wait * 1000;
34
+ for (;;) {
35
+ let terminal;
36
+ try {
37
+ terminal = hcObject(["get", address, "--json"]);
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof Refusal)) {
41
+ throw error;
42
+ }
43
+ // The session is running by now, so a failed read is a message not confirmed, never a start that did not happen.
44
+ return `the terminal could not be read back: ${describe(error)}`;
45
+ }
46
+ const failed = failure(terminal, who);
47
+ if (failed !== undefined) {
48
+ return failed;
49
+ }
50
+ if (WORKING.has(reportedStatus(terminal) ?? "")) {
51
+ return undefined;
52
+ }
53
+ if (Date.now() >= deadline) {
54
+ return `the ${who} showed no sign of work within ${wait} s`;
55
+ }
56
+ await sleep(POLL_MS);
57
+ }
58
+ }
59
+ /** The id of the herdr space the terminal `id` sits in, if it sits in one. */
60
+ export function spaceOf(id) {
61
+ const { labels } = hcObject(["get", id, "--json"]);
62
+ const space = isObject(labels) ? labels["herdr.space"] : undefined;
63
+ return typeof space === "string" ? space : undefined;
64
+ }
65
+ /**
66
+ * Runs `hc run` once for `launch`, outliving its caller, then names the tab and reads the terminal back side
67
+ * by side. It never sends the first message again; `reason` says why the session did not take it.
68
+ */
69
+ export async function launch(command, { name, role, space, cwd, argv, wait }) {
70
+ const { address } = hcObject([
71
+ "run",
72
+ "--bg",
73
+ "--json",
74
+ "--name",
75
+ name,
76
+ "--label",
77
+ `role=${role}`,
78
+ ...(space === undefined ? [] : ["--label", `herdr.space=${space}`]),
79
+ "--outlive-parent",
80
+ "--cwd",
81
+ cwd,
82
+ "--",
83
+ ...argv
84
+ ]);
85
+ if (typeof address !== "string") {
86
+ throw new Refusal("hc run answered no address for the terminal it started", "check hc ls before starting it again");
87
+ }
88
+ // Side by side, so the wait for the tab overlaps the wait for the session to start.
89
+ const naming = space === undefined ? Promise.resolve("none") : nameTab(address, name, Math.min(TAB_WAIT, wait));
90
+ const [named, reason] = await Promise.all([naming, delivery(address, wait, role === "lead" ? "Lead" : "Worker")]);
91
+ if (typeof named === "object") {
92
+ process.stderr.write(`dw-sdlc ${command}: warning: ${named.kept}\n`);
93
+ }
94
+ return { address, tab: typeof named === "object" ? "kept" : named, reason };
95
+ }
96
+ /** The line that says a started session did not take its first message, and how to recover. */
97
+ export function undelivered(command, name, address, reason, message) {
98
+ return (`dw-sdlc ${command}: ${name} (${address}) started but its first message was not delivered: ${reason} — ` +
99
+ `run hc read ${address} to see why; do not send the ${message} again before you have read it\n`);
100
+ }
@@ -27,7 +27,8 @@ export function freshenOrigin(root, baseBranch) {
27
27
  const branch = defaultBranch(root, baseBranch);
28
28
  const args = ["-C", root, "fetch", "--quiet", "origin", ...(branch === undefined ? [] : [branch])];
29
29
  const fetched = `git fetch origin${branch === undefined ? "" : ` ${branch}`}`;
30
- const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin, then start the Worker again");
30
+ const stale = (why) => new Refusal(`${fetched} failed, so the Worker would begin from a stale base: ${why}`, "check the network and the access to origin; when origin/HEAD names a branch the remote no longer has, " +
31
+ "run `git remote set-head origin --auto`; then start the Worker again");
31
32
  let result;
32
33
  try {
33
34
  result = exec("git", args, { env: repositoryFreeEnv(), limitMs: SLOW_LIMIT_MS });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.15.2",
3
+ "version": "0.17.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {