@miroshnik/elves 0.0.0 → 2026.1018.1
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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
|
@@ -0,0 +1,4390 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* github — the repository's GitHub project and tasks by the canon (AGENTS.md, "Task tracking"; the full rules are in
|
|
4
|
+
* reference.md next to SKILL.md: types, decision labels, epics, the project, milestones, epic refresh).
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* github project check — a check against the canon, item by item ✅/❌
|
|
8
|
+
* github project fix — bring it to the canon: API, UI steps with links, deleting and renaming — with --confirm
|
|
9
|
+
* github task new — a task in one command: type or label, project and Backlog, Priority, epic, blocked by,
|
|
10
|
+
* milestone, decision labels (the decision's name, the kind by color), the questions label
|
|
11
|
+
* (an open question in the description) and the estimate (est)
|
|
12
|
+
* github task show — the task in one output: the description, Status, estimate, the epic, blockers, PRs and
|
|
13
|
+
* their checks, open questions
|
|
14
|
+
* github task answer — ✅ and the answer at a question of the questions section; none open — the label goes
|
|
15
|
+
* github task status — Status in the project (and the epic's "In progress" once its first subtask is taken);
|
|
16
|
+
* "In progress" pins the task to the session — another task in the same session is refused
|
|
17
|
+
* with the question whether to queue it
|
|
18
|
+
* github task drop — close without doing it and remove from the project
|
|
19
|
+
* github task actualize — after the merge, in parallel with the deploy: the epic refresh's mechanics and the
|
|
20
|
+
* subagent's prompt (or "not needed"); --assignment — the subagent's whole assignment
|
|
21
|
+
* github task close — after the merge and the deploy: the actual, "Done", the epic and the milestone, the task's
|
|
22
|
+
* stacks (task-env) and the task's merged branches removed
|
|
23
|
+
* github task queue — the dispatcher's ready tasks with the prompts of their executors (subagents);
|
|
24
|
+
* --assignment — an executor's whole assignment
|
|
25
|
+
* github pr labels — decision labels for the task from "Closes #N" by the PR diff, for its epic — the union
|
|
26
|
+
* github pr premerge — before the merge: the default branch moved after the PR's CI — typecheck and test:spec on
|
|
27
|
+
* the merge with it
|
|
28
|
+
*
|
|
29
|
+
* Runs on Bun (`bun github.ts …`), only `node:` APIs + the `gh` CLI. Check and fix are one function, `analyze`: each
|
|
30
|
+
* discrepancy carries its own fix step, so `check` and `fix` don't diverge.
|
|
31
|
+
* Decision labels are checked against the spec tree of the default branch; the architecture model is read by a
|
|
32
|
+
* separate process of the same runtime (`modelModules`). Merging into the default branch — the `elves` ruleset:
|
|
33
|
+
* required checks, green on the last merged PRs; the merge method — rebase only (a repository setting, REST).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
38
|
+
import os from "node:os";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { parseArgs } from "node:util";
|
|
41
|
+
|
|
42
|
+
import locale from "./locale.ts";
|
|
43
|
+
import type { Names } from "./locale.ts";
|
|
44
|
+
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
type Any = any;
|
|
47
|
+
|
|
48
|
+
// ----------------------------------------------------------------------------
|
|
49
|
+
// Canon
|
|
50
|
+
// ----------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Canon names in the project language (`locales/<language>.json`, the `locale.ts` module): views, Status options,
|
|
54
|
+
* fields, issue types, label markers, sections, comments. The language is the one of the project of the `io.cwd`
|
|
55
|
+
* directory, `main` sets it; parsing — by the names of any dictionary language (`locale.every`).
|
|
56
|
+
*/
|
|
57
|
+
let N: Names = locale.names(locale.projectLanguage());
|
|
58
|
+
|
|
59
|
+
const canonViews = () => [
|
|
60
|
+
{ name: N.views.board, layout: "BOARD_LAYOUT" },
|
|
61
|
+
{ name: N.views.table, layout: "TABLE_LAYOUT" },
|
|
62
|
+
{ name: N.views.roadmap, layout: "ROADMAP_LAYOUT" },
|
|
63
|
+
];
|
|
64
|
+
export const STATUS = "Status";
|
|
65
|
+
const statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
|
|
66
|
+
const numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
|
|
67
|
+
const issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
|
|
68
|
+
export const PRIORITY = "Priority";
|
|
69
|
+
/** Priority options from the most urgent; a new task gets Medium, an epic — no lower than its most urgent subtask. */
|
|
70
|
+
export const PRIORITIES = ["Urgent", "High", "Medium", "Low"] as const;
|
|
71
|
+
export const DEFAULT_PRIORITY = "Medium";
|
|
72
|
+
/** A personal account has no issue types — an epic is marked with a label, the only exception. */
|
|
73
|
+
export const EPIC_LABEL = "epic";
|
|
74
|
+
const epicLabel = () => ({ name: EPIC_LABEL, color: "8250DF", description: N.labels.epicDescription });
|
|
75
|
+
/** Open questions in the task description: `task new` sets it (creates it if missing), the last answer removes it. */
|
|
76
|
+
const questionsLabel = () => ({ name: N.labels.questions, color: "FBCA04", description: N.labels.questionsDescription });
|
|
77
|
+
/**
|
|
78
|
+
* Decision labels: the name is the decision's name (a spec tree folder or a model module), the kind is the color.
|
|
79
|
+
* Decisions of different kinds with one name are one label: the color of the senior kind (the order here), all of them
|
|
80
|
+
* in the description. For architecture a decision is the rule `tests/architecture/<name>` or a module of the model
|
|
81
|
+
* `tests/architecture/model.ts`.
|
|
82
|
+
*/
|
|
83
|
+
export const DECISIONS = {
|
|
84
|
+
capability: { dir: "capabilities", color: "0E8A16" },
|
|
85
|
+
standard: { dir: "standards", color: "1D76DB" },
|
|
86
|
+
// not FBCA04: the "questions" label has it, and a decision label's kind shows only by color
|
|
87
|
+
architecture: { dir: "architecture", color: "D93F0B" },
|
|
88
|
+
} as const;
|
|
89
|
+
export type DecisionKind = keyof typeof DECISIONS;
|
|
90
|
+
const DECISION_KINDS = Object.keys(DECISIONS) as DecisionKind[];
|
|
91
|
+
const MODEL_PATH = "tests/architecture/model.ts";
|
|
92
|
+
/**
|
|
93
|
+
* GitHub puts at most this many labels on an issue: `addLabelsToLabelable` past it fails as a whole (#569). An issue's
|
|
94
|
+
* labels are read all at once (`IssueRef`) — otherwise `pr labels` couldn't count the room.
|
|
95
|
+
*/
|
|
96
|
+
const ISSUE_LABEL_LIMIT = 100;
|
|
97
|
+
export const WORKFLOW_ADDED = "Item added to project";
|
|
98
|
+
export const WORKFLOW_CLOSED = "Item closed";
|
|
99
|
+
export const WORKFLOW_AUTO_ADD = "Auto-add to project";
|
|
100
|
+
/** The template: a new project is its copy (views, fields, configured workflows except auto-add). */
|
|
101
|
+
export const DEFAULT_TEMPLATE = "miroshnik/6";
|
|
102
|
+
/**
|
|
103
|
+
* The default-branch ruleset that fix sets: GitHub doesn't let a branch with a red required check be merged. It doesn't
|
|
104
|
+
* require the branch to be up to date (strict is off): the next PR's CI shows that two green PRs are incompatible.
|
|
105
|
+
*/
|
|
106
|
+
export const RULESET = "elves";
|
|
107
|
+
/**
|
|
108
|
+
* The name `RULESET` had before the flow was renamed to Elves: a repository set up by an earlier fix has its ruleset
|
|
109
|
+
* under it. Check finds it as its own (`RULESET` first), fix renames it with the same edit — not a second ruleset.
|
|
110
|
+
*/
|
|
111
|
+
const FORMER_RULESET = "ai-dev";
|
|
112
|
+
/** A required check is green on each of this many last merged PRs with checks. */
|
|
113
|
+
export const STABLE_PRS = 10;
|
|
114
|
+
/** The repository admin role (`repositoryRoleDatabaseId`) bypasses the ruleset: direct push, `gh pr merge --admin`. */
|
|
115
|
+
const ADMIN_ROLE = 5;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Status options of a new project and names in the dictionary's other languages → the name in the project language:
|
|
119
|
+
* renaming with the same id keeps the tasks' values and the target of the "Item closed" workflow (replacing all the
|
|
120
|
+
* options gives new ids).
|
|
121
|
+
*/
|
|
122
|
+
function statusAliases(): Record<string, string> {
|
|
123
|
+
const out: Record<string, string> = { todo: N.status.backlog, "to do": N.status.backlog, doing: N.status.inProgress };
|
|
124
|
+
for (const n of locale.everyNames()) {
|
|
125
|
+
out[n.status.backlog.toLowerCase()] = N.status.backlog;
|
|
126
|
+
out[n.status.inProgress.toLowerCase()] = N.status.inProgress;
|
|
127
|
+
out[n.status.done.toLowerCase()] = N.status.done;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
/** Issue types in any dictionary language (and `task`, `bug`, `epic`) → the type in the project language. */
|
|
132
|
+
function typeAliases(): Record<string, string> {
|
|
133
|
+
const out: Record<string, string> = {};
|
|
134
|
+
for (const n of locale.everyNames()) {
|
|
135
|
+
out[n.issueTypes.task.toLowerCase()] = N.issueTypes.task;
|
|
136
|
+
out[n.issueTypes.bug.toLowerCase()] = N.issueTypes.bug;
|
|
137
|
+
out[n.issueTypes.epic.toLowerCase()] = N.issueTypes.epic;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Project fields in any dictionary language → the field in the project language. */
|
|
142
|
+
function fieldAliases(): Record<string, string> {
|
|
143
|
+
const out: Record<string, string> = {};
|
|
144
|
+
for (const n of locale.everyNames()) {
|
|
145
|
+
out[n.fields.estimate] = N.fields.estimate;
|
|
146
|
+
out[n.fields.actual] = N.fields.actual;
|
|
147
|
+
out[n.fields.tokens] = N.fields.tokens;
|
|
148
|
+
out[n.fields.cost] = N.fields.cost;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
// Built-in project fields; custom ones are everything else (TEXT, NUMBER, DATE, SINGLE_SELECT, ITERATION…).
|
|
153
|
+
const BUILTIN_FIELDS = new Set(["TITLE", "ASSIGNEES", "LABELS", "LINKED_PULL_REQUESTS", "MILESTONE", "REPOSITORY", "REVIEWERS", "PARENT_ISSUE", "SUB_ISSUES_PROGRESS", "CREATED", "UPDATED", "CLOSED", "TRACKS", "TRACKED_BY", "ISSUE_TYPE"]);
|
|
154
|
+
const NOT_DONE_REASONS = new Set(["NOT_PLANNED", "DUPLICATE"]);
|
|
155
|
+
|
|
156
|
+
// ----------------------------------------------------------------------------
|
|
157
|
+
// Model
|
|
158
|
+
// ----------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface Io {
|
|
161
|
+
/** `gh` with arguments and stdin → stdout; an error is an exception. An external edge: replaced in tests. */
|
|
162
|
+
gh: (args: string[], stdin?: string) => string;
|
|
163
|
+
out: (line: string) => void;
|
|
164
|
+
err: (line: string) => void;
|
|
165
|
+
env: Record<string, string | undefined>;
|
|
166
|
+
/** A pause before re-reading (GitHub doesn't show additions right away); in tests — no waiting. */
|
|
167
|
+
sleep?: (ms: number) => void;
|
|
168
|
+
/** A subprocess (`est`, `git` of `task close`) — an external edge: replaced in tests. */
|
|
169
|
+
run?: (cmd: string, args: string[], cwd?: string) => RunResult;
|
|
170
|
+
/**
|
|
171
|
+
* The project checkout: its language (`.agents/elves.json`) is the language of names, `task close` runs its git
|
|
172
|
+
* steps in it; the current directory by default.
|
|
173
|
+
*/
|
|
174
|
+
cwd?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface RunResult {
|
|
178
|
+
status: number | null;
|
|
179
|
+
stdout: string;
|
|
180
|
+
stderr: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function realRun(cmd: string, args: string[], cwd?: string): RunResult {
|
|
184
|
+
const r = spawnSync(cmd, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
185
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.error ? String(r.error.message) : (r.stderr ?? "") };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const pause = (ms: number) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
189
|
+
|
|
190
|
+
export interface ProjectRef {
|
|
191
|
+
id: string;
|
|
192
|
+
number: number;
|
|
193
|
+
title: string;
|
|
194
|
+
url: string;
|
|
195
|
+
closed: boolean;
|
|
196
|
+
}
|
|
197
|
+
interface View {
|
|
198
|
+
id: string;
|
|
199
|
+
number: number;
|
|
200
|
+
name: string;
|
|
201
|
+
layout: string;
|
|
202
|
+
filter: string | null;
|
|
203
|
+
sortBy: string[];
|
|
204
|
+
columnsBy: string[];
|
|
205
|
+
}
|
|
206
|
+
interface Option {
|
|
207
|
+
id: string;
|
|
208
|
+
name: string;
|
|
209
|
+
color: string;
|
|
210
|
+
description: string;
|
|
211
|
+
}
|
|
212
|
+
interface Field {
|
|
213
|
+
id: string;
|
|
214
|
+
name: string;
|
|
215
|
+
dataType: string;
|
|
216
|
+
isIssueField: boolean;
|
|
217
|
+
options: Option[] | null;
|
|
218
|
+
}
|
|
219
|
+
interface Workflow {
|
|
220
|
+
id: string;
|
|
221
|
+
number: number;
|
|
222
|
+
/** The workflow address in the UI uses it, not number: `<project>/workflows/<fullDatabaseId>`. */
|
|
223
|
+
fullDatabaseId: string | number;
|
|
224
|
+
name: string;
|
|
225
|
+
enabled: boolean;
|
|
226
|
+
}
|
|
227
|
+
interface Item {
|
|
228
|
+
id: string;
|
|
229
|
+
issue: { id: string; number: number; state: string; stateReason: string | null } | null;
|
|
230
|
+
status: string | null;
|
|
231
|
+
}
|
|
232
|
+
interface Project extends ProjectRef {
|
|
233
|
+
views: View[];
|
|
234
|
+
fields: Field[];
|
|
235
|
+
workflows: Workflow[];
|
|
236
|
+
items: Item[];
|
|
237
|
+
}
|
|
238
|
+
interface Owner {
|
|
239
|
+
org: boolean;
|
|
240
|
+
id: string;
|
|
241
|
+
login: string;
|
|
242
|
+
issueTypes: { id: string; name: string; isEnabled: boolean }[];
|
|
243
|
+
issueFields: { id: string; name: string; options: { id: string; name: string }[] }[];
|
|
244
|
+
}
|
|
245
|
+
interface Label {
|
|
246
|
+
id: string;
|
|
247
|
+
name: string;
|
|
248
|
+
color: string;
|
|
249
|
+
description: string | null;
|
|
250
|
+
/** Open tasks with the label: how many in total and the numbers of the first OPEN_SHOWN. */
|
|
251
|
+
open: { total: number; numbers: number[] };
|
|
252
|
+
}
|
|
253
|
+
/** Default-branch decisions by kind; the model didn't load — the reason, and then `architecture` has no modules. */
|
|
254
|
+
export interface Decisions {
|
|
255
|
+
names: Record<DecisionKind, string[]>;
|
|
256
|
+
modelError: string | null;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* A PR head's check state — the buckets of `gh pr checks`: green, skipped or neutral, still running, cancelled, red
|
|
260
|
+
* (`checkStates`).
|
|
261
|
+
*/
|
|
262
|
+
type CheckBucket = "pass" | "skipping" | "pending" | "cancel" | "fail";
|
|
263
|
+
interface Head {
|
|
264
|
+
number: number;
|
|
265
|
+
checks: Record<string, CheckBucket>;
|
|
266
|
+
}
|
|
267
|
+
interface Ruleset {
|
|
268
|
+
id: string;
|
|
269
|
+
/** `RULESET`, or `FORMER_RULESET` until fix renames it. */
|
|
270
|
+
name: string;
|
|
271
|
+
enforcement: string;
|
|
272
|
+
include: string[];
|
|
273
|
+
strict: boolean;
|
|
274
|
+
contexts: string[];
|
|
275
|
+
bypassAdmin: boolean;
|
|
276
|
+
}
|
|
277
|
+
/** Merging into the default branch: its active rules with required checks and the checks of PR heads into it. */
|
|
278
|
+
export interface Merge {
|
|
279
|
+
branch: string;
|
|
280
|
+
/** Branch rules are unavailable on the plan (a private repository on Free) — the reason from GitHub. */
|
|
281
|
+
unavailable: string | null;
|
|
282
|
+
/** Active rules with required checks: a ruleset of any level and classic branch protection. */
|
|
283
|
+
rules: { source: string; contexts: string[] }[];
|
|
284
|
+
/** Our own ruleset — this repository's `RULESET` (otherwise `FORMER_RULESET`), in any state. */
|
|
285
|
+
own: Ruleset | null;
|
|
286
|
+
/** The last `STABLE_PRS` merged PRs with checks, oldest to newest. */
|
|
287
|
+
merged: Head[];
|
|
288
|
+
/** Open PRs whose checks have all finished: a check they don't have won't come. */
|
|
289
|
+
open: Head[];
|
|
290
|
+
/** Allowed PR merge methods (a repository setting): merge commit, squash, rebase. */
|
|
291
|
+
methods: { merge: boolean; squash: boolean; rebase: boolean };
|
|
292
|
+
}
|
|
293
|
+
export interface State {
|
|
294
|
+
repo: { id: string; name: string; nameWithOwner: string; owner: Owner; linked: ProjectRef[] };
|
|
295
|
+
project: Project | null;
|
|
296
|
+
openIssues: { id: string; number: number }[];
|
|
297
|
+
labels: Label[];
|
|
298
|
+
/** Tasks of old `kind:name` labels that merge into an already taken name: by label id. */
|
|
299
|
+
labelIssues: Record<string, { id: string; number: number }[]>;
|
|
300
|
+
decisions: Decisions;
|
|
301
|
+
/** An empty repository (no default branch) — null. */
|
|
302
|
+
merge: Merge | null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
interface Mutation {
|
|
306
|
+
op: string;
|
|
307
|
+
input: Record<string, unknown>;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A fix step: `api` — fix does it; `confirm` — only with --confirm (deleting, renaming, the organization);
|
|
311
|
+
* `ui` — by hand via the link. `sticky` — a UI step stays in the list even when fix has already removed the symptom.
|
|
312
|
+
*/
|
|
313
|
+
export interface Step {
|
|
314
|
+
kind: "api" | "confirm" | "ui";
|
|
315
|
+
text: string;
|
|
316
|
+
mutations?: Mutation[];
|
|
317
|
+
url?: string;
|
|
318
|
+
sticky?: boolean;
|
|
319
|
+
}
|
|
320
|
+
export interface Check {
|
|
321
|
+
key: string;
|
|
322
|
+
title: string;
|
|
323
|
+
problems: { text: string; steps: Step[] }[];
|
|
324
|
+
/** Not a discrepancy but the state of things (a new decision's label): an `○` line, the item stays ✅. */
|
|
325
|
+
notes: string[];
|
|
326
|
+
/** The item doesn't apply (plan, no checks): a `➖` line with the reason instead of ✅/❌. */
|
|
327
|
+
skip?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export class GhError extends Error {}
|
|
331
|
+
|
|
332
|
+
// ----------------------------------------------------------------------------
|
|
333
|
+
// GraphQL
|
|
334
|
+
// ----------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
const REF = "id number title url closed";
|
|
337
|
+
/** Numbers of the label's open tasks in the "new decision" line; the rest as a count. */
|
|
338
|
+
const OPEN_SHOWN = 10;
|
|
339
|
+
/**
|
|
340
|
+
* How many recent PRs task close looks at when the PR isn't linked to the task: it's called right after the merge, so
|
|
341
|
+
* the PR is among them.
|
|
342
|
+
*/
|
|
343
|
+
const RECENT_PRS = 50;
|
|
344
|
+
/**
|
|
345
|
+
* A PR head's checks for `checkStates` — the one selection of every query that reads them (`PR_SHOW`, `MergeRules`):
|
|
346
|
+
* without the run's id and its app reruns can't be told apart (`tests/standards/check-run-state`).
|
|
347
|
+
*/
|
|
348
|
+
export const HEAD_CHECKS = `commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion databaseId checkSuite { app { databaseId } } } ... on StatusContext { context state } } } } } } }`;
|
|
349
|
+
/** A PR for task show: its state and the checks of its head. */
|
|
350
|
+
const PR_SHOW = `number state merged isDraft headRefName
|
|
351
|
+
${HEAD_CHECKS}`;
|
|
352
|
+
export const Q = {
|
|
353
|
+
/** The repository's current name: GitHub answers a renamed repository's former name with it (`canonicalRepo`). */
|
|
354
|
+
RepoName: `query RepoName($owner: String!, $name: String!) {
|
|
355
|
+
repository(owner: $owner, name: $name) { nameWithOwner }
|
|
356
|
+
}`,
|
|
357
|
+
RepoState: `query RepoState($owner: String!, $name: String!) {
|
|
358
|
+
repository(owner: $owner, name: $name) {
|
|
359
|
+
id name nameWithOwner
|
|
360
|
+
owner {
|
|
361
|
+
__typename id login
|
|
362
|
+
... on Organization {
|
|
363
|
+
issueTypes(first: 50) { nodes { id name isEnabled } }
|
|
364
|
+
issueFields(first: 50) { nodes { __typename ... on IssueFieldSingleSelect { id name options { id name } } } }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
projectsV2(first: 20) { nodes { ${REF} } }
|
|
368
|
+
}
|
|
369
|
+
}`,
|
|
370
|
+
RepoIssues: `query RepoIssues($owner: String!, $name: String!, $after: String) {
|
|
371
|
+
repository(owner: $owner, name: $name) {
|
|
372
|
+
issues(states: OPEN, first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } }
|
|
373
|
+
}
|
|
374
|
+
}`,
|
|
375
|
+
OwnerProjects: `query OwnerProjects($login: String!, $query: String!) {
|
|
376
|
+
repositoryOwner(login: $login) { ... on ProjectV2Owner { projectsV2(first: 20, query: $query) { nodes { ${REF} } } } }
|
|
377
|
+
}`,
|
|
378
|
+
TemplateProject: `query TemplateProject($login: String!, $number: Int!) {
|
|
379
|
+
repositoryOwner(login: $login) { ... on ProjectV2Owner { projectV2(number: $number) { ${REF} } } }
|
|
380
|
+
}`,
|
|
381
|
+
ProjectState: `query ProjectState($id: ID!) {
|
|
382
|
+
node(id: $id) {
|
|
383
|
+
... on ProjectV2 {
|
|
384
|
+
${REF}
|
|
385
|
+
views(first: 50) {
|
|
386
|
+
nodes {
|
|
387
|
+
id number name layout filter
|
|
388
|
+
sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
|
|
389
|
+
verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
fields(first: 100) {
|
|
393
|
+
nodes {
|
|
394
|
+
__typename
|
|
395
|
+
... on ProjectV2FieldCommon { id name dataType }
|
|
396
|
+
... on ProjectV2Field { isIssueField }
|
|
397
|
+
... on ProjectV2SingleSelectField { isIssueField options { id name color description } }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
workflows(first: 50) { nodes { id number fullDatabaseId name enabled } }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}`,
|
|
404
|
+
IssueRef: `query IssueRef($owner: String!, $name: String!, $number: Int!) {
|
|
405
|
+
repository(owner: $owner, name: $name) {
|
|
406
|
+
issue(number: $number) {
|
|
407
|
+
id number title state stateReason url
|
|
408
|
+
issueType { name }
|
|
409
|
+
labels(first: ${ISSUE_LABEL_LIMIT}) { nodes { name } }
|
|
410
|
+
parent { id number }
|
|
411
|
+
subIssues(first: 100) { nodes { number state title } }
|
|
412
|
+
blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } }
|
|
413
|
+
milestone { id number title issues(states: OPEN) { totalCount } }
|
|
414
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { number state merged headRefName baseRefName } }
|
|
415
|
+
projectItems(first: 20) { nodes { id project { id } status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } }
|
|
416
|
+
issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}`,
|
|
420
|
+
// The epic refresh (task actualize): the text, links and milestone of the epic and of each of its open tasks; task
|
|
421
|
+
// close — the refresh's report among the epic's comments
|
|
422
|
+
TaskText: `query TaskText($owner: String!, $name: String!, $number: Int!) {
|
|
423
|
+
repository(owner: $owner, name: $name) {
|
|
424
|
+
issue(number: $number) {
|
|
425
|
+
id number title state body
|
|
426
|
+
labels(first: 20) { nodes { id name } }
|
|
427
|
+
milestone { number title state issues(states: OPEN) { totalCount } }
|
|
428
|
+
blockedBy(first: 50) { nodes { id number state repository { nameWithOwner } } }
|
|
429
|
+
comments(last: 50) { nodes { body url } }
|
|
430
|
+
subIssues(first: 100) { nodes { number title state milestone { number title state issues(states: OPEN) { totalCount } } } }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}`,
|
|
434
|
+
TaskContext: `query TaskContext($owner: String!, $name: String!) {
|
|
435
|
+
repository(owner: $owner, name: $name) {
|
|
436
|
+
label(name: "${EPIC_LABEL}") { id }
|
|
437
|
+
milestones(first: 100, states: OPEN) { nodes { id title } }
|
|
438
|
+
}
|
|
439
|
+
}`,
|
|
440
|
+
// HEAD is the default branch: labels are checked against what's already merged
|
|
441
|
+
SpecDecisions: `query SpecDecisions($owner: String!, $name: String!) {
|
|
442
|
+
repository(owner: $owner, name: $name) {
|
|
443
|
+
capabilities: object(expression: "HEAD:tests/capabilities") { ... on Tree { entries { name type } } }
|
|
444
|
+
standards: object(expression: "HEAD:tests/standards") { ... on Tree { entries { name type } } }
|
|
445
|
+
architecture: object(expression: "HEAD:tests/architecture") { ... on Tree { entries { name type } } }
|
|
446
|
+
model: object(expression: "HEAD:${MODEL_PATH}") { ... on Blob { text } }
|
|
447
|
+
}
|
|
448
|
+
}`,
|
|
449
|
+
LabelIssues: `query LabelIssues($owner: String!, $name: String!, $label: String!, $after: String) {
|
|
450
|
+
repository(owner: $owner, name: $name) {
|
|
451
|
+
label(name: $label) { issues(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } } }
|
|
452
|
+
}
|
|
453
|
+
}`,
|
|
454
|
+
RepoLabels: `query RepoLabels($owner: String!, $name: String!, $after: String) {
|
|
455
|
+
repository(owner: $owner, name: $name) {
|
|
456
|
+
labels(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id name color description issues(states: OPEN, first: ${OPEN_SHOWN}) { totalCount nodes { number } } } }
|
|
457
|
+
}
|
|
458
|
+
}`,
|
|
459
|
+
PrChange: `query PrChange($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
460
|
+
repository(owner: $owner, name: $name) {
|
|
461
|
+
pullRequest(number: $number) {
|
|
462
|
+
number headRefOid body
|
|
463
|
+
closingIssuesReferences(first: 20) { nodes { number } }
|
|
464
|
+
files(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path changeType additions deletions } }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}`,
|
|
468
|
+
ModelAt: `query ModelAt($owner: String!, $name: String!, $expression: String!) {
|
|
469
|
+
repository(owner: $owner, name: $name) { object(expression: $expression) { ... on Blob { text } } }
|
|
470
|
+
}`,
|
|
471
|
+
// Ref.rules — the rules of active rulesets (of the repository and the organization) that GitHub applies to the branch
|
|
472
|
+
MergeRules: `query MergeRules($owner: String!, $name: String!) {
|
|
473
|
+
repository(owner: $owner, name: $name) {
|
|
474
|
+
isPrivate mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
|
475
|
+
defaultBranchRef {
|
|
476
|
+
name
|
|
477
|
+
branchProtectionRule { requiredStatusCheckContexts }
|
|
478
|
+
rules(first: 100) { nodes { type repositoryRuleset { name } parameters { ...StatusChecks } } }
|
|
479
|
+
}
|
|
480
|
+
rulesets(first: 100) {
|
|
481
|
+
nodes {
|
|
482
|
+
id name enforcement
|
|
483
|
+
conditions { refName { include } }
|
|
484
|
+
bypassActors(first: 50) { nodes { bypassMode repositoryRoleDatabaseId } }
|
|
485
|
+
rules(first: 100) { nodes { type parameters { ...StatusChecks } } }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
merged: pullRequests(states: MERGED, last: 30) { nodes { ...Head } }
|
|
489
|
+
open: pullRequests(states: OPEN, last: 30) { nodes { ...Head } }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
fragment StatusChecks on RuleParameters { ... on RequiredStatusChecksParameters { strictRequiredStatusChecksPolicy requiredStatusChecks { context } } }
|
|
493
|
+
fragment Head on PullRequest {
|
|
494
|
+
number baseRefName
|
|
495
|
+
${HEAD_CHECKS}
|
|
496
|
+
}`,
|
|
497
|
+
// The task's PR that GitHub didn't link to it (task close): the latest open and merged ones, newest first
|
|
498
|
+
RecentPrs: `query RecentPrs($owner: String!, $name: String!) {
|
|
499
|
+
repository(owner: $owner, name: $name) {
|
|
500
|
+
defaultBranchRef { name }
|
|
501
|
+
pullRequests(states: [OPEN, MERGED], first: ${RECENT_PRS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number state merged headRefName baseRefName body } }
|
|
502
|
+
}
|
|
503
|
+
}`,
|
|
504
|
+
// task show and task answer: the description, the epic with its description and subtasks, blocked by, the PRs with
|
|
505
|
+
// their head's checks, Status and number fields of the project
|
|
506
|
+
IssueShow: `query IssueShow($owner: String!, $name: String!, $number: Int!) {
|
|
507
|
+
repository(owner: $owner, name: $name) {
|
|
508
|
+
issue(number: $number) {
|
|
509
|
+
id number title state stateReason url body
|
|
510
|
+
issueType { name }
|
|
511
|
+
labels(first: 20) { nodes { id name } }
|
|
512
|
+
milestone { title }
|
|
513
|
+
parent { number title body subIssues(first: 100) { nodes { number state title } } }
|
|
514
|
+
subIssues(first: 100) { nodes { number state title } }
|
|
515
|
+
blockedBy(first: 20) { nodes { number state title repository { nameWithOwner } } }
|
|
516
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { ${PR_SHOW} } }
|
|
517
|
+
projectItems(first: 20) {
|
|
518
|
+
nodes {
|
|
519
|
+
project { title }
|
|
520
|
+
fieldValues(first: 50) {
|
|
521
|
+
nodes {
|
|
522
|
+
__typename
|
|
523
|
+
... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } }
|
|
524
|
+
... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { name } } }
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}`,
|
|
533
|
+
// a PR that GitHub didn't link to the task yet (task show)
|
|
534
|
+
PrShow: `query PrShow($owner: String!, $name: String!, $number: Int!) {
|
|
535
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_SHOW} } }
|
|
536
|
+
}`,
|
|
537
|
+
IssueSearch: `query IssueSearch($q: String!) {
|
|
538
|
+
search(query: $q, type: ISSUE, first: 100) { nodes { ... on Issue { number title state } } }
|
|
539
|
+
}`,
|
|
540
|
+
ProjectItems: `query ProjectItems($id: ID!, $after: String) {
|
|
541
|
+
node(id: $id) {
|
|
542
|
+
... on ProjectV2 {
|
|
543
|
+
items(first: 100, after: $after) {
|
|
544
|
+
pageInfo { hasNextPage endCursor }
|
|
545
|
+
nodes {
|
|
546
|
+
id
|
|
547
|
+
content { __typename ... on Issue { id number state stateReason } }
|
|
548
|
+
status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}`,
|
|
554
|
+
} as const;
|
|
555
|
+
|
|
556
|
+
// Mutations take one $input parameter: input has only the given keys (GitHub reads null in input as "erase").
|
|
557
|
+
const M: Record<string, [field: string, inputType: string, select: string]> = {
|
|
558
|
+
LinkProject: ["linkProjectV2ToRepository", "LinkProjectV2ToRepositoryInput", "repository { id }"],
|
|
559
|
+
UnlinkProject: ["unlinkProjectV2FromRepository", "UnlinkProjectV2FromRepositoryInput", "repository { id }"],
|
|
560
|
+
CopyProject: ["copyProjectV2", "CopyProjectV2Input", `projectV2 { ${REF} }`],
|
|
561
|
+
CreateProject: ["createProjectV2", "CreateProjectV2Input", `projectV2 { ${REF} }`],
|
|
562
|
+
RenameProject: ["updateProjectV2", "UpdateProjectV2Input", "projectV2 { id }"],
|
|
563
|
+
CreateView: ["createProjectV2View", "CreateProjectV2ViewInput", "projectV2View { id }"],
|
|
564
|
+
UpdateView: ["updateProjectV2View", "UpdateProjectV2ViewInput", "projectV2View { id }"],
|
|
565
|
+
DeleteView: ["deleteProjectV2View", "DeleteProjectV2ViewInput", "clientMutationId"],
|
|
566
|
+
CreateField: ["createProjectV2Field", "CreateProjectV2FieldInput", "clientMutationId"],
|
|
567
|
+
UpdateField: ["updateProjectV2Field", "UpdateProjectV2FieldInput", "clientMutationId"],
|
|
568
|
+
DeleteField: ["deleteProjectV2Field", "DeleteProjectV2FieldInput", "clientMutationId"],
|
|
569
|
+
AddIssueField: ["createProjectV2IssueField", "CreateProjectV2IssueFieldInput", "clientMutationId"],
|
|
570
|
+
SetItemStatus: ["updateProjectV2ItemFieldValue", "UpdateProjectV2ItemFieldValueInput", "projectV2Item { id }"],
|
|
571
|
+
AddItem: ["addProjectV2ItemById", "AddProjectV2ItemByIdInput", "item { id }"],
|
|
572
|
+
DeleteItem: ["deleteProjectV2Item", "DeleteProjectV2ItemInput", "deletedItemId"],
|
|
573
|
+
CreateIssueType: ["createIssueType", "CreateIssueTypeInput", "issueType { id }"],
|
|
574
|
+
UpdateIssueType: ["updateIssueType", "UpdateIssueTypeInput", "issueType { id }"],
|
|
575
|
+
CreateIssue: ["createIssue", "CreateIssueInput", "issue { id number title url projectItems(first: 10) { nodes { id project { id } } } }"],
|
|
576
|
+
CreateLabel: ["createLabel", "CreateLabelInput", "label { id }"],
|
|
577
|
+
UpdateLabel: ["updateLabel", "UpdateLabelInput", "label { id }"],
|
|
578
|
+
DeleteLabel: ["deleteLabel", "DeleteLabelInput", "clientMutationId"],
|
|
579
|
+
AddLabels: ["addLabelsToLabelable", "AddLabelsToLabelableInput", "clientMutationId"],
|
|
580
|
+
RemoveLabels: ["removeLabelsFromLabelable", "RemoveLabelsFromLabelableInput", "clientMutationId"],
|
|
581
|
+
AddBlockedBy: ["addBlockedBy", "AddBlockedByInput", "issue { id }"],
|
|
582
|
+
RemoveBlockedBy: ["removeBlockedBy", "RemoveBlockedByInput", "issue { id }"],
|
|
583
|
+
SetIssueField: ["setIssueFieldValue", "SetIssueFieldValueInput", "issue { id }"],
|
|
584
|
+
CloseIssue: ["closeIssue", "CloseIssueInput", "issue { id state }"],
|
|
585
|
+
UpdateIssue: ["updateIssue", "UpdateIssueInput", "issue { id }"],
|
|
586
|
+
AddComment: ["addComment", "AddCommentInput", "subject { id }"],
|
|
587
|
+
CreateRuleset: ["createRepositoryRuleset", "CreateRepositoryRulesetInput", "ruleset { id }"],
|
|
588
|
+
UpdateRuleset: ["updateRepositoryRuleset", "UpdateRepositoryRulesetInput", "ruleset { id }"],
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* REST mutations — what GraphQL lacks: the repository's merge settings (`UpdateRepositoryInput` doesn't know them).
|
|
593
|
+
* Input — `repo` and the request fields; `gh api -F` sends true/false as booleans.
|
|
594
|
+
*/
|
|
595
|
+
const REST: Record<string, (input: Record<string, unknown>) => string[]> = {
|
|
596
|
+
UpdateRepository: ({ repo, ...fields }) => ["api", "-X", "PATCH", `repos/${repo}`, ...Object.entries(fields).flatMap(([k, v]) => ["-F", `${k}=${v}`])],
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
function mutationQuery(op: string): string {
|
|
600
|
+
const spec = M[op];
|
|
601
|
+
if (!spec) throw new GhError(`unknown mutation ${op}`);
|
|
602
|
+
const [field, inputType, select] = spec;
|
|
603
|
+
return `mutation ${op}($input: ${inputType}!) { ${field}(input: $input) { ${select} } }`;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** A GraphQL query; partial data with errors — a warning, but for a mutation (strict) an error: no result received. */
|
|
607
|
+
export function graphql(io: Io, query: string, variables: Record<string, unknown>, strict = false): Any {
|
|
608
|
+
const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query, variables }));
|
|
609
|
+
let res: Any;
|
|
610
|
+
try {
|
|
611
|
+
res = JSON.parse(out);
|
|
612
|
+
} catch {
|
|
613
|
+
throw new GhError(`gh api graphql: the response is not JSON: ${out.slice(0, 200)}`);
|
|
614
|
+
}
|
|
615
|
+
const errors: string[] = (res.errors ?? []).map((e: Any) => e.message ?? String(e));
|
|
616
|
+
if (errors.length && (!res.data || strict)) throw new GhError(`GraphQL: ${errors.join("; ")}`);
|
|
617
|
+
if (errors.length) io.err(`GraphQL warning: ${errors.join("; ")}`);
|
|
618
|
+
return res.data;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function mutate(io: Io, m: Mutation): Any {
|
|
622
|
+
const rest = REST[m.op];
|
|
623
|
+
return rest ? restCall(io, rest(m.input)) : graphql(io, mutationQuery(m.op), { input: m.input }, true);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** A REST request; an error response (realGh returns JSON even with a code ≠ 0) is an exception with its message. */
|
|
627
|
+
function restCall(io: Io, args: string[]): Any {
|
|
628
|
+
// `-X PATCH repos/…` or GET `repos/…`
|
|
629
|
+
const what = args.slice(1).filter((a) => !a.startsWith("-")).slice(0, 2).join(" ");
|
|
630
|
+
const out = io.gh(args);
|
|
631
|
+
let res: Any;
|
|
632
|
+
try {
|
|
633
|
+
res = JSON.parse(out);
|
|
634
|
+
} catch {
|
|
635
|
+
throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
|
|
636
|
+
}
|
|
637
|
+
if (res?.message && res.id === undefined) throw new GhError(`${what}: ${res.message}`);
|
|
638
|
+
return res;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* A Claude Code cloud session (docs/cloud-sessions.md): its proxy closes GraphQL, `search/…` and Projects v2 — only the
|
|
643
|
+
* REST of `repos/{owner}/{repo}/…` of the session's repositories.
|
|
644
|
+
*/
|
|
645
|
+
const inCloud = (io: Io) => io.env.CLAUDE_CODE_REMOTE === "true";
|
|
646
|
+
/** The commands with a REST path in the cloud (#603): the rest need GraphQL or the project and refuse there. */
|
|
647
|
+
const CLOUD_COMMANDS = ["task show", "task answer", "task new"];
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* A REST request of the cloud's path: the payload — JSON on stdin (a description doesn't fit an argument); an error
|
|
651
|
+
* response is an exception with its message, a missing resource (404) with `missing` — null; an empty answer (204) —
|
|
652
|
+
* null.
|
|
653
|
+
*/
|
|
654
|
+
function rest(io: Io, route: string, o: { method?: string; payload?: unknown; missing?: boolean } = {}): Any {
|
|
655
|
+
const what = `${o.method ?? "GET"} ${route}`;
|
|
656
|
+
const out = io.gh(["api", ...(o.method ? ["-X", o.method] : []), route, ...(o.payload === undefined ? [] : ["--input", "-"])], o.payload === undefined ? undefined : JSON.stringify(o.payload));
|
|
657
|
+
let res: Any;
|
|
658
|
+
try {
|
|
659
|
+
res = out.trim() ? JSON.parse(out) : null;
|
|
660
|
+
} catch {
|
|
661
|
+
throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
|
|
662
|
+
}
|
|
663
|
+
if (res && !Array.isArray(res) && typeof res.message === "string" && res.id === undefined) {
|
|
664
|
+
if (o.missing && String(res.status) === "404") return null;
|
|
665
|
+
throw new GhError(`${what}: ${res.message}`);
|
|
666
|
+
}
|
|
667
|
+
return res;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** A whole REST list: pages of 100 until a shorter one. */
|
|
671
|
+
function restList(io: Io, route: string): Any[] {
|
|
672
|
+
const out: Any[] = [];
|
|
673
|
+
for (let page = 1; page <= 100; page++) {
|
|
674
|
+
const rows: Any[] = rest(io, `${route}${route.includes("?") ? "&" : "?"}per_page=100&page=${page}`) ?? [];
|
|
675
|
+
out.push(...rows);
|
|
676
|
+
if (rows.length < 100) break;
|
|
677
|
+
}
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** `owner/repo` of a REST `repository_url` (`https://api.github.com/repos/owner/repo`). */
|
|
682
|
+
const repoOfUrl = (url: string | undefined, slug: string) => /\/repos\/([^/]+\/[^/]+)$/.exec(url ?? "")?.[1] ?? slug;
|
|
683
|
+
|
|
684
|
+
/** A REST issue in the shape of the GraphQL answer: the state and its reason in upper case, labels with ids. */
|
|
685
|
+
function restIssueNode(i: Any): Any {
|
|
686
|
+
return {
|
|
687
|
+
id: i.node_id,
|
|
688
|
+
databaseId: i.id,
|
|
689
|
+
number: i.number,
|
|
690
|
+
title: i.title,
|
|
691
|
+
state: String(i.state).toUpperCase(),
|
|
692
|
+
stateReason: i.state_reason ? String(i.state_reason).toUpperCase() : null,
|
|
693
|
+
url: i.html_url,
|
|
694
|
+
body: i.body ?? "",
|
|
695
|
+
issueType: i.type?.name ? { name: i.type.name } : null,
|
|
696
|
+
labels: { nodes: (i.labels ?? []).map((l: Any) => ({ id: l.node_id, name: l.name })) },
|
|
697
|
+
milestone: i.milestone ? { id: i.milestone.node_id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.open_issues ?? 0 } : null,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** The issue over REST (#603); not an issue (no such number, a PR) — null. */
|
|
702
|
+
function restIssue(io: Io, slug: string, number: number): Any {
|
|
703
|
+
const i = rest(io, `repos/${slug}/issues/${number}`, { missing: true });
|
|
704
|
+
return i && !i.pull_request ? restIssueNode(i) : null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** An issue in a REST list (sub-issues, blocked by) in the shape of the GraphQL answer. */
|
|
708
|
+
const restBrief = (x: Any, slug: string) => ({ id: x.node_id, databaseId: x.id, number: x.number, state: String(x.state).toUpperCase(), title: x.title, repository: { nameWithOwner: repoOfUrl(x.repository_url, slug) } });
|
|
709
|
+
const restSubIssues = (io: Io, slug: string, number: number) => ({ nodes: restList(io, `repos/${slug}/issues/${number}/sub_issues`).map((x) => restBrief(x, slug)) });
|
|
710
|
+
|
|
711
|
+
/** The issue's sub-issues, blocked by and epic over REST, in the shape of the GraphQL answer. */
|
|
712
|
+
function restLinks(io: Io, slug: string, number: number): { subIssues: Any; blockedBy: Any; parent: Any } {
|
|
713
|
+
const parent = rest(io, `repos/${slug}/issues/${number}/parent`, { missing: true });
|
|
714
|
+
return {
|
|
715
|
+
subIssues: restSubIssues(io, slug, number),
|
|
716
|
+
blockedBy: { nodes: restList(io, `repos/${slug}/issues/${number}/dependencies/blocked_by`).map((x) => restBrief(x, slug)) },
|
|
717
|
+
parent: parent && restIssueNode(parent),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The real `gh`: an exit code ≠ 0 is an exception with stderr. */
|
|
722
|
+
export function realGh(args: string[], stdin?: string): string {
|
|
723
|
+
const r = spawnSync("gh", args, { input: stdin, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
724
|
+
if (r.error) {
|
|
725
|
+
if ((r.error as NodeJS.ErrnoException).code === "ENOENT") throw new GhError("gh command not found; the gh CLI must be installed");
|
|
726
|
+
throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${r.error.message}`);
|
|
727
|
+
}
|
|
728
|
+
// gh api graphql exits with 1 on GraphQL errors, but the JSON with errors is in stdout — let graphql() parse it
|
|
729
|
+
if (r.status !== 0 && !r.stdout.trim().startsWith("{")) throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${(r.stderr || "").trim().slice(0, 500)}`);
|
|
730
|
+
return r.stdout;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ----------------------------------------------------------------------------
|
|
734
|
+
// Reading the state
|
|
735
|
+
// ----------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
function pages<T>(fetch: (after: string | null) => { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] }): T[] {
|
|
738
|
+
const out: T[] = [];
|
|
739
|
+
let after: string | null = null;
|
|
740
|
+
for (let i = 0; i < 100; i++) {
|
|
741
|
+
const page = fetch(after);
|
|
742
|
+
out.push(...page.nodes);
|
|
743
|
+
if (!page.pageInfo.hasNextPage) break;
|
|
744
|
+
after = page.pageInfo.endCursor;
|
|
745
|
+
}
|
|
746
|
+
return out;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const ref = (p: Any): ProjectRef => ({ id: p.id, number: p.number, title: p.title, url: p.url, closed: !!p.closed });
|
|
750
|
+
|
|
751
|
+
function loadRepo(io: Io, slug: string): State["repo"] {
|
|
752
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
753
|
+
const data = graphql(io, Q.RepoState, { owner, name });
|
|
754
|
+
const r = data?.repository;
|
|
755
|
+
if (!r) throw new GhError(`repository ${slug} not found or no access`);
|
|
756
|
+
const o = r.owner;
|
|
757
|
+
return {
|
|
758
|
+
id: r.id,
|
|
759
|
+
name: r.name,
|
|
760
|
+
nameWithOwner: r.nameWithOwner,
|
|
761
|
+
owner: {
|
|
762
|
+
org: o.__typename === "Organization",
|
|
763
|
+
id: o.id,
|
|
764
|
+
login: o.login,
|
|
765
|
+
issueTypes: (o.issueTypes?.nodes ?? []).filter(Boolean),
|
|
766
|
+
issueFields: (o.issueFields?.nodes ?? []).filter((f: Any) => f && f.name).map((f: Any) => ({ id: f.id, name: f.name, options: f.options ?? [] })),
|
|
767
|
+
},
|
|
768
|
+
linked: (r.projectsV2?.nodes ?? []).filter(Boolean).map(ref),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** The repository's project: a linked one titled with the repository name, otherwise the first open linked one. */
|
|
773
|
+
const mainProject = (repo: State["repo"]) => repo.linked.find((p) => p.title === repo.name && !p.closed) ?? repo.linked.find((p) => !p.closed) ?? null;
|
|
774
|
+
|
|
775
|
+
export function loadState(io: Io, slug: string): State {
|
|
776
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
777
|
+
const repo = loadRepo(io, slug);
|
|
778
|
+
const openIssues = pages<{ id: string; number: number }>((after) => graphql(io, Q.RepoIssues, { owner, name, after }).repository.issues);
|
|
779
|
+
const main = mainProject(repo);
|
|
780
|
+
const labels = loadLabels(io, slug);
|
|
781
|
+
return { repo, project: main ? loadProject(io, main.id) : null, openIssues, labels, labelIssues: mergedIssues(io, slug, labels), decisions: loadDecisions(io, slug), merge: loadMerge(io, slug) };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const statusChecks = (rule: Any): { strict: boolean; contexts: string[] } | null =>
|
|
785
|
+
rule?.type === "REQUIRED_STATUS_CHECKS" ? { strict: !!rule.parameters?.strictRequiredStatusChecksPolicy, contexts: (rule.parameters?.requiredStatusChecks ?? []).map((c: Any) => c.context) } : null;
|
|
786
|
+
|
|
787
|
+
/** From the worst: a name reported by several apps gets the worst of their states. */
|
|
788
|
+
const WORST: CheckBucket[] = ["fail", "cancel", "pending", "skipping", "pass"];
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* The state of each check of a PR head by name, from the `statusCheckRollup` contexts (`HEAD_CHECKS`) — the one rule
|
|
792
|
+
* of `task show` and `project check`. A check-run: not completed — pending; success — pass; neutral, skipped —
|
|
793
|
+
* skipping; cancelled — cancel; any other conclusion — fail. Reruns of one name — the last check-run of each app (the
|
|
794
|
+
* largest `databaseId`), not the best one and not the order of the response; several apps — the worst of their states.
|
|
795
|
+
* A commit status — GitHub already keeps the latest of a context: success — pass, pending and expected — pending,
|
|
796
|
+
* otherwise fail. The copy — `CHECK_STATES` in ci-wait's `wait-ci.sh` (REST check-runs): both answer by one table,
|
|
797
|
+
* `tests/standards/check-run-state`.
|
|
798
|
+
*/
|
|
799
|
+
export function checkStates(nodes: Any[]): Map<string, CheckBucket> {
|
|
800
|
+
const app = (c: Any) => JSON.stringify([c.name, c.checkSuite?.app?.databaseId ?? null]);
|
|
801
|
+
const last = new Map<string, Any>();
|
|
802
|
+
for (const c of nodes) {
|
|
803
|
+
if (c?.__typename === "CheckRun" && (!last.has(app(c)) || (last.get(app(c)).databaseId ?? 0) <= (c.databaseId ?? 0))) last.set(app(c), c);
|
|
804
|
+
}
|
|
805
|
+
const out = new Map<string, CheckBucket>();
|
|
806
|
+
const put = (name: string, state: CheckBucket) => {
|
|
807
|
+
const was = out.get(name);
|
|
808
|
+
if (!was || WORST.indexOf(state) < WORST.indexOf(was)) out.set(name, state);
|
|
809
|
+
};
|
|
810
|
+
// in the order of the response: task show lists the names as GitHub gives them
|
|
811
|
+
for (const c of nodes) {
|
|
812
|
+
if (c?.__typename === "StatusContext") put(c.context, c.state === "SUCCESS" ? "pass" : c.state === "PENDING" || c.state === "EXPECTED" ? "pending" : "fail");
|
|
813
|
+
if (c?.__typename !== "CheckRun" || last.get(app(c)) !== c) continue;
|
|
814
|
+
const s = c.status !== "COMPLETED" ? "pending" : c.conclusion === "SUCCESS" ? "pass" : c.conclusion === "NEUTRAL" || c.conclusion === "SKIPPED" ? "skipping" : c.conclusion === "CANCELLED" ? "cancel" : "fail";
|
|
815
|
+
put(c.name, s);
|
|
816
|
+
}
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const rollupOf = (pr: Any): Any[] => pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
|
|
821
|
+
|
|
822
|
+
/** A private repository on Free: GraphQL silently returns empty rules, REST — 403 "Upgrade to GitHub Pro…". */
|
|
823
|
+
function rulesetsUnavailable(io: Io, slug: string): string | null {
|
|
824
|
+
const out = io.gh(["api", `repos/${slug}/rulesets`]);
|
|
825
|
+
let res: Any;
|
|
826
|
+
try {
|
|
827
|
+
res = JSON.parse(out);
|
|
828
|
+
} catch {
|
|
829
|
+
throw new GhError(`gh api repos/${slug}/rulesets: the response is not JSON: ${out.slice(0, 200)}`);
|
|
830
|
+
}
|
|
831
|
+
return !Array.isArray(res) && /upgrade/i.test(res?.message ?? "") ? String(res.message) : null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function loadMerge(io: Io, slug: string): Merge | null {
|
|
835
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
836
|
+
const r = graphql(io, Q.MergeRules, { owner, name })?.repository;
|
|
837
|
+
const b = r?.defaultBranchRef;
|
|
838
|
+
if (!b) return null;
|
|
839
|
+
const heads = (nodes: Any[]): Head[] =>
|
|
840
|
+
(nodes ?? []).filter((p) => p && p.baseRefName === b.name).map((p) => ({ number: p.number, checks: Object.fromEntries(checkStates(rollupOf(p))) })).filter((h) => Object.keys(h.checks).length);
|
|
841
|
+
const bp = b.branchProtectionRule;
|
|
842
|
+
const rules = (b.rules?.nodes ?? []).flatMap((x: Any) => {
|
|
843
|
+
const sc = statusChecks(x);
|
|
844
|
+
return sc ? [{ source: `ruleset ${q(x.repositoryRuleset?.name ?? "?")}`, contexts: sc.contexts }] : [];
|
|
845
|
+
});
|
|
846
|
+
if (bp?.requiredStatusCheckContexts?.length) rules.push({ source: "branch protection", contexts: bp.requiredStatusCheckContexts });
|
|
847
|
+
const sets: Any[] = r.rulesets?.nodes ?? [];
|
|
848
|
+
const o = sets.find((x) => x?.name === RULESET) ?? sets.find((x) => x?.name === FORMER_RULESET);
|
|
849
|
+
const osc = o ? ((o.rules?.nodes ?? []).map(statusChecks).find(Boolean) ?? { strict: false, contexts: [] }) : null;
|
|
850
|
+
return {
|
|
851
|
+
branch: b.name,
|
|
852
|
+
unavailable: r.isPrivate ? rulesetsUnavailable(io, slug) : null,
|
|
853
|
+
rules,
|
|
854
|
+
own: o
|
|
855
|
+
? {
|
|
856
|
+
id: o.id,
|
|
857
|
+
name: o.name,
|
|
858
|
+
enforcement: o.enforcement,
|
|
859
|
+
include: o.conditions?.refName?.include ?? [],
|
|
860
|
+
...osc!,
|
|
861
|
+
bypassAdmin: (o.bypassActors?.nodes ?? []).some((a: Any) => a?.repositoryRoleDatabaseId === ADMIN_ROLE && a.bypassMode === "ALWAYS"),
|
|
862
|
+
}
|
|
863
|
+
: null,
|
|
864
|
+
merged: heads(r.merged?.nodes).slice(-STABLE_PRS),
|
|
865
|
+
open: heads(r.open?.nodes).filter((h) => Object.values(h.checks).every((c) => c !== "pending")),
|
|
866
|
+
methods: { merge: !!r.mergeCommitAllowed, squash: !!r.squashMergeAllowed, rebase: !!r.rebaseMergeAllowed },
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** Tasks of old labels whose name is taken (by another label or an old label earlier in the list) — fix merges them. */
|
|
871
|
+
function mergedIssues(io: Io, slug: string, labels: Label[]): State["labelIssues"] {
|
|
872
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
873
|
+
const seen = new Set(labels.filter((l) => !legacyDecision(l.name)).map((l) => l.name));
|
|
874
|
+
const out: State["labelIssues"] = {};
|
|
875
|
+
for (const l of labels) {
|
|
876
|
+
const old = legacyDecision(l.name);
|
|
877
|
+
if (!old) continue;
|
|
878
|
+
if (seen.has(old.name)) out[l.id] = pages<{ id: string; number: number }>((after) => graphql(io, Q.LabelIssues, { owner, name, label: l.name, after }).repository.label.issues);
|
|
879
|
+
seen.add(old.name);
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function loadLabels(io: Io, slug: string): Label[] {
|
|
885
|
+
// the cloud (#603) — REST: the labels without their open tasks (task new doesn't need them)
|
|
886
|
+
if (inCloud(io)) return restList(io, `repos/${slug}/labels`).map((l) => ({ id: l.node_id, name: l.name, color: l.color, description: l.description ?? null, open: { total: 0, numbers: [] } }));
|
|
887
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
888
|
+
return pages<Any>((after) => graphql(io, Q.RepoLabels, { owner, name, after }).repository.labels).map(({ issues, ...l }) => ({
|
|
889
|
+
...l,
|
|
890
|
+
open: { total: issues?.totalCount ?? 0, numbers: (issues?.nodes ?? []).map((i: Any) => i.number) },
|
|
891
|
+
}));
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Architecture model modules and their directories: the model text goes to a temporary file, `modules` is printed by
|
|
896
|
+
* a separate process of the same runtime (Bun strips types; the model doesn't need the spec skill's `import type`).
|
|
897
|
+
* Didn't load — the reason as a string.
|
|
898
|
+
*/
|
|
899
|
+
export function modelModules(text: string): Record<string, string[]> | string {
|
|
900
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "github-model-"));
|
|
901
|
+
try {
|
|
902
|
+
writeFileSync(path.join(dir, "model.ts"), text);
|
|
903
|
+
writeFileSync(
|
|
904
|
+
path.join(dir, "modules.mjs"),
|
|
905
|
+
'const m = await import("./model.ts");\n' +
|
|
906
|
+
"const paths = (p) => (Array.isArray(p) ? p : [p]).map((x) => String(x).replace(/\\/+$/, \"\"));\n" +
|
|
907
|
+
"console.log(JSON.stringify(Object.fromEntries(Object.entries(m.default?.modules ?? {}).map(([k, v]) => [k, paths(v?.path ?? [])]))));\n",
|
|
908
|
+
);
|
|
909
|
+
const r = spawnSync(process.execPath, ["--no-env-file", path.join(dir, "modules.mjs")], { encoding: "utf8", timeout: 30_000 });
|
|
910
|
+
if (r.status !== 0) return ((r.stderr || r.error?.message || "").trim().split("\n").find((l) => /error/i.test(l)) ?? "the process exited with an error").trim().slice(0, 200);
|
|
911
|
+
return JSON.parse(r.stdout) as Record<string, string[]>;
|
|
912
|
+
} catch (e) {
|
|
913
|
+
return (e as Error).message;
|
|
914
|
+
} finally {
|
|
915
|
+
rmSync(dir, { recursive: true, force: true });
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** Default-branch decisions: spec tree folders by kind, and model modules for architecture. */
|
|
920
|
+
export function loadDecisions(io: Io, slug: string): Decisions {
|
|
921
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
922
|
+
const r = inCloud(io) ? restSpecTree(io, slug) : (graphql(io, Q.SpecDecisions, { owner, name })?.repository ?? {});
|
|
923
|
+
const dirs = (t: Any): string[] => (t?.entries ?? []).filter((e: Any) => e.type === "tree").map((e: Any) => e.name as string);
|
|
924
|
+
const names = { capability: dirs(r.capabilities), standard: dirs(r.standards), architecture: dirs(r.architecture) };
|
|
925
|
+
let modelError: string | null = null;
|
|
926
|
+
if (r.model) {
|
|
927
|
+
const mods = modelModules(r.model.text ?? "");
|
|
928
|
+
if (typeof mods === "string") modelError = mods;
|
|
929
|
+
else names.architecture.push(...Object.keys(mods));
|
|
930
|
+
}
|
|
931
|
+
for (const k of DECISION_KINDS) names[k] = [...new Set(names[k])].sort();
|
|
932
|
+
return { names, modelError };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** SpecDecisions over the REST contents of the default branch — the cloud (#603): folders as `tree` entries, the model's text. */
|
|
936
|
+
function restSpecTree(io: Io, slug: string): Any {
|
|
937
|
+
const dir = (kind: DecisionKind) => {
|
|
938
|
+
const entries = rest(io, `repos/${slug}/contents/tests/${DECISIONS[kind].dir}`, { missing: true });
|
|
939
|
+
return Array.isArray(entries) ? { entries: entries.map((e: Any) => ({ name: e.name, type: e.type === "dir" ? "tree" : "blob" })) } : null;
|
|
940
|
+
};
|
|
941
|
+
const model = rest(io, `repos/${slug}/contents/${MODEL_PATH}`, { missing: true });
|
|
942
|
+
return { capabilities: dir("capability"), standards: dir("standard"), architecture: dir("architecture"), model: model?.content ? { text: Buffer.from(model.content, "base64").toString("utf8") } : null };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* `capability:billing` → the decision's kind and name; anything else — null. An old-form label or the kind of a new
|
|
947
|
+
* decision in `--labels`.
|
|
948
|
+
*/
|
|
949
|
+
export function legacyDecision(label: string): { kind: DecisionKind; name: string } | null {
|
|
950
|
+
const i = label.indexOf(":");
|
|
951
|
+
const kind = label.slice(0, i) as DecisionKind;
|
|
952
|
+
return i > 0 && DECISION_KINDS.includes(kind) && label.length > i + 1 ? { kind, name: label.slice(i + 1) } : null;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* A decision label is marked by its description `<marker> <paths>`: a regular label's color may match, and so may its
|
|
957
|
+
* name. The marker is written in the project language and recognized in any dictionary language: fix rewrites a
|
|
958
|
+
* description in another language rather than taking the label for a regular one.
|
|
959
|
+
*/
|
|
960
|
+
const decisionMark = () => `${N.labelMarkers.decision} `;
|
|
961
|
+
const decisionPath = (kind: DecisionKind, name: string) => (kind === "architecture" ? locale.fill(N.labelMarkers.architecturePath, { name }) : `tests/${DECISIONS[kind].dir}/${name}`);
|
|
962
|
+
/** The marker of any dictionary language the description starts with; none — null. */
|
|
963
|
+
const markOf = (description: string | null | undefined, marks: string[]) => marks.map((m) => `${m} `).find((m) => description?.startsWith(m)) ?? null;
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* A decision label: the senior kind's color, the paths of all kinds in the description; GitHub won't accept one longer
|
|
967
|
+
* than 100 characters — then the kinds.
|
|
968
|
+
*/
|
|
969
|
+
export function decisionLabel(name: string, kinds: DecisionKind[]): { name: string; color: string; description: string } {
|
|
970
|
+
const ks = DECISION_KINDS.filter((k) => kinds.includes(k));
|
|
971
|
+
const full = decisionMark() + ks.map((k) => decisionPath(k, name)).join("; ");
|
|
972
|
+
return { name, color: DECISIONS[ks[0]!].color, description: full.length <= 100 ? full : decisionMark() + ks.join(", ") };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** A decision label's kinds — by its description; a regular label — null. */
|
|
976
|
+
export function labelKinds(description: string | null | undefined): DecisionKind[] | null {
|
|
977
|
+
const mark = markOf(description, locale.every((n) => n.labelMarkers.decision));
|
|
978
|
+
if (!mark) return null;
|
|
979
|
+
const parts = description!.slice(mark.length).split(/; |, /);
|
|
980
|
+
return DECISION_KINDS.filter((k) => parts.some((p) => p === k || p.startsWith(`tests/${DECISIONS[k].dir}`)));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* A slice label is marked by its description `<slice marker> <slice>[ — note]` (the marker is `labelMarkers.slice` of
|
|
985
|
+
* any dictionary language): a slice of the project's tasks (apps, teams), not a decision, though its name may match a
|
|
986
|
+
* model module. The slice lives with the label, not in `.agents/elves.json`: `install` and `update` rewrite the
|
|
987
|
+
* install manifest, and `check` reads everything through the API.
|
|
988
|
+
*/
|
|
989
|
+
function labelDimension(description: string | null | undefined): string | null {
|
|
990
|
+
const mark = markOf(description, locale.every((n) => n.labelMarkers.slice));
|
|
991
|
+
if (!mark) return null;
|
|
992
|
+
return description!.slice(mark.length).split(" — ")[0]!.trim() || null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Default-branch decisions by name: name → its kinds (the senior one first). */
|
|
996
|
+
function decisionsByName(d: Decisions): Map<string, DecisionKind[]> {
|
|
997
|
+
const out = new Map<string, DecisionKind[]>();
|
|
998
|
+
for (const k of DECISION_KINDS) for (const n of d.names[k]) out.set(n, [...(out.get(n) ?? []), k]);
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function loadProject(io: Io, id: string, withItems = true): Project {
|
|
1003
|
+
const p = graphql(io, Q.ProjectState, { id }).node;
|
|
1004
|
+
if (!p) throw new GhError(`project ${id} not found or no access (needs the project scope: gh auth refresh -s project)`);
|
|
1005
|
+
const items = withItems ? pages<Any>((after) => graphql(io, Q.ProjectItems, { id, after }).node.items) : [];
|
|
1006
|
+
return {
|
|
1007
|
+
...ref(p),
|
|
1008
|
+
views: p.views.nodes.map((v: Any) => ({
|
|
1009
|
+
id: v.id,
|
|
1010
|
+
number: v.number,
|
|
1011
|
+
name: v.name,
|
|
1012
|
+
layout: v.layout,
|
|
1013
|
+
filter: v.filter ?? null,
|
|
1014
|
+
sortBy: (v.sortByFields?.nodes ?? []).map((s: Any) => s.field?.name).filter(Boolean),
|
|
1015
|
+
columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
|
|
1016
|
+
})),
|
|
1017
|
+
fields: p.fields.nodes
|
|
1018
|
+
.filter((f: Any) => f && f.id)
|
|
1019
|
+
.map((f: Any) => ({ id: f.id, name: f.name, dataType: f.dataType, isIssueField: !!f.isIssueField, options: f.options ?? null })),
|
|
1020
|
+
workflows: p.workflows.nodes.filter(Boolean),
|
|
1021
|
+
items: items.map((it: Any) => ({
|
|
1022
|
+
id: it.id,
|
|
1023
|
+
issue: it.content?.__typename === "Issue" ? { id: it.content.id, number: it.content.number, state: it.content.state, stateReason: it.content.stateReason ?? null } : null,
|
|
1024
|
+
status: it.status?.name ?? null,
|
|
1025
|
+
})),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// ----------------------------------------------------------------------------
|
|
1030
|
+
// Checking against the canon
|
|
1031
|
+
// ----------------------------------------------------------------------------
|
|
1032
|
+
|
|
1033
|
+
const nums = (xs: { number: number }[]) => xs.map((x) => `#${x.number}`).join(", ");
|
|
1034
|
+
const q = (s: string) => `«${s}»`;
|
|
1035
|
+
|
|
1036
|
+
/** All discrepancies with the canon by item; each has fix steps. No project — only the linking item. */
|
|
1037
|
+
export function analyze(s: State): Check[] {
|
|
1038
|
+
const p = s.project;
|
|
1039
|
+
const checks: Check[] = [];
|
|
1040
|
+
const add = (key: string, title: string) => {
|
|
1041
|
+
const c: Check = { key, title, problems: [], notes: [] };
|
|
1042
|
+
checks.push(c);
|
|
1043
|
+
return Object.assign((text: string, ...steps: Step[]) => c.problems.push({ text, steps }), { note: (text: string) => c.notes.push(text), skip: (why: string) => void (c.skip = why) });
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
const link = add("link", `Project linked to the repository and named ${q(s.repo.name)}`);
|
|
1047
|
+
// the default-branch rule and the merge method don't depend on the project
|
|
1048
|
+
const merge = () => {
|
|
1049
|
+
if (!s.merge) return;
|
|
1050
|
+
analyzeMerge(s.merge, s.repo.id, add("merge", `Merge into ${s.merge.branch} — only with green required checks`));
|
|
1051
|
+
analyzeMethod(s.merge, s.repo.nameWithOwner, add("method", `PR merge method — rebase only`));
|
|
1052
|
+
};
|
|
1053
|
+
if (!p) {
|
|
1054
|
+
link("no open project is linked to the repository", { kind: "api", text: `find, copy from the template or create project ${q(s.repo.name)} and link it to ${s.repo.nameWithOwner}` });
|
|
1055
|
+
merge();
|
|
1056
|
+
return checks;
|
|
1057
|
+
}
|
|
1058
|
+
const used = p.items.length > 0;
|
|
1059
|
+
const guarded = (text: string, ...mutations: Mutation[]): Step => ({ kind: used ? "confirm" : "api", text, mutations });
|
|
1060
|
+
const api = (text: string, ...mutations: Mutation[]): Step => ({ kind: "api", text, mutations });
|
|
1061
|
+
const ui = (text: string, url: string): Step => ({ kind: "ui", text, url });
|
|
1062
|
+
|
|
1063
|
+
if (p.title !== s.repo.name) link(`title ${q(p.title)}`, guarded(`rename project ${q(p.title)} → ${q(s.repo.name)}`, { op: "RenameProject", input: { projectId: p.id, title: s.repo.name } }));
|
|
1064
|
+
for (const other of s.repo.linked.filter((x) => x.id !== p.id)) {
|
|
1065
|
+
link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Views: by name; missing — rename an extra one of the same layout or create one; extra ones — delete.
|
|
1069
|
+
const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
|
|
1070
|
+
const canonNames = new Set<string>(canonViews().map((v) => v.name));
|
|
1071
|
+
const spare = p.views.filter((v) => !canonNames.has(v.name));
|
|
1072
|
+
for (const c of canonViews()) {
|
|
1073
|
+
const v = p.views.find((x) => x.name === c.name);
|
|
1074
|
+
if (!v) {
|
|
1075
|
+
const cand = spare.find((x) => x.layout === c.layout);
|
|
1076
|
+
if (cand) {
|
|
1077
|
+
spare.splice(spare.indexOf(cand), 1);
|
|
1078
|
+
views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
|
|
1079
|
+
} else {
|
|
1080
|
+
views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input: { projectId: p.id, name: c.name, layout: c.layout } }));
|
|
1081
|
+
}
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (v.layout !== c.layout) views(`${q(c.name)} — ${v.layout}`, api(`${q(c.name)}: layout ${c.layout}`, { op: "UpdateView", input: { viewId: v.id, layout: c.layout } }));
|
|
1085
|
+
if (v.filter) views(`${q(c.name)} has filter ${q(v.filter)}`, api(`${q(c.name)}: remove filter ${q(v.filter)}`, { op: "UpdateView", input: { viewId: v.id, filter: "" } }));
|
|
1086
|
+
if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
|
|
1087
|
+
views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
for (const v of spare) views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
|
|
1091
|
+
|
|
1092
|
+
// Status: options by name or by a known English name — with the same id; missing ones — new.
|
|
1093
|
+
const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
|
|
1094
|
+
const sf = p.fields.find((f) => f.name === STATUS && f.options);
|
|
1095
|
+
if (!sf) {
|
|
1096
|
+
status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color: "GRAY", description: "" })) } }));
|
|
1097
|
+
} else if (sf.options!.map((o) => o.name).join("\n") !== statusOptions().join("\n")) {
|
|
1098
|
+
const left = [...sf.options!];
|
|
1099
|
+
const take = (pred: (o: Option) => boolean) => {
|
|
1100
|
+
const i = left.findIndex(pred);
|
|
1101
|
+
return i < 0 ? null : left.splice(i, 1)[0]!;
|
|
1102
|
+
};
|
|
1103
|
+
const aliases = statusAliases();
|
|
1104
|
+
const next = statusOptions().map((name) => {
|
|
1105
|
+
const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
|
|
1106
|
+
return o ? { id: o.id, name, color: o.color, description: o.description ?? "" } : { name, color: "GRAY", description: "" };
|
|
1107
|
+
});
|
|
1108
|
+
const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
|
|
1109
|
+
status(`options: ${sf.options!.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Fields: four numeric ones; a field in another dictionary language is renamed with the same id (the tasks' values are
|
|
1113
|
+
// kept); no custom fields beyond the canon; in an organization Priority is an issue field.
|
|
1114
|
+
const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
|
|
1115
|
+
const fieldAlias = fieldAliases();
|
|
1116
|
+
const renamed = new Set<Field>();
|
|
1117
|
+
for (const name of numberFields()) {
|
|
1118
|
+
const f = p.fields.find((x) => x.name === name);
|
|
1119
|
+
const create: Mutation = { op: "CreateField", input: { projectId: p.id, dataType: "NUMBER", name } };
|
|
1120
|
+
const other = f ? undefined : p.fields.find((x) => x.dataType === "NUMBER" && !renamed.has(x) && fieldAlias[x.name] === name);
|
|
1121
|
+
if (other) {
|
|
1122
|
+
renamed.add(other);
|
|
1123
|
+
fields(`missing ${q(name)}`, guarded(`rename field ${q(other.name)} → ${q(name)} (task values are kept)`, { op: "UpdateField", input: { fieldId: other.id, name } }));
|
|
1124
|
+
} else if (!f) fields(`missing ${q(name)}`, api(`create field ${q(name)} (Number)`, create));
|
|
1125
|
+
else if (f.dataType !== "NUMBER") fields(`${q(name)} — ${f.dataType}`, { kind: "confirm", text: `recreate field ${q(name)} as Number (the field's values will be lost)`, mutations: [{ op: "DeleteField", input: { fieldId: f.id } }, create] });
|
|
1126
|
+
}
|
|
1127
|
+
const orgPriority = s.repo.owner.org ? s.repo.owner.issueFields.find((f) => f.name === PRIORITY) : undefined;
|
|
1128
|
+
const isCanon = (f: Field) => BUILTIN_FIELDS.has(f.dataType) || f.name === STATUS || numberFields().includes(f.name) || (s.repo.owner.org && f.isIssueField && f.name === PRIORITY);
|
|
1129
|
+
const ownPriority = p.fields.find((f) => f.name === PRIORITY && !f.isIssueField);
|
|
1130
|
+
for (const f of p.fields.filter((x) => !isCanon(x) && x !== ownPriority && !renamed.has(x))) {
|
|
1131
|
+
fields(`extra ${q(f.name)} (${f.dataType}${f.isIssueField ? ", issue field" : ""})`, guarded(`delete field ${q(f.name)}`, { op: "DeleteField", input: { fieldId: f.id } }));
|
|
1132
|
+
}
|
|
1133
|
+
if (ownPriority && !s.repo.owner.org) fields(`extra ${q(PRIORITY)} — a personal account keeps no priority`, guarded(`delete field ${q(PRIORITY)}`, { op: "DeleteField", input: { fieldId: ownPriority.id } }));
|
|
1134
|
+
|
|
1135
|
+
if (s.repo.owner.org) {
|
|
1136
|
+
const org = s.repo.owner.login;
|
|
1137
|
+
const prio = add("priority", `${PRIORITY} — the organization's issue field in the project, ${q(N.views.table)} and ${q(N.views.board)} are sorted by it`);
|
|
1138
|
+
const connected = p.fields.some((f) => f.isIssueField && f.name === PRIORITY);
|
|
1139
|
+
if (!connected) {
|
|
1140
|
+
const connect: Mutation[] = orgPriority ? [{ op: "AddIssueField", input: { projectId: p.id, issueFieldId: orgPriority.id } }] : [];
|
|
1141
|
+
if (ownPriority) {
|
|
1142
|
+
prio(`own project field ${q(PRIORITY)} instead of the issue field`, { kind: "confirm", text: `delete the own field ${q(PRIORITY)} and connect the issue field ${PRIORITY}`, mutations: [{ op: "DeleteField", input: { fieldId: ownPriority.id } }, ...connect] });
|
|
1143
|
+
} else if (orgPriority) {
|
|
1144
|
+
prio(`issue field ${PRIORITY} not connected`, api(`connect the issue field ${PRIORITY}`, ...connect));
|
|
1145
|
+
}
|
|
1146
|
+
if (!orgPriority) prio(`the organization has no issue field ${PRIORITY}`, ui(`create the issue field ${PRIORITY} (single select: Urgent, High, Medium, Low), then fix again`, `https://github.com/organizations/${org}/settings/issue-fields`));
|
|
1147
|
+
}
|
|
1148
|
+
for (const name of [N.views.table, N.views.board]) {
|
|
1149
|
+
const v = p.views.find((x) => x.name === name);
|
|
1150
|
+
if (v && v.sortBy[0] !== PRIORITY) prio(`${q(name)} not sorted by ${PRIORITY}`, ui(`${q(name)}: View options → Sort by → ${PRIORITY} → Save`, `${p.url}/views/${v.number}`));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const types = add("types", `Issue types of the organization: ${issueTypes().join(", ")} — and only these`);
|
|
1154
|
+
const t = s.repo.owner.issueTypes;
|
|
1155
|
+
const url = `https://github.com/organizations/${org}/settings/issue-types`;
|
|
1156
|
+
const claimed = new Set<string>();
|
|
1157
|
+
const aliases = typeAliases();
|
|
1158
|
+
for (const name of issueTypes()) {
|
|
1159
|
+
const exact = t.find((x) => x.name === name);
|
|
1160
|
+
if (exact) {
|
|
1161
|
+
claimed.add(exact.id);
|
|
1162
|
+
if (!exact.isEnabled) types(`${q(name)} disabled`, { kind: "confirm", text: `enable type ${q(name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: exact.id, isEnabled: true } }] });
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
const alias = t.find((x) => !claimed.has(x.id) && aliases[x.name.toLowerCase()] === name);
|
|
1166
|
+
if (alias) {
|
|
1167
|
+
claimed.add(alias.id);
|
|
1168
|
+
types(`missing ${q(name)}`, { kind: "confirm", text: `rename type ${q(alias.name)} → ${q(name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: alias.id, name, isEnabled: true } }] });
|
|
1169
|
+
} else {
|
|
1170
|
+
types(`missing ${q(name)}`, { kind: "confirm", text: `create type ${q(name)}`, url, mutations: [{ op: "CreateIssueType", input: { ownerId: s.repo.owner.id, name, isEnabled: true, color: name === N.issueTypes.epic ? "PURPLE" : name === N.issueTypes.bug ? "RED" : "GRAY" } }] });
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
for (const x of t.filter((x) => x.isEnabled && !claimed.has(x.id))) {
|
|
1174
|
+
types(`extra ${q(x.name)}`, { kind: "confirm", text: `disable type ${q(x.name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: x.id, isEnabled: false } }] });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Workflows: the API doesn't turn them on or show the target — only the UI. An unconfigured one isn't in the list.
|
|
1179
|
+
const wf = add("workflows", `Workflows ${[WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD].map(q).join(", ")} are on`);
|
|
1180
|
+
const how: Record<string, string> = {
|
|
1181
|
+
[WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
|
|
1182
|
+
[WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
1183
|
+
// is:open — otherwise an updated closed task (not planned too) comes back into the project
|
|
1184
|
+
[WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
|
|
1185
|
+
};
|
|
1186
|
+
for (const name of [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD]) {
|
|
1187
|
+
const w = p.workflows.find((x) => x.name === name);
|
|
1188
|
+
if (w?.enabled) continue;
|
|
1189
|
+
// an unconfigured workflow isn't in the API — a link to the list, it's in the menu on the left there
|
|
1190
|
+
wf(`${q(name)} ${w ? "disabled" : "not configured"}`, ui(`${q(name)}: ${how[name]} → Save and turn on workflow`, `${p.url}/workflows${w ? `/${w.fullDatabaseId}` : ""}`));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Tasks: statuses by the canon. Closed ones not in "Done" — a symptom of "Item closed" on a deleted Status option.
|
|
1194
|
+
const opt = (name: string) => sf?.options?.find((o) => o.name === name)?.id;
|
|
1195
|
+
const setStatus = (it: Item, name: string): Mutation => ({ op: "SetItemStatus", input: { projectId: p.id, itemId: it.id, fieldId: sf!.id, value: { singleSelectOptionId: opt(name) } } });
|
|
1196
|
+
const statusReady = !!(opt(N.status.backlog) && opt(N.status.done));
|
|
1197
|
+
const closedAdd = add("closed", `Closed tasks — in ${q(N.status.done)}, closed without being done — outside the project`);
|
|
1198
|
+
const issues = p.items.filter((it) => it.issue);
|
|
1199
|
+
// "Done" in another dictionary language is the same option: the Status item renames it, no need to move tasks
|
|
1200
|
+
const doneAlias = statusAliases();
|
|
1201
|
+
const notDone = issues.filter((it) => it.issue!.state === "CLOSED" && !NOT_DONE_REASONS.has(it.issue!.stateReason ?? "") && doneAlias[(it.status ?? "").toLowerCase()] !== N.status.done);
|
|
1202
|
+
if (notDone.length) {
|
|
1203
|
+
const steps: Step[] = [];
|
|
1204
|
+
if (statusReady) steps.push(api(`${STATUS} ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue!))}`, ...notDone.map((it) => setStatus(it, N.status.done))));
|
|
1205
|
+
const closed = p.workflows.find((x) => x.name === WORKFLOW_CLOSED);
|
|
1206
|
+
// fix sets the statuses itself, and the workflow's target isn't visible from the API — check it in the UI, even
|
|
1207
|
+
// when the symptom is gone
|
|
1208
|
+
if (closed?.enabled) steps.push({ ...ui(`${q(WORKFLOW_CLOSED)}: check Set value → ${STATUS}: ${N.status.done}, otherwise Edit → ${N.status.done} → Save`, `${p.url}/workflows/${closed.fullDatabaseId}`), sticky: true });
|
|
1209
|
+
closedAdd(`not in ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue!))}`, ...steps);
|
|
1210
|
+
}
|
|
1211
|
+
const dropped = issues.filter((it) => it.issue!.state === "CLOSED" && NOT_DONE_REASONS.has(it.issue!.stateReason ?? ""));
|
|
1212
|
+
if (dropped.length) closedAdd(`closed without being done, but in the project: ${nums(dropped.map((it) => it.issue!))}`, api(`remove from the project ${nums(dropped.map((it) => it.issue!))}`, ...dropped.map((it) => ({ op: "DeleteItem", input: { projectId: p.id, itemId: it.id } }))));
|
|
1213
|
+
|
|
1214
|
+
const open = add("open", `Open tasks of the repository — in the project, with ${STATUS}`);
|
|
1215
|
+
const inProject = new Set(issues.map((it) => it.issue!.id));
|
|
1216
|
+
const missing = s.openIssues.filter((i) => !inProject.has(i.id));
|
|
1217
|
+
if (missing.length) open(`not in the project: ${nums(missing)}`, api(`add to the project ${nums(missing)}`, ...missing.map((i) => ({ op: "AddItem", input: { projectId: p.id, contentId: i.id } }))));
|
|
1218
|
+
const noStatus = issues.filter((it) => it.issue!.state === "OPEN" && !it.status);
|
|
1219
|
+
if (noStatus.length) open(`without ${STATUS}: ${nums(noStatus.map((it) => it.issue!))}`, ...(statusReady ? [api(`${STATUS} ${q(N.status.backlog)}: ${nums(noStatus.map((it) => it.issue!))}`, ...noStatus.map((it) => setStatus(it, N.status.backlog)))] : []));
|
|
1220
|
+
|
|
1221
|
+
// Decision labels: by the spec tree and the model of the default branch. Deleting, renaming by a guess, merging, making
|
|
1222
|
+
// a regular label a decision one — only with --confirm: the label is on tasks. One extra and one missing of the same
|
|
1223
|
+
// kind — a renamed folder, not two decisions. The old form `kind:name` → name — fix does it itself, decision or not:
|
|
1224
|
+
// tasks keep the label, there's no guess. A label without a decision on an open task is a new decision (task new sets
|
|
1225
|
+
// it before the PR): an ○ line, not deleted and not renamed by a guess — that would take it away from the task. A
|
|
1226
|
+
// slice label with a decision's name is a different slice of tasks: an ○ line, fix doesn't touch it, and the decision
|
|
1227
|
+
// stays without a label (the name is taken).
|
|
1228
|
+
const labels = add("labels", "Decision labels — the decision's name, the kind by color; by the spec tree and the model of the default branch");
|
|
1229
|
+
const d = s.decisions;
|
|
1230
|
+
if (d.modelError) labels(`model ${MODEL_PATH} doesn't load — module labels aren't checked: ${d.modelError}`);
|
|
1231
|
+
const want = decisionsByName(d);
|
|
1232
|
+
// the model wasn't read — it's unknown which modules exist: architecture labels aren't deleted or re-described
|
|
1233
|
+
const unsure = (kinds: DecisionKind[]) => !!d.modelError && kinds.includes("architecture");
|
|
1234
|
+
const taken = new Map(s.labels.filter((l) => !legacyDecision(l.name)).map((l) => [l.name, l]));
|
|
1235
|
+
const orphans: { label: Label; kinds: DecisionKind[] }[] = [];
|
|
1236
|
+
const dimensions = new Map<string, string[]>();
|
|
1237
|
+
for (const l of s.labels) {
|
|
1238
|
+
const old = legacyDecision(l.name);
|
|
1239
|
+
if (!old) continue;
|
|
1240
|
+
const to = decisionLabel(old.name, want.get(old.name) ?? [old.kind]);
|
|
1241
|
+
const into = taken.get(old.name);
|
|
1242
|
+
if (!into) {
|
|
1243
|
+
taken.set(old.name, { ...l, ...to });
|
|
1244
|
+
labels(`label ${q(l.name)} of the old form \`kind:name\``, api(`rename label ${q(l.name)} → ${q(old.name)} (tasks keep it)`, { op: "UpdateLabel", input: { id: l.id, ...to } }));
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
const issues = s.labelIssues[l.id] ?? [];
|
|
1248
|
+
labels(`label ${q(l.name)} of the old form, label ${q(old.name)} already exists`, {
|
|
1249
|
+
kind: "confirm",
|
|
1250
|
+
text: `merge label ${q(l.name)} into ${q(old.name)}: ${issues.length ? `${nums(issues)} get ${q(old.name)}, ` : ""}the old one is deleted`,
|
|
1251
|
+
mutations: [...issues.map((i) => ({ op: "AddLabels", input: { labelableId: i.id, labelIds: [into.id] } })), { op: "DeleteLabel", input: { id: l.id } }],
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
for (const l of s.labels) {
|
|
1255
|
+
if (legacyDecision(l.name)) continue;
|
|
1256
|
+
const kinds = labelKinds(l.description);
|
|
1257
|
+
const w = want.get(l.name);
|
|
1258
|
+
if (!w) {
|
|
1259
|
+
if (kinds) orphans.push({ label: l, kinds });
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const dim = labelDimension(l.description);
|
|
1263
|
+
if (dim) {
|
|
1264
|
+
dimensions.set(dim, [...(dimensions.get(dim) ?? []), l.name]);
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
const to = decisionLabel(l.name, w);
|
|
1268
|
+
if (!kinds) {
|
|
1269
|
+
labels(`label ${q(l.name)} isn't a decision label, but decision ${l.name} is in the default branch`, { kind: "confirm", text: `make ${q(l.name)} a decision label: color ${to.color}, description ${q(to.description)}`, mutations: [{ op: "UpdateLabel", input: { id: l.id, color: to.color, description: to.description } }] });
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const recolor = l.color.toLowerCase() !== to.color.toLowerCase();
|
|
1273
|
+
if (!recolor && (l.description === to.description || unsure(kinds))) continue;
|
|
1274
|
+
const text = recolor ? `label ${q(l.name)} is colored ${l.color}, not ${to.color}` : `label ${q(l.name)}: wrong decision kinds in the description`;
|
|
1275
|
+
labels(text, api(`${recolor ? "recolor" : "rewrite the description of"} label ${q(l.name)}: ${to.color}, ${q(to.description)}`, { op: "UpdateLabel", input: { id: l.id, color: to.color, description: to.description } }));
|
|
1276
|
+
}
|
|
1277
|
+
for (const [dim, names] of dimensions) {
|
|
1278
|
+
labels.note(`slice ${q(dim)}: labels ${names.map(q).join(", ")} aren't decision labels, decisions of the same name aren't labeled`);
|
|
1279
|
+
}
|
|
1280
|
+
const unlabeled = [...want.keys()].filter((n) => !taken.has(n));
|
|
1281
|
+
const sure = orphans.filter((o) => !unsure(o.kinds));
|
|
1282
|
+
for (const { label: l } of sure.filter((o) => o.label.open.total)) {
|
|
1283
|
+
const more = l.open.total - l.open.numbers.length;
|
|
1284
|
+
labels.note(`new decision ${q(l.name)}: not in the default branch yet, the label is on open tasks ${l.open.numbers.map((n) => `#${n}`).join(", ")}${more > 0 ? ` and ${more} more` : ""}`);
|
|
1285
|
+
}
|
|
1286
|
+
const live = sure.filter((o) => !o.label.open.total);
|
|
1287
|
+
const paired = new Set<string>();
|
|
1288
|
+
for (const kind of DECISION_KINDS) {
|
|
1289
|
+
const os = live.filter((o) => o.kinds.includes(kind) && !paired.has(o.label.id));
|
|
1290
|
+
const ms = unlabeled.filter((n) => want.get(n)!.includes(kind) && !paired.has(n));
|
|
1291
|
+
if (os.length !== 1 || ms.length !== 1) continue;
|
|
1292
|
+
const o = os[0]!.label;
|
|
1293
|
+
const to = decisionLabel(ms[0]!, want.get(ms[0]!)!);
|
|
1294
|
+
paired.add(o.id).add(to.name);
|
|
1295
|
+
labels(`label ${q(o.name)} without a decision, decision ${to.name} without a label`, { kind: "confirm", text: `rename label ${q(o.name)} → ${q(to.name)} (tasks keep it)`, mutations: [{ op: "UpdateLabel", input: { id: o.id, ...to } }] });
|
|
1296
|
+
}
|
|
1297
|
+
for (const { label: o } of live.filter((x) => !paired.has(x.label.id))) {
|
|
1298
|
+
labels(`label ${q(o.name)} without a decision in the default branch, no open tasks have it`, { kind: "confirm", text: `delete label ${q(o.name)}`, mutations: [{ op: "DeleteLabel", input: { id: o.id } }] });
|
|
1299
|
+
}
|
|
1300
|
+
const fresh = unlabeled.filter((n) => !paired.has(n)).map((n) => decisionLabel(n, want.get(n)!));
|
|
1301
|
+
if (fresh.length) {
|
|
1302
|
+
labels(`decisions without a label: ${fresh.map((x) => x.name).join(", ")}`, api(`create labels ${fresh.map((x) => q(x.name)).join(", ")}`, ...fresh.map((x) => ({ op: "CreateLabel", input: { repositoryId: s.repo.id, ...x } }))));
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
merge();
|
|
1306
|
+
return checks;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* The `RULESET` ruleset: the default branch, required checks without strict (the field is mandatory in the API),
|
|
1311
|
+
* bypass — admin (the owner's direct push).
|
|
1312
|
+
*/
|
|
1313
|
+
function rulesetInput(contexts: string[]): Record<string, unknown> {
|
|
1314
|
+
return {
|
|
1315
|
+
name: RULESET,
|
|
1316
|
+
target: "BRANCH",
|
|
1317
|
+
enforcement: "ACTIVE",
|
|
1318
|
+
conditions: { refName: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
1319
|
+
rules: [{ type: "REQUIRED_STATUS_CHECKS", parameters: { requiredStatusChecks: { requiredStatusChecks: contexts.map((context) => ({ context })), strictRequiredStatusChecksPolicy: false } } }],
|
|
1320
|
+
bypassActors: [{ repositoryRoleDatabaseId: ADMIN_ROLE, bypassMode: "ALWAYS" }],
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Merge: the canon rule "merge only with green checks" is held by GitHub, not by the agent's memory. Required checks
|
|
1326
|
+
* are those green on each of the last merged PRs; a check missing from a fresh head (the last merged PR, open ones with
|
|
1327
|
+
* finished checks) would hold the PR forever — it leaves the list rather than gets into it. Someone else's rule with
|
|
1328
|
+
* checks isn't duplicated; our own ruleset with strict is fixed: GitHub must merge a branch that fell behind.
|
|
1329
|
+
*/
|
|
1330
|
+
function analyzeMerge(m: Merge, repositoryId: string, merge: ((text: string, ...steps: Step[]) => void) & { skip: (why: string) => void }): void {
|
|
1331
|
+
if (m.unavailable) return merge.skip(`branch rules are unavailable: ${m.unavailable}`);
|
|
1332
|
+
const fresh = [...m.merged.slice(-1), ...m.open];
|
|
1333
|
+
const gone = (c: string) => fresh.find((h) => !(c in h.checks));
|
|
1334
|
+
const stable = Object.keys(m.merged[0]?.checks ?? {})
|
|
1335
|
+
// stricter than task show: a check skipped by a condition or neutral checked nothing — only pass makes it required
|
|
1336
|
+
.filter((c) => m.merged.every((h) => h.checks[c] === "pass") && !gone(c))
|
|
1337
|
+
.sort();
|
|
1338
|
+
const enforced = m.rules.some((r) => r.contexts.length);
|
|
1339
|
+
const list = (cs: string[]) => cs.map(q).join(", ");
|
|
1340
|
+
const none = `the last merged PRs have no common green check — nothing to make required`;
|
|
1341
|
+
const own = m.own;
|
|
1342
|
+
if (!own) {
|
|
1343
|
+
if (enforced) return;
|
|
1344
|
+
if (!stable.length) return merge.skip(none);
|
|
1345
|
+
const text = `create ruleset ${q(RULESET)} on ${m.branch}: required ${list(stable)}, bypass — admin`;
|
|
1346
|
+
const step: Step = { kind: "api", text, mutations: [{ op: "CreateRuleset", input: { sourceId: repositoryId, ...rulesetInput(stable) } }] };
|
|
1347
|
+
return merge(`no rule with required checks: a branch with a red check can be merged`, step);
|
|
1348
|
+
}
|
|
1349
|
+
const want = [...new Set([...own.contexts.filter((c) => !gone(c)), ...stable])].sort();
|
|
1350
|
+
// the input carries `name: RULESET` — the same edit renames a ruleset under the former name
|
|
1351
|
+
const named = own.name === RULESET ? q(RULESET) : `${q(own.name)} → ${q(RULESET)}`;
|
|
1352
|
+
const step: Step = { kind: "api", text: `ruleset ${named}: default branch, required ${list(want) || "—"}, bypass — admin`, mutations: [{ op: "UpdateRuleset", input: { repositoryRulesetId: own.id, ...rulesetInput(want) } }] };
|
|
1353
|
+
const why: string[] = [];
|
|
1354
|
+
if (own.name !== RULESET) why.push(`ruleset ${q(own.name)} — the flow's former name: fix renames it to ${q(RULESET)}`);
|
|
1355
|
+
if (own.enforcement !== "ACTIVE") why.push(`ruleset ${q(own.name)} — ${own.enforcement}, not in effect`);
|
|
1356
|
+
if (!own.include.includes("~DEFAULT_BRANCH")) why.push(`ruleset ${q(own.name)} is not on the default branch: ${own.include.join(", ") || "—"}`);
|
|
1357
|
+
if (own.strict) why.push(`ruleset ${q(own.name)}: strict is on — GitHub won't merge a branch that fell behind`);
|
|
1358
|
+
if (!own.bypassAdmin) why.push(`ruleset ${q(own.name)}: admin can't bypass — no direct push to ${m.branch}`);
|
|
1359
|
+
for (const c of own.contexts.filter((x) => gone(x))) why.push(`required ${q(c)} didn't come to PR #${gone(c)!.number} — the PR would wait for it forever`);
|
|
1360
|
+
const add = stable.filter((c) => !own.contexts.includes(c));
|
|
1361
|
+
if (add.length) why.push(`green on the last ${m.merged.length} merged PRs, but not required: ${list(add)}`);
|
|
1362
|
+
for (const w of why) merge(w, step);
|
|
1363
|
+
if (!why.length && !enforced && !want.length) merge.skip(none);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* The merge method is rebase only (the canon, "PR"): the merge rule is the same for all repositories —
|
|
1368
|
+
* `gh pr merge <N> --rebase`, the branch's own commits go into the default branch. The repository setting keeps the
|
|
1369
|
+
* rule constant: a PR can't be merged another way, and rebase isn't turned off. All causes — one request.
|
|
1370
|
+
*/
|
|
1371
|
+
function analyzeMethod(m: Merge, slug: string, method: (text: string, ...steps: Step[]) => void): void {
|
|
1372
|
+
const step: Step = {
|
|
1373
|
+
kind: "api",
|
|
1374
|
+
text: "keep only rebase: turn off merge commit and squash, turn on rebase",
|
|
1375
|
+
url: `https://github.com/${slug}/settings`,
|
|
1376
|
+
mutations: [{ op: "UpdateRepository", input: { repo: slug, allow_merge_commit: false, allow_squash_merge: false, allow_rebase_merge: true } }],
|
|
1377
|
+
};
|
|
1378
|
+
if (!m.methods.rebase) method("rebase is off — GitHub will refuse gh pr merge --rebase", step);
|
|
1379
|
+
if (m.methods.merge) method("merge commit is allowed", step);
|
|
1380
|
+
if (m.methods.squash) method("squash is allowed", step);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ----------------------------------------------------------------------------
|
|
1384
|
+
// Commands
|
|
1385
|
+
// ----------------------------------------------------------------------------
|
|
1386
|
+
|
|
1387
|
+
function header(io: Io, s: State): void {
|
|
1388
|
+
io.out(`Project ${s.repo.nameWithOwner}: ${s.project ? s.project.url : "none"}`);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function report(io: Io, s: State, checks: Check[]): boolean {
|
|
1392
|
+
for (const c of checks) {
|
|
1393
|
+
if (c.skip) {
|
|
1394
|
+
io.out(`➖ ${c.title} — ${c.skip}`);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
io.out(`${c.problems.length ? "❌" : "✅"} ${c.title}`);
|
|
1398
|
+
for (const pr of c.problems) io.out(` · ${pr.text}`);
|
|
1399
|
+
for (const n of c.notes) io.out(` ○ ${n}`);
|
|
1400
|
+
}
|
|
1401
|
+
if (s.project && !s.repo.owner.org) io.out(`➖ ${PRIORITY} and issue types — a personal account has none`);
|
|
1402
|
+
return checks.every((c) => !c.problems.length);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
export function cmdCheck(io: Io, slug: string): number {
|
|
1406
|
+
const s = loadState(io, slug);
|
|
1407
|
+
header(io, s);
|
|
1408
|
+
return report(io, s, analyze(s)) ? 0 : 1;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** No project: link the owner's project of the same name, otherwise copy the template, otherwise create one. */
|
|
1412
|
+
function ensureProject(io: Io, s: State, template: string | null): string {
|
|
1413
|
+
const found = graphql(io, Q.OwnerProjects, { login: s.repo.owner.login, query: s.repo.name })?.repositoryOwner?.projectsV2?.nodes ?? [];
|
|
1414
|
+
const same = found.find((p: Any) => p && p.title === s.repo.name && !p.closed);
|
|
1415
|
+
const link = (p: ProjectRef) => mutate(io, { op: "LinkProject", input: { projectId: p.id, repositoryId: s.repo.id } });
|
|
1416
|
+
if (same) {
|
|
1417
|
+
link(same);
|
|
1418
|
+
return `linked project ${q(same.title)} (${same.url})`;
|
|
1419
|
+
}
|
|
1420
|
+
if (template) {
|
|
1421
|
+
const [login, number] = template.split("/");
|
|
1422
|
+
let tpl: Any = null;
|
|
1423
|
+
try {
|
|
1424
|
+
tpl = graphql(io, Q.TemplateProject, { login, number: Number(number) })?.repositoryOwner?.projectV2 ?? null;
|
|
1425
|
+
} catch (e) {
|
|
1426
|
+
io.err(`template ${template} is unavailable: ${(e as Error).message}`);
|
|
1427
|
+
}
|
|
1428
|
+
if (tpl) {
|
|
1429
|
+
const copy = mutate(io, { op: "CopyProject", input: { projectId: tpl.id, ownerId: s.repo.owner.id, title: s.repo.name, includeDraftIssues: false } }).copyProjectV2.projectV2;
|
|
1430
|
+
link(copy);
|
|
1431
|
+
return `project ${q(s.repo.name)} — a copy of template ${template} (${copy.url}), linked`;
|
|
1432
|
+
}
|
|
1433
|
+
io.err(`template ${template} not found — the project is created from scratch`);
|
|
1434
|
+
}
|
|
1435
|
+
const created = mutate(io, { op: "CreateProject", input: { ownerId: s.repo.owner.id, title: s.repo.name, repositoryId: s.repo.id } }).createProjectV2.projectV2;
|
|
1436
|
+
return `created project ${q(s.repo.name)} (${created.url}), linked`;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
export function cmdFix(io: Io, slug: string, opts: { confirm: boolean; template: string | null }): number {
|
|
1440
|
+
const done: string[] = [];
|
|
1441
|
+
const failed: string[] = [];
|
|
1442
|
+
const tried = new Set<string>();
|
|
1443
|
+
const sticky: Step[] = [];
|
|
1444
|
+
let s = loadState(io, slug);
|
|
1445
|
+
// Rounds: a step may open the next one (a new Status field → the tasks' statuses); a step isn't done twice.
|
|
1446
|
+
for (let round = 0; round < 5; round++) {
|
|
1447
|
+
if (!s.project) {
|
|
1448
|
+
try {
|
|
1449
|
+
done.push(ensureProject(io, s, opts.template));
|
|
1450
|
+
} catch (e) {
|
|
1451
|
+
failed.push(`project: ${(e as Error).message}`);
|
|
1452
|
+
break;
|
|
1453
|
+
}
|
|
1454
|
+
s = loadState(io, slug);
|
|
1455
|
+
if (!s.project) break;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
const all = analyze(s).flatMap((c) => c.problems.flatMap((pr) => pr.steps));
|
|
1459
|
+
sticky.push(...all.filter((st) => st.sticky));
|
|
1460
|
+
const steps = all
|
|
1461
|
+
.filter((st) => st.mutations?.length && (st.kind === "api" || (st.kind === "confirm" && opts.confirm)) && !tried.has(st.text));
|
|
1462
|
+
if (!steps.length) break;
|
|
1463
|
+
for (const st of steps) {
|
|
1464
|
+
// several discrepancies may share one step (editing the whole ruleset) — it's done once
|
|
1465
|
+
if (tried.has(st.text)) continue;
|
|
1466
|
+
tried.add(st.text);
|
|
1467
|
+
try {
|
|
1468
|
+
for (const m of st.mutations!) mutate(io, m);
|
|
1469
|
+
done.push(st.text);
|
|
1470
|
+
} catch (e) {
|
|
1471
|
+
failed.push(`${st.text}: ${(e as Error).message}${st.url ? ` — in the UI: ${st.url}` : ""}`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
s = loadState(io, slug);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
header(io, s);
|
|
1478
|
+
for (const d of done) io.out(`+ ${d}`);
|
|
1479
|
+
for (const f of failed) io.out(`! ${f}`);
|
|
1480
|
+
const checks = analyze(s);
|
|
1481
|
+
const left = [...sticky, ...checks.flatMap((c) => c.problems.flatMap((pr) => pr.steps))];
|
|
1482
|
+
const uniq = (xs: Step[]) => xs.filter((x, i) => xs.findIndex((y) => y.text === x.text) === i);
|
|
1483
|
+
const ui = uniq(left.filter((st) => st.kind === "ui"));
|
|
1484
|
+
const confirm = uniq(left.filter((st) => st.kind === "confirm" && !opts.confirm));
|
|
1485
|
+
if (ui.length) {
|
|
1486
|
+
io.out("UI steps — in the session's browser, without a browser — for the user; then check again:");
|
|
1487
|
+
ui.forEach((st, i) => io.out(`${i + 1}. ${st.text} — ${st.url}`));
|
|
1488
|
+
}
|
|
1489
|
+
if (confirm.length) {
|
|
1490
|
+
io.out("Confirmation needed from the user — then fix --confirm:");
|
|
1491
|
+
confirm.forEach((st, i) => io.out(`${i + 1}. ${st.text}${st.url ? ` (or in the UI: ${st.url})` : ""}`));
|
|
1492
|
+
}
|
|
1493
|
+
io.out("Summary:");
|
|
1494
|
+
const ok = report(io, s, checks);
|
|
1495
|
+
return ok && !failed.length && !ui.length && !confirm.length ? 0 : 1;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// ----------------------------------------------------------------------------
|
|
1499
|
+
// Tasks
|
|
1500
|
+
// ----------------------------------------------------------------------------
|
|
1501
|
+
|
|
1502
|
+
interface Issue {
|
|
1503
|
+
id: string;
|
|
1504
|
+
/** Read in the cloud (#603): the REST id (sub-issues and blocked by take it) and the issue's route — its writes go there. */
|
|
1505
|
+
databaseId?: number;
|
|
1506
|
+
rest?: string;
|
|
1507
|
+
number: number;
|
|
1508
|
+
title: string;
|
|
1509
|
+
state: string;
|
|
1510
|
+
stateReason: string | null;
|
|
1511
|
+
url: string;
|
|
1512
|
+
type: string | null;
|
|
1513
|
+
labels: string[];
|
|
1514
|
+
parent: { id: string; number: number } | null;
|
|
1515
|
+
subIssues: { number: number; state: string; title: string }[];
|
|
1516
|
+
/** The issues blocking this one (blocked by), closed ones and other repositories' included. */
|
|
1517
|
+
blockers: Blocker[];
|
|
1518
|
+
/** The task's milestone and the number of open tasks in it (including this one if it's open). */
|
|
1519
|
+
milestone: { id: string; number: number; title: string; open: number } | null;
|
|
1520
|
+
/** PRs that close the task ("Closes #N"), closed ones included: whether merged, head and base. */
|
|
1521
|
+
prs: { number: number; state: string; merged: boolean; head: string; base: string }[];
|
|
1522
|
+
items: { id: string; projectId: string; status: string | null }[];
|
|
1523
|
+
priority: string | null;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* A blocker (blocked by): GitHub links an issue of another repository too, of another owner as well — an external
|
|
1528
|
+
* blocker, its number alone would name a task of this repository.
|
|
1529
|
+
*/
|
|
1530
|
+
interface Blocker {
|
|
1531
|
+
number: number;
|
|
1532
|
+
state: string;
|
|
1533
|
+
repo: string;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/** A blocker in a line: of this repository — `#N`, of another — `owner/repo#N`. */
|
|
1537
|
+
const blockerRef = (slug: string, b: { number: number; repo: string }) => (b.repo.toLowerCase() === slug.toLowerCase() ? `#${b.number}` : `${b.repo}#${b.number}`);
|
|
1538
|
+
|
|
1539
|
+
function loadIssue(io: Io, slug: string, number: number): Issue {
|
|
1540
|
+
if (inCloud(io)) return restLoadIssue(io, slug, number);
|
|
1541
|
+
const [owner, name] = slug.split("/");
|
|
1542
|
+
const i = graphql(io, Q.IssueRef, { owner, name, number })?.repository?.issue;
|
|
1543
|
+
if (!i) throw new GhError(`no issue #${number} in ${slug}`);
|
|
1544
|
+
return {
|
|
1545
|
+
id: i.id,
|
|
1546
|
+
number: i.number,
|
|
1547
|
+
title: i.title,
|
|
1548
|
+
state: i.state,
|
|
1549
|
+
stateReason: i.stateReason ?? null,
|
|
1550
|
+
url: i.url,
|
|
1551
|
+
type: i.issueType?.name ?? null,
|
|
1552
|
+
labels: (i.labels?.nodes ?? []).map((l: Any) => l.name),
|
|
1553
|
+
parent: i.parent ? { id: i.parent.id, number: i.parent.number } : null,
|
|
1554
|
+
subIssues: i.subIssues?.nodes ?? [],
|
|
1555
|
+
blockers: (i.blockedBy?.nodes ?? []).map((b: Any) => ({ number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
|
|
1556
|
+
milestone: i.milestone ? { id: i.milestone.id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.issues?.totalCount ?? 0 } : null,
|
|
1557
|
+
prs: (i.closedByPullRequestsReferences?.nodes ?? []).map((p: Any) => ({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head: p.headRefName ?? "", base: p.baseRefName ?? "main" })),
|
|
1558
|
+
items: (i.projectItems?.nodes ?? []).map((it: Any) => ({ id: it.id, projectId: it.project.id, status: it.status?.name ?? null })),
|
|
1559
|
+
priority: (i.issueFieldValues?.nodes ?? []).find((v: Any) => v?.field?.name === PRIORITY)?.name ?? null,
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** loadIssue in the cloud (#603) — the repository's REST: no project items, PRs and Priority (an issue field). */
|
|
1564
|
+
function restLoadIssue(io: Io, slug: string, number: number): Issue {
|
|
1565
|
+
const i = restIssue(io, slug, number);
|
|
1566
|
+
if (!i) throw new GhError(`no issue #${number} in ${slug}`);
|
|
1567
|
+
const l = restLinks(io, slug, number);
|
|
1568
|
+
return {
|
|
1569
|
+
id: i.id,
|
|
1570
|
+
databaseId: i.databaseId,
|
|
1571
|
+
rest: `repos/${slug}/issues/${number}`,
|
|
1572
|
+
number: i.number,
|
|
1573
|
+
title: i.title,
|
|
1574
|
+
state: i.state,
|
|
1575
|
+
stateReason: i.stateReason,
|
|
1576
|
+
url: i.url,
|
|
1577
|
+
type: i.issueType?.name ?? null,
|
|
1578
|
+
labels: i.labels.nodes.map((x: Any) => x.name),
|
|
1579
|
+
parent: l.parent ? { id: l.parent.id, number: l.parent.number } : null,
|
|
1580
|
+
subIssues: l.subIssues.nodes.map((x: Any) => ({ number: x.number, state: x.state, title: x.title })),
|
|
1581
|
+
blockers: l.blockedBy.nodes.map((b: Any) => ({ number: b.number, state: b.state, repo: b.repository.nameWithOwner })),
|
|
1582
|
+
milestone: i.milestone,
|
|
1583
|
+
prs: [],
|
|
1584
|
+
items: [],
|
|
1585
|
+
priority: null,
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
interface TaskCtx {
|
|
1590
|
+
slug: string;
|
|
1591
|
+
repo: State["repo"];
|
|
1592
|
+
project: Project;
|
|
1593
|
+
status: Field;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* The repository and its project with Status by the canon: without them a task can't be created by the canon — run
|
|
1598
|
+
* project fix first. If the Status options are not in the project language but in another dictionary language (the
|
|
1599
|
+
* script wasn't called from the project checkout; the language changed and there was no fix yet), the task is handled
|
|
1600
|
+
* in the board's language: `project check` checks the language, not every command.
|
|
1601
|
+
*/
|
|
1602
|
+
function taskContext(io: Io, slug: string): TaskCtx {
|
|
1603
|
+
const repo = loadRepo(io, slug);
|
|
1604
|
+
const main = mainProject(repo);
|
|
1605
|
+
if (!main) throw new GhError(`no project is linked to ${slug} — github project fix first`);
|
|
1606
|
+
const project = loadProject(io, main.id, false);
|
|
1607
|
+
const status = project.fields.find((f) => f.name === STATUS && f.options);
|
|
1608
|
+
const onBoard = (n: Names) => !!status && [n.status.backlog, n.status.inProgress, n.status.done].every((name) => status.options!.some((o) => o.name === name));
|
|
1609
|
+
const board = onBoard(N) ? N : locale.everyNames().find(onBoard);
|
|
1610
|
+
if (!status || !board) throw new GhError(`the project's ${STATUS} is not per the canon — github project fix first`);
|
|
1611
|
+
N = board;
|
|
1612
|
+
return { slug, repo, project, status };
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
const isEpic = (i: Issue, org: boolean) => (org ? locale.every((n) => n.issueTypes.epic).includes(i.type ?? "") : i.labels.includes(EPIC_LABEL));
|
|
1616
|
+
const itemIn = (ctx: TaskCtx, i: Pick<Issue, "items">) => i.items.find((it) => it.projectId === ctx.project.id) ?? null;
|
|
1617
|
+
|
|
1618
|
+
function setStatus(io: Io, ctx: TaskCtx, itemId: string, name: string): void {
|
|
1619
|
+
const optionId = ctx.status.options!.find((o) => o.name === name)!.id;
|
|
1620
|
+
mutate(io, { op: "SetItemStatus", input: { projectId: ctx.project.id, itemId, fieldId: ctx.status.id, value: { singleSelectOptionId: optionId } } });
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* The task's item in the project; none — add it. The project may have added the task itself (projectV2Ids in
|
|
1625
|
+
* createIssue, auto-add) while reading doesn't show it yet: addProjectV2ItemById then answers "Content already
|
|
1626
|
+
* exists" — re-read after a pause.
|
|
1627
|
+
*/
|
|
1628
|
+
function ensureItem(io: Io, ctx: TaskCtx, i: Pick<Issue, "id" | "number" | "items">): { id: string; added: boolean } {
|
|
1629
|
+
const it = itemIn(ctx, i);
|
|
1630
|
+
if (it) return { id: it.id, added: false };
|
|
1631
|
+
try {
|
|
1632
|
+
return { id: mutate(io, { op: "AddItem", input: { projectId: ctx.project.id, contentId: i.id } }).addProjectV2ItemById.item.id, added: true };
|
|
1633
|
+
} catch (e) {
|
|
1634
|
+
if (!/already exists/i.test((e as Error).message)) throw e;
|
|
1635
|
+
}
|
|
1636
|
+
for (let k = 0; k < 5; k++) {
|
|
1637
|
+
(io.sleep ?? pause)(1000);
|
|
1638
|
+
const again = itemIn(ctx, loadIssue(io, ctx.slug, i.number));
|
|
1639
|
+
if (again) return { id: again.id, added: false };
|
|
1640
|
+
}
|
|
1641
|
+
throw new GhError(`#${i.number} is already in the project, but its item isn't visible — retry: github task status ${i.number} <status>`);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* The personal configuration directory — `ELVES_CONFIG_DIR`, otherwise `~/.config/elves`: one expression for all
|
|
1646
|
+
* scripts (the Elves `config-dir` standard) rather than a shared module — github imports no other skill
|
|
1647
|
+
* (tests/standards/skill-imports). The flow's former names are read for the transition: `AI_DEV_CONFIG_DIR` — a
|
|
1648
|
+
* variable the owner may have set in the profile; `~/.config/ai-dev` — the directory of a machine before `update -g`
|
|
1649
|
+
* moved it, while the new one doesn't exist; a clean machine gets the new one. est
|
|
1650
|
+
* moves the old `~/.claude/est` into the directory only while the directory doesn't exist (est.ts, `ensureConfigDir`):
|
|
1651
|
+
* creating it here first would leave est without its registry and prices. Before the move the configuration is the
|
|
1652
|
+
* old directory: the pins move along with it.
|
|
1653
|
+
*/
|
|
1654
|
+
function configDir(env: Io["env"]): string {
|
|
1655
|
+
const home = env.HOME || os.homedir();
|
|
1656
|
+
const dir = env.ELVES_CONFIG_DIR || env.AI_DEV_CONFIG_DIR || [path.join(home, ".config", "elves"), path.join(home, ".config", "ai-dev")].find((d) => existsSync(d)) || path.join(home, ".config", "elves");
|
|
1657
|
+
if (existsSync(dir)) return dir;
|
|
1658
|
+
const legacy = path.join(home, ".claude", "est");
|
|
1659
|
+
// lstat: a symlink in place of the old directory — the move has already happened
|
|
1660
|
+
return lstatSync(legacy, { throwIfNoEntry: false })?.isDirectory() ? legacy : dir;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/** The session pin file — `<config directory>/sessions/<session id>`; the session isn't identified — null. */
|
|
1664
|
+
function pinFile(io: Io): string | null {
|
|
1665
|
+
const id = io.env.CLAUDE_CODE_SESSION_ID;
|
|
1666
|
+
return id ? path.join(configDir(io.env), "sessions", id) : null;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* The session pin: `owner/repo#N` — the task, anything else — not a pin. est has the same parse: github imports no other
|
|
1671
|
+
* skill (tests/standards/skill-imports, tests/standards/session-pin).
|
|
1672
|
+
*/
|
|
1673
|
+
export function sessionPin(text: string): { slug: string; number: number } | null {
|
|
1674
|
+
const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(text.trim());
|
|
1675
|
+
return m ? { slug: m[1]!, number: Number(m[2]) } : null;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** The line `task status` writes into the pin file — sessionPin of every skill reads it back as the same task. */
|
|
1679
|
+
export function pinLine(slug: string, number: number): string {
|
|
1680
|
+
return `${slug}#${number}\n`;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* The session pin: its file, the line and the task in it — the line isn't `owner/repo#N`, task null: not a pin; no file
|
|
1685
|
+
* or the session isn't identified — null.
|
|
1686
|
+
*/
|
|
1687
|
+
function pinnedTask(io: Io): { file: string; line: string; task: { slug: string; number: number } | null } | null {
|
|
1688
|
+
const file = pinFile(io);
|
|
1689
|
+
if (!file || !existsSync(file)) return null;
|
|
1690
|
+
const line = readFileSync(file, "utf8").trim();
|
|
1691
|
+
return { file, line, task: sessionPin(line) };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/**
|
|
1695
|
+
* Another task isn't taken in a session with a task (#546): the question to the owner whether to queue it, and its "yes"
|
|
1696
|
+
* — a background executor this session launches right away, with the prompt `executorPrompt`. Not a new session: a
|
|
1697
|
+
* session card waited for the owner's click, and its work fell out of this session's sight.
|
|
1698
|
+
*/
|
|
1699
|
+
function queueQuestion(slug: string, number: number, epic: number | null): string {
|
|
1700
|
+
return `the question to the owner «queue #${number}?» — yes: a background subagent in its own worktree, its prompt: ${executorPrompt(slug, number, epic)}`;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** The refusal of another task in a session with a pinned one: the pinned task, the queue question, the pin's file. */
|
|
1704
|
+
function anotherTask(runs: string, file: string, slug: string, number: number, epic: number | null): GhError {
|
|
1705
|
+
return new GhError(`the session runs ${runs}; #${number} isn't taken here — ${queueQuestion(slug, number, epic)} (pin: ${file} — a mistaken one is removed by a human)`);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
/**
|
|
1709
|
+
* A session runs one task: the first one taken "In progress" is pinned to the session, another is refused before any
|
|
1710
|
+
* change. The key is the session id (a subagent has the same one, /clear changes it), not the checkout: session
|
|
1711
|
+
* worktrees are reused. No command removes the pin — a human removes a mistaken one by deleting the file. A task handed
|
|
1712
|
+
* out to an executor of this session (`handedOutFile`) isn't pinned and isn't refused: the executor is a subagent with
|
|
1713
|
+
* the session's id — in a dispatcher session and, since #546, in one with its own task.
|
|
1714
|
+
*/
|
|
1715
|
+
function pinTask(io: Io, slug: string, number: number, epic: number | null): void {
|
|
1716
|
+
const file = pinFile(io);
|
|
1717
|
+
if (!file) return io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — the task isn't pinned");
|
|
1718
|
+
const task = `${slug}#${number}`;
|
|
1719
|
+
const held = pinnedTask(io)?.task;
|
|
1720
|
+
const runs = held ? `${held.slug}#${held.number}` : null;
|
|
1721
|
+
if (runs === task) return;
|
|
1722
|
+
if (handedOut(io, slug).some((h) => h.number === number)) {
|
|
1723
|
+
return io.out(`○ #${number} is handed out to an executor of this session — not pinned: the executor is a subagent with the session's id, est links the work by «${N.subagentPrompt.task} #${number}» in the assignment`);
|
|
1724
|
+
}
|
|
1725
|
+
if (runs) throw anotherTask(runs, file, slug, number, epic);
|
|
1726
|
+
if (isDispatcher(io)) {
|
|
1727
|
+
return io.out(`○ dispatcher session: #${number} isn't pinned — its executor is a subagent with the session's id, est links the work by «${N.subagentPrompt.task} #${number}» in the assignment`);
|
|
1728
|
+
}
|
|
1729
|
+
const created = (f: string, text: string) => {
|
|
1730
|
+
try {
|
|
1731
|
+
writeFileSync(f, text, { flag: "wx" });
|
|
1732
|
+
return true;
|
|
1733
|
+
} catch (e) {
|
|
1734
|
+
if ((e as Any)?.code !== "EEXIST") throw e;
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1739
|
+
// the configuration directory may be a personal git checkout (ELVES_PRIVATE) — pins aren't committed into it
|
|
1740
|
+
created(path.join(path.dirname(file), ".gitignore"), "*\n");
|
|
1741
|
+
if (created(file, pinLine(slug, number))) return;
|
|
1742
|
+
const now = pinnedTask(io)!;
|
|
1743
|
+
// not a pin — the session is free for est and the dashboard too: the file takes the task
|
|
1744
|
+
if (!now.task) {
|
|
1745
|
+
writeFileSync(file, pinLine(slug, number));
|
|
1746
|
+
return io.out(`○ the session pin ${file} held «${now.line}», not owner/repo#N — replaced with ${task}`);
|
|
1747
|
+
}
|
|
1748
|
+
// pinned by a parallel call meanwhile
|
|
1749
|
+
const pinnedNow = `${now.task.slug}#${now.task.number}`;
|
|
1750
|
+
if (pinnedNow !== task) throw anotherTask(pinnedNow, file, slug, number, epic);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* The dispatcher marker — `<config directory>/dispatchers/<session id>` with the queue command (`task queue`): the
|
|
1755
|
+
* session hands tasks out to background subagents, and they share its id — a pin would refuse the second executor and
|
|
1756
|
+
* give est the whole session as one task. The session isn't identified — null.
|
|
1757
|
+
*/
|
|
1758
|
+
function dispatcherFile(io: Io): string | null {
|
|
1759
|
+
const id = io.env.CLAUDE_CODE_SESSION_ID;
|
|
1760
|
+
return id ? path.join(configDir(io.env), "dispatchers", id) : null;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const isDispatcher = (io: Io) => {
|
|
1764
|
+
const file = dispatcherFile(io);
|
|
1765
|
+
return !!file && existsSync(file);
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* The tasks handed out to the dispatcher's executors — `<marker>.tasks`, a line `owner/repo#N <worktree>` per `task
|
|
1770
|
+
* queue --assignment N` in the session (#513): the executor's first call, In progress comes later, after the task's
|
|
1771
|
+
* questions, and till then the queue gave the task out as ready. A separate file: executors append to it in parallel,
|
|
1772
|
+
* the queue rewrites the marker. It lives for the session: after the app restarts, the stopped executors are continued
|
|
1773
|
+
* with `SendMessage` (`task resume`) — dropped, it would make a task not yet In progress ready for a second executor.
|
|
1774
|
+
* The worktree — the checkout the executor called its assignment in (#533): the queue keeps it while the task is open. A
|
|
1775
|
+
* cloud executor's (`--assignment N --cloud`, #609) — `CLOUD_HANDOUT` in its place: the dispatcher printed its brief,
|
|
1776
|
+
* there is no worktree, and the queue names the wait for the task's closing.
|
|
1777
|
+
*/
|
|
1778
|
+
const handedOutFile = (io: Io) => {
|
|
1779
|
+
const file = dispatcherFile(io);
|
|
1780
|
+
return file && `${file}.tasks`;
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
/** A hand-out: the task and its executor's worktree; the assignment called outside a checkout or a cloud one — null. */
|
|
1784
|
+
interface HandOut {
|
|
1785
|
+
number: number;
|
|
1786
|
+
worktree: string | null;
|
|
1787
|
+
/** A cloud executor's (#609): the dispatcher waits for the task's closing. */
|
|
1788
|
+
cloud: boolean;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
/** A cloud executor's mark in place of the worktree: a worktree is an absolute path. */
|
|
1792
|
+
const CLOUD_HANDOUT = "@cloud";
|
|
1793
|
+
|
|
1794
|
+
/** The repository's tasks handed out to this dispatcher's executors (`handedOutFile`); a line without a worktree — `null`. */
|
|
1795
|
+
function handedOut(io: Io, slug: string): HandOut[] {
|
|
1796
|
+
const file = handedOutFile(io);
|
|
1797
|
+
return file ? handOutsIn(file, slug) : [];
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/** The repository's hand-outs in a `<session id>.tasks` file; no file — none. */
|
|
1801
|
+
function handOutsIn(file: string, slug: string): HandOut[] {
|
|
1802
|
+
if (!existsSync(file)) return [];
|
|
1803
|
+
const lines = readFileSync(file, "utf8").split("\n").map((l) => l.trim());
|
|
1804
|
+
return lines
|
|
1805
|
+
.filter((l) => l.startsWith(`${slug}#`))
|
|
1806
|
+
.map((l) => {
|
|
1807
|
+
const [number, ...rest] = l.slice(slug.length + 1).split(" ");
|
|
1808
|
+
const worktree = rest.join(" ").trim();
|
|
1809
|
+
return { number: Number(number), worktree: worktree === CLOUD_HANDOUT ? null : worktree || null, cloud: worktree === CLOUD_HANDOUT };
|
|
1810
|
+
})
|
|
1811
|
+
.filter((h) => Number.isInteger(h.number) && h.number > 0);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/** A path without symbolic links (`/var` → `/private/var` on macOS) — a worktree's and a hand-out's compare so; gone — as is. */
|
|
1815
|
+
function realPath(p: string): string {
|
|
1816
|
+
try {
|
|
1817
|
+
return realpathSync(p);
|
|
1818
|
+
} catch {
|
|
1819
|
+
return path.resolve(p);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** The epic prefix — the part of the title before " · ": the titles of the epic and all its subtasks start with it. */
|
|
1824
|
+
export function epicPrefix(title: string): string | null {
|
|
1825
|
+
const i = title.indexOf(" · ");
|
|
1826
|
+
return i > 0 ? title.slice(0, i) : null;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
export interface NewTask {
|
|
1830
|
+
title: string;
|
|
1831
|
+
body: string;
|
|
1832
|
+
type: string;
|
|
1833
|
+
epic?: number;
|
|
1834
|
+
milestone?: string;
|
|
1835
|
+
blockedBy: number[];
|
|
1836
|
+
priority?: string;
|
|
1837
|
+
/**
|
|
1838
|
+
* Labels: a decision by name or a new decision `kind:name` (no label — it's created with the kind's color), regular
|
|
1839
|
+
* ones — existing.
|
|
1840
|
+
*/
|
|
1841
|
+
labels?: string[];
|
|
1842
|
+
/** The branch type (feat, fix, …) — also the estimate's type; a bug without it is fix, an epic has none. */
|
|
1843
|
+
branchType?: string;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* The task description skeleton without `--body` — canon sections in the project language: scenarios (an epic has
|
|
1848
|
+
* none — it has no code) and questions.
|
|
1849
|
+
*/
|
|
1850
|
+
export const taskTemplate = (type: string): string =>
|
|
1851
|
+
type === N.issueTypes.epic ? `${N.sections.questions}\n` : `${N.sections.scenarios}\n\n${N.sections.questions}\n`;
|
|
1852
|
+
|
|
1853
|
+
/**
|
|
1854
|
+
* Titles of the same task as `title`: itself and, for the project's flow update task (`updateTask` of a dictionary
|
|
1855
|
+
* language), its title before the flow's rename (`formerly.updateTask`) — an open update task of an earlier release is
|
|
1856
|
+
* the same task (#544).
|
|
1857
|
+
*/
|
|
1858
|
+
const sameTitles = (title: string): string[] => [title, ...locale.everyNames().filter((n) => n.updateTask === title).map((n) => n.formerly.updateTask)];
|
|
1859
|
+
|
|
1860
|
+
export function cmdTaskNew(io: Io, slug: string, task: NewTask): number {
|
|
1861
|
+
const [owner, name] = slug.split("/");
|
|
1862
|
+
// the cloud (#603): the repository's REST without the project — what it can't set, a line for a local session names
|
|
1863
|
+
const cloud = inCloud(io);
|
|
1864
|
+
const ctx = cloud ? null : taskContext(io, slug);
|
|
1865
|
+
const org = ctx ? ctx.repo.owner.org : rest(io, `repos/${slug}`).owner?.type === "Organization";
|
|
1866
|
+
|
|
1867
|
+
// All checks come before creating: a task mustn't appear half-made. The type — a name in any dictionary language.
|
|
1868
|
+
const type = typeAliases()[task.type.toLowerCase()];
|
|
1869
|
+
if (!type) throw new GhError(`--type: ${issueTypes().join(", ")}`);
|
|
1870
|
+
// the estimate needs the branch type: a bug's branch is fix/<N>-…, an epic has no branch and no estimate of its own
|
|
1871
|
+
const branchType = task.branchType?.trim().toLowerCase() || (type === N.issueTypes.bug ? "fix" : "");
|
|
1872
|
+
if (type === N.issueTypes.epic) {
|
|
1873
|
+
if (branchType) throw new GhError("an epic has no branch and isn't estimated — --branch-type is for a task or a bug");
|
|
1874
|
+
} else if (!branchType) throw new GhError(`--branch-type is required for a task: ${TASK_TYPES.join(", ")} — the type of its branch and of its estimate`);
|
|
1875
|
+
else if (!TASK_TYPES.includes(branchType)) throw new GhError(`--branch-type: ${TASK_TYPES.join(", ")}`);
|
|
1876
|
+
const o = { ...task, type, body: task.body.trim() ? task.body : taskTemplate(type) };
|
|
1877
|
+
// in the cloud the organization's types aren't readable (`orgs/…` — 403): the type goes by name, GitHub checks it
|
|
1878
|
+
const issueType = org && ctx ? ctx.repo.owner.issueTypes.find((t) => t.name === o.type && t.isEnabled) : undefined;
|
|
1879
|
+
if (org && ctx && !issueType) throw new GhError(`the organization has no enabled type ${q(o.type)} — github project fix`);
|
|
1880
|
+
if (!org && o.priority) throw new GhError("a personal account has no issue fields — no priority is kept, --priority isn't needed");
|
|
1881
|
+
if (org && !ctx && o.priority && !(PRIORITIES as readonly string[]).includes(o.priority)) throw new GhError(`--priority: ${PRIORITIES.join(", ")}`);
|
|
1882
|
+
let priority: { fieldId: string; optionId: string; name: string } | null = null;
|
|
1883
|
+
if (org && ctx) {
|
|
1884
|
+
const f = ctx.repo.owner.issueFields.find((x) => x.name === PRIORITY);
|
|
1885
|
+
if (!f) throw new GhError(`the organization has no issue field ${PRIORITY} — github project fix`);
|
|
1886
|
+
const want = o.priority ?? DEFAULT_PRIORITY;
|
|
1887
|
+
const opt = f.options.find((x) => x.name === want);
|
|
1888
|
+
if (!opt) throw new GhError(`--priority: ${f.options.map((x) => x.name).join(", ")}`);
|
|
1889
|
+
priority = { fieldId: f.id, optionId: opt.id, name: want };
|
|
1890
|
+
}
|
|
1891
|
+
let title = o.title.trim();
|
|
1892
|
+
const epic = o.epic === undefined ? null : loadIssue(io, slug, o.epic);
|
|
1893
|
+
if (epic) {
|
|
1894
|
+
if (!isEpic(epic, org)) throw new GhError(`#${epic.number} is not an epic (${org ? `the type is not ${q(N.issueTypes.epic)}` : `no ${EPIC_LABEL} label`})`);
|
|
1895
|
+
if (epic.state !== "OPEN") throw new GhError(`epic #${epic.number} is closed`);
|
|
1896
|
+
const prefix = epicPrefix(epic.title);
|
|
1897
|
+
if (prefix && !title.startsWith(`${prefix} · `)) title = `${prefix} · ${title}`;
|
|
1898
|
+
}
|
|
1899
|
+
const blockers = o.blockedBy.map((n) => loadIssue(io, slug, n));
|
|
1900
|
+
const needCtx = !!o.milestone || (!org && o.type === N.issueTypes.epic);
|
|
1901
|
+
const restCtx = () => {
|
|
1902
|
+
const label = rest(io, `repos/${slug}/labels/${EPIC_LABEL}`, { missing: true });
|
|
1903
|
+
return { label: label && { id: label.node_id }, milestones: { nodes: restList(io, `repos/${slug}/milestones?state=open`).map((m) => ({ id: m.node_id, number: m.number, title: m.title })) } };
|
|
1904
|
+
};
|
|
1905
|
+
const repoCtx = !needCtx ? null : cloud ? restCtx() : graphql(io, Q.TaskContext, { owner, name }).repository;
|
|
1906
|
+
const milestone = o.milestone ? (repoCtx.milestones.nodes as { id: string; number?: number; title: string }[]).find((m) => m.title === o.milestone) : undefined;
|
|
1907
|
+
if (o.milestone && !milestone) throw new GhError(`no open milestone ${q(o.milestone)}`);
|
|
1908
|
+
// `search/issues` in the cloud is 403 («sessions are bound to their configured repositories») — a listing of the open ones
|
|
1909
|
+
const openIssues: Any[] | null = cloud ? restList(io, `repos/${slug}/issues?state=open`).filter((i) => !i.pull_request) : null;
|
|
1910
|
+
for (const same of sameTitles(title)) {
|
|
1911
|
+
const found: Any[] = openIssues ?? graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open in:title "${same.replaceAll('"', "")}"` }).search.nodes;
|
|
1912
|
+
const dup = found.find((x) => x?.title === same);
|
|
1913
|
+
if (dup) throw new GhError(`an open task with this title already exists: #${dup.number}${same === title ? "" : ` (${q(same)} — its title before the flow's rename)`}`);
|
|
1914
|
+
}
|
|
1915
|
+
const wanted = [...new Set(o.labels ?? [])];
|
|
1916
|
+
// open questions in the description — the questions label: the dashboard and the queue find the task by it
|
|
1917
|
+
const open = questionItems(o.body).filter((x) => !x.answered).map((x) => x.k);
|
|
1918
|
+
const repoLabels = wanted.length || open.length ? loadLabels(io, slug) : [];
|
|
1919
|
+
const has = (n: string) => repoLabels.find((x) => x.name === n);
|
|
1920
|
+
// a decision by name comes from the default branch; `kind:name` — also a new one that isn't there yet
|
|
1921
|
+
const decisions = wanted.some((l) => legacyDecision(l) || !has(l)) ? loadDecisions(io, slug) : null;
|
|
1922
|
+
const inMain = decisions ? decisionsByName(decisions) : new Map<string, DecisionKind[]>();
|
|
1923
|
+
const plan = wanted.map((l) => {
|
|
1924
|
+
const dec = legacyDecision(l);
|
|
1925
|
+
const name = dec?.name ?? l;
|
|
1926
|
+
const label = has(name);
|
|
1927
|
+
const main = inMain.get(name) ?? [];
|
|
1928
|
+
const kinds = dec && !main.includes(dec.kind) ? [...main, dec.kind] : main;
|
|
1929
|
+
const dim = dec && label ? labelDimension(label.description) : null;
|
|
1930
|
+
if (dim) throw new GhError(`label ${q(name)} is slice ${q(dim)}, not a decision label: decision ${name} isn't labeled; the slice label — --labels ${name}`);
|
|
1931
|
+
if (!label && !kinds.length) {
|
|
1932
|
+
throw new GhError(`no label ${q(l)} in the repository and no decision ${l} in the default branch; a new decision — kind:name (${DECISION_KINDS.map((k) => `${k}:${l}`).join(", ")})`);
|
|
1933
|
+
}
|
|
1934
|
+
return { name, label, kinds, decision: dec !== null || (label ? labelKinds(label.description) !== null : true), fresh: dec && !main.includes(dec.kind) ? `${name} — new decision: ${decisionPath(dec.kind, name)} is not in the default branch yet` : null };
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
const done: string[] = [];
|
|
1938
|
+
// a new label of the repository — its id; in the cloud over REST
|
|
1939
|
+
const createLabel = (label: { name: string; color: string; description: string }): string =>
|
|
1940
|
+
ctx ? (mutate(io, { op: "CreateLabel", input: { repositoryId: ctx.repo.id, ...label } }).createLabel.label.id as string) : rest(io, `repos/${slug}/labels`, { method: "POST", payload: label }).node_id;
|
|
1941
|
+
// the issue's labels: GraphQL takes their ids, REST — their names
|
|
1942
|
+
const labels: { id: string; name: string }[] = [];
|
|
1943
|
+
if (issueType || (org && !ctx)) done.push(`type ${q(o.type)}`);
|
|
1944
|
+
if (!org && o.type === N.issueTypes.epic) {
|
|
1945
|
+
let labelId: string | undefined = repoCtx.label?.id;
|
|
1946
|
+
if (!labelId) {
|
|
1947
|
+
labelId = createLabel(epicLabel());
|
|
1948
|
+
done.push(`created label ${q(EPIC_LABEL)}`);
|
|
1949
|
+
}
|
|
1950
|
+
labels.push({ id: labelId, name: EPIC_LABEL });
|
|
1951
|
+
done.push(`label ${q(EPIC_LABEL)} — the type in a personal account`);
|
|
1952
|
+
}
|
|
1953
|
+
if (plan.length) {
|
|
1954
|
+
for (const x of plan) {
|
|
1955
|
+
let id = x.label?.id;
|
|
1956
|
+
if (!id) {
|
|
1957
|
+
id = createLabel(decisionLabel(x.name, x.kinds));
|
|
1958
|
+
done.push(`created label ${q(x.name)}`);
|
|
1959
|
+
}
|
|
1960
|
+
labels.push({ id, name: x.name });
|
|
1961
|
+
}
|
|
1962
|
+
// the decision will appear in this task's PR — the label says so instead of failing
|
|
1963
|
+
done.push(`labels: ${plan.map((x) => x.name).join(", ")}`, ...plan.flatMap((x) => (x.fresh ? [x.fresh] : [])));
|
|
1964
|
+
}
|
|
1965
|
+
if (open.length) {
|
|
1966
|
+
let id = has(N.labels.questions)?.id;
|
|
1967
|
+
if (!id) {
|
|
1968
|
+
id = createLabel(questionsLabel());
|
|
1969
|
+
done.push(`created label ${q(N.labels.questions)}`);
|
|
1970
|
+
}
|
|
1971
|
+
// `--labels` may name it too
|
|
1972
|
+
if (!labels.some((l) => l.id === id)) labels.push({ id, name: N.labels.questions });
|
|
1973
|
+
done.push(`label ${q(N.labels.questions)} — open questions in the description: ${open.join(", ")}`);
|
|
1974
|
+
}
|
|
1975
|
+
let created: { id: string; databaseId?: number; number: number; title: string; url: string; projectItems?: Any };
|
|
1976
|
+
if (ctx) {
|
|
1977
|
+
const input: Record<string, unknown> = { repositoryId: ctx.repo.id, title, body: o.body, projectV2Ids: [ctx.project.id] };
|
|
1978
|
+
if (issueType) input.issueTypeId = issueType.id;
|
|
1979
|
+
if (labels.length) input.labelIds = labels.map((l) => l.id);
|
|
1980
|
+
if (milestone) input.milestoneId = milestone.id;
|
|
1981
|
+
if (epic) input.parentIssueId = epic.id;
|
|
1982
|
+
if (priority) input.issueFields = [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }];
|
|
1983
|
+
created = mutate(io, { op: "CreateIssue", input }).createIssue.issue;
|
|
1984
|
+
// We set Status ourselves, without waiting for the "Item added to project" workflow.
|
|
1985
|
+
const listed = (created.projectItems?.nodes ?? []).map((it: Any) => ({ id: it.id, projectId: it.project.id, status: null }));
|
|
1986
|
+
const item = ensureItem(io, ctx, { id: created.id, number: created.number, items: listed }).id;
|
|
1987
|
+
setStatus(io, ctx, item, N.status.backlog);
|
|
1988
|
+
done.push(`project ${q(ctx.project.title)}: ${STATUS} ${q(N.status.backlog)}`);
|
|
1989
|
+
} else {
|
|
1990
|
+
const payload: Record<string, unknown> = { title, body: o.body };
|
|
1991
|
+
if (org) payload.type = o.type;
|
|
1992
|
+
if (labels.length) payload.labels = labels.map((l) => l.name);
|
|
1993
|
+
if (milestone) payload.milestone = milestone.number;
|
|
1994
|
+
const i = rest(io, `repos/${slug}/issues`, { method: "POST", payload });
|
|
1995
|
+
created = { id: i.node_id, databaseId: i.id, number: i.number, title: i.title, url: i.html_url };
|
|
1996
|
+
// REST has no parent in the issue's creation — the epic takes it as its sub-issue by the REST id
|
|
1997
|
+
if (epic) rest(io, `repos/${slug}/issues/${epic.number}/sub_issues`, { method: "POST", payload: { sub_issue_id: created.databaseId } });
|
|
1998
|
+
}
|
|
1999
|
+
if (priority) done.push(`${PRIORITY} ${q(priority.name)}`);
|
|
2000
|
+
if (epic) done.push(`subtask of epic #${epic.number}`);
|
|
2001
|
+
for (const b of blockers) {
|
|
2002
|
+
if (b.state !== "OPEN") {
|
|
2003
|
+
done.push(`#${b.number} is closed — doesn't block, skipped`);
|
|
2004
|
+
continue;
|
|
2005
|
+
}
|
|
2006
|
+
if (ctx) mutate(io, { op: "AddBlockedBy", input: { issueId: created.id, blockingIssueId: b.id } });
|
|
2007
|
+
else rest(io, `repos/${slug}/issues/${created.number}/dependencies/blocked_by`, { method: "POST", payload: { issue_id: b.databaseId } });
|
|
2008
|
+
done.push(`blocked by #${b.number}`);
|
|
2009
|
+
}
|
|
2010
|
+
if (milestone) done.push(`milestone ${q(milestone.title)}`);
|
|
2011
|
+
// the epic accumulates its subtasks' decision labels — up to GitHub's limit: the task is already created, a failure
|
|
2012
|
+
// here would leave the epic without its labels and without a line about them (#574)
|
|
2013
|
+
const decided = plan.filter((x) => x.decision).map((x) => x.name);
|
|
2014
|
+
const epicLabels = epic && decided.length ? putLabels(io, epic, `epic #${epic.number}`, decided, null, loadLabels(io, slug)) : [];
|
|
2015
|
+
// the epic — no lower than its most urgent open subtask
|
|
2016
|
+
if (epic && priority) {
|
|
2017
|
+
const rank = (p: string | null) => (p === null ? PRIORITIES.length : (PRIORITIES as readonly string[]).indexOf(p));
|
|
2018
|
+
if (rank(priority.name) < rank(epic.priority)) {
|
|
2019
|
+
mutate(io, { op: "SetIssueField", input: { issueId: epic.id, issueFields: [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }] } });
|
|
2020
|
+
done.push(`${PRIORITY} of epic #${epic.number}: ${epic.priority ?? "—"} → ${priority.name}`);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
io.out(`Created #${created.number} ${created.title} — ${created.url}`);
|
|
2025
|
+
for (const d of done) io.out(`+ ${d}`);
|
|
2026
|
+
for (const l of epicLabels) io.out(l);
|
|
2027
|
+
const isEpicType = o.type === N.issueTypes.epic;
|
|
2028
|
+
if (!ctx) {
|
|
2029
|
+
// the project and the estimate (est reads the analogs' actuals from the project, it refuses in the cloud) — a local
|
|
2030
|
+
// session's commands; Priority — an issue field, GraphQL only
|
|
2031
|
+
const n = created.number;
|
|
2032
|
+
const local = [`github task status ${n} "${N.status.backlog}" (the project, ${STATUS})`, ...(isEpicType ? [] : [`est estimate ${n} --type ${branchType} --write (${q(N.fields.estimate)})`])];
|
|
2033
|
+
const want = org ? (o.priority ?? DEFAULT_PRIORITY) : null;
|
|
2034
|
+
if (want) local.push(`${PRIORITY} ${q(want)}${epic ? ` and no lower at epic #${epic.number}` : ""} — on the issue page`);
|
|
2035
|
+
io.out(`Next: the cloud has no GitHub Projects (docs/cloud-sessions.md) — a local session: ${local.join("; ")}.`);
|
|
2036
|
+
}
|
|
2037
|
+
if (isEpicType) {
|
|
2038
|
+
io.out(`Next: an epic isn't estimated — its ${q(N.fields.estimate)} is the sum of its subtasks' estimates.`);
|
|
2039
|
+
// an epic's work goes to the dispatcher on the owner's "yes" (#546), not to a session card per subtask
|
|
2040
|
+
io.out(
|
|
2041
|
+
`Next: once its subtasks are created — the question to the owner «run the dispatcher on epic #${created.number}?»; ` +
|
|
2042
|
+
`yes — bun --no-env-file ${import.meta.path} task queue --epic ${created.number} --repo ${slug} in this session, its executors — background subagents.`,
|
|
2043
|
+
);
|
|
2044
|
+
return 0;
|
|
2045
|
+
}
|
|
2046
|
+
if (ctx) runEstimate(io, slug, created.number, branchType);
|
|
2047
|
+
if (epic) io.out(`Next: ${q(N.fields.estimate)} of epic #${epic.number} — recompute as the sum of its subtasks' estimates.`);
|
|
2048
|
+
// a session with a pinned task won't take a new one into work (pinTask): the owner decides whether to queue it
|
|
2049
|
+
const held = pinnedTask(io)?.task;
|
|
2050
|
+
if (held) io.out(`Next: the session runs ${held.slug}#${held.number}; #${created.number} isn't taken here — ${queueQuestion(slug, created.number, epic?.number ?? null)}`);
|
|
2051
|
+
return 0;
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
export function cmdTaskStatus(io: Io, slug: string, number: number, name: string, branch?: string): number {
|
|
2055
|
+
// status: a name in any dictionary language; checked before reading the project; the option is in the board's language
|
|
2056
|
+
if (!statusAliases()[name.toLowerCase()]) throw new GhError(`status: ${statusOptions().join(", ")}`);
|
|
2057
|
+
if (branch !== undefined && statusAliases()[name.toLowerCase()] !== N.status.inProgress) throw new GhError(`--branch — only with ${q(N.status.inProgress)}: the branch is renamed when the task is taken into work`);
|
|
2058
|
+
if (branch !== undefined && (taskBranch(branch)?.issue !== number || !TASK_SLUG_RE.test(branch))) {
|
|
2059
|
+
throw new GhError(`--branch ${q(branch)}: expected a branch of #${number} by the canon — [<area>/]<type>/${number}-<slug>, type ${TASK_TYPES.join("|")}, slug — Latin kebab-case`);
|
|
2060
|
+
}
|
|
2061
|
+
const ctx = taskContext(io, slug);
|
|
2062
|
+
const status = statusAliases()[name.toLowerCase()]!;
|
|
2063
|
+
const issue = loadIssue(io, slug, number);
|
|
2064
|
+
const epicTask = isEpic(issue, ctx.repo.owner.org);
|
|
2065
|
+
if (branch !== undefined && epicTask) throw new GhError(`--branch: #${number} is an epic — an epic has no branch`);
|
|
2066
|
+
// an epic isn't pinned: its session plans and hands out work, and the first subtask also sets the epic "In progress"
|
|
2067
|
+
if (status === N.status.inProgress && !epicTask) pinTask(io, slug, number, issue.parent?.number ?? null);
|
|
2068
|
+
const item = ensureItem(io, ctx, issue);
|
|
2069
|
+
setStatus(io, ctx, item.id, status);
|
|
2070
|
+
io.out(`+ #${issue.number}: ${STATUS} ${q(status)}${item.added ? " (added to the project)" : ""}`);
|
|
2071
|
+
const code = branch === undefined || renameBranch(io, slug, branch) ? 0 : 1;
|
|
2072
|
+
if (!issue.parent) return code;
|
|
2073
|
+
const epic = loadIssue(io, slug, issue.parent.number);
|
|
2074
|
+
const epicStatus = itemIn(ctx, epic)?.status ?? null;
|
|
2075
|
+
if (status === N.status.inProgress && epic.state === "OPEN" && (epicStatus === null || epicStatus === N.status.backlog)) {
|
|
2076
|
+
setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.inProgress);
|
|
2077
|
+
io.out(`+ epic #${epic.number}: ${STATUS} ${q(N.status.inProgress)} — the first subtask is taken`);
|
|
2078
|
+
}
|
|
2079
|
+
if (status === N.status.inProgress && epic.state === "CLOSED") io.out(`Next: epic #${epic.number} is closed, but a subtask is in work again — reopen it (and its milestone, if closed).`);
|
|
2080
|
+
if (status === N.status.done && epic.state === "OPEN" && epic.subIssues.length && epic.subIssues.every((x) => x.state === "CLOSED")) {
|
|
2081
|
+
io.out(`Next: all subtasks of epic #${epic.number} are closed — close the epic and set it to ${q(N.status.done)}.`);
|
|
2082
|
+
}
|
|
2083
|
+
return code;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
export function cmdTaskDrop(io: Io, slug: string, number: number, duplicateOf?: number): number {
|
|
2087
|
+
const ctx = taskContext(io, slug);
|
|
2088
|
+
const issue = loadIssue(io, slug, number);
|
|
2089
|
+
if (issue.state === "CLOSED" && issue.stateReason === "COMPLETED") throw new GhError(`#${number} is closed as completed — drop is only for tasks not done`);
|
|
2090
|
+
const dup = duplicateOf === undefined ? null : loadIssue(io, slug, duplicateOf);
|
|
2091
|
+
if (issue.state === "OPEN") {
|
|
2092
|
+
mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: dup ? "DUPLICATE" : "NOT_PLANNED", ...(dup ? { duplicateIssueId: dup.id } : {}) } });
|
|
2093
|
+
io.out(`+ #${number} closed: ${dup ? `duplicate of #${dup.number}` : "not planned"}`);
|
|
2094
|
+
}
|
|
2095
|
+
// "Item closed" would put a task closed without doing it into "Done" — it has no place in the project
|
|
2096
|
+
const item = itemIn(ctx, issue);
|
|
2097
|
+
if (item) {
|
|
2098
|
+
mutate(io, { op: "DeleteItem", input: { projectId: ctx.project.id, itemId: item.id } });
|
|
2099
|
+
io.out(`+ #${number} removed from project ${q(ctx.project.title)}`);
|
|
2100
|
+
}
|
|
2101
|
+
if (issue.parent) io.out(`Next: ${q(N.fields.estimate)} of epic #${issue.parent.number} — recompute as the sum of the remaining subtasks.`);
|
|
2102
|
+
return 0;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// ----------------------------------------------------------------------------
|
|
2106
|
+
// Reading a task and answering its questions
|
|
2107
|
+
// ----------------------------------------------------------------------------
|
|
2108
|
+
|
|
2109
|
+
/** A task's PR for task show: its state and its head's checks (none yet — null); `why` — what an unlinked one was found by. */
|
|
2110
|
+
interface ShownPr {
|
|
2111
|
+
number: number;
|
|
2112
|
+
state: string;
|
|
2113
|
+
merged: boolean;
|
|
2114
|
+
draft: boolean;
|
|
2115
|
+
checks: { green: number; pending: string[]; red: string[] } | null;
|
|
2116
|
+
why?: string;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/** The head's checks by name (`checkStates`): green (pass, skipping), still running, red (cancel, fail). */
|
|
2120
|
+
function shownPr(p: Any, why?: string): ShownPr {
|
|
2121
|
+
const by = [...checkStates(rollupOf(p))];
|
|
2122
|
+
const of = (...states: CheckBucket[]) => by.filter(([, v]) => states.includes(v)).map(([k]) => k);
|
|
2123
|
+
return { number: p.number, state: p.state ?? "", merged: Boolean(p.merged), draft: Boolean(p.isDraft), checks: by.length ? { green: of("pass", "skipping").length, pending: of("pending"), red: of("cancel", "fail") } : null, why };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function prLine(p: ShownPr): string {
|
|
2127
|
+
const done = p.merged || p.state === "CLOSED";
|
|
2128
|
+
const state = p.merged ? "merged" : p.state === "CLOSED" ? "closed without a merge" : p.draft ? "open (draft)" : "open";
|
|
2129
|
+
const c = p.checks;
|
|
2130
|
+
const checks = done
|
|
2131
|
+
? ""
|
|
2132
|
+
: !c
|
|
2133
|
+
? " — no checks yet"
|
|
2134
|
+
: ` — checks: ${[c.green ? `${c.green} ✅` : "", c.pending.length ? `${c.pending.length} ⏳ (${c.pending.join(", ")})` : "", c.red.length ? `${c.red.length} ❌ (${c.red.join(", ")})` : ""].filter(Boolean).join(", ")}`;
|
|
2135
|
+
return `PR #${p.number} ${state}${checks}${p.why ? ` (not linked: ${p.why})` : ""}`;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/**
|
|
2139
|
+
* IssueShow in the cloud (#603) — the repository's REST in the shape of the GraphQL answer: the issue, its epic with the
|
|
2140
|
+
* epic's tasks, sub-issues, blocked by; no project (Projects v2 — 403) and no Priority (an issue field, GraphQL only).
|
|
2141
|
+
*/
|
|
2142
|
+
function restIssueShow(io: Io, slug: string, number: number): Any {
|
|
2143
|
+
const i = restIssue(io, slug, number);
|
|
2144
|
+
if (!i) return null;
|
|
2145
|
+
const l = restLinks(io, slug, number);
|
|
2146
|
+
return { ...i, ...l, parent: l.parent && { ...l.parent, subIssues: restSubIssues(io, slug, l.parent.number) } };
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/**
|
|
2150
|
+
* The task's PRs in the cloud (#603): GraphQL's links to the task aren't there — the latest open and merged PRs into the
|
|
2151
|
+
* default branch by the task branch or «Closes #N» in the body, as `unlinkedPrs`; an open one's head checks — REST
|
|
2152
|
+
* check-runs and commit statuses in the shape of `HEAD_CHECKS`, for `checkStates`.
|
|
2153
|
+
*/
|
|
2154
|
+
function restPrs(io: Io, slug: string, number: number): Any[] {
|
|
2155
|
+
const prs: Any[] = rest(io, `repos/${slug}/pulls?state=all&sort=updated&direction=desc&per_page=${RECENT_PRS}`) ?? [];
|
|
2156
|
+
const mine = prs.filter((p) => p.base?.ref === p.base?.repo?.default_branch && (p.state === "open" || p.merged_at) && (isTaskBranch(p.head?.ref ?? "", number) || closingInBody(p.body ?? "").includes(number)));
|
|
2157
|
+
return mine.map((p) => {
|
|
2158
|
+
const open = p.state === "open";
|
|
2159
|
+
const runs: Any[] = open ? (rest(io, `repos/${slug}/commits/${p.head.sha}/check-runs?per_page=100`)?.check_runs ?? []) : [];
|
|
2160
|
+
const statuses: Any[] = open ? (rest(io, `repos/${slug}/commits/${p.head.sha}/status`)?.statuses ?? []) : [];
|
|
2161
|
+
const up = (v: unknown) => (v ? String(v).toUpperCase() : null);
|
|
2162
|
+
const nodes = [
|
|
2163
|
+
...runs.map((c) => ({ __typename: "CheckRun", name: c.name, status: up(c.status), conclusion: up(c.conclusion), databaseId: c.id, checkSuite: { app: { databaseId: c.app?.id ?? null } } })),
|
|
2164
|
+
...statuses.map((c) => ({ __typename: "StatusContext", context: c.context, state: up(c.state) })),
|
|
2165
|
+
];
|
|
2166
|
+
return { number: p.number, state: open ? "OPEN" : "MERGED", merged: !open, isDraft: Boolean(p.draft), headRefName: p.head?.ref ?? "", commits: { nodes: [{ commit: { statusCheckRollup: nodes.length ? { contexts: { nodes } } : null } }] } };
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
/**
|
|
2171
|
+
* Everything needed to take a task into work in one output, instead of `gh issue view` per piece: the title, type,
|
|
2172
|
+
* state, Status and estimate, the epic with its open questions and other open tasks, subtasks, open blockers, the PRs
|
|
2173
|
+
* with their checks (GitHub didn't link one yet — found by the branch or «Closes #N», as task close does), the
|
|
2174
|
+
* questions — open and answered, and the description as is. Read-only.
|
|
2175
|
+
*/
|
|
2176
|
+
export function cmdTaskShow(io: Io, slug: string, number: number): number {
|
|
2177
|
+
const [owner, name] = slug.split("/");
|
|
2178
|
+
const cloud = inCloud(io);
|
|
2179
|
+
const i = cloud ? restIssueShow(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
|
|
2180
|
+
if (!i) throw new GhError(`no issue #${number} in ${slug}`);
|
|
2181
|
+
const labels: string[] = (i.labels?.nodes ?? []).map((l: Any) => l.name);
|
|
2182
|
+
// a personal account has no issue types: an epic is the epic label
|
|
2183
|
+
const type: string | null = i.issueType?.name ?? (labels.includes(EPIC_LABEL) ? N.issueTypes.epic : null);
|
|
2184
|
+
const epic = type !== null && locale.every((n) => n.issueTypes.epic).includes(type);
|
|
2185
|
+
const items: Any[] = i.projectItems?.nodes ?? [];
|
|
2186
|
+
const values: Any[] = (items.find((it) => it.project?.title === name) ?? items[0])?.fieldValues?.nodes ?? [];
|
|
2187
|
+
const num = (field: (n: Names) => string) => values.find((v) => v?.__typename === "ProjectV2ItemFieldNumberValue" && locale.every(field).includes(v.field?.name));
|
|
2188
|
+
const status: string | null = values.find((v) => v?.field?.name === STATUS && v.name)?.name ?? null;
|
|
2189
|
+
const estimate = num((n) => n.fields.estimate);
|
|
2190
|
+
const actual = num((n) => n.fields.actual);
|
|
2191
|
+
const priority: string | null = (i.issueFieldValues?.nodes ?? []).find((v: Any) => v?.field?.name === PRIORITY)?.name ?? null;
|
|
2192
|
+
const reason: Record<string, string> = { NOT_PLANNED: ": not planned", DUPLICATE: ": duplicate" };
|
|
2193
|
+
const shownLabels = labels.filter((l) => !(l === EPIC_LABEL && !i.issueType));
|
|
2194
|
+
const facts = [
|
|
2195
|
+
type,
|
|
2196
|
+
i.state === "CLOSED" ? `closed${reason[i.stateReason] ?? ""}` : null,
|
|
2197
|
+
cloud ? "no project in the cloud (docs/cloud-sessions.md)" : !items.length ? "not in the project" : status ? `${STATUS} ${q(status)}` : `no ${STATUS}`,
|
|
2198
|
+
cloud ? null : estimate ? `${q(estimate.field.name)} ${estimate.number}` : `no ${q(N.fields.estimate)}`,
|
|
2199
|
+
actual ? `${q(actual.field.name)} ${actual.number}` : null,
|
|
2200
|
+
priority ? `${PRIORITY} ${priority}` : null,
|
|
2201
|
+
i.milestone ? `milestone ${q(i.milestone.title)}` : null,
|
|
2202
|
+
shownLabels.length ? `labels: ${shownLabels.join(", ")}` : null,
|
|
2203
|
+
];
|
|
2204
|
+
const out = [`#${i.number} ${i.title} — ${i.url}`, facts.filter(Boolean).join(" · ")];
|
|
2205
|
+
if (i.parent) {
|
|
2206
|
+
const p = i.parent;
|
|
2207
|
+
const open = questionItems(p.body ?? "").filter((x) => !x.answered);
|
|
2208
|
+
const tasks: Any[] = (p.subIssues?.nodes ?? []).filter((x: Any) => x.state === "OPEN" && x.number !== number);
|
|
2209
|
+
out.push(`Epic #${p.number} ${q(p.title)} — open questions: ${open.length}, other open tasks: ${tasks.length}`);
|
|
2210
|
+
out.push(...open.map((x) => `- question ${x.text}`), ...tasks.map((x) => `- task #${x.number} ${q(x.title)}`));
|
|
2211
|
+
}
|
|
2212
|
+
const subs: Any[] = i.subIssues?.nodes ?? [];
|
|
2213
|
+
if (subs.length) {
|
|
2214
|
+
const open = subs.filter((x) => x.state === "OPEN");
|
|
2215
|
+
out.push(`Subtasks: open ${open.length} of ${subs.length}`, ...open.map((x) => `- task #${x.number} ${q(x.title)}`));
|
|
2216
|
+
}
|
|
2217
|
+
const blockers: Any[] = (i.blockedBy?.nodes ?? []).filter((x: Any) => x.state === "OPEN");
|
|
2218
|
+
if (blockers.length) out.push(`Blocked by: ${blockers.map((x) => `${blockerRef(slug, { number: x.number, repo: x.repository?.nameWithOwner ?? slug })} ${q(x.title)}`).join(", ")}`);
|
|
2219
|
+
// an epic has no PR of its own
|
|
2220
|
+
if (!epic) {
|
|
2221
|
+
let prs: ShownPr[] = (cloud ? restPrs(io, slug, number) : (i.closedByPullRequestsReferences?.nodes ?? []).filter(Boolean)).map((p: Any) => shownPr(p));
|
|
2222
|
+
if (!cloud && !prs.length && i.state === "OPEN") {
|
|
2223
|
+
for (const p of unlinkedPrs(io, slug, number).prs) {
|
|
2224
|
+
const pr = graphql(io, Q.PrShow, { owner, name, number: p.number })?.repository?.pullRequest;
|
|
2225
|
+
if (pr) prs.push(shownPr(pr, p.why));
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
out.push(...(prs.length ? prs.map(prLine) : ["PR: none"]));
|
|
2229
|
+
}
|
|
2230
|
+
const lines = (i.body ?? "").trimEnd().split(/\r?\n/);
|
|
2231
|
+
const questions = questionItems(i.body ?? "");
|
|
2232
|
+
const open = questions.filter((x) => !x.answered).map((x) => x.k);
|
|
2233
|
+
const answered = questions.filter((x) => x.answered).map((x) => x.k);
|
|
2234
|
+
out.push(
|
|
2235
|
+
!questions.length
|
|
2236
|
+
? "Questions: none"
|
|
2237
|
+
: !open.length
|
|
2238
|
+
? `Questions: all answered (${answered.join(", ")})`
|
|
2239
|
+
: `Questions: open ${open.join(", ")}${answered.length ? `; answered ${answered.join(", ")}` : ""} — task answer ${number} <k> "<answer>" [--auto]`,
|
|
2240
|
+
);
|
|
2241
|
+
if (lines.join("").trim()) out.push("", ...lines);
|
|
2242
|
+
for (const line of out) io.out(line);
|
|
2243
|
+
return 0;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* The answer at question k of the questions section, in place of rewriting the description by hand: ✅ (with --auto —
|
|
2248
|
+
* `autoAnswer`) after the item's number and «**Answer:** …» (`est.answer`: est strips it from the description size) at
|
|
2249
|
+
* its end — on the same line for a one-line item, otherwise on a line of its own; the numbering doesn't change, the
|
|
2250
|
+
* rest of the description stays as is. No open questions left — the questions label is removed. An answered question
|
|
2251
|
+
* is refused: a changed answer is edited by hand.
|
|
2252
|
+
*/
|
|
2253
|
+
export function cmdTaskAnswer(io: Io, slug: string, number: number, k: number, answer: string, auto: boolean): number {
|
|
2254
|
+
const text = answer.trim().replace(/\s*\r?\n\s*/g, " ");
|
|
2255
|
+
if (!text) throw new GhError("task answer: expected the answer text after the question number");
|
|
2256
|
+
const [owner, name] = slug.split("/");
|
|
2257
|
+
const cloud = inCloud(io);
|
|
2258
|
+
const i = cloud ? restIssue(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
|
|
2259
|
+
if (!i) throw new GhError(`no issue #${number} in ${slug}`);
|
|
2260
|
+
const body: string = i.body ?? "";
|
|
2261
|
+
const lines = body.split(/\r?\n/);
|
|
2262
|
+
const questions = questionItems(body);
|
|
2263
|
+
if (!questions.length) throw new GhError(`#${number}: no ${q(N.sections.questions)} section with numbered questions`);
|
|
2264
|
+
const question = questions.find((x) => x.k === k);
|
|
2265
|
+
if (!question) throw new GhError(`#${number}: no question ${k} in ${q(N.sections.questions)}; questions: ${questions.map((x) => x.k).join(", ")}`);
|
|
2266
|
+
if (question.answered) throw new GhError(`#${number}: question ${k} is already answered (✅) — a changed answer is edited by hand`);
|
|
2267
|
+
const reply = `**${N.est.answer}:** ${text}`;
|
|
2268
|
+
lines[question.first] = lines[question.first]!.replace(/^(\d+\.\s+)/, `$1${auto ? N.autoAnswer : "✅"} `);
|
|
2269
|
+
if (question.first === question.last) lines[question.first] += ` ${reply}`;
|
|
2270
|
+
else lines.splice(question.last + 1, 0, `${" ".repeat(`${k}. `.length)}${reply}`);
|
|
2271
|
+
const next = lines.join(body.includes("\r\n") ? "\r\n" : "\n");
|
|
2272
|
+
if (cloud) rest(io, `repos/${slug}/issues/${number}`, { method: "PATCH", payload: { body: next } });
|
|
2273
|
+
else mutate(io, { op: "UpdateIssue", input: { id: i.id, body: next } });
|
|
2274
|
+
io.out(`+ #${number}: question ${k} — ✅ and the answer in the description`);
|
|
2275
|
+
const open = questions.filter((x) => !x.answered && x !== question).map((x) => x.k);
|
|
2276
|
+
if (open.length) return io.out(`○ #${number}: open questions left: ${open.join(", ")}`), 0;
|
|
2277
|
+
const label: Any[] = (i.labels?.nodes ?? []).filter((l: Any) => locale.every((n) => n.labels.questions).includes(l.name));
|
|
2278
|
+
if (label.length) {
|
|
2279
|
+
if (cloud) for (const l of label) rest(io, `repos/${slug}/issues/${number}/labels/${encodeURIComponent(l.name)}`, { method: "DELETE" });
|
|
2280
|
+
else mutate(io, { op: "RemoveLabels", input: { labelableId: i.id, labelIds: label.map((l) => l.id) } });
|
|
2281
|
+
io.out(`+ #${number}: label ${label.map((l) => q(l.name)).join(", ")} removed — no open questions left`);
|
|
2282
|
+
}
|
|
2283
|
+
return 0;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// ----------------------------------------------------------------------------
|
|
2287
|
+
// Closing a task in one command
|
|
2288
|
+
// ----------------------------------------------------------------------------
|
|
2289
|
+
|
|
2290
|
+
/**
|
|
2291
|
+
* The five epic-refresh checks — from the skill's reference.md, the one place of the canon: the list between
|
|
2292
|
+
* "Five checks:" and "What was fixed", an item per check.
|
|
2293
|
+
*/
|
|
2294
|
+
/** The dispatcher's selection: the epic's tasks or the repository's open ones, narrowed by type and Priority. */
|
|
2295
|
+
export interface QueueFilter {
|
|
2296
|
+
epic?: number;
|
|
2297
|
+
type?: string;
|
|
2298
|
+
priority?: string[];
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/** How many executors run at once by default: `slot` lets two heavy runs through, a third executor writes code meanwhile. */
|
|
2302
|
+
export const QUEUE_EXECUTORS = 3;
|
|
2303
|
+
|
|
2304
|
+
const priorityRank = (p: string | null) => PRIORITIES.indexOf((p ?? DEFAULT_PRIORITY) as (typeof PRIORITIES)[number]);
|
|
2305
|
+
|
|
2306
|
+
/**
|
|
2307
|
+
* The reason of an external blocker that isn't an issue (a release, a PR, another tracker — GitHub links only issues):
|
|
2308
|
+
* the label and the first line of the task's latest comment starting with `comments.externalBlocker` of any dictionary
|
|
2309
|
+
* language. The comments are read only for a labeled task.
|
|
2310
|
+
*/
|
|
2311
|
+
function externalBlocker(io: Io, slug: string, number: number, label: string): string {
|
|
2312
|
+
const marks = locale.every((n) => n.comments.externalBlocker);
|
|
2313
|
+
const comment = loadTaskText(io, slug, number)
|
|
2314
|
+
.comments.map((c) => c.body.trim())
|
|
2315
|
+
.filter((b) => marks.some((m) => b.startsWith(m)))
|
|
2316
|
+
.at(-1);
|
|
2317
|
+
if (comment === undefined) return `label ${q(label)}, no comment ${q(`${N.comments.externalBlocker} <reason>`)}`;
|
|
2318
|
+
const mark = marks.find((m) => comment.startsWith(m))!;
|
|
2319
|
+
return `label ${q(label)}: ${clip(comment.slice(mark.length).split(/\r?\n/)[0]!.trim())}`;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
/** The executor's prompt: the task first (`est` links the subagent's work by it), the whole assignment — from the script. */
|
|
2323
|
+
function executorPrompt(slug: string, number: number, epic: number | null): string {
|
|
2324
|
+
return locale.fill(N.queue.prompt, {
|
|
2325
|
+
task: N.subagentPrompt.task,
|
|
2326
|
+
number,
|
|
2327
|
+
repo: slug,
|
|
2328
|
+
epic: epic === null ? "" : locale.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic }),
|
|
2329
|
+
command: `bun --no-env-file ${import.meta.path} task queue --assignment ${number} --repo ${slug}`,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
/**
|
|
2334
|
+
* A cloud executor's brief and the dispatcher's steps after it (`task queue --assignment N --cloud`, #609). The brief —
|
|
2335
|
+
* without local paths and `#` but at its task: numbers in the title and the epic go without it.
|
|
2336
|
+
*/
|
|
2337
|
+
function cloudBrief(io: Io, slug: string, issue: Issue): void {
|
|
2338
|
+
const number = issue.number;
|
|
2339
|
+
const vars = {
|
|
2340
|
+
task: N.subagentPrompt.task,
|
|
2341
|
+
number,
|
|
2342
|
+
title: issue.title.replace(/#(\d)/g, "$1"),
|
|
2343
|
+
repo: slug,
|
|
2344
|
+
epic: issue.parent ? locale.fill(N.queue.cloudEpic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
|
|
2345
|
+
actual: N.est.cloud.actual.replace(/:$/, ""),
|
|
2346
|
+
state: N.comments.state,
|
|
2347
|
+
questions: N.sections.questions,
|
|
2348
|
+
};
|
|
2349
|
+
for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion]) io.out(locale.fill(line, vars));
|
|
2350
|
+
io.out(
|
|
2351
|
+
`Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` +
|
|
2352
|
+
`then as a background command: bash ${waitCi()} issue ${number} — a cloud session can't message a local one (docs/cloud-sessions.md), the task's closing is the signal: ` +
|
|
2353
|
+
`PASS — bun --no-env-file ${import.meta.path} task close ${number} (the actual with --write, ${STATUS}, the merged branch), then archive the executor's session where the agent has the tool; ` +
|
|
2354
|
+
`TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`,
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* The dispatcher's queue: its ready tasks go to background executors (subagents), K at a time; the queue is re-read
|
|
2360
|
+
* after each answer, so tasks that appeared meanwhile are picked up. A session with its own task dispatches too (#546:
|
|
2361
|
+
* the owner's "yes" to an epic's dispatcher becomes work at once, without a new session) — its pin stays, its task is
|
|
2362
|
+
* in work. Ready — open, not an epic, not in work, no open blockers (other repositories' too), no external blocker
|
|
2363
|
+
* label; order — Priority, then the number. In work (`=`) — "In progress", an open PR (linked, or by the task branch and
|
|
2364
|
+
* "Closes #N" among recent PRs) named with its link, or handed out to an executor of this session (`handedOutFile`).
|
|
2365
|
+
* Marks the session as a dispatcher (`dispatcherFile`). Before the queue — finished agents' worktrees
|
|
2366
|
+
* (`pruneAgentWorktrees`) but those of executors whose handed-out tasks are open and those a subagent still works in.
|
|
2367
|
+
*/
|
|
2368
|
+
export function cmdTaskQueue(io: Io, slug: string, o: QueueFilter): number {
|
|
2369
|
+
const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
|
|
2370
|
+
if (o.type !== undefined && (!want || want === N.issueTypes.epic)) throw new GhError(`--type: ${N.issueTypes.task} or ${N.issueTypes.bug}`);
|
|
2371
|
+
const bad = (o.priority ?? []).filter((p) => !(PRIORITIES as readonly string[]).includes(p));
|
|
2372
|
+
if (bad.length) throw new GhError(`--priority: ${PRIORITIES.join(", ")}, not ${bad.join(", ")}`);
|
|
2373
|
+
const ctx = taskContext(io, slug);
|
|
2374
|
+
const org = ctx.repo.owner.org;
|
|
2375
|
+
if (!org && (o.type !== undefined || o.priority?.length)) throw new GhError("--type and --priority — only in an organization: a personal account has no issue types and no Priority");
|
|
2376
|
+
let numbers: number[];
|
|
2377
|
+
if (o.epic !== undefined) {
|
|
2378
|
+
const epic = loadIssue(io, slug, o.epic);
|
|
2379
|
+
if (!isEpic(epic, org)) throw new GhError(`#${o.epic} is not an epic — --epic takes an epic`);
|
|
2380
|
+
if (epic.state !== "OPEN") throw new GhError(`epic #${o.epic} is closed`);
|
|
2381
|
+
numbers = epic.subIssues.filter((x) => x.state === "OPEN").map((x) => x.number);
|
|
2382
|
+
} else {
|
|
2383
|
+
const found = graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open` }).search.nodes as Any[];
|
|
2384
|
+
if (found.length >= 100) io.out("○ the search gave 100 open tasks — the queue sees only them: narrow it with --epic");
|
|
2385
|
+
numbers = found.filter((x) => x?.state === "OPEN").map((x) => x.number);
|
|
2386
|
+
}
|
|
2387
|
+
const [owner, name] = slug.split("/");
|
|
2388
|
+
const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p: Any) => p.state === "OPEN");
|
|
2389
|
+
const openPr = (i: Issue) =>
|
|
2390
|
+
i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p: Any) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
|
|
2391
|
+
const handed = handedOut(io, slug);
|
|
2392
|
+
/** Issue states the queue has read; a hand-out's task outside the queue is read when its worktree is up for removal. */
|
|
2393
|
+
const states = new Map<number, string>();
|
|
2394
|
+
const isOpen = (n: number) => {
|
|
2395
|
+
if (!states.has(n)) states.set(n, loadIssue(io, slug, n).state);
|
|
2396
|
+
return states.get(n) === "OPEN";
|
|
2397
|
+
};
|
|
2398
|
+
const ready: Issue[] = [];
|
|
2399
|
+
const running: string[] = [];
|
|
2400
|
+
const waiting: string[] = [];
|
|
2401
|
+
for (const n of numbers.sort((a, b) => a - b)) {
|
|
2402
|
+
const i = loadIssue(io, slug, n);
|
|
2403
|
+
states.set(i.number, i.state);
|
|
2404
|
+
if (i.state !== "OPEN") continue;
|
|
2405
|
+
const label = `#${i.number} ${q(i.title)}`;
|
|
2406
|
+
if (isEpic(i, org)) {
|
|
2407
|
+
waiting.push(`○ ${label} — an epic: its tasks — task queue --epic ${i.number}`);
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
if (want && typeAliases()[(i.type ?? "").toLowerCase()] !== want) continue;
|
|
2411
|
+
if (o.priority?.length && !o.priority.includes(i.priority ?? DEFAULT_PRIORITY)) continue;
|
|
2412
|
+
const status = itemIn(ctx, i)?.status ?? null;
|
|
2413
|
+
// the task is in work: In progress, an open PR (the executor waits for its CI — #513: it was ○ and the dispatcher
|
|
2414
|
+
// lost it from both lists), or handed out to an executor of this session that hasn't set In progress yet
|
|
2415
|
+
const pr = openPr(i);
|
|
2416
|
+
const work = [status === N.status.inProgress ? q(N.status.inProgress) : "", pr === undefined ? "" : `open PR #${pr} https://github.com/${slug}/pull/${pr}`].filter(Boolean);
|
|
2417
|
+
const hand = handed.find((h) => h.number === i.number);
|
|
2418
|
+
// a cloud executor's wait for the task's closing dies with the app's process: the line names it, task resume — the queue
|
|
2419
|
+
if (!work.length && hand?.cloud) work.push(`handed out to a cloud executor of this session, its wait — a background command: bash ${waitCi()} issue ${i.number}`);
|
|
2420
|
+
else if (!work.length && hand) work.push("handed out to an executor of this session");
|
|
2421
|
+
if (work.length) {
|
|
2422
|
+
running.push(`= ${label} — ${work.join(", ")}`);
|
|
2423
|
+
continue;
|
|
2424
|
+
}
|
|
2425
|
+
const blockers = i.blockers.filter((b) => b.state === "OPEN").map((b) => blockerRef(slug, b));
|
|
2426
|
+
const external = i.labels.find((l) => locale.every((n) => n.labels.externalBlocker).includes(l));
|
|
2427
|
+
if (status === N.status.done) waiting.push(`○ ${label} — ${STATUS} ${q(status)}, but the task is open`);
|
|
2428
|
+
else if (blockers.length) waiting.push(`○ ${label} — blocked by ${blockers.join(", ")}`);
|
|
2429
|
+
else if (external) waiting.push(`○ ${label} — ${externalBlocker(io, slug, i.number, external)}`);
|
|
2430
|
+
else ready.push(i);
|
|
2431
|
+
}
|
|
2432
|
+
ready.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.number - b.number);
|
|
2433
|
+
const args = [o.epic !== undefined && `--epic ${o.epic}`, o.type !== undefined && `--type ${o.type}`, o.priority?.length && `--priority ${o.priority.join(",")}`].filter(Boolean).join(" ");
|
|
2434
|
+
const file = dispatcherFile(io);
|
|
2435
|
+
if (file) {
|
|
2436
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2437
|
+
// the configuration directory may be a personal git checkout (ELVES_PRIVATE) — markers aren't committed into it
|
|
2438
|
+
writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n");
|
|
2439
|
+
writeFileSync(file, `bun --no-env-file ${import.meta.path} task queue ${args ? `${args} ` : ""}--repo ${slug}\n`);
|
|
2440
|
+
} else io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — not marked as a dispatcher: task status in the executors would pin their tasks");
|
|
2441
|
+
appWorktreeWarning(io);
|
|
2442
|
+
// the executor the dispatcher continues with SendMessage — an answered question, a restart — has no lock (#533)
|
|
2443
|
+
const executors = handed.flatMap((h) => (h.worktree === null ? [] : [{ number: h.number, worktree: realPath(h.worktree) }]));
|
|
2444
|
+
pruneAgentWorktrees(io, slug, (dir) => executors.some((h) => h.worktree === realPath(dir) && isOpen(h.number)));
|
|
2445
|
+
const scope = [o.epic !== undefined ? `epic #${o.epic}` : "open tasks", want && `type ${q(want)}`, o.priority?.length && `Priority ${o.priority.join("|")}`].filter(Boolean).join(", ");
|
|
2446
|
+
io.out(`Queue ${slug} (${scope}): ready ${ready.length}, ${q(N.status.inProgress)} ${running.length}, not ready ${waiting.length}`);
|
|
2447
|
+
for (const i of ready) {
|
|
2448
|
+
io.out(`+ #${i.number} ${q(i.title)}${org ? ` (${i.priority ?? DEFAULT_PRIORITY})` : ""}`);
|
|
2449
|
+
io.out(` ${executorPrompt(slug, i.number, i.parent?.number ?? null)}`);
|
|
2450
|
+
}
|
|
2451
|
+
for (const line of running) io.out(line);
|
|
2452
|
+
for (const line of waiting) io.out(line);
|
|
2453
|
+
if (ready.length) {
|
|
2454
|
+
io.out(
|
|
2455
|
+
`Next: each ready task above — a background subagent in its own worktree while fewer than ${QUEUE_EXECUTORS} run (a number from the request replaces it); ` +
|
|
2456
|
+
"the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in.",
|
|
2457
|
+
);
|
|
2458
|
+
} else if (running.length) io.out("Next: no ready tasks — wait for the executors' answers; after each — task queue again.");
|
|
2459
|
+
else io.out(`Queue is empty: nothing ready, nothing ${q(N.status.inProgress)} — the dispatcher's summary (merged, questions, the not ready ones above) and the last line; a session with its own task — after it.`);
|
|
2460
|
+
return 0;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/** A Claude Code agent's worktree (`isolation: "worktree"`): `<checkout>/.claude/worktrees/agent-<id>`, branch `worktree-agent-<id>`. */
|
|
2464
|
+
const AGENT_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]agent-[^\\/]+$/;
|
|
2465
|
+
const AGENT_BRANCH = /^worktree-agent-/;
|
|
2466
|
+
/** A session's worktree of the app: `<checkout>/.claude/worktrees/<name>`, not an agent's. */
|
|
2467
|
+
const APP_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/](?!agent-)[^\\/]+$/;
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* The app starts a cloud session from the branch in the starting session's record (`get_session` → `branch`), not from
|
|
2471
|
+
* git (#588): a session from the app's worktree has its `claude/<slug>-<hash>` there, never pushed — the clone fails, and
|
|
2472
|
+
* a detached checkout doesn't help, the record keeps the branch; a session in the project folder has none — `main`. The
|
|
2473
|
+
* dispatcher gets a `!` line, the queue still goes: subagent executors branch from origin/main in their own worktrees.
|
|
2474
|
+
*/
|
|
2475
|
+
function appWorktreeWarning(io: Io): void {
|
|
2476
|
+
const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd).stdout.trim();
|
|
2477
|
+
if (!APP_WORKTREE.test(top)) return;
|
|
2478
|
+
const project = top.replace(/[\\/]\.claude[\\/]worktrees[\\/][^\\/]+$/, "");
|
|
2479
|
+
io.out(
|
|
2480
|
+
`! session in an app worktree ${top}: the app starts its cloud sessions from the branch in the session's record, not from main — ` +
|
|
2481
|
+
`cloud executors from it fail with «The requested branch or commit was not found»; a dispatcher with cloud executors — ` +
|
|
2482
|
+
`a session in the project folder ${project} without a worktree; subagent executors aren't affected`,
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
interface Worktree {
|
|
2487
|
+
path: string;
|
|
2488
|
+
head: string;
|
|
2489
|
+
/** The checked out branch; detached — null. */
|
|
2490
|
+
branch: string | null;
|
|
2491
|
+
/** The lock's reason ("" — locked without one); not locked — null. */
|
|
2492
|
+
locked: string | null;
|
|
2493
|
+
/** The directory is gone — `git worktree prune` business, not ours. */
|
|
2494
|
+
prunable: boolean;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/** `git worktree list --porcelain`: a block per worktree, an attribute per line (`worktree <path>`, `HEAD`, `branch`, `locked [reason]`). */
|
|
2498
|
+
function worktreeList(text: string): Worktree[] {
|
|
2499
|
+
return text
|
|
2500
|
+
.split(/\n\s*\n/)
|
|
2501
|
+
.map((block) => {
|
|
2502
|
+
const lines = block.split("\n");
|
|
2503
|
+
const attr = (key: string) => {
|
|
2504
|
+
const line = lines.find((l) => l === key || l.startsWith(`${key} `));
|
|
2505
|
+
return line === undefined ? null : line.slice(key.length + 1);
|
|
2506
|
+
};
|
|
2507
|
+
return { path: attr("worktree") ?? "", head: attr("HEAD") ?? "", branch: attr("branch")?.replace(/^refs\/heads\//, "") ?? null, locked: attr("locked"), prunable: attr("prunable") !== null };
|
|
2508
|
+
})
|
|
2509
|
+
.filter((w) => w.path);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
/**
|
|
2513
|
+
* Whether a lock still holds: Claude Code locks a running agent's worktree — `claude agent agent-<id> (pid N start …)`
|
|
2514
|
+
* — and unlocks it when the agent finishes. Its process is gone (the app restarted, the agent died with it) — the lock
|
|
2515
|
+
* is stale. A lock without a pid is someone else's — it holds.
|
|
2516
|
+
*/
|
|
2517
|
+
function lockHolds(reason: string): boolean {
|
|
2518
|
+
const pid = /\bpid (\d+)\b/.exec(reason)?.[1];
|
|
2519
|
+
if (!pid) return true;
|
|
2520
|
+
try {
|
|
2521
|
+
process.kill(Number(pid), 0);
|
|
2522
|
+
return true;
|
|
2523
|
+
} catch (e) {
|
|
2524
|
+
return (e as NodeJS.ErrnoException).code !== "ESRCH";
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Executors' worktrees go away with the dispatcher's next queue (#515): an executor can't remove its own — it works in it,
|
|
2532
|
+
* and `task close` leaves it detached on origin/<base>; its subagents leave theirs on `worktree-agent-*` branches.
|
|
2533
|
+
* Only agents' worktrees (`AGENT_WORKTREE`), detached or on a `worktree-agent-*` branch, not locked by a live process
|
|
2534
|
+
* (a running agent): clean and without commits outside origin/<base> (`git cherry` — a rebase merge gives new SHAs) —
|
|
2535
|
+
* removed with their branch, a `+` line each; uncommitted changes or unmerged commits — left, a `○` line with the
|
|
2536
|
+
* reason. A `worktree-agent-*` branch without a worktree — the same by its commits. A session's worktree, an agent's on
|
|
2537
|
+
* a task branch (its task isn't closed), an executor's whose handed-out task is open (`kept`, #533: Claude Code unlocks
|
|
2538
|
+
* an agent's worktree when it ends its answer — a question too — and doesn't lock it again when the agent is continued
|
|
2539
|
+
* with `SendMessage` after a restart) — not touched, no line. A worktree a subagent of its agent works in without one
|
|
2540
|
+
* of its own (`guestWhy`, #571: the executor answered, its epic refresh still ran in its directory) — left, a `○` line
|
|
2541
|
+
* naming the subagent. The directory isn't a checkout of the repository — nothing.
|
|
2542
|
+
*/
|
|
2543
|
+
function pruneAgentWorktrees(io: Io, slug: string, kept: (dir: string) => boolean): void {
|
|
2544
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) return;
|
|
2545
|
+
const git = (cwd: string | undefined, ...args: string[]) => (io.run ?? realRun)("git", args, cwd);
|
|
2546
|
+
const listed = git(io.cwd, "worktree", "list", "--porcelain");
|
|
2547
|
+
if (listed.status !== 0) return;
|
|
2548
|
+
const all = worktreeList(listed.stdout);
|
|
2549
|
+
const here = git(io.cwd, "rev-parse", "--show-toplevel").stdout.trim();
|
|
2550
|
+
const base = git(io.cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
|
|
2551
|
+
const upstream = `origin/${base}`;
|
|
2552
|
+
const why = (r: RunResult) => r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`;
|
|
2553
|
+
/** Commits of the ref outside origin/<base> by patch (`git cherry`), or why they couldn't be counted. */
|
|
2554
|
+
const unmerged = (ref: string): string | null => {
|
|
2555
|
+
const r = git(io.cwd, "cherry", upstream, ref);
|
|
2556
|
+
if (r.status !== 0) return `git cherry ${upstream}: ${why(r)}`;
|
|
2557
|
+
const n = r.stdout.split("\n").filter((l) => l.startsWith("+")).length;
|
|
2558
|
+
return n ? `${plural(n, "commit", "commits")} not in ${upstream}` : null;
|
|
2559
|
+
};
|
|
2560
|
+
const deleteBranch = (branch: string) => {
|
|
2561
|
+
const d = git(io.cwd, "branch", "-D", branch);
|
|
2562
|
+
if (d.status !== 0) io.out(`! git branch -D ${branch}: ${why(d)}`);
|
|
2563
|
+
return d.status === 0;
|
|
2564
|
+
};
|
|
2565
|
+
const candidates = all
|
|
2566
|
+
.filter((w) => AGENT_WORKTREE.test(w.path) && w.path !== here && !w.prunable && (w.branch === null || AGENT_BRANCH.test(w.branch)))
|
|
2567
|
+
.filter((w) => (w.locked === null || !lockHolds(w.locked)) && !kept(w.path))
|
|
2568
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
2569
|
+
// the subagents of this session and of the machine's other sessions with hand-outs: their executors' worktrees are here too
|
|
2570
|
+
const agents = candidates.length ? subagentsOf(io, [io.env.CLAUDE_CODE_SESSION_ID ?? "", ...handOutFiles(io).map((h) => h.session)].filter(Boolean)) : new Map<string, Subagent>();
|
|
2571
|
+
for (const w of candidates) {
|
|
2572
|
+
const guest = guestWhy(agents, agentIdOf(w.path)!);
|
|
2573
|
+
if (guest) {
|
|
2574
|
+
io.out(`○ worktree ${w.path} left: ${guest}`);
|
|
2575
|
+
continue;
|
|
2576
|
+
}
|
|
2577
|
+
const status = git(w.path, "status", "--porcelain");
|
|
2578
|
+
if (status.status !== 0) {
|
|
2579
|
+
io.out(`! worktree ${w.path}: git status: ${why(status)}`);
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
const dirty = status.stdout.split("\n").filter((l) => l.trim()).length;
|
|
2583
|
+
const reasons = [dirty ? `${plural(dirty, "uncommitted file", "uncommitted files")}` : null, unmerged(w.head)].filter((r): r is string => r !== null);
|
|
2584
|
+
if (reasons.length) {
|
|
2585
|
+
io.out(`○ worktree ${w.path} left: ${reasons.join(", ")}`);
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2588
|
+
// a stale lock: its process is gone, and a locked worktree isn't removed without double force — which would drop the dirty check too
|
|
2589
|
+
if (w.locked !== null) git(io.cwd, "worktree", "unlock", w.path);
|
|
2590
|
+
const removed = git(io.cwd, "worktree", "remove", w.path);
|
|
2591
|
+
if (removed.status !== 0) {
|
|
2592
|
+
io.out(`! git worktree remove ${w.path}: ${why(removed)}`);
|
|
2593
|
+
continue;
|
|
2594
|
+
}
|
|
2595
|
+
const withBranch = w.branch !== null && deleteBranch(w.branch) ? ` with branch ${w.branch}` : "";
|
|
2596
|
+
io.out(`+ worktree ${w.path} removed${withBranch}: clean, nothing outside ${upstream}`);
|
|
2597
|
+
}
|
|
2598
|
+
// a branch checked out anywhere — a locked worktree's included — isn't loose
|
|
2599
|
+
const checkedOut = new Set(all.map((w) => w.branch).filter(Boolean));
|
|
2600
|
+
const loose = git(io.cwd, "for-each-ref", "--format=%(refname:short)", "refs/heads/worktree-agent-*")
|
|
2601
|
+
.stdout.split("\n")
|
|
2602
|
+
.map((b) => b.trim())
|
|
2603
|
+
.filter((b) => b && !checkedOut.has(b))
|
|
2604
|
+
.sort();
|
|
2605
|
+
for (const branch of loose) {
|
|
2606
|
+
const reason = unmerged(branch);
|
|
2607
|
+
if (reason) io.out(`○ branch ${branch} left: ${reason}`);
|
|
2608
|
+
else if (deleteBranch(branch)) io.out(`+ branch ${branch} deleted: nothing outside ${upstream}`);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/**
|
|
2613
|
+
* A worktree whose HEAD moved (a commit, a switch, a rebase) this recently still has an agent at work: Claude Code
|
|
2614
|
+
* doesn't lock the worktree of an agent continued after its answer (#533) — woken by its subagents' answers, the
|
|
2615
|
+
* executor of #545 worked unlocked (#565). The wait for CI is a Bash call of up to 9 minutes without commits.
|
|
2616
|
+
*/
|
|
2617
|
+
const QUIET_MINUTES = 15;
|
|
2618
|
+
|
|
2619
|
+
/** The agent id of an agent's worktree (`agent-<id>`); another directory — null. */
|
|
2620
|
+
const agentIdOf = (dir: string) => (AGENT_WORKTREE.test(dir) ? path.basename(dir).slice("agent-".length) : null);
|
|
2621
|
+
|
|
2622
|
+
/** A Claude Code subagent by its metadata (`subagentsOf`). */
|
|
2623
|
+
interface Subagent {
|
|
2624
|
+
/** The agent that launched it; one the session itself launched — null. */
|
|
2625
|
+
parent: string | null;
|
|
2626
|
+
/** It has a worktree of its own (`isolation: "worktree"`); without one it works in its parent's directory. */
|
|
2627
|
+
own: boolean;
|
|
2628
|
+
/** The last write of its transcript, ms; no transcript — 0. */
|
|
2629
|
+
written: number;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* The sessions' Claude Code subagents, by id (`agent-<id>` — its worktree's name too), from the subagent metadata
|
|
2634
|
+
* `<Claude Code configuration>/projects/<directory>/<session id>/subagents/agent-<id>.meta.json`: `parentAgentId` — the
|
|
2635
|
+
* agent that launched it, `worktreePath` named `agent-<id>` — its own worktree; the transcript `agent-<id>.jsonl` next to
|
|
2636
|
+
* it. No metadata — an empty map.
|
|
2637
|
+
*/
|
|
2638
|
+
function subagentsOf(io: Io, sessions: string[]): Map<string, Subagent> {
|
|
2639
|
+
const agents = new Map<string, Subagent>();
|
|
2640
|
+
const projects = path.join(io.env.CLAUDE_CONFIG_DIR || path.join(io.env.HOME || os.homedir(), ".claude"), "projects");
|
|
2641
|
+
let dirs: string[];
|
|
2642
|
+
try {
|
|
2643
|
+
dirs = readdirSync(projects);
|
|
2644
|
+
} catch {
|
|
2645
|
+
return agents;
|
|
2646
|
+
}
|
|
2647
|
+
for (const dir of dirs) {
|
|
2648
|
+
for (const session of new Set(sessions)) {
|
|
2649
|
+
const sub = path.join(projects, dir, session, "subagents");
|
|
2650
|
+
let names: string[];
|
|
2651
|
+
try {
|
|
2652
|
+
names = readdirSync(sub);
|
|
2653
|
+
} catch {
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
for (const name of names) {
|
|
2657
|
+
const id = /^agent-(.+)\.meta\.json$/.exec(name)?.[1];
|
|
2658
|
+
if (!id) continue;
|
|
2659
|
+
try {
|
|
2660
|
+
const meta = JSON.parse(readFileSync(path.join(sub, name), "utf8"));
|
|
2661
|
+
const parent = meta?.parentAgentId;
|
|
2662
|
+
const worktree = meta?.worktreePath;
|
|
2663
|
+
agents.set(id, {
|
|
2664
|
+
parent: typeof parent === "string" && parent ? parent : null,
|
|
2665
|
+
own: typeof worktree === "string" && path.basename(worktree) === `agent-${id}`,
|
|
2666
|
+
written: statSync(path.join(sub, `agent-${id}.jsonl`), { throwIfNoEntry: false })?.mtimeMs ?? 0,
|
|
2667
|
+
});
|
|
2668
|
+
} catch {
|
|
2669
|
+
// the app is writing it — the agent has only just started, its worktree is locked
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
return agents;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
/**
|
|
2678
|
+
* A subagent at work in an agent's worktree without one of its own (#571): Claude Code runs a subagent launched without
|
|
2679
|
+
* `isolation: "worktree"` in its parent's directory — an executor's epic refresh, in the executor's worktree. The host
|
|
2680
|
+
* agent's descendants through such subagents, at any depth; at work — its transcript written less than `QUIET_MINUTES`
|
|
2681
|
+
* ago (a Bash call of up to 9 minutes writes nothing). The freshest is named; none — null.
|
|
2682
|
+
*/
|
|
2683
|
+
function guestWhy(agents: Map<string, Subagent>, host: string): string | null {
|
|
2684
|
+
const inside = new Set([host]);
|
|
2685
|
+
let fresh: { id: string; minutes: number } | null = null;
|
|
2686
|
+
for (let grew = true; grew; ) {
|
|
2687
|
+
grew = false;
|
|
2688
|
+
for (const [id, a] of agents) {
|
|
2689
|
+
if (a.own || a.parent === null || !inside.has(a.parent) || inside.has(id)) continue;
|
|
2690
|
+
inside.add(id);
|
|
2691
|
+
grew = true;
|
|
2692
|
+
const minutes = Math.max(0, Math.floor((Date.now() - a.written) / 60_000));
|
|
2693
|
+
if (a.written > 0 && minutes < QUIET_MINUTES && (!fresh || minutes < fresh.minutes)) fresh = { id, minutes };
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
return fresh && `subagent agent-${fresh.id} works in it without a worktree of its own — its transcript written ${fresh.minutes} min ago, quiet after ${QUIET_MINUTES}`;
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
/**
|
|
2700
|
+
* Why a worktree is alive: a lock held by a live process, a subagent of its agent at work in it (`guestWhy`), or HEAD
|
|
2701
|
+
* moved less than `QUIET_MINUTES` ago; quiet — null.
|
|
2702
|
+
*/
|
|
2703
|
+
function aliveWhy(io: Io, w: Worktree, agents: Map<string, Subagent>): string | null {
|
|
2704
|
+
if (w.locked !== null && lockHolds(w.locked)) return `locked by a live process — ${w.locked || "no reason"}`;
|
|
2705
|
+
const id = agentIdOf(w.path);
|
|
2706
|
+
const guest = id === null ? null : guestWhy(agents, id);
|
|
2707
|
+
if (guest) return guest;
|
|
2708
|
+
// the worktree's own HEAD log: the time of its latest entry (`HEAD@{<unix time>}`)
|
|
2709
|
+
const r = (io.run ?? realRun)("git", ["reflog", "-1", "--date=unix", "--format=%gd"], w.path);
|
|
2710
|
+
const at = Number(/@\{(\d+)\}/.exec(r.stdout)?.[1]);
|
|
2711
|
+
const minutes = Math.floor((Date.now() / 1000 - at) / 60);
|
|
2712
|
+
return r.status === 0 && at > 0 && minutes < QUIET_MINUTES ? `HEAD moved ${Math.max(0, minutes)} min ago (a commit or a switch), quiet after ${QUIET_MINUTES}` : null;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** The sessions' hand-out files on the machine — `dispatchers/<session id>.tasks` in the configuration directory (`handedOutFile`). */
|
|
2716
|
+
function handOutFiles(io: Io): { session: string; file: string }[] {
|
|
2717
|
+
const dir = path.join(configDir(io.env), "dispatchers");
|
|
2718
|
+
try {
|
|
2719
|
+
return readdirSync(dir)
|
|
2720
|
+
.filter((f) => f.endsWith(".tasks"))
|
|
2721
|
+
.map((f) => ({ session: f.slice(0, -".tasks".length), file: path.join(dir, f) }));
|
|
2722
|
+
} catch {
|
|
2723
|
+
return [];
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
/**
|
|
2728
|
+
* The task's previous executor and its subagents still alive (#565): the `!` lines with each live worktree. The
|
|
2729
|
+
* executor of #545 answered «failed» while two of its background subagents were still working; their answers woke it,
|
|
2730
|
+
* and it merged the task — while a second executor, launched on the owner's restart, worked on the same branch. The
|
|
2731
|
+
* previous executor's worktrees — its hand-outs of the task in any dispatcher session (`dispatchers/*.tasks`) and a
|
|
2732
|
+
* worktree on the task branch, the caller's own aside; its subagents' — by Claude Code's metadata (`subagentsOf`), at any
|
|
2733
|
+
* depth. Alive — `aliveWhy`: a subagent without a worktree of its own keeps alive the worktree it works in. The directory
|
|
2734
|
+
* isn't a checkout of the repository — none: the script doesn't see the worktrees.
|
|
2735
|
+
*/
|
|
2736
|
+
function livePredecessors(io: Io, slug: string, number: number, own: string): string[] {
|
|
2737
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) return [];
|
|
2738
|
+
const listed = (io.run ?? realRun)("git", ["worktree", "list", "--porcelain"], io.cwd);
|
|
2739
|
+
if (listed.status !== 0) return [];
|
|
2740
|
+
const mine = realPath(own);
|
|
2741
|
+
const all = worktreeList(listed.stdout).filter((w) => !w.prunable && realPath(w.path) !== mine);
|
|
2742
|
+
const handOuts = handOutFiles(io).flatMap(({ session, file }) =>
|
|
2743
|
+
handOutsIn(file, slug)
|
|
2744
|
+
.filter((h) => h.number === number && h.worktree !== null)
|
|
2745
|
+
.map((h) => ({ session, worktree: realPath(h.worktree!) })),
|
|
2746
|
+
);
|
|
2747
|
+
const executors = all.filter((w) => handOuts.some((h) => h.worktree === realPath(w.path)) || (w.branch !== null && isTaskBranch(w.branch, number)));
|
|
2748
|
+
if (!executors.length) return [];
|
|
2749
|
+
const agents = subagentsOf(io, [...handOuts.map((h) => h.session), io.env.CLAUDE_CODE_SESSION_ID ?? ""].filter(Boolean));
|
|
2750
|
+
// the executors' agents and everything they launched
|
|
2751
|
+
const family = new Set(executors.map((w) => agentIdOf(w.path)).filter((id): id is string => id !== null));
|
|
2752
|
+
for (let grew = true; grew; ) {
|
|
2753
|
+
grew = false;
|
|
2754
|
+
for (const [id, { parent }] of agents) {
|
|
2755
|
+
if (parent !== null && family.has(parent) && !family.has(id)) {
|
|
2756
|
+
family.add(id);
|
|
2757
|
+
grew = true;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
const lines: string[] = [];
|
|
2762
|
+
for (const w of all) {
|
|
2763
|
+
const parent = agents.get(agentIdOf(w.path) ?? "")?.parent;
|
|
2764
|
+
const role = executors.includes(w) ? "the previous executor" : parent && family.has(parent) ? `a subagent of agent-${parent}` : null;
|
|
2765
|
+
const why = role && aliveWhy(io, w, agents);
|
|
2766
|
+
if (why) lines.push(`! worktree ${w.path} (${role}): ${why}`);
|
|
2767
|
+
}
|
|
2768
|
+
return lines;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/**
|
|
2772
|
+
* The task's branches `[<area>/]<type>/<N>-<slug>`: local ones of the current directory's checkout and those on its origin
|
|
2773
|
+
* (`git ls-remote`: the executor's fresh worktree hasn't fetched a branch its predecessor pushed). The directory isn't a
|
|
2774
|
+
* checkout of the repository — none: the script doesn't see the branches and claims nothing.
|
|
2775
|
+
*/
|
|
2776
|
+
function taskBranchesHere(io: Io, slug: string, number: number): string[] {
|
|
2777
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) return [];
|
|
2778
|
+
const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd).stdout.split("\n");
|
|
2779
|
+
const refs = [...git("for-each-ref", "--format=%(refname)", "refs/heads/"), ...git("ls-remote", "--heads", "origin").map((l) => l.split("\t")[1] ?? "")];
|
|
2780
|
+
return [...new Set(refs.map((r) => r.trim().replace(/^refs\/heads\//, "")).filter((b) => isTaskBranch(b, number)))].sort();
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* The executor's whole assignment (`task queue --assignment N`): the task by the canon, what a subagent does otherwise, the
|
|
2785
|
+
* answer form. The restart line — only when it is true: the task is In progress and has a branch `<type>/N-<slug>`
|
|
2786
|
+
* (#511); printed always, it misled the executor of a Backlog task, who argued with it in the answer. The task is
|
|
2787
|
+
* recorded as handed out with the executor's worktree (`handedOutFile`): the queue no longer gives it out as ready and
|
|
2788
|
+
* keeps the worktree while the task is open, and `task status` in the executor doesn't pin it. In any identified
|
|
2789
|
+
* session, not only a dispatcher's (#546): a session with its own task, or none yet, launches an executor on the
|
|
2790
|
+
* owner's "yes" — unrecorded, its "In progress" would be refused by the session's pin or would pin the session to it.
|
|
2791
|
+
* A session pinned to this very task records nothing: the task is its own. A restart while the previous executor or its
|
|
2792
|
+
* subagent is alive (`livePredecessors`, #565) — no assignment and no hand-out: the `!` lines and the answer that
|
|
2793
|
+
* makes the dispatcher wait, code 1. `cloud` — the brief of a cloud executor (#609), printed by the dispatcher: whole, `task
|
|
2794
|
+
* queue` refuses in the cloud; `#` only at its task (`est` links a cloud session by the only `#N` of its first
|
|
2795
|
+
* prompt — docs/cloud-sessions.md, "The brief"); no restart line — the cloud assigns its own branch. A cloud session
|
|
2796
|
+
* can't message a local one, so `Next:` gives the dispatcher the wait for the task's closing and the local close.
|
|
2797
|
+
*/
|
|
2798
|
+
export function cmdTaskAssignment(io: Io, slug: string, number: number, o: { cloud: boolean } = { cloud: false }): number {
|
|
2799
|
+
const issue = loadIssue(io, slug, number);
|
|
2800
|
+
const file = handedOutFile(io);
|
|
2801
|
+
const own = pinnedTask(io)?.task;
|
|
2802
|
+
// the executor's worktree — the checkout it calls the assignment in: the queue keeps it while the task is open (#533)
|
|
2803
|
+
const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd);
|
|
2804
|
+
const worktree = top.status === 0 ? top.stdout.trim() : "";
|
|
2805
|
+
const alive = worktree ? livePredecessors(io, slug, number, worktree) : [];
|
|
2806
|
+
if (alive.length) {
|
|
2807
|
+
for (const line of alive) io.out(line);
|
|
2808
|
+
io.out(locale.fill(N.queue.alive, { task: N.subagentPrompt.task, number, repo: slug }));
|
|
2809
|
+
return 1;
|
|
2810
|
+
}
|
|
2811
|
+
if (file && !(own?.slug === slug && own.number === number)) {
|
|
2812
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2813
|
+
// the configuration directory may be a personal git checkout (ELVES_PRIVATE) — hand-outs aren't committed into it
|
|
2814
|
+
writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n");
|
|
2815
|
+
const where = o.cloud ? CLOUD_HANDOUT : worktree;
|
|
2816
|
+
appendFileSync(file, `${slug}#${number}${where ? ` ${where}` : ""}\n`);
|
|
2817
|
+
}
|
|
2818
|
+
if (o.cloud) return cloudBrief(io, slug, issue), 0;
|
|
2819
|
+
const restart = itemIn(taskContext(io, slug), issue)?.status === N.status.inProgress ? taskBranchesHere(io, slug, number) : [];
|
|
2820
|
+
const vars = {
|
|
2821
|
+
task: N.subagentPrompt.task,
|
|
2822
|
+
number,
|
|
2823
|
+
title: issue.title,
|
|
2824
|
+
repo: slug,
|
|
2825
|
+
epic: issue.parent ? locale.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
|
|
2826
|
+
script: `bun --no-env-file ${import.meta.path}`,
|
|
2827
|
+
waitCi: `bash ${waitCi()}`,
|
|
2828
|
+
inProgress: N.status.inProgress,
|
|
2829
|
+
done: N.lastLine.done,
|
|
2830
|
+
questions: N.sections.questions,
|
|
2831
|
+
branches: restart.map((b) => `\`${b}\``).join(", "),
|
|
2832
|
+
};
|
|
2833
|
+
const lines = [N.queue.lead, ...(restart.length ? [N.queue.resume] : []), N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.found, N.queue.question, N.queue.answer];
|
|
2834
|
+
for (const line of lines) io.out(locale.fill(line, vars));
|
|
2835
|
+
return 0;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function actualizeChecks(): string[] {
|
|
2839
|
+
const file = path.resolve(import.meta.dir, "../reference.md");
|
|
2840
|
+
const m = existsSync(file) ? /Five checks:\n\n([\s\S]*?)\n\nWhat was fixed/.exec(readFileSync(file, "utf8")) : null;
|
|
2841
|
+
const items = m ? m[1]!.split(/\n(?=\d\. )/) : [];
|
|
2842
|
+
if (items.length !== 5) throw new GhError(`the five epic-refresh checks not found in ${file}: expected a list of five between «Five checks:» and «What was fixed»`);
|
|
2843
|
+
return items;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
/** The checks by meaning — the subagent's: 1 (whether done), 3, 5; the mechanics of 1, 2 and 4 is the script's. */
|
|
2847
|
+
const MEANING_CHECKS = [0, 2, 4];
|
|
2848
|
+
|
|
2849
|
+
/**
|
|
2850
|
+
* ✅ that answers a question — at the start of its answer: after the item's number (`task answer`), at the start of an
|
|
2851
|
+
* item's line, after the end of the question or a sentence (`?`, `.`, `!`, a dash; closing quotes and markup included),
|
|
2852
|
+
* as answers by hand put it. ✅ inside the text — after a word, in parentheses, quotes or code (a recommendation naming
|
|
2853
|
+
* «✅ auto») — doesn't answer (#603).
|
|
2854
|
+
*/
|
|
2855
|
+
const ANSWER_MARK_RE = /(?:^\s*(?:\d+\.\s+)?|[?.!—–][»"'*_)`]*\s+)[*_(]*✅/;
|
|
2856
|
+
/** A thematic break ends the questions section: the cloud proxy's signature after `---` isn't the last question's (#603). */
|
|
2857
|
+
const THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
2858
|
+
|
|
2859
|
+
/**
|
|
2860
|
+
* The items of the issue's questions section ("## Questions" in any language of the dictionary, up to the next section
|
|
2861
|
+
* or a thematic break): the number, the first line, all the item's non-empty lines and whether it is answered — ✅ at
|
|
2862
|
+
* the start of its answer (`ANSWER_MARK_RE`); `first` and `last` — the indexes of the item's first and last non-empty
|
|
2863
|
+
* line in the description's lines. The dashboard lists the open ones and the answers marked auto, `task actualize`
|
|
2864
|
+
* removes the questions label from a task without open ones, `task show` names them, `task answer` puts the answer
|
|
2865
|
+
* into the item.
|
|
2866
|
+
*/
|
|
2867
|
+
export function questionItems(body: string): { k: number; text: string; lines: string[]; answered: boolean; first: number; last: number }[] {
|
|
2868
|
+
const section = new RegExp(`^(?:${locale.every((n) => n.sections.questions).map(locale.escapeRe).join("|")})\\s*$`);
|
|
2869
|
+
const lines = body.split(/\r?\n/);
|
|
2870
|
+
const from = lines.findIndex((l) => section.test(l));
|
|
2871
|
+
if (from < 0) return [];
|
|
2872
|
+
const items: { first: number; last: number }[] = [];
|
|
2873
|
+
for (let i = from + 1; i < lines.length && !/^##\s/.test(lines[i]!) && !THEMATIC_BREAK_RE.test(lines[i]!); i++) {
|
|
2874
|
+
if (/^\d+\.\s/.test(lines[i]!)) items.push({ first: i, last: i });
|
|
2875
|
+
else if (items.length && lines[i]!.trim()) items.at(-1)!.last = i;
|
|
2876
|
+
}
|
|
2877
|
+
return items.map(({ first, last }) => {
|
|
2878
|
+
const item = lines.slice(first, last + 1).filter((l) => l.trim());
|
|
2879
|
+
return { k: parseInt(lines[first]!, 10), text: lines[first]!.trim(), lines: item, answered: item.some((l) => ANSWER_MARK_RE.test(l)), first, last };
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
interface MilestoneRef {
|
|
2884
|
+
number: number;
|
|
2885
|
+
title: string;
|
|
2886
|
+
state: string;
|
|
2887
|
+
/** Open tasks in it. */
|
|
2888
|
+
open: number;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
/** An issue as the epic refresh reads it: the text, labels with ids, blocked by, comments, sub-issues with milestones. */
|
|
2892
|
+
interface TaskText {
|
|
2893
|
+
id: string;
|
|
2894
|
+
number: number;
|
|
2895
|
+
title: string;
|
|
2896
|
+
state: string;
|
|
2897
|
+
body: string;
|
|
2898
|
+
labels: { id: string; name: string }[];
|
|
2899
|
+
milestone: MilestoneRef | null;
|
|
2900
|
+
blockedBy: (Blocker & { id: string })[];
|
|
2901
|
+
comments: { body: string; url: string }[];
|
|
2902
|
+
subIssues: { number: number; title: string; state: string; milestone: MilestoneRef | null }[];
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
function loadTaskText(io: Io, slug: string, number: number): TaskText {
|
|
2906
|
+
const [owner, name] = slug.split("/");
|
|
2907
|
+
const i = graphql(io, Q.TaskText, { owner, name, number })?.repository?.issue;
|
|
2908
|
+
if (!i) throw new GhError(`no issue #${number} in ${slug}`);
|
|
2909
|
+
const milestone = (m: Any): MilestoneRef | null => (m ? { number: m.number, title: m.title, state: m.state, open: m.issues?.totalCount ?? 0 } : null);
|
|
2910
|
+
return {
|
|
2911
|
+
id: i.id,
|
|
2912
|
+
number: i.number,
|
|
2913
|
+
title: i.title,
|
|
2914
|
+
state: i.state,
|
|
2915
|
+
// the GitHub UI saves a description with CRLF
|
|
2916
|
+
body: (i.body ?? "").replace(/\r\n/g, "\n").trim(),
|
|
2917
|
+
labels: i.labels?.nodes ?? [],
|
|
2918
|
+
milestone: milestone(i.milestone),
|
|
2919
|
+
blockedBy: (i.blockedBy?.nodes ?? []).map((b: Any) => ({ id: b.id, number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
|
|
2920
|
+
comments: i.comments?.nodes ?? [],
|
|
2921
|
+
subIssues: (i.subIssues?.nodes ?? []).map((x: Any) => ({ number: x.number, title: x.title, state: x.state, milestone: milestone(x.milestone) })),
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
/**
|
|
2926
|
+
* The refresh's mechanics — what needs no judgment, so the subagent doesn't collect it (#435): blocked by on done tasks
|
|
2927
|
+
* (closed ones and this task, whose PR is merged) is removed — check 4; the questions label without open questions in
|
|
2928
|
+
* the section — check 2; a milestone of the epic's tasks without open tasks is closed — check 1, except the task's and
|
|
2929
|
+
* the epic's own: `task close` closes them, the task may still be open. The fixes — one line in a comment on the epic.
|
|
2930
|
+
*/
|
|
2931
|
+
function refreshMechanics(io: Io, slug: string, r: { number: number; merged: boolean; epic: TaskText; open: TaskText[] }): void {
|
|
2932
|
+
const fixes: string[] = [];
|
|
2933
|
+
const tasks = [r.epic, ...r.open];
|
|
2934
|
+
for (const t of tasks) {
|
|
2935
|
+
// an external blocker is done only when closed: the merged task is of this repository
|
|
2936
|
+
const done = t.blockedBy.filter((b) => b.state === "CLOSED" || (r.merged && blockerRef(slug, b) === `#${r.number}`));
|
|
2937
|
+
if (!done.length) continue;
|
|
2938
|
+
for (const b of done) mutate(io, { op: "RemoveBlockedBy", input: { issueId: t.id, blockingIssueId: b.id } });
|
|
2939
|
+
io.out(`+ #${t.number}: blocked by ${done.map((b) => `${blockerRef(slug, b)} (${b.state === "CLOSED" ? "closed" : "merged"})`).join(", ")} removed`);
|
|
2940
|
+
fixes.push(locale.fill(N.actualize.unblocked, { task: t.number, blockers: done.map((b) => blockerRef(slug, b)).join(", ") }));
|
|
2941
|
+
}
|
|
2942
|
+
const questionLabels = locale.every((n) => n.labels.questions);
|
|
2943
|
+
for (const t of tasks) {
|
|
2944
|
+
const labels = t.labels.filter((l) => questionLabels.includes(l.name));
|
|
2945
|
+
if (!labels.length || questionItems(t.body).some((x) => !x.answered)) continue;
|
|
2946
|
+
mutate(io, { op: "RemoveLabels", input: { labelableId: t.id, labelIds: labels.map((l) => l.id) } });
|
|
2947
|
+
for (const l of labels) {
|
|
2948
|
+
io.out(`+ #${t.number}: label ${q(l.name)} removed — no open questions`);
|
|
2949
|
+
fixes.push(locale.fill(N.actualize.unlabeled, { task: t.number, label: l.name }));
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
const own = r.epic.subIssues.find((x) => x.number === r.number)?.milestone?.number;
|
|
2953
|
+
const seen = new Set([own, r.epic.milestone?.number]);
|
|
2954
|
+
for (const { milestone: m } of r.epic.subIssues) {
|
|
2955
|
+
if (!m || seen.has(m.number)) continue;
|
|
2956
|
+
seen.add(m.number);
|
|
2957
|
+
if (m.state !== "OPEN" || m.open > 0) continue;
|
|
2958
|
+
io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
|
|
2959
|
+
io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
|
|
2960
|
+
fixes.push(locale.fill(N.actualize.milestone, { title: m.title }));
|
|
2961
|
+
}
|
|
2962
|
+
if (!fixes.length) return;
|
|
2963
|
+
mutate(io, { op: "AddComment", input: { subjectId: r.epic.id, body: locale.fill(N.actualize.mechanics, { number: r.number, fixes: fixes.join("; ") }) } });
|
|
2964
|
+
io.out(`+ epic #${r.epic.number}: a comment with the script's fixes`);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/** A quoted line of an issue or a comment in the assignment: one line, at most 200 characters. */
|
|
2968
|
+
const clip = (line: string) => (line.length > 200 ? `${line.slice(0, 199)}…` : line);
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* Copies of links in text (check 4): in the epic — a list item that starts with the number of its sub-issue; in the
|
|
2972
|
+
* epic and its open tasks — "⛔" and a dependency phrase before `#N` (`linkWords` of any language). Named, not edited:
|
|
2973
|
+
* a line may carry meaning besides the copy.
|
|
2974
|
+
*/
|
|
2975
|
+
function linkCopies(epic: TaskText, open: TaskText[]): { number: number; line: string }[] {
|
|
2976
|
+
const subs = new Set(epic.subIssues.map((x) => x.number));
|
|
2977
|
+
const words = locale.every((n) => n.actualize.linkWords).flatMap((w) => w.split("|")).map((w) => w.trim()).filter(Boolean).map(locale.escapeRe);
|
|
2978
|
+
const phrase = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${words.join("|")})\\s*:?\\s*#\\d+`, "iu");
|
|
2979
|
+
const item = /^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?#(\d+)(?!\d)/;
|
|
2980
|
+
const out: { number: number; line: string }[] = [];
|
|
2981
|
+
for (const t of [epic, ...open]) {
|
|
2982
|
+
for (const raw of t.body.split("\n")) {
|
|
2983
|
+
const listed = t === epic ? item.exec(raw) : null;
|
|
2984
|
+
if ((listed && subs.has(Number(listed[1]))) || raw.includes("⛔") || phrase.test(raw)) out.push({ number: t.number, line: clip(raw.trim()) });
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
return out;
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
/**
|
|
2991
|
+
* Answers to open questions in comments (check 2): an item `k. ✅ …` of a comment for the open question k, the latest
|
|
2992
|
+
* wins. Named, not moved: a comment's numbering may be its own (answers to a concept, not to the section).
|
|
2993
|
+
*/
|
|
2994
|
+
function commentAnswers(tasks: TaskText[]): { number: number; k: number; text: string; url: string }[] {
|
|
2995
|
+
const out: { number: number; k: number; text: string; url: string }[] = [];
|
|
2996
|
+
for (const t of tasks) {
|
|
2997
|
+
const open = new Set(questionItems(t.body).filter((x) => !x.answered).map((x) => x.k));
|
|
2998
|
+
if (!open.size) continue;
|
|
2999
|
+
const found = new Map<number, { text: string; url: string }>();
|
|
3000
|
+
for (const c of t.comments) for (const m of c.body.matchAll(/^\s*(\d+)\.\s*✅\s*(.+)$/gm)) if (open.has(Number(m[1]))) found.set(Number(m[1]), { text: clip(m[2]!.trim()), url: c.url });
|
|
3001
|
+
for (const [k, a] of [...found].sort(([x], [y]) => x - y)) out.push({ number: t.number, k, ...a });
|
|
3002
|
+
}
|
|
3003
|
+
return out;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/** A changed PR file in the assignment: the path, the kind of change unless it's an edit, the line counts. */
|
|
3007
|
+
const fileLine = (f: PrFile) => `- ${f.path} (${f.changeType === "MODIFIED" ? "" : `${f.changeType.toLowerCase()}, `}+${f.additions} −${f.deletions})`;
|
|
3008
|
+
/** An issue's description in the assignment — in a fence: its headings don't mix with the assignment's. */
|
|
3009
|
+
const textBlock = (body: string) => (body ? `~~~~markdown\n${body}\n~~~~` : N.actualize.noBody);
|
|
3010
|
+
|
|
3011
|
+
/**
|
|
3012
|
+
* The epic refresh — right after the merge, in parallel with the deploy: the refresh doesn't depend on the deploy. The
|
|
3013
|
+
* command does the mechanics itself (`refreshMechanics`) and prints the subagent's prompt — two lines: «Task #N (epic
|
|
3014
|
+
* #M)» (`subagentPrompt.task` and the number: est ties the subagent's work to the task by it) and the command of its
|
|
3015
|
+
* whole assignment. The assignment (`--assignment`, read-only) is the subagent's first call: the texts of the epic and
|
|
3016
|
+
* its open tasks and the PR files run to tens of KB, and the session at peak context would copy them into the subagent
|
|
3017
|
+
* call as output tokens. In it — the task and its PR with its files, the epic and its open tasks with their texts, the
|
|
3018
|
+
* copies of links and the answers in comments the script found, the checks by meaning quoted from reference.md as is,
|
|
3019
|
+
* what may be changed and the answer form; in the project language (the `actualize` keys). No epic, or no open tasks in
|
|
3020
|
+
* it besides this one — the checks by meaning have nothing to go through: a "not needed" line, no subagent.
|
|
3021
|
+
*/
|
|
3022
|
+
export function cmdTaskActualize(io: Io, slug: string, number: number, o: { assignment: boolean } = { assignment: false }): number {
|
|
3023
|
+
const issue = loadIssue(io, slug, number);
|
|
3024
|
+
const later = `Next: task close ${number} — after the deploy, where the merge deploys.`;
|
|
3025
|
+
if (!issue.parent) return io.out(`○ refresh not needed: #${number} has no epic. ${later}`), 0;
|
|
3026
|
+
const linked = issue.prs.filter((p) => p.merged);
|
|
3027
|
+
const prs = linked.length ? linked : unlinkedPrs(io, slug, number).prs.filter((p) => p.merged);
|
|
3028
|
+
const epic = loadTaskText(io, slug, issue.parent.number);
|
|
3029
|
+
const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).map((x) => loadTaskText(io, slug, x.number));
|
|
3030
|
+
if (!o.assignment) refreshMechanics(io, slug, { number, merged: prs.length > 0, epic, open });
|
|
3031
|
+
if (!open.length) return io.out(`○ refresh not needed: epic #${epic.number} has no open tasks besides #${number}. ${later}`), 0;
|
|
3032
|
+
const vars = { task: N.subagentPrompt.task, epicWord: N.subagentPrompt.epic, number, epic: epic.number, repo: slug };
|
|
3033
|
+
const lead = locale.fill(N.actualize.lead, vars);
|
|
3034
|
+
if (!o.assignment) {
|
|
3035
|
+
io.out("Next: a subagent in the background — the two lines below are its whole prompt, pass them as is.");
|
|
3036
|
+
io.out(lead);
|
|
3037
|
+
io.out(locale.fill(N.actualize.fetch, { command: `bun --no-env-file ${import.meta.path} task actualize ${number} --assignment --repo ${slug}` }));
|
|
3038
|
+
return 0;
|
|
3039
|
+
}
|
|
3040
|
+
const checks = actualizeChecks();
|
|
3041
|
+
const shipped = prs.length
|
|
3042
|
+
? locale.fill(N.actualize.prs, { list: prs.map((p) => `#${p.number} (\`gh pr view ${p.number}\`, \`gh pr diff ${p.number}\`)`).join(", ") })
|
|
3043
|
+
: locale.fill(N.actualize.noPr, { command: `\`git log origin/main --grep '#${number}'\`` });
|
|
3044
|
+
const lines = [lead, locale.fill(N.actualize.shipped, { ...vars, title: issue.title, shipped })];
|
|
3045
|
+
for (const p of prs) {
|
|
3046
|
+
const files = loadPrChange(io, slug, p.number).files;
|
|
3047
|
+
if (files.length) lines.push(locale.fill(N.actualize.files, { pr: p.number }), ...files.map(fileLine));
|
|
3048
|
+
}
|
|
3049
|
+
lines.push("", locale.fill(N.actualize.epic, { ...vars, title: epic.title }), textBlock(epic.body), "", N.actualize.openTasks);
|
|
3050
|
+
for (const t of open) lines.push("", locale.fill(N.actualize.openTask, { number: t.number, title: t.title }), textBlock(t.body));
|
|
3051
|
+
const copies = linkCopies(epic, open);
|
|
3052
|
+
if (copies.length) lines.push("", N.actualize.copies, ...copies.map((c) => locale.fill(N.actualize.copy, c)));
|
|
3053
|
+
const answers = commentAnswers([epic, ...open]);
|
|
3054
|
+
if (answers.length) lines.push("", locale.fill(N.actualize.answers, { questions: N.sections.questions }), ...answers.map((a) => locale.fill(N.actualize.commentAnswer, a)));
|
|
3055
|
+
lines.push(
|
|
3056
|
+
"",
|
|
3057
|
+
N.actualize.checks,
|
|
3058
|
+
"",
|
|
3059
|
+
MEANING_CHECKS.map((i) => checks[i]!).join("\n"),
|
|
3060
|
+
"",
|
|
3061
|
+
locale.fill(N.actualize.scope, { ...vars, questions: N.sections.questions, script: `bun --no-env-file ${import.meta.path}` }),
|
|
3062
|
+
locale.fill(N.actualize.comment, { ...vars, report: locale.fill(N.actualize.report, { number, text: "…" }) }),
|
|
3063
|
+
N.actualize.answer,
|
|
3064
|
+
);
|
|
3065
|
+
for (const line of lines) io.out(line);
|
|
3066
|
+
return 0;
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3069
|
+
/**
|
|
3070
|
+
* The epic refresh's report (#571): the refresh subagent ends with a comment on the epic that starts with `report` —
|
|
3071
|
+
* «Refresh after #N, result: …» in any language of the dictionary, case and markup aside; the script's own comment
|
|
3072
|
+
* (`mechanics`) comes before the subagent and isn't it.
|
|
3073
|
+
*/
|
|
3074
|
+
function refreshReported(epic: TaskText, number: number): boolean {
|
|
3075
|
+
const heads = locale.every((n) => n.actualize.report).map((t) => locale.fill(t, { number, text: "" }).trim().toLowerCase());
|
|
3076
|
+
return epic.comments.some((c) => heads.some((h) => c.body.toLowerCase().includes(h)));
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
/**
|
|
3080
|
+
* The closing ritual after a PR merge — the actual, Status "Done", the epic, the milestone, taking down the task's
|
|
3081
|
+
* stacks, cleaning up the merged branch — in one call: the session did it one step per turn at its largest context.
|
|
3082
|
+
* The epic refresh (the canon's five checks) isn't here: it runs before close, in parallel with the deploy, by a
|
|
3083
|
+
* subagent with the `task actualize` assignment. Where the epic has open tasks besides this one, close refuses before
|
|
3084
|
+
* any change until the subagent's report is on the epic (`refreshReported`, #571): an executor closed its task and
|
|
3085
|
+
* answered while its refresh subagent still worked in its worktree — the dispatcher's next queue removed the worktree,
|
|
3086
|
+
* and the subagent was left without a directory. Close names the three lines, one of which ends the session's answer,
|
|
3087
|
+
* and archiving the session in auto mode — except a session with questions for the owner.
|
|
3088
|
+
*/
|
|
3089
|
+
export function cmdTaskClose(io: Io, slug: string, number: number, o: { git: boolean }): number {
|
|
3090
|
+
const ctx = taskContext(io, slug);
|
|
3091
|
+
const issue = loadIssue(io, slug, number);
|
|
3092
|
+
const linked = issue.prs.filter((p) => p.merged);
|
|
3093
|
+
const closedNoPr = !issue.prs.length && issue.state === "CLOSED" && issue.stateReason === "COMPLETED";
|
|
3094
|
+
// GitHub may not link a merged PR to the task for hours: the task is open, no merged PR among the linked ones — we
|
|
3095
|
+
// look for the task's PR ourselves
|
|
3096
|
+
const found = !linked.length && issue.state === "OPEN" ? unlinkedPrs(io, slug, number) : null;
|
|
3097
|
+
const fresh = (found?.prs ?? []).filter((p) => !issue.prs.some((x) => x.number === p.number));
|
|
3098
|
+
const unlinked = fresh.filter((p) => p.merged);
|
|
3099
|
+
const merged = linked.length ? linked : unlinked;
|
|
3100
|
+
if (!merged.length && !closedNoPr) {
|
|
3101
|
+
const prs = [...issue.prs, ...fresh].map((p) => `#${p.number} ${p.state === "OPEN" ? "open" : "closed without a merge"}`).join(", ");
|
|
3102
|
+
const what = prs ? `PR ${prs}` : `the task is ${issue.state === "OPEN" ? "open" : "closed without being done"}, no merged PR`;
|
|
3103
|
+
const checked = found ? `; checked: the PR–task link, the branch <type>/${number}-<slug> and «Closes #${number}» in the body of the last ${RECENT_PRS} PRs into ${found.base}` : "";
|
|
3104
|
+
throw new GhError(`#${number} is too early to close: ${what}${checked} — close after the PR is merged or the task is closed`);
|
|
3105
|
+
}
|
|
3106
|
+
if (issue.parent) {
|
|
3107
|
+
const epic = loadTaskText(io, slug, issue.parent.number);
|
|
3108
|
+
const others = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
|
|
3109
|
+
if (others && !refreshReported(epic, number)) {
|
|
3110
|
+
const head = locale.fill(N.actualize.report, { number, text: "…" });
|
|
3111
|
+
throw new GhError(
|
|
3112
|
+
`#${number} is too early to close: the epic refresh hasn't reported — epic #${epic.number} has open tasks besides #${number} (${others}), and no comment «${head}» on it. ` +
|
|
3113
|
+
`The refresh subagent is at work — wait for its answer, then task close ${number} again; it wasn't launched — task actualize ${number} and the subagent with its prompt; ` +
|
|
3114
|
+
`it answered without the comment — post its result: gh issue comment ${epic.number} --repo ${slug} --body "${head}"`,
|
|
3115
|
+
);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
const wasOpen = issue.state === "OPEN";
|
|
3119
|
+
if (wasOpen) {
|
|
3120
|
+
// without the link the task doesn't see its PR — a comment names it
|
|
3121
|
+
if (unlinked.length) mutate(io, { op: "AddComment", input: { subjectId: issue.id, body: locale.fill(N.comments.closedByPr, { prs: unlinked.map((p) => `#${p.number}`).join(", ") }) } });
|
|
3122
|
+
mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: "COMPLETED" } });
|
|
3123
|
+
io.out(
|
|
3124
|
+
unlinked.length
|
|
3125
|
+
? `+ #${number} closed by PR ${unlinked.map((p) => `#${p.number} (${p.why})`).join(", ")}: GitHub didn't link the PR to the task`
|
|
3126
|
+
: `+ #${number} closed: PR ${merged.map((p) => `#${p.number}`).join(", ")} merged`,
|
|
3127
|
+
);
|
|
3128
|
+
}
|
|
3129
|
+
freshClone(io, slug);
|
|
3130
|
+
runFact(io, slug, number);
|
|
3131
|
+
const item = ensureItem(io, ctx, issue);
|
|
3132
|
+
setStatus(io, ctx, item.id, N.status.done);
|
|
3133
|
+
io.out(`+ #${number}: ${STATUS} ${q(N.status.done)}${item.added ? " (added to the project)" : ""}`);
|
|
3134
|
+
closeMilestone(io, slug, issue, wasOpen);
|
|
3135
|
+
if (issue.parent) {
|
|
3136
|
+
const epic = loadIssue(io, slug, issue.parent.number);
|
|
3137
|
+
const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
|
|
3138
|
+
if (epic.state === "OPEN" && epic.subIssues.length && !open) {
|
|
3139
|
+
mutate(io, { op: "CloseIssue", input: { issueId: epic.id, stateReason: "COMPLETED" } });
|
|
3140
|
+
setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.done);
|
|
3141
|
+
io.out(`+ epic #${epic.number} closed and in ${q(N.status.done)}: all subtasks are closed`);
|
|
3142
|
+
closeMilestone(io, slug, epic, true);
|
|
3143
|
+
} else if (epic.state === "OPEN") io.out(`○ epic #${epic.number}: open subtasks ${open}`);
|
|
3144
|
+
}
|
|
3145
|
+
// stacks of task-env and branches are named by the checkout's origin: from someone else's checkout — another project's
|
|
3146
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) io.out(`○ ${o.git ? "branches and task stacks" : "task stacks"} left alone: the current directory is not a checkout of ${slug}`);
|
|
3147
|
+
else {
|
|
3148
|
+
takeDownStacks(io, number);
|
|
3149
|
+
if (o.git) {
|
|
3150
|
+
// a follow-up fix to a merged PR is the same task, and GitHub doesn't link it (Refs, not Closes): the branches are
|
|
3151
|
+
// those of every merged PR of the task — the linked ones and the ones found by the branch or «Closes #N»
|
|
3152
|
+
const more = (found ?? unlinkedPrs(io, slug, number)).prs.filter((p) => p.merged && !merged.some((x) => x.number === p.number));
|
|
3153
|
+
cleanupBranches(io, number, [...merged, ...more]);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
// three lines — in the project language: dashboard parses them
|
|
3157
|
+
io.out(
|
|
3158
|
+
`Next: the last line of the answer is one of three: ${q(N.lastLine.done)}; ` +
|
|
3159
|
+
`the owner has something to decide — ${q(`${N.lastLine.questions} …`)} with the questions themselves, without ${q(N.lastLine.done)}; ` +
|
|
3160
|
+
`something remains — ${q(`${N.lastLine.remaining} …`)}. ` +
|
|
3161
|
+
// the command doesn't read the mode (auto) or the session's background tasks: the session knows them, the step is spelled out in words
|
|
3162
|
+
`In auto mode after ${q(N.lastLine.done)} — archive the session where the agent has the tool, ` +
|
|
3163
|
+
`first stopping its own background tasks still running (a wait, a watcher over its output, a subagent): a live one blocks archiving; ` +
|
|
3164
|
+
`after the other two lines — don't. ` +
|
|
3165
|
+
`Information without a decision (an observation, a deadline, a warning) isn't a question: the line and archiving are as with ${q(N.lastLine.done)}`,
|
|
3166
|
+
);
|
|
3167
|
+
return 0;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
/**
|
|
3171
|
+
* What a resumed session restarts: a background wait (ci-wait) dies with the app's process — at a restart four sessions
|
|
3172
|
+
* lost their waits in one minute, and the owner wrote "continue" 17 times (#433). The SessionStart hook runs this
|
|
3173
|
+
* command: the task pinned to the session (its repository is in the pin, not in the checkout) and its PRs name the
|
|
3174
|
+
* wait — an open PR's checks; a merged PR of an open task — the deploy and `task close`. The PR GitHub didn't link — by
|
|
3175
|
+
* the task branch, as in `task close`. No pin — no line and no request: the session has no task. Read-only on GitHub.
|
|
3176
|
+
* A dispatcher's executors stopped with the app's process, but their transcripts are intact (#528): each is continued
|
|
3177
|
+
* with `SendMessage` — its context and worktree, where the task branch and uncommitted work are; a new executor would get
|
|
3178
|
+
* a fresh worktree, and git won't give it the branch. So the hand-outs stay (`handedOutFile`): their tasks aren't ready.
|
|
3179
|
+
* A session with its own task and executors (#546) — the same lines, without the queue command if it ran none, and
|
|
3180
|
+
* then its own task's wait.
|
|
3181
|
+
*/
|
|
3182
|
+
export function cmdTaskResume(io: Io): number {
|
|
3183
|
+
const dispatcher = dispatcherFile(io);
|
|
3184
|
+
const queued = !!dispatcher && existsSync(dispatcher);
|
|
3185
|
+
if (queued || (dispatcher && existsSync(`${dispatcher}.tasks`))) {
|
|
3186
|
+
io.out(
|
|
3187
|
+
"Dispatcher session: its executors (background subagents) stopped with the app's process, their transcripts are intact — " +
|
|
3188
|
+
"continue each with SendMessage by its id from the app's notification, not a new executor: it goes on with its context in its own worktree" +
|
|
3189
|
+
(queued ? `; then re-read the queue: ${readFileSync(dispatcher!, "utf8").trim()} — their tasks stay = in it` : ""),
|
|
3190
|
+
);
|
|
3191
|
+
io.out(
|
|
3192
|
+
"Dispatcher session: a new executor — only for one that can't be continued, with the same prompt; " +
|
|
3193
|
+
`a task ${q(N.status.inProgress)} it continues in the worktree of the task's branch (\`git worktree list\`), not in a new one — its assignment says so: ` +
|
|
3194
|
+
"git won't give the branch to a second worktree, and uncommitted work stays in the old one",
|
|
3195
|
+
);
|
|
3196
|
+
}
|
|
3197
|
+
const pin = pinnedTask(io);
|
|
3198
|
+
if (!pin) return 0;
|
|
3199
|
+
// not a pin — the session has no task, as for est and the dashboard; the line names the file for a human
|
|
3200
|
+
if (!pin.task) return io.out(`○ the session pin ${pin.file} — «${pin.line}», not owner/repo#N: the session has no task`), 0;
|
|
3201
|
+
const { slug, number } = pin.task;
|
|
3202
|
+
const task = `session task ${slug}#${number}`;
|
|
3203
|
+
const issue = loadIssue(io, slug, number);
|
|
3204
|
+
if (issue.state !== "OPEN") return io.out(`○ ${task} is closed — no wait to restart`), 0;
|
|
3205
|
+
const live = (prs: Issue["prs"]) => prs.filter((p) => p.state === "OPEN" || p.merged);
|
|
3206
|
+
const prs = live(issue.prs).length ? live(issue.prs) : live(unlinkedPrs(io, slug, number).prs);
|
|
3207
|
+
const open = prs.filter((p) => p.state === "OPEN");
|
|
3208
|
+
// the task isn't done while a PR of it is open: a follow-up fix after a merge waits for its own checks
|
|
3209
|
+
for (const p of open) io.out(`Session task ${slug}#${number}: PR #${p.number} is open — a background wait dies with the app's process; restart it as a background command: bash ${waitCi()} pr ${p.number}`);
|
|
3210
|
+
const merged = prs.filter((p) => p.merged).sort((a, b) => b.number - a.number)[0];
|
|
3211
|
+
if (!open.length && merged)
|
|
3212
|
+
io.out(
|
|
3213
|
+
`Session task ${slug}#${number}: PR #${merged.number} is merged, the task is open — where the merge deploys, the deploy wait as a background command: bash ${waitCi()} merged ${merged.number}; ` +
|
|
3214
|
+
`then close the task: bun --no-env-file ${import.meta.path} task close ${number}`,
|
|
3215
|
+
);
|
|
3216
|
+
if (!prs.length) io.out(`○ ${task}: no PR — no wait to restart`);
|
|
3217
|
+
return 0;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// canon task types — a copy of EST_TYPES and BRANCH_CONV_RE from skills/est/scripts/est.ts (and in skills/task-env/scripts/task-env.ts):
|
|
3221
|
+
// no shared module: github imports no other skill (tests/standards/skill-imports); the tests/standards/task-branch
|
|
3222
|
+
// standard keeps the parsing identical
|
|
3223
|
+
const TASK_TYPES = ["feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build", "research"];
|
|
3224
|
+
const TASK_BRANCH_RE = new RegExp("^(?:[\\p{L}\\p{N}_.-]+/)?(" + TASK_TYPES.join("|") + ")/(\\d{1,6})-", "iu");
|
|
3225
|
+
|
|
3226
|
+
/** A task branch by the canon — `[<area>/]<type>/<N>-<slug>`: the type (lowercased) and the number, otherwise null. */
|
|
3227
|
+
export function taskBranch(branch: string): { type: string; issue: number } | null {
|
|
3228
|
+
const m = TASK_BRANCH_RE.exec(branch);
|
|
3229
|
+
return m ? { type: m[1]!.toLowerCase(), issue: parseInt(m[2]!, 10) } : null;
|
|
3230
|
+
}
|
|
3231
|
+
const isTaskBranch = (branch: string, number: number) => taskBranch(branch)?.issue === number;
|
|
3232
|
+
/** The slug of a branch `task status --branch` creates: Latin kebab-case after `<N>-` (AGENTS.md, "Session and branch"). */
|
|
3233
|
+
const TASK_SLUG_RE = /\/\d{1,6}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3234
|
+
|
|
3235
|
+
/** Attempts to write the shared `.git/config` while another checkout's git holds its lock: 20 with 0.5 s between — ~10 s. */
|
|
3236
|
+
const CONFIG_LOCK_TRIES = 20;
|
|
3237
|
+
const CONFIG_LOCK_PAUSE_MS = 500;
|
|
3238
|
+
const CONFIG_LOCKED = /could not lock config file (.+)$/m;
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* The worktree tool's branch (`claude/<slug>-<hash>`) becomes the task branch — `task status N "In progress" --branch`.
|
|
3242
|
+
* `git branch -m` renames the ref, then the branch's section in `.git/config` (upstream): the config is shared by all
|
|
3243
|
+
* worktrees of the repository, another checkout's git holds its lock — the ref is renamed, the section is not, code 128,
|
|
3244
|
+
* and a repeated `git branch -m` no longer finds the old name (#438). So the step that failed on the lock is repeated —
|
|
3245
|
+
* the whole rename or, once the ref is renamed, `git config --rename-section`; still locked — the command that finishes
|
|
3246
|
+
* it, the lock isn't removed: another process holds it. A branch already on origin (main, a pushed one) is renamed
|
|
3247
|
+
* together with its PR, by hand — left alone. Returns false when something is left undone.
|
|
3248
|
+
*/
|
|
3249
|
+
function renameBranch(io: Io, slug: string, target: string): boolean {
|
|
3250
|
+
const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd);
|
|
3251
|
+
const has = (ref: string) => git("rev-parse", "--verify", "--quiet", ref).status === 0;
|
|
3252
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) return (io.out(`○ branch left alone: the current directory is not a checkout of ${slug}`), true);
|
|
3253
|
+
const current = git("branch", "--show-current").stdout.trim();
|
|
3254
|
+
if (!current) {
|
|
3255
|
+
const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim() || "origin/main";
|
|
3256
|
+
return (io.out(`○ branch left alone: HEAD is detached — a task branch from a fresh ${base}: git fetch origin && git switch -c ${target} ${base} --no-track`), true);
|
|
3257
|
+
}
|
|
3258
|
+
if (current === target) return (io.out(`= branch ${target}`), true);
|
|
3259
|
+
if (has(`refs/remotes/origin/${current}`)) return (io.out(`○ branch ${current} is already on origin — left alone: a pushed branch is renamed together with its PR, by hand`), true);
|
|
3260
|
+
let renamed = false;
|
|
3261
|
+
let lock = "";
|
|
3262
|
+
for (let k = 0; k < CONFIG_LOCK_TRIES; k++) {
|
|
3263
|
+
if (k) (io.sleep ?? pause)(CONFIG_LOCK_PAUSE_MS);
|
|
3264
|
+
const args = renamed ? ["config", "--rename-section", `branch.${current}`, `branch.${target}`] : ["branch", "-m", current, target];
|
|
3265
|
+
const r = git(...args);
|
|
3266
|
+
// a branch without a section (no upstream) — nothing to rename in the config; est reads the `+ branch` line as a
|
|
3267
|
+
// switch of the checkout's branch (gitBranchesIn in skills/est/scripts/est.ts) — the format is shared
|
|
3268
|
+
if (r.status === 0 || (renamed && /no such section/.test(r.stderr))) {
|
|
3269
|
+
return (io.out(`+ branch ${current} → ${target}${k ? ` (.git/config was locked: ${k} retries)` : ""}`), true);
|
|
3270
|
+
}
|
|
3271
|
+
const locked = CONFIG_LOCKED.exec(r.stderr);
|
|
3272
|
+
if (!locked) return (io.out(`! git ${args.join(" ")}: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`), false);
|
|
3273
|
+
lock = `${path.resolve(io.cwd ?? process.cwd(), locked[1]!.trim())}.lock`;
|
|
3274
|
+
renamed ||= !has(`refs/heads/${current}`) && has(`refs/heads/${target}`);
|
|
3275
|
+
}
|
|
3276
|
+
let age = "";
|
|
3277
|
+
try {
|
|
3278
|
+
age = ` (${Math.round((Date.now() - statSync(lock).mtimeMs) / 1000)} s)`;
|
|
3279
|
+
} catch {
|
|
3280
|
+
// released right after the last attempt — the command below finishes the rename anyway
|
|
3281
|
+
}
|
|
3282
|
+
const what = renamed ? `renamed to ${target}, its section in .git/config is not` : `${current} is not renamed`;
|
|
3283
|
+
const finish = renamed ? `git config --rename-section branch.${current} branch.${target}` : `git branch -m ${current} ${target}`;
|
|
3284
|
+
io.out(`! branch ${what}: ${lock} is held by another git process${age} — not removed; finish: ${finish}`);
|
|
3285
|
+
return false;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
/**
|
|
3289
|
+
* The task's PRs that GitHub didn't link to it: among the latest open and merged PRs into the default branch — the
|
|
3290
|
+
* task's branch or "Closes #N" in the body (as `pr labels` does before the link appears). `why` — what it was found by.
|
|
3291
|
+
*/
|
|
3292
|
+
function unlinkedPrs(io: Io, slug: string, number: number): { base: string; prs: (Issue["prs"][number] & { why: string })[] } {
|
|
3293
|
+
const [owner, name] = slug.split("/");
|
|
3294
|
+
const r = graphql(io, Q.RecentPrs, { owner, name })?.repository;
|
|
3295
|
+
const base: string = r?.defaultBranchRef?.name ?? "main";
|
|
3296
|
+
const prs: (Issue["prs"][number] & { why: string })[] = [];
|
|
3297
|
+
for (const p of r?.pullRequests?.nodes ?? []) {
|
|
3298
|
+
if (p.baseRefName !== base) continue;
|
|
3299
|
+
const head: string = p.headRefName ?? "";
|
|
3300
|
+
const why = isTaskBranch(head, number) ? `branch ${head}` : closingInBody(p.body ?? "").includes(number) ? `«Closes #${number}» in the body` : null;
|
|
3301
|
+
if (why) prs.push({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head, base, why });
|
|
3302
|
+
}
|
|
3303
|
+
return { base, prs };
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
/**
|
|
3307
|
+
* The est script next to the skill (`../est/scripts/est.ts`: the project copy, `~/.agents/skills` and the clone are
|
|
3308
|
+
* laid out this way).
|
|
3309
|
+
*/
|
|
3310
|
+
const estScript = (io: Io) => io.env.ELVES_EST ?? path.resolve(import.meta.dir, "../../est/scripts/est.ts");
|
|
3311
|
+
|
|
3312
|
+
/** The origin URL of the current directory's checkout; not a checkout or no origin — "". */
|
|
3313
|
+
function originUrl(io: Io): string {
|
|
3314
|
+
const remote = (io.run ?? realRun)("git", ["remote", "get-url", "origin"], io.cwd);
|
|
3315
|
+
return remote.status === 0 ? remote.stdout : "";
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
/** The task-env script next to the skill — laid out like est (`../task-env/scripts/task-env.ts`). */
|
|
3319
|
+
const taskEnvScript = (io: Io) => io.env.ELVES_TASK_ENV ?? path.resolve(import.meta.dir, "../../task-env/scripts/task-env.ts");
|
|
3320
|
+
|
|
3321
|
+
/**
|
|
3322
|
+
* The task's stacks (task-env, epic #392) live until someone takes them down — `task close` does: `task-env down <N>`
|
|
3323
|
+
* in the checkout takes the dev and e2e stacks down with their volumes and prints a line per stack (`+`, `○`, `!`) —
|
|
3324
|
+
* printed as is; it failed without a line — its error as a `!` line. A failure doesn't stop the close: a stack left
|
|
3325
|
+
* behind is found by `task-env prune`. Docker doesn't work in the Claude Code sandbox — task close is an unsandboxed
|
|
3326
|
+
* call (SKILL.md).
|
|
3327
|
+
*/
|
|
3328
|
+
function takeDownStacks(io: Io, number: number): void {
|
|
3329
|
+
const script = taskEnvScript(io);
|
|
3330
|
+
if (!existsSync(script)) return io.out(`○ task stacks: task-env is not installed (${script})`);
|
|
3331
|
+
const r = (io.run ?? realRun)("bun", ["--no-env-file", script, "down", String(number)], io.cwd);
|
|
3332
|
+
const lines = r.stdout.split("\n").filter((l) => l.trim());
|
|
3333
|
+
for (const line of lines) io.out(line);
|
|
3334
|
+
if (r.status !== 0 && !lines.length) io.out(`! task-env down: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/** est with arguments: its output without the marker is printed as is, a failure — its last error line as a `!` line. */
|
|
3338
|
+
function runEst(io: Io, est: string, args: string[]): void {
|
|
3339
|
+
const r = (io.run ?? realRun)("bun", ["--no-env-file", est, ...args], io.cwd);
|
|
3340
|
+
for (const line of r.stdout.split("\n")) if (line.trim() && !line.startsWith("<!--")) io.out(line);
|
|
3341
|
+
if (r.status !== 0) io.out(`! est ${args[0]}: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`);
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
/**
|
|
3345
|
+
* The scripts' clone catches up with the merged main before the actual (#517): a dispatcher's executors call the
|
|
3346
|
+
* scripts of the machine's clone, and nothing updates the clone during a session — a later executor's actual was
|
|
3347
|
+
* computed by the est from before the fixes earlier executors had merged. Only for a task of the scripts' own
|
|
3348
|
+
* repository: another project's merge doesn't change the scripts. The clone is the checkout the est script is in (an
|
|
3349
|
+
* installed copy isn't a checkout — nothing to do, no line); it moves only fast-forward, on the default branch and
|
|
3350
|
+
* without uncommitted changes to tracked files — another branch or an edit is someone's work in progress. Untracked
|
|
3351
|
+
* files don't count: the clone holds the sessions' worktrees, and a fast-forward that would overwrite one refuses itself.
|
|
3352
|
+
*/
|
|
3353
|
+
function freshClone(io: Io, slug: string): void {
|
|
3354
|
+
const est = estScript(io);
|
|
3355
|
+
if (!existsSync(est)) return;
|
|
3356
|
+
const run = io.run ?? realRun;
|
|
3357
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], path.dirname(est));
|
|
3358
|
+
if (top.status !== 0) return;
|
|
3359
|
+
const dir = top.stdout.trim();
|
|
3360
|
+
const git = (...args: string[]) => run("git", args, dir);
|
|
3361
|
+
const origin = git("remote", "get-url", "origin");
|
|
3362
|
+
if (origin.status !== 0 || !checkoutOfRepo(io, origin.stdout, slug)) return;
|
|
3363
|
+
const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
|
|
3364
|
+
const branch = git("branch", "--show-current").stdout.trim();
|
|
3365
|
+
const left = (why: string) => io.out(`○ clone ${dir} left alone: ${why} — the actual is by its code`);
|
|
3366
|
+
if (branch !== base) return left(branch ? `on branch ${branch}, not ${base}` : `HEAD is detached, not on ${base}`);
|
|
3367
|
+
const status = git("status", "--porcelain", "--untracked-files=no");
|
|
3368
|
+
if (status.status !== 0 || status.stdout.trim()) return left("uncommitted changes");
|
|
3369
|
+
const head = () => git("rev-parse", "--short", "HEAD").stdout.trim();
|
|
3370
|
+
const before = head();
|
|
3371
|
+
const pull = git("pull", "--ff-only", "--no-rebase", "--quiet", "origin", base);
|
|
3372
|
+
if (pull.status !== 0) {
|
|
3373
|
+
const why = pull.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${pull.status}`;
|
|
3374
|
+
return io.out(`! clone ${dir}: git pull --ff-only origin ${base}: ${why} — the actual is by its code`);
|
|
3375
|
+
}
|
|
3376
|
+
const after = head();
|
|
3377
|
+
io.out(after === before ? `= clone ${dir} ${after}` : `+ clone ${dir} ${before} → ${after}`);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/** The actual — `task close`. */
|
|
3381
|
+
function runFact(io: Io, slug: string, number: number): void {
|
|
3382
|
+
const est = estScript(io);
|
|
3383
|
+
if (!existsSync(est)) return io.out(`○ actual unavailable: est is not installed (${est}) — est fact ${number} --write after installing it`);
|
|
3384
|
+
runEst(io, est, ["fact", String(number), "--repo", slug, "--write"]);
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3387
|
+
/**
|
|
3388
|
+
* The estimate — `task new`, like the actual in `task close`: the script picks the analogs, the agent has no step of
|
|
3389
|
+
* its own. Fewer than two analogs in the repository — est prints "No estimate" and the task stays without one.
|
|
3390
|
+
*/
|
|
3391
|
+
function runEstimate(io: Io, slug: string, number: number, type: string): void {
|
|
3392
|
+
const est = estScript(io);
|
|
3393
|
+
if (!existsSync(est)) return io.out(`○ no estimate: est is not installed (${est})`);
|
|
3394
|
+
runEst(io, est, ["estimate", String(number), "--repo", slug, "--type", type, "--write"]);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/** GraphQL has no milestone mutation — REST. wasOpen — the task was open when read: the open count includes it. */
|
|
3398
|
+
function closeMilestone(io: Io, slug: string, issue: Issue, wasOpen: boolean): void {
|
|
3399
|
+
const m = issue.milestone;
|
|
3400
|
+
if (!m) return;
|
|
3401
|
+
const open = m.open - (wasOpen ? 1 : 0);
|
|
3402
|
+
if (open > 0) return io.out(`○ milestone ${q(m.title)}: open tasks ${open}`);
|
|
3403
|
+
io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
|
|
3404
|
+
io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
/**
|
|
3408
|
+
* Merged branches go away (SKILL.md, "Git, PRs and merging — mechanics", the "Right after a PR merge" item): the branch
|
|
3409
|
+
* of each merged PR of the task — a task may have two, its PR and a follow-up fix to it (#473) — and the checkout's task
|
|
3410
|
+
* branch; the checkout moves off a removed branch to origin/<base> detached, the local and remote branches are deleted.
|
|
3411
|
+
* "Merged" — the PR is merged; a branch without a merged PR — `git cherry`: another merged PR of the task doesn't make
|
|
3412
|
+
* it merged. Not merged or someone else's — left alone. That the directory is the repository's checkout, the caller
|
|
3413
|
+
* checks.
|
|
3414
|
+
*/
|
|
3415
|
+
function cleanupBranches(io: Io, number: number, prs: { head: string; base: string }[]): void {
|
|
3416
|
+
const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd);
|
|
3417
|
+
const current = git("branch", "--show-current").stdout.trim();
|
|
3418
|
+
// a merged branch → the base it went into: each merged PR's head once
|
|
3419
|
+
const merged = new Map<string, string>();
|
|
3420
|
+
for (const p of prs) if (p.head && !merged.has(p.head)) merged.set(p.head, p.base || "main");
|
|
3421
|
+
const loose = current && !merged.has(current) && isTaskBranch(current, number) ? current : "";
|
|
3422
|
+
if (!merged.size && !loose) return io.out("○ the task branch not found — branches left alone");
|
|
3423
|
+
git("fetch", "--prune", "origin");
|
|
3424
|
+
if (loose) {
|
|
3425
|
+
const base = prs[0]?.base || "main";
|
|
3426
|
+
const cherry = git("cherry", `origin/${base}`, loose);
|
|
3427
|
+
if (cherry.status !== 0 || cherry.stdout.split("\n").some((l) => l.startsWith("+"))) io.out(`○ branch ${loose} is not merged into origin/${base} — left alone`);
|
|
3428
|
+
else merged.set(loose, base);
|
|
3429
|
+
}
|
|
3430
|
+
if (!merged.size) return;
|
|
3431
|
+
for (const [branch, base] of merged) {
|
|
3432
|
+
if (current === branch) {
|
|
3433
|
+
const sw = git("switch", "--detach", `origin/${base}`);
|
|
3434
|
+
if (sw.status !== 0) {
|
|
3435
|
+
io.out(`! git switch --detach origin/${base}: ${sw.stderr.trim()}`);
|
|
3436
|
+
continue;
|
|
3437
|
+
}
|
|
3438
|
+
io.out(`+ checkout — on origin/${base} (detached)`);
|
|
3439
|
+
}
|
|
3440
|
+
if (git("branch", "--list", branch).stdout.trim()) {
|
|
3441
|
+
const d = git("branch", "-D", branch);
|
|
3442
|
+
io.out(d.status === 0 ? `+ local branch ${branch} deleted` : `! git branch -D ${branch}: ${d.stderr.trim()}`);
|
|
3443
|
+
}
|
|
3444
|
+
if (git("ls-remote", "--heads", "origin", branch).stdout.trim()) {
|
|
3445
|
+
const d = git("push", "origin", "--delete", branch);
|
|
3446
|
+
io.out(d.status === 0 ? `+ remote branch ${branch} deleted` : `! git push origin --delete ${branch}: ${d.stderr.trim()}`);
|
|
3447
|
+
} else io.out(`○ remote branch ${branch} is already gone`);
|
|
3448
|
+
}
|
|
3449
|
+
const top = git("rev-parse", "--show-toplevel").stdout.trim();
|
|
3450
|
+
const common = git("rev-parse", "--git-common-dir").stdout.trim();
|
|
3451
|
+
if (!top || !common || path.resolve(io.cwd ?? process.cwd(), common) === path.join(top, ".git")) return;
|
|
3452
|
+
// an agent's worktree — an executor's: it can't remove the worktree it works in, the dispatcher's queue does (#515)
|
|
3453
|
+
if (AGENT_WORKTREE.test(top)) io.out(`○ agent worktree ${top}: the dispatcher's next task queue removes it`);
|
|
3454
|
+
else io.out(`Next: remove worktree ${top} from the main checkout: git worktree remove ${top}`);
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
// ----------------------------------------------------------------------------
|
|
3458
|
+
// Decision labels by PR
|
|
3459
|
+
// ----------------------------------------------------------------------------
|
|
3460
|
+
|
|
3461
|
+
/** The labels the task doesn't have — in one mutation; returns them comma-separated (nothing to add — empty). */
|
|
3462
|
+
function addLabels(io: Io, issue: Pick<Issue, "id" | "labels" | "rest">, names: string[], all: Label[]): string {
|
|
3463
|
+
const missing = names.filter((n) => !issue.labels.includes(n));
|
|
3464
|
+
if (!missing.length) return "";
|
|
3465
|
+
if (issue.rest) rest(io, `${issue.rest}/labels`, { method: "POST", payload: { labels: missing } });
|
|
3466
|
+
else mutate(io, { op: "AddLabels", input: { labelableId: issue.id, labelIds: missing.map((n) => all.find((l) => l.name === n)!.id) } });
|
|
3467
|
+
issue.labels.push(...missing);
|
|
3468
|
+
return missing.join(", ");
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
/** A changed PR file (`PullRequestChangedFile`): the kind of change and the line counts. */
|
|
3472
|
+
export interface PrFile {
|
|
3473
|
+
path: string;
|
|
3474
|
+
changeType: string;
|
|
3475
|
+
additions: number;
|
|
3476
|
+
deletions: number;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
// the former exceptions format — an array in one file: the harness's exceptions.ts
|
|
3480
|
+
const LEGACY_EXCEPTIONS = /^exceptions\.(ts|mts|js|mjs|json)$/;
|
|
3481
|
+
|
|
3482
|
+
/**
|
|
3483
|
+
* Which decision folder's exceptions file is it? A copy of the spec skill's `exceptionFile` rule (`speclib.ts`):
|
|
3484
|
+
* github imports no other skill (tests/standards/skill-imports) and works without spec; the
|
|
3485
|
+
* `tests/standards/exception-files` standard checks both copies against one table. The `exceptions/` directory of a
|
|
3486
|
+
* `tests/<kind>/<name>` folder, a `.json` file — the folder's exception; another file in the directory and a directory
|
|
3487
|
+
* in a subfolder — an error; `exceptions.*` in a folder or a subfolder — the former format; outside decision folders
|
|
3488
|
+
* and hidden ones — null.
|
|
3489
|
+
*/
|
|
3490
|
+
export function exceptionFile(p: string): { folder: string; dir: string; legacy: boolean; error?: string } | null {
|
|
3491
|
+
const parts = p.split("/");
|
|
3492
|
+
if (parts[0] !== "tests" || parts.length < 4 || !DECISION_KINDS.some((k) => DECISIONS[k].dir === parts[1])) return null;
|
|
3493
|
+
const folder = parts.slice(0, 3).join("/");
|
|
3494
|
+
const inner = parts.slice(3);
|
|
3495
|
+
if (inner.some((s) => s.startsWith("."))) return null;
|
|
3496
|
+
const at = inner.findIndex((s, i) => (i < inner.length - 1 ? s === "exceptions" : LEGACY_EXCEPTIONS.test(s)));
|
|
3497
|
+
if (at < 0) return null;
|
|
3498
|
+
const dir = "exceptions";
|
|
3499
|
+
if (at === inner.length - 1) return { folder, dir, legacy: true };
|
|
3500
|
+
if (at > 0) return { folder, dir, legacy: false, error: "the exceptions directory is in a subfolder" };
|
|
3501
|
+
if (inner.length > 2 || !p.endsWith(".json")) return { folder, dir, legacy: false, error: "an exception is not a .json file" };
|
|
3502
|
+
return { folder, dir, legacy: false };
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
/**
|
|
3506
|
+
* A mechanical edit doesn't change a decision: the file is deleted, renamed without content changes, or it's an
|
|
3507
|
+
* exceptions file of a decision folder that spec reads as an exception (`exceptionFile` without an error): a file in
|
|
3508
|
+
* `exceptions/`, the former `exceptions.ts` (the exceptions ratchet). It gives no decision label — otherwise with mass
|
|
3509
|
+
* edits a task gets labels of decisions it didn't change.
|
|
3510
|
+
*/
|
|
3511
|
+
function mechanical(f: PrFile): boolean {
|
|
3512
|
+
if (f.changeType === "DELETED") return true;
|
|
3513
|
+
if (f.changeType === "RENAMED" && f.additions === 0 && f.deletions === 0) return true;
|
|
3514
|
+
const x = exceptionFile(f.path);
|
|
3515
|
+
return !!x && !x.error;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
/**
|
|
3519
|
+
* Decisions the diff touches: a spec tree folder is the decision itself, a code file — the model module with the
|
|
3520
|
+
* longest matching directory (a nested module is more precise than the enclosing one). `tests/lib` and files outside
|
|
3521
|
+
* modules aren't decisions. `changed` — decision name → its kinds in the diff, at least one of the decision's files
|
|
3522
|
+
* edited in content; `mechanical` — decisions touched only by a mechanical edit. Names in alphabetical order.
|
|
3523
|
+
*/
|
|
3524
|
+
export function decisionsOfFiles(files: PrFile[], modules: Record<string, string[]>): { changed: Map<string, DecisionKind[]>; mechanical: string[] } {
|
|
3525
|
+
const out = new Map<string, { kinds: Set<DecisionKind>; changed: boolean }>();
|
|
3526
|
+
const add = (name: string, kind: DecisionKind, f: PrFile) => {
|
|
3527
|
+
const d = out.get(name) ?? { kinds: new Set(), changed: false };
|
|
3528
|
+
d.kinds.add(kind);
|
|
3529
|
+
d.changed ||= !mechanical(f);
|
|
3530
|
+
out.set(name, d);
|
|
3531
|
+
};
|
|
3532
|
+
const byDir = Object.fromEntries(DECISION_KINDS.map((k) => [DECISIONS[k].dir, k])) as Record<string, DecisionKind>;
|
|
3533
|
+
for (const f of files) {
|
|
3534
|
+
const parts = f.path.split("/");
|
|
3535
|
+
if (parts[0] === "tests") {
|
|
3536
|
+
const kind = byDir[parts[1] ?? ""];
|
|
3537
|
+
if (kind && parts.length > 3) add(parts[2]!, kind, f);
|
|
3538
|
+
continue;
|
|
3539
|
+
}
|
|
3540
|
+
let best: [string, number] | null = null;
|
|
3541
|
+
for (const [m, dirs] of Object.entries(modules)) {
|
|
3542
|
+
for (const d of dirs) if ((f.path === d || f.path.startsWith(d + "/")) && (!best || d.length > best[1])) best = [m, d.length];
|
|
3543
|
+
}
|
|
3544
|
+
if (best) add(best[0], "architecture", f);
|
|
3545
|
+
}
|
|
3546
|
+
const names = [...out.keys()].sort();
|
|
3547
|
+
return {
|
|
3548
|
+
changed: new Map(names.filter((n) => out.get(n)!.changed).map((n) => [n, DECISION_KINDS.filter((k) => out.get(n)!.kinds.has(k))])),
|
|
3549
|
+
mechanical: names.filter((n) => !out.get(n)!.changed),
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
/**
|
|
3554
|
+
* Decisions whose statements the PR changes — who gets a label when not all of them fit on an issue (#569): a codemod
|
|
3555
|
+
* over every test (a test runner change) rewrites the bodies and changes no statement, and a decision label means "the
|
|
3556
|
+
* task changes the decision". A statement is the decision folder's `<name>.md` edited in content, or the folder named in
|
|
3557
|
+
* the spec section of the PR body (spec-diff output): a list line or a summary row starting with the folder — removed,
|
|
3558
|
+
* changed and added tests, the move into the tree, harness checks, scenarios that became tests. Exceptions aren't
|
|
3559
|
+
* statements (`mechanical`): their blocks are skipped. The section heading and block titles — in any dictionary
|
|
3560
|
+
* language: the body is in the project's language, and the script may run from another project with --repo.
|
|
3561
|
+
*/
|
|
3562
|
+
function statementDecisions(files: PrFile[], body: string): Set<string> {
|
|
3563
|
+
const dirs = new Set<string>(DECISION_KINDS.map((k) => DECISIONS[k].dir));
|
|
3564
|
+
const out = new Set<string>();
|
|
3565
|
+
for (const f of files) {
|
|
3566
|
+
const [root, dir, name, file, ...rest] = f.path.split("/");
|
|
3567
|
+
if (root === "tests" && dirs.has(dir!) && file === `${name}.md` && !rest.length && !mechanical(f)) out.add(name!);
|
|
3568
|
+
}
|
|
3569
|
+
const heads = locale.every((n) => n.prSpecSection);
|
|
3570
|
+
const exceptions = locale.every((n) => n.spec.diff.exceptionsTitle).map((t) => `**${t}`);
|
|
3571
|
+
let inSection = false;
|
|
3572
|
+
let skip = false;
|
|
3573
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
3574
|
+
const line = raw.trim();
|
|
3575
|
+
if (heads.includes(line)) [inSection, skip] = [true, false];
|
|
3576
|
+
else if (/^##\s/.test(line)) inSection = false;
|
|
3577
|
+
else if (!inSection) continue;
|
|
3578
|
+
else if (/^#{3,}\s/.test(line)) skip = false;
|
|
3579
|
+
else if (line.startsWith("**")) skip = exceptions.some((t) => line.startsWith(t));
|
|
3580
|
+
else if (!skip) {
|
|
3581
|
+
const m = /^(?:[-*]\s+(?:✅\s+)?|\|\s*)`tests\/([^`/]+)\/([^`/]+)[^`]*`/.exec(line);
|
|
3582
|
+
if (m && dirs.has(m[1]!)) out.add(m[2]!);
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
return out;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
/**
|
|
3589
|
+
* The decision labels an issue doesn't have, as many as fit (`ISSUE_LABEL_LIMIT`): all of them fit — all; otherwise only
|
|
3590
|
+
* the decisions whose statements the PR changes (`statementDecisions`), in alphabetical order, as many as there's room
|
|
3591
|
+
* for; no PR yet (`task new --epic`, `statements` null) — any of them, alphabetically (#574). What isn't set — `○` lines:
|
|
3592
|
+
* a mutation past the limit would fail and leave the issue without a single label. Returns the lines to print.
|
|
3593
|
+
*/
|
|
3594
|
+
function putLabels(io: Io, issue: Issue, who: string, want: string[], statements: Set<string> | null, all: Label[]): string[] {
|
|
3595
|
+
const lines: string[] = [];
|
|
3596
|
+
const missing = want.filter((n) => !issue.labels.includes(n));
|
|
3597
|
+
const room = Math.max(0, ISSUE_LABEL_LIMIT - issue.labels.length);
|
|
3598
|
+
let add = missing;
|
|
3599
|
+
if (missing.length > room) {
|
|
3600
|
+
const named = statements ? missing.filter((n) => statements.has(n)) : [...missing].sort();
|
|
3601
|
+
const bodies = statements ? missing.filter((n) => !statements.has(n)) : [];
|
|
3602
|
+
add = named.slice(0, room);
|
|
3603
|
+
const which = statements ? "only decisions whose <folder>.md or test names the PR changes" : "alphabetically, as many as fit";
|
|
3604
|
+
const without = bodies.length ? `; test bodies and code only, no label: ${bodies.join(", ")}` : "";
|
|
3605
|
+
lines.push(`○ ${who}: ${missing.length} new decision labels, room for ${room} (GitHub puts at most ${ISSUE_LABEL_LIMIT} on an issue, it has ${issue.labels.length}) — ${which}${without}`);
|
|
3606
|
+
if (named.length > room) lines.push(`○ ${who}: no room — not set: ${named.slice(room).join(", ")}`);
|
|
3607
|
+
}
|
|
3608
|
+
const added = addLabels(io, issue, add, all);
|
|
3609
|
+
if (added) lines.push(`+ ${who}: ${added}`);
|
|
3610
|
+
return lines;
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3613
|
+
/**
|
|
3614
|
+
* Tasks the PR closes, by its body — as GitHub understands them: a keyword (close, fix, resolve in any form) before
|
|
3615
|
+
* each `#N`; "Closes #1, #2" closes only #1.
|
|
3616
|
+
*/
|
|
3617
|
+
function closingInBody(body: string): number[] {
|
|
3618
|
+
const out: number[] = [];
|
|
3619
|
+
for (const m of body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi)) if (!out.includes(Number(m[1]))) out.push(Number(m[1]));
|
|
3620
|
+
return out;
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
/** The PR and all its changed files (`PrChange`, page by page): `pr labels` and the epic refresh's assignment. */
|
|
3624
|
+
function loadPrChange(io: Io, slug: string, number: number): { head: Any; files: PrFile[] } {
|
|
3625
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
3626
|
+
let head: Any = null;
|
|
3627
|
+
const files = pages<PrFile>((after) => {
|
|
3628
|
+
const pr = graphql(io, Q.PrChange, { owner, name, number, after })?.repository?.pullRequest;
|
|
3629
|
+
if (!pr) throw new GhError(`no PR #${number} in ${slug}`);
|
|
3630
|
+
head ??= pr;
|
|
3631
|
+
return pr.files;
|
|
3632
|
+
});
|
|
3633
|
+
return { head, files };
|
|
3634
|
+
}
|
|
3635
|
+
|
|
3636
|
+
export function cmdPrLabels(io: Io, slug: string, number: number): number {
|
|
3637
|
+
const [owner, name] = slug.split("/") as [string, string];
|
|
3638
|
+
const { head, files } = loadPrChange(io, slug, number);
|
|
3639
|
+
let closes: number[] = (head.closingIssuesReferences?.nodes ?? []).map((x: Any) => x.number);
|
|
3640
|
+
// right after the PR is created GitHub hasn't linked it to the task yet — the same keywords in the body
|
|
3641
|
+
if (!closes.length) closes = closingInBody(head.body ?? "");
|
|
3642
|
+
if (!closes.length) throw new GhError(`PR #${number} has no «Closes #N» — nowhere to put the labels`);
|
|
3643
|
+
// modules come from the PR head: the PR may add a module or move its directory
|
|
3644
|
+
const model = graphql(io, Q.ModelAt, { owner, name, expression: `${head.headRefOid}:${MODEL_PATH}` })?.repository?.object;
|
|
3645
|
+
let modules: Record<string, string[]> = {};
|
|
3646
|
+
if (model) {
|
|
3647
|
+
const m = modelModules(model.text ?? "");
|
|
3648
|
+
if (typeof m === "string") io.err(`warning: model ${MODEL_PATH} of the PR head doesn't load — modules not counted: ${m}`);
|
|
3649
|
+
else modules = m;
|
|
3650
|
+
}
|
|
3651
|
+
const { changed: decided, mechanical: skipped } = decisionsOfFiles(files, modules);
|
|
3652
|
+
const all = decided.size ? loadLabels(io, slug) : [];
|
|
3653
|
+
// a slice label with a decision's name is a different slice of tasks: it isn't set as a decision label
|
|
3654
|
+
const dimensions = all.flatMap((l) => {
|
|
3655
|
+
const dim = decided.has(l.name) ? labelDimension(l.description) : null;
|
|
3656
|
+
return dim ? [{ name: l.name, dim }] : [];
|
|
3657
|
+
});
|
|
3658
|
+
const want = [...decided.keys()].filter((n) => !dimensions.some((x) => x.name === n));
|
|
3659
|
+
io.out(`PR #${number} → ${closes.map((n) => `#${n}`).join(", ")}: ${want.join(", ") || "no decisions in the diff"}`);
|
|
3660
|
+
for (const l of skipped) io.out(`○ ${l} — only a mechanical edit (a deletion, a rename without changes, exceptions): no label`);
|
|
3661
|
+
for (const x of dimensions) io.out(`○ ${x.name} — label ${q(x.name)} is slice ${q(x.dim)}, not a decision label: not set`);
|
|
3662
|
+
if (!want.length) return 0;
|
|
3663
|
+
|
|
3664
|
+
const fresh = want.filter((l) => !all.some((x) => x.name === l));
|
|
3665
|
+
const repoId = fresh.length ? loadRepo(io, slug).id : "";
|
|
3666
|
+
for (const l of fresh) {
|
|
3667
|
+
const label = decisionLabel(l, decided.get(l)!);
|
|
3668
|
+
const created = mutate(io, { op: "CreateLabel", input: { repositoryId: repoId, ...label } }).createLabel.label;
|
|
3669
|
+
all.push({ id: created.id, ...label, open: { total: 0, numbers: [] } });
|
|
3670
|
+
io.out(`+ created label ${q(l)}`);
|
|
3671
|
+
}
|
|
3672
|
+
const isDecision = (n: string) => legacyDecision(n) !== null || labelKinds(all.find((x) => x.name === n)?.description) !== null;
|
|
3673
|
+
const statements = statementDecisions(files, head.body ?? "");
|
|
3674
|
+
const epics = new Map<number, Issue>();
|
|
3675
|
+
for (const n of closes) {
|
|
3676
|
+
const issue = loadIssue(io, slug, n);
|
|
3677
|
+
for (const l of putLabels(io, issue, `#${n}`, want, statements, all)) io.out(l);
|
|
3678
|
+
// earlier labels aren't removed: the task may have touched the decision in another PR too
|
|
3679
|
+
for (const l of issue.labels.filter((x) => isDecision(x) && !want.includes(x))) io.out(`= #${n}: ${l} — the decision is not in the PR diff, the label stays`);
|
|
3680
|
+
if (issue.parent && !epics.has(issue.parent.number)) epics.set(issue.parent.number, loadIssue(io, slug, issue.parent.number));
|
|
3681
|
+
}
|
|
3682
|
+
for (const epic of epics.values()) for (const l of putLabels(io, epic, `epic #${epic.number}`, want, statements, all)) io.out(l);
|
|
3683
|
+
return 0;
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// ----------------------------------------------------------------------------
|
|
3687
|
+
// Checking the merge before merging
|
|
3688
|
+
// ----------------------------------------------------------------------------
|
|
3689
|
+
|
|
3690
|
+
/**
|
|
3691
|
+
* The project's fast checks — these `package.json` scripts, those that exist, in CI order: types (a semantic conflict
|
|
3692
|
+
* of two PRs, seconds), then standards and architecture — minutes, not the whole `test`.
|
|
3693
|
+
*/
|
|
3694
|
+
export const PREMERGE_SCRIPTS = ["typecheck", "test:spec"];
|
|
3695
|
+
/**
|
|
3696
|
+
* Spec roots whose checks `test:spec` runs — standards and architecture (the spec skill): there two green PRs break
|
|
3697
|
+
* main. A tree with them and without the script — premerge names what isn't checked on the merge, the project's
|
|
3698
|
+
* `check` (bin/elves.ts) requires the script (#442).
|
|
3699
|
+
*/
|
|
3700
|
+
export const SPEC_ROOTS = ["tests/standards", "tests/architecture"];
|
|
3701
|
+
/** The spec roots the tree in dir has. */
|
|
3702
|
+
export const specRoots = (dir: string) => SPEC_ROOTS.filter((d) => statSync(path.join(dir, d), { throwIfNoEntry: false })?.isDirectory());
|
|
3703
|
+
/**
|
|
3704
|
+
* GitHub builds the merge-ref before the event that creates the check suite: main moving within this minute counts as
|
|
3705
|
+
* having moved.
|
|
3706
|
+
*/
|
|
3707
|
+
const MERGE_REF_SLACK_MS = 60_000;
|
|
3708
|
+
const INSTALL: Record<PackageManager["name"], string[]> = { bun: ["bun", "install", "--frozen-lockfile"], pnpm: ["pnpm", "install", "--frozen-lockfile"], yarn: ["yarn", "install", "--frozen-lockfile"], npm: ["npm", "ci"] };
|
|
3709
|
+
/**
|
|
3710
|
+
* Failure lines: a test — bun `(fail)`, Vitest `FAIL` and `×`, Jest `●`, Playwright `✘`; a compiler error —
|
|
3711
|
+
* `error TS2304:`.
|
|
3712
|
+
*/
|
|
3713
|
+
const FAILED_LINE = /^\s*(\(fail\)|FAIL\s|×|✗|✘|●\s)|\berror TS\d+:/;
|
|
3714
|
+
/**
|
|
3715
|
+
* A `bun check` error under an agent (`CLAUDECODE=1`) — a block `<error file="a.ts" line="3" column="5" code="TS2304">`
|
|
3716
|
+
* with the text on the next line; it is brought to a tsc line without a TTY, `a.ts(3,5): error TS2304: …`.
|
|
3717
|
+
*/
|
|
3718
|
+
const BUN_CHECK_ERROR = /<error file="([^"]+)" line="(\d+)" column="(\d+)" code="(TS\d+)">\n([^\n]*)/g;
|
|
3719
|
+
const asTsc = (out: string) => out.replace(BUN_CHECK_ERROR, "$1($2,$3): error $4: $5");
|
|
3720
|
+
|
|
3721
|
+
/** The project's package manager: its command, Yarn 2+ and the lockfile dependencies are installed from. */
|
|
3722
|
+
export interface PackageManager {
|
|
3723
|
+
name: "bun" | "pnpm" | "yarn" | "npm";
|
|
3724
|
+
/** Yarn 2+: package.json scripts run in its own shell (`@yarnpkg/shell`) — without `.`, `eval` and `export`. */
|
|
3725
|
+
berry: boolean;
|
|
3726
|
+
/** The manager's own lockfile, otherwise the first one in order; none — null. */
|
|
3727
|
+
lockfile: string | null;
|
|
3728
|
+
/** The version `packageManager` declares (`bun@1.4.3` — `1.4.3`); no field or no version — null. */
|
|
3729
|
+
version: string | null;
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
/**
|
|
3733
|
+
* The project's package manager in dir — the one answer of the flow: `pr premerge` (`projectCommands`), the slot
|
|
3734
|
+
* wrapper and the `test:spec` runner of the project's `check` (bin/elves.ts) take it here,
|
|
3735
|
+
* `tests/standards/package-manager`. The manager — `packageManager` in package.json, otherwise the lockfile in the
|
|
3736
|
+
* order below, otherwise `.yarnrc.yml` — Yarn, otherwise npm. Yarn 2+ — `yarn@2+` in `packageManager`, Yarn by the
|
|
3737
|
+
* files — with `.yarnrc.yml`: `packageManager` decides the version too.
|
|
3738
|
+
*/
|
|
3739
|
+
export function packageManager(dir: string): PackageManager {
|
|
3740
|
+
const lockfiles = [["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["package-lock.json", "npm"]] as const;
|
|
3741
|
+
const has = (file: string) => existsSync(path.join(dir, file));
|
|
3742
|
+
let field = "";
|
|
3743
|
+
try {
|
|
3744
|
+
field = String(JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"))?.packageManager ?? "");
|
|
3745
|
+
} catch {
|
|
3746
|
+
// no package.json or not JSON — no field
|
|
3747
|
+
}
|
|
3748
|
+
const declared = /^(bun|pnpm|yarn|npm)@((\d*)(?:\.\d+)*)/.exec(field);
|
|
3749
|
+
const found = lockfiles.filter(([file]) => has(file));
|
|
3750
|
+
const yarnrc = has(".yarnrc.yml");
|
|
3751
|
+
const name = (declared?.[1] as PackageManager["name"] | undefined) ?? found[0]?.[1] ?? (yarnrc ? "yarn" : "npm");
|
|
3752
|
+
const berry = name === "yarn" && (declared ? Number(declared[3]) >= 2 : yarnrc);
|
|
3753
|
+
return { name, berry, lockfile: (found.find(([, pm]) => pm === name) ?? found[0])?.[0] ?? null, version: declared?.[2] || null };
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
/**
|
|
3757
|
+
* The project's commands in a directory: the package manager — `packageManager`; dependencies are installed from the
|
|
3758
|
+
* lockfile without changing it, no lockfile — no install.
|
|
3759
|
+
*/
|
|
3760
|
+
export function projectCommands(dir: string): { install: string[] | null; run: (script: string) => string[] } {
|
|
3761
|
+
const pm = packageManager(dir);
|
|
3762
|
+
return { install: pm.lockfile ? INSTALL[pm.name] : null, run: (script) => [pm.name, "run", script] };
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
const tail = (r: RunResult, n = 20) => `${r.stdout}\n${r.stderr}`.split("\n").filter((l) => l.trim()).slice(-n);
|
|
3766
|
+
/**
|
|
3767
|
+
* A failure line without the run time (bun `[1.2ms]`, Vitest `12ms`) and the compiler error position (`a.ts(3,5):` —
|
|
3768
|
+
* tsc without a TTY; a PR edit higher up in the file shifts it) — one failure on the merge and on main.
|
|
3769
|
+
*/
|
|
3770
|
+
const failure = (l: string) => l.trim().replace(/\s*\[[\d.]+\s*m?s\]$/, "").replace(/\s+[\d.]+\s*m?s$/, "").replace(/^(\S+?)\(\d+,\d+\)(?=:)/, "$1");
|
|
3771
|
+
|
|
3772
|
+
/**
|
|
3773
|
+
* Waiting for the bug to close — the ci-wait script next to it (`../ci-wait`: the project copy, `~/.agents/skills` and
|
|
3774
|
+
* the clone are laid out this way). A function, not a value at load: `import.meta.dir` is Bun's, and a project's test
|
|
3775
|
+
* loads this module from the package under Node and Vitest too (`exports` of the Elves package.json).
|
|
3776
|
+
*/
|
|
3777
|
+
const waitCi = () => path.resolve(import.meta.dir, "../../ci-wait/scripts/wait-ci.sh");
|
|
3778
|
+
// a red check outcome: cancelled (a rerun, a new commit) and waiting for a human's decision aren't red
|
|
3779
|
+
const RED_RUN = new Set(["failure", "timed_out", "startup_failure"]);
|
|
3780
|
+
const RED_STATUS = new Set(["failure", "error"]);
|
|
3781
|
+
|
|
3782
|
+
/**
|
|
3783
|
+
* The spec publish job (the `spec` skill, `ci.md`; `<job> / spec-publish` — the same job in a reusable workflow) —
|
|
3784
|
+
* publishing the documentation, not a check of the branch: its red isn't a red main (#443). A copy — in ci-wait's
|
|
3785
|
+
* `wait-ci.sh` (`SPEC_PUBLISH`): both answer by one table, `tests/standards/spec-publish-job`.
|
|
3786
|
+
*/
|
|
3787
|
+
export const SPEC_PUBLISH = /^(?:.+ \/ )?spec-publish$/;
|
|
3788
|
+
|
|
3789
|
+
/**
|
|
3790
|
+
* Red checks of the default branch's last commit — check-runs (the last run of each name) and hosting statuses;
|
|
3791
|
+
* `publish` — the red spec publish jobs, apart.
|
|
3792
|
+
*/
|
|
3793
|
+
function redChecksOf(io: Io, slug: string, base: string): { sha: string; names: string[]; publish: string[] } {
|
|
3794
|
+
const ref = encodeURIComponent(base);
|
|
3795
|
+
const latest = new Map<string, Any>();
|
|
3796
|
+
for (const r of restCall(io, ["api", `repos/${slug}/commits/${ref}/check-runs?per_page=100`]).check_runs ?? []) {
|
|
3797
|
+
if (!latest.has(r.name) || latest.get(r.name).id < r.id) latest.set(r.name, r);
|
|
3798
|
+
}
|
|
3799
|
+
const status = restCall(io, ["api", `repos/${slug}/commits/${ref}/status`]);
|
|
3800
|
+
const names = [
|
|
3801
|
+
...[...latest.values()].filter((r) => r.status === "completed" && RED_RUN.has(r.conclusion)).map((r) => r.name as string),
|
|
3802
|
+
...(status.statuses ?? []).filter((x: Any) => RED_STATUS.has(x.state)).map((x: Any) => x.context as string),
|
|
3803
|
+
];
|
|
3804
|
+
const red = [...new Set(names)].sort();
|
|
3805
|
+
return { sha: String(status.sha ?? ""), names: red.filter((n) => !SPEC_PUBLISH.test(n)), publish: red.filter((n) => SPEC_PUBLISH.test(n)) };
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
/**
|
|
3809
|
+
* Title prefixes of a bug about a red default branch — `mainRed.title` of each dictionary language, the project
|
|
3810
|
+
* language first.
|
|
3811
|
+
*/
|
|
3812
|
+
const mainRedPrefixes = (base: string) => [...new Set([N, ...locale.everyNames()].map((n) => locale.fill(n.mainRed.title, { base })))];
|
|
3813
|
+
|
|
3814
|
+
/**
|
|
3815
|
+
* Open bugs about a red default branch — the title "<base> is red…" in any dictionary language (a search for each),
|
|
3816
|
+
* by number: the search lags, and a duplicate happens.
|
|
3817
|
+
*/
|
|
3818
|
+
function mainBugsOf(io: Io, slug: string, base: string): { number: number; title: string }[] {
|
|
3819
|
+
const prefixes = mainRedPrefixes(base);
|
|
3820
|
+
const found = new Map<number, Any>();
|
|
3821
|
+
for (const prefix of prefixes) {
|
|
3822
|
+
const q = `repo:${slug} is:issue is:open in:title "${prefix}"`;
|
|
3823
|
+
for (const i of restCall(io, ["api", `search/issues?q=${encodeURIComponent(q)}&per_page=20`]).items ?? []) found.set(i.number, i);
|
|
3824
|
+
}
|
|
3825
|
+
return [...found.values()].filter((i) => !i.pull_request && prefixes.some((p) => String(i.title).startsWith(p))).sort((a, b) => a.number - b.number);
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* The bug title "<base> is red…" names the check — the whole name after the prefix, not part of another one: `test`
|
|
3830
|
+
* isn't found in "test:spec", nor `main` in the prefix.
|
|
3831
|
+
*/
|
|
3832
|
+
function bugNames(base: string, title: string, check: string): boolean {
|
|
3833
|
+
const prefix = mainRedPrefixes(base).find((p) => title.startsWith(p)) ?? "";
|
|
3834
|
+
return new RegExp(`(?:^|[\\s,«"(])${locale.escapeRe(check)}(?=$|[\\s,;»")])`, "u").test(title.slice(prefix.length));
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
/**
|
|
3838
|
+
* Before merging a green PR: main moved after its CI — the PR head merged with a fresh main in a temporary worktree and
|
|
3839
|
+
* the fast checks (`typecheck`, then `test:spec`, as in CI); didn't move — the CI checked exactly this merge. GitHub
|
|
3840
|
+
* doesn't keep what the CI checked (the merge-ref is rebuilt), so by time: the head's earliest check suite against the
|
|
3841
|
+
* last event of the default branch (REST: the GraphQL quota is shared by all sessions). The unknown is checked. A red
|
|
3842
|
+
* main isn't this PR: a red check on main's last commit or a merge red by the same thing as bare main. Code 0 —
|
|
3843
|
+
* merge, 1 — don't merge (the PR breaks it), 3 — main is red.
|
|
3844
|
+
*/
|
|
3845
|
+
export function cmdPrPremerge(io: Io, slug: string, number: number): number {
|
|
3846
|
+
const pr = restCall(io, ["api", `repos/${slug}/pulls/${number}`]);
|
|
3847
|
+
if (pr.state !== "open") throw new GhError(`PR #${number} is not open — nothing to merge`);
|
|
3848
|
+
const head: string = pr.head.sha;
|
|
3849
|
+
const base: string = pr.base.ref;
|
|
3850
|
+
const closes = closingInBody(pr.body ?? "");
|
|
3851
|
+
|
|
3852
|
+
/**
|
|
3853
|
+
* A red main: don't merge and don't fix it in your PR — one bug for everyone, the others wait for it to close.
|
|
3854
|
+
* `red` — the failed checks (or the script), `what` — how to name them in the bug title. A PR that closes this bug is
|
|
3855
|
+
* the fix: null, the check goes as usual.
|
|
3856
|
+
*/
|
|
3857
|
+
const mainRed = (lead: string, why: string, red: string[], what: (names: string[]) => string, say = what): number | null => {
|
|
3858
|
+
let bugs: { number: number; title: string }[] = [];
|
|
3859
|
+
try {
|
|
3860
|
+
bugs = mainBugsOf(io, slug, base);
|
|
3861
|
+
} catch (e) {
|
|
3862
|
+
if (!(e instanceof GhError)) throw e;
|
|
3863
|
+
io.out(`○ open bug on red ${base} not found by search: ${e.message}`);
|
|
3864
|
+
}
|
|
3865
|
+
// the fix is a PR that closes any of the open bugs, without matching the failed check (unlike the wait below): one
|
|
3866
|
+
// red main is named sometimes by the script (test:spec on bare main), sometimes by a CI check — matching would
|
|
3867
|
+
// keep the fix from merging
|
|
3868
|
+
const fix = bugs.find((b) => closes.includes(b.number));
|
|
3869
|
+
if (fix) {
|
|
3870
|
+
io.out(`○ ${base} is red — ${why}; the PR closes bug #${fix.number} about it — checking the PR as usual`);
|
|
3871
|
+
return null;
|
|
3872
|
+
}
|
|
3873
|
+
io.out(lead);
|
|
3874
|
+
// wait for a bug whose title names the failed check (unlike the fix above): a bug about another check won't make
|
|
3875
|
+
// main green
|
|
3876
|
+
const named = (b: { title: string }) => red.some((n) => bugNames(base, b.title, n));
|
|
3877
|
+
for (const b of bugs.filter((b) => !named(b))) io.out(`○ #${b.number} «${b.title}» — about another check: don't wait`);
|
|
3878
|
+
const wait = (n: string) => `wait for it to close — bash ${waitCi()} issue ${n} as a background command, then pr premerge ${number} again`;
|
|
3879
|
+
const bug = bugs.find(named);
|
|
3880
|
+
if (bug) io.out(`bug on red ${base} — #${bug.number} «${bug.title}»: ${wait(String(bug.number))}`);
|
|
3881
|
+
const unnamed = red.filter((n) => !bugs.some((b) => bugNames(base, b.title, n)));
|
|
3882
|
+
if (unnamed.length) {
|
|
3883
|
+
const priority = pr.base?.repo?.owner?.type === "Organization" ? " --priority Urgent" : "";
|
|
3884
|
+
const title = `${locale.fill(N.mainRed.title, { base })}: ${what(unnamed)}`;
|
|
3885
|
+
io.out(`no bug on red ${base} about ${say(unnamed)} — find an open one on the failed test or file one (label — the failed test's decision) and ask the owner whether to queue it: bun --no-env-file ${import.meta.path} task new --type ${N.issueTypes.bug}${priority} --title ${JSON.stringify(title)}; ${wait("<N>")}`);
|
|
3886
|
+
}
|
|
3887
|
+
io.out(`⛔ don't merge: ${base} is red, not this PR — don't fix it in your PR`);
|
|
3888
|
+
return 3;
|
|
3889
|
+
};
|
|
3890
|
+
|
|
3891
|
+
try {
|
|
3892
|
+
const red = redChecksOf(io, slug, base);
|
|
3893
|
+
if (red.publish.length) {
|
|
3894
|
+
io.out(`○ ${red.publish.join(", ")} red on the last commit of ${base} (${red.sha.slice(0, 7)}) — publishing the documentation, not a check of ${base}: not counted`);
|
|
3895
|
+
}
|
|
3896
|
+
if (red.names.length) {
|
|
3897
|
+
const checks = (names: string[]) => `${names.length > 1 ? "checks" : "check"} ${names.join(", ")}`;
|
|
3898
|
+
// in the bug title — in the project language
|
|
3899
|
+
const titled = (names: string[]) => locale.fill(names.length > 1 ? N.mainRed.checks : N.mainRed.check, { names: names.join(", ") });
|
|
3900
|
+
const why = `${checks(red.names)} of the last commit of ${base} (${red.sha.slice(0, 7)})`;
|
|
3901
|
+
const code = mainRed(`${base} is red: ${why}`, why, red.names, titled, checks);
|
|
3902
|
+
if (code !== null) return code;
|
|
3903
|
+
}
|
|
3904
|
+
} catch (e) {
|
|
3905
|
+
if (!(e instanceof GhError)) throw e;
|
|
3906
|
+
io.out(`○ checks of the last commit of ${base} not read (${e.message}) — whether ${base} is red is unknown from the checks`);
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3909
|
+
const suites: string[] = (restCall(io, ["api", `repos/${slug}/commits/${head}/check-suites?per_page=100`]).check_suites ?? []).map((s: Any) => s.created_at).filter(Boolean);
|
|
3910
|
+
const ci = suites.sort()[0];
|
|
3911
|
+
let moved: string | null = null;
|
|
3912
|
+
let unread: string | null = null;
|
|
3913
|
+
try {
|
|
3914
|
+
moved = restCall(io, ["api", `repos/${slug}/activity?ref=${encodeURIComponent(`refs/heads/${base}`)}&per_page=1`])[0]?.timestamp ?? null;
|
|
3915
|
+
} catch (e) {
|
|
3916
|
+
if (!(e instanceof GhError)) throw e;
|
|
3917
|
+
unread = e.message;
|
|
3918
|
+
}
|
|
3919
|
+
const check = `checking the merge with origin/${base}`;
|
|
3920
|
+
if (!ci) io.out(`the head of PR #${number} has no checks — what CI checked is unknown: ${check}`);
|
|
3921
|
+
else if (unread) io.out(`○ movement of ${base} not read (${unread}) — ${check}`);
|
|
3922
|
+
else if (!moved || Date.parse(moved) <= Date.parse(ci) - MERGE_REF_SLACK_MS) {
|
|
3923
|
+
io.out(`✅ merge: ${base} hasn't moved since the CI of PR #${number} (CI since ${ci}, last movement of ${base} — ${moved ?? "none"}) — CI checked this merge`);
|
|
3924
|
+
return 0;
|
|
3925
|
+
} else io.out(`${base} moved after the CI of PR #${number} (CI since ${ci}, ${base} — ${moved}) — ${check}`);
|
|
3926
|
+
|
|
3927
|
+
const run = io.run ?? realRun;
|
|
3928
|
+
const git = (cwd: string | undefined, ...args: string[]) => run("git", args, cwd);
|
|
3929
|
+
if (!checkoutOfRepo(io, originUrl(io), slug)) throw new GhError(`the merge check runs from a checkout of ${slug}: the current directory is not one`);
|
|
3930
|
+
const fetched = git(io.cwd, "fetch", "-q", "origin", base);
|
|
3931
|
+
if (fetched.status !== 0) throw new GhError(`git fetch origin ${base}: ${fetched.stderr.trim()}`);
|
|
3932
|
+
// the head — by its SHA, not refs/pull/<N>/head: GitHub creates that ref up to minutes after the PR, when CI has
|
|
3933
|
+
// already passed (#505), while a reachable commit (the head branch, refs/pull/<N>/merge) it gives by SHA
|
|
3934
|
+
const got = git(io.cwd, "fetch", "-q", "origin", head);
|
|
3935
|
+
if (got.status !== 0) throw new GhError(`PR head ${head} not fetched from origin (${got.stderr.trim().split("\n").at(-1)}) — was the PR updated? ci-wait again`);
|
|
3936
|
+
|
|
3937
|
+
const dirs: string[] = [];
|
|
3938
|
+
/** A temporary worktree at origin/<base>: always removed. */
|
|
3939
|
+
const worktree = (): string => {
|
|
3940
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "elves-premerge-"));
|
|
3941
|
+
dirs.push(dir);
|
|
3942
|
+
const add = git(io.cwd, "worktree", "add", "-q", "--detach", dir, `origin/${base}`);
|
|
3943
|
+
if (add.status !== 0) throw new GhError(`git worktree add: ${add.stderr.trim()}`);
|
|
3944
|
+
return dir;
|
|
3945
|
+
};
|
|
3946
|
+
const ON_MERGE = "on the merge";
|
|
3947
|
+
/** The tree's package.json scripts; no package.json — none. */
|
|
3948
|
+
const scriptsOf = (dir: string): Record<string, unknown> => {
|
|
3949
|
+
const pkg = path.join(dir, "package.json");
|
|
3950
|
+
return existsSync(pkg) ? (JSON.parse(readFileSync(pkg, "utf8")).scripts ?? {}) : {};
|
|
3951
|
+
};
|
|
3952
|
+
type Red = { script: string; cmd: string; result: RunResult; failed: string[] };
|
|
3953
|
+
/**
|
|
3954
|
+
* The tree's fast checks (`scripts` — those that exist) with dependencies from its lockfile, up to the first red one;
|
|
3955
|
+
* none — null.
|
|
3956
|
+
*/
|
|
3957
|
+
const checks = (dir: string, where: string, scripts: string[]): { ran: string[]; red: Red | null } | null => {
|
|
3958
|
+
const defined = scriptsOf(dir);
|
|
3959
|
+
const present = scripts.filter((s) => defined[s]);
|
|
3960
|
+
if (!present.length) return null;
|
|
3961
|
+
const cmds = projectCommands(dir);
|
|
3962
|
+
if (cmds.install) {
|
|
3963
|
+
const r = run(cmds.install[0]!, cmds.install.slice(1), dir);
|
|
3964
|
+
if (r.status !== 0) throw new GhError(`installing dependencies ${where} failed (${cmds.install.join(" ")}): ${tail(r, 5).join(" / ")}`);
|
|
3965
|
+
io.out(`+ dependencies${where === ON_MERGE ? "" : ` ${where}`}: ${cmds.install.join(" ")}`);
|
|
3966
|
+
}
|
|
3967
|
+
const ran: string[] = [];
|
|
3968
|
+
for (const script of present) {
|
|
3969
|
+
const cmd = cmds.run(script);
|
|
3970
|
+
const result = run(cmd[0]!, cmd.slice(1), dir);
|
|
3971
|
+
ran.push(script);
|
|
3972
|
+
if (result.status === 0) continue;
|
|
3973
|
+
const failed = [...new Set(asTsc(`${result.stdout}\n${result.stderr}`).split("\n").filter((l) => FAILED_LINE.test(l)).map((l) => l.trim()))];
|
|
3974
|
+
return { ran, red: { script, cmd: cmd.join(" "), result, failed } };
|
|
3975
|
+
}
|
|
3976
|
+
return { ran, red: null };
|
|
3977
|
+
};
|
|
3978
|
+
const breaks = (script: string) => `❌ don't merge: ${script} is red on the merge with origin/${base} — fix the root cause in the PR: rebase onto origin/${base}, edit, push --force-with-lease, ci-wait again`;
|
|
3979
|
+
try {
|
|
3980
|
+
const dir = worktree();
|
|
3981
|
+
const short = (ref: string) => git(dir, "rev-parse", "--short=7", ref).stdout.trim();
|
|
3982
|
+
// like GitHub's merge-ref; a rebase merge without conflicts gives the same tree. A temporary merge doesn't need the
|
|
3983
|
+
// project's hooks
|
|
3984
|
+
const merge = git(dir, "-c", "user.name=elves", "-c", "user.email=elves@localhost", "-c", "commit.gpgsign=false", "merge", "-q", "--no-ff", "--no-verify", "--no-edit", head);
|
|
3985
|
+
if (merge.status !== 0) {
|
|
3986
|
+
const files = git(dir, "diff", "--name-only", "--diff-filter=U").stdout.split("\n").filter(Boolean);
|
|
3987
|
+
if (!files.length) throw new GhError(`git merge: ${merge.stderr.trim()}`);
|
|
3988
|
+
io.out(`❌ don't merge: a merge conflict with origin/${base} — ${files.join(", ")}; rebase onto origin/${base}, push --force-with-lease, ci-wait again`);
|
|
3989
|
+
return 1;
|
|
3990
|
+
}
|
|
3991
|
+
io.out(`+ merge of ${head.slice(0, 7)} with origin/${base} ${short("HEAD^1")} — no conflicts`);
|
|
3992
|
+
// standards without test:spec don't stop the merge: the project's check holds the script, here — that they weren't run
|
|
3993
|
+
const roots = specRoots(dir);
|
|
3994
|
+
if (roots.length && !scriptsOf(dir)["test:spec"]) io.out(`○ ${roots.join(", ")} aren't checked on the merge: the merge's package.json has no test:spec script — the project's check names the line to add (bunx github:miroshnik/elves check)`);
|
|
3995
|
+
const merged = checks(dir, ON_MERGE, PREMERGE_SCRIPTS);
|
|
3996
|
+
if (!merged) {
|
|
3997
|
+
io.out(`○ no fast checks: the merge's package.json has no ${PREMERGE_SCRIPTS.join(" or ")} scripts — step skipped`);
|
|
3998
|
+
io.out(`✅ merge: the merge with origin/${base} has no conflicts`);
|
|
3999
|
+
return 0;
|
|
4000
|
+
}
|
|
4001
|
+
const red = merged.red;
|
|
4002
|
+
if (!red) {
|
|
4003
|
+
io.out(`✅ merge: ${merged.ran.join(" and ")} ${merged.ran.length > 1 ? "are" : "is"} green on the merge with origin/${base}`);
|
|
4004
|
+
return 0;
|
|
4005
|
+
}
|
|
4006
|
+
io.out(`${red.cmd} failed:`);
|
|
4007
|
+
for (const l of (red.failed.length ? red.failed.slice(0, 20) : tail(red.result))) io.out(` ${l.trim()}`);
|
|
4008
|
+
|
|
4009
|
+
// a red merge — is bare main red by the same thing: then it's not this PR that breaks it
|
|
4010
|
+
io.out(`checking bare origin/${base}: is it red by itself`);
|
|
4011
|
+
const bare = checks(worktree(), `on origin/${base}`, [red.script])?.red;
|
|
4012
|
+
if (!bare) {
|
|
4013
|
+
io.out(breaks(red.script));
|
|
4014
|
+
return 1;
|
|
4015
|
+
}
|
|
4016
|
+
const onMain = new Set(bare.failed.map(failure));
|
|
4017
|
+
const own = red.failed.filter((l) => !onMain.has(failure(l)));
|
|
4018
|
+
// can't tell what failed — can't tell them apart: main is red, after its fix premerge checks the PR again
|
|
4019
|
+
if (own.length && bare.failed.length) {
|
|
4020
|
+
io.out(`${base} is red by itself, the PR breaks beyond it:`);
|
|
4021
|
+
for (const l of own.slice(0, 20)) io.out(` ${l}`);
|
|
4022
|
+
io.out(breaks(red.script));
|
|
4023
|
+
return 1;
|
|
4024
|
+
}
|
|
4025
|
+
const why = `${red.script} is red on bare origin/${base} too, with the same failures`;
|
|
4026
|
+
const code = mainRed(`${base} is red by itself: ${why}`, why, [red.script], (names) => names.join(", "));
|
|
4027
|
+
if (code !== null) return code;
|
|
4028
|
+
io.out(breaks(red.script));
|
|
4029
|
+
return 1;
|
|
4030
|
+
} finally {
|
|
4031
|
+
for (const dir of dirs) {
|
|
4032
|
+
git(io.cwd, "worktree", "remove", "--force", dir);
|
|
4033
|
+
rmSync(dir, { recursive: true, force: true });
|
|
4034
|
+
}
|
|
4035
|
+
git(io.cwd, "worktree", "prune");
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
// ----------------------------------------------------------------------------
|
|
4040
|
+
// CLI
|
|
4041
|
+
// ----------------------------------------------------------------------------
|
|
4042
|
+
|
|
4043
|
+
/**
|
|
4044
|
+
* Help — `--help` or `-h` anywhere in the arguments (tests/standards/cli-help): a command's synopsis (the first
|
|
4045
|
+
* paragraph), what it does and examples; `github --help` — the synopses of all. The dispatcher takes only the commands
|
|
4046
|
+
* from here: a command without help can't be called.
|
|
4047
|
+
*/
|
|
4048
|
+
const HELP: Record<string, string> = {
|
|
4049
|
+
"project check": `github project check [--repo owner/repo]
|
|
4050
|
+
|
|
4051
|
+
Items ✅/❌; code 0 — everything per the canon, 1 — there are ❌. Decision labels — by the default branch's spec tree; a
|
|
4052
|
+
new decision's label on an open task and a slice label («Slice: …») with a decision's name — an ○ line, not ❌.
|
|
4053
|
+
|
|
4054
|
+
Examples:
|
|
4055
|
+
github project check
|
|
4056
|
+
github project check --repo acme/shop`,
|
|
4057
|
+
"project fix": `github project fix [--repo owner/repo] [--confirm] [--template owner/N | none]
|
|
4058
|
+
|
|
4059
|
+
Fixes via the API and prints UI steps with links; deleting and renaming in a project with tasks and organization
|
|
4060
|
+
settings — only with --confirm (after the user's "yes"). No project — links the one of the same name, otherwise copies
|
|
4061
|
+
the template (${DEFAULT_TEMPLATE}), otherwise creates one.
|
|
4062
|
+
|
|
4063
|
+
Examples:
|
|
4064
|
+
github project fix
|
|
4065
|
+
github project fix --confirm --template none`,
|
|
4066
|
+
"task new": `github task new --title "…" --branch-type <type> [--body "…" | --body-file F] [--type Task|Bug|Epic] [--epic N]
|
|
4067
|
+
[--milestone "…"] [--blocked-by N,N] [--priority Urgent|High|Medium|Low]
|
|
4068
|
+
[--labels <decision>,<kind>:<new decision>,<label>] [--repo owner/repo]
|
|
4069
|
+
|
|
4070
|
+
A task by the canon: type, project and Backlog, Priority, epic, blocked by, milestone, decision labels, the questions
|
|
4071
|
+
label when the description has a question without ✅ (created if missing); sets the estimate itself (est estimate
|
|
4072
|
+
--write with --branch-type: feat, fix, docs, refactor, perf, test, chore, ci, build, research; a bug without it is
|
|
4073
|
+
fix, an epic isn't estimated) and prints what was created and the next step. A Claude Code cloud session — over the
|
|
4074
|
+
repository's REST, without the project: Status, the estimate and an organization's Priority — a line for a local session.
|
|
4075
|
+
|
|
4076
|
+
Examples:
|
|
4077
|
+
github task new --title "Billing · Export invoices to PDF" --branch-type feat --epic 40 --body-file task.md
|
|
4078
|
+
github task new --title "Login fails on Safari" --type Bug --priority High --body "Steps: …"`,
|
|
4079
|
+
"task show": `github task show <N> [--repo owner/repo]
|
|
4080
|
+
|
|
4081
|
+
The task in one output: title, type, Status, estimate, the epic with its open questions and tasks, subtasks, open
|
|
4082
|
+
blockers, the PRs with their checks, open and answered questions, the description; read-only. A cloud session — over the
|
|
4083
|
+
repository's REST, without the project's fields; the PRs — by the task branch or «Closes #N».
|
|
4084
|
+
|
|
4085
|
+
Examples:
|
|
4086
|
+
github task show 42`,
|
|
4087
|
+
"task answer": `github task answer <N> <k> "<answer>" [--auto] [--repo owner/repo]
|
|
4088
|
+
|
|
4089
|
+
✅ and «**Answer:** …» at question k of the questions section (--auto — the auto mark), the numbering unchanged; no open
|
|
4090
|
+
questions left — the questions label is removed; an answered question is refused. A question is answered by ✅ at the
|
|
4091
|
+
start of its answer, not by ✅ in its text. A cloud session — over the repository's REST.
|
|
4092
|
+
|
|
4093
|
+
Examples:
|
|
4094
|
+
github task answer 42 1 "Only PDF, no CSV yet"
|
|
4095
|
+
github task answer 42 2 "Medium, by recommendation" --auto`,
|
|
4096
|
+
"task status": `github task status <N> <Backlog|In progress|Done> [--branch <type>/<N>-<slug>] [--repo owner/repo]
|
|
4097
|
+
|
|
4098
|
+
Status in the project (and the epic's In progress once its first subtask is taken). In progress pins the task to the
|
|
4099
|
+
session (file sessions/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): another task in the same session — a refusal
|
|
4100
|
+
(code 2) with the question to the owner whether to queue it and its executor's prompt; a task handed out to an
|
|
4101
|
+
executor of this session (task queue --assignment) isn't pinned; a human removes the pin. --branch — the worktree
|
|
4102
|
+
tool's branch becomes the task branch (git branch -m with its .git/config section); a .git/config locked by another
|
|
4103
|
+
checkout — retried ~10 s, then a ! line with the command that finishes it (code 1).
|
|
4104
|
+
|
|
4105
|
+
Examples:
|
|
4106
|
+
github task status 42 "In progress" --branch feat/42-invoice-export
|
|
4107
|
+
github task status 42 Backlog`,
|
|
4108
|
+
"task drop": `github task drop <N> [--duplicate-of M] [--repo owner/repo]
|
|
4109
|
+
|
|
4110
|
+
Closes the task without doing it (not planned) and removes it from the project.
|
|
4111
|
+
|
|
4112
|
+
Examples:
|
|
4113
|
+
github task drop 42
|
|
4114
|
+
github task drop 42 --duplicate-of 17`,
|
|
4115
|
+
"task actualize": `github task actualize <N> [--assignment] [--repo owner/repo]
|
|
4116
|
+
|
|
4117
|
+
Right after the merge, in parallel with the deploy: the epic refresh's mechanics (blocked by on done tasks, the questions
|
|
4118
|
+
label without open questions, milestones without open tasks; a comment on the epic) and the subagent's prompt;
|
|
4119
|
+
--assignment (read-only, the subagent's first call) — its whole assignment: the texts of the epic and its open tasks,
|
|
4120
|
+
the PR files, copies of links and answers in comments, the checks by meaning; no epic or no open tasks in it —
|
|
4121
|
+
"refresh not needed".
|
|
4122
|
+
|
|
4123
|
+
Examples:
|
|
4124
|
+
github task actualize 42
|
|
4125
|
+
github task actualize 42 --assignment`,
|
|
4126
|
+
"task resume": `github task resume
|
|
4127
|
+
|
|
4128
|
+
The SessionStart hook runs it: the wait to restart for the task pinned to the session — an open PR's checks, a merged PR
|
|
4129
|
+
of an open task — the deploy and task close; no pin — nothing (code 0). Read-only on GitHub; a session with executors —
|
|
4130
|
+
its stopped executors continued with SendMessage, then the queue command (a dispatcher's); a new executor only for one
|
|
4131
|
+
that can't be continued, in the worktree of its task's branch.
|
|
4132
|
+
|
|
4133
|
+
Examples:
|
|
4134
|
+
github task resume`,
|
|
4135
|
+
"task queue": `github task queue [--epic N] [--type Task|Bug] [--priority Urgent,High] [--repo owner/repo]
|
|
4136
|
+
github task queue --assignment <N> [--cloud] [--repo owner/repo]
|
|
4137
|
+
|
|
4138
|
+
The dispatcher session's queue: ready tasks (open, not an epic, not in work, no open blockers — other repositories'
|
|
4139
|
+
too —, no external blocker label) by Priority, then number, each with its executor's prompt; the rest — in work (=: In
|
|
4140
|
+
progress, an open PR with its link, handed out to an executor of this session) and ○ lines with the reason (the
|
|
4141
|
+
label's — from the latest «External blocker: …» comment). Marks the session as a dispatcher
|
|
4142
|
+
(dispatchers/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): task status in it doesn't pin — the executors are subagents
|
|
4143
|
+
with the session's id; a session with a pinned task keeps it. Before the queue, in the repository's checkout —
|
|
4144
|
+
finished agents' worktrees (.claude/worktrees/agent-*, detached or on worktree-agent-*, not locked by a live process,
|
|
4145
|
+
not an executor's whose handed-out task is open): clean and merged (git cherry) — removed with the branch (+),
|
|
4146
|
+
otherwise left (○ with the reason), and so is one a subagent works in without a worktree of its own (its transcript
|
|
4147
|
+
written in the last ${QUIET_MINUTES} min); loose worktree-agent-* branches — the same. --type and --priority — in an
|
|
4148
|
+
organization. --assignment N (the executor's first call; read-only on GitHub) — its whole assignment; in a session
|
|
4149
|
+
(not one pinned to N) it records the task as handed out with the executor's worktree (dispatchers/<id>.tasks). A
|
|
4150
|
+
restart while the task's previous executor or its subagent is alive — a worktree of it locked by a live process, with
|
|
4151
|
+
such a subagent at work or HEAD moved in the last ${QUIET_MINUTES} min — code 1: ! lines and the answer that makes the
|
|
4152
|
+
dispatcher wait, no hand-out. --cloud — printed by the dispatcher: the brief of a cloud executor (claude.ai/code) —
|
|
4153
|
+
whole, # only at its task — and the dispatcher's steps: a cloud session can't message a local one, so the dispatcher
|
|
4154
|
+
waits for the task's closing (ci-wait issue N, background) and closes it locally; the hand-out has no worktree, and the
|
|
4155
|
+
queue's = line names that wait.
|
|
4156
|
+
|
|
4157
|
+
Examples:
|
|
4158
|
+
github task queue --epic 40
|
|
4159
|
+
github task queue --type Bug --priority Urgent,High
|
|
4160
|
+
github task queue --assignment 42
|
|
4161
|
+
github task queue --assignment 42 --cloud`,
|
|
4162
|
+
"task close": `github task close <N> [--no-git] [--repo owner/repo]
|
|
4163
|
+
|
|
4164
|
+
After the PR merge and the deploy (or a close without a PR) in one call: the actual (est fact --write), Status Done, the
|
|
4165
|
+
epic and the milestone, the task's stacks taken down with their volumes (task-env down), the merged branches removed
|
|
4166
|
+
(--no-git — without git). The epic has other open tasks and no report of the refresh subagent on it («Refresh after
|
|
4167
|
+
#N, result: …») — a refusal before any changes. A task of the scripts' own repository: before the actual the scripts'
|
|
4168
|
+
clone catches up with origin (pull --ff-only; only on the default branch, without uncommitted changes). Docker and gh
|
|
4169
|
+
don't work in an agent's sandbox — run it outside.
|
|
4170
|
+
|
|
4171
|
+
Examples:
|
|
4172
|
+
github task close 42
|
|
4173
|
+
github task close 42 --no-git`,
|
|
4174
|
+
"pr labels": `github pr labels <N> [--repo owner/repo]
|
|
4175
|
+
|
|
4176
|
+
Decision labels from the PR diff for the tasks from «Closes #N» and their epics; doesn't remove earlier ones. A decision
|
|
4177
|
+
touched only mechanically (a deletion, a rename without changes, exceptions) and a decision with a slice label's name —
|
|
4178
|
+
an ○ line, no label. More than fit on an issue (GitHub allows ${ISSUE_LABEL_LIMIT}) — only decisions whose <folder>.md
|
|
4179
|
+
or test names (the PR body's spec section) the PR changes, as many as fit; the rest — ○ lines.
|
|
4180
|
+
|
|
4181
|
+
Examples:
|
|
4182
|
+
github pr labels 57`,
|
|
4183
|
+
"pr premerge": `github pr premerge <N> [--repo owner/repo]
|
|
4184
|
+
|
|
4185
|
+
Before gh pr merge, after ci-wait PASS: the default branch moved after the PR's CI — the PR head merged with it in a
|
|
4186
|
+
temporary worktree and ${PREMERGE_SCRIPTS.join(", then ")} from package.json (those that exist, as in CI). Code 0 —
|
|
4187
|
+
merge, 1 — don't merge (red, conflict), 2 — couldn't check, 3 — main is red by itself (the same check on bare main, a
|
|
4188
|
+
red check on its last commit): don't merge, one bug for it — wait for it to close.
|
|
4189
|
+
|
|
4190
|
+
Examples:
|
|
4191
|
+
github pr premerge 57`,
|
|
4192
|
+
};
|
|
4193
|
+
|
|
4194
|
+
/** `github --help`: what the script is and the synopsis of each command. */
|
|
4195
|
+
const OVERVIEW = [
|
|
4196
|
+
"github — the repository's GitHub project and tasks by the AGENTS.md canon",
|
|
4197
|
+
"",
|
|
4198
|
+
...Object.values(HELP).map((h) => h.split("\n\n")[0]!.replace(/^/gm, " ")),
|
|
4199
|
+
"",
|
|
4200
|
+
"github <command> --help — the command's details and examples",
|
|
4201
|
+
].join("\n");
|
|
4202
|
+
|
|
4203
|
+
/** The help the arguments ask for (`--help`, `-h`): of the command they start with, otherwise the overview; none — undefined. */
|
|
4204
|
+
function helpOf(argv: string[]): string | undefined {
|
|
4205
|
+
if (!argv.some((a) => a === "--help" || a === "-h")) return undefined;
|
|
4206
|
+
const words = `${argv.join(" ")} `;
|
|
4207
|
+
const cmd = Object.keys(HELP).find((c) => words.startsWith(`${c} `));
|
|
4208
|
+
return cmd ? HELP[cmd] : OVERVIEW;
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
const issueNumber = (s: string | undefined, what: string): number => {
|
|
4212
|
+
const m = /^#?(\d+)$/.exec((s ?? "").trim());
|
|
4213
|
+
if (!m) throw new GhError(`${what}: expected an issue number, not «${s ?? ""}»`);
|
|
4214
|
+
return Number(m[1]);
|
|
4215
|
+
};
|
|
4216
|
+
|
|
4217
|
+
// The checkout's GitHub repository by its origin — a copy of originRepo from skills/est/scripts/est.ts and skills/task-env/scripts/task-env.ts:
|
|
4218
|
+
// no shared module: github imports no other skill (tests/standards/skill-imports); the tests/standards/origin-repo
|
|
4219
|
+
// standard keeps the parsing identical
|
|
4220
|
+
const ORIGIN_RE = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?(?:ssh\.)?github\.com(?::\d+)?\/|(?:[^@/\s:]+@)?(?:ssh\.)?github\.com:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i;
|
|
4221
|
+
|
|
4222
|
+
/** `owner/repo` of a GitHub origin URL (host github.com or ssh.github.com, any case, with a port), otherwise null. */
|
|
4223
|
+
export function originRepo(url: string): string | null {
|
|
4224
|
+
const m = ORIGIN_RE.exec(url.trim());
|
|
4225
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
/** A checkout with this origin URL is a checkout of `repo` (owner/name): the parsed origin equals it — GitHub ignores case. */
|
|
4229
|
+
export function checkoutOf(origin: string, repo: string): boolean {
|
|
4230
|
+
return originRepo(origin)?.toLowerCase() === repo.toLowerCase();
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
function detectRepo(): string {
|
|
4234
|
+
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
|
|
4235
|
+
const repo = r.status === 0 ? originRepo(r.stdout ?? "") : null;
|
|
4236
|
+
if (!repo) throw new GhError("couldn't determine the repository from git remote origin; pass --repo owner/repo");
|
|
4237
|
+
return repo;
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
/** Names already resolved by `canonicalRepo`, per Io: lowercase name → the current one. */
|
|
4241
|
+
const canonicalNames = new WeakMap<Io, Map<string, string>>();
|
|
4242
|
+
|
|
4243
|
+
/**
|
|
4244
|
+
* The repository's current name `owner/repo` (#560): GitHub answers a renamed repository's former name with it. Reads by
|
|
4245
|
+
* the former name work — GraphQL and a REST GET follow the rename — but a REST write gets HTTP 307, which `gh` doesn't
|
|
4246
|
+
* repeat for a non-GET, and a hint with the former name sends the next command there: the commands work under the
|
|
4247
|
+
* current name. No such repository or no access — the name as given: the command's own reads say so.
|
|
4248
|
+
*/
|
|
4249
|
+
function canonicalRepo(io: Io, slug: string): string {
|
|
4250
|
+
let known = canonicalNames.get(io);
|
|
4251
|
+
if (!known) canonicalNames.set(io, (known = new Map()));
|
|
4252
|
+
const key = slug.toLowerCase();
|
|
4253
|
+
if (!known.has(key)) {
|
|
4254
|
+
const [owner, name] = slug.split("/");
|
|
4255
|
+
const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query: Q.RepoName, variables: { owner, name } }));
|
|
4256
|
+
let current: unknown = null;
|
|
4257
|
+
try {
|
|
4258
|
+
current = JSON.parse(out)?.data?.repository?.nameWithOwner;
|
|
4259
|
+
} catch {
|
|
4260
|
+
current = null;
|
|
4261
|
+
}
|
|
4262
|
+
known.set(key, typeof current === "string" && current ? current : slug);
|
|
4263
|
+
}
|
|
4264
|
+
return known.get(key)!;
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
/**
|
|
4268
|
+
* The checkout with this origin is a checkout of `slug` (the current name): by the origin's name, and a clone made before
|
|
4269
|
+
* the repository's rename — by the name GitHub resolves its origin's name to (#560).
|
|
4270
|
+
*/
|
|
4271
|
+
function checkoutOfRepo(io: Io, origin: string, slug: string): boolean {
|
|
4272
|
+
if (checkoutOf(origin, slug)) return true;
|
|
4273
|
+
const repo = originRepo(origin);
|
|
4274
|
+
return repo !== null && canonicalRepo(io, repo).toLowerCase() === slug.toLowerCase();
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
export function main(argv: string[], io: Io): number {
|
|
4278
|
+
const help = helpOf(argv);
|
|
4279
|
+
if (help) return io.out(help), 0;
|
|
4280
|
+
N = locale.names(locale.projectLanguage(io.cwd ?? process.cwd()));
|
|
4281
|
+
try {
|
|
4282
|
+
const [group, cmd, ...rest] = argv;
|
|
4283
|
+
if (!group) return io.out(OVERVIEW), 2;
|
|
4284
|
+
if (!Object.hasOwn(HELP, `${group} ${cmd ?? ""}`)) {
|
|
4285
|
+
io.err(`unknown command «${argv.slice(0, 2).join(" ")}»; expected ${Object.keys(HELP).join(", ")} (github --help)`);
|
|
4286
|
+
return 2;
|
|
4287
|
+
}
|
|
4288
|
+
if (inCloud(io) && group === "task" && cmd === "close") {
|
|
4289
|
+
const n = /^#?(\d+)$/.exec(rest.find((a) => !a.startsWith("--")) ?? "")?.[1] ?? "<N>";
|
|
4290
|
+
io.err(`cloud session: task close is unavailable to it — no GitHub Projects in the cloud (docs/cloud-sessions.md). The actual — the ${q(N.est.cloud.actual.replace(/:$/, ""))} comment printed by est fact ${n}; ${q(N.status.done)} is set by the project's workflow ${q(WORKFLOW_CLOSED)}; the cloud's branch and container are temporary, nothing to clean up.`);
|
|
4291
|
+
return 2;
|
|
4292
|
+
}
|
|
4293
|
+
if (inCloud(io) && !CLOUD_COMMANDS.includes(`${group} ${cmd}`)) {
|
|
4294
|
+
io.err(`cloud session: GitHub GraphQL and Projects are unavailable to it (403, docs/cloud-sessions.md) — ${group} ${cmd} runs in a local session; in the cloud — ${CLOUD_COMMANDS.join(", ")} over the repository's REST`);
|
|
4295
|
+
return 2;
|
|
4296
|
+
}
|
|
4297
|
+
// the repository — under its current name: --repo or origin may still name it as it was before a rename (#560); the
|
|
4298
|
+
// cloud has no GraphQL, and its session is bound to its repositories by their names
|
|
4299
|
+
const repoOf = (v: string | undefined) => {
|
|
4300
|
+
const slug = v ?? detectRepo();
|
|
4301
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(slug)) throw new GhError(`invalid --repo «${slug}», expected owner/repo`);
|
|
4302
|
+
return inCloud(io) ? slug : canonicalRepo(io, slug);
|
|
4303
|
+
};
|
|
4304
|
+
if (group === "pr") {
|
|
4305
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { repo: { type: "string" } } });
|
|
4306
|
+
if (positionals.length > 1) throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
|
|
4307
|
+
const m = /^#?(\d+)$/.exec((positionals[0] ?? "").trim());
|
|
4308
|
+
if (!m) throw new GhError(`pr ${cmd}: expected a PR number, not «${positionals[0] ?? ""}»`);
|
|
4309
|
+
return (cmd === "premerge" ? cmdPrPremerge : cmdPrLabels)(io, repoOf(values.repo), Number(m[1]));
|
|
4310
|
+
}
|
|
4311
|
+
if (group === "task") {
|
|
4312
|
+
const { values, positionals } = parseArgs({
|
|
4313
|
+
args: rest,
|
|
4314
|
+
allowPositionals: true,
|
|
4315
|
+
options: {
|
|
4316
|
+
repo: { type: "string" },
|
|
4317
|
+
title: { type: "string" },
|
|
4318
|
+
body: { type: "string" },
|
|
4319
|
+
"body-file": { type: "string" },
|
|
4320
|
+
type: { type: "string" },
|
|
4321
|
+
epic: { type: "string" },
|
|
4322
|
+
milestone: { type: "string" },
|
|
4323
|
+
"blocked-by": { type: "string" },
|
|
4324
|
+
priority: { type: "string" },
|
|
4325
|
+
"duplicate-of": { type: "string" },
|
|
4326
|
+
labels: { type: "string" },
|
|
4327
|
+
"branch-type": { type: "string" },
|
|
4328
|
+
branch: { type: "string" },
|
|
4329
|
+
"no-git": { type: "boolean", default: false },
|
|
4330
|
+
assignment: { type: "boolean", default: false },
|
|
4331
|
+
cloud: { type: "boolean", default: false },
|
|
4332
|
+
auto: { type: "boolean", default: false },
|
|
4333
|
+
},
|
|
4334
|
+
});
|
|
4335
|
+
// the task and its repository — from the session pin: the hook runs it from any directory
|
|
4336
|
+
if (cmd === "resume") {
|
|
4337
|
+
if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")} — task resume takes the task from the session pin`);
|
|
4338
|
+
return cmdTaskResume(io);
|
|
4339
|
+
}
|
|
4340
|
+
const slug = repoOf(values.repo);
|
|
4341
|
+
if (cmd === "new") {
|
|
4342
|
+
if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")}`);
|
|
4343
|
+
if (!values.title?.trim()) throw new GhError("--title is required");
|
|
4344
|
+
if (values.body !== undefined && values["body-file"]) throw new GhError("--body or --body-file, not both");
|
|
4345
|
+
const body = values["body-file"] ? readFileSync(values["body-file"], "utf8") : (values.body ?? "");
|
|
4346
|
+
const blockedBy = (values["blocked-by"] ?? "").split(",").filter((x) => x.trim()).map((x) => issueNumber(x, "--blocked-by"));
|
|
4347
|
+
const epic = values.epic === undefined ? undefined : issueNumber(values.epic, "--epic");
|
|
4348
|
+
const labels = (values.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
4349
|
+
return cmdTaskNew(io, slug, { title: values.title, body, type: values.type ?? N.issueTypes.task, epic, milestone: values.milestone, blockedBy, priority: values.priority, labels, branchType: values["branch-type"] });
|
|
4350
|
+
}
|
|
4351
|
+
if (cmd === "queue") {
|
|
4352
|
+
if (values.cloud && !values.assignment) throw new GhError("--cloud — only with --assignment <N>: the brief of one task's cloud executor");
|
|
4353
|
+
if (values.assignment) {
|
|
4354
|
+
if (positionals.length !== 1) throw new GhError("task queue --assignment <N>: one task number");
|
|
4355
|
+
return cmdTaskAssignment(io, slug, issueNumber(positionals[0], "task queue --assignment"), { cloud: values.cloud! });
|
|
4356
|
+
}
|
|
4357
|
+
if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")} — the queue is chosen by --epic, --type and --priority`);
|
|
4358
|
+
const priority = values.priority === undefined ? undefined : values.priority.split(",").map((x) => x.trim()).filter(Boolean);
|
|
4359
|
+
return cmdTaskQueue(io, slug, { epic: values.epic === undefined ? undefined : issueNumber(values.epic, "--epic"), type: values.type, priority });
|
|
4360
|
+
}
|
|
4361
|
+
const number = issueNumber(positionals[0], `task ${cmd}`);
|
|
4362
|
+
if (cmd === "status") return cmdTaskStatus(io, slug, number, positionals.slice(1).join(" "), values.branch);
|
|
4363
|
+
if (cmd === "answer") {
|
|
4364
|
+
const k = /^\d+$/.exec((positionals[1] ?? "").trim());
|
|
4365
|
+
if (!k) throw new GhError(`task answer: expected a question number, not «${positionals[1] ?? ""}»`);
|
|
4366
|
+
return cmdTaskAnswer(io, slug, number, Number(k[0]), positionals.slice(2).join(" "), values.auto!);
|
|
4367
|
+
}
|
|
4368
|
+
if (positionals.length > 1) throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
|
|
4369
|
+
if (cmd === "show") return cmdTaskShow(io, slug, number);
|
|
4370
|
+
if (cmd === "close") return cmdTaskClose(io, slug, number, { git: !values["no-git"] });
|
|
4371
|
+
if (cmd === "actualize") return cmdTaskActualize(io, slug, number, { assignment: values.assignment! });
|
|
4372
|
+
return cmdTaskDrop(io, slug, number, values["duplicate-of"] === undefined ? undefined : issueNumber(values["duplicate-of"], "--duplicate-of"));
|
|
4373
|
+
}
|
|
4374
|
+
const { values } = parseArgs({ args: rest, options: { repo: { type: "string" }, confirm: { type: "boolean", default: false }, template: { type: "string", default: DEFAULT_TEMPLATE } } });
|
|
4375
|
+
const slug = repoOf(values.repo);
|
|
4376
|
+
const template = values.template === "none" ? null : values.template!;
|
|
4377
|
+
if (template && !/^[\w.-]+\/\d+$/.test(template)) throw new GhError(`invalid --template «${template}», expected owner/N or none`);
|
|
4378
|
+
return cmd === "check" ? cmdCheck(io, slug) : cmdFix(io, slug, { confirm: values.confirm!, template });
|
|
4379
|
+
} catch (e) {
|
|
4380
|
+
if (e instanceof GhError || (e as Any)?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
4381
|
+
io.err(`error: ${(e as Error).message}`);
|
|
4382
|
+
return 2;
|
|
4383
|
+
}
|
|
4384
|
+
throw e;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
if (import.meta.main) {
|
|
4389
|
+
process.exitCode = main(process.argv.slice(2), { gh: realGh, out: (l) => console.log(l), err: (l) => console.error(l), env: process.env });
|
|
4390
|
+
}
|