@miroshnik/elves 0.0.0 → 2026.1018.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +203 -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 +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -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 +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
|
@@ -0,0 +1,3428 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import {
|
|
3
|
+
locale_default
|
|
4
|
+
} from "../../spec/scripts/harness-qzb932c8.js";
|
|
5
|
+
|
|
6
|
+
// skills/github/scripts/github.ts
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
var N = locale_default.names(locale_default.projectLanguage());
|
|
13
|
+
var canonViews = () => [
|
|
14
|
+
{ name: N.views.board, layout: "BOARD_LAYOUT" },
|
|
15
|
+
{ name: N.views.table, layout: "TABLE_LAYOUT" },
|
|
16
|
+
{ name: N.views.roadmap, layout: "ROADMAP_LAYOUT" }
|
|
17
|
+
];
|
|
18
|
+
var STATUS = "Status";
|
|
19
|
+
var statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
|
|
20
|
+
var numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
|
|
21
|
+
var issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
|
|
22
|
+
var PRIORITY = "Priority";
|
|
23
|
+
var PRIORITIES = ["Urgent", "High", "Medium", "Low"];
|
|
24
|
+
var DEFAULT_PRIORITY = "Medium";
|
|
25
|
+
var EPIC_LABEL = "epic";
|
|
26
|
+
var epicLabel = () => ({ name: EPIC_LABEL, color: "8250DF", description: N.labels.epicDescription });
|
|
27
|
+
var questionsLabel = () => ({ name: N.labels.questions, color: "FBCA04", description: N.labels.questionsDescription });
|
|
28
|
+
var DECISIONS = {
|
|
29
|
+
capability: { dir: "capabilities", color: "0E8A16" },
|
|
30
|
+
standard: { dir: "standards", color: "1D76DB" },
|
|
31
|
+
architecture: { dir: "architecture", color: "D93F0B" }
|
|
32
|
+
};
|
|
33
|
+
var DECISION_KINDS = Object.keys(DECISIONS);
|
|
34
|
+
var MODEL_PATH = "tests/architecture/model.ts";
|
|
35
|
+
var ISSUE_LABEL_LIMIT = 100;
|
|
36
|
+
var WORKFLOW_ADDED = "Item added to project";
|
|
37
|
+
var WORKFLOW_CLOSED = "Item closed";
|
|
38
|
+
var WORKFLOW_AUTO_ADD = "Auto-add to project";
|
|
39
|
+
var DEFAULT_TEMPLATE = "miroshnik/6";
|
|
40
|
+
var RULESET = "elves";
|
|
41
|
+
var FORMER_RULESET = "ai-dev";
|
|
42
|
+
var STABLE_PRS = 10;
|
|
43
|
+
var ADMIN_ROLE = 5;
|
|
44
|
+
function statusAliases() {
|
|
45
|
+
const out = { todo: N.status.backlog, "to do": N.status.backlog, doing: N.status.inProgress };
|
|
46
|
+
for (const n of locale_default.everyNames()) {
|
|
47
|
+
out[n.status.backlog.toLowerCase()] = N.status.backlog;
|
|
48
|
+
out[n.status.inProgress.toLowerCase()] = N.status.inProgress;
|
|
49
|
+
out[n.status.done.toLowerCase()] = N.status.done;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function typeAliases() {
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const n of locale_default.everyNames()) {
|
|
56
|
+
out[n.issueTypes.task.toLowerCase()] = N.issueTypes.task;
|
|
57
|
+
out[n.issueTypes.bug.toLowerCase()] = N.issueTypes.bug;
|
|
58
|
+
out[n.issueTypes.epic.toLowerCase()] = N.issueTypes.epic;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function fieldAliases() {
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const n of locale_default.everyNames()) {
|
|
65
|
+
out[n.fields.estimate] = N.fields.estimate;
|
|
66
|
+
out[n.fields.actual] = N.fields.actual;
|
|
67
|
+
out[n.fields.tokens] = N.fields.tokens;
|
|
68
|
+
out[n.fields.cost] = N.fields.cost;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
var 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"]);
|
|
73
|
+
var NOT_DONE_REASONS = new Set(["NOT_PLANNED", "DUPLICATE"]);
|
|
74
|
+
function realRun(cmd, args, cwd) {
|
|
75
|
+
const r = spawnSync(cmd, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
76
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.error ? String(r.error.message) : r.stderr ?? "" };
|
|
77
|
+
}
|
|
78
|
+
var pause = (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
79
|
+
|
|
80
|
+
class GhError extends Error {
|
|
81
|
+
}
|
|
82
|
+
var REF = "id number title url closed";
|
|
83
|
+
var OPEN_SHOWN = 10;
|
|
84
|
+
var RECENT_PRS = 50;
|
|
85
|
+
var 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 } } } } } } }`;
|
|
86
|
+
var PR_SHOW = `number state merged isDraft headRefName
|
|
87
|
+
${HEAD_CHECKS}`;
|
|
88
|
+
var Q = {
|
|
89
|
+
RepoName: `query RepoName($owner: String!, $name: String!) {
|
|
90
|
+
repository(owner: $owner, name: $name) { nameWithOwner }
|
|
91
|
+
}`,
|
|
92
|
+
RepoState: `query RepoState($owner: String!, $name: String!) {
|
|
93
|
+
repository(owner: $owner, name: $name) {
|
|
94
|
+
id name nameWithOwner
|
|
95
|
+
owner {
|
|
96
|
+
__typename id login
|
|
97
|
+
... on Organization {
|
|
98
|
+
issueTypes(first: 50) { nodes { id name isEnabled } }
|
|
99
|
+
issueFields(first: 50) { nodes { __typename ... on IssueFieldSingleSelect { id name options { id name } } } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
projectsV2(first: 20) { nodes { ${REF} } }
|
|
103
|
+
}
|
|
104
|
+
}`,
|
|
105
|
+
RepoIssues: `query RepoIssues($owner: String!, $name: String!, $after: String) {
|
|
106
|
+
repository(owner: $owner, name: $name) {
|
|
107
|
+
issues(states: OPEN, first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } }
|
|
108
|
+
}
|
|
109
|
+
}`,
|
|
110
|
+
OwnerProjects: `query OwnerProjects($login: String!, $query: String!) {
|
|
111
|
+
repositoryOwner(login: $login) { ... on ProjectV2Owner { projectsV2(first: 20, query: $query) { nodes { ${REF} } } } }
|
|
112
|
+
}`,
|
|
113
|
+
TemplateProject: `query TemplateProject($login: String!, $number: Int!) {
|
|
114
|
+
repositoryOwner(login: $login) { ... on ProjectV2Owner { projectV2(number: $number) { ${REF} } } }
|
|
115
|
+
}`,
|
|
116
|
+
ProjectState: `query ProjectState($id: ID!) {
|
|
117
|
+
node(id: $id) {
|
|
118
|
+
... on ProjectV2 {
|
|
119
|
+
${REF}
|
|
120
|
+
views(first: 50) {
|
|
121
|
+
nodes {
|
|
122
|
+
id number name layout filter
|
|
123
|
+
sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
|
|
124
|
+
verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
fields(first: 100) {
|
|
128
|
+
nodes {
|
|
129
|
+
__typename
|
|
130
|
+
... on ProjectV2FieldCommon { id name dataType }
|
|
131
|
+
... on ProjectV2Field { isIssueField }
|
|
132
|
+
... on ProjectV2SingleSelectField { isIssueField options { id name color description } }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
workflows(first: 50) { nodes { id number fullDatabaseId name enabled } }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}`,
|
|
139
|
+
IssueRef: `query IssueRef($owner: String!, $name: String!, $number: Int!) {
|
|
140
|
+
repository(owner: $owner, name: $name) {
|
|
141
|
+
issue(number: $number) {
|
|
142
|
+
id number title state stateReason url
|
|
143
|
+
issueType { name }
|
|
144
|
+
labels(first: ${ISSUE_LABEL_LIMIT}) { nodes { name } }
|
|
145
|
+
parent { id number }
|
|
146
|
+
subIssues(first: 100) { nodes { number state title } }
|
|
147
|
+
blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } }
|
|
148
|
+
milestone { id number title issues(states: OPEN) { totalCount } }
|
|
149
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { number state merged headRefName baseRefName } }
|
|
150
|
+
projectItems(first: 20) { nodes { id project { id } status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } }
|
|
151
|
+
issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}`,
|
|
155
|
+
TaskText: `query TaskText($owner: String!, $name: String!, $number: Int!) {
|
|
156
|
+
repository(owner: $owner, name: $name) {
|
|
157
|
+
issue(number: $number) {
|
|
158
|
+
id number title state body
|
|
159
|
+
labels(first: 20) { nodes { id name } }
|
|
160
|
+
milestone { number title state issues(states: OPEN) { totalCount } }
|
|
161
|
+
blockedBy(first: 50) { nodes { id number state repository { nameWithOwner } } }
|
|
162
|
+
comments(last: 50) { nodes { body url } }
|
|
163
|
+
subIssues(first: 100) { nodes { number title state milestone { number title state issues(states: OPEN) { totalCount } } } }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}`,
|
|
167
|
+
TaskContext: `query TaskContext($owner: String!, $name: String!) {
|
|
168
|
+
repository(owner: $owner, name: $name) {
|
|
169
|
+
label(name: "${EPIC_LABEL}") { id }
|
|
170
|
+
milestones(first: 100, states: OPEN) { nodes { id title } }
|
|
171
|
+
}
|
|
172
|
+
}`,
|
|
173
|
+
SpecDecisions: `query SpecDecisions($owner: String!, $name: String!) {
|
|
174
|
+
repository(owner: $owner, name: $name) {
|
|
175
|
+
capabilities: object(expression: "HEAD:tests/capabilities") { ... on Tree { entries { name type } } }
|
|
176
|
+
standards: object(expression: "HEAD:tests/standards") { ... on Tree { entries { name type } } }
|
|
177
|
+
architecture: object(expression: "HEAD:tests/architecture") { ... on Tree { entries { name type } } }
|
|
178
|
+
model: object(expression: "HEAD:${MODEL_PATH}") { ... on Blob { text } }
|
|
179
|
+
}
|
|
180
|
+
}`,
|
|
181
|
+
LabelIssues: `query LabelIssues($owner: String!, $name: String!, $label: String!, $after: String) {
|
|
182
|
+
repository(owner: $owner, name: $name) {
|
|
183
|
+
label(name: $label) { issues(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } } }
|
|
184
|
+
}
|
|
185
|
+
}`,
|
|
186
|
+
RepoLabels: `query RepoLabels($owner: String!, $name: String!, $after: String) {
|
|
187
|
+
repository(owner: $owner, name: $name) {
|
|
188
|
+
labels(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id name color description issues(states: OPEN, first: ${OPEN_SHOWN}) { totalCount nodes { number } } } }
|
|
189
|
+
}
|
|
190
|
+
}`,
|
|
191
|
+
PrChange: `query PrChange($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
192
|
+
repository(owner: $owner, name: $name) {
|
|
193
|
+
pullRequest(number: $number) {
|
|
194
|
+
number headRefOid body
|
|
195
|
+
closingIssuesReferences(first: 20) { nodes { number } }
|
|
196
|
+
files(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path changeType additions deletions } }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}`,
|
|
200
|
+
ModelAt: `query ModelAt($owner: String!, $name: String!, $expression: String!) {
|
|
201
|
+
repository(owner: $owner, name: $name) { object(expression: $expression) { ... on Blob { text } } }
|
|
202
|
+
}`,
|
|
203
|
+
MergeRules: `query MergeRules($owner: String!, $name: String!) {
|
|
204
|
+
repository(owner: $owner, name: $name) {
|
|
205
|
+
isPrivate mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
|
|
206
|
+
defaultBranchRef {
|
|
207
|
+
name
|
|
208
|
+
branchProtectionRule { requiredStatusCheckContexts }
|
|
209
|
+
rules(first: 100) { nodes { type repositoryRuleset { name } parameters { ...StatusChecks } } }
|
|
210
|
+
}
|
|
211
|
+
rulesets(first: 100) {
|
|
212
|
+
nodes {
|
|
213
|
+
id name enforcement
|
|
214
|
+
conditions { refName { include } }
|
|
215
|
+
bypassActors(first: 50) { nodes { bypassMode repositoryRoleDatabaseId } }
|
|
216
|
+
rules(first: 100) { nodes { type parameters { ...StatusChecks } } }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
merged: pullRequests(states: MERGED, last: 30) { nodes { ...Head } }
|
|
220
|
+
open: pullRequests(states: OPEN, last: 30) { nodes { ...Head } }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
fragment StatusChecks on RuleParameters { ... on RequiredStatusChecksParameters { strictRequiredStatusChecksPolicy requiredStatusChecks { context } } }
|
|
224
|
+
fragment Head on PullRequest {
|
|
225
|
+
number baseRefName
|
|
226
|
+
${HEAD_CHECKS}
|
|
227
|
+
}`,
|
|
228
|
+
RecentPrs: `query RecentPrs($owner: String!, $name: String!) {
|
|
229
|
+
repository(owner: $owner, name: $name) {
|
|
230
|
+
defaultBranchRef { name }
|
|
231
|
+
pullRequests(states: [OPEN, MERGED], first: ${RECENT_PRS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number state merged headRefName baseRefName body } }
|
|
232
|
+
}
|
|
233
|
+
}`,
|
|
234
|
+
IssueShow: `query IssueShow($owner: String!, $name: String!, $number: Int!) {
|
|
235
|
+
repository(owner: $owner, name: $name) {
|
|
236
|
+
issue(number: $number) {
|
|
237
|
+
id number title state stateReason url body
|
|
238
|
+
issueType { name }
|
|
239
|
+
labels(first: 20) { nodes { id name } }
|
|
240
|
+
milestone { title }
|
|
241
|
+
parent { number title body subIssues(first: 100) { nodes { number state title } } }
|
|
242
|
+
subIssues(first: 100) { nodes { number state title } }
|
|
243
|
+
blockedBy(first: 20) { nodes { number state title repository { nameWithOwner } } }
|
|
244
|
+
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { ${PR_SHOW} } }
|
|
245
|
+
projectItems(first: 20) {
|
|
246
|
+
nodes {
|
|
247
|
+
project { title }
|
|
248
|
+
fieldValues(first: 50) {
|
|
249
|
+
nodes {
|
|
250
|
+
__typename
|
|
251
|
+
... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } }
|
|
252
|
+
... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { name } } }
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}`,
|
|
261
|
+
PrShow: `query PrShow($owner: String!, $name: String!, $number: Int!) {
|
|
262
|
+
repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_SHOW} } }
|
|
263
|
+
}`,
|
|
264
|
+
IssueSearch: `query IssueSearch($q: String!) {
|
|
265
|
+
search(query: $q, type: ISSUE, first: 100) { nodes { ... on Issue { number title state } } }
|
|
266
|
+
}`,
|
|
267
|
+
ProjectItems: `query ProjectItems($id: ID!, $after: String) {
|
|
268
|
+
node(id: $id) {
|
|
269
|
+
... on ProjectV2 {
|
|
270
|
+
items(first: 100, after: $after) {
|
|
271
|
+
pageInfo { hasNextPage endCursor }
|
|
272
|
+
nodes {
|
|
273
|
+
id
|
|
274
|
+
content { __typename ... on Issue { id number state stateReason } }
|
|
275
|
+
status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}`
|
|
281
|
+
};
|
|
282
|
+
var M = {
|
|
283
|
+
LinkProject: ["linkProjectV2ToRepository", "LinkProjectV2ToRepositoryInput", "repository { id }"],
|
|
284
|
+
UnlinkProject: ["unlinkProjectV2FromRepository", "UnlinkProjectV2FromRepositoryInput", "repository { id }"],
|
|
285
|
+
CopyProject: ["copyProjectV2", "CopyProjectV2Input", `projectV2 { ${REF} }`],
|
|
286
|
+
CreateProject: ["createProjectV2", "CreateProjectV2Input", `projectV2 { ${REF} }`],
|
|
287
|
+
RenameProject: ["updateProjectV2", "UpdateProjectV2Input", "projectV2 { id }"],
|
|
288
|
+
CreateView: ["createProjectV2View", "CreateProjectV2ViewInput", "projectV2View { id }"],
|
|
289
|
+
UpdateView: ["updateProjectV2View", "UpdateProjectV2ViewInput", "projectV2View { id }"],
|
|
290
|
+
DeleteView: ["deleteProjectV2View", "DeleteProjectV2ViewInput", "clientMutationId"],
|
|
291
|
+
CreateField: ["createProjectV2Field", "CreateProjectV2FieldInput", "clientMutationId"],
|
|
292
|
+
UpdateField: ["updateProjectV2Field", "UpdateProjectV2FieldInput", "clientMutationId"],
|
|
293
|
+
DeleteField: ["deleteProjectV2Field", "DeleteProjectV2FieldInput", "clientMutationId"],
|
|
294
|
+
AddIssueField: ["createProjectV2IssueField", "CreateProjectV2IssueFieldInput", "clientMutationId"],
|
|
295
|
+
SetItemStatus: ["updateProjectV2ItemFieldValue", "UpdateProjectV2ItemFieldValueInput", "projectV2Item { id }"],
|
|
296
|
+
AddItem: ["addProjectV2ItemById", "AddProjectV2ItemByIdInput", "item { id }"],
|
|
297
|
+
DeleteItem: ["deleteProjectV2Item", "DeleteProjectV2ItemInput", "deletedItemId"],
|
|
298
|
+
CreateIssueType: ["createIssueType", "CreateIssueTypeInput", "issueType { id }"],
|
|
299
|
+
UpdateIssueType: ["updateIssueType", "UpdateIssueTypeInput", "issueType { id }"],
|
|
300
|
+
CreateIssue: ["createIssue", "CreateIssueInput", "issue { id number title url projectItems(first: 10) { nodes { id project { id } } } }"],
|
|
301
|
+
CreateLabel: ["createLabel", "CreateLabelInput", "label { id }"],
|
|
302
|
+
UpdateLabel: ["updateLabel", "UpdateLabelInput", "label { id }"],
|
|
303
|
+
DeleteLabel: ["deleteLabel", "DeleteLabelInput", "clientMutationId"],
|
|
304
|
+
AddLabels: ["addLabelsToLabelable", "AddLabelsToLabelableInput", "clientMutationId"],
|
|
305
|
+
RemoveLabels: ["removeLabelsFromLabelable", "RemoveLabelsFromLabelableInput", "clientMutationId"],
|
|
306
|
+
AddBlockedBy: ["addBlockedBy", "AddBlockedByInput", "issue { id }"],
|
|
307
|
+
RemoveBlockedBy: ["removeBlockedBy", "RemoveBlockedByInput", "issue { id }"],
|
|
308
|
+
SetIssueField: ["setIssueFieldValue", "SetIssueFieldValueInput", "issue { id }"],
|
|
309
|
+
CloseIssue: ["closeIssue", "CloseIssueInput", "issue { id state }"],
|
|
310
|
+
UpdateIssue: ["updateIssue", "UpdateIssueInput", "issue { id }"],
|
|
311
|
+
AddComment: ["addComment", "AddCommentInput", "subject { id }"],
|
|
312
|
+
CreateRuleset: ["createRepositoryRuleset", "CreateRepositoryRulesetInput", "ruleset { id }"],
|
|
313
|
+
UpdateRuleset: ["updateRepositoryRuleset", "UpdateRepositoryRulesetInput", "ruleset { id }"]
|
|
314
|
+
};
|
|
315
|
+
var REST = {
|
|
316
|
+
UpdateRepository: ({ repo, ...fields }) => ["api", "-X", "PATCH", `repos/${repo}`, ...Object.entries(fields).flatMap(([k, v]) => ["-F", `${k}=${v}`])]
|
|
317
|
+
};
|
|
318
|
+
function mutationQuery(op) {
|
|
319
|
+
const spec = M[op];
|
|
320
|
+
if (!spec)
|
|
321
|
+
throw new GhError(`unknown mutation ${op}`);
|
|
322
|
+
const [field, inputType, select] = spec;
|
|
323
|
+
return `mutation ${op}($input: ${inputType}!) { ${field}(input: $input) { ${select} } }`;
|
|
324
|
+
}
|
|
325
|
+
function graphql(io, query, variables, strict = false) {
|
|
326
|
+
const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query, variables }));
|
|
327
|
+
let res;
|
|
328
|
+
try {
|
|
329
|
+
res = JSON.parse(out);
|
|
330
|
+
} catch {
|
|
331
|
+
throw new GhError(`gh api graphql: the response is not JSON: ${out.slice(0, 200)}`);
|
|
332
|
+
}
|
|
333
|
+
const errors = (res.errors ?? []).map((e) => e.message ?? String(e));
|
|
334
|
+
if (errors.length && (!res.data || strict))
|
|
335
|
+
throw new GhError(`GraphQL: ${errors.join("; ")}`);
|
|
336
|
+
if (errors.length)
|
|
337
|
+
io.err(`GraphQL warning: ${errors.join("; ")}`);
|
|
338
|
+
return res.data;
|
|
339
|
+
}
|
|
340
|
+
function mutate(io, m) {
|
|
341
|
+
const rest = REST[m.op];
|
|
342
|
+
return rest ? restCall(io, rest(m.input)) : graphql(io, mutationQuery(m.op), { input: m.input }, true);
|
|
343
|
+
}
|
|
344
|
+
function restCall(io, args) {
|
|
345
|
+
const what = args.slice(1).filter((a) => !a.startsWith("-")).slice(0, 2).join(" ");
|
|
346
|
+
const out = io.gh(args);
|
|
347
|
+
let res;
|
|
348
|
+
try {
|
|
349
|
+
res = JSON.parse(out);
|
|
350
|
+
} catch {
|
|
351
|
+
throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
|
|
352
|
+
}
|
|
353
|
+
if (res?.message && res.id === undefined)
|
|
354
|
+
throw new GhError(`${what}: ${res.message}`);
|
|
355
|
+
return res;
|
|
356
|
+
}
|
|
357
|
+
var inCloud = (io) => io.env.CLAUDE_CODE_REMOTE === "true";
|
|
358
|
+
var CLOUD_COMMANDS = ["task show", "task answer", "task new"];
|
|
359
|
+
function rest(io, route, o = {}) {
|
|
360
|
+
const what = `${o.method ?? "GET"} ${route}`;
|
|
361
|
+
const out = io.gh(["api", ...o.method ? ["-X", o.method] : [], route, ...o.payload === undefined ? [] : ["--input", "-"]], o.payload === undefined ? undefined : JSON.stringify(o.payload));
|
|
362
|
+
let res;
|
|
363
|
+
try {
|
|
364
|
+
res = out.trim() ? JSON.parse(out) : null;
|
|
365
|
+
} catch {
|
|
366
|
+
throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
|
|
367
|
+
}
|
|
368
|
+
if (res && !Array.isArray(res) && typeof res.message === "string" && res.id === undefined) {
|
|
369
|
+
if (o.missing && String(res.status) === "404")
|
|
370
|
+
return null;
|
|
371
|
+
throw new GhError(`${what}: ${res.message}`);
|
|
372
|
+
}
|
|
373
|
+
return res;
|
|
374
|
+
}
|
|
375
|
+
function restList(io, route) {
|
|
376
|
+
const out = [];
|
|
377
|
+
for (let page = 1;page <= 100; page++) {
|
|
378
|
+
const rows = rest(io, `${route}${route.includes("?") ? "&" : "?"}per_page=100&page=${page}`) ?? [];
|
|
379
|
+
out.push(...rows);
|
|
380
|
+
if (rows.length < 100)
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
var repoOfUrl = (url, slug) => /\/repos\/([^/]+\/[^/]+)$/.exec(url ?? "")?.[1] ?? slug;
|
|
386
|
+
function restIssueNode(i) {
|
|
387
|
+
return {
|
|
388
|
+
id: i.node_id,
|
|
389
|
+
databaseId: i.id,
|
|
390
|
+
number: i.number,
|
|
391
|
+
title: i.title,
|
|
392
|
+
state: String(i.state).toUpperCase(),
|
|
393
|
+
stateReason: i.state_reason ? String(i.state_reason).toUpperCase() : null,
|
|
394
|
+
url: i.html_url,
|
|
395
|
+
body: i.body ?? "",
|
|
396
|
+
issueType: i.type?.name ? { name: i.type.name } : null,
|
|
397
|
+
labels: { nodes: (i.labels ?? []).map((l) => ({ id: l.node_id, name: l.name })) },
|
|
398
|
+
milestone: i.milestone ? { id: i.milestone.node_id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.open_issues ?? 0 } : null
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function restIssue(io, slug, number) {
|
|
402
|
+
const i = rest(io, `repos/${slug}/issues/${number}`, { missing: true });
|
|
403
|
+
return i && !i.pull_request ? restIssueNode(i) : null;
|
|
404
|
+
}
|
|
405
|
+
var restBrief = (x, slug) => ({ 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) } });
|
|
406
|
+
var restSubIssues = (io, slug, number) => ({ nodes: restList(io, `repos/${slug}/issues/${number}/sub_issues`).map((x) => restBrief(x, slug)) });
|
|
407
|
+
function restLinks(io, slug, number) {
|
|
408
|
+
const parent = rest(io, `repos/${slug}/issues/${number}/parent`, { missing: true });
|
|
409
|
+
return {
|
|
410
|
+
subIssues: restSubIssues(io, slug, number),
|
|
411
|
+
blockedBy: { nodes: restList(io, `repos/${slug}/issues/${number}/dependencies/blocked_by`).map((x) => restBrief(x, slug)) },
|
|
412
|
+
parent: parent && restIssueNode(parent)
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function realGh(args, stdin) {
|
|
416
|
+
const r = spawnSync("gh", args, { input: stdin, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
417
|
+
if (r.error) {
|
|
418
|
+
if (r.error.code === "ENOENT")
|
|
419
|
+
throw new GhError("gh command not found; the gh CLI must be installed");
|
|
420
|
+
throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${r.error.message}`);
|
|
421
|
+
}
|
|
422
|
+
if (r.status !== 0 && !r.stdout.trim().startsWith("{"))
|
|
423
|
+
throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${(r.stderr || "").trim().slice(0, 500)}`);
|
|
424
|
+
return r.stdout;
|
|
425
|
+
}
|
|
426
|
+
function pages(fetch) {
|
|
427
|
+
const out = [];
|
|
428
|
+
let after = null;
|
|
429
|
+
for (let i = 0;i < 100; i++) {
|
|
430
|
+
const page = fetch(after);
|
|
431
|
+
out.push(...page.nodes);
|
|
432
|
+
if (!page.pageInfo.hasNextPage)
|
|
433
|
+
break;
|
|
434
|
+
after = page.pageInfo.endCursor;
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
var ref = (p) => ({ id: p.id, number: p.number, title: p.title, url: p.url, closed: !!p.closed });
|
|
439
|
+
function loadRepo(io, slug) {
|
|
440
|
+
const [owner, name] = slug.split("/");
|
|
441
|
+
const data = graphql(io, Q.RepoState, { owner, name });
|
|
442
|
+
const r = data?.repository;
|
|
443
|
+
if (!r)
|
|
444
|
+
throw new GhError(`repository ${slug} not found or no access`);
|
|
445
|
+
const o = r.owner;
|
|
446
|
+
return {
|
|
447
|
+
id: r.id,
|
|
448
|
+
name: r.name,
|
|
449
|
+
nameWithOwner: r.nameWithOwner,
|
|
450
|
+
owner: {
|
|
451
|
+
org: o.__typename === "Organization",
|
|
452
|
+
id: o.id,
|
|
453
|
+
login: o.login,
|
|
454
|
+
issueTypes: (o.issueTypes?.nodes ?? []).filter(Boolean),
|
|
455
|
+
issueFields: (o.issueFields?.nodes ?? []).filter((f) => f && f.name).map((f) => ({ id: f.id, name: f.name, options: f.options ?? [] }))
|
|
456
|
+
},
|
|
457
|
+
linked: (r.projectsV2?.nodes ?? []).filter(Boolean).map(ref)
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
var mainProject = (repo) => repo.linked.find((p) => p.title === repo.name && !p.closed) ?? repo.linked.find((p) => !p.closed) ?? null;
|
|
461
|
+
function loadState(io, slug) {
|
|
462
|
+
const [owner, name] = slug.split("/");
|
|
463
|
+
const repo = loadRepo(io, slug);
|
|
464
|
+
const openIssues = pages((after) => graphql(io, Q.RepoIssues, { owner, name, after }).repository.issues);
|
|
465
|
+
const main = mainProject(repo);
|
|
466
|
+
const labels = loadLabels(io, slug);
|
|
467
|
+
return { repo, project: main ? loadProject(io, main.id) : null, openIssues, labels, labelIssues: mergedIssues(io, slug, labels), decisions: loadDecisions(io, slug), merge: loadMerge(io, slug) };
|
|
468
|
+
}
|
|
469
|
+
var statusChecks = (rule) => rule?.type === "REQUIRED_STATUS_CHECKS" ? { strict: !!rule.parameters?.strictRequiredStatusChecksPolicy, contexts: (rule.parameters?.requiredStatusChecks ?? []).map((c) => c.context) } : null;
|
|
470
|
+
var WORST = ["fail", "cancel", "pending", "skipping", "pass"];
|
|
471
|
+
function checkStates(nodes) {
|
|
472
|
+
const app = (c) => JSON.stringify([c.name, c.checkSuite?.app?.databaseId ?? null]);
|
|
473
|
+
const last = new Map;
|
|
474
|
+
for (const c of nodes) {
|
|
475
|
+
if (c?.__typename === "CheckRun" && (!last.has(app(c)) || (last.get(app(c)).databaseId ?? 0) <= (c.databaseId ?? 0)))
|
|
476
|
+
last.set(app(c), c);
|
|
477
|
+
}
|
|
478
|
+
const out = new Map;
|
|
479
|
+
const put = (name, state) => {
|
|
480
|
+
const was = out.get(name);
|
|
481
|
+
if (!was || WORST.indexOf(state) < WORST.indexOf(was))
|
|
482
|
+
out.set(name, state);
|
|
483
|
+
};
|
|
484
|
+
for (const c of nodes) {
|
|
485
|
+
if (c?.__typename === "StatusContext")
|
|
486
|
+
put(c.context, c.state === "SUCCESS" ? "pass" : c.state === "PENDING" || c.state === "EXPECTED" ? "pending" : "fail");
|
|
487
|
+
if (c?.__typename !== "CheckRun" || last.get(app(c)) !== c)
|
|
488
|
+
continue;
|
|
489
|
+
const s = c.status !== "COMPLETED" ? "pending" : c.conclusion === "SUCCESS" ? "pass" : c.conclusion === "NEUTRAL" || c.conclusion === "SKIPPED" ? "skipping" : c.conclusion === "CANCELLED" ? "cancel" : "fail";
|
|
490
|
+
put(c.name, s);
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
var rollupOf = (pr) => pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
|
|
495
|
+
function rulesetsUnavailable(io, slug) {
|
|
496
|
+
const out = io.gh(["api", `repos/${slug}/rulesets`]);
|
|
497
|
+
let res;
|
|
498
|
+
try {
|
|
499
|
+
res = JSON.parse(out);
|
|
500
|
+
} catch {
|
|
501
|
+
throw new GhError(`gh api repos/${slug}/rulesets: the response is not JSON: ${out.slice(0, 200)}`);
|
|
502
|
+
}
|
|
503
|
+
return !Array.isArray(res) && /upgrade/i.test(res?.message ?? "") ? String(res.message) : null;
|
|
504
|
+
}
|
|
505
|
+
function loadMerge(io, slug) {
|
|
506
|
+
const [owner, name] = slug.split("/");
|
|
507
|
+
const r = graphql(io, Q.MergeRules, { owner, name })?.repository;
|
|
508
|
+
const b = r?.defaultBranchRef;
|
|
509
|
+
if (!b)
|
|
510
|
+
return null;
|
|
511
|
+
const heads = (nodes) => (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);
|
|
512
|
+
const bp = b.branchProtectionRule;
|
|
513
|
+
const rules = (b.rules?.nodes ?? []).flatMap((x) => {
|
|
514
|
+
const sc = statusChecks(x);
|
|
515
|
+
return sc ? [{ source: `ruleset ${q(x.repositoryRuleset?.name ?? "?")}`, contexts: sc.contexts }] : [];
|
|
516
|
+
});
|
|
517
|
+
if (bp?.requiredStatusCheckContexts?.length)
|
|
518
|
+
rules.push({ source: "branch protection", contexts: bp.requiredStatusCheckContexts });
|
|
519
|
+
const sets = r.rulesets?.nodes ?? [];
|
|
520
|
+
const o = sets.find((x) => x?.name === RULESET) ?? sets.find((x) => x?.name === FORMER_RULESET);
|
|
521
|
+
const osc = o ? (o.rules?.nodes ?? []).map(statusChecks).find(Boolean) ?? { strict: false, contexts: [] } : null;
|
|
522
|
+
return {
|
|
523
|
+
branch: b.name,
|
|
524
|
+
unavailable: r.isPrivate ? rulesetsUnavailable(io, slug) : null,
|
|
525
|
+
rules,
|
|
526
|
+
own: o ? {
|
|
527
|
+
id: o.id,
|
|
528
|
+
name: o.name,
|
|
529
|
+
enforcement: o.enforcement,
|
|
530
|
+
include: o.conditions?.refName?.include ?? [],
|
|
531
|
+
...osc,
|
|
532
|
+
bypassAdmin: (o.bypassActors?.nodes ?? []).some((a) => a?.repositoryRoleDatabaseId === ADMIN_ROLE && a.bypassMode === "ALWAYS")
|
|
533
|
+
} : null,
|
|
534
|
+
merged: heads(r.merged?.nodes).slice(-STABLE_PRS),
|
|
535
|
+
open: heads(r.open?.nodes).filter((h) => Object.values(h.checks).every((c) => c !== "pending")),
|
|
536
|
+
methods: { merge: !!r.mergeCommitAllowed, squash: !!r.squashMergeAllowed, rebase: !!r.rebaseMergeAllowed }
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function mergedIssues(io, slug, labels) {
|
|
540
|
+
const [owner, name] = slug.split("/");
|
|
541
|
+
const seen = new Set(labels.filter((l) => !legacyDecision(l.name)).map((l) => l.name));
|
|
542
|
+
const out = {};
|
|
543
|
+
for (const l of labels) {
|
|
544
|
+
const old = legacyDecision(l.name);
|
|
545
|
+
if (!old)
|
|
546
|
+
continue;
|
|
547
|
+
if (seen.has(old.name))
|
|
548
|
+
out[l.id] = pages((after) => graphql(io, Q.LabelIssues, { owner, name, label: l.name, after }).repository.label.issues);
|
|
549
|
+
seen.add(old.name);
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
function loadLabels(io, slug) {
|
|
554
|
+
if (inCloud(io))
|
|
555
|
+
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: [] } }));
|
|
556
|
+
const [owner, name] = slug.split("/");
|
|
557
|
+
return pages((after) => graphql(io, Q.RepoLabels, { owner, name, after }).repository.labels).map(({ issues, ...l }) => ({
|
|
558
|
+
...l,
|
|
559
|
+
open: { total: issues?.totalCount ?? 0, numbers: (issues?.nodes ?? []).map((i) => i.number) }
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
function modelModules(text) {
|
|
563
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "github-model-"));
|
|
564
|
+
try {
|
|
565
|
+
writeFileSync(path.join(dir, "model.ts"), text);
|
|
566
|
+
writeFileSync(path.join(dir, "modules.mjs"), `const m = await import("./model.ts");
|
|
567
|
+
` + `const paths = (p) => (Array.isArray(p) ? p : [p]).map((x) => String(x).replace(/\\/+$/, ""));
|
|
568
|
+
` + `console.log(JSON.stringify(Object.fromEntries(Object.entries(m.default?.modules ?? {}).map(([k, v]) => [k, paths(v?.path ?? [])]))));
|
|
569
|
+
`);
|
|
570
|
+
const r = spawnSync(process.execPath, ["--no-env-file", path.join(dir, "modules.mjs")], { encoding: "utf8", timeout: 30000 });
|
|
571
|
+
if (r.status !== 0)
|
|
572
|
+
return ((r.stderr || r.error?.message || "").trim().split(`
|
|
573
|
+
`).find((l) => /error/i.test(l)) ?? "the process exited with an error").trim().slice(0, 200);
|
|
574
|
+
return JSON.parse(r.stdout);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return e.message;
|
|
577
|
+
} finally {
|
|
578
|
+
rmSync(dir, { recursive: true, force: true });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function loadDecisions(io, slug) {
|
|
582
|
+
const [owner, name] = slug.split("/");
|
|
583
|
+
const r = inCloud(io) ? restSpecTree(io, slug) : graphql(io, Q.SpecDecisions, { owner, name })?.repository ?? {};
|
|
584
|
+
const dirs = (t) => (t?.entries ?? []).filter((e) => e.type === "tree").map((e) => e.name);
|
|
585
|
+
const names = { capability: dirs(r.capabilities), standard: dirs(r.standards), architecture: dirs(r.architecture) };
|
|
586
|
+
let modelError = null;
|
|
587
|
+
if (r.model) {
|
|
588
|
+
const mods = modelModules(r.model.text ?? "");
|
|
589
|
+
if (typeof mods === "string")
|
|
590
|
+
modelError = mods;
|
|
591
|
+
else
|
|
592
|
+
names.architecture.push(...Object.keys(mods));
|
|
593
|
+
}
|
|
594
|
+
for (const k of DECISION_KINDS)
|
|
595
|
+
names[k] = [...new Set(names[k])].sort();
|
|
596
|
+
return { names, modelError };
|
|
597
|
+
}
|
|
598
|
+
function restSpecTree(io, slug) {
|
|
599
|
+
const dir = (kind) => {
|
|
600
|
+
const entries = rest(io, `repos/${slug}/contents/tests/${DECISIONS[kind].dir}`, { missing: true });
|
|
601
|
+
return Array.isArray(entries) ? { entries: entries.map((e) => ({ name: e.name, type: e.type === "dir" ? "tree" : "blob" })) } : null;
|
|
602
|
+
};
|
|
603
|
+
const model = rest(io, `repos/${slug}/contents/${MODEL_PATH}`, { missing: true });
|
|
604
|
+
return { capabilities: dir("capability"), standards: dir("standard"), architecture: dir("architecture"), model: model?.content ? { text: Buffer.from(model.content, "base64").toString("utf8") } : null };
|
|
605
|
+
}
|
|
606
|
+
function legacyDecision(label) {
|
|
607
|
+
const i = label.indexOf(":");
|
|
608
|
+
const kind = label.slice(0, i);
|
|
609
|
+
return i > 0 && DECISION_KINDS.includes(kind) && label.length > i + 1 ? { kind, name: label.slice(i + 1) } : null;
|
|
610
|
+
}
|
|
611
|
+
var decisionMark = () => `${N.labelMarkers.decision} `;
|
|
612
|
+
var decisionPath = (kind, name) => kind === "architecture" ? locale_default.fill(N.labelMarkers.architecturePath, { name }) : `tests/${DECISIONS[kind].dir}/${name}`;
|
|
613
|
+
var markOf = (description, marks) => marks.map((m) => `${m} `).find((m) => description?.startsWith(m)) ?? null;
|
|
614
|
+
function decisionLabel(name, kinds) {
|
|
615
|
+
const ks = DECISION_KINDS.filter((k) => kinds.includes(k));
|
|
616
|
+
const full = decisionMark() + ks.map((k) => decisionPath(k, name)).join("; ");
|
|
617
|
+
return { name, color: DECISIONS[ks[0]].color, description: full.length <= 100 ? full : decisionMark() + ks.join(", ") };
|
|
618
|
+
}
|
|
619
|
+
function labelKinds(description) {
|
|
620
|
+
const mark = markOf(description, locale_default.every((n) => n.labelMarkers.decision));
|
|
621
|
+
if (!mark)
|
|
622
|
+
return null;
|
|
623
|
+
const parts = description.slice(mark.length).split(/; |, /);
|
|
624
|
+
return DECISION_KINDS.filter((k) => parts.some((p) => p === k || p.startsWith(`tests/${DECISIONS[k].dir}`)));
|
|
625
|
+
}
|
|
626
|
+
function labelDimension(description) {
|
|
627
|
+
const mark = markOf(description, locale_default.every((n) => n.labelMarkers.slice));
|
|
628
|
+
if (!mark)
|
|
629
|
+
return null;
|
|
630
|
+
return description.slice(mark.length).split(" — ")[0].trim() || null;
|
|
631
|
+
}
|
|
632
|
+
function decisionsByName(d) {
|
|
633
|
+
const out = new Map;
|
|
634
|
+
for (const k of DECISION_KINDS)
|
|
635
|
+
for (const n of d.names[k])
|
|
636
|
+
out.set(n, [...out.get(n) ?? [], k]);
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
function loadProject(io, id, withItems = true) {
|
|
640
|
+
const p = graphql(io, Q.ProjectState, { id }).node;
|
|
641
|
+
if (!p)
|
|
642
|
+
throw new GhError(`project ${id} not found or no access (needs the project scope: gh auth refresh -s project)`);
|
|
643
|
+
const items = withItems ? pages((after) => graphql(io, Q.ProjectItems, { id, after }).node.items) : [];
|
|
644
|
+
return {
|
|
645
|
+
...ref(p),
|
|
646
|
+
views: p.views.nodes.map((v) => ({
|
|
647
|
+
id: v.id,
|
|
648
|
+
number: v.number,
|
|
649
|
+
name: v.name,
|
|
650
|
+
layout: v.layout,
|
|
651
|
+
filter: v.filter ?? null,
|
|
652
|
+
sortBy: (v.sortByFields?.nodes ?? []).map((s) => s.field?.name).filter(Boolean),
|
|
653
|
+
columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f) => f?.name).filter(Boolean)
|
|
654
|
+
})),
|
|
655
|
+
fields: p.fields.nodes.filter((f) => f && f.id).map((f) => ({ id: f.id, name: f.name, dataType: f.dataType, isIssueField: !!f.isIssueField, options: f.options ?? null })),
|
|
656
|
+
workflows: p.workflows.nodes.filter(Boolean),
|
|
657
|
+
items: items.map((it) => ({
|
|
658
|
+
id: it.id,
|
|
659
|
+
issue: it.content?.__typename === "Issue" ? { id: it.content.id, number: it.content.number, state: it.content.state, stateReason: it.content.stateReason ?? null } : null,
|
|
660
|
+
status: it.status?.name ?? null
|
|
661
|
+
}))
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
var nums = (xs) => xs.map((x) => `#${x.number}`).join(", ");
|
|
665
|
+
var q = (s) => `«${s}»`;
|
|
666
|
+
function analyze(s) {
|
|
667
|
+
const p = s.project;
|
|
668
|
+
const checks = [];
|
|
669
|
+
const add = (key, title) => {
|
|
670
|
+
const c = { key, title, problems: [], notes: [] };
|
|
671
|
+
checks.push(c);
|
|
672
|
+
return Object.assign((text, ...steps) => c.problems.push({ text, steps }), { note: (text) => c.notes.push(text), skip: (why) => void (c.skip = why) });
|
|
673
|
+
};
|
|
674
|
+
const link = add("link", `Project linked to the repository and named ${q(s.repo.name)}`);
|
|
675
|
+
const merge = () => {
|
|
676
|
+
if (!s.merge)
|
|
677
|
+
return;
|
|
678
|
+
analyzeMerge(s.merge, s.repo.id, add("merge", `Merge into ${s.merge.branch} — only with green required checks`));
|
|
679
|
+
analyzeMethod(s.merge, s.repo.nameWithOwner, add("method", `PR merge method — rebase only`));
|
|
680
|
+
};
|
|
681
|
+
if (!p) {
|
|
682
|
+
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}` });
|
|
683
|
+
merge();
|
|
684
|
+
return checks;
|
|
685
|
+
}
|
|
686
|
+
const used = p.items.length > 0;
|
|
687
|
+
const guarded = (text, ...mutations) => ({ kind: used ? "confirm" : "api", text, mutations });
|
|
688
|
+
const api = (text, ...mutations) => ({ kind: "api", text, mutations });
|
|
689
|
+
const ui = (text, url) => ({ kind: "ui", text, url });
|
|
690
|
+
if (p.title !== s.repo.name)
|
|
691
|
+
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 } }));
|
|
692
|
+
for (const other of s.repo.linked.filter((x) => x.id !== p.id)) {
|
|
693
|
+
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 } }] });
|
|
694
|
+
}
|
|
695
|
+
const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
|
|
696
|
+
const canonNames = new Set(canonViews().map((v) => v.name));
|
|
697
|
+
const spare = p.views.filter((v) => !canonNames.has(v.name));
|
|
698
|
+
for (const c of canonViews()) {
|
|
699
|
+
const v = p.views.find((x) => x.name === c.name);
|
|
700
|
+
if (!v) {
|
|
701
|
+
const cand = spare.find((x) => x.layout === c.layout);
|
|
702
|
+
if (cand) {
|
|
703
|
+
spare.splice(spare.indexOf(cand), 1);
|
|
704
|
+
views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
|
|
705
|
+
} else {
|
|
706
|
+
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 } }));
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
if (v.layout !== c.layout)
|
|
711
|
+
views(`${q(c.name)} — ${v.layout}`, api(`${q(c.name)}: layout ${c.layout}`, { op: "UpdateView", input: { viewId: v.id, layout: c.layout } }));
|
|
712
|
+
if (v.filter)
|
|
713
|
+
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: "" } }));
|
|
714
|
+
if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
|
|
715
|
+
views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
for (const v of spare)
|
|
719
|
+
views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
|
|
720
|
+
const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
|
|
721
|
+
const sf = p.fields.find((f) => f.name === STATUS && f.options);
|
|
722
|
+
if (!sf) {
|
|
723
|
+
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: "" })) } }));
|
|
724
|
+
} else if (sf.options.map((o) => o.name).join(`
|
|
725
|
+
`) !== statusOptions().join(`
|
|
726
|
+
`)) {
|
|
727
|
+
const left = [...sf.options];
|
|
728
|
+
const take = (pred) => {
|
|
729
|
+
const i = left.findIndex(pred);
|
|
730
|
+
return i < 0 ? null : left.splice(i, 1)[0];
|
|
731
|
+
};
|
|
732
|
+
const aliases = statusAliases();
|
|
733
|
+
const next = statusOptions().map((name) => {
|
|
734
|
+
const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
|
|
735
|
+
return o ? { id: o.id, name, color: o.color, description: o.description ?? "" } : { name, color: "GRAY", description: "" };
|
|
736
|
+
});
|
|
737
|
+
const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
|
|
738
|
+
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 } }));
|
|
739
|
+
}
|
|
740
|
+
const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
|
|
741
|
+
const fieldAlias = fieldAliases();
|
|
742
|
+
const renamed = new Set;
|
|
743
|
+
for (const name of numberFields()) {
|
|
744
|
+
const f = p.fields.find((x) => x.name === name);
|
|
745
|
+
const create = { op: "CreateField", input: { projectId: p.id, dataType: "NUMBER", name } };
|
|
746
|
+
const other = f ? undefined : p.fields.find((x) => x.dataType === "NUMBER" && !renamed.has(x) && fieldAlias[x.name] === name);
|
|
747
|
+
if (other) {
|
|
748
|
+
renamed.add(other);
|
|
749
|
+
fields(`missing ${q(name)}`, guarded(`rename field ${q(other.name)} → ${q(name)} (task values are kept)`, { op: "UpdateField", input: { fieldId: other.id, name } }));
|
|
750
|
+
} else if (!f)
|
|
751
|
+
fields(`missing ${q(name)}`, api(`create field ${q(name)} (Number)`, create));
|
|
752
|
+
else if (f.dataType !== "NUMBER")
|
|
753
|
+
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] });
|
|
754
|
+
}
|
|
755
|
+
const orgPriority = s.repo.owner.org ? s.repo.owner.issueFields.find((f) => f.name === PRIORITY) : undefined;
|
|
756
|
+
const isCanon = (f) => BUILTIN_FIELDS.has(f.dataType) || f.name === STATUS || numberFields().includes(f.name) || s.repo.owner.org && f.isIssueField && f.name === PRIORITY;
|
|
757
|
+
const ownPriority = p.fields.find((f) => f.name === PRIORITY && !f.isIssueField);
|
|
758
|
+
for (const f of p.fields.filter((x) => !isCanon(x) && x !== ownPriority && !renamed.has(x))) {
|
|
759
|
+
fields(`extra ${q(f.name)} (${f.dataType}${f.isIssueField ? ", issue field" : ""})`, guarded(`delete field ${q(f.name)}`, { op: "DeleteField", input: { fieldId: f.id } }));
|
|
760
|
+
}
|
|
761
|
+
if (ownPriority && !s.repo.owner.org)
|
|
762
|
+
fields(`extra ${q(PRIORITY)} — a personal account keeps no priority`, guarded(`delete field ${q(PRIORITY)}`, { op: "DeleteField", input: { fieldId: ownPriority.id } }));
|
|
763
|
+
if (s.repo.owner.org) {
|
|
764
|
+
const org = s.repo.owner.login;
|
|
765
|
+
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`);
|
|
766
|
+
const connected = p.fields.some((f) => f.isIssueField && f.name === PRIORITY);
|
|
767
|
+
if (!connected) {
|
|
768
|
+
const connect = orgPriority ? [{ op: "AddIssueField", input: { projectId: p.id, issueFieldId: orgPriority.id } }] : [];
|
|
769
|
+
if (ownPriority) {
|
|
770
|
+
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] });
|
|
771
|
+
} else if (orgPriority) {
|
|
772
|
+
prio(`issue field ${PRIORITY} not connected`, api(`connect the issue field ${PRIORITY}`, ...connect));
|
|
773
|
+
}
|
|
774
|
+
if (!orgPriority)
|
|
775
|
+
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`));
|
|
776
|
+
}
|
|
777
|
+
for (const name of [N.views.table, N.views.board]) {
|
|
778
|
+
const v = p.views.find((x) => x.name === name);
|
|
779
|
+
if (v && v.sortBy[0] !== PRIORITY)
|
|
780
|
+
prio(`${q(name)} not sorted by ${PRIORITY}`, ui(`${q(name)}: View options → Sort by → ${PRIORITY} → Save`, `${p.url}/views/${v.number}`));
|
|
781
|
+
}
|
|
782
|
+
const types = add("types", `Issue types of the organization: ${issueTypes().join(", ")} — and only these`);
|
|
783
|
+
const t = s.repo.owner.issueTypes;
|
|
784
|
+
const url = `https://github.com/organizations/${org}/settings/issue-types`;
|
|
785
|
+
const claimed = new Set;
|
|
786
|
+
const aliases = typeAliases();
|
|
787
|
+
for (const name of issueTypes()) {
|
|
788
|
+
const exact = t.find((x) => x.name === name);
|
|
789
|
+
if (exact) {
|
|
790
|
+
claimed.add(exact.id);
|
|
791
|
+
if (!exact.isEnabled)
|
|
792
|
+
types(`${q(name)} disabled`, { kind: "confirm", text: `enable type ${q(name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: exact.id, isEnabled: true } }] });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const alias = t.find((x) => !claimed.has(x.id) && aliases[x.name.toLowerCase()] === name);
|
|
796
|
+
if (alias) {
|
|
797
|
+
claimed.add(alias.id);
|
|
798
|
+
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 } }] });
|
|
799
|
+
} else {
|
|
800
|
+
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" } }] });
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
for (const x of t.filter((x) => x.isEnabled && !claimed.has(x.id))) {
|
|
804
|
+
types(`extra ${q(x.name)}`, { kind: "confirm", text: `disable type ${q(x.name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: x.id, isEnabled: false } }] });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const wf = add("workflows", `Workflows ${[WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD].map(q).join(", ")} are on`);
|
|
808
|
+
const how = {
|
|
809
|
+
[WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
|
|
810
|
+
[WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
|
|
811
|
+
[WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`
|
|
812
|
+
};
|
|
813
|
+
for (const name of [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD]) {
|
|
814
|
+
const w = p.workflows.find((x) => x.name === name);
|
|
815
|
+
if (w?.enabled)
|
|
816
|
+
continue;
|
|
817
|
+
wf(`${q(name)} ${w ? "disabled" : "not configured"}`, ui(`${q(name)}: ${how[name]} → Save and turn on workflow`, `${p.url}/workflows${w ? `/${w.fullDatabaseId}` : ""}`));
|
|
818
|
+
}
|
|
819
|
+
const opt = (name) => sf?.options?.find((o) => o.name === name)?.id;
|
|
820
|
+
const setStatus = (it, name) => ({ op: "SetItemStatus", input: { projectId: p.id, itemId: it.id, fieldId: sf.id, value: { singleSelectOptionId: opt(name) } } });
|
|
821
|
+
const statusReady = !!(opt(N.status.backlog) && opt(N.status.done));
|
|
822
|
+
const closedAdd = add("closed", `Closed tasks — in ${q(N.status.done)}, closed without being done — outside the project`);
|
|
823
|
+
const issues = p.items.filter((it) => it.issue);
|
|
824
|
+
const doneAlias = statusAliases();
|
|
825
|
+
const notDone = issues.filter((it) => it.issue.state === "CLOSED" && !NOT_DONE_REASONS.has(it.issue.stateReason ?? "") && doneAlias[(it.status ?? "").toLowerCase()] !== N.status.done);
|
|
826
|
+
if (notDone.length) {
|
|
827
|
+
const steps = [];
|
|
828
|
+
if (statusReady)
|
|
829
|
+
steps.push(api(`${STATUS} ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue))}`, ...notDone.map((it) => setStatus(it, N.status.done))));
|
|
830
|
+
const closed = p.workflows.find((x) => x.name === WORKFLOW_CLOSED);
|
|
831
|
+
if (closed?.enabled)
|
|
832
|
+
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 });
|
|
833
|
+
closedAdd(`not in ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue))}`, ...steps);
|
|
834
|
+
}
|
|
835
|
+
const dropped = issues.filter((it) => it.issue.state === "CLOSED" && NOT_DONE_REASONS.has(it.issue.stateReason ?? ""));
|
|
836
|
+
if (dropped.length)
|
|
837
|
+
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 } }))));
|
|
838
|
+
const open = add("open", `Open tasks of the repository — in the project, with ${STATUS}`);
|
|
839
|
+
const inProject = new Set(issues.map((it) => it.issue.id));
|
|
840
|
+
const missing = s.openIssues.filter((i) => !inProject.has(i.id));
|
|
841
|
+
if (missing.length)
|
|
842
|
+
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 } }))));
|
|
843
|
+
const noStatus = issues.filter((it) => it.issue.state === "OPEN" && !it.status);
|
|
844
|
+
if (noStatus.length)
|
|
845
|
+
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)))] : []);
|
|
846
|
+
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");
|
|
847
|
+
const d = s.decisions;
|
|
848
|
+
if (d.modelError)
|
|
849
|
+
labels(`model ${MODEL_PATH} doesn't load — module labels aren't checked: ${d.modelError}`);
|
|
850
|
+
const want = decisionsByName(d);
|
|
851
|
+
const unsure = (kinds) => !!d.modelError && kinds.includes("architecture");
|
|
852
|
+
const taken = new Map(s.labels.filter((l) => !legacyDecision(l.name)).map((l) => [l.name, l]));
|
|
853
|
+
const orphans = [];
|
|
854
|
+
const dimensions = new Map;
|
|
855
|
+
for (const l of s.labels) {
|
|
856
|
+
const old = legacyDecision(l.name);
|
|
857
|
+
if (!old)
|
|
858
|
+
continue;
|
|
859
|
+
const to = decisionLabel(old.name, want.get(old.name) ?? [old.kind]);
|
|
860
|
+
const into = taken.get(old.name);
|
|
861
|
+
if (!into) {
|
|
862
|
+
taken.set(old.name, { ...l, ...to });
|
|
863
|
+
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 } }));
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const issues = s.labelIssues[l.id] ?? [];
|
|
867
|
+
labels(`label ${q(l.name)} of the old form, label ${q(old.name)} already exists`, {
|
|
868
|
+
kind: "confirm",
|
|
869
|
+
text: `merge label ${q(l.name)} into ${q(old.name)}: ${issues.length ? `${nums(issues)} get ${q(old.name)}, ` : ""}the old one is deleted`,
|
|
870
|
+
mutations: [...issues.map((i) => ({ op: "AddLabels", input: { labelableId: i.id, labelIds: [into.id] } })), { op: "DeleteLabel", input: { id: l.id } }]
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
for (const l of s.labels) {
|
|
874
|
+
if (legacyDecision(l.name))
|
|
875
|
+
continue;
|
|
876
|
+
const kinds = labelKinds(l.description);
|
|
877
|
+
const w = want.get(l.name);
|
|
878
|
+
if (!w) {
|
|
879
|
+
if (kinds)
|
|
880
|
+
orphans.push({ label: l, kinds });
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
const dim = labelDimension(l.description);
|
|
884
|
+
if (dim) {
|
|
885
|
+
dimensions.set(dim, [...dimensions.get(dim) ?? [], l.name]);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const to = decisionLabel(l.name, w);
|
|
889
|
+
if (!kinds) {
|
|
890
|
+
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 } }] });
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
const recolor = l.color.toLowerCase() !== to.color.toLowerCase();
|
|
894
|
+
if (!recolor && (l.description === to.description || unsure(kinds)))
|
|
895
|
+
continue;
|
|
896
|
+
const text = recolor ? `label ${q(l.name)} is colored ${l.color}, not ${to.color}` : `label ${q(l.name)}: wrong decision kinds in the description`;
|
|
897
|
+
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 } }));
|
|
898
|
+
}
|
|
899
|
+
for (const [dim, names] of dimensions) {
|
|
900
|
+
labels.note(`slice ${q(dim)}: labels ${names.map(q).join(", ")} aren't decision labels, decisions of the same name aren't labeled`);
|
|
901
|
+
}
|
|
902
|
+
const unlabeled = [...want.keys()].filter((n) => !taken.has(n));
|
|
903
|
+
const sure = orphans.filter((o) => !unsure(o.kinds));
|
|
904
|
+
for (const { label: l } of sure.filter((o) => o.label.open.total)) {
|
|
905
|
+
const more = l.open.total - l.open.numbers.length;
|
|
906
|
+
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` : ""}`);
|
|
907
|
+
}
|
|
908
|
+
const live = sure.filter((o) => !o.label.open.total);
|
|
909
|
+
const paired = new Set;
|
|
910
|
+
for (const kind of DECISION_KINDS) {
|
|
911
|
+
const os2 = live.filter((o) => o.kinds.includes(kind) && !paired.has(o.label.id));
|
|
912
|
+
const ms = unlabeled.filter((n) => want.get(n).includes(kind) && !paired.has(n));
|
|
913
|
+
if (os2.length !== 1 || ms.length !== 1)
|
|
914
|
+
continue;
|
|
915
|
+
const o = os2[0].label;
|
|
916
|
+
const to = decisionLabel(ms[0], want.get(ms[0]));
|
|
917
|
+
paired.add(o.id).add(to.name);
|
|
918
|
+
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 } }] });
|
|
919
|
+
}
|
|
920
|
+
for (const { label: o } of live.filter((x) => !paired.has(x.label.id))) {
|
|
921
|
+
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 } }] });
|
|
922
|
+
}
|
|
923
|
+
const fresh = unlabeled.filter((n) => !paired.has(n)).map((n) => decisionLabel(n, want.get(n)));
|
|
924
|
+
if (fresh.length) {
|
|
925
|
+
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 } }))));
|
|
926
|
+
}
|
|
927
|
+
merge();
|
|
928
|
+
return checks;
|
|
929
|
+
}
|
|
930
|
+
function rulesetInput(contexts) {
|
|
931
|
+
return {
|
|
932
|
+
name: RULESET,
|
|
933
|
+
target: "BRANCH",
|
|
934
|
+
enforcement: "ACTIVE",
|
|
935
|
+
conditions: { refName: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
936
|
+
rules: [{ type: "REQUIRED_STATUS_CHECKS", parameters: { requiredStatusChecks: { requiredStatusChecks: contexts.map((context) => ({ context })), strictRequiredStatusChecksPolicy: false } } }],
|
|
937
|
+
bypassActors: [{ repositoryRoleDatabaseId: ADMIN_ROLE, bypassMode: "ALWAYS" }]
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function analyzeMerge(m, repositoryId, merge) {
|
|
941
|
+
if (m.unavailable)
|
|
942
|
+
return merge.skip(`branch rules are unavailable: ${m.unavailable}`);
|
|
943
|
+
const fresh = [...m.merged.slice(-1), ...m.open];
|
|
944
|
+
const gone = (c) => fresh.find((h) => !(c in h.checks));
|
|
945
|
+
const stable = Object.keys(m.merged[0]?.checks ?? {}).filter((c) => m.merged.every((h) => h.checks[c] === "pass") && !gone(c)).sort();
|
|
946
|
+
const enforced = m.rules.some((r) => r.contexts.length);
|
|
947
|
+
const list = (cs) => cs.map(q).join(", ");
|
|
948
|
+
const none = `the last merged PRs have no common green check — nothing to make required`;
|
|
949
|
+
const own = m.own;
|
|
950
|
+
if (!own) {
|
|
951
|
+
if (enforced)
|
|
952
|
+
return;
|
|
953
|
+
if (!stable.length)
|
|
954
|
+
return merge.skip(none);
|
|
955
|
+
const text = `create ruleset ${q(RULESET)} on ${m.branch}: required ${list(stable)}, bypass — admin`;
|
|
956
|
+
const step = { kind: "api", text, mutations: [{ op: "CreateRuleset", input: { sourceId: repositoryId, ...rulesetInput(stable) } }] };
|
|
957
|
+
return merge(`no rule with required checks: a branch with a red check can be merged`, step);
|
|
958
|
+
}
|
|
959
|
+
const want = [...new Set([...own.contexts.filter((c) => !gone(c)), ...stable])].sort();
|
|
960
|
+
const named = own.name === RULESET ? q(RULESET) : `${q(own.name)} → ${q(RULESET)}`;
|
|
961
|
+
const step = { kind: "api", text: `ruleset ${named}: default branch, required ${list(want) || "—"}, bypass — admin`, mutations: [{ op: "UpdateRuleset", input: { repositoryRulesetId: own.id, ...rulesetInput(want) } }] };
|
|
962
|
+
const why = [];
|
|
963
|
+
if (own.name !== RULESET)
|
|
964
|
+
why.push(`ruleset ${q(own.name)} — the flow's former name: fix renames it to ${q(RULESET)}`);
|
|
965
|
+
if (own.enforcement !== "ACTIVE")
|
|
966
|
+
why.push(`ruleset ${q(own.name)} — ${own.enforcement}, not in effect`);
|
|
967
|
+
if (!own.include.includes("~DEFAULT_BRANCH"))
|
|
968
|
+
why.push(`ruleset ${q(own.name)} is not on the default branch: ${own.include.join(", ") || "—"}`);
|
|
969
|
+
if (own.strict)
|
|
970
|
+
why.push(`ruleset ${q(own.name)}: strict is on — GitHub won't merge a branch that fell behind`);
|
|
971
|
+
if (!own.bypassAdmin)
|
|
972
|
+
why.push(`ruleset ${q(own.name)}: admin can't bypass — no direct push to ${m.branch}`);
|
|
973
|
+
for (const c of own.contexts.filter((x) => gone(x)))
|
|
974
|
+
why.push(`required ${q(c)} didn't come to PR #${gone(c).number} — the PR would wait for it forever`);
|
|
975
|
+
const add = stable.filter((c) => !own.contexts.includes(c));
|
|
976
|
+
if (add.length)
|
|
977
|
+
why.push(`green on the last ${m.merged.length} merged PRs, but not required: ${list(add)}`);
|
|
978
|
+
for (const w of why)
|
|
979
|
+
merge(w, step);
|
|
980
|
+
if (!why.length && !enforced && !want.length)
|
|
981
|
+
merge.skip(none);
|
|
982
|
+
}
|
|
983
|
+
function analyzeMethod(m, slug, method) {
|
|
984
|
+
const step = {
|
|
985
|
+
kind: "api",
|
|
986
|
+
text: "keep only rebase: turn off merge commit and squash, turn on rebase",
|
|
987
|
+
url: `https://github.com/${slug}/settings`,
|
|
988
|
+
mutations: [{ op: "UpdateRepository", input: { repo: slug, allow_merge_commit: false, allow_squash_merge: false, allow_rebase_merge: true } }]
|
|
989
|
+
};
|
|
990
|
+
if (!m.methods.rebase)
|
|
991
|
+
method("rebase is off — GitHub will refuse gh pr merge --rebase", step);
|
|
992
|
+
if (m.methods.merge)
|
|
993
|
+
method("merge commit is allowed", step);
|
|
994
|
+
if (m.methods.squash)
|
|
995
|
+
method("squash is allowed", step);
|
|
996
|
+
}
|
|
997
|
+
function header(io, s) {
|
|
998
|
+
io.out(`Project ${s.repo.nameWithOwner}: ${s.project ? s.project.url : "none"}`);
|
|
999
|
+
}
|
|
1000
|
+
function report(io, s, checks) {
|
|
1001
|
+
for (const c of checks) {
|
|
1002
|
+
if (c.skip) {
|
|
1003
|
+
io.out(`➖ ${c.title} — ${c.skip}`);
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
io.out(`${c.problems.length ? "❌" : "✅"} ${c.title}`);
|
|
1007
|
+
for (const pr of c.problems)
|
|
1008
|
+
io.out(` · ${pr.text}`);
|
|
1009
|
+
for (const n of c.notes)
|
|
1010
|
+
io.out(` ○ ${n}`);
|
|
1011
|
+
}
|
|
1012
|
+
if (s.project && !s.repo.owner.org)
|
|
1013
|
+
io.out(`➖ ${PRIORITY} and issue types — a personal account has none`);
|
|
1014
|
+
return checks.every((c) => !c.problems.length);
|
|
1015
|
+
}
|
|
1016
|
+
function cmdCheck(io, slug) {
|
|
1017
|
+
const s = loadState(io, slug);
|
|
1018
|
+
header(io, s);
|
|
1019
|
+
return report(io, s, analyze(s)) ? 0 : 1;
|
|
1020
|
+
}
|
|
1021
|
+
function ensureProject(io, s, template) {
|
|
1022
|
+
const found = graphql(io, Q.OwnerProjects, { login: s.repo.owner.login, query: s.repo.name })?.repositoryOwner?.projectsV2?.nodes ?? [];
|
|
1023
|
+
const same = found.find((p) => p && p.title === s.repo.name && !p.closed);
|
|
1024
|
+
const link = (p) => mutate(io, { op: "LinkProject", input: { projectId: p.id, repositoryId: s.repo.id } });
|
|
1025
|
+
if (same) {
|
|
1026
|
+
link(same);
|
|
1027
|
+
return `linked project ${q(same.title)} (${same.url})`;
|
|
1028
|
+
}
|
|
1029
|
+
if (template) {
|
|
1030
|
+
const [login, number] = template.split("/");
|
|
1031
|
+
let tpl = null;
|
|
1032
|
+
try {
|
|
1033
|
+
tpl = graphql(io, Q.TemplateProject, { login, number: Number(number) })?.repositoryOwner?.projectV2 ?? null;
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
io.err(`template ${template} is unavailable: ${e.message}`);
|
|
1036
|
+
}
|
|
1037
|
+
if (tpl) {
|
|
1038
|
+
const copy = mutate(io, { op: "CopyProject", input: { projectId: tpl.id, ownerId: s.repo.owner.id, title: s.repo.name, includeDraftIssues: false } }).copyProjectV2.projectV2;
|
|
1039
|
+
link(copy);
|
|
1040
|
+
return `project ${q(s.repo.name)} — a copy of template ${template} (${copy.url}), linked`;
|
|
1041
|
+
}
|
|
1042
|
+
io.err(`template ${template} not found — the project is created from scratch`);
|
|
1043
|
+
}
|
|
1044
|
+
const created = mutate(io, { op: "CreateProject", input: { ownerId: s.repo.owner.id, title: s.repo.name, repositoryId: s.repo.id } }).createProjectV2.projectV2;
|
|
1045
|
+
return `created project ${q(s.repo.name)} (${created.url}), linked`;
|
|
1046
|
+
}
|
|
1047
|
+
function cmdFix(io, slug, opts) {
|
|
1048
|
+
const done = [];
|
|
1049
|
+
const failed = [];
|
|
1050
|
+
const tried = new Set;
|
|
1051
|
+
const sticky = [];
|
|
1052
|
+
let s = loadState(io, slug);
|
|
1053
|
+
for (let round = 0;round < 5; round++) {
|
|
1054
|
+
if (!s.project) {
|
|
1055
|
+
try {
|
|
1056
|
+
done.push(ensureProject(io, s, opts.template));
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
failed.push(`project: ${e.message}`);
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
s = loadState(io, slug);
|
|
1062
|
+
if (!s.project)
|
|
1063
|
+
break;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const all = analyze(s).flatMap((c) => c.problems.flatMap((pr) => pr.steps));
|
|
1067
|
+
sticky.push(...all.filter((st) => st.sticky));
|
|
1068
|
+
const steps = all.filter((st) => st.mutations?.length && (st.kind === "api" || st.kind === "confirm" && opts.confirm) && !tried.has(st.text));
|
|
1069
|
+
if (!steps.length)
|
|
1070
|
+
break;
|
|
1071
|
+
for (const st of steps) {
|
|
1072
|
+
if (tried.has(st.text))
|
|
1073
|
+
continue;
|
|
1074
|
+
tried.add(st.text);
|
|
1075
|
+
try {
|
|
1076
|
+
for (const m of st.mutations)
|
|
1077
|
+
mutate(io, m);
|
|
1078
|
+
done.push(st.text);
|
|
1079
|
+
} catch (e) {
|
|
1080
|
+
failed.push(`${st.text}: ${e.message}${st.url ? ` — in the UI: ${st.url}` : ""}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
s = loadState(io, slug);
|
|
1084
|
+
}
|
|
1085
|
+
header(io, s);
|
|
1086
|
+
for (const d of done)
|
|
1087
|
+
io.out(`+ ${d}`);
|
|
1088
|
+
for (const f of failed)
|
|
1089
|
+
io.out(`! ${f}`);
|
|
1090
|
+
const checks = analyze(s);
|
|
1091
|
+
const left = [...sticky, ...checks.flatMap((c) => c.problems.flatMap((pr) => pr.steps))];
|
|
1092
|
+
const uniq = (xs) => xs.filter((x, i) => xs.findIndex((y) => y.text === x.text) === i);
|
|
1093
|
+
const ui = uniq(left.filter((st) => st.kind === "ui"));
|
|
1094
|
+
const confirm = uniq(left.filter((st) => st.kind === "confirm" && !opts.confirm));
|
|
1095
|
+
if (ui.length) {
|
|
1096
|
+
io.out("UI steps — in the session's browser, without a browser — for the user; then check again:");
|
|
1097
|
+
ui.forEach((st, i) => io.out(`${i + 1}. ${st.text} — ${st.url}`));
|
|
1098
|
+
}
|
|
1099
|
+
if (confirm.length) {
|
|
1100
|
+
io.out("Confirmation needed from the user — then fix --confirm:");
|
|
1101
|
+
confirm.forEach((st, i) => io.out(`${i + 1}. ${st.text}${st.url ? ` (or in the UI: ${st.url})` : ""}`));
|
|
1102
|
+
}
|
|
1103
|
+
io.out("Summary:");
|
|
1104
|
+
const ok = report(io, s, checks);
|
|
1105
|
+
return ok && !failed.length && !ui.length && !confirm.length ? 0 : 1;
|
|
1106
|
+
}
|
|
1107
|
+
var blockerRef = (slug, b) => b.repo.toLowerCase() === slug.toLowerCase() ? `#${b.number}` : `${b.repo}#${b.number}`;
|
|
1108
|
+
function loadIssue(io, slug, number) {
|
|
1109
|
+
if (inCloud(io))
|
|
1110
|
+
return restLoadIssue(io, slug, number);
|
|
1111
|
+
const [owner, name] = slug.split("/");
|
|
1112
|
+
const i = graphql(io, Q.IssueRef, { owner, name, number })?.repository?.issue;
|
|
1113
|
+
if (!i)
|
|
1114
|
+
throw new GhError(`no issue #${number} in ${slug}`);
|
|
1115
|
+
return {
|
|
1116
|
+
id: i.id,
|
|
1117
|
+
number: i.number,
|
|
1118
|
+
title: i.title,
|
|
1119
|
+
state: i.state,
|
|
1120
|
+
stateReason: i.stateReason ?? null,
|
|
1121
|
+
url: i.url,
|
|
1122
|
+
type: i.issueType?.name ?? null,
|
|
1123
|
+
labels: (i.labels?.nodes ?? []).map((l) => l.name),
|
|
1124
|
+
parent: i.parent ? { id: i.parent.id, number: i.parent.number } : null,
|
|
1125
|
+
subIssues: i.subIssues?.nodes ?? [],
|
|
1126
|
+
blockers: (i.blockedBy?.nodes ?? []).map((b) => ({ number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
|
|
1127
|
+
milestone: i.milestone ? { id: i.milestone.id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.issues?.totalCount ?? 0 } : null,
|
|
1128
|
+
prs: (i.closedByPullRequestsReferences?.nodes ?? []).map((p) => ({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head: p.headRefName ?? "", base: p.baseRefName ?? "main" })),
|
|
1129
|
+
items: (i.projectItems?.nodes ?? []).map((it) => ({ id: it.id, projectId: it.project.id, status: it.status?.name ?? null })),
|
|
1130
|
+
priority: (i.issueFieldValues?.nodes ?? []).find((v) => v?.field?.name === PRIORITY)?.name ?? null
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function restLoadIssue(io, slug, number) {
|
|
1134
|
+
const i = restIssue(io, slug, number);
|
|
1135
|
+
if (!i)
|
|
1136
|
+
throw new GhError(`no issue #${number} in ${slug}`);
|
|
1137
|
+
const l = restLinks(io, slug, number);
|
|
1138
|
+
return {
|
|
1139
|
+
id: i.id,
|
|
1140
|
+
databaseId: i.databaseId,
|
|
1141
|
+
rest: `repos/${slug}/issues/${number}`,
|
|
1142
|
+
number: i.number,
|
|
1143
|
+
title: i.title,
|
|
1144
|
+
state: i.state,
|
|
1145
|
+
stateReason: i.stateReason,
|
|
1146
|
+
url: i.url,
|
|
1147
|
+
type: i.issueType?.name ?? null,
|
|
1148
|
+
labels: i.labels.nodes.map((x) => x.name),
|
|
1149
|
+
parent: l.parent ? { id: l.parent.id, number: l.parent.number } : null,
|
|
1150
|
+
subIssues: l.subIssues.nodes.map((x) => ({ number: x.number, state: x.state, title: x.title })),
|
|
1151
|
+
blockers: l.blockedBy.nodes.map((b) => ({ number: b.number, state: b.state, repo: b.repository.nameWithOwner })),
|
|
1152
|
+
milestone: i.milestone,
|
|
1153
|
+
prs: [],
|
|
1154
|
+
items: [],
|
|
1155
|
+
priority: null
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function taskContext(io, slug) {
|
|
1159
|
+
const repo = loadRepo(io, slug);
|
|
1160
|
+
const main = mainProject(repo);
|
|
1161
|
+
if (!main)
|
|
1162
|
+
throw new GhError(`no project is linked to ${slug} — github project fix first`);
|
|
1163
|
+
const project = loadProject(io, main.id, false);
|
|
1164
|
+
const status = project.fields.find((f) => f.name === STATUS && f.options);
|
|
1165
|
+
const onBoard = (n) => !!status && [n.status.backlog, n.status.inProgress, n.status.done].every((name) => status.options.some((o) => o.name === name));
|
|
1166
|
+
const board = onBoard(N) ? N : locale_default.everyNames().find(onBoard);
|
|
1167
|
+
if (!status || !board)
|
|
1168
|
+
throw new GhError(`the project's ${STATUS} is not per the canon — github project fix first`);
|
|
1169
|
+
N = board;
|
|
1170
|
+
return { slug, repo, project, status };
|
|
1171
|
+
}
|
|
1172
|
+
var isEpic = (i, org) => org ? locale_default.every((n) => n.issueTypes.epic).includes(i.type ?? "") : i.labels.includes(EPIC_LABEL);
|
|
1173
|
+
var itemIn = (ctx, i) => i.items.find((it) => it.projectId === ctx.project.id) ?? null;
|
|
1174
|
+
function setStatus(io, ctx, itemId, name) {
|
|
1175
|
+
const optionId = ctx.status.options.find((o) => o.name === name).id;
|
|
1176
|
+
mutate(io, { op: "SetItemStatus", input: { projectId: ctx.project.id, itemId, fieldId: ctx.status.id, value: { singleSelectOptionId: optionId } } });
|
|
1177
|
+
}
|
|
1178
|
+
function ensureItem(io, ctx, i) {
|
|
1179
|
+
const it = itemIn(ctx, i);
|
|
1180
|
+
if (it)
|
|
1181
|
+
return { id: it.id, added: false };
|
|
1182
|
+
try {
|
|
1183
|
+
return { id: mutate(io, { op: "AddItem", input: { projectId: ctx.project.id, contentId: i.id } }).addProjectV2ItemById.item.id, added: true };
|
|
1184
|
+
} catch (e) {
|
|
1185
|
+
if (!/already exists/i.test(e.message))
|
|
1186
|
+
throw e;
|
|
1187
|
+
}
|
|
1188
|
+
for (let k = 0;k < 5; k++) {
|
|
1189
|
+
(io.sleep ?? pause)(1000);
|
|
1190
|
+
const again = itemIn(ctx, loadIssue(io, ctx.slug, i.number));
|
|
1191
|
+
if (again)
|
|
1192
|
+
return { id: again.id, added: false };
|
|
1193
|
+
}
|
|
1194
|
+
throw new GhError(`#${i.number} is already in the project, but its item isn't visible — retry: github task status ${i.number} <status>`);
|
|
1195
|
+
}
|
|
1196
|
+
function configDir(env) {
|
|
1197
|
+
const home = env.HOME || os.homedir();
|
|
1198
|
+
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");
|
|
1199
|
+
if (existsSync(dir))
|
|
1200
|
+
return dir;
|
|
1201
|
+
const legacy = path.join(home, ".claude", "est");
|
|
1202
|
+
return lstatSync(legacy, { throwIfNoEntry: false })?.isDirectory() ? legacy : dir;
|
|
1203
|
+
}
|
|
1204
|
+
function pinFile(io) {
|
|
1205
|
+
const id = io.env.CLAUDE_CODE_SESSION_ID;
|
|
1206
|
+
return id ? path.join(configDir(io.env), "sessions", id) : null;
|
|
1207
|
+
}
|
|
1208
|
+
function sessionPin(text) {
|
|
1209
|
+
const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(text.trim());
|
|
1210
|
+
return m ? { slug: m[1], number: Number(m[2]) } : null;
|
|
1211
|
+
}
|
|
1212
|
+
function pinLine(slug, number) {
|
|
1213
|
+
return `${slug}#${number}
|
|
1214
|
+
`;
|
|
1215
|
+
}
|
|
1216
|
+
function pinnedTask(io) {
|
|
1217
|
+
const file = pinFile(io);
|
|
1218
|
+
if (!file || !existsSync(file))
|
|
1219
|
+
return null;
|
|
1220
|
+
const line = readFileSync(file, "utf8").trim();
|
|
1221
|
+
return { file, line, task: sessionPin(line) };
|
|
1222
|
+
}
|
|
1223
|
+
function queueQuestion(slug, number, epic) {
|
|
1224
|
+
return `the question to the owner «queue #${number}?» — yes: a background subagent in its own worktree, its prompt: ${executorPrompt(slug, number, epic)}`;
|
|
1225
|
+
}
|
|
1226
|
+
function anotherTask(runs, file, slug, number, epic) {
|
|
1227
|
+
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)`);
|
|
1228
|
+
}
|
|
1229
|
+
function pinTask(io, slug, number, epic) {
|
|
1230
|
+
const file = pinFile(io);
|
|
1231
|
+
if (!file)
|
|
1232
|
+
return io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — the task isn't pinned");
|
|
1233
|
+
const task = `${slug}#${number}`;
|
|
1234
|
+
const held = pinnedTask(io)?.task;
|
|
1235
|
+
const runs = held ? `${held.slug}#${held.number}` : null;
|
|
1236
|
+
if (runs === task)
|
|
1237
|
+
return;
|
|
1238
|
+
if (handedOut(io, slug).some((h) => h.number === number)) {
|
|
1239
|
+
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`);
|
|
1240
|
+
}
|
|
1241
|
+
if (runs)
|
|
1242
|
+
throw anotherTask(runs, file, slug, number, epic);
|
|
1243
|
+
if (isDispatcher(io)) {
|
|
1244
|
+
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`);
|
|
1245
|
+
}
|
|
1246
|
+
const created = (f, text) => {
|
|
1247
|
+
try {
|
|
1248
|
+
writeFileSync(f, text, { flag: "wx" });
|
|
1249
|
+
return true;
|
|
1250
|
+
} catch (e) {
|
|
1251
|
+
if (e?.code !== "EEXIST")
|
|
1252
|
+
throw e;
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1257
|
+
created(path.join(path.dirname(file), ".gitignore"), `*
|
|
1258
|
+
`);
|
|
1259
|
+
if (created(file, pinLine(slug, number)))
|
|
1260
|
+
return;
|
|
1261
|
+
const now = pinnedTask(io);
|
|
1262
|
+
if (!now.task) {
|
|
1263
|
+
writeFileSync(file, pinLine(slug, number));
|
|
1264
|
+
return io.out(`○ the session pin ${file} held «${now.line}», not owner/repo#N — replaced with ${task}`);
|
|
1265
|
+
}
|
|
1266
|
+
const pinnedNow = `${now.task.slug}#${now.task.number}`;
|
|
1267
|
+
if (pinnedNow !== task)
|
|
1268
|
+
throw anotherTask(pinnedNow, file, slug, number, epic);
|
|
1269
|
+
}
|
|
1270
|
+
function dispatcherFile(io) {
|
|
1271
|
+
const id = io.env.CLAUDE_CODE_SESSION_ID;
|
|
1272
|
+
return id ? path.join(configDir(io.env), "dispatchers", id) : null;
|
|
1273
|
+
}
|
|
1274
|
+
var isDispatcher = (io) => {
|
|
1275
|
+
const file = dispatcherFile(io);
|
|
1276
|
+
return !!file && existsSync(file);
|
|
1277
|
+
};
|
|
1278
|
+
var handedOutFile = (io) => {
|
|
1279
|
+
const file = dispatcherFile(io);
|
|
1280
|
+
return file && `${file}.tasks`;
|
|
1281
|
+
};
|
|
1282
|
+
var CLOUD_HANDOUT = "@cloud";
|
|
1283
|
+
function handedOut(io, slug) {
|
|
1284
|
+
const file = handedOutFile(io);
|
|
1285
|
+
return file ? handOutsIn(file, slug) : [];
|
|
1286
|
+
}
|
|
1287
|
+
function handOutsIn(file, slug) {
|
|
1288
|
+
if (!existsSync(file))
|
|
1289
|
+
return [];
|
|
1290
|
+
const lines = readFileSync(file, "utf8").split(`
|
|
1291
|
+
`).map((l) => l.trim());
|
|
1292
|
+
return lines.filter((l) => l.startsWith(`${slug}#`)).map((l) => {
|
|
1293
|
+
const [number, ...rest] = l.slice(slug.length + 1).split(" ");
|
|
1294
|
+
const worktree = rest.join(" ").trim();
|
|
1295
|
+
return { number: Number(number), worktree: worktree === CLOUD_HANDOUT ? null : worktree || null, cloud: worktree === CLOUD_HANDOUT };
|
|
1296
|
+
}).filter((h) => Number.isInteger(h.number) && h.number > 0);
|
|
1297
|
+
}
|
|
1298
|
+
function realPath(p) {
|
|
1299
|
+
try {
|
|
1300
|
+
return realpathSync(p);
|
|
1301
|
+
} catch {
|
|
1302
|
+
return path.resolve(p);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
function epicPrefix(title) {
|
|
1306
|
+
const i = title.indexOf(" · ");
|
|
1307
|
+
return i > 0 ? title.slice(0, i) : null;
|
|
1308
|
+
}
|
|
1309
|
+
var taskTemplate = (type) => type === N.issueTypes.epic ? `${N.sections.questions}
|
|
1310
|
+
` : `${N.sections.scenarios}
|
|
1311
|
+
|
|
1312
|
+
${N.sections.questions}
|
|
1313
|
+
`;
|
|
1314
|
+
var sameTitles = (title) => [title, ...locale_default.everyNames().filter((n) => n.updateTask === title).map((n) => n.formerly.updateTask)];
|
|
1315
|
+
function cmdTaskNew(io, slug, task) {
|
|
1316
|
+
const [owner, name] = slug.split("/");
|
|
1317
|
+
const cloud = inCloud(io);
|
|
1318
|
+
const ctx = cloud ? null : taskContext(io, slug);
|
|
1319
|
+
const org = ctx ? ctx.repo.owner.org : rest(io, `repos/${slug}`).owner?.type === "Organization";
|
|
1320
|
+
const type = typeAliases()[task.type.toLowerCase()];
|
|
1321
|
+
if (!type)
|
|
1322
|
+
throw new GhError(`--type: ${issueTypes().join(", ")}`);
|
|
1323
|
+
const branchType = task.branchType?.trim().toLowerCase() || (type === N.issueTypes.bug ? "fix" : "");
|
|
1324
|
+
if (type === N.issueTypes.epic) {
|
|
1325
|
+
if (branchType)
|
|
1326
|
+
throw new GhError("an epic has no branch and isn't estimated — --branch-type is for a task or a bug");
|
|
1327
|
+
} else if (!branchType)
|
|
1328
|
+
throw new GhError(`--branch-type is required for a task: ${TASK_TYPES.join(", ")} — the type of its branch and of its estimate`);
|
|
1329
|
+
else if (!TASK_TYPES.includes(branchType))
|
|
1330
|
+
throw new GhError(`--branch-type: ${TASK_TYPES.join(", ")}`);
|
|
1331
|
+
const o = { ...task, type, body: task.body.trim() ? task.body : taskTemplate(type) };
|
|
1332
|
+
const issueType = org && ctx ? ctx.repo.owner.issueTypes.find((t) => t.name === o.type && t.isEnabled) : undefined;
|
|
1333
|
+
if (org && ctx && !issueType)
|
|
1334
|
+
throw new GhError(`the organization has no enabled type ${q(o.type)} — github project fix`);
|
|
1335
|
+
if (!org && o.priority)
|
|
1336
|
+
throw new GhError("a personal account has no issue fields — no priority is kept, --priority isn't needed");
|
|
1337
|
+
if (org && !ctx && o.priority && !PRIORITIES.includes(o.priority))
|
|
1338
|
+
throw new GhError(`--priority: ${PRIORITIES.join(", ")}`);
|
|
1339
|
+
let priority = null;
|
|
1340
|
+
if (org && ctx) {
|
|
1341
|
+
const f = ctx.repo.owner.issueFields.find((x) => x.name === PRIORITY);
|
|
1342
|
+
if (!f)
|
|
1343
|
+
throw new GhError(`the organization has no issue field ${PRIORITY} — github project fix`);
|
|
1344
|
+
const want = o.priority ?? DEFAULT_PRIORITY;
|
|
1345
|
+
const opt = f.options.find((x) => x.name === want);
|
|
1346
|
+
if (!opt)
|
|
1347
|
+
throw new GhError(`--priority: ${f.options.map((x) => x.name).join(", ")}`);
|
|
1348
|
+
priority = { fieldId: f.id, optionId: opt.id, name: want };
|
|
1349
|
+
}
|
|
1350
|
+
let title = o.title.trim();
|
|
1351
|
+
const epic = o.epic === undefined ? null : loadIssue(io, slug, o.epic);
|
|
1352
|
+
if (epic) {
|
|
1353
|
+
if (!isEpic(epic, org))
|
|
1354
|
+
throw new GhError(`#${epic.number} is not an epic (${org ? `the type is not ${q(N.issueTypes.epic)}` : `no ${EPIC_LABEL} label`})`);
|
|
1355
|
+
if (epic.state !== "OPEN")
|
|
1356
|
+
throw new GhError(`epic #${epic.number} is closed`);
|
|
1357
|
+
const prefix = epicPrefix(epic.title);
|
|
1358
|
+
if (prefix && !title.startsWith(`${prefix} · `))
|
|
1359
|
+
title = `${prefix} · ${title}`;
|
|
1360
|
+
}
|
|
1361
|
+
const blockers = o.blockedBy.map((n) => loadIssue(io, slug, n));
|
|
1362
|
+
const needCtx = !!o.milestone || !org && o.type === N.issueTypes.epic;
|
|
1363
|
+
const restCtx = () => {
|
|
1364
|
+
const label = rest(io, `repos/${slug}/labels/${EPIC_LABEL}`, { missing: true });
|
|
1365
|
+
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 })) } };
|
|
1366
|
+
};
|
|
1367
|
+
const repoCtx = !needCtx ? null : cloud ? restCtx() : graphql(io, Q.TaskContext, { owner, name }).repository;
|
|
1368
|
+
const milestone = o.milestone ? repoCtx.milestones.nodes.find((m) => m.title === o.milestone) : undefined;
|
|
1369
|
+
if (o.milestone && !milestone)
|
|
1370
|
+
throw new GhError(`no open milestone ${q(o.milestone)}`);
|
|
1371
|
+
const openIssues = cloud ? restList(io, `repos/${slug}/issues?state=open`).filter((i) => !i.pull_request) : null;
|
|
1372
|
+
for (const same of sameTitles(title)) {
|
|
1373
|
+
const found = openIssues ?? graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open in:title "${same.replaceAll('"', "")}"` }).search.nodes;
|
|
1374
|
+
const dup = found.find((x) => x?.title === same);
|
|
1375
|
+
if (dup)
|
|
1376
|
+
throw new GhError(`an open task with this title already exists: #${dup.number}${same === title ? "" : ` (${q(same)} — its title before the flow's rename)`}`);
|
|
1377
|
+
}
|
|
1378
|
+
const wanted = [...new Set(o.labels ?? [])];
|
|
1379
|
+
const open = questionItems(o.body).filter((x) => !x.answered).map((x) => x.k);
|
|
1380
|
+
const repoLabels = wanted.length || open.length ? loadLabels(io, slug) : [];
|
|
1381
|
+
const has = (n) => repoLabels.find((x) => x.name === n);
|
|
1382
|
+
const decisions = wanted.some((l) => legacyDecision(l) || !has(l)) ? loadDecisions(io, slug) : null;
|
|
1383
|
+
const inMain = decisions ? decisionsByName(decisions) : new Map;
|
|
1384
|
+
const plan = wanted.map((l) => {
|
|
1385
|
+
const dec = legacyDecision(l);
|
|
1386
|
+
const name = dec?.name ?? l;
|
|
1387
|
+
const label = has(name);
|
|
1388
|
+
const main = inMain.get(name) ?? [];
|
|
1389
|
+
const kinds = dec && !main.includes(dec.kind) ? [...main, dec.kind] : main;
|
|
1390
|
+
const dim = dec && label ? labelDimension(label.description) : null;
|
|
1391
|
+
if (dim)
|
|
1392
|
+
throw new GhError(`label ${q(name)} is slice ${q(dim)}, not a decision label: decision ${name} isn't labeled; the slice label — --labels ${name}`);
|
|
1393
|
+
if (!label && !kinds.length) {
|
|
1394
|
+
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(", ")})`);
|
|
1395
|
+
}
|
|
1396
|
+
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 };
|
|
1397
|
+
});
|
|
1398
|
+
const done = [];
|
|
1399
|
+
const createLabel = (label) => ctx ? mutate(io, { op: "CreateLabel", input: { repositoryId: ctx.repo.id, ...label } }).createLabel.label.id : rest(io, `repos/${slug}/labels`, { method: "POST", payload: label }).node_id;
|
|
1400
|
+
const labels = [];
|
|
1401
|
+
if (issueType || org && !ctx)
|
|
1402
|
+
done.push(`type ${q(o.type)}`);
|
|
1403
|
+
if (!org && o.type === N.issueTypes.epic) {
|
|
1404
|
+
let labelId = repoCtx.label?.id;
|
|
1405
|
+
if (!labelId) {
|
|
1406
|
+
labelId = createLabel(epicLabel());
|
|
1407
|
+
done.push(`created label ${q(EPIC_LABEL)}`);
|
|
1408
|
+
}
|
|
1409
|
+
labels.push({ id: labelId, name: EPIC_LABEL });
|
|
1410
|
+
done.push(`label ${q(EPIC_LABEL)} — the type in a personal account`);
|
|
1411
|
+
}
|
|
1412
|
+
if (plan.length) {
|
|
1413
|
+
for (const x of plan) {
|
|
1414
|
+
let id = x.label?.id;
|
|
1415
|
+
if (!id) {
|
|
1416
|
+
id = createLabel(decisionLabel(x.name, x.kinds));
|
|
1417
|
+
done.push(`created label ${q(x.name)}`);
|
|
1418
|
+
}
|
|
1419
|
+
labels.push({ id, name: x.name });
|
|
1420
|
+
}
|
|
1421
|
+
done.push(`labels: ${plan.map((x) => x.name).join(", ")}`, ...plan.flatMap((x) => x.fresh ? [x.fresh] : []));
|
|
1422
|
+
}
|
|
1423
|
+
if (open.length) {
|
|
1424
|
+
let id = has(N.labels.questions)?.id;
|
|
1425
|
+
if (!id) {
|
|
1426
|
+
id = createLabel(questionsLabel());
|
|
1427
|
+
done.push(`created label ${q(N.labels.questions)}`);
|
|
1428
|
+
}
|
|
1429
|
+
if (!labels.some((l) => l.id === id))
|
|
1430
|
+
labels.push({ id, name: N.labels.questions });
|
|
1431
|
+
done.push(`label ${q(N.labels.questions)} — open questions in the description: ${open.join(", ")}`);
|
|
1432
|
+
}
|
|
1433
|
+
let created;
|
|
1434
|
+
if (ctx) {
|
|
1435
|
+
const input = { repositoryId: ctx.repo.id, title, body: o.body, projectV2Ids: [ctx.project.id] };
|
|
1436
|
+
if (issueType)
|
|
1437
|
+
input.issueTypeId = issueType.id;
|
|
1438
|
+
if (labels.length)
|
|
1439
|
+
input.labelIds = labels.map((l) => l.id);
|
|
1440
|
+
if (milestone)
|
|
1441
|
+
input.milestoneId = milestone.id;
|
|
1442
|
+
if (epic)
|
|
1443
|
+
input.parentIssueId = epic.id;
|
|
1444
|
+
if (priority)
|
|
1445
|
+
input.issueFields = [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }];
|
|
1446
|
+
created = mutate(io, { op: "CreateIssue", input }).createIssue.issue;
|
|
1447
|
+
const listed = (created.projectItems?.nodes ?? []).map((it) => ({ id: it.id, projectId: it.project.id, status: null }));
|
|
1448
|
+
const item = ensureItem(io, ctx, { id: created.id, number: created.number, items: listed }).id;
|
|
1449
|
+
setStatus(io, ctx, item, N.status.backlog);
|
|
1450
|
+
done.push(`project ${q(ctx.project.title)}: ${STATUS} ${q(N.status.backlog)}`);
|
|
1451
|
+
} else {
|
|
1452
|
+
const payload = { title, body: o.body };
|
|
1453
|
+
if (org)
|
|
1454
|
+
payload.type = o.type;
|
|
1455
|
+
if (labels.length)
|
|
1456
|
+
payload.labels = labels.map((l) => l.name);
|
|
1457
|
+
if (milestone)
|
|
1458
|
+
payload.milestone = milestone.number;
|
|
1459
|
+
const i = rest(io, `repos/${slug}/issues`, { method: "POST", payload });
|
|
1460
|
+
created = { id: i.node_id, databaseId: i.id, number: i.number, title: i.title, url: i.html_url };
|
|
1461
|
+
if (epic)
|
|
1462
|
+
rest(io, `repos/${slug}/issues/${epic.number}/sub_issues`, { method: "POST", payload: { sub_issue_id: created.databaseId } });
|
|
1463
|
+
}
|
|
1464
|
+
if (priority)
|
|
1465
|
+
done.push(`${PRIORITY} ${q(priority.name)}`);
|
|
1466
|
+
if (epic)
|
|
1467
|
+
done.push(`subtask of epic #${epic.number}`);
|
|
1468
|
+
for (const b of blockers) {
|
|
1469
|
+
if (b.state !== "OPEN") {
|
|
1470
|
+
done.push(`#${b.number} is closed — doesn't block, skipped`);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (ctx)
|
|
1474
|
+
mutate(io, { op: "AddBlockedBy", input: { issueId: created.id, blockingIssueId: b.id } });
|
|
1475
|
+
else
|
|
1476
|
+
rest(io, `repos/${slug}/issues/${created.number}/dependencies/blocked_by`, { method: "POST", payload: { issue_id: b.databaseId } });
|
|
1477
|
+
done.push(`blocked by #${b.number}`);
|
|
1478
|
+
}
|
|
1479
|
+
if (milestone)
|
|
1480
|
+
done.push(`milestone ${q(milestone.title)}`);
|
|
1481
|
+
const decided = plan.filter((x) => x.decision).map((x) => x.name);
|
|
1482
|
+
const epicLabels = epic && decided.length ? putLabels(io, epic, `epic #${epic.number}`, decided, null, loadLabels(io, slug)) : [];
|
|
1483
|
+
if (epic && priority) {
|
|
1484
|
+
const rank = (p) => p === null ? PRIORITIES.length : PRIORITIES.indexOf(p);
|
|
1485
|
+
if (rank(priority.name) < rank(epic.priority)) {
|
|
1486
|
+
mutate(io, { op: "SetIssueField", input: { issueId: epic.id, issueFields: [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }] } });
|
|
1487
|
+
done.push(`${PRIORITY} of epic #${epic.number}: ${epic.priority ?? "—"} → ${priority.name}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
io.out(`Created #${created.number} ${created.title} — ${created.url}`);
|
|
1491
|
+
for (const d of done)
|
|
1492
|
+
io.out(`+ ${d}`);
|
|
1493
|
+
for (const l of epicLabels)
|
|
1494
|
+
io.out(l);
|
|
1495
|
+
const isEpicType = o.type === N.issueTypes.epic;
|
|
1496
|
+
if (!ctx) {
|
|
1497
|
+
const n = created.number;
|
|
1498
|
+
const local = [`github task status ${n} "${N.status.backlog}" (the project, ${STATUS})`, ...isEpicType ? [] : [`est estimate ${n} --type ${branchType} --write (${q(N.fields.estimate)})`]];
|
|
1499
|
+
const want = org ? o.priority ?? DEFAULT_PRIORITY : null;
|
|
1500
|
+
if (want)
|
|
1501
|
+
local.push(`${PRIORITY} ${q(want)}${epic ? ` and no lower at epic #${epic.number}` : ""} — on the issue page`);
|
|
1502
|
+
io.out(`Next: the cloud has no GitHub Projects (docs/cloud-sessions.md) — a local session: ${local.join("; ")}.`);
|
|
1503
|
+
}
|
|
1504
|
+
if (isEpicType) {
|
|
1505
|
+
io.out(`Next: an epic isn't estimated — its ${q(N.fields.estimate)} is the sum of its subtasks' estimates.`);
|
|
1506
|
+
io.out(`Next: once its subtasks are created — the question to the owner «run the dispatcher on epic #${created.number}?»; ` + `yes — bun --no-env-file ${import.meta.path} task queue --epic ${created.number} --repo ${slug} in this session, its executors — background subagents.`);
|
|
1507
|
+
return 0;
|
|
1508
|
+
}
|
|
1509
|
+
if (ctx)
|
|
1510
|
+
runEstimate(io, slug, created.number, branchType);
|
|
1511
|
+
if (epic)
|
|
1512
|
+
io.out(`Next: ${q(N.fields.estimate)} of epic #${epic.number} — recompute as the sum of its subtasks' estimates.`);
|
|
1513
|
+
const held = pinnedTask(io)?.task;
|
|
1514
|
+
if (held)
|
|
1515
|
+
io.out(`Next: the session runs ${held.slug}#${held.number}; #${created.number} isn't taken here — ${queueQuestion(slug, created.number, epic?.number ?? null)}`);
|
|
1516
|
+
return 0;
|
|
1517
|
+
}
|
|
1518
|
+
function cmdTaskStatus(io, slug, number, name, branch) {
|
|
1519
|
+
if (!statusAliases()[name.toLowerCase()])
|
|
1520
|
+
throw new GhError(`status: ${statusOptions().join(", ")}`);
|
|
1521
|
+
if (branch !== undefined && statusAliases()[name.toLowerCase()] !== N.status.inProgress)
|
|
1522
|
+
throw new GhError(`--branch — only with ${q(N.status.inProgress)}: the branch is renamed when the task is taken into work`);
|
|
1523
|
+
if (branch !== undefined && (taskBranch(branch)?.issue !== number || !TASK_SLUG_RE.test(branch))) {
|
|
1524
|
+
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`);
|
|
1525
|
+
}
|
|
1526
|
+
const ctx = taskContext(io, slug);
|
|
1527
|
+
const status = statusAliases()[name.toLowerCase()];
|
|
1528
|
+
const issue = loadIssue(io, slug, number);
|
|
1529
|
+
const epicTask = isEpic(issue, ctx.repo.owner.org);
|
|
1530
|
+
if (branch !== undefined && epicTask)
|
|
1531
|
+
throw new GhError(`--branch: #${number} is an epic — an epic has no branch`);
|
|
1532
|
+
if (status === N.status.inProgress && !epicTask)
|
|
1533
|
+
pinTask(io, slug, number, issue.parent?.number ?? null);
|
|
1534
|
+
const item = ensureItem(io, ctx, issue);
|
|
1535
|
+
setStatus(io, ctx, item.id, status);
|
|
1536
|
+
io.out(`+ #${issue.number}: ${STATUS} ${q(status)}${item.added ? " (added to the project)" : ""}`);
|
|
1537
|
+
const code = branch === undefined || renameBranch(io, slug, branch) ? 0 : 1;
|
|
1538
|
+
if (!issue.parent)
|
|
1539
|
+
return code;
|
|
1540
|
+
const epic = loadIssue(io, slug, issue.parent.number);
|
|
1541
|
+
const epicStatus = itemIn(ctx, epic)?.status ?? null;
|
|
1542
|
+
if (status === N.status.inProgress && epic.state === "OPEN" && (epicStatus === null || epicStatus === N.status.backlog)) {
|
|
1543
|
+
setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.inProgress);
|
|
1544
|
+
io.out(`+ epic #${epic.number}: ${STATUS} ${q(N.status.inProgress)} — the first subtask is taken`);
|
|
1545
|
+
}
|
|
1546
|
+
if (status === N.status.inProgress && epic.state === "CLOSED")
|
|
1547
|
+
io.out(`Next: epic #${epic.number} is closed, but a subtask is in work again — reopen it (and its milestone, if closed).`);
|
|
1548
|
+
if (status === N.status.done && epic.state === "OPEN" && epic.subIssues.length && epic.subIssues.every((x) => x.state === "CLOSED")) {
|
|
1549
|
+
io.out(`Next: all subtasks of epic #${epic.number} are closed — close the epic and set it to ${q(N.status.done)}.`);
|
|
1550
|
+
}
|
|
1551
|
+
return code;
|
|
1552
|
+
}
|
|
1553
|
+
function cmdTaskDrop(io, slug, number, duplicateOf) {
|
|
1554
|
+
const ctx = taskContext(io, slug);
|
|
1555
|
+
const issue = loadIssue(io, slug, number);
|
|
1556
|
+
if (issue.state === "CLOSED" && issue.stateReason === "COMPLETED")
|
|
1557
|
+
throw new GhError(`#${number} is closed as completed — drop is only for tasks not done`);
|
|
1558
|
+
const dup = duplicateOf === undefined ? null : loadIssue(io, slug, duplicateOf);
|
|
1559
|
+
if (issue.state === "OPEN") {
|
|
1560
|
+
mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: dup ? "DUPLICATE" : "NOT_PLANNED", ...dup ? { duplicateIssueId: dup.id } : {} } });
|
|
1561
|
+
io.out(`+ #${number} closed: ${dup ? `duplicate of #${dup.number}` : "not planned"}`);
|
|
1562
|
+
}
|
|
1563
|
+
const item = itemIn(ctx, issue);
|
|
1564
|
+
if (item) {
|
|
1565
|
+
mutate(io, { op: "DeleteItem", input: { projectId: ctx.project.id, itemId: item.id } });
|
|
1566
|
+
io.out(`+ #${number} removed from project ${q(ctx.project.title)}`);
|
|
1567
|
+
}
|
|
1568
|
+
if (issue.parent)
|
|
1569
|
+
io.out(`Next: ${q(N.fields.estimate)} of epic #${issue.parent.number} — recompute as the sum of the remaining subtasks.`);
|
|
1570
|
+
return 0;
|
|
1571
|
+
}
|
|
1572
|
+
function shownPr(p, why) {
|
|
1573
|
+
const by = [...checkStates(rollupOf(p))];
|
|
1574
|
+
const of = (...states) => by.filter(([, v]) => states.includes(v)).map(([k]) => k);
|
|
1575
|
+
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 };
|
|
1576
|
+
}
|
|
1577
|
+
function prLine(p) {
|
|
1578
|
+
const done = p.merged || p.state === "CLOSED";
|
|
1579
|
+
const state = p.merged ? "merged" : p.state === "CLOSED" ? "closed without a merge" : p.draft ? "open (draft)" : "open";
|
|
1580
|
+
const c = p.checks;
|
|
1581
|
+
const checks = done ? "" : !c ? " — no checks yet" : ` — 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(", ")}`;
|
|
1582
|
+
return `PR #${p.number} ${state}${checks}${p.why ? ` (not linked: ${p.why})` : ""}`;
|
|
1583
|
+
}
|
|
1584
|
+
function restIssueShow(io, slug, number) {
|
|
1585
|
+
const i = restIssue(io, slug, number);
|
|
1586
|
+
if (!i)
|
|
1587
|
+
return null;
|
|
1588
|
+
const l = restLinks(io, slug, number);
|
|
1589
|
+
return { ...i, ...l, parent: l.parent && { ...l.parent, subIssues: restSubIssues(io, slug, l.parent.number) } };
|
|
1590
|
+
}
|
|
1591
|
+
function restPrs(io, slug, number) {
|
|
1592
|
+
const prs = rest(io, `repos/${slug}/pulls?state=all&sort=updated&direction=desc&per_page=${RECENT_PRS}`) ?? [];
|
|
1593
|
+
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)));
|
|
1594
|
+
return mine.map((p) => {
|
|
1595
|
+
const open = p.state === "open";
|
|
1596
|
+
const runs = open ? rest(io, `repos/${slug}/commits/${p.head.sha}/check-runs?per_page=100`)?.check_runs ?? [] : [];
|
|
1597
|
+
const statuses = open ? rest(io, `repos/${slug}/commits/${p.head.sha}/status`)?.statuses ?? [] : [];
|
|
1598
|
+
const up = (v) => v ? String(v).toUpperCase() : null;
|
|
1599
|
+
const nodes = [
|
|
1600
|
+
...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 } } })),
|
|
1601
|
+
...statuses.map((c) => ({ __typename: "StatusContext", context: c.context, state: up(c.state) }))
|
|
1602
|
+
];
|
|
1603
|
+
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 } }] } };
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
function cmdTaskShow(io, slug, number) {
|
|
1607
|
+
const [owner, name] = slug.split("/");
|
|
1608
|
+
const cloud = inCloud(io);
|
|
1609
|
+
const i = cloud ? restIssueShow(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
|
|
1610
|
+
if (!i)
|
|
1611
|
+
throw new GhError(`no issue #${number} in ${slug}`);
|
|
1612
|
+
const labels = (i.labels?.nodes ?? []).map((l) => l.name);
|
|
1613
|
+
const type = i.issueType?.name ?? (labels.includes(EPIC_LABEL) ? N.issueTypes.epic : null);
|
|
1614
|
+
const epic = type !== null && locale_default.every((n) => n.issueTypes.epic).includes(type);
|
|
1615
|
+
const items = i.projectItems?.nodes ?? [];
|
|
1616
|
+
const values = (items.find((it) => it.project?.title === name) ?? items[0])?.fieldValues?.nodes ?? [];
|
|
1617
|
+
const num = (field) => values.find((v) => v?.__typename === "ProjectV2ItemFieldNumberValue" && locale_default.every(field).includes(v.field?.name));
|
|
1618
|
+
const status = values.find((v) => v?.field?.name === STATUS && v.name)?.name ?? null;
|
|
1619
|
+
const estimate = num((n) => n.fields.estimate);
|
|
1620
|
+
const actual = num((n) => n.fields.actual);
|
|
1621
|
+
const priority = (i.issueFieldValues?.nodes ?? []).find((v) => v?.field?.name === PRIORITY)?.name ?? null;
|
|
1622
|
+
const reason = { NOT_PLANNED: ": not planned", DUPLICATE: ": duplicate" };
|
|
1623
|
+
const shownLabels = labels.filter((l) => !(l === EPIC_LABEL && !i.issueType));
|
|
1624
|
+
const facts = [
|
|
1625
|
+
type,
|
|
1626
|
+
i.state === "CLOSED" ? `closed${reason[i.stateReason] ?? ""}` : null,
|
|
1627
|
+
cloud ? "no project in the cloud (docs/cloud-sessions.md)" : !items.length ? "not in the project" : status ? `${STATUS} ${q(status)}` : `no ${STATUS}`,
|
|
1628
|
+
cloud ? null : estimate ? `${q(estimate.field.name)} ${estimate.number}` : `no ${q(N.fields.estimate)}`,
|
|
1629
|
+
actual ? `${q(actual.field.name)} ${actual.number}` : null,
|
|
1630
|
+
priority ? `${PRIORITY} ${priority}` : null,
|
|
1631
|
+
i.milestone ? `milestone ${q(i.milestone.title)}` : null,
|
|
1632
|
+
shownLabels.length ? `labels: ${shownLabels.join(", ")}` : null
|
|
1633
|
+
];
|
|
1634
|
+
const out = [`#${i.number} ${i.title} — ${i.url}`, facts.filter(Boolean).join(" · ")];
|
|
1635
|
+
if (i.parent) {
|
|
1636
|
+
const p = i.parent;
|
|
1637
|
+
const open = questionItems(p.body ?? "").filter((x) => !x.answered);
|
|
1638
|
+
const tasks = (p.subIssues?.nodes ?? []).filter((x) => x.state === "OPEN" && x.number !== number);
|
|
1639
|
+
out.push(`Epic #${p.number} ${q(p.title)} — open questions: ${open.length}, other open tasks: ${tasks.length}`);
|
|
1640
|
+
out.push(...open.map((x) => `- question ${x.text}`), ...tasks.map((x) => `- task #${x.number} ${q(x.title)}`));
|
|
1641
|
+
}
|
|
1642
|
+
const subs = i.subIssues?.nodes ?? [];
|
|
1643
|
+
if (subs.length) {
|
|
1644
|
+
const open = subs.filter((x) => x.state === "OPEN");
|
|
1645
|
+
out.push(`Subtasks: open ${open.length} of ${subs.length}`, ...open.map((x) => `- task #${x.number} ${q(x.title)}`));
|
|
1646
|
+
}
|
|
1647
|
+
const blockers = (i.blockedBy?.nodes ?? []).filter((x) => x.state === "OPEN");
|
|
1648
|
+
if (blockers.length)
|
|
1649
|
+
out.push(`Blocked by: ${blockers.map((x) => `${blockerRef(slug, { number: x.number, repo: x.repository?.nameWithOwner ?? slug })} ${q(x.title)}`).join(", ")}`);
|
|
1650
|
+
if (!epic) {
|
|
1651
|
+
let prs = (cloud ? restPrs(io, slug, number) : (i.closedByPullRequestsReferences?.nodes ?? []).filter(Boolean)).map((p) => shownPr(p));
|
|
1652
|
+
if (!cloud && !prs.length && i.state === "OPEN") {
|
|
1653
|
+
for (const p of unlinkedPrs(io, slug, number).prs) {
|
|
1654
|
+
const pr = graphql(io, Q.PrShow, { owner, name, number: p.number })?.repository?.pullRequest;
|
|
1655
|
+
if (pr)
|
|
1656
|
+
prs.push(shownPr(pr, p.why));
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
out.push(...prs.length ? prs.map(prLine) : ["PR: none"]);
|
|
1660
|
+
}
|
|
1661
|
+
const lines = (i.body ?? "").trimEnd().split(/\r?\n/);
|
|
1662
|
+
const questions = questionItems(i.body ?? "");
|
|
1663
|
+
const open = questions.filter((x) => !x.answered).map((x) => x.k);
|
|
1664
|
+
const answered = questions.filter((x) => x.answered).map((x) => x.k);
|
|
1665
|
+
out.push(!questions.length ? "Questions: none" : !open.length ? `Questions: all answered (${answered.join(", ")})` : `Questions: open ${open.join(", ")}${answered.length ? `; answered ${answered.join(", ")}` : ""} — task answer ${number} <k> "<answer>" [--auto]`);
|
|
1666
|
+
if (lines.join("").trim())
|
|
1667
|
+
out.push("", ...lines);
|
|
1668
|
+
for (const line of out)
|
|
1669
|
+
io.out(line);
|
|
1670
|
+
return 0;
|
|
1671
|
+
}
|
|
1672
|
+
function cmdTaskAnswer(io, slug, number, k, answer, auto) {
|
|
1673
|
+
const text = answer.trim().replace(/\s*\r?\n\s*/g, " ");
|
|
1674
|
+
if (!text)
|
|
1675
|
+
throw new GhError("task answer: expected the answer text after the question number");
|
|
1676
|
+
const [owner, name] = slug.split("/");
|
|
1677
|
+
const cloud = inCloud(io);
|
|
1678
|
+
const i = cloud ? restIssue(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
|
|
1679
|
+
if (!i)
|
|
1680
|
+
throw new GhError(`no issue #${number} in ${slug}`);
|
|
1681
|
+
const body = i.body ?? "";
|
|
1682
|
+
const lines = body.split(/\r?\n/);
|
|
1683
|
+
const questions = questionItems(body);
|
|
1684
|
+
if (!questions.length)
|
|
1685
|
+
throw new GhError(`#${number}: no ${q(N.sections.questions)} section with numbered questions`);
|
|
1686
|
+
const question = questions.find((x) => x.k === k);
|
|
1687
|
+
if (!question)
|
|
1688
|
+
throw new GhError(`#${number}: no question ${k} in ${q(N.sections.questions)}; questions: ${questions.map((x) => x.k).join(", ")}`);
|
|
1689
|
+
if (question.answered)
|
|
1690
|
+
throw new GhError(`#${number}: question ${k} is already answered (✅) — a changed answer is edited by hand`);
|
|
1691
|
+
const reply = `**${N.est.answer}:** ${text}`;
|
|
1692
|
+
lines[question.first] = lines[question.first].replace(/^(\d+\.\s+)/, `$1${auto ? N.autoAnswer : "✅"} `);
|
|
1693
|
+
if (question.first === question.last)
|
|
1694
|
+
lines[question.first] += ` ${reply}`;
|
|
1695
|
+
else
|
|
1696
|
+
lines.splice(question.last + 1, 0, `${" ".repeat(`${k}. `.length)}${reply}`);
|
|
1697
|
+
const next = lines.join(body.includes(`\r
|
|
1698
|
+
`) ? `\r
|
|
1699
|
+
` : `
|
|
1700
|
+
`);
|
|
1701
|
+
if (cloud)
|
|
1702
|
+
rest(io, `repos/${slug}/issues/${number}`, { method: "PATCH", payload: { body: next } });
|
|
1703
|
+
else
|
|
1704
|
+
mutate(io, { op: "UpdateIssue", input: { id: i.id, body: next } });
|
|
1705
|
+
io.out(`+ #${number}: question ${k} — ✅ and the answer in the description`);
|
|
1706
|
+
const open = questions.filter((x) => !x.answered && x !== question).map((x) => x.k);
|
|
1707
|
+
if (open.length)
|
|
1708
|
+
return io.out(`○ #${number}: open questions left: ${open.join(", ")}`), 0;
|
|
1709
|
+
const label = (i.labels?.nodes ?? []).filter((l) => locale_default.every((n) => n.labels.questions).includes(l.name));
|
|
1710
|
+
if (label.length) {
|
|
1711
|
+
if (cloud)
|
|
1712
|
+
for (const l of label)
|
|
1713
|
+
rest(io, `repos/${slug}/issues/${number}/labels/${encodeURIComponent(l.name)}`, { method: "DELETE" });
|
|
1714
|
+
else
|
|
1715
|
+
mutate(io, { op: "RemoveLabels", input: { labelableId: i.id, labelIds: label.map((l) => l.id) } });
|
|
1716
|
+
io.out(`+ #${number}: label ${label.map((l) => q(l.name)).join(", ")} removed — no open questions left`);
|
|
1717
|
+
}
|
|
1718
|
+
return 0;
|
|
1719
|
+
}
|
|
1720
|
+
var QUEUE_EXECUTORS = 3;
|
|
1721
|
+
var priorityRank = (p) => PRIORITIES.indexOf(p ?? DEFAULT_PRIORITY);
|
|
1722
|
+
function externalBlocker(io, slug, number, label) {
|
|
1723
|
+
const marks = locale_default.every((n) => n.comments.externalBlocker);
|
|
1724
|
+
const comment = loadTaskText(io, slug, number).comments.map((c) => c.body.trim()).filter((b) => marks.some((m) => b.startsWith(m))).at(-1);
|
|
1725
|
+
if (comment === undefined)
|
|
1726
|
+
return `label ${q(label)}, no comment ${q(`${N.comments.externalBlocker} <reason>`)}`;
|
|
1727
|
+
const mark = marks.find((m) => comment.startsWith(m));
|
|
1728
|
+
return `label ${q(label)}: ${clip(comment.slice(mark.length).split(/\r?\n/)[0].trim())}`;
|
|
1729
|
+
}
|
|
1730
|
+
function executorPrompt(slug, number, epic) {
|
|
1731
|
+
return locale_default.fill(N.queue.prompt, {
|
|
1732
|
+
task: N.subagentPrompt.task,
|
|
1733
|
+
number,
|
|
1734
|
+
repo: slug,
|
|
1735
|
+
epic: epic === null ? "" : locale_default.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic }),
|
|
1736
|
+
command: `bun --no-env-file ${import.meta.path} task queue --assignment ${number} --repo ${slug}`
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
function cloudBrief(io, slug, issue) {
|
|
1740
|
+
const number = issue.number;
|
|
1741
|
+
const vars = {
|
|
1742
|
+
task: N.subagentPrompt.task,
|
|
1743
|
+
number,
|
|
1744
|
+
title: issue.title.replace(/#(\d)/g, "$1"),
|
|
1745
|
+
repo: slug,
|
|
1746
|
+
epic: issue.parent ? locale_default.fill(N.queue.cloudEpic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
|
|
1747
|
+
actual: N.est.cloud.actual.replace(/:$/, ""),
|
|
1748
|
+
state: N.comments.state,
|
|
1749
|
+
questions: N.sections.questions
|
|
1750
|
+
};
|
|
1751
|
+
for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion])
|
|
1752
|
+
io.out(locale_default.fill(line, vars));
|
|
1753
|
+
io.out(`Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` + `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: ` + `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; ` + `TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`);
|
|
1754
|
+
}
|
|
1755
|
+
function cmdTaskQueue(io, slug, o) {
|
|
1756
|
+
const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
|
|
1757
|
+
if (o.type !== undefined && (!want || want === N.issueTypes.epic))
|
|
1758
|
+
throw new GhError(`--type: ${N.issueTypes.task} or ${N.issueTypes.bug}`);
|
|
1759
|
+
const bad = (o.priority ?? []).filter((p) => !PRIORITIES.includes(p));
|
|
1760
|
+
if (bad.length)
|
|
1761
|
+
throw new GhError(`--priority: ${PRIORITIES.join(", ")}, not ${bad.join(", ")}`);
|
|
1762
|
+
const ctx = taskContext(io, slug);
|
|
1763
|
+
const org = ctx.repo.owner.org;
|
|
1764
|
+
if (!org && (o.type !== undefined || o.priority?.length))
|
|
1765
|
+
throw new GhError("--type and --priority — only in an organization: a personal account has no issue types and no Priority");
|
|
1766
|
+
let numbers;
|
|
1767
|
+
if (o.epic !== undefined) {
|
|
1768
|
+
const epic = loadIssue(io, slug, o.epic);
|
|
1769
|
+
if (!isEpic(epic, org))
|
|
1770
|
+
throw new GhError(`#${o.epic} is not an epic — --epic takes an epic`);
|
|
1771
|
+
if (epic.state !== "OPEN")
|
|
1772
|
+
throw new GhError(`epic #${o.epic} is closed`);
|
|
1773
|
+
numbers = epic.subIssues.filter((x) => x.state === "OPEN").map((x) => x.number);
|
|
1774
|
+
} else {
|
|
1775
|
+
const found = graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open` }).search.nodes;
|
|
1776
|
+
if (found.length >= 100)
|
|
1777
|
+
io.out("○ the search gave 100 open tasks — the queue sees only them: narrow it with --epic");
|
|
1778
|
+
numbers = found.filter((x) => x?.state === "OPEN").map((x) => x.number);
|
|
1779
|
+
}
|
|
1780
|
+
const [owner, name] = slug.split("/");
|
|
1781
|
+
const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p) => p.state === "OPEN");
|
|
1782
|
+
const openPr = (i) => i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
|
|
1783
|
+
const handed = handedOut(io, slug);
|
|
1784
|
+
const states = new Map;
|
|
1785
|
+
const isOpen = (n) => {
|
|
1786
|
+
if (!states.has(n))
|
|
1787
|
+
states.set(n, loadIssue(io, slug, n).state);
|
|
1788
|
+
return states.get(n) === "OPEN";
|
|
1789
|
+
};
|
|
1790
|
+
const ready = [];
|
|
1791
|
+
const running = [];
|
|
1792
|
+
const waiting = [];
|
|
1793
|
+
for (const n of numbers.sort((a, b) => a - b)) {
|
|
1794
|
+
const i = loadIssue(io, slug, n);
|
|
1795
|
+
states.set(i.number, i.state);
|
|
1796
|
+
if (i.state !== "OPEN")
|
|
1797
|
+
continue;
|
|
1798
|
+
const label = `#${i.number} ${q(i.title)}`;
|
|
1799
|
+
if (isEpic(i, org)) {
|
|
1800
|
+
waiting.push(`○ ${label} — an epic: its tasks — task queue --epic ${i.number}`);
|
|
1801
|
+
continue;
|
|
1802
|
+
}
|
|
1803
|
+
if (want && typeAliases()[(i.type ?? "").toLowerCase()] !== want)
|
|
1804
|
+
continue;
|
|
1805
|
+
if (o.priority?.length && !o.priority.includes(i.priority ?? DEFAULT_PRIORITY))
|
|
1806
|
+
continue;
|
|
1807
|
+
const status = itemIn(ctx, i)?.status ?? null;
|
|
1808
|
+
const pr = openPr(i);
|
|
1809
|
+
const work = [status === N.status.inProgress ? q(N.status.inProgress) : "", pr === undefined ? "" : `open PR #${pr} https://github.com/${slug}/pull/${pr}`].filter(Boolean);
|
|
1810
|
+
const hand = handed.find((h) => h.number === i.number);
|
|
1811
|
+
if (!work.length && hand?.cloud)
|
|
1812
|
+
work.push(`handed out to a cloud executor of this session, its wait — a background command: bash ${waitCi()} issue ${i.number}`);
|
|
1813
|
+
else if (!work.length && hand)
|
|
1814
|
+
work.push("handed out to an executor of this session");
|
|
1815
|
+
if (work.length) {
|
|
1816
|
+
running.push(`= ${label} — ${work.join(", ")}`);
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
const blockers = i.blockers.filter((b) => b.state === "OPEN").map((b) => blockerRef(slug, b));
|
|
1820
|
+
const external = i.labels.find((l) => locale_default.every((n) => n.labels.externalBlocker).includes(l));
|
|
1821
|
+
if (status === N.status.done)
|
|
1822
|
+
waiting.push(`○ ${label} — ${STATUS} ${q(status)}, but the task is open`);
|
|
1823
|
+
else if (blockers.length)
|
|
1824
|
+
waiting.push(`○ ${label} — blocked by ${blockers.join(", ")}`);
|
|
1825
|
+
else if (external)
|
|
1826
|
+
waiting.push(`○ ${label} — ${externalBlocker(io, slug, i.number, external)}`);
|
|
1827
|
+
else
|
|
1828
|
+
ready.push(i);
|
|
1829
|
+
}
|
|
1830
|
+
ready.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.number - b.number);
|
|
1831
|
+
const args = [o.epic !== undefined && `--epic ${o.epic}`, o.type !== undefined && `--type ${o.type}`, o.priority?.length && `--priority ${o.priority.join(",")}`].filter(Boolean).join(" ");
|
|
1832
|
+
const file = dispatcherFile(io);
|
|
1833
|
+
if (file) {
|
|
1834
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1835
|
+
writeFileSync(path.join(path.dirname(file), ".gitignore"), `*
|
|
1836
|
+
`);
|
|
1837
|
+
writeFileSync(file, `bun --no-env-file ${import.meta.path} task queue ${args ? `${args} ` : ""}--repo ${slug}
|
|
1838
|
+
`);
|
|
1839
|
+
} else
|
|
1840
|
+
io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — not marked as a dispatcher: task status in the executors would pin their tasks");
|
|
1841
|
+
appWorktreeWarning(io);
|
|
1842
|
+
const executors = handed.flatMap((h) => h.worktree === null ? [] : [{ number: h.number, worktree: realPath(h.worktree) }]);
|
|
1843
|
+
pruneAgentWorktrees(io, slug, (dir) => executors.some((h) => h.worktree === realPath(dir) && isOpen(h.number)));
|
|
1844
|
+
const scope = [o.epic !== undefined ? `epic #${o.epic}` : "open tasks", want && `type ${q(want)}`, o.priority?.length && `Priority ${o.priority.join("|")}`].filter(Boolean).join(", ");
|
|
1845
|
+
io.out(`Queue ${slug} (${scope}): ready ${ready.length}, ${q(N.status.inProgress)} ${running.length}, not ready ${waiting.length}`);
|
|
1846
|
+
for (const i of ready) {
|
|
1847
|
+
io.out(`+ #${i.number} ${q(i.title)}${org ? ` (${i.priority ?? DEFAULT_PRIORITY})` : ""}`);
|
|
1848
|
+
io.out(` ${executorPrompt(slug, i.number, i.parent?.number ?? null)}`);
|
|
1849
|
+
}
|
|
1850
|
+
for (const line of running)
|
|
1851
|
+
io.out(line);
|
|
1852
|
+
for (const line of waiting)
|
|
1853
|
+
io.out(line);
|
|
1854
|
+
if (ready.length) {
|
|
1855
|
+
io.out(`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); ` + "the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in.");
|
|
1856
|
+
} else if (running.length)
|
|
1857
|
+
io.out("Next: no ready tasks — wait for the executors' answers; after each — task queue again.");
|
|
1858
|
+
else
|
|
1859
|
+
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.`);
|
|
1860
|
+
return 0;
|
|
1861
|
+
}
|
|
1862
|
+
var AGENT_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]agent-[^\\/]+$/;
|
|
1863
|
+
var AGENT_BRANCH = /^worktree-agent-/;
|
|
1864
|
+
var APP_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/](?!agent-)[^\\/]+$/;
|
|
1865
|
+
function appWorktreeWarning(io) {
|
|
1866
|
+
const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd).stdout.trim();
|
|
1867
|
+
if (!APP_WORKTREE.test(top))
|
|
1868
|
+
return;
|
|
1869
|
+
const project = top.replace(/[\\/]\.claude[\\/]worktrees[\\/][^\\/]+$/, "");
|
|
1870
|
+
io.out(`! session in an app worktree ${top}: the app starts its cloud sessions from the branch in the session's record, not from main — ` + `cloud executors from it fail with «The requested branch or commit was not found»; a dispatcher with cloud executors — ` + `a session in the project folder ${project} without a worktree; subagent executors aren't affected`);
|
|
1871
|
+
}
|
|
1872
|
+
function worktreeList(text) {
|
|
1873
|
+
return text.split(/\n\s*\n/).map((block) => {
|
|
1874
|
+
const lines = block.split(`
|
|
1875
|
+
`);
|
|
1876
|
+
const attr = (key) => {
|
|
1877
|
+
const line = lines.find((l) => l === key || l.startsWith(`${key} `));
|
|
1878
|
+
return line === undefined ? null : line.slice(key.length + 1);
|
|
1879
|
+
};
|
|
1880
|
+
return { path: attr("worktree") ?? "", head: attr("HEAD") ?? "", branch: attr("branch")?.replace(/^refs\/heads\//, "") ?? null, locked: attr("locked"), prunable: attr("prunable") !== null };
|
|
1881
|
+
}).filter((w) => w.path);
|
|
1882
|
+
}
|
|
1883
|
+
function lockHolds(reason) {
|
|
1884
|
+
const pid = /\bpid (\d+)\b/.exec(reason)?.[1];
|
|
1885
|
+
if (!pid)
|
|
1886
|
+
return true;
|
|
1887
|
+
try {
|
|
1888
|
+
process.kill(Number(pid), 0);
|
|
1889
|
+
return true;
|
|
1890
|
+
} catch (e) {
|
|
1891
|
+
return e.code !== "ESRCH";
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
1895
|
+
function pruneAgentWorktrees(io, slug, kept) {
|
|
1896
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
1897
|
+
return;
|
|
1898
|
+
const git = (cwd, ...args) => (io.run ?? realRun)("git", args, cwd);
|
|
1899
|
+
const listed = git(io.cwd, "worktree", "list", "--porcelain");
|
|
1900
|
+
if (listed.status !== 0)
|
|
1901
|
+
return;
|
|
1902
|
+
const all = worktreeList(listed.stdout);
|
|
1903
|
+
const here = git(io.cwd, "rev-parse", "--show-toplevel").stdout.trim();
|
|
1904
|
+
const base = git(io.cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
|
|
1905
|
+
const upstream = `origin/${base}`;
|
|
1906
|
+
const why = (r) => r.stderr.trim().split(`
|
|
1907
|
+
`).filter(Boolean).pop() ?? `code ${r.status}`;
|
|
1908
|
+
const unmerged = (ref) => {
|
|
1909
|
+
const r = git(io.cwd, "cherry", upstream, ref);
|
|
1910
|
+
if (r.status !== 0)
|
|
1911
|
+
return `git cherry ${upstream}: ${why(r)}`;
|
|
1912
|
+
const n = r.stdout.split(`
|
|
1913
|
+
`).filter((l) => l.startsWith("+")).length;
|
|
1914
|
+
return n ? `${plural(n, "commit", "commits")} not in ${upstream}` : null;
|
|
1915
|
+
};
|
|
1916
|
+
const deleteBranch = (branch) => {
|
|
1917
|
+
const d = git(io.cwd, "branch", "-D", branch);
|
|
1918
|
+
if (d.status !== 0)
|
|
1919
|
+
io.out(`! git branch -D ${branch}: ${why(d)}`);
|
|
1920
|
+
return d.status === 0;
|
|
1921
|
+
};
|
|
1922
|
+
const candidates = all.filter((w) => AGENT_WORKTREE.test(w.path) && w.path !== here && !w.prunable && (w.branch === null || AGENT_BRANCH.test(w.branch))).filter((w) => (w.locked === null || !lockHolds(w.locked)) && !kept(w.path)).sort((a, b) => a.path.localeCompare(b.path));
|
|
1923
|
+
const agents = candidates.length ? subagentsOf(io, [io.env.CLAUDE_CODE_SESSION_ID ?? "", ...handOutFiles(io).map((h) => h.session)].filter(Boolean)) : new Map;
|
|
1924
|
+
for (const w of candidates) {
|
|
1925
|
+
const guest = guestWhy(agents, agentIdOf(w.path));
|
|
1926
|
+
if (guest) {
|
|
1927
|
+
io.out(`○ worktree ${w.path} left: ${guest}`);
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
const status = git(w.path, "status", "--porcelain");
|
|
1931
|
+
if (status.status !== 0) {
|
|
1932
|
+
io.out(`! worktree ${w.path}: git status: ${why(status)}`);
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
const dirty = status.stdout.split(`
|
|
1936
|
+
`).filter((l) => l.trim()).length;
|
|
1937
|
+
const reasons = [dirty ? `${plural(dirty, "uncommitted file", "uncommitted files")}` : null, unmerged(w.head)].filter((r) => r !== null);
|
|
1938
|
+
if (reasons.length) {
|
|
1939
|
+
io.out(`○ worktree ${w.path} left: ${reasons.join(", ")}`);
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
if (w.locked !== null)
|
|
1943
|
+
git(io.cwd, "worktree", "unlock", w.path);
|
|
1944
|
+
const removed = git(io.cwd, "worktree", "remove", w.path);
|
|
1945
|
+
if (removed.status !== 0) {
|
|
1946
|
+
io.out(`! git worktree remove ${w.path}: ${why(removed)}`);
|
|
1947
|
+
continue;
|
|
1948
|
+
}
|
|
1949
|
+
const withBranch = w.branch !== null && deleteBranch(w.branch) ? ` with branch ${w.branch}` : "";
|
|
1950
|
+
io.out(`+ worktree ${w.path} removed${withBranch}: clean, nothing outside ${upstream}`);
|
|
1951
|
+
}
|
|
1952
|
+
const checkedOut = new Set(all.map((w) => w.branch).filter(Boolean));
|
|
1953
|
+
const loose = git(io.cwd, "for-each-ref", "--format=%(refname:short)", "refs/heads/worktree-agent-*").stdout.split(`
|
|
1954
|
+
`).map((b) => b.trim()).filter((b) => b && !checkedOut.has(b)).sort();
|
|
1955
|
+
for (const branch of loose) {
|
|
1956
|
+
const reason = unmerged(branch);
|
|
1957
|
+
if (reason)
|
|
1958
|
+
io.out(`○ branch ${branch} left: ${reason}`);
|
|
1959
|
+
else if (deleteBranch(branch))
|
|
1960
|
+
io.out(`+ branch ${branch} deleted: nothing outside ${upstream}`);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
var QUIET_MINUTES = 15;
|
|
1964
|
+
var agentIdOf = (dir) => AGENT_WORKTREE.test(dir) ? path.basename(dir).slice("agent-".length) : null;
|
|
1965
|
+
function subagentsOf(io, sessions) {
|
|
1966
|
+
const agents = new Map;
|
|
1967
|
+
const projects = path.join(io.env.CLAUDE_CONFIG_DIR || path.join(io.env.HOME || os.homedir(), ".claude"), "projects");
|
|
1968
|
+
let dirs;
|
|
1969
|
+
try {
|
|
1970
|
+
dirs = readdirSync(projects);
|
|
1971
|
+
} catch {
|
|
1972
|
+
return agents;
|
|
1973
|
+
}
|
|
1974
|
+
for (const dir of dirs) {
|
|
1975
|
+
for (const session of new Set(sessions)) {
|
|
1976
|
+
const sub = path.join(projects, dir, session, "subagents");
|
|
1977
|
+
let names;
|
|
1978
|
+
try {
|
|
1979
|
+
names = readdirSync(sub);
|
|
1980
|
+
} catch {
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
for (const name of names) {
|
|
1984
|
+
const id = /^agent-(.+)\.meta\.json$/.exec(name)?.[1];
|
|
1985
|
+
if (!id)
|
|
1986
|
+
continue;
|
|
1987
|
+
try {
|
|
1988
|
+
const meta = JSON.parse(readFileSync(path.join(sub, name), "utf8"));
|
|
1989
|
+
const parent = meta?.parentAgentId;
|
|
1990
|
+
const worktree = meta?.worktreePath;
|
|
1991
|
+
agents.set(id, {
|
|
1992
|
+
parent: typeof parent === "string" && parent ? parent : null,
|
|
1993
|
+
own: typeof worktree === "string" && path.basename(worktree) === `agent-${id}`,
|
|
1994
|
+
written: statSync(path.join(sub, `agent-${id}.jsonl`), { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
1995
|
+
});
|
|
1996
|
+
} catch {}
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return agents;
|
|
2001
|
+
}
|
|
2002
|
+
function guestWhy(agents, host) {
|
|
2003
|
+
const inside = new Set([host]);
|
|
2004
|
+
let fresh = null;
|
|
2005
|
+
for (let grew = true;grew; ) {
|
|
2006
|
+
grew = false;
|
|
2007
|
+
for (const [id, a] of agents) {
|
|
2008
|
+
if (a.own || a.parent === null || !inside.has(a.parent) || inside.has(id))
|
|
2009
|
+
continue;
|
|
2010
|
+
inside.add(id);
|
|
2011
|
+
grew = true;
|
|
2012
|
+
const minutes = Math.max(0, Math.floor((Date.now() - a.written) / 60000));
|
|
2013
|
+
if (a.written > 0 && minutes < QUIET_MINUTES && (!fresh || minutes < fresh.minutes))
|
|
2014
|
+
fresh = { id, minutes };
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
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}`;
|
|
2018
|
+
}
|
|
2019
|
+
function aliveWhy(io, w, agents) {
|
|
2020
|
+
if (w.locked !== null && lockHolds(w.locked))
|
|
2021
|
+
return `locked by a live process — ${w.locked || "no reason"}`;
|
|
2022
|
+
const id = agentIdOf(w.path);
|
|
2023
|
+
const guest = id === null ? null : guestWhy(agents, id);
|
|
2024
|
+
if (guest)
|
|
2025
|
+
return guest;
|
|
2026
|
+
const r = (io.run ?? realRun)("git", ["reflog", "-1", "--date=unix", "--format=%gd"], w.path);
|
|
2027
|
+
const at = Number(/@\{(\d+)\}/.exec(r.stdout)?.[1]);
|
|
2028
|
+
const minutes = Math.floor((Date.now() / 1000 - at) / 60);
|
|
2029
|
+
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;
|
|
2030
|
+
}
|
|
2031
|
+
function handOutFiles(io) {
|
|
2032
|
+
const dir = path.join(configDir(io.env), "dispatchers");
|
|
2033
|
+
try {
|
|
2034
|
+
return readdirSync(dir).filter((f) => f.endsWith(".tasks")).map((f) => ({ session: f.slice(0, -".tasks".length), file: path.join(dir, f) }));
|
|
2035
|
+
} catch {
|
|
2036
|
+
return [];
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
function livePredecessors(io, slug, number, own) {
|
|
2040
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
2041
|
+
return [];
|
|
2042
|
+
const listed = (io.run ?? realRun)("git", ["worktree", "list", "--porcelain"], io.cwd);
|
|
2043
|
+
if (listed.status !== 0)
|
|
2044
|
+
return [];
|
|
2045
|
+
const mine = realPath(own);
|
|
2046
|
+
const all = worktreeList(listed.stdout).filter((w) => !w.prunable && realPath(w.path) !== mine);
|
|
2047
|
+
const handOuts = handOutFiles(io).flatMap(({ session, file }) => handOutsIn(file, slug).filter((h) => h.number === number && h.worktree !== null).map((h) => ({ session, worktree: realPath(h.worktree) })));
|
|
2048
|
+
const executors = all.filter((w) => handOuts.some((h) => h.worktree === realPath(w.path)) || w.branch !== null && isTaskBranch(w.branch, number));
|
|
2049
|
+
if (!executors.length)
|
|
2050
|
+
return [];
|
|
2051
|
+
const agents = subagentsOf(io, [...handOuts.map((h) => h.session), io.env.CLAUDE_CODE_SESSION_ID ?? ""].filter(Boolean));
|
|
2052
|
+
const family = new Set(executors.map((w) => agentIdOf(w.path)).filter((id) => id !== null));
|
|
2053
|
+
for (let grew = true;grew; ) {
|
|
2054
|
+
grew = false;
|
|
2055
|
+
for (const [id, { parent }] of agents) {
|
|
2056
|
+
if (parent !== null && family.has(parent) && !family.has(id)) {
|
|
2057
|
+
family.add(id);
|
|
2058
|
+
grew = true;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
const lines = [];
|
|
2063
|
+
for (const w of all) {
|
|
2064
|
+
const parent = agents.get(agentIdOf(w.path) ?? "")?.parent;
|
|
2065
|
+
const role = executors.includes(w) ? "the previous executor" : parent && family.has(parent) ? `a subagent of agent-${parent}` : null;
|
|
2066
|
+
const why = role && aliveWhy(io, w, agents);
|
|
2067
|
+
if (why)
|
|
2068
|
+
lines.push(`! worktree ${w.path} (${role}): ${why}`);
|
|
2069
|
+
}
|
|
2070
|
+
return lines;
|
|
2071
|
+
}
|
|
2072
|
+
function taskBranchesHere(io, slug, number) {
|
|
2073
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
2074
|
+
return [];
|
|
2075
|
+
const git = (...args) => (io.run ?? realRun)("git", args, io.cwd).stdout.split(`
|
|
2076
|
+
`);
|
|
2077
|
+
const refs = [...git("for-each-ref", "--format=%(refname)", "refs/heads/"), ...git("ls-remote", "--heads", "origin").map((l) => l.split("\t")[1] ?? "")];
|
|
2078
|
+
return [...new Set(refs.map((r) => r.trim().replace(/^refs\/heads\//, "")).filter((b) => isTaskBranch(b, number)))].sort();
|
|
2079
|
+
}
|
|
2080
|
+
function cmdTaskAssignment(io, slug, number, o = { cloud: false }) {
|
|
2081
|
+
const issue = loadIssue(io, slug, number);
|
|
2082
|
+
const file = handedOutFile(io);
|
|
2083
|
+
const own = pinnedTask(io)?.task;
|
|
2084
|
+
const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd);
|
|
2085
|
+
const worktree = top.status === 0 ? top.stdout.trim() : "";
|
|
2086
|
+
const alive = worktree ? livePredecessors(io, slug, number, worktree) : [];
|
|
2087
|
+
if (alive.length) {
|
|
2088
|
+
for (const line of alive)
|
|
2089
|
+
io.out(line);
|
|
2090
|
+
io.out(locale_default.fill(N.queue.alive, { task: N.subagentPrompt.task, number, repo: slug }));
|
|
2091
|
+
return 1;
|
|
2092
|
+
}
|
|
2093
|
+
if (file && !(own?.slug === slug && own.number === number)) {
|
|
2094
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2095
|
+
writeFileSync(path.join(path.dirname(file), ".gitignore"), `*
|
|
2096
|
+
`);
|
|
2097
|
+
const where = o.cloud ? CLOUD_HANDOUT : worktree;
|
|
2098
|
+
appendFileSync(file, `${slug}#${number}${where ? ` ${where}` : ""}
|
|
2099
|
+
`);
|
|
2100
|
+
}
|
|
2101
|
+
if (o.cloud)
|
|
2102
|
+
return cloudBrief(io, slug, issue), 0;
|
|
2103
|
+
const restart = itemIn(taskContext(io, slug), issue)?.status === N.status.inProgress ? taskBranchesHere(io, slug, number) : [];
|
|
2104
|
+
const vars = {
|
|
2105
|
+
task: N.subagentPrompt.task,
|
|
2106
|
+
number,
|
|
2107
|
+
title: issue.title,
|
|
2108
|
+
repo: slug,
|
|
2109
|
+
epic: issue.parent ? locale_default.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
|
|
2110
|
+
script: `bun --no-env-file ${import.meta.path}`,
|
|
2111
|
+
waitCi: `bash ${waitCi()}`,
|
|
2112
|
+
inProgress: N.status.inProgress,
|
|
2113
|
+
done: N.lastLine.done,
|
|
2114
|
+
questions: N.sections.questions,
|
|
2115
|
+
branches: restart.map((b) => `\`${b}\``).join(", ")
|
|
2116
|
+
};
|
|
2117
|
+
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];
|
|
2118
|
+
for (const line of lines)
|
|
2119
|
+
io.out(locale_default.fill(line, vars));
|
|
2120
|
+
return 0;
|
|
2121
|
+
}
|
|
2122
|
+
function actualizeChecks() {
|
|
2123
|
+
const file = path.resolve(import.meta.dir, "../reference.md");
|
|
2124
|
+
const m = existsSync(file) ? /Five checks:\n\n([\s\S]*?)\n\nWhat was fixed/.exec(readFileSync(file, "utf8")) : null;
|
|
2125
|
+
const items = m ? m[1].split(/\n(?=\d\. )/) : [];
|
|
2126
|
+
if (items.length !== 5)
|
|
2127
|
+
throw new GhError(`the five epic-refresh checks not found in ${file}: expected a list of five between «Five checks:» and «What was fixed»`);
|
|
2128
|
+
return items;
|
|
2129
|
+
}
|
|
2130
|
+
var MEANING_CHECKS = [0, 2, 4];
|
|
2131
|
+
var ANSWER_MARK_RE = /(?:^\s*(?:\d+\.\s+)?|[?.!—–][»"'*_)`]*\s+)[*_(]*✅/;
|
|
2132
|
+
var THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
2133
|
+
function questionItems(body) {
|
|
2134
|
+
const section = new RegExp(`^(?:${locale_default.every((n) => n.sections.questions).map(locale_default.escapeRe).join("|")})\\s*$`);
|
|
2135
|
+
const lines = body.split(/\r?\n/);
|
|
2136
|
+
const from = lines.findIndex((l) => section.test(l));
|
|
2137
|
+
if (from < 0)
|
|
2138
|
+
return [];
|
|
2139
|
+
const items = [];
|
|
2140
|
+
for (let i = from + 1;i < lines.length && !/^##\s/.test(lines[i]) && !THEMATIC_BREAK_RE.test(lines[i]); i++) {
|
|
2141
|
+
if (/^\d+\.\s/.test(lines[i]))
|
|
2142
|
+
items.push({ first: i, last: i });
|
|
2143
|
+
else if (items.length && lines[i].trim())
|
|
2144
|
+
items.at(-1).last = i;
|
|
2145
|
+
}
|
|
2146
|
+
return items.map(({ first, last }) => {
|
|
2147
|
+
const item = lines.slice(first, last + 1).filter((l) => l.trim());
|
|
2148
|
+
return { k: parseInt(lines[first], 10), text: lines[first].trim(), lines: item, answered: item.some((l) => ANSWER_MARK_RE.test(l)), first, last };
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
function loadTaskText(io, slug, number) {
|
|
2152
|
+
const [owner, name] = slug.split("/");
|
|
2153
|
+
const i = graphql(io, Q.TaskText, { owner, name, number })?.repository?.issue;
|
|
2154
|
+
if (!i)
|
|
2155
|
+
throw new GhError(`no issue #${number} in ${slug}`);
|
|
2156
|
+
const milestone = (m) => m ? { number: m.number, title: m.title, state: m.state, open: m.issues?.totalCount ?? 0 } : null;
|
|
2157
|
+
return {
|
|
2158
|
+
id: i.id,
|
|
2159
|
+
number: i.number,
|
|
2160
|
+
title: i.title,
|
|
2161
|
+
state: i.state,
|
|
2162
|
+
body: (i.body ?? "").replace(/\r\n/g, `
|
|
2163
|
+
`).trim(),
|
|
2164
|
+
labels: i.labels?.nodes ?? [],
|
|
2165
|
+
milestone: milestone(i.milestone),
|
|
2166
|
+
blockedBy: (i.blockedBy?.nodes ?? []).map((b) => ({ id: b.id, number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
|
|
2167
|
+
comments: i.comments?.nodes ?? [],
|
|
2168
|
+
subIssues: (i.subIssues?.nodes ?? []).map((x) => ({ number: x.number, title: x.title, state: x.state, milestone: milestone(x.milestone) }))
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function refreshMechanics(io, slug, r) {
|
|
2172
|
+
const fixes = [];
|
|
2173
|
+
const tasks = [r.epic, ...r.open];
|
|
2174
|
+
for (const t of tasks) {
|
|
2175
|
+
const done = t.blockedBy.filter((b) => b.state === "CLOSED" || r.merged && blockerRef(slug, b) === `#${r.number}`);
|
|
2176
|
+
if (!done.length)
|
|
2177
|
+
continue;
|
|
2178
|
+
for (const b of done)
|
|
2179
|
+
mutate(io, { op: "RemoveBlockedBy", input: { issueId: t.id, blockingIssueId: b.id } });
|
|
2180
|
+
io.out(`+ #${t.number}: blocked by ${done.map((b) => `${blockerRef(slug, b)} (${b.state === "CLOSED" ? "closed" : "merged"})`).join(", ")} removed`);
|
|
2181
|
+
fixes.push(locale_default.fill(N.actualize.unblocked, { task: t.number, blockers: done.map((b) => blockerRef(slug, b)).join(", ") }));
|
|
2182
|
+
}
|
|
2183
|
+
const questionLabels = locale_default.every((n) => n.labels.questions);
|
|
2184
|
+
for (const t of tasks) {
|
|
2185
|
+
const labels = t.labels.filter((l) => questionLabels.includes(l.name));
|
|
2186
|
+
if (!labels.length || questionItems(t.body).some((x) => !x.answered))
|
|
2187
|
+
continue;
|
|
2188
|
+
mutate(io, { op: "RemoveLabels", input: { labelableId: t.id, labelIds: labels.map((l) => l.id) } });
|
|
2189
|
+
for (const l of labels) {
|
|
2190
|
+
io.out(`+ #${t.number}: label ${q(l.name)} removed — no open questions`);
|
|
2191
|
+
fixes.push(locale_default.fill(N.actualize.unlabeled, { task: t.number, label: l.name }));
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
const own = r.epic.subIssues.find((x) => x.number === r.number)?.milestone?.number;
|
|
2195
|
+
const seen = new Set([own, r.epic.milestone?.number]);
|
|
2196
|
+
for (const { milestone: m } of r.epic.subIssues) {
|
|
2197
|
+
if (!m || seen.has(m.number))
|
|
2198
|
+
continue;
|
|
2199
|
+
seen.add(m.number);
|
|
2200
|
+
if (m.state !== "OPEN" || m.open > 0)
|
|
2201
|
+
continue;
|
|
2202
|
+
io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
|
|
2203
|
+
io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
|
|
2204
|
+
fixes.push(locale_default.fill(N.actualize.milestone, { title: m.title }));
|
|
2205
|
+
}
|
|
2206
|
+
if (!fixes.length)
|
|
2207
|
+
return;
|
|
2208
|
+
mutate(io, { op: "AddComment", input: { subjectId: r.epic.id, body: locale_default.fill(N.actualize.mechanics, { number: r.number, fixes: fixes.join("; ") }) } });
|
|
2209
|
+
io.out(`+ epic #${r.epic.number}: a comment with the script's fixes`);
|
|
2210
|
+
}
|
|
2211
|
+
var clip = (line) => line.length > 200 ? `${line.slice(0, 199)}…` : line;
|
|
2212
|
+
function linkCopies(epic, open) {
|
|
2213
|
+
const subs = new Set(epic.subIssues.map((x) => x.number));
|
|
2214
|
+
const words = locale_default.every((n) => n.actualize.linkWords).flatMap((w) => w.split("|")).map((w) => w.trim()).filter(Boolean).map(locale_default.escapeRe);
|
|
2215
|
+
const phrase = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${words.join("|")})\\s*:?\\s*#\\d+`, "iu");
|
|
2216
|
+
const item = /^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?#(\d+)(?!\d)/;
|
|
2217
|
+
const out = [];
|
|
2218
|
+
for (const t of [epic, ...open]) {
|
|
2219
|
+
for (const raw of t.body.split(`
|
|
2220
|
+
`)) {
|
|
2221
|
+
const listed = t === epic ? item.exec(raw) : null;
|
|
2222
|
+
if (listed && subs.has(Number(listed[1])) || raw.includes("⛔") || phrase.test(raw))
|
|
2223
|
+
out.push({ number: t.number, line: clip(raw.trim()) });
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return out;
|
|
2227
|
+
}
|
|
2228
|
+
function commentAnswers(tasks) {
|
|
2229
|
+
const out = [];
|
|
2230
|
+
for (const t of tasks) {
|
|
2231
|
+
const open = new Set(questionItems(t.body).filter((x) => !x.answered).map((x) => x.k));
|
|
2232
|
+
if (!open.size)
|
|
2233
|
+
continue;
|
|
2234
|
+
const found = new Map;
|
|
2235
|
+
for (const c of t.comments)
|
|
2236
|
+
for (const m of c.body.matchAll(/^\s*(\d+)\.\s*✅\s*(.+)$/gm))
|
|
2237
|
+
if (open.has(Number(m[1])))
|
|
2238
|
+
found.set(Number(m[1]), { text: clip(m[2].trim()), url: c.url });
|
|
2239
|
+
for (const [k, a] of [...found].sort(([x], [y]) => x - y))
|
|
2240
|
+
out.push({ number: t.number, k, ...a });
|
|
2241
|
+
}
|
|
2242
|
+
return out;
|
|
2243
|
+
}
|
|
2244
|
+
var fileLine = (f) => `- ${f.path} (${f.changeType === "MODIFIED" ? "" : `${f.changeType.toLowerCase()}, `}+${f.additions} −${f.deletions})`;
|
|
2245
|
+
var textBlock = (body) => body ? `~~~~markdown
|
|
2246
|
+
${body}
|
|
2247
|
+
~~~~` : N.actualize.noBody;
|
|
2248
|
+
function cmdTaskActualize(io, slug, number, o = { assignment: false }) {
|
|
2249
|
+
const issue = loadIssue(io, slug, number);
|
|
2250
|
+
const later = `Next: task close ${number} — after the deploy, where the merge deploys.`;
|
|
2251
|
+
if (!issue.parent)
|
|
2252
|
+
return io.out(`○ refresh not needed: #${number} has no epic. ${later}`), 0;
|
|
2253
|
+
const linked = issue.prs.filter((p) => p.merged);
|
|
2254
|
+
const prs = linked.length ? linked : unlinkedPrs(io, slug, number).prs.filter((p) => p.merged);
|
|
2255
|
+
const epic = loadTaskText(io, slug, issue.parent.number);
|
|
2256
|
+
const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).map((x) => loadTaskText(io, slug, x.number));
|
|
2257
|
+
if (!o.assignment)
|
|
2258
|
+
refreshMechanics(io, slug, { number, merged: prs.length > 0, epic, open });
|
|
2259
|
+
if (!open.length)
|
|
2260
|
+
return io.out(`○ refresh not needed: epic #${epic.number} has no open tasks besides #${number}. ${later}`), 0;
|
|
2261
|
+
const vars = { task: N.subagentPrompt.task, epicWord: N.subagentPrompt.epic, number, epic: epic.number, repo: slug };
|
|
2262
|
+
const lead = locale_default.fill(N.actualize.lead, vars);
|
|
2263
|
+
if (!o.assignment) {
|
|
2264
|
+
io.out("Next: a subagent in the background — the two lines below are its whole prompt, pass them as is.");
|
|
2265
|
+
io.out(lead);
|
|
2266
|
+
io.out(locale_default.fill(N.actualize.fetch, { command: `bun --no-env-file ${import.meta.path} task actualize ${number} --assignment --repo ${slug}` }));
|
|
2267
|
+
return 0;
|
|
2268
|
+
}
|
|
2269
|
+
const checks = actualizeChecks();
|
|
2270
|
+
const shipped = prs.length ? locale_default.fill(N.actualize.prs, { list: prs.map((p) => `#${p.number} (\`gh pr view ${p.number}\`, \`gh pr diff ${p.number}\`)`).join(", ") }) : locale_default.fill(N.actualize.noPr, { command: `\`git log origin/main --grep '#${number}'\`` });
|
|
2271
|
+
const lines = [lead, locale_default.fill(N.actualize.shipped, { ...vars, title: issue.title, shipped })];
|
|
2272
|
+
for (const p of prs) {
|
|
2273
|
+
const files = loadPrChange(io, slug, p.number).files;
|
|
2274
|
+
if (files.length)
|
|
2275
|
+
lines.push(locale_default.fill(N.actualize.files, { pr: p.number }), ...files.map(fileLine));
|
|
2276
|
+
}
|
|
2277
|
+
lines.push("", locale_default.fill(N.actualize.epic, { ...vars, title: epic.title }), textBlock(epic.body), "", N.actualize.openTasks);
|
|
2278
|
+
for (const t of open)
|
|
2279
|
+
lines.push("", locale_default.fill(N.actualize.openTask, { number: t.number, title: t.title }), textBlock(t.body));
|
|
2280
|
+
const copies = linkCopies(epic, open);
|
|
2281
|
+
if (copies.length)
|
|
2282
|
+
lines.push("", N.actualize.copies, ...copies.map((c) => locale_default.fill(N.actualize.copy, c)));
|
|
2283
|
+
const answers = commentAnswers([epic, ...open]);
|
|
2284
|
+
if (answers.length)
|
|
2285
|
+
lines.push("", locale_default.fill(N.actualize.answers, { questions: N.sections.questions }), ...answers.map((a) => locale_default.fill(N.actualize.commentAnswer, a)));
|
|
2286
|
+
lines.push("", N.actualize.checks, "", MEANING_CHECKS.map((i) => checks[i]).join(`
|
|
2287
|
+
`), "", locale_default.fill(N.actualize.scope, { ...vars, questions: N.sections.questions, script: `bun --no-env-file ${import.meta.path}` }), locale_default.fill(N.actualize.comment, { ...vars, report: locale_default.fill(N.actualize.report, { number, text: "…" }) }), N.actualize.answer);
|
|
2288
|
+
for (const line of lines)
|
|
2289
|
+
io.out(line);
|
|
2290
|
+
return 0;
|
|
2291
|
+
}
|
|
2292
|
+
function refreshReported(epic, number) {
|
|
2293
|
+
const heads = locale_default.every((n) => n.actualize.report).map((t) => locale_default.fill(t, { number, text: "" }).trim().toLowerCase());
|
|
2294
|
+
return epic.comments.some((c) => heads.some((h) => c.body.toLowerCase().includes(h)));
|
|
2295
|
+
}
|
|
2296
|
+
function cmdTaskClose(io, slug, number, o) {
|
|
2297
|
+
const ctx = taskContext(io, slug);
|
|
2298
|
+
const issue = loadIssue(io, slug, number);
|
|
2299
|
+
const linked = issue.prs.filter((p) => p.merged);
|
|
2300
|
+
const closedNoPr = !issue.prs.length && issue.state === "CLOSED" && issue.stateReason === "COMPLETED";
|
|
2301
|
+
const found = !linked.length && issue.state === "OPEN" ? unlinkedPrs(io, slug, number) : null;
|
|
2302
|
+
const fresh = (found?.prs ?? []).filter((p) => !issue.prs.some((x) => x.number === p.number));
|
|
2303
|
+
const unlinked = fresh.filter((p) => p.merged);
|
|
2304
|
+
const merged = linked.length ? linked : unlinked;
|
|
2305
|
+
if (!merged.length && !closedNoPr) {
|
|
2306
|
+
const prs = [...issue.prs, ...fresh].map((p) => `#${p.number} ${p.state === "OPEN" ? "open" : "closed without a merge"}`).join(", ");
|
|
2307
|
+
const what = prs ? `PR ${prs}` : `the task is ${issue.state === "OPEN" ? "open" : "closed without being done"}, no merged PR`;
|
|
2308
|
+
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}` : "";
|
|
2309
|
+
throw new GhError(`#${number} is too early to close: ${what}${checked} — close after the PR is merged or the task is closed`);
|
|
2310
|
+
}
|
|
2311
|
+
if (issue.parent) {
|
|
2312
|
+
const epic = loadTaskText(io, slug, issue.parent.number);
|
|
2313
|
+
const others = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
|
|
2314
|
+
if (others && !refreshReported(epic, number)) {
|
|
2315
|
+
const head = locale_default.fill(N.actualize.report, { number, text: "…" });
|
|
2316
|
+
throw new GhError(`#${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. ` + `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; ` + `it answered without the comment — post its result: gh issue comment ${epic.number} --repo ${slug} --body "${head}"`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
const wasOpen = issue.state === "OPEN";
|
|
2320
|
+
if (wasOpen) {
|
|
2321
|
+
if (unlinked.length)
|
|
2322
|
+
mutate(io, { op: "AddComment", input: { subjectId: issue.id, body: locale_default.fill(N.comments.closedByPr, { prs: unlinked.map((p) => `#${p.number}`).join(", ") }) } });
|
|
2323
|
+
mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: "COMPLETED" } });
|
|
2324
|
+
io.out(unlinked.length ? `+ #${number} closed by PR ${unlinked.map((p) => `#${p.number} (${p.why})`).join(", ")}: GitHub didn't link the PR to the task` : `+ #${number} closed: PR ${merged.map((p) => `#${p.number}`).join(", ")} merged`);
|
|
2325
|
+
}
|
|
2326
|
+
freshClone(io, slug);
|
|
2327
|
+
runFact(io, slug, number);
|
|
2328
|
+
const item = ensureItem(io, ctx, issue);
|
|
2329
|
+
setStatus(io, ctx, item.id, N.status.done);
|
|
2330
|
+
io.out(`+ #${number}: ${STATUS} ${q(N.status.done)}${item.added ? " (added to the project)" : ""}`);
|
|
2331
|
+
closeMilestone(io, slug, issue, wasOpen);
|
|
2332
|
+
if (issue.parent) {
|
|
2333
|
+
const epic = loadIssue(io, slug, issue.parent.number);
|
|
2334
|
+
const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
|
|
2335
|
+
if (epic.state === "OPEN" && epic.subIssues.length && !open) {
|
|
2336
|
+
mutate(io, { op: "CloseIssue", input: { issueId: epic.id, stateReason: "COMPLETED" } });
|
|
2337
|
+
setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.done);
|
|
2338
|
+
io.out(`+ epic #${epic.number} closed and in ${q(N.status.done)}: all subtasks are closed`);
|
|
2339
|
+
closeMilestone(io, slug, epic, true);
|
|
2340
|
+
} else if (epic.state === "OPEN")
|
|
2341
|
+
io.out(`○ epic #${epic.number}: open subtasks ${open}`);
|
|
2342
|
+
}
|
|
2343
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
2344
|
+
io.out(`○ ${o.git ? "branches and task stacks" : "task stacks"} left alone: the current directory is not a checkout of ${slug}`);
|
|
2345
|
+
else {
|
|
2346
|
+
takeDownStacks(io, number);
|
|
2347
|
+
if (o.git) {
|
|
2348
|
+
const more = (found ?? unlinkedPrs(io, slug, number)).prs.filter((p) => p.merged && !merged.some((x) => x.number === p.number));
|
|
2349
|
+
cleanupBranches(io, number, [...merged, ...more]);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
io.out(`Next: the last line of the answer is one of three: ${q(N.lastLine.done)}; ` + `the owner has something to decide — ${q(`${N.lastLine.questions} …`)} with the questions themselves, without ${q(N.lastLine.done)}; ` + `something remains — ${q(`${N.lastLine.remaining} …`)}. ` + `In auto mode after ${q(N.lastLine.done)} — archive the session where the agent has the tool, ` + `first stopping its own background tasks still running (a wait, a watcher over its output, a subagent): a live one blocks archiving; ` + `after the other two lines — don't. ` + `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)}`);
|
|
2353
|
+
return 0;
|
|
2354
|
+
}
|
|
2355
|
+
function cmdTaskResume(io) {
|
|
2356
|
+
const dispatcher = dispatcherFile(io);
|
|
2357
|
+
const queued = !!dispatcher && existsSync(dispatcher);
|
|
2358
|
+
if (queued || dispatcher && existsSync(`${dispatcher}.tasks`)) {
|
|
2359
|
+
io.out("Dispatcher session: its executors (background subagents) stopped with the app's process, their transcripts are intact — " + "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" + (queued ? `; then re-read the queue: ${readFileSync(dispatcher, "utf8").trim()} — their tasks stay = in it` : ""));
|
|
2360
|
+
io.out("Dispatcher session: a new executor — only for one that can't be continued, with the same prompt; " + `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: ` + "git won't give the branch to a second worktree, and uncommitted work stays in the old one");
|
|
2361
|
+
}
|
|
2362
|
+
const pin = pinnedTask(io);
|
|
2363
|
+
if (!pin)
|
|
2364
|
+
return 0;
|
|
2365
|
+
if (!pin.task)
|
|
2366
|
+
return io.out(`○ the session pin ${pin.file} — «${pin.line}», not owner/repo#N: the session has no task`), 0;
|
|
2367
|
+
const { slug, number } = pin.task;
|
|
2368
|
+
const task = `session task ${slug}#${number}`;
|
|
2369
|
+
const issue = loadIssue(io, slug, number);
|
|
2370
|
+
if (issue.state !== "OPEN")
|
|
2371
|
+
return io.out(`○ ${task} is closed — no wait to restart`), 0;
|
|
2372
|
+
const live = (prs) => prs.filter((p) => p.state === "OPEN" || p.merged);
|
|
2373
|
+
const prs = live(issue.prs).length ? live(issue.prs) : live(unlinkedPrs(io, slug, number).prs);
|
|
2374
|
+
const open = prs.filter((p) => p.state === "OPEN");
|
|
2375
|
+
for (const p of open)
|
|
2376
|
+
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}`);
|
|
2377
|
+
const merged = prs.filter((p) => p.merged).sort((a, b) => b.number - a.number)[0];
|
|
2378
|
+
if (!open.length && merged)
|
|
2379
|
+
io.out(`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}; ` + `then close the task: bun --no-env-file ${import.meta.path} task close ${number}`);
|
|
2380
|
+
if (!prs.length)
|
|
2381
|
+
io.out(`○ ${task}: no PR — no wait to restart`);
|
|
2382
|
+
return 0;
|
|
2383
|
+
}
|
|
2384
|
+
var TASK_TYPES = ["feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build", "research"];
|
|
2385
|
+
var TASK_BRANCH_RE = new RegExp("^(?:[\\p{L}\\p{N}_.-]+/)?(" + TASK_TYPES.join("|") + ")/(\\d{1,6})-", "iu");
|
|
2386
|
+
function taskBranch(branch) {
|
|
2387
|
+
const m = TASK_BRANCH_RE.exec(branch);
|
|
2388
|
+
return m ? { type: m[1].toLowerCase(), issue: parseInt(m[2], 10) } : null;
|
|
2389
|
+
}
|
|
2390
|
+
var isTaskBranch = (branch, number) => taskBranch(branch)?.issue === number;
|
|
2391
|
+
var TASK_SLUG_RE = /\/\d{1,6}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2392
|
+
var CONFIG_LOCK_TRIES = 20;
|
|
2393
|
+
var CONFIG_LOCK_PAUSE_MS = 500;
|
|
2394
|
+
var CONFIG_LOCKED = /could not lock config file (.+)$/m;
|
|
2395
|
+
function renameBranch(io, slug, target) {
|
|
2396
|
+
const git = (...args) => (io.run ?? realRun)("git", args, io.cwd);
|
|
2397
|
+
const has = (ref) => git("rev-parse", "--verify", "--quiet", ref).status === 0;
|
|
2398
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
2399
|
+
return io.out(`○ branch left alone: the current directory is not a checkout of ${slug}`), true;
|
|
2400
|
+
const current = git("branch", "--show-current").stdout.trim();
|
|
2401
|
+
if (!current) {
|
|
2402
|
+
const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim() || "origin/main";
|
|
2403
|
+
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;
|
|
2404
|
+
}
|
|
2405
|
+
if (current === target)
|
|
2406
|
+
return io.out(`= branch ${target}`), true;
|
|
2407
|
+
if (has(`refs/remotes/origin/${current}`))
|
|
2408
|
+
return io.out(`○ branch ${current} is already on origin — left alone: a pushed branch is renamed together with its PR, by hand`), true;
|
|
2409
|
+
let renamed = false;
|
|
2410
|
+
let lock = "";
|
|
2411
|
+
for (let k = 0;k < CONFIG_LOCK_TRIES; k++) {
|
|
2412
|
+
if (k)
|
|
2413
|
+
(io.sleep ?? pause)(CONFIG_LOCK_PAUSE_MS);
|
|
2414
|
+
const args = renamed ? ["config", "--rename-section", `branch.${current}`, `branch.${target}`] : ["branch", "-m", current, target];
|
|
2415
|
+
const r = git(...args);
|
|
2416
|
+
if (r.status === 0 || renamed && /no such section/.test(r.stderr)) {
|
|
2417
|
+
return io.out(`+ branch ${current} → ${target}${k ? ` (.git/config was locked: ${k} retries)` : ""}`), true;
|
|
2418
|
+
}
|
|
2419
|
+
const locked = CONFIG_LOCKED.exec(r.stderr);
|
|
2420
|
+
if (!locked)
|
|
2421
|
+
return io.out(`! git ${args.join(" ")}: ${r.stderr.trim().split(`
|
|
2422
|
+
`).filter(Boolean).pop() ?? `code ${r.status}`}`), false;
|
|
2423
|
+
lock = `${path.resolve(io.cwd ?? process.cwd(), locked[1].trim())}.lock`;
|
|
2424
|
+
renamed ||= !has(`refs/heads/${current}`) && has(`refs/heads/${target}`);
|
|
2425
|
+
}
|
|
2426
|
+
let age = "";
|
|
2427
|
+
try {
|
|
2428
|
+
age = ` (${Math.round((Date.now() - statSync(lock).mtimeMs) / 1000)} s)`;
|
|
2429
|
+
} catch {}
|
|
2430
|
+
const what = renamed ? `renamed to ${target}, its section in .git/config is not` : `${current} is not renamed`;
|
|
2431
|
+
const finish = renamed ? `git config --rename-section branch.${current} branch.${target}` : `git branch -m ${current} ${target}`;
|
|
2432
|
+
io.out(`! branch ${what}: ${lock} is held by another git process${age} — not removed; finish: ${finish}`);
|
|
2433
|
+
return false;
|
|
2434
|
+
}
|
|
2435
|
+
function unlinkedPrs(io, slug, number) {
|
|
2436
|
+
const [owner, name] = slug.split("/");
|
|
2437
|
+
const r = graphql(io, Q.RecentPrs, { owner, name })?.repository;
|
|
2438
|
+
const base = r?.defaultBranchRef?.name ?? "main";
|
|
2439
|
+
const prs = [];
|
|
2440
|
+
for (const p of r?.pullRequests?.nodes ?? []) {
|
|
2441
|
+
if (p.baseRefName !== base)
|
|
2442
|
+
continue;
|
|
2443
|
+
const head = p.headRefName ?? "";
|
|
2444
|
+
const why = isTaskBranch(head, number) ? `branch ${head}` : closingInBody(p.body ?? "").includes(number) ? `«Closes #${number}» in the body` : null;
|
|
2445
|
+
if (why)
|
|
2446
|
+
prs.push({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head, base, why });
|
|
2447
|
+
}
|
|
2448
|
+
return { base, prs };
|
|
2449
|
+
}
|
|
2450
|
+
var estScript = (io) => io.env.ELVES_EST ?? path.resolve(import.meta.dir, "../../est/scripts/est.ts");
|
|
2451
|
+
function originUrl(io) {
|
|
2452
|
+
const remote = (io.run ?? realRun)("git", ["remote", "get-url", "origin"], io.cwd);
|
|
2453
|
+
return remote.status === 0 ? remote.stdout : "";
|
|
2454
|
+
}
|
|
2455
|
+
var taskEnvScript = (io) => io.env.ELVES_TASK_ENV ?? path.resolve(import.meta.dir, "../../task-env/scripts/task-env.ts");
|
|
2456
|
+
function takeDownStacks(io, number) {
|
|
2457
|
+
const script = taskEnvScript(io);
|
|
2458
|
+
if (!existsSync(script))
|
|
2459
|
+
return io.out(`○ task stacks: task-env is not installed (${script})`);
|
|
2460
|
+
const r = (io.run ?? realRun)("bun", ["--no-env-file", script, "down", String(number)], io.cwd);
|
|
2461
|
+
const lines = r.stdout.split(`
|
|
2462
|
+
`).filter((l) => l.trim());
|
|
2463
|
+
for (const line of lines)
|
|
2464
|
+
io.out(line);
|
|
2465
|
+
if (r.status !== 0 && !lines.length)
|
|
2466
|
+
io.out(`! task-env down: ${r.stderr.trim().split(`
|
|
2467
|
+
`).filter(Boolean).pop() ?? `code ${r.status}`}`);
|
|
2468
|
+
}
|
|
2469
|
+
function runEst(io, est, args) {
|
|
2470
|
+
const r = (io.run ?? realRun)("bun", ["--no-env-file", est, ...args], io.cwd);
|
|
2471
|
+
for (const line of r.stdout.split(`
|
|
2472
|
+
`))
|
|
2473
|
+
if (line.trim() && !line.startsWith("<!--"))
|
|
2474
|
+
io.out(line);
|
|
2475
|
+
if (r.status !== 0)
|
|
2476
|
+
io.out(`! est ${args[0]}: ${r.stderr.trim().split(`
|
|
2477
|
+
`).filter(Boolean).pop() ?? `code ${r.status}`}`);
|
|
2478
|
+
}
|
|
2479
|
+
function freshClone(io, slug) {
|
|
2480
|
+
const est = estScript(io);
|
|
2481
|
+
if (!existsSync(est))
|
|
2482
|
+
return;
|
|
2483
|
+
const run = io.run ?? realRun;
|
|
2484
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], path.dirname(est));
|
|
2485
|
+
if (top.status !== 0)
|
|
2486
|
+
return;
|
|
2487
|
+
const dir = top.stdout.trim();
|
|
2488
|
+
const git = (...args) => run("git", args, dir);
|
|
2489
|
+
const origin = git("remote", "get-url", "origin");
|
|
2490
|
+
if (origin.status !== 0 || !checkoutOfRepo(io, origin.stdout, slug))
|
|
2491
|
+
return;
|
|
2492
|
+
const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
|
|
2493
|
+
const branch = git("branch", "--show-current").stdout.trim();
|
|
2494
|
+
const left = (why) => io.out(`○ clone ${dir} left alone: ${why} — the actual is by its code`);
|
|
2495
|
+
if (branch !== base)
|
|
2496
|
+
return left(branch ? `on branch ${branch}, not ${base}` : `HEAD is detached, not on ${base}`);
|
|
2497
|
+
const status = git("status", "--porcelain", "--untracked-files=no");
|
|
2498
|
+
if (status.status !== 0 || status.stdout.trim())
|
|
2499
|
+
return left("uncommitted changes");
|
|
2500
|
+
const head = () => git("rev-parse", "--short", "HEAD").stdout.trim();
|
|
2501
|
+
const before = head();
|
|
2502
|
+
const pull = git("pull", "--ff-only", "--no-rebase", "--quiet", "origin", base);
|
|
2503
|
+
if (pull.status !== 0) {
|
|
2504
|
+
const why = pull.stderr.trim().split(`
|
|
2505
|
+
`).filter(Boolean).pop() ?? `code ${pull.status}`;
|
|
2506
|
+
return io.out(`! clone ${dir}: git pull --ff-only origin ${base}: ${why} — the actual is by its code`);
|
|
2507
|
+
}
|
|
2508
|
+
const after = head();
|
|
2509
|
+
io.out(after === before ? `= clone ${dir} ${after}` : `+ clone ${dir} ${before} → ${after}`);
|
|
2510
|
+
}
|
|
2511
|
+
function runFact(io, slug, number) {
|
|
2512
|
+
const est = estScript(io);
|
|
2513
|
+
if (!existsSync(est))
|
|
2514
|
+
return io.out(`○ actual unavailable: est is not installed (${est}) — est fact ${number} --write after installing it`);
|
|
2515
|
+
runEst(io, est, ["fact", String(number), "--repo", slug, "--write"]);
|
|
2516
|
+
}
|
|
2517
|
+
function runEstimate(io, slug, number, type) {
|
|
2518
|
+
const est = estScript(io);
|
|
2519
|
+
if (!existsSync(est))
|
|
2520
|
+
return io.out(`○ no estimate: est is not installed (${est})`);
|
|
2521
|
+
runEst(io, est, ["estimate", String(number), "--repo", slug, "--type", type, "--write"]);
|
|
2522
|
+
}
|
|
2523
|
+
function closeMilestone(io, slug, issue, wasOpen) {
|
|
2524
|
+
const m = issue.milestone;
|
|
2525
|
+
if (!m)
|
|
2526
|
+
return;
|
|
2527
|
+
const open = m.open - (wasOpen ? 1 : 0);
|
|
2528
|
+
if (open > 0)
|
|
2529
|
+
return io.out(`○ milestone ${q(m.title)}: open tasks ${open}`);
|
|
2530
|
+
io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
|
|
2531
|
+
io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
|
|
2532
|
+
}
|
|
2533
|
+
function cleanupBranches(io, number, prs) {
|
|
2534
|
+
const git = (...args) => (io.run ?? realRun)("git", args, io.cwd);
|
|
2535
|
+
const current = git("branch", "--show-current").stdout.trim();
|
|
2536
|
+
const merged = new Map;
|
|
2537
|
+
for (const p of prs)
|
|
2538
|
+
if (p.head && !merged.has(p.head))
|
|
2539
|
+
merged.set(p.head, p.base || "main");
|
|
2540
|
+
const loose = current && !merged.has(current) && isTaskBranch(current, number) ? current : "";
|
|
2541
|
+
if (!merged.size && !loose)
|
|
2542
|
+
return io.out("○ the task branch not found — branches left alone");
|
|
2543
|
+
git("fetch", "--prune", "origin");
|
|
2544
|
+
if (loose) {
|
|
2545
|
+
const base = prs[0]?.base || "main";
|
|
2546
|
+
const cherry = git("cherry", `origin/${base}`, loose);
|
|
2547
|
+
if (cherry.status !== 0 || cherry.stdout.split(`
|
|
2548
|
+
`).some((l) => l.startsWith("+")))
|
|
2549
|
+
io.out(`○ branch ${loose} is not merged into origin/${base} — left alone`);
|
|
2550
|
+
else
|
|
2551
|
+
merged.set(loose, base);
|
|
2552
|
+
}
|
|
2553
|
+
if (!merged.size)
|
|
2554
|
+
return;
|
|
2555
|
+
for (const [branch, base] of merged) {
|
|
2556
|
+
if (current === branch) {
|
|
2557
|
+
const sw = git("switch", "--detach", `origin/${base}`);
|
|
2558
|
+
if (sw.status !== 0) {
|
|
2559
|
+
io.out(`! git switch --detach origin/${base}: ${sw.stderr.trim()}`);
|
|
2560
|
+
continue;
|
|
2561
|
+
}
|
|
2562
|
+
io.out(`+ checkout — on origin/${base} (detached)`);
|
|
2563
|
+
}
|
|
2564
|
+
if (git("branch", "--list", branch).stdout.trim()) {
|
|
2565
|
+
const d = git("branch", "-D", branch);
|
|
2566
|
+
io.out(d.status === 0 ? `+ local branch ${branch} deleted` : `! git branch -D ${branch}: ${d.stderr.trim()}`);
|
|
2567
|
+
}
|
|
2568
|
+
if (git("ls-remote", "--heads", "origin", branch).stdout.trim()) {
|
|
2569
|
+
const d = git("push", "origin", "--delete", branch);
|
|
2570
|
+
io.out(d.status === 0 ? `+ remote branch ${branch} deleted` : `! git push origin --delete ${branch}: ${d.stderr.trim()}`);
|
|
2571
|
+
} else
|
|
2572
|
+
io.out(`○ remote branch ${branch} is already gone`);
|
|
2573
|
+
}
|
|
2574
|
+
const top = git("rev-parse", "--show-toplevel").stdout.trim();
|
|
2575
|
+
const common = git("rev-parse", "--git-common-dir").stdout.trim();
|
|
2576
|
+
if (!top || !common || path.resolve(io.cwd ?? process.cwd(), common) === path.join(top, ".git"))
|
|
2577
|
+
return;
|
|
2578
|
+
if (AGENT_WORKTREE.test(top))
|
|
2579
|
+
io.out(`○ agent worktree ${top}: the dispatcher's next task queue removes it`);
|
|
2580
|
+
else
|
|
2581
|
+
io.out(`Next: remove worktree ${top} from the main checkout: git worktree remove ${top}`);
|
|
2582
|
+
}
|
|
2583
|
+
function addLabels(io, issue, names, all) {
|
|
2584
|
+
const missing = names.filter((n) => !issue.labels.includes(n));
|
|
2585
|
+
if (!missing.length)
|
|
2586
|
+
return "";
|
|
2587
|
+
if (issue.rest)
|
|
2588
|
+
rest(io, `${issue.rest}/labels`, { method: "POST", payload: { labels: missing } });
|
|
2589
|
+
else
|
|
2590
|
+
mutate(io, { op: "AddLabels", input: { labelableId: issue.id, labelIds: missing.map((n) => all.find((l) => l.name === n).id) } });
|
|
2591
|
+
issue.labels.push(...missing);
|
|
2592
|
+
return missing.join(", ");
|
|
2593
|
+
}
|
|
2594
|
+
var LEGACY_EXCEPTIONS = /^exceptions\.(ts|mts|js|mjs|json)$/;
|
|
2595
|
+
function exceptionFile(p) {
|
|
2596
|
+
const parts = p.split("/");
|
|
2597
|
+
if (parts[0] !== "tests" || parts.length < 4 || !DECISION_KINDS.some((k) => DECISIONS[k].dir === parts[1]))
|
|
2598
|
+
return null;
|
|
2599
|
+
const folder = parts.slice(0, 3).join("/");
|
|
2600
|
+
const inner = parts.slice(3);
|
|
2601
|
+
if (inner.some((s) => s.startsWith(".")))
|
|
2602
|
+
return null;
|
|
2603
|
+
const at = inner.findIndex((s, i) => i < inner.length - 1 ? s === "exceptions" : LEGACY_EXCEPTIONS.test(s));
|
|
2604
|
+
if (at < 0)
|
|
2605
|
+
return null;
|
|
2606
|
+
const dir = "exceptions";
|
|
2607
|
+
if (at === inner.length - 1)
|
|
2608
|
+
return { folder, dir, legacy: true };
|
|
2609
|
+
if (at > 0)
|
|
2610
|
+
return { folder, dir, legacy: false, error: "the exceptions directory is in a subfolder" };
|
|
2611
|
+
if (inner.length > 2 || !p.endsWith(".json"))
|
|
2612
|
+
return { folder, dir, legacy: false, error: "an exception is not a .json file" };
|
|
2613
|
+
return { folder, dir, legacy: false };
|
|
2614
|
+
}
|
|
2615
|
+
function mechanical(f) {
|
|
2616
|
+
if (f.changeType === "DELETED")
|
|
2617
|
+
return true;
|
|
2618
|
+
if (f.changeType === "RENAMED" && f.additions === 0 && f.deletions === 0)
|
|
2619
|
+
return true;
|
|
2620
|
+
const x = exceptionFile(f.path);
|
|
2621
|
+
return !!x && !x.error;
|
|
2622
|
+
}
|
|
2623
|
+
function decisionsOfFiles(files, modules) {
|
|
2624
|
+
const out = new Map;
|
|
2625
|
+
const add = (name, kind, f) => {
|
|
2626
|
+
const d = out.get(name) ?? { kinds: new Set, changed: false };
|
|
2627
|
+
d.kinds.add(kind);
|
|
2628
|
+
d.changed ||= !mechanical(f);
|
|
2629
|
+
out.set(name, d);
|
|
2630
|
+
};
|
|
2631
|
+
const byDir = Object.fromEntries(DECISION_KINDS.map((k) => [DECISIONS[k].dir, k]));
|
|
2632
|
+
for (const f of files) {
|
|
2633
|
+
const parts = f.path.split("/");
|
|
2634
|
+
if (parts[0] === "tests") {
|
|
2635
|
+
const kind = byDir[parts[1] ?? ""];
|
|
2636
|
+
if (kind && parts.length > 3)
|
|
2637
|
+
add(parts[2], kind, f);
|
|
2638
|
+
continue;
|
|
2639
|
+
}
|
|
2640
|
+
let best = null;
|
|
2641
|
+
for (const [m, dirs] of Object.entries(modules)) {
|
|
2642
|
+
for (const d of dirs)
|
|
2643
|
+
if ((f.path === d || f.path.startsWith(d + "/")) && (!best || d.length > best[1]))
|
|
2644
|
+
best = [m, d.length];
|
|
2645
|
+
}
|
|
2646
|
+
if (best)
|
|
2647
|
+
add(best[0], "architecture", f);
|
|
2648
|
+
}
|
|
2649
|
+
const names = [...out.keys()].sort();
|
|
2650
|
+
return {
|
|
2651
|
+
changed: new Map(names.filter((n) => out.get(n).changed).map((n) => [n, DECISION_KINDS.filter((k) => out.get(n).kinds.has(k))])),
|
|
2652
|
+
mechanical: names.filter((n) => !out.get(n).changed)
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
function statementDecisions(files, body) {
|
|
2656
|
+
const dirs = new Set(DECISION_KINDS.map((k) => DECISIONS[k].dir));
|
|
2657
|
+
const out = new Set;
|
|
2658
|
+
for (const f of files) {
|
|
2659
|
+
const [root, dir, name, file, ...rest] = f.path.split("/");
|
|
2660
|
+
if (root === "tests" && dirs.has(dir) && file === `${name}.md` && !rest.length && !mechanical(f))
|
|
2661
|
+
out.add(name);
|
|
2662
|
+
}
|
|
2663
|
+
const heads = locale_default.every((n) => n.prSpecSection);
|
|
2664
|
+
const exceptions = locale_default.every((n) => n.spec.diff.exceptionsTitle).map((t) => `**${t}`);
|
|
2665
|
+
let inSection = false;
|
|
2666
|
+
let skip = false;
|
|
2667
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
2668
|
+
const line = raw.trim();
|
|
2669
|
+
if (heads.includes(line))
|
|
2670
|
+
[inSection, skip] = [true, false];
|
|
2671
|
+
else if (/^##\s/.test(line))
|
|
2672
|
+
inSection = false;
|
|
2673
|
+
else if (!inSection)
|
|
2674
|
+
continue;
|
|
2675
|
+
else if (/^#{3,}\s/.test(line))
|
|
2676
|
+
skip = false;
|
|
2677
|
+
else if (line.startsWith("**"))
|
|
2678
|
+
skip = exceptions.some((t) => line.startsWith(t));
|
|
2679
|
+
else if (!skip) {
|
|
2680
|
+
const m = /^(?:[-*]\s+(?:✅\s+)?|\|\s*)`tests\/([^`/]+)\/([^`/]+)[^`]*`/.exec(line);
|
|
2681
|
+
if (m && dirs.has(m[1]))
|
|
2682
|
+
out.add(m[2]);
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
return out;
|
|
2686
|
+
}
|
|
2687
|
+
function putLabels(io, issue, who, want, statements, all) {
|
|
2688
|
+
const lines = [];
|
|
2689
|
+
const missing = want.filter((n) => !issue.labels.includes(n));
|
|
2690
|
+
const room = Math.max(0, ISSUE_LABEL_LIMIT - issue.labels.length);
|
|
2691
|
+
let add = missing;
|
|
2692
|
+
if (missing.length > room) {
|
|
2693
|
+
const named = statements ? missing.filter((n) => statements.has(n)) : [...missing].sort();
|
|
2694
|
+
const bodies = statements ? missing.filter((n) => !statements.has(n)) : [];
|
|
2695
|
+
add = named.slice(0, room);
|
|
2696
|
+
const which = statements ? "only decisions whose <folder>.md or test names the PR changes" : "alphabetically, as many as fit";
|
|
2697
|
+
const without = bodies.length ? `; test bodies and code only, no label: ${bodies.join(", ")}` : "";
|
|
2698
|
+
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}`);
|
|
2699
|
+
if (named.length > room)
|
|
2700
|
+
lines.push(`○ ${who}: no room — not set: ${named.slice(room).join(", ")}`);
|
|
2701
|
+
}
|
|
2702
|
+
const added = addLabels(io, issue, add, all);
|
|
2703
|
+
if (added)
|
|
2704
|
+
lines.push(`+ ${who}: ${added}`);
|
|
2705
|
+
return lines;
|
|
2706
|
+
}
|
|
2707
|
+
function closingInBody(body) {
|
|
2708
|
+
const out = [];
|
|
2709
|
+
for (const m of body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi))
|
|
2710
|
+
if (!out.includes(Number(m[1])))
|
|
2711
|
+
out.push(Number(m[1]));
|
|
2712
|
+
return out;
|
|
2713
|
+
}
|
|
2714
|
+
function loadPrChange(io, slug, number) {
|
|
2715
|
+
const [owner, name] = slug.split("/");
|
|
2716
|
+
let head = null;
|
|
2717
|
+
const files = pages((after) => {
|
|
2718
|
+
const pr = graphql(io, Q.PrChange, { owner, name, number, after })?.repository?.pullRequest;
|
|
2719
|
+
if (!pr)
|
|
2720
|
+
throw new GhError(`no PR #${number} in ${slug}`);
|
|
2721
|
+
head ??= pr;
|
|
2722
|
+
return pr.files;
|
|
2723
|
+
});
|
|
2724
|
+
return { head, files };
|
|
2725
|
+
}
|
|
2726
|
+
function cmdPrLabels(io, slug, number) {
|
|
2727
|
+
const [owner, name] = slug.split("/");
|
|
2728
|
+
const { head, files } = loadPrChange(io, slug, number);
|
|
2729
|
+
let closes = (head.closingIssuesReferences?.nodes ?? []).map((x) => x.number);
|
|
2730
|
+
if (!closes.length)
|
|
2731
|
+
closes = closingInBody(head.body ?? "");
|
|
2732
|
+
if (!closes.length)
|
|
2733
|
+
throw new GhError(`PR #${number} has no «Closes #N» — nowhere to put the labels`);
|
|
2734
|
+
const model = graphql(io, Q.ModelAt, { owner, name, expression: `${head.headRefOid}:${MODEL_PATH}` })?.repository?.object;
|
|
2735
|
+
let modules = {};
|
|
2736
|
+
if (model) {
|
|
2737
|
+
const m = modelModules(model.text ?? "");
|
|
2738
|
+
if (typeof m === "string")
|
|
2739
|
+
io.err(`warning: model ${MODEL_PATH} of the PR head doesn't load — modules not counted: ${m}`);
|
|
2740
|
+
else
|
|
2741
|
+
modules = m;
|
|
2742
|
+
}
|
|
2743
|
+
const { changed: decided, mechanical: skipped } = decisionsOfFiles(files, modules);
|
|
2744
|
+
const all = decided.size ? loadLabels(io, slug) : [];
|
|
2745
|
+
const dimensions = all.flatMap((l) => {
|
|
2746
|
+
const dim = decided.has(l.name) ? labelDimension(l.description) : null;
|
|
2747
|
+
return dim ? [{ name: l.name, dim }] : [];
|
|
2748
|
+
});
|
|
2749
|
+
const want = [...decided.keys()].filter((n) => !dimensions.some((x) => x.name === n));
|
|
2750
|
+
io.out(`PR #${number} → ${closes.map((n) => `#${n}`).join(", ")}: ${want.join(", ") || "no decisions in the diff"}`);
|
|
2751
|
+
for (const l of skipped)
|
|
2752
|
+
io.out(`○ ${l} — only a mechanical edit (a deletion, a rename without changes, exceptions): no label`);
|
|
2753
|
+
for (const x of dimensions)
|
|
2754
|
+
io.out(`○ ${x.name} — label ${q(x.name)} is slice ${q(x.dim)}, not a decision label: not set`);
|
|
2755
|
+
if (!want.length)
|
|
2756
|
+
return 0;
|
|
2757
|
+
const fresh = want.filter((l) => !all.some((x) => x.name === l));
|
|
2758
|
+
const repoId = fresh.length ? loadRepo(io, slug).id : "";
|
|
2759
|
+
for (const l of fresh) {
|
|
2760
|
+
const label = decisionLabel(l, decided.get(l));
|
|
2761
|
+
const created = mutate(io, { op: "CreateLabel", input: { repositoryId: repoId, ...label } }).createLabel.label;
|
|
2762
|
+
all.push({ id: created.id, ...label, open: { total: 0, numbers: [] } });
|
|
2763
|
+
io.out(`+ created label ${q(l)}`);
|
|
2764
|
+
}
|
|
2765
|
+
const isDecision = (n) => legacyDecision(n) !== null || labelKinds(all.find((x) => x.name === n)?.description) !== null;
|
|
2766
|
+
const statements = statementDecisions(files, head.body ?? "");
|
|
2767
|
+
const epics = new Map;
|
|
2768
|
+
for (const n of closes) {
|
|
2769
|
+
const issue = loadIssue(io, slug, n);
|
|
2770
|
+
for (const l of putLabels(io, issue, `#${n}`, want, statements, all))
|
|
2771
|
+
io.out(l);
|
|
2772
|
+
for (const l of issue.labels.filter((x) => isDecision(x) && !want.includes(x)))
|
|
2773
|
+
io.out(`= #${n}: ${l} — the decision is not in the PR diff, the label stays`);
|
|
2774
|
+
if (issue.parent && !epics.has(issue.parent.number))
|
|
2775
|
+
epics.set(issue.parent.number, loadIssue(io, slug, issue.parent.number));
|
|
2776
|
+
}
|
|
2777
|
+
for (const epic of epics.values())
|
|
2778
|
+
for (const l of putLabels(io, epic, `epic #${epic.number}`, want, statements, all))
|
|
2779
|
+
io.out(l);
|
|
2780
|
+
return 0;
|
|
2781
|
+
}
|
|
2782
|
+
var PREMERGE_SCRIPTS = ["typecheck", "test:spec"];
|
|
2783
|
+
var SPEC_ROOTS = ["tests/standards", "tests/architecture"];
|
|
2784
|
+
var specRoots = (dir) => SPEC_ROOTS.filter((d) => statSync(path.join(dir, d), { throwIfNoEntry: false })?.isDirectory());
|
|
2785
|
+
var MERGE_REF_SLACK_MS = 60000;
|
|
2786
|
+
var INSTALL = { bun: ["bun", "install", "--frozen-lockfile"], pnpm: ["pnpm", "install", "--frozen-lockfile"], yarn: ["yarn", "install", "--frozen-lockfile"], npm: ["npm", "ci"] };
|
|
2787
|
+
var FAILED_LINE = /^\s*(\(fail\)|FAIL\s|×|✗|✘|●\s)|\berror TS\d+:/;
|
|
2788
|
+
var BUN_CHECK_ERROR = /<error file="([^"]+)" line="(\d+)" column="(\d+)" code="(TS\d+)">\n([^\n]*)/g;
|
|
2789
|
+
var asTsc = (out) => out.replace(BUN_CHECK_ERROR, "$1($2,$3): error $4: $5");
|
|
2790
|
+
function packageManager(dir) {
|
|
2791
|
+
const lockfiles = [["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["package-lock.json", "npm"]];
|
|
2792
|
+
const has = (file) => existsSync(path.join(dir, file));
|
|
2793
|
+
let field = "";
|
|
2794
|
+
try {
|
|
2795
|
+
field = String(JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"))?.packageManager ?? "");
|
|
2796
|
+
} catch {}
|
|
2797
|
+
const declared = /^(bun|pnpm|yarn|npm)@((\d*)(?:\.\d+)*)/.exec(field);
|
|
2798
|
+
const found = lockfiles.filter(([file]) => has(file));
|
|
2799
|
+
const yarnrc = has(".yarnrc.yml");
|
|
2800
|
+
const name = declared?.[1] ?? found[0]?.[1] ?? (yarnrc ? "yarn" : "npm");
|
|
2801
|
+
const berry = name === "yarn" && (declared ? Number(declared[3]) >= 2 : yarnrc);
|
|
2802
|
+
return { name, berry, lockfile: (found.find(([, pm]) => pm === name) ?? found[0])?.[0] ?? null, version: declared?.[2] || null };
|
|
2803
|
+
}
|
|
2804
|
+
function projectCommands(dir) {
|
|
2805
|
+
const pm = packageManager(dir);
|
|
2806
|
+
return { install: pm.lockfile ? INSTALL[pm.name] : null, run: (script) => [pm.name, "run", script] };
|
|
2807
|
+
}
|
|
2808
|
+
var tail = (r, n = 20) => `${r.stdout}
|
|
2809
|
+
${r.stderr}`.split(`
|
|
2810
|
+
`).filter((l) => l.trim()).slice(-n);
|
|
2811
|
+
var failure = (l) => l.trim().replace(/\s*\[[\d.]+\s*m?s\]$/, "").replace(/\s+[\d.]+\s*m?s$/, "").replace(/^(\S+?)\(\d+,\d+\)(?=:)/, "$1");
|
|
2812
|
+
var waitCi = () => path.resolve(import.meta.dir, "../../ci-wait/scripts/wait-ci.sh");
|
|
2813
|
+
var RED_RUN = new Set(["failure", "timed_out", "startup_failure"]);
|
|
2814
|
+
var RED_STATUS = new Set(["failure", "error"]);
|
|
2815
|
+
var SPEC_PUBLISH = /^(?:.+ \/ )?spec-publish$/;
|
|
2816
|
+
function redChecksOf(io, slug, base) {
|
|
2817
|
+
const ref = encodeURIComponent(base);
|
|
2818
|
+
const latest = new Map;
|
|
2819
|
+
for (const r of restCall(io, ["api", `repos/${slug}/commits/${ref}/check-runs?per_page=100`]).check_runs ?? []) {
|
|
2820
|
+
if (!latest.has(r.name) || latest.get(r.name).id < r.id)
|
|
2821
|
+
latest.set(r.name, r);
|
|
2822
|
+
}
|
|
2823
|
+
const status = restCall(io, ["api", `repos/${slug}/commits/${ref}/status`]);
|
|
2824
|
+
const names = [
|
|
2825
|
+
...[...latest.values()].filter((r) => r.status === "completed" && RED_RUN.has(r.conclusion)).map((r) => r.name),
|
|
2826
|
+
...(status.statuses ?? []).filter((x) => RED_STATUS.has(x.state)).map((x) => x.context)
|
|
2827
|
+
];
|
|
2828
|
+
const red = [...new Set(names)].sort();
|
|
2829
|
+
return { sha: String(status.sha ?? ""), names: red.filter((n) => !SPEC_PUBLISH.test(n)), publish: red.filter((n) => SPEC_PUBLISH.test(n)) };
|
|
2830
|
+
}
|
|
2831
|
+
var mainRedPrefixes = (base) => [...new Set([N, ...locale_default.everyNames()].map((n) => locale_default.fill(n.mainRed.title, { base })))];
|
|
2832
|
+
function mainBugsOf(io, slug, base) {
|
|
2833
|
+
const prefixes = mainRedPrefixes(base);
|
|
2834
|
+
const found = new Map;
|
|
2835
|
+
for (const prefix of prefixes) {
|
|
2836
|
+
const q = `repo:${slug} is:issue is:open in:title "${prefix}"`;
|
|
2837
|
+
for (const i of restCall(io, ["api", `search/issues?q=${encodeURIComponent(q)}&per_page=20`]).items ?? [])
|
|
2838
|
+
found.set(i.number, i);
|
|
2839
|
+
}
|
|
2840
|
+
return [...found.values()].filter((i) => !i.pull_request && prefixes.some((p) => String(i.title).startsWith(p))).sort((a, b) => a.number - b.number);
|
|
2841
|
+
}
|
|
2842
|
+
function bugNames(base, title, check) {
|
|
2843
|
+
const prefix = mainRedPrefixes(base).find((p) => title.startsWith(p)) ?? "";
|
|
2844
|
+
return new RegExp(`(?:^|[\\s,«"(])${locale_default.escapeRe(check)}(?=$|[\\s,;»")])`, "u").test(title.slice(prefix.length));
|
|
2845
|
+
}
|
|
2846
|
+
function cmdPrPremerge(io, slug, number) {
|
|
2847
|
+
const pr = restCall(io, ["api", `repos/${slug}/pulls/${number}`]);
|
|
2848
|
+
if (pr.state !== "open")
|
|
2849
|
+
throw new GhError(`PR #${number} is not open — nothing to merge`);
|
|
2850
|
+
const head = pr.head.sha;
|
|
2851
|
+
const base = pr.base.ref;
|
|
2852
|
+
const closes = closingInBody(pr.body ?? "");
|
|
2853
|
+
const mainRed = (lead, why, red, what, say = what) => {
|
|
2854
|
+
let bugs = [];
|
|
2855
|
+
try {
|
|
2856
|
+
bugs = mainBugsOf(io, slug, base);
|
|
2857
|
+
} catch (e) {
|
|
2858
|
+
if (!(e instanceof GhError))
|
|
2859
|
+
throw e;
|
|
2860
|
+
io.out(`○ open bug on red ${base} not found by search: ${e.message}`);
|
|
2861
|
+
}
|
|
2862
|
+
const fix = bugs.find((b) => closes.includes(b.number));
|
|
2863
|
+
if (fix) {
|
|
2864
|
+
io.out(`○ ${base} is red — ${why}; the PR closes bug #${fix.number} about it — checking the PR as usual`);
|
|
2865
|
+
return null;
|
|
2866
|
+
}
|
|
2867
|
+
io.out(lead);
|
|
2868
|
+
const named = (b) => red.some((n) => bugNames(base, b.title, n));
|
|
2869
|
+
for (const b of bugs.filter((b) => !named(b)))
|
|
2870
|
+
io.out(`○ #${b.number} «${b.title}» — about another check: don't wait`);
|
|
2871
|
+
const wait = (n) => `wait for it to close — bash ${waitCi()} issue ${n} as a background command, then pr premerge ${number} again`;
|
|
2872
|
+
const bug = bugs.find(named);
|
|
2873
|
+
if (bug)
|
|
2874
|
+
io.out(`bug on red ${base} — #${bug.number} «${bug.title}»: ${wait(String(bug.number))}`);
|
|
2875
|
+
const unnamed = red.filter((n) => !bugs.some((b) => bugNames(base, b.title, n)));
|
|
2876
|
+
if (unnamed.length) {
|
|
2877
|
+
const priority = pr.base?.repo?.owner?.type === "Organization" ? " --priority Urgent" : "";
|
|
2878
|
+
const title = `${locale_default.fill(N.mainRed.title, { base })}: ${what(unnamed)}`;
|
|
2879
|
+
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>")}`);
|
|
2880
|
+
}
|
|
2881
|
+
io.out(`⛔ don't merge: ${base} is red, not this PR — don't fix it in your PR`);
|
|
2882
|
+
return 3;
|
|
2883
|
+
};
|
|
2884
|
+
try {
|
|
2885
|
+
const red = redChecksOf(io, slug, base);
|
|
2886
|
+
if (red.publish.length) {
|
|
2887
|
+
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`);
|
|
2888
|
+
}
|
|
2889
|
+
if (red.names.length) {
|
|
2890
|
+
const checks = (names) => `${names.length > 1 ? "checks" : "check"} ${names.join(", ")}`;
|
|
2891
|
+
const titled = (names) => locale_default.fill(names.length > 1 ? N.mainRed.checks : N.mainRed.check, { names: names.join(", ") });
|
|
2892
|
+
const why = `${checks(red.names)} of the last commit of ${base} (${red.sha.slice(0, 7)})`;
|
|
2893
|
+
const code = mainRed(`${base} is red: ${why}`, why, red.names, titled, checks);
|
|
2894
|
+
if (code !== null)
|
|
2895
|
+
return code;
|
|
2896
|
+
}
|
|
2897
|
+
} catch (e) {
|
|
2898
|
+
if (!(e instanceof GhError))
|
|
2899
|
+
throw e;
|
|
2900
|
+
io.out(`○ checks of the last commit of ${base} not read (${e.message}) — whether ${base} is red is unknown from the checks`);
|
|
2901
|
+
}
|
|
2902
|
+
const suites = (restCall(io, ["api", `repos/${slug}/commits/${head}/check-suites?per_page=100`]).check_suites ?? []).map((s) => s.created_at).filter(Boolean);
|
|
2903
|
+
const ci = suites.sort()[0];
|
|
2904
|
+
let moved = null;
|
|
2905
|
+
let unread = null;
|
|
2906
|
+
try {
|
|
2907
|
+
moved = restCall(io, ["api", `repos/${slug}/activity?ref=${encodeURIComponent(`refs/heads/${base}`)}&per_page=1`])[0]?.timestamp ?? null;
|
|
2908
|
+
} catch (e) {
|
|
2909
|
+
if (!(e instanceof GhError))
|
|
2910
|
+
throw e;
|
|
2911
|
+
unread = e.message;
|
|
2912
|
+
}
|
|
2913
|
+
const check = `checking the merge with origin/${base}`;
|
|
2914
|
+
if (!ci)
|
|
2915
|
+
io.out(`the head of PR #${number} has no checks — what CI checked is unknown: ${check}`);
|
|
2916
|
+
else if (unread)
|
|
2917
|
+
io.out(`○ movement of ${base} not read (${unread}) — ${check}`);
|
|
2918
|
+
else if (!moved || Date.parse(moved) <= Date.parse(ci) - MERGE_REF_SLACK_MS) {
|
|
2919
|
+
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`);
|
|
2920
|
+
return 0;
|
|
2921
|
+
} else
|
|
2922
|
+
io.out(`${base} moved after the CI of PR #${number} (CI since ${ci}, ${base} — ${moved}) — ${check}`);
|
|
2923
|
+
const run = io.run ?? realRun;
|
|
2924
|
+
const git = (cwd, ...args) => run("git", args, cwd);
|
|
2925
|
+
if (!checkoutOfRepo(io, originUrl(io), slug))
|
|
2926
|
+
throw new GhError(`the merge check runs from a checkout of ${slug}: the current directory is not one`);
|
|
2927
|
+
const fetched = git(io.cwd, "fetch", "-q", "origin", base);
|
|
2928
|
+
if (fetched.status !== 0)
|
|
2929
|
+
throw new GhError(`git fetch origin ${base}: ${fetched.stderr.trim()}`);
|
|
2930
|
+
const got = git(io.cwd, "fetch", "-q", "origin", head);
|
|
2931
|
+
if (got.status !== 0)
|
|
2932
|
+
throw new GhError(`PR head ${head} not fetched from origin (${got.stderr.trim().split(`
|
|
2933
|
+
`).at(-1)}) — was the PR updated? ci-wait again`);
|
|
2934
|
+
const dirs = [];
|
|
2935
|
+
const worktree = () => {
|
|
2936
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "elves-premerge-"));
|
|
2937
|
+
dirs.push(dir);
|
|
2938
|
+
const add = git(io.cwd, "worktree", "add", "-q", "--detach", dir, `origin/${base}`);
|
|
2939
|
+
if (add.status !== 0)
|
|
2940
|
+
throw new GhError(`git worktree add: ${add.stderr.trim()}`);
|
|
2941
|
+
return dir;
|
|
2942
|
+
};
|
|
2943
|
+
const ON_MERGE = "on the merge";
|
|
2944
|
+
const scriptsOf = (dir) => {
|
|
2945
|
+
const pkg = path.join(dir, "package.json");
|
|
2946
|
+
return existsSync(pkg) ? JSON.parse(readFileSync(pkg, "utf8")).scripts ?? {} : {};
|
|
2947
|
+
};
|
|
2948
|
+
const checks = (dir, where, scripts) => {
|
|
2949
|
+
const defined = scriptsOf(dir);
|
|
2950
|
+
const present = scripts.filter((s) => defined[s]);
|
|
2951
|
+
if (!present.length)
|
|
2952
|
+
return null;
|
|
2953
|
+
const cmds = projectCommands(dir);
|
|
2954
|
+
if (cmds.install) {
|
|
2955
|
+
const r = run(cmds.install[0], cmds.install.slice(1), dir);
|
|
2956
|
+
if (r.status !== 0)
|
|
2957
|
+
throw new GhError(`installing dependencies ${where} failed (${cmds.install.join(" ")}): ${tail(r, 5).join(" / ")}`);
|
|
2958
|
+
io.out(`+ dependencies${where === ON_MERGE ? "" : ` ${where}`}: ${cmds.install.join(" ")}`);
|
|
2959
|
+
}
|
|
2960
|
+
const ran = [];
|
|
2961
|
+
for (const script of present) {
|
|
2962
|
+
const cmd = cmds.run(script);
|
|
2963
|
+
const result = run(cmd[0], cmd.slice(1), dir);
|
|
2964
|
+
ran.push(script);
|
|
2965
|
+
if (result.status === 0)
|
|
2966
|
+
continue;
|
|
2967
|
+
const failed = [...new Set(asTsc(`${result.stdout}
|
|
2968
|
+
${result.stderr}`).split(`
|
|
2969
|
+
`).filter((l) => FAILED_LINE.test(l)).map((l) => l.trim()))];
|
|
2970
|
+
return { ran, red: { script, cmd: cmd.join(" "), result, failed } };
|
|
2971
|
+
}
|
|
2972
|
+
return { ran, red: null };
|
|
2973
|
+
};
|
|
2974
|
+
const breaks = (script) => `❌ 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`;
|
|
2975
|
+
try {
|
|
2976
|
+
const dir = worktree();
|
|
2977
|
+
const short = (ref) => git(dir, "rev-parse", "--short=7", ref).stdout.trim();
|
|
2978
|
+
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);
|
|
2979
|
+
if (merge.status !== 0) {
|
|
2980
|
+
const files = git(dir, "diff", "--name-only", "--diff-filter=U").stdout.split(`
|
|
2981
|
+
`).filter(Boolean);
|
|
2982
|
+
if (!files.length)
|
|
2983
|
+
throw new GhError(`git merge: ${merge.stderr.trim()}`);
|
|
2984
|
+
io.out(`❌ don't merge: a merge conflict with origin/${base} — ${files.join(", ")}; rebase onto origin/${base}, push --force-with-lease, ci-wait again`);
|
|
2985
|
+
return 1;
|
|
2986
|
+
}
|
|
2987
|
+
io.out(`+ merge of ${head.slice(0, 7)} with origin/${base} ${short("HEAD^1")} — no conflicts`);
|
|
2988
|
+
const roots = specRoots(dir);
|
|
2989
|
+
if (roots.length && !scriptsOf(dir)["test:spec"])
|
|
2990
|
+
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)`);
|
|
2991
|
+
const merged = checks(dir, ON_MERGE, PREMERGE_SCRIPTS);
|
|
2992
|
+
if (!merged) {
|
|
2993
|
+
io.out(`○ no fast checks: the merge's package.json has no ${PREMERGE_SCRIPTS.join(" or ")} scripts — step skipped`);
|
|
2994
|
+
io.out(`✅ merge: the merge with origin/${base} has no conflicts`);
|
|
2995
|
+
return 0;
|
|
2996
|
+
}
|
|
2997
|
+
const red = merged.red;
|
|
2998
|
+
if (!red) {
|
|
2999
|
+
io.out(`✅ merge: ${merged.ran.join(" and ")} ${merged.ran.length > 1 ? "are" : "is"} green on the merge with origin/${base}`);
|
|
3000
|
+
return 0;
|
|
3001
|
+
}
|
|
3002
|
+
io.out(`${red.cmd} failed:`);
|
|
3003
|
+
for (const l of red.failed.length ? red.failed.slice(0, 20) : tail(red.result))
|
|
3004
|
+
io.out(` ${l.trim()}`);
|
|
3005
|
+
io.out(`checking bare origin/${base}: is it red by itself`);
|
|
3006
|
+
const bare = checks(worktree(), `on origin/${base}`, [red.script])?.red;
|
|
3007
|
+
if (!bare) {
|
|
3008
|
+
io.out(breaks(red.script));
|
|
3009
|
+
return 1;
|
|
3010
|
+
}
|
|
3011
|
+
const onMain = new Set(bare.failed.map(failure));
|
|
3012
|
+
const own = red.failed.filter((l) => !onMain.has(failure(l)));
|
|
3013
|
+
if (own.length && bare.failed.length) {
|
|
3014
|
+
io.out(`${base} is red by itself, the PR breaks beyond it:`);
|
|
3015
|
+
for (const l of own.slice(0, 20))
|
|
3016
|
+
io.out(` ${l}`);
|
|
3017
|
+
io.out(breaks(red.script));
|
|
3018
|
+
return 1;
|
|
3019
|
+
}
|
|
3020
|
+
const why = `${red.script} is red on bare origin/${base} too, with the same failures`;
|
|
3021
|
+
const code = mainRed(`${base} is red by itself: ${why}`, why, [red.script], (names) => names.join(", "));
|
|
3022
|
+
if (code !== null)
|
|
3023
|
+
return code;
|
|
3024
|
+
io.out(breaks(red.script));
|
|
3025
|
+
return 1;
|
|
3026
|
+
} finally {
|
|
3027
|
+
for (const dir of dirs) {
|
|
3028
|
+
git(io.cwd, "worktree", "remove", "--force", dir);
|
|
3029
|
+
rmSync(dir, { recursive: true, force: true });
|
|
3030
|
+
}
|
|
3031
|
+
git(io.cwd, "worktree", "prune");
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
var HELP = {
|
|
3035
|
+
"project check": `github project check [--repo owner/repo]
|
|
3036
|
+
|
|
3037
|
+
Items ✅/❌; code 0 — everything per the canon, 1 — there are ❌. Decision labels — by the default branch's spec tree; a
|
|
3038
|
+
new decision's label on an open task and a slice label («Slice: …») with a decision's name — an ○ line, not ❌.
|
|
3039
|
+
|
|
3040
|
+
Examples:
|
|
3041
|
+
github project check
|
|
3042
|
+
github project check --repo acme/shop`,
|
|
3043
|
+
"project fix": `github project fix [--repo owner/repo] [--confirm] [--template owner/N | none]
|
|
3044
|
+
|
|
3045
|
+
Fixes via the API and prints UI steps with links; deleting and renaming in a project with tasks and organization
|
|
3046
|
+
settings — only with --confirm (after the user's "yes"). No project — links the one of the same name, otherwise copies
|
|
3047
|
+
the template (${DEFAULT_TEMPLATE}), otherwise creates one.
|
|
3048
|
+
|
|
3049
|
+
Examples:
|
|
3050
|
+
github project fix
|
|
3051
|
+
github project fix --confirm --template none`,
|
|
3052
|
+
"task new": `github task new --title "…" --branch-type <type> [--body "…" | --body-file F] [--type Task|Bug|Epic] [--epic N]
|
|
3053
|
+
[--milestone "…"] [--blocked-by N,N] [--priority Urgent|High|Medium|Low]
|
|
3054
|
+
[--labels <decision>,<kind>:<new decision>,<label>] [--repo owner/repo]
|
|
3055
|
+
|
|
3056
|
+
A task by the canon: type, project and Backlog, Priority, epic, blocked by, milestone, decision labels, the questions
|
|
3057
|
+
label when the description has a question without ✅ (created if missing); sets the estimate itself (est estimate
|
|
3058
|
+
--write with --branch-type: feat, fix, docs, refactor, perf, test, chore, ci, build, research; a bug without it is
|
|
3059
|
+
fix, an epic isn't estimated) and prints what was created and the next step. A Claude Code cloud session — over the
|
|
3060
|
+
repository's REST, without the project: Status, the estimate and an organization's Priority — a line for a local session.
|
|
3061
|
+
|
|
3062
|
+
Examples:
|
|
3063
|
+
github task new --title "Billing · Export invoices to PDF" --branch-type feat --epic 40 --body-file task.md
|
|
3064
|
+
github task new --title "Login fails on Safari" --type Bug --priority High --body "Steps: …"`,
|
|
3065
|
+
"task show": `github task show <N> [--repo owner/repo]
|
|
3066
|
+
|
|
3067
|
+
The task in one output: title, type, Status, estimate, the epic with its open questions and tasks, subtasks, open
|
|
3068
|
+
blockers, the PRs with their checks, open and answered questions, the description; read-only. A cloud session — over the
|
|
3069
|
+
repository's REST, without the project's fields; the PRs — by the task branch or «Closes #N».
|
|
3070
|
+
|
|
3071
|
+
Examples:
|
|
3072
|
+
github task show 42`,
|
|
3073
|
+
"task answer": `github task answer <N> <k> "<answer>" [--auto] [--repo owner/repo]
|
|
3074
|
+
|
|
3075
|
+
✅ and «**Answer:** …» at question k of the questions section (--auto — the auto mark), the numbering unchanged; no open
|
|
3076
|
+
questions left — the questions label is removed; an answered question is refused. A question is answered by ✅ at the
|
|
3077
|
+
start of its answer, not by ✅ in its text. A cloud session — over the repository's REST.
|
|
3078
|
+
|
|
3079
|
+
Examples:
|
|
3080
|
+
github task answer 42 1 "Only PDF, no CSV yet"
|
|
3081
|
+
github task answer 42 2 "Medium, by recommendation" --auto`,
|
|
3082
|
+
"task status": `github task status <N> <Backlog|In progress|Done> [--branch <type>/<N>-<slug>] [--repo owner/repo]
|
|
3083
|
+
|
|
3084
|
+
Status in the project (and the epic's In progress once its first subtask is taken). In progress pins the task to the
|
|
3085
|
+
session (file sessions/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): another task in the same session — a refusal
|
|
3086
|
+
(code 2) with the question to the owner whether to queue it and its executor's prompt; a task handed out to an
|
|
3087
|
+
executor of this session (task queue --assignment) isn't pinned; a human removes the pin. --branch — the worktree
|
|
3088
|
+
tool's branch becomes the task branch (git branch -m with its .git/config section); a .git/config locked by another
|
|
3089
|
+
checkout — retried ~10 s, then a ! line with the command that finishes it (code 1).
|
|
3090
|
+
|
|
3091
|
+
Examples:
|
|
3092
|
+
github task status 42 "In progress" --branch feat/42-invoice-export
|
|
3093
|
+
github task status 42 Backlog`,
|
|
3094
|
+
"task drop": `github task drop <N> [--duplicate-of M] [--repo owner/repo]
|
|
3095
|
+
|
|
3096
|
+
Closes the task without doing it (not planned) and removes it from the project.
|
|
3097
|
+
|
|
3098
|
+
Examples:
|
|
3099
|
+
github task drop 42
|
|
3100
|
+
github task drop 42 --duplicate-of 17`,
|
|
3101
|
+
"task actualize": `github task actualize <N> [--assignment] [--repo owner/repo]
|
|
3102
|
+
|
|
3103
|
+
Right after the merge, in parallel with the deploy: the epic refresh's mechanics (blocked by on done tasks, the questions
|
|
3104
|
+
label without open questions, milestones without open tasks; a comment on the epic) and the subagent's prompt;
|
|
3105
|
+
--assignment (read-only, the subagent's first call) — its whole assignment: the texts of the epic and its open tasks,
|
|
3106
|
+
the PR files, copies of links and answers in comments, the checks by meaning; no epic or no open tasks in it —
|
|
3107
|
+
"refresh not needed".
|
|
3108
|
+
|
|
3109
|
+
Examples:
|
|
3110
|
+
github task actualize 42
|
|
3111
|
+
github task actualize 42 --assignment`,
|
|
3112
|
+
"task resume": `github task resume
|
|
3113
|
+
|
|
3114
|
+
The SessionStart hook runs it: the wait to restart for the task pinned to the session — an open PR's checks, a merged PR
|
|
3115
|
+
of an open task — the deploy and task close; no pin — nothing (code 0). Read-only on GitHub; a session with executors —
|
|
3116
|
+
its stopped executors continued with SendMessage, then the queue command (a dispatcher's); a new executor only for one
|
|
3117
|
+
that can't be continued, in the worktree of its task's branch.
|
|
3118
|
+
|
|
3119
|
+
Examples:
|
|
3120
|
+
github task resume`,
|
|
3121
|
+
"task queue": `github task queue [--epic N] [--type Task|Bug] [--priority Urgent,High] [--repo owner/repo]
|
|
3122
|
+
github task queue --assignment <N> [--cloud] [--repo owner/repo]
|
|
3123
|
+
|
|
3124
|
+
The dispatcher session's queue: ready tasks (open, not an epic, not in work, no open blockers — other repositories'
|
|
3125
|
+
too —, no external blocker label) by Priority, then number, each with its executor's prompt; the rest — in work (=: In
|
|
3126
|
+
progress, an open PR with its link, handed out to an executor of this session) and ○ lines with the reason (the
|
|
3127
|
+
label's — from the latest «External blocker: …» comment). Marks the session as a dispatcher
|
|
3128
|
+
(dispatchers/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): task status in it doesn't pin — the executors are subagents
|
|
3129
|
+
with the session's id; a session with a pinned task keeps it. Before the queue, in the repository's checkout —
|
|
3130
|
+
finished agents' worktrees (.claude/worktrees/agent-*, detached or on worktree-agent-*, not locked by a live process,
|
|
3131
|
+
not an executor's whose handed-out task is open): clean and merged (git cherry) — removed with the branch (+),
|
|
3132
|
+
otherwise left (○ with the reason), and so is one a subagent works in without a worktree of its own (its transcript
|
|
3133
|
+
written in the last ${QUIET_MINUTES} min); loose worktree-agent-* branches — the same. --type and --priority — in an
|
|
3134
|
+
organization. --assignment N (the executor's first call; read-only on GitHub) — its whole assignment; in a session
|
|
3135
|
+
(not one pinned to N) it records the task as handed out with the executor's worktree (dispatchers/<id>.tasks). A
|
|
3136
|
+
restart while the task's previous executor or its subagent is alive — a worktree of it locked by a live process, with
|
|
3137
|
+
such a subagent at work or HEAD moved in the last ${QUIET_MINUTES} min — code 1: ! lines and the answer that makes the
|
|
3138
|
+
dispatcher wait, no hand-out. --cloud — printed by the dispatcher: the brief of a cloud executor (claude.ai/code) —
|
|
3139
|
+
whole, # only at its task — and the dispatcher's steps: a cloud session can't message a local one, so the dispatcher
|
|
3140
|
+
waits for the task's closing (ci-wait issue N, background) and closes it locally; the hand-out has no worktree, and the
|
|
3141
|
+
queue's = line names that wait.
|
|
3142
|
+
|
|
3143
|
+
Examples:
|
|
3144
|
+
github task queue --epic 40
|
|
3145
|
+
github task queue --type Bug --priority Urgent,High
|
|
3146
|
+
github task queue --assignment 42
|
|
3147
|
+
github task queue --assignment 42 --cloud`,
|
|
3148
|
+
"task close": `github task close <N> [--no-git] [--repo owner/repo]
|
|
3149
|
+
|
|
3150
|
+
After the PR merge and the deploy (or a close without a PR) in one call: the actual (est fact --write), Status Done, the
|
|
3151
|
+
epic and the milestone, the task's stacks taken down with their volumes (task-env down), the merged branches removed
|
|
3152
|
+
(--no-git — without git). The epic has other open tasks and no report of the refresh subagent on it («Refresh after
|
|
3153
|
+
#N, result: …») — a refusal before any changes. A task of the scripts' own repository: before the actual the scripts'
|
|
3154
|
+
clone catches up with origin (pull --ff-only; only on the default branch, without uncommitted changes). Docker and gh
|
|
3155
|
+
don't work in an agent's sandbox — run it outside.
|
|
3156
|
+
|
|
3157
|
+
Examples:
|
|
3158
|
+
github task close 42
|
|
3159
|
+
github task close 42 --no-git`,
|
|
3160
|
+
"pr labels": `github pr labels <N> [--repo owner/repo]
|
|
3161
|
+
|
|
3162
|
+
Decision labels from the PR diff for the tasks from «Closes #N» and their epics; doesn't remove earlier ones. A decision
|
|
3163
|
+
touched only mechanically (a deletion, a rename without changes, exceptions) and a decision with a slice label's name —
|
|
3164
|
+
an ○ line, no label. More than fit on an issue (GitHub allows ${ISSUE_LABEL_LIMIT}) — only decisions whose <folder>.md
|
|
3165
|
+
or test names (the PR body's spec section) the PR changes, as many as fit; the rest — ○ lines.
|
|
3166
|
+
|
|
3167
|
+
Examples:
|
|
3168
|
+
github pr labels 57`,
|
|
3169
|
+
"pr premerge": `github pr premerge <N> [--repo owner/repo]
|
|
3170
|
+
|
|
3171
|
+
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
|
|
3172
|
+
temporary worktree and ${PREMERGE_SCRIPTS.join(", then ")} from package.json (those that exist, as in CI). Code 0 —
|
|
3173
|
+
merge, 1 — don't merge (red, conflict), 2 — couldn't check, 3 — main is red by itself (the same check on bare main, a
|
|
3174
|
+
red check on its last commit): don't merge, one bug for it — wait for it to close.
|
|
3175
|
+
|
|
3176
|
+
Examples:
|
|
3177
|
+
github pr premerge 57`
|
|
3178
|
+
};
|
|
3179
|
+
var OVERVIEW = [
|
|
3180
|
+
"github — the repository's GitHub project and tasks by the AGENTS.md canon",
|
|
3181
|
+
"",
|
|
3182
|
+
...Object.values(HELP).map((h) => h.split(`
|
|
3183
|
+
|
|
3184
|
+
`)[0].replace(/^/gm, " ")),
|
|
3185
|
+
"",
|
|
3186
|
+
"github <command> --help — the command's details and examples"
|
|
3187
|
+
].join(`
|
|
3188
|
+
`);
|
|
3189
|
+
function helpOf(argv) {
|
|
3190
|
+
if (!argv.some((a) => a === "--help" || a === "-h"))
|
|
3191
|
+
return;
|
|
3192
|
+
const words = `${argv.join(" ")} `;
|
|
3193
|
+
const cmd = Object.keys(HELP).find((c) => words.startsWith(`${c} `));
|
|
3194
|
+
return cmd ? HELP[cmd] : OVERVIEW;
|
|
3195
|
+
}
|
|
3196
|
+
var issueNumber = (s, what) => {
|
|
3197
|
+
const m = /^#?(\d+)$/.exec((s ?? "").trim());
|
|
3198
|
+
if (!m)
|
|
3199
|
+
throw new GhError(`${what}: expected an issue number, not «${s ?? ""}»`);
|
|
3200
|
+
return Number(m[1]);
|
|
3201
|
+
};
|
|
3202
|
+
var ORIGIN_RE = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?(?:ssh\.)?github\.com(?::\d+)?\/|(?:[^@/\s:]+@)?(?:ssh\.)?github\.com:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i;
|
|
3203
|
+
function originRepo(url) {
|
|
3204
|
+
const m = ORIGIN_RE.exec(url.trim());
|
|
3205
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
3206
|
+
}
|
|
3207
|
+
function checkoutOf(origin, repo) {
|
|
3208
|
+
return originRepo(origin)?.toLowerCase() === repo.toLowerCase();
|
|
3209
|
+
}
|
|
3210
|
+
function detectRepo() {
|
|
3211
|
+
const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
|
|
3212
|
+
const repo = r.status === 0 ? originRepo(r.stdout ?? "") : null;
|
|
3213
|
+
if (!repo)
|
|
3214
|
+
throw new GhError("couldn't determine the repository from git remote origin; pass --repo owner/repo");
|
|
3215
|
+
return repo;
|
|
3216
|
+
}
|
|
3217
|
+
var canonicalNames = new WeakMap;
|
|
3218
|
+
function canonicalRepo(io, slug) {
|
|
3219
|
+
let known = canonicalNames.get(io);
|
|
3220
|
+
if (!known)
|
|
3221
|
+
canonicalNames.set(io, known = new Map);
|
|
3222
|
+
const key = slug.toLowerCase();
|
|
3223
|
+
if (!known.has(key)) {
|
|
3224
|
+
const [owner, name] = slug.split("/");
|
|
3225
|
+
const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query: Q.RepoName, variables: { owner, name } }));
|
|
3226
|
+
let current = null;
|
|
3227
|
+
try {
|
|
3228
|
+
current = JSON.parse(out)?.data?.repository?.nameWithOwner;
|
|
3229
|
+
} catch {
|
|
3230
|
+
current = null;
|
|
3231
|
+
}
|
|
3232
|
+
known.set(key, typeof current === "string" && current ? current : slug);
|
|
3233
|
+
}
|
|
3234
|
+
return known.get(key);
|
|
3235
|
+
}
|
|
3236
|
+
function checkoutOfRepo(io, origin, slug) {
|
|
3237
|
+
if (checkoutOf(origin, slug))
|
|
3238
|
+
return true;
|
|
3239
|
+
const repo = originRepo(origin);
|
|
3240
|
+
return repo !== null && canonicalRepo(io, repo).toLowerCase() === slug.toLowerCase();
|
|
3241
|
+
}
|
|
3242
|
+
function main(argv, io) {
|
|
3243
|
+
const help = helpOf(argv);
|
|
3244
|
+
if (help)
|
|
3245
|
+
return io.out(help), 0;
|
|
3246
|
+
N = locale_default.names(locale_default.projectLanguage(io.cwd ?? process.cwd()));
|
|
3247
|
+
try {
|
|
3248
|
+
const [group, cmd, ...rest] = argv;
|
|
3249
|
+
if (!group)
|
|
3250
|
+
return io.out(OVERVIEW), 2;
|
|
3251
|
+
if (!Object.hasOwn(HELP, `${group} ${cmd ?? ""}`)) {
|
|
3252
|
+
io.err(`unknown command «${argv.slice(0, 2).join(" ")}»; expected ${Object.keys(HELP).join(", ")} (github --help)`);
|
|
3253
|
+
return 2;
|
|
3254
|
+
}
|
|
3255
|
+
if (inCloud(io) && group === "task" && cmd === "close") {
|
|
3256
|
+
const n = /^#?(\d+)$/.exec(rest.find((a) => !a.startsWith("--")) ?? "")?.[1] ?? "<N>";
|
|
3257
|
+
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.`);
|
|
3258
|
+
return 2;
|
|
3259
|
+
}
|
|
3260
|
+
if (inCloud(io) && !CLOUD_COMMANDS.includes(`${group} ${cmd}`)) {
|
|
3261
|
+
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`);
|
|
3262
|
+
return 2;
|
|
3263
|
+
}
|
|
3264
|
+
const repoOf = (v) => {
|
|
3265
|
+
const slug = v ?? detectRepo();
|
|
3266
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(slug))
|
|
3267
|
+
throw new GhError(`invalid --repo «${slug}», expected owner/repo`);
|
|
3268
|
+
return inCloud(io) ? slug : canonicalRepo(io, slug);
|
|
3269
|
+
};
|
|
3270
|
+
if (group === "pr") {
|
|
3271
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { repo: { type: "string" } } });
|
|
3272
|
+
if (positionals.length > 1)
|
|
3273
|
+
throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
|
|
3274
|
+
const m = /^#?(\d+)$/.exec((positionals[0] ?? "").trim());
|
|
3275
|
+
if (!m)
|
|
3276
|
+
throw new GhError(`pr ${cmd}: expected a PR number, not «${positionals[0] ?? ""}»`);
|
|
3277
|
+
return (cmd === "premerge" ? cmdPrPremerge : cmdPrLabels)(io, repoOf(values.repo), Number(m[1]));
|
|
3278
|
+
}
|
|
3279
|
+
if (group === "task") {
|
|
3280
|
+
const { values, positionals } = parseArgs({
|
|
3281
|
+
args: rest,
|
|
3282
|
+
allowPositionals: true,
|
|
3283
|
+
options: {
|
|
3284
|
+
repo: { type: "string" },
|
|
3285
|
+
title: { type: "string" },
|
|
3286
|
+
body: { type: "string" },
|
|
3287
|
+
"body-file": { type: "string" },
|
|
3288
|
+
type: { type: "string" },
|
|
3289
|
+
epic: { type: "string" },
|
|
3290
|
+
milestone: { type: "string" },
|
|
3291
|
+
"blocked-by": { type: "string" },
|
|
3292
|
+
priority: { type: "string" },
|
|
3293
|
+
"duplicate-of": { type: "string" },
|
|
3294
|
+
labels: { type: "string" },
|
|
3295
|
+
"branch-type": { type: "string" },
|
|
3296
|
+
branch: { type: "string" },
|
|
3297
|
+
"no-git": { type: "boolean", default: false },
|
|
3298
|
+
assignment: { type: "boolean", default: false },
|
|
3299
|
+
cloud: { type: "boolean", default: false },
|
|
3300
|
+
auto: { type: "boolean", default: false }
|
|
3301
|
+
}
|
|
3302
|
+
});
|
|
3303
|
+
if (cmd === "resume") {
|
|
3304
|
+
if (positionals.length)
|
|
3305
|
+
throw new GhError(`extra arguments: ${positionals.join(" ")} — task resume takes the task from the session pin`);
|
|
3306
|
+
return cmdTaskResume(io);
|
|
3307
|
+
}
|
|
3308
|
+
const slug = repoOf(values.repo);
|
|
3309
|
+
if (cmd === "new") {
|
|
3310
|
+
if (positionals.length)
|
|
3311
|
+
throw new GhError(`extra arguments: ${positionals.join(" ")}`);
|
|
3312
|
+
if (!values.title?.trim())
|
|
3313
|
+
throw new GhError("--title is required");
|
|
3314
|
+
if (values.body !== undefined && values["body-file"])
|
|
3315
|
+
throw new GhError("--body or --body-file, not both");
|
|
3316
|
+
const body = values["body-file"] ? readFileSync(values["body-file"], "utf8") : values.body ?? "";
|
|
3317
|
+
const blockedBy = (values["blocked-by"] ?? "").split(",").filter((x) => x.trim()).map((x) => issueNumber(x, "--blocked-by"));
|
|
3318
|
+
const epic = values.epic === undefined ? undefined : issueNumber(values.epic, "--epic");
|
|
3319
|
+
const labels = (values.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
3320
|
+
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"] });
|
|
3321
|
+
}
|
|
3322
|
+
if (cmd === "queue") {
|
|
3323
|
+
if (values.cloud && !values.assignment)
|
|
3324
|
+
throw new GhError("--cloud — only with --assignment <N>: the brief of one task's cloud executor");
|
|
3325
|
+
if (values.assignment) {
|
|
3326
|
+
if (positionals.length !== 1)
|
|
3327
|
+
throw new GhError("task queue --assignment <N>: one task number");
|
|
3328
|
+
return cmdTaskAssignment(io, slug, issueNumber(positionals[0], "task queue --assignment"), { cloud: values.cloud });
|
|
3329
|
+
}
|
|
3330
|
+
if (positionals.length)
|
|
3331
|
+
throw new GhError(`extra arguments: ${positionals.join(" ")} — the queue is chosen by --epic, --type and --priority`);
|
|
3332
|
+
const priority = values.priority === undefined ? undefined : values.priority.split(",").map((x) => x.trim()).filter(Boolean);
|
|
3333
|
+
return cmdTaskQueue(io, slug, { epic: values.epic === undefined ? undefined : issueNumber(values.epic, "--epic"), type: values.type, priority });
|
|
3334
|
+
}
|
|
3335
|
+
const number = issueNumber(positionals[0], `task ${cmd}`);
|
|
3336
|
+
if (cmd === "status")
|
|
3337
|
+
return cmdTaskStatus(io, slug, number, positionals.slice(1).join(" "), values.branch);
|
|
3338
|
+
if (cmd === "answer") {
|
|
3339
|
+
const k = /^\d+$/.exec((positionals[1] ?? "").trim());
|
|
3340
|
+
if (!k)
|
|
3341
|
+
throw new GhError(`task answer: expected a question number, not «${positionals[1] ?? ""}»`);
|
|
3342
|
+
return cmdTaskAnswer(io, slug, number, Number(k[0]), positionals.slice(2).join(" "), values.auto);
|
|
3343
|
+
}
|
|
3344
|
+
if (positionals.length > 1)
|
|
3345
|
+
throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
|
|
3346
|
+
if (cmd === "show")
|
|
3347
|
+
return cmdTaskShow(io, slug, number);
|
|
3348
|
+
if (cmd === "close")
|
|
3349
|
+
return cmdTaskClose(io, slug, number, { git: !values["no-git"] });
|
|
3350
|
+
if (cmd === "actualize")
|
|
3351
|
+
return cmdTaskActualize(io, slug, number, { assignment: values.assignment });
|
|
3352
|
+
return cmdTaskDrop(io, slug, number, values["duplicate-of"] === undefined ? undefined : issueNumber(values["duplicate-of"], "--duplicate-of"));
|
|
3353
|
+
}
|
|
3354
|
+
const { values } = parseArgs({ args: rest, options: { repo: { type: "string" }, confirm: { type: "boolean", default: false }, template: { type: "string", default: DEFAULT_TEMPLATE } } });
|
|
3355
|
+
const slug = repoOf(values.repo);
|
|
3356
|
+
const template = values.template === "none" ? null : values.template;
|
|
3357
|
+
if (template && !/^[\w.-]+\/\d+$/.test(template))
|
|
3358
|
+
throw new GhError(`invalid --template «${template}», expected owner/N or none`);
|
|
3359
|
+
return cmd === "check" ? cmdCheck(io, slug) : cmdFix(io, slug, { confirm: values.confirm, template });
|
|
3360
|
+
} catch (e) {
|
|
3361
|
+
if (e instanceof GhError || e?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
|
|
3362
|
+
io.err(`error: ${e.message}`);
|
|
3363
|
+
return 2;
|
|
3364
|
+
}
|
|
3365
|
+
throw e;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
if (false) {}
|
|
3369
|
+
export {
|
|
3370
|
+
DECISIONS,
|
|
3371
|
+
DEFAULT_PRIORITY,
|
|
3372
|
+
DEFAULT_TEMPLATE,
|
|
3373
|
+
EPIC_LABEL,
|
|
3374
|
+
GhError,
|
|
3375
|
+
HEAD_CHECKS,
|
|
3376
|
+
PREMERGE_SCRIPTS,
|
|
3377
|
+
PRIORITIES,
|
|
3378
|
+
PRIORITY,
|
|
3379
|
+
Q,
|
|
3380
|
+
QUEUE_EXECUTORS,
|
|
3381
|
+
RULESET,
|
|
3382
|
+
SPEC_PUBLISH,
|
|
3383
|
+
SPEC_ROOTS,
|
|
3384
|
+
STABLE_PRS,
|
|
3385
|
+
STATUS,
|
|
3386
|
+
WORKFLOW_ADDED,
|
|
3387
|
+
WORKFLOW_AUTO_ADD,
|
|
3388
|
+
WORKFLOW_CLOSED,
|
|
3389
|
+
analyze,
|
|
3390
|
+
checkStates,
|
|
3391
|
+
checkoutOf,
|
|
3392
|
+
cmdCheck,
|
|
3393
|
+
cmdFix,
|
|
3394
|
+
cmdPrLabels,
|
|
3395
|
+
cmdPrPremerge,
|
|
3396
|
+
cmdTaskActualize,
|
|
3397
|
+
cmdTaskAnswer,
|
|
3398
|
+
cmdTaskAssignment,
|
|
3399
|
+
cmdTaskClose,
|
|
3400
|
+
cmdTaskDrop,
|
|
3401
|
+
cmdTaskNew,
|
|
3402
|
+
cmdTaskQueue,
|
|
3403
|
+
cmdTaskResume,
|
|
3404
|
+
cmdTaskShow,
|
|
3405
|
+
cmdTaskStatus,
|
|
3406
|
+
decisionLabel,
|
|
3407
|
+
decisionsOfFiles,
|
|
3408
|
+
epicPrefix,
|
|
3409
|
+
exceptionFile,
|
|
3410
|
+
graphql,
|
|
3411
|
+
labelKinds,
|
|
3412
|
+
legacyDecision,
|
|
3413
|
+
loadDecisions,
|
|
3414
|
+
loadState,
|
|
3415
|
+
main,
|
|
3416
|
+
modelModules,
|
|
3417
|
+
originRepo,
|
|
3418
|
+
packageManager,
|
|
3419
|
+
pinLine,
|
|
3420
|
+
projectCommands,
|
|
3421
|
+
questionItems,
|
|
3422
|
+
realGh,
|
|
3423
|
+
realRun,
|
|
3424
|
+
sessionPin,
|
|
3425
|
+
specRoots,
|
|
3426
|
+
taskBranch,
|
|
3427
|
+
taskTemplate
|
|
3428
|
+
};
|