@floh-solutions/pharos-cli 0.23.0 → 0.25.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/README.md +409 -5
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +23 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/cli.d.ts +4 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +362 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/comment-github.d.ts +138 -0
- package/dist/commands/comment-github.d.ts.map +1 -0
- package/dist/commands/comment-github.js +427 -0
- package/dist/commands/comment-github.js.map +1 -0
- package/dist/commands/comment.d.ts.map +1 -1
- package/dist/commands/comment.js +114 -19
- package/dist/commands/comment.js.map +1 -1
- package/dist/commands/doctor.d.ts +71 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +414 -8
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/issue.d.ts +75 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +2466 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +114 -0
- package/dist/commands/setup.js.map +1 -1
- package/dist/half-link.d.ts +157 -0
- package/dist/half-link.d.ts.map +1 -0
- package/dist/half-link.js +257 -0
- package/dist/half-link.js.map +1 -0
- package/dist/issue-scan.d.ts +383 -0
- package/dist/issue-scan.d.ts.map +1 -0
- package/dist/issue-scan.js +455 -0
- package/dist/issue-scan.js.map +1 -0
- package/dist/output.d.ts +18 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +293 -0
- package/dist/output.js.map +1 -1
- package/dist/session.d.ts +101 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +132 -11
- package/dist/session.js.map +1 -1
- package/dist/target.d.ts +40 -4
- package/dist/target.d.ts.map +1 -1
- package/dist/target.js +14 -3
- package/dist/target.js.map +1 -1
- package/package.json +3 -2
- package/skill/SKILL.md +422 -11
|
@@ -0,0 +1,2466 @@
|
|
|
1
|
+
import { addHyperlink, addParent, setField, setMarkdown, } from "@floh-solutions/ado-core";
|
|
2
|
+
import { GitHubNotFoundError, PullRequestNotAnIssueError, assertIssueCoordinate, composeLinkComment, formatIssueCoordinate, isPharosAuthored, isTwoSided, issueUrl, linksFromComments, linksFromHyperlinks, mergeLinks, milestoneNumber, normalizeRepo, parseRepo, } from "@floh-solutions/gh-core";
|
|
3
|
+
import { DEFAULT_WRITE_BUDGET } from "../budget.js";
|
|
4
|
+
import { ADOPT_GUARD_LIMIT, READ_CONCURRENCY, WRITES_PER_ADOPTION, adoptGuardWiql, backfillCandidates, claimsOn, driftWiql, finishedStates, inBatches, isFinished, judgeLink, linkedNumbersIn, linksOnWorkItems, looksLikeWiqlRejection, reposOf, tallyDrift, terminalStateFor, } from "../issue-scan.js";
|
|
5
|
+
import { CliError, emit, emitText, failureOf, refusal, usageError, } from "../output.js";
|
|
6
|
+
import { approve } from "../session.js";
|
|
7
|
+
/**
|
|
8
|
+
* `pharos issue` — the cross-platform **edge**, and only the edge.
|
|
9
|
+
*
|
|
10
|
+
* ## Why there are three verbs here and not thirty
|
|
11
|
+
*
|
|
12
|
+
* **Do not rebuild `gh`** (plan §8). The agent already has it, it is better than
|
|
13
|
+
* anything here would be, and a second GitHub CLI is the "second implementation
|
|
14
|
+
* that drifts" failure `AGENTS.md` names three separate times. There is no
|
|
15
|
+
* `pharos issue list`, no `issue view`, no `issue close`: reading and writing an
|
|
16
|
+
* issue is `gh`'s job, reading and writing a work item is the other twenty-seven
|
|
17
|
+
* verbs' job. What neither can do is join them, and that is all this is.
|
|
18
|
+
*
|
|
19
|
+
* | verb | what only this can do |
|
|
20
|
+
* |---|---|
|
|
21
|
+
* | `adopt` | create the work item AND write both ends of the link, in that order, idempotently |
|
|
22
|
+
* | `link` | join an existing pair, or finish a join that half-happened |
|
|
23
|
+
* | `say` | one message, two audiences — the full explanation to GitHub, a summary and the comment's URL to Azure DevOps |
|
|
24
|
+
* | `trail` | the whole trail from **either** end, with the evidence for each half |
|
|
25
|
+
*
|
|
26
|
+
* `say` is the one that could be mistaken for `gh issue comment` plus `pharos
|
|
27
|
+
* comment add`, and it is not: those two write the same words twice to two
|
|
28
|
+
* audiences with different access, and neither of them knows the other
|
|
29
|
+
* happened. The asymmetry is the feature (§8) — see {@link runSay}.
|
|
30
|
+
*
|
|
31
|
+
* ## The link is three layers, and this writes two of them
|
|
32
|
+
*
|
|
33
|
+
* Plan §6, in order of durability:
|
|
34
|
+
*
|
|
35
|
+
* 1. **Durable and human-visible.** On Azure DevOps a `Hyperlink` relation to
|
|
36
|
+
* the issue URL. Measured, not chosen: `_apis/githubconnections` answers 401
|
|
37
|
+
* on Pharos's PAT scopes while a control WIQL call answers 200 with the same
|
|
38
|
+
* token, so the connection id a native `vstfs:///GitHub/Issue/…` artifact
|
|
39
|
+
* link needs cannot be read at all (§2). On GitHub, one Pharos comment
|
|
40
|
+
* carrying `AB#<id>` — free official linking wherever the Azure Boards app
|
|
41
|
+
* happens to be installed, harmless text where it is not.
|
|
42
|
+
* 2. **Machine-readable.** The `<!-- pharos:v1 … -->` trailer inside that same
|
|
43
|
+
* comment. **In a comment, never the issue body**: editing a reporter's body
|
|
44
|
+
* collides with them, is rude on a public repo, and needs write access we may
|
|
45
|
+
* not have on a community issue.
|
|
46
|
+
* 3. **Fast.** A local join table, rebuilt from 1 and 2. This CLI has none — it
|
|
47
|
+
* reads the durable layers every time, which is why it needs no state on disk
|
|
48
|
+
* and cannot be stale.
|
|
49
|
+
*
|
|
50
|
+
* Both layers are composed by `gh-core`'s {@link composeLinkComment} and read
|
|
51
|
+
* back by its {@link linksFromComments} / {@link linksFromHyperlinks}. Nothing
|
|
52
|
+
* here parses or formats a marker. The grammar is a **shared contract** with a
|
|
53
|
+
* Swift port that tests against the same fixture file, and a second parser here
|
|
54
|
+
* would be the drift that fixture exists to prevent.
|
|
55
|
+
*
|
|
56
|
+
* ## The write order, and the one failure it leaves behind
|
|
57
|
+
*
|
|
58
|
+
* Azure DevOps first, GitHub second — in `adopt` because the work item id does
|
|
59
|
+
* not exist until the create lands, and in `link` for the same order so there is
|
|
60
|
+
* **one** recovery path rather than two. So the failure this can leave is always
|
|
61
|
+
* the same shape: the ADO end written, the GitHub end not. `pharos issue link
|
|
62
|
+
* <repo#n> <id>` completes exactly that, and `adopt` reports the created id and
|
|
63
|
+
* that command when the comment fails rather than only reporting the error — a
|
|
64
|
+
* work item nobody can name is worse than a failed call.
|
|
65
|
+
*
|
|
66
|
+
* ## What "already linked" means, and the three places it is asked
|
|
67
|
+
*
|
|
68
|
+
* The GitHub comment is the index, and it is the *first* thing read: an issue
|
|
69
|
+
* carrying a marker is already adopted, `adopt` returns the work item it names,
|
|
70
|
+
* exit 0, nothing written. That is the property an agent that retries depends
|
|
71
|
+
* on.
|
|
72
|
+
*
|
|
73
|
+
* **It is not sufficient on its own, and that cost a real duplicate.** In the
|
|
74
|
+
* half-linked state — the work item created, the comment not — the marker was
|
|
75
|
+
* never posted, so the index is silent about a work item that already exists.
|
|
76
|
+
* A retry there used to create a *second* one: its own id, its own place in a
|
|
77
|
+
* sprint, its own notifications, on a client's board, and nothing in this app
|
|
78
|
+
* removes it. That state is exactly the one a retry follows, because it is the
|
|
79
|
+
* one that just returned an error.
|
|
80
|
+
*
|
|
81
|
+
* So before creating anything, two more questions are asked, cheapest first,
|
|
82
|
+
* and either of them makes `adopt` **refuse** and hand over the same
|
|
83
|
+
* `pharos issue link` command the failure itself carried:
|
|
84
|
+
*
|
|
85
|
+
* 1. **This machine.** `half-link.ts` records an adoption between the create
|
|
86
|
+
* and the comment, and removes it when the comment lands. Local, so the
|
|
87
|
+
* refusal costs no round trip — and it is the **only** guard left when the
|
|
88
|
+
* board cannot be asked at all, which is a state this deliberately does not
|
|
89
|
+
* treat as fatal. It also knows *which* work item this machine created and
|
|
90
|
+
* when, where a scan can only infer it. An entry is *verified* against Azure
|
|
91
|
+
* DevOps before it is believed, so a work item that was since deleted cannot
|
|
92
|
+
* wedge a legitimate adoption.
|
|
93
|
+
* 2. **The board**, for everything the first cannot see: a retry from another
|
|
94
|
+
* machine, another checkout, or after the journal was lost. Azure DevOps has
|
|
95
|
+
* no query over relation URLs, so this narrows on `[System.HyperLinkCount]`
|
|
96
|
+
* and matches the URLs client-side — see `issue-scan.ts` ▸
|
|
97
|
+
* {@link adoptGuardWiql}, which explains why it is capped and why it is not
|
|
98
|
+
* the statement `drift` uses. A failed guard query is reported and does not
|
|
99
|
+
* block the adoption: this is a safety net, and one that refuses to adopt
|
|
100
|
+
* because it could not run would be worse than the thing it prevents.
|
|
101
|
+
*
|
|
102
|
+
* `pharos issue drift` is still the sweep that finds a half-linked pair nobody
|
|
103
|
+
* retried, and reports it as `one-sided` with the same repair.
|
|
104
|
+
*/
|
|
105
|
+
/** The Basic process's name for a reported problem. Overridden with `--type`. */
|
|
106
|
+
const DEFAULT_WORK_ITEM_TYPE = "Issue";
|
|
107
|
+
export async function runIssue(io, session, positionals, options) {
|
|
108
|
+
const verb = positionals[0];
|
|
109
|
+
switch (verb) {
|
|
110
|
+
case "adopt":
|
|
111
|
+
return runAdopt(io, session, positionals.slice(1), options);
|
|
112
|
+
case "link":
|
|
113
|
+
return runLink(io, session, positionals.slice(1));
|
|
114
|
+
case "say":
|
|
115
|
+
return runSay(io, session, positionals.slice(1), options);
|
|
116
|
+
case "trail":
|
|
117
|
+
return runTrail(io, session, positionals.slice(1));
|
|
118
|
+
case "drift":
|
|
119
|
+
return runDrift(io, session, positionals.slice(1), options);
|
|
120
|
+
case "backfill":
|
|
121
|
+
return runBackfill(io, session, positionals.slice(1), options);
|
|
122
|
+
case "close":
|
|
123
|
+
return runClose(io, session, positionals.slice(1), options);
|
|
124
|
+
case "edit":
|
|
125
|
+
return runEdit(io, session, positionals.slice(1), options);
|
|
126
|
+
case undefined:
|
|
127
|
+
throw usageError(`Which issue verb? ${ISSUE_VERBS.join(", ")}. Reading an issue is \`gh\`.`, { verbs: ISSUE_VERBS });
|
|
128
|
+
default:
|
|
129
|
+
throw usageError(`Unknown issue verb "${verb}". This owns the EDGE — ${ISSUE_VERBS.join(", ")} — and `
|
|
130
|
+
+ "nothing else: listing, viewing and commenting on an issue are `gh`'s job. Closing "
|
|
131
|
+
+ "one END is `gh issue close`; closing the PAIR is `pharos issue close`. Editing one "
|
|
132
|
+
+ "unguarded is `gh issue edit`; editing it behind a lost-update guard is "
|
|
133
|
+
+ "`pharos issue edit`.", { verbs: ISSUE_VERBS });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const ISSUE_VERBS = [
|
|
137
|
+
"adopt",
|
|
138
|
+
"link",
|
|
139
|
+
"say",
|
|
140
|
+
"trail",
|
|
141
|
+
"drift",
|
|
142
|
+
"backfill",
|
|
143
|
+
"close",
|
|
144
|
+
"edit",
|
|
145
|
+
];
|
|
146
|
+
// MARK: - adopt
|
|
147
|
+
/**
|
|
148
|
+
* `pharos issue adopt <repo#n>` — the issue becomes a work item, and both ends
|
|
149
|
+
* of the link are written.
|
|
150
|
+
*
|
|
151
|
+
* The `Hyperlink` relation and `--parent` go into the **same patch as the
|
|
152
|
+
* fields**, for the reason `pharos create` puts a parent there: a create
|
|
153
|
+
* followed by a relation add leaves a window where the work item exists and
|
|
154
|
+
* points at nothing, and if the second call fails the window never closes.
|
|
155
|
+
*/
|
|
156
|
+
async function runAdopt(io, session, positionals, options) {
|
|
157
|
+
const ref = await resolveIssue(session, positionals[0], "adopt");
|
|
158
|
+
const coordinate = { repo: ref.repo, number: ref.number };
|
|
159
|
+
const github = await session.github();
|
|
160
|
+
// Throws `PullRequestNotAnIssueError` for a PR number, which resolves through
|
|
161
|
+
// this endpoint and is out of scope for v1 (plan §13.3).
|
|
162
|
+
const issue = await github.issues.get(ref);
|
|
163
|
+
const comments = await github.issues.comments(ref);
|
|
164
|
+
const existing = linksFromComments(coordinate, comments);
|
|
165
|
+
if (existing.length > 0) {
|
|
166
|
+
return alreadyAdopted(io, session, coordinate, issue, existing);
|
|
167
|
+
}
|
|
168
|
+
// Nothing on the issue says it is adopted — which is also true of the state
|
|
169
|
+
// where a work item exists and only its comment is missing. THROWS a refusal
|
|
170
|
+
// when it finds one; a second work item is the damage this whole verb has to
|
|
171
|
+
// avoid.
|
|
172
|
+
const boardCheck = await refuseIfAlreadyTracked(session, coordinate);
|
|
173
|
+
const type = options.type ?? DEFAULT_WORK_ITEM_TYPE;
|
|
174
|
+
const title = options.title ?? issue.title;
|
|
175
|
+
const url = issueUrl(coordinate);
|
|
176
|
+
const parent = options.parent === undefined ? undefined : await resolveParent(session, options.parent);
|
|
177
|
+
const ops = adoptionOps(issue, { title, parent });
|
|
178
|
+
if (session.previewOnly) {
|
|
179
|
+
return report(io, session, {
|
|
180
|
+
applied: false,
|
|
181
|
+
dryRun: true,
|
|
182
|
+
created: false,
|
|
183
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url },
|
|
184
|
+
workItem: { type, title },
|
|
185
|
+
...(options.parent === undefined ? {} : { parent: options.parent }),
|
|
186
|
+
wouldWrite: ["hyperlink", "comment"],
|
|
187
|
+
// A dry run runs the guard for real, because "would this refuse" is the
|
|
188
|
+
// most useful thing a preview of this verb can answer.
|
|
189
|
+
boardCheck,
|
|
190
|
+
operations: ops,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const adopted = await performAdoption(session, ref, { type, title, ops });
|
|
194
|
+
return report(io, session, {
|
|
195
|
+
applied: true,
|
|
196
|
+
created: true,
|
|
197
|
+
issue: {
|
|
198
|
+
...coordinate,
|
|
199
|
+
title: issue.title,
|
|
200
|
+
state: issue.state,
|
|
201
|
+
url,
|
|
202
|
+
author: issue.author ?? null,
|
|
203
|
+
},
|
|
204
|
+
workItem: {
|
|
205
|
+
id: adopted.workItemId,
|
|
206
|
+
type,
|
|
207
|
+
title,
|
|
208
|
+
rev: adopted.rev,
|
|
209
|
+
url: adopted.workItemUrl,
|
|
210
|
+
...(parent === undefined ? {} : { parent: parent.id, parentTitle: parent.title }),
|
|
211
|
+
},
|
|
212
|
+
wrote: ["hyperlink", "comment"],
|
|
213
|
+
commentUrl: adopted.commentUrl,
|
|
214
|
+
// Never silent about a guard that did not run: "the board says nobody has
|
|
215
|
+
// adopted this" and "the board could not be asked" are different facts, and
|
|
216
|
+
// only one of them means the duplicate check happened.
|
|
217
|
+
boardCheck,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* **The duplicate guard.** Refuses when a work item already tracks this issue
|
|
222
|
+
* without the issue saying so, and otherwise reports what the check cost.
|
|
223
|
+
*
|
|
224
|
+
* Two sources, cheapest first — see this file's header ▸ *What "already linked"
|
|
225
|
+
* means*. The board is the stronger one and it is what catches a retry from
|
|
226
|
+
* another machine; the journal is what is left when the board query cannot run,
|
|
227
|
+
* and it answers without a round trip.
|
|
228
|
+
*/
|
|
229
|
+
async function refuseIfAlreadyTracked(session, coordinate) {
|
|
230
|
+
const key = halfLinkKey(session, coordinate);
|
|
231
|
+
const remembered = await session.halfLinks.find(key);
|
|
232
|
+
if (remembered !== undefined) {
|
|
233
|
+
const item = await readHalfLinked(session, coordinate, remembered.workItem);
|
|
234
|
+
if (item !== undefined) {
|
|
235
|
+
throw trackedRefusal(coordinate, {
|
|
236
|
+
workItemIds: [remembered.workItem],
|
|
237
|
+
source: "journal",
|
|
238
|
+
since: remembered.at,
|
|
239
|
+
workItem: summariseWorkItem(session, item),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// The work item is gone, or somebody removed its hyperlink on purpose.
|
|
243
|
+
// Either way the entry describes a state that no longer exists, and a
|
|
244
|
+
// remembered failure must never be able to block a legitimate adoption.
|
|
245
|
+
// **Not during a dry run**, which changes nothing anywhere — it costs the
|
|
246
|
+
// next preview one read and keeps that promise unqualified.
|
|
247
|
+
if (!session.previewOnly)
|
|
248
|
+
await session.halfLinks.forget(key);
|
|
249
|
+
}
|
|
250
|
+
let items;
|
|
251
|
+
try {
|
|
252
|
+
items = await session.client.wiql.queryAndFetch(adoptGuardWiql(), {
|
|
253
|
+
expand: "relations",
|
|
254
|
+
top: ADOPT_GUARD_LIMIT,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// Reported, never fatal. A process template that does not expose
|
|
259
|
+
// `System.HyperLinkCount`, or a token that cannot run WIQL, would otherwise
|
|
260
|
+
// turn a safety net into a verb that no longer works — and the write it was
|
|
261
|
+
// guarding is one this caller explicitly asked for.
|
|
262
|
+
return {
|
|
263
|
+
ran: false,
|
|
264
|
+
problem: String(failureOf(error).body["message"] ?? error),
|
|
265
|
+
wiql: adoptGuardWiql(),
|
|
266
|
+
hint: "The board was NOT checked for a work item that already links this issue, so a "
|
|
267
|
+
+ "half-linked pair from another machine would not have been seen. `pharos issue drift "
|
|
268
|
+
+ "--repo <owner/name>` is the same question asked deliberately.",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const found = linksOnWorkItems(items, (item) => describeWorkItem(session, item), (item) => linksFromHyperlinks(item.id, hyperlinkUrls(item)));
|
|
272
|
+
const claims = claimsOn(found, coordinate);
|
|
273
|
+
if (claims.length > 0) {
|
|
274
|
+
throw trackedRefusal(coordinate, {
|
|
275
|
+
workItemIds: [...new Set(claims.map((claim) => claim.workItem.id))],
|
|
276
|
+
source: "board",
|
|
277
|
+
workItem: { ...claims[0].workItem },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
ran: true,
|
|
282
|
+
workItems: items.length,
|
|
283
|
+
// **Both numbers, and the second one is the one that catches this check
|
|
284
|
+
// going blind.** `workItems` counts what the query matched; `links` counts
|
|
285
|
+
// the GitHub issue URLs actually parsed off them. A guard narrowing on the
|
|
286
|
+
// wrong field, or reading relations it never asked to be expanded, reports
|
|
287
|
+
// work items and zero links — which is #820's failure exactly: a check that
|
|
288
|
+
// answers all-clear forever because it is looking at nothing.
|
|
289
|
+
links: found.length,
|
|
290
|
+
// A capped scan that found nothing is not the same answer as a complete one
|
|
291
|
+
// that found nothing, and printing them identically is how a partial check
|
|
292
|
+
// gets read as an all-clear.
|
|
293
|
+
truncated: items.length >= ADOPT_GUARD_LIMIT,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The work item a journal entry names, **if it is still half-linked to this
|
|
298
|
+
* issue**.
|
|
299
|
+
*
|
|
300
|
+
* A remembered failure is a hint, never a verdict. Three things can have
|
|
301
|
+
* happened since: it was repaired (then the marker is on the issue and this
|
|
302
|
+
* code was never reached), it was deleted, or somebody removed the hyperlink by
|
|
303
|
+
* hand. Only the first of those is worth refusing over, so this costs one read
|
|
304
|
+
* — and one read, on the path where an agent is about to create a duplicate
|
|
305
|
+
* work item, is not a cost worth arguing about.
|
|
306
|
+
*/
|
|
307
|
+
async function readHalfLinked(session, coordinate, workItemId) {
|
|
308
|
+
let item;
|
|
309
|
+
try {
|
|
310
|
+
item = await session.client.workItems.get(workItemId, { expand: "relations" });
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Deleted, moved to a project this token cannot see, or unreadable for a
|
|
314
|
+
// reason the create is about to hit anyway. Not evidence of a live link.
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
const links = linksFromHyperlinks(workItemId, hyperlinkUrls(item));
|
|
318
|
+
const linked = links.some((link) => link.repo === normalizeRepo(coordinate.repo) && link.issueNumber === coordinate.number);
|
|
319
|
+
return linked ? item : undefined;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* "A work item already tracks this issue and the issue does not say so."
|
|
323
|
+
*
|
|
324
|
+
* **Exit 3 and no write**, where the marker path is exit 0. The difference is
|
|
325
|
+
* real: there the state the caller asked for already holds, and here the link
|
|
326
|
+
* is genuinely incomplete and finishing it needs a different command. So this
|
|
327
|
+
* hands over that command — the same literal string the half-link failure
|
|
328
|
+
* carried, so a caller that kept the error and a caller that retried blind end
|
|
329
|
+
* up typing the same thing.
|
|
330
|
+
*/
|
|
331
|
+
function trackedRefusal(coordinate, found) {
|
|
332
|
+
const label = formatIssueCoordinate(coordinate);
|
|
333
|
+
const ids = found.workItemIds;
|
|
334
|
+
const one = ids.length === 1 ? ids[0] : undefined;
|
|
335
|
+
const recover = one === undefined ? undefined : `pharos issue link ${label} ${one}`;
|
|
336
|
+
const message = one === undefined
|
|
337
|
+
? `${label} is already tracked by ${ids.length} work items (${ids.join(", ")}), none of `
|
|
338
|
+
+ "which the issue mentions. Adopting would add another — `pharos issue trail` shows what "
|
|
339
|
+
+ "is there, and one of them wants unlinking before this can be resolved."
|
|
340
|
+
: `${label} is ALREADY tracked by work item #${one}, which links the issue while the issue `
|
|
341
|
+
+ "says nothing about it — what a failed `adopt` leaves behind. Nothing was created: a "
|
|
342
|
+
+ `second work item is not the repair. Finish the one that exists with \`${recover}\`.`;
|
|
343
|
+
return refusal(message, {
|
|
344
|
+
issue: coordinate,
|
|
345
|
+
...(one === undefined ? {} : { workItemId: one }),
|
|
346
|
+
workItemIds: [...ids],
|
|
347
|
+
// The same three keys the half-linked failure carries, so anything that
|
|
348
|
+
// learned to read that error reads this one for free.
|
|
349
|
+
halfLinked: true,
|
|
350
|
+
created: false,
|
|
351
|
+
wrote: [],
|
|
352
|
+
evidence: ["hyperlink"],
|
|
353
|
+
...(recover === undefined ? {} : { recover }),
|
|
354
|
+
// Which of the two guards saw it, because they answer different questions:
|
|
355
|
+
// `journal` means this machine started that adoption, `board` means
|
|
356
|
+
// somebody's did.
|
|
357
|
+
foundBy: found.source,
|
|
358
|
+
...(found.since === undefined || found.since === "" ? {} : { since: found.since }),
|
|
359
|
+
...(found.workItem === undefined ? {} : { workItem: found.workItem }),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** Which issue, on which board — see `half-link.ts` for why the board is part of it. */
|
|
363
|
+
function halfLinkKey(session, coordinate) {
|
|
364
|
+
return {
|
|
365
|
+
repo: normalizeRepo(coordinate.repo),
|
|
366
|
+
issue: coordinate.number,
|
|
367
|
+
organization: session.client.organization,
|
|
368
|
+
project: session.client.project ?? null,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* A parent, read once.
|
|
373
|
+
*
|
|
374
|
+
* Resolved rather than composed from the id: `addParent` wants the parent's
|
|
375
|
+
* canonical url, and a parent that does not exist fails HERE by name instead of
|
|
376
|
+
* arriving as an opaque 400 about a relation.
|
|
377
|
+
*
|
|
378
|
+
* Separated from {@link adoptionOps} because `backfill` adopts many issues under
|
|
379
|
+
* **one** parent, and reading it once per issue would be four hundred requests
|
|
380
|
+
* for one answer that cannot change during the run.
|
|
381
|
+
*/
|
|
382
|
+
async function resolveParent(session, id) {
|
|
383
|
+
const parent = await session.client.workItems.get(id);
|
|
384
|
+
if (typeof parent.url !== "string" || parent.url === "") {
|
|
385
|
+
throw usageError(`Work item #${id} did not report a url, so it cannot be linked as a parent.`);
|
|
386
|
+
}
|
|
387
|
+
return { id, url: parent.url, title: titleOf(parent) };
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The patch that turns one issue into one work item.
|
|
391
|
+
*
|
|
392
|
+
* **The `Hyperlink` relation and the parent ride in the create patch**, for the
|
|
393
|
+
* reason `pharos create` puts a parent there: a create followed by a relation
|
|
394
|
+
* add leaves a window where the work item exists and points at nothing, and if
|
|
395
|
+
* the second call fails the window never closes.
|
|
396
|
+
*/
|
|
397
|
+
function adoptionOps(issue, options) {
|
|
398
|
+
const coordinate = { repo: issue.repo, number: issue.number };
|
|
399
|
+
const ops = [
|
|
400
|
+
setField("System.Title", options.title),
|
|
401
|
+
...setMarkdown("System.Description", descriptionFor(issue)),
|
|
402
|
+
// The relation's comment is what a person scanning the Links tab reads, and
|
|
403
|
+
// `contoso/widgets#45` is the one string that identifies the issue there.
|
|
404
|
+
addHyperlink(issueUrl(coordinate), formatIssueCoordinate(coordinate)),
|
|
405
|
+
];
|
|
406
|
+
if (options.parent !== undefined)
|
|
407
|
+
ops.push(addParent(options.parent.url));
|
|
408
|
+
return ops;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Create the work item, then write the GitHub half. **The two writes of an
|
|
412
|
+
* adoption, in the one place both doors go through.**
|
|
413
|
+
*
|
|
414
|
+
* `adopt` and `backfill` differ in how they choose an issue and in what they
|
|
415
|
+
* print; they must not differ by a byte in what they *write*, or the bulk route
|
|
416
|
+
* would be a second adoption with its own failure modes and its own idea of what
|
|
417
|
+
* a link looks like. That is the "second implementation that drifts" failure
|
|
418
|
+
* `AGENTS.md` names three times, and it would be worse here than usual: the
|
|
419
|
+
* drifting one would be the one that runs four hundred times unattended.
|
|
420
|
+
*
|
|
421
|
+
* **The window between the two writes is written down before it opens.** From
|
|
422
|
+
* the create until the comment lands, a work item exists that neither index can
|
|
423
|
+
* find — see `half-link.ts`. Recording it here rather than in the failure
|
|
424
|
+
* handler is deliberate: a `catch` sees an HTTP error and misses ctrl-C, a
|
|
425
|
+
* killed process and a closed laptop, which leave exactly the same orphan.
|
|
426
|
+
*/
|
|
427
|
+
async function performAdoption(session, ref, plan) {
|
|
428
|
+
const created = await session.client.workItems.create(plan.type, plan.ops, {
|
|
429
|
+
suppressNotifications: true,
|
|
430
|
+
});
|
|
431
|
+
const workItemUrl = session.workItemUrl(created);
|
|
432
|
+
const key = halfLinkKey(session, { repo: ref.repo, number: ref.number });
|
|
433
|
+
await session.halfLinks.record({ ...key, workItem: created.id, at: new Date().toISOString() });
|
|
434
|
+
const comment = await postLinkComment(session, ref, {
|
|
435
|
+
workItemId: created.id,
|
|
436
|
+
workItemUrl,
|
|
437
|
+
workItemTitle: plan.title,
|
|
438
|
+
// The work item exists from here on. If the comment fails, the failure has
|
|
439
|
+
// to carry the id or nothing anywhere names what was just created.
|
|
440
|
+
createdWorkItem: created.id,
|
|
441
|
+
});
|
|
442
|
+
// Both halves are written, so there is no longer a window to remember. The
|
|
443
|
+
// entry that survives a failure here is the one the next `adopt` refuses on.
|
|
444
|
+
await session.halfLinks.forget(key);
|
|
445
|
+
return {
|
|
446
|
+
workItemId: created.id,
|
|
447
|
+
rev: created.rev,
|
|
448
|
+
workItemUrl,
|
|
449
|
+
commentUrl: comment.htmlUrl ?? null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* A second `adopt` of an issue that already carries a marker.
|
|
454
|
+
*
|
|
455
|
+
* **Exit 0 with `created: false`, not a refusal.** The state the caller asked
|
|
456
|
+
* for already holds, and an agent that retries a call it cannot tell succeeded
|
|
457
|
+
* is the normal case rather than the odd one. Creating a second work item would
|
|
458
|
+
* be the actual damage.
|
|
459
|
+
*
|
|
460
|
+
* Two states here are genuinely wrong and are refused instead:
|
|
461
|
+
*
|
|
462
|
+
* - **Two different work items** claim the issue. Picking one would be
|
|
463
|
+
* inventing an answer to the question this whole feature exists to answer.
|
|
464
|
+
* - The work item the marker names **cannot be read**. It may be deleted, or
|
|
465
|
+
* in a project this token cannot see; either way "already adopted, here it
|
|
466
|
+
* is" would be a lie, and reporting the id with what went wrong is the only
|
|
467
|
+
* honest answer.
|
|
468
|
+
*/
|
|
469
|
+
async function alreadyAdopted(io, session, coordinate, issue, links) {
|
|
470
|
+
const ids = [...new Set(links.map((link) => link.workItemId))];
|
|
471
|
+
if (ids.length > 1) {
|
|
472
|
+
throw refusal(`${formatIssueCoordinate(coordinate)} carries markers for ${ids.length} different work `
|
|
473
|
+
+ `items (${ids.join(", ")}). Adopting again would add a third — resolve which one is `
|
|
474
|
+
+ "right first; `pharos issue trail` shows all of them.", { issue: coordinate, workItemIds: ids });
|
|
475
|
+
}
|
|
476
|
+
const workItemId = ids[0];
|
|
477
|
+
// The marker is on the issue, so whatever this machine remembered about an
|
|
478
|
+
// unfinished adoption of it is settled. Left in place it would cost one
|
|
479
|
+
// pointless read on some future run. **Not during a dry run**: a preview
|
|
480
|
+
// changes nothing anywhere, and that includes here.
|
|
481
|
+
if (!session.previewOnly) {
|
|
482
|
+
await session.halfLinks.forget(halfLinkKey(session, coordinate));
|
|
483
|
+
}
|
|
484
|
+
let item;
|
|
485
|
+
try {
|
|
486
|
+
item = await session.client.workItems.get(workItemId, { expand: "relations" });
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const { kind, code, body } = failureOf(error);
|
|
490
|
+
throw new CliError(`${formatIssueCoordinate(coordinate)} is already adopted as work item #${workItemId}, and `
|
|
491
|
+
+ `that work item could not be read: ${String(body["message"] ?? "")}`, kind, code, { issue: coordinate, workItemId, cause: body });
|
|
492
|
+
}
|
|
493
|
+
return report(io, session, {
|
|
494
|
+
applied: false,
|
|
495
|
+
created: false,
|
|
496
|
+
unchanged: true,
|
|
497
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url: issueUrl(coordinate) },
|
|
498
|
+
workItem: summariseWorkItem(session, item),
|
|
499
|
+
evidence: mergeLinks(links)[0]?.evidence ?? [],
|
|
500
|
+
message: `${formatIssueCoordinate(coordinate)} is already adopted as #${workItemId}. Nothing was `
|
|
501
|
+
+ "created.",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/** The issue's own words, under a line saying where they came from. */
|
|
505
|
+
function descriptionFor(issue) {
|
|
506
|
+
const coordinate = formatIssueCoordinate({ repo: issue.repo, number: issue.number });
|
|
507
|
+
const reporter = issue.author === undefined ? "" : ` — reported by \`@${issue.author}\``;
|
|
508
|
+
const header = `Adopted from [${coordinate}](${issue.htmlUrl})${reporter}.`;
|
|
509
|
+
const body = issue.body?.trim();
|
|
510
|
+
// The body crosses because the trail is the point: "the link exists and
|
|
511
|
+
// carries nothing" is the complaint against the Azure Boards app (plan §1),
|
|
512
|
+
// and a work item whose description says only "see GitHub" repeats it.
|
|
513
|
+
return body === undefined || body === "" ? header : `${header}\n\n---\n\n${body}`;
|
|
514
|
+
}
|
|
515
|
+
// MARK: - link
|
|
516
|
+
/**
|
|
517
|
+
* `pharos issue link <repo#n> <id>` — join an existing pair, or finish a join
|
|
518
|
+
* that half-happened.
|
|
519
|
+
*
|
|
520
|
+
* Both halves are checked before either is written, and only the missing ones
|
|
521
|
+
* are written, so running it twice is a no-op and running it after a failed
|
|
522
|
+
* `adopt` completes the link rather than duplicating the half that survived.
|
|
523
|
+
*/
|
|
524
|
+
async function runLink(io, session, positionals) {
|
|
525
|
+
const ref = await resolveIssue(session, positionals[0], "link");
|
|
526
|
+
const coordinate = { repo: ref.repo, number: ref.number };
|
|
527
|
+
const workItemId = workItemArgument(positionals[1], "Which work item? `pharos issue link owner/name#45 4821`.");
|
|
528
|
+
const github = await session.github();
|
|
529
|
+
const issue = await github.issues.get(ref);
|
|
530
|
+
const comments = await github.issues.comments(ref);
|
|
531
|
+
const item = await session.client.workItems.get(workItemId, { expand: "relations" });
|
|
532
|
+
const url = issueUrl(coordinate);
|
|
533
|
+
const fromComments = linksFromComments(coordinate, comments);
|
|
534
|
+
const fromRelations = linksFromHyperlinks(workItemId, hyperlinkUrls(item));
|
|
535
|
+
// **An issue may belong to one work item; a work item may reference many
|
|
536
|
+
// issues.** The asymmetry is not fussiness: "which work item tracks this
|
|
537
|
+
// issue" is the question the trail answers, and two answers is no answer. The
|
|
538
|
+
// other direction has an obvious meaning — one work item, several reported
|
|
539
|
+
// symptoms — so it is allowed.
|
|
540
|
+
const claimedBy = [...new Set(fromComments.map((link) => link.workItemId))].filter((id) => id !== workItemId);
|
|
541
|
+
if (claimedBy.length > 0) {
|
|
542
|
+
throw refusal(`${formatIssueCoordinate(coordinate)} is already linked to work item `
|
|
543
|
+
+ `${claimedBy.map((id) => `#${id}`).join(", ")}. An issue belongs to ONE work item — `
|
|
544
|
+
+ "unlink the other end first, or check `pharos issue trail` for what is already there.", { issue: coordinate, workItemId, linkedTo: claimedBy });
|
|
545
|
+
}
|
|
546
|
+
const hasHyperlink = fromRelations.some((link) => link.repo === normalizeRepo(coordinate.repo) && link.issueNumber === coordinate.number);
|
|
547
|
+
const hasComment = fromComments.some((link) => link.workItemId === workItemId);
|
|
548
|
+
if (hasHyperlink && hasComment) {
|
|
549
|
+
return report(io, session, {
|
|
550
|
+
applied: false,
|
|
551
|
+
unchanged: true,
|
|
552
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url },
|
|
553
|
+
workItem: summariseWorkItem(session, item),
|
|
554
|
+
// Found by the pair rather than taken as the first row: a work item is
|
|
555
|
+
// allowed to reference several issues, so `[0]` would be an assumption
|
|
556
|
+
// about ordering that happens to hold.
|
|
557
|
+
evidence: mergeLinks(fromComments, fromRelations).find((link) => link.repo === normalizeRepo(coordinate.repo)
|
|
558
|
+
&& link.issueNumber === coordinate.number
|
|
559
|
+
&& link.workItemId === workItemId)?.evidence ?? [],
|
|
560
|
+
message: `${formatIssueCoordinate(coordinate)} and #${workItemId} are already linked.`,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
const missing = [
|
|
564
|
+
...(hasHyperlink ? [] : ["hyperlink"]),
|
|
565
|
+
...(hasComment ? [] : ["comment"]),
|
|
566
|
+
];
|
|
567
|
+
if (session.previewOnly) {
|
|
568
|
+
return report(io, session, {
|
|
569
|
+
applied: false,
|
|
570
|
+
dryRun: true,
|
|
571
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url },
|
|
572
|
+
workItem: summariseWorkItem(session, item),
|
|
573
|
+
wouldWrite: missing,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
if (!hasHyperlink) {
|
|
577
|
+
// Guarded on the rev of the read the relations came from, like every other
|
|
578
|
+
// relation write here: adding a relation is itself a revision, so a
|
|
579
|
+
// teammate's write lands as a conflict rather than on top of.
|
|
580
|
+
await session.client.workItems.updateWithRev(workItemId, item.rev, [addHyperlink(url, formatIssueCoordinate(coordinate))], { suppressNotifications: true });
|
|
581
|
+
}
|
|
582
|
+
let commentUrl = null;
|
|
583
|
+
if (!hasComment) {
|
|
584
|
+
const comment = await postLinkComment(session, ref, {
|
|
585
|
+
workItemId,
|
|
586
|
+
workItemUrl: session.workItemUrl(item),
|
|
587
|
+
workItemTitle: titleOf(item) ?? undefined,
|
|
588
|
+
});
|
|
589
|
+
commentUrl = comment.htmlUrl ?? null;
|
|
590
|
+
// This is the repair for a half-linked adoption, and it just completed one.
|
|
591
|
+
// Nothing depends on the entry going — the marker now answers the question
|
|
592
|
+
// by itself — but leaving it would keep a resolved failure on the books.
|
|
593
|
+
await session.halfLinks.forget(halfLinkKey(session, coordinate));
|
|
594
|
+
}
|
|
595
|
+
return report(io, session, {
|
|
596
|
+
applied: true,
|
|
597
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url },
|
|
598
|
+
workItem: summariseWorkItem(session, item),
|
|
599
|
+
wrote: missing,
|
|
600
|
+
commentUrl,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// MARK: - say
|
|
604
|
+
/**
|
|
605
|
+
* How much of the message reaches the board when nobody wrote a `--summary`.
|
|
606
|
+
*
|
|
607
|
+
* Truncating is safe **only because the comment's URL always travels with it**.
|
|
608
|
+
* The board never carries the last word — it carries enough to know whether to
|
|
609
|
+
* click, and the click lands on the exact comment rather than on the issue.
|
|
610
|
+
*/
|
|
611
|
+
const SUMMARY_LIMIT = 200;
|
|
612
|
+
/**
|
|
613
|
+
* `pharos issue say <owner/name#45> <message>` — one event, two audiences, and
|
|
614
|
+
* the asymmetry is the whole verb.
|
|
615
|
+
*
|
|
616
|
+
* | | GitHub | Azure DevOps |
|
|
617
|
+
* |---|---|---|
|
|
618
|
+
* | who reads it | the reporter, who does **not** have an Azure DevOps account and never will | the team |
|
|
619
|
+
* | what they get | the full explanation, in their register | a summary, and the URL of the comment that carries the rest |
|
|
620
|
+
*
|
|
621
|
+
* Writing the same words to both would be `gh issue comment` followed by
|
|
622
|
+
* `pharos comment add`, and that is *not* what this is: those two are equally
|
|
623
|
+
* long in both places and neither knows the other happened. Plan §8.
|
|
624
|
+
*
|
|
625
|
+
* ## The loop guard, which is the reason this verb is dangerous
|
|
626
|
+
*
|
|
627
|
+
* Get it wrong and Pharos summarises its own summary back across the bridge,
|
|
628
|
+
* forever. It has two halves and only one of them is on this path:
|
|
629
|
+
*
|
|
630
|
+
* 1. **Every comment posted here carries the `pharos:v1` trailer**, because
|
|
631
|
+
* that is what the read side keys on. `isPharosAuthored` is total only if
|
|
632
|
+
* nothing anywhere posts an unmarked Pharos comment — a single one is a
|
|
633
|
+
* comment the sweep reads as a human's, and the loop that starts from it
|
|
634
|
+
* does not stop. The trailer is composed by `gh-core`, never here.
|
|
635
|
+
* 2. **A message that already carries a trailer is refused.** That is somebody
|
|
636
|
+
* handing Pharos its own output back — a mirrored body, a copy-paste, a
|
|
637
|
+
* script re-posting what it read — and it is the loop starting one step
|
|
638
|
+
* before any sweep could see it. The app's queue refuses exactly this
|
|
639
|
+
* (#785); both doors refuse the same thing.
|
|
640
|
+
*
|
|
641
|
+
* **The guard keys on content, never on `sender.login`.** GitHub events carry
|
|
642
|
+
* an actor and Azure DevOps events do not, which makes the actor look like the
|
|
643
|
+
* easy answer here and is the trap: every client writes with its own `gh`
|
|
644
|
+
* credential, so `sender.login == me` is *also* true of the human typing into
|
|
645
|
+
* the web UI. Suppressing on it silently swallows their real comments (§5).
|
|
646
|
+
*
|
|
647
|
+
* ## GitHub first, and that is not the order `adopt` uses
|
|
648
|
+
*
|
|
649
|
+
* `adopt` and `link` write Azure DevOps first because the work item id does not
|
|
650
|
+
* exist until the create lands. Here the dependency points the other way: the
|
|
651
|
+
* board's comment carries the GitHub comment's URL, which does not exist until
|
|
652
|
+
* the POST returns. So the half-written state is the mirror image — GitHub
|
|
653
|
+
* written, the board not — and it gets the same treatment: reported with the
|
|
654
|
+
* URL and a runnable command, never swallowed.
|
|
655
|
+
*
|
|
656
|
+
* The work item is **read before anything is posted**, because the GitHub half
|
|
657
|
+
* is published on somebody else's repository and cannot be taken back. A
|
|
658
|
+
* message announcing a work item that turns out to be unreadable is worse than
|
|
659
|
+
* a failed command.
|
|
660
|
+
*
|
|
661
|
+
* **Re-running is not the recovery, and that is the one thing to know.** A
|
|
662
|
+
* comment is an append: `say` twice says it twice. So the error left by a
|
|
663
|
+
* half-written fan-out names `pharos comment add` — finish the missing half —
|
|
664
|
+
* rather than inviting a retry that would post a second public comment.
|
|
665
|
+
*/
|
|
666
|
+
async function runSay(io, session, positionals, options) {
|
|
667
|
+
const target = positionals[0];
|
|
668
|
+
if (target !== undefined && /^\d+$/.test(target.trim())) {
|
|
669
|
+
// A work item may hyperlink SEVERAL issues, so that end names no single
|
|
670
|
+
// audience — which is why this verb has one direction while `trail` has two.
|
|
671
|
+
throw usageError(`\`say\` starts from the issue, not from work item #${target.trim()} — a work item can `
|
|
672
|
+
+ "reference several issues and there would be no one reporter to answer. "
|
|
673
|
+
+ `\`pharos issue trail ${target.trim()}\` names them.`, { given: target.trim() });
|
|
674
|
+
}
|
|
675
|
+
const ref = await resolveIssue(session, target, "say");
|
|
676
|
+
const coordinate = { repo: ref.repo, number: ref.number };
|
|
677
|
+
const message = messageArgument(positionals[1], options.message);
|
|
678
|
+
// Before any call. The refusal is about what was HANDED to this command, so
|
|
679
|
+
// spending a request to discover it would only make it slower.
|
|
680
|
+
if (isPharosAuthored(message)) {
|
|
681
|
+
throw refusal("That message already carries a pharos:v1 trailer, which means it is a comment Pharos "
|
|
682
|
+
+ "wrote. Posting it again is how the fan-out starts summarising its own summaries — "
|
|
683
|
+
+ "say what you mean in your own words instead.", { issue: coordinate, hint: "The trailer is added on the way out; never pass one in." });
|
|
684
|
+
}
|
|
685
|
+
const github = await session.github();
|
|
686
|
+
const issue = await github.issues.get(ref);
|
|
687
|
+
const comments = await github.issues.comments(ref);
|
|
688
|
+
const links = linksFromComments(coordinate, comments);
|
|
689
|
+
const workItemId = soleWorkItem(coordinate, links);
|
|
690
|
+
// Read first: past the POST below the message is public on a repository that
|
|
691
|
+
// may not be ours, and "the work item it names cannot be read" is a fact
|
|
692
|
+
// worth having BEFORE that rather than after.
|
|
693
|
+
let item;
|
|
694
|
+
try {
|
|
695
|
+
item = await session.client.workItems.get(workItemId);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
const { kind, code, body } = failureOf(error);
|
|
699
|
+
throw new CliError(`${formatIssueCoordinate(coordinate)} is linked to work item #${workItemId}, and that work `
|
|
700
|
+
+ `item could not be read: ${String(body["message"] ?? "")}. Nothing was posted.`, kind, code, { issue: coordinate, workItemId, wrote: [], cause: body });
|
|
701
|
+
}
|
|
702
|
+
const url = issueUrl(coordinate);
|
|
703
|
+
const workItemUrl = session.workItemUrl(item);
|
|
704
|
+
// Composed by `gh-core`, so this and the macOS app produce the same bytes and
|
|
705
|
+
// the shared fixture means something. The trailer arrives with it.
|
|
706
|
+
const githubBody = composeLinkComment({
|
|
707
|
+
repo: ref.repo,
|
|
708
|
+
issue: ref.number,
|
|
709
|
+
adoId: workItemId,
|
|
710
|
+
workItemUrl,
|
|
711
|
+
workItemTitle: titleOf(item) ?? undefined,
|
|
712
|
+
message,
|
|
713
|
+
});
|
|
714
|
+
const summary = summaryFor(message, options.summary);
|
|
715
|
+
if (session.previewOnly) {
|
|
716
|
+
return report(io, session, {
|
|
717
|
+
applied: false,
|
|
718
|
+
dryRun: true,
|
|
719
|
+
issue: { ...coordinate, title: issue.title, state: issue.state, url },
|
|
720
|
+
workItem: summariseWorkItem(session, item),
|
|
721
|
+
wouldWrite: ["github-comment", "ado-comment"],
|
|
722
|
+
// Both bodies, in full. A preview of a two-audience write that showed one
|
|
723
|
+
// audience's words would hide the half this verb exists to get right.
|
|
724
|
+
//
|
|
725
|
+
// The board's half links the ISSUE here rather than the comment, and that
|
|
726
|
+
// is the one thing a preview cannot be honest about: the comment does not
|
|
727
|
+
// exist until it is posted, so there is no anchor to show.
|
|
728
|
+
github: { body: githubBody },
|
|
729
|
+
ado: { body: adoSummaryComment({ coordinate, link: url, summary }) },
|
|
730
|
+
summary: { ...summary },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
let posted;
|
|
734
|
+
try {
|
|
735
|
+
posted = await github.issues.comment(ref, githubBody);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
const { kind, code, body } = failureOf(error);
|
|
739
|
+
throw new CliError(`Nothing was posted: GitHub refused the comment on ${formatIssueCoordinate(coordinate)}: `
|
|
740
|
+
+ String(body["message"] ?? ""), kind, code, {
|
|
741
|
+
platform: "github",
|
|
742
|
+
issue: coordinate,
|
|
743
|
+
workItemId,
|
|
744
|
+
// Named even though it is empty: "which half landed" is the first thing
|
|
745
|
+
// a caller needs from a two-platform verb, and absent is not the same
|
|
746
|
+
// answer as none.
|
|
747
|
+
wrote: [],
|
|
748
|
+
hint: "Neither platform was written, so re-running this command is safe.",
|
|
749
|
+
cause: body,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
// Past this line the comment is public on somebody else's repository.
|
|
753
|
+
const commentUrl = posted.htmlUrl ?? url;
|
|
754
|
+
const adoBody = adoSummaryComment({ coordinate, link: commentUrl, summary });
|
|
755
|
+
let adoComment;
|
|
756
|
+
try {
|
|
757
|
+
adoComment = await session.client.comments.add(workItemId, adoBody);
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
const { kind, code, body } = failureOf(error);
|
|
761
|
+
const recover = `pharos comment add ${workItemId} --stdin`;
|
|
762
|
+
throw new CliError(`The GitHub comment on ${formatIssueCoordinate(coordinate)} was POSTED and the Azure DevOps `
|
|
763
|
+
+ `summary on #${workItemId} was not: ${String(body["message"] ?? "")}`, kind, code, {
|
|
764
|
+
platform: "azure-devops",
|
|
765
|
+
issue: coordinate,
|
|
766
|
+
workItemId,
|
|
767
|
+
wrote: ["github-comment"],
|
|
768
|
+
halfSaid: true,
|
|
769
|
+
github: { commentId: posted.id, commentUrl },
|
|
770
|
+
// The exact text, so finishing the job needs no reconstruction — and so
|
|
771
|
+
// the URL inside it is the one already published rather than a new one.
|
|
772
|
+
adoComment: adoBody,
|
|
773
|
+
recover,
|
|
774
|
+
hint: "Do NOT re-run `pharos issue say` — a comment is an append, so it would post a SECOND "
|
|
775
|
+
+ `public comment on ${formatIssueCoordinate(coordinate)}. Pipe the text in `
|
|
776
|
+
+ `\`adoComment\` into \`${recover}\` to write the half that is missing.`,
|
|
777
|
+
cause: body,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return report(io, session, {
|
|
781
|
+
applied: true,
|
|
782
|
+
issue: {
|
|
783
|
+
...coordinate,
|
|
784
|
+
title: issue.title,
|
|
785
|
+
state: issue.state,
|
|
786
|
+
url,
|
|
787
|
+
author: issue.author ?? null,
|
|
788
|
+
},
|
|
789
|
+
workItem: summariseWorkItem(session, item),
|
|
790
|
+
wrote: ["github-comment", "ado-comment"],
|
|
791
|
+
github: { commentId: posted.id, commentUrl },
|
|
792
|
+
ado: { commentId: adoComment.id },
|
|
793
|
+
summary: { ...summary },
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* The one work item this issue belongs to, or a refusal saying why there isn't
|
|
798
|
+
* one.
|
|
799
|
+
*
|
|
800
|
+
* Both refusals matter and they are different states. **Nothing** means the
|
|
801
|
+
* issue was never adopted, and `say` will not invent the other end of a link —
|
|
802
|
+
* `adopt` is the verb that creates one. **Several** is the state `adopt` and
|
|
803
|
+
* `link` already refuse: an issue belongs to one work item, so two answers is
|
|
804
|
+
* no answer, and here it would also mean choosing whose board hears about it.
|
|
805
|
+
*/
|
|
806
|
+
function soleWorkItem(coordinate, links) {
|
|
807
|
+
const ids = [...new Set(links.map((link) => link.workItemId))];
|
|
808
|
+
if (ids.length === 0) {
|
|
809
|
+
const label = formatIssueCoordinate(coordinate);
|
|
810
|
+
throw refusal(`${label} is not linked to any work item, so there is no board to summarise this on. `
|
|
811
|
+
+ `Adopt it first: \`pharos issue adopt ${label}\`, or \`pharos issue link ${label} <id>\` `
|
|
812
|
+
+ "if the work item already exists.", {
|
|
813
|
+
issue: coordinate,
|
|
814
|
+
recover: `pharos issue adopt ${label}`,
|
|
815
|
+
// Said explicitly: `say` is a fan-out, not a comment verb wearing a
|
|
816
|
+
// link. Somebody who only wants to talk to GitHub already has `gh`.
|
|
817
|
+
hint: "`say` writes to both platforms — commenting on GitHub alone is `gh issue comment`.",
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
if (ids.length > 1) {
|
|
821
|
+
throw refusal(`${formatIssueCoordinate(coordinate)} carries markers for ${ids.length} different work `
|
|
822
|
+
+ `items (${ids.join(", ")}), so there is no one board to summarise this on. `
|
|
823
|
+
+ "`pharos issue trail` shows all of them; resolve which one is right first.", { issue: coordinate, workItemIds: ids });
|
|
824
|
+
}
|
|
825
|
+
return ids[0];
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* What the board hears.
|
|
829
|
+
*
|
|
830
|
+
* `--summary` when there is one. Otherwise the first paragraph, collapsed to a
|
|
831
|
+
* line and cut at {@link SUMMARY_LIMIT} on a word boundary — which is a guess,
|
|
832
|
+
* and is allowed to be one **only because the comment's URL goes with it**. The
|
|
833
|
+
* board is never the record of what was said; GitHub is, and the summary's job
|
|
834
|
+
* is to be enough to decide whether to click.
|
|
835
|
+
*
|
|
836
|
+
* `complete` is tracked rather than assumed so the composed comment can stop
|
|
837
|
+
* claiming there is more to read when there isn't. A `--summary` is never
|
|
838
|
+
* called complete: only its author knows whether it left anything out, and
|
|
839
|
+
* over-claiming is the failure that matters.
|
|
840
|
+
*/
|
|
841
|
+
function summaryFor(message, given) {
|
|
842
|
+
const written = given?.trim();
|
|
843
|
+
if (written !== undefined && written !== "") {
|
|
844
|
+
return { text: written, source: "given", complete: false };
|
|
845
|
+
}
|
|
846
|
+
const whole = collapse(message);
|
|
847
|
+
// `\r` is in the class on purpose: `--file` reads whatever is on disk, and a
|
|
848
|
+
// CRLF file would otherwise have no paragraph breaks at all — every message
|
|
849
|
+
// one paragraph, every summary a truncation.
|
|
850
|
+
const paragraph = collapse(message.trim().split(/\n[ \t\r]*\n/)[0] ?? "");
|
|
851
|
+
if (paragraph.length <= SUMMARY_LIMIT) {
|
|
852
|
+
return { text: paragraph, source: "derived", complete: paragraph === whole };
|
|
853
|
+
}
|
|
854
|
+
const cut = paragraph.slice(0, SUMMARY_LIMIT);
|
|
855
|
+
const space = cut.lastIndexOf(" ");
|
|
856
|
+
// Only break on a word if the word boundary is near the end; a single long
|
|
857
|
+
// token would otherwise cut the summary down to almost nothing.
|
|
858
|
+
const kept = space > SUMMARY_LIMIT * 0.6 ? cut.slice(0, space) : cut;
|
|
859
|
+
return { text: `${kept.trimEnd()}…`, source: "derived", complete: false };
|
|
860
|
+
}
|
|
861
|
+
/** Every run of whitespace to one space. A board comment is one paragraph. */
|
|
862
|
+
function collapse(text) {
|
|
863
|
+
return text.replace(/\s+/g, " ").trim();
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The board's half: the summary, then where the rest of it is.
|
|
867
|
+
*
|
|
868
|
+
* The link is to the **comment**, not to the issue — a reader who has to find
|
|
869
|
+
* the right comment among forty has been handed a search, not a link. It falls
|
|
870
|
+
* back to the issue only when GitHub returned no `html_url`.
|
|
871
|
+
*
|
|
872
|
+
* There is deliberately **no marker on this side.** The Azure DevOps echo guard
|
|
873
|
+
* is the hub's `rev <= knownRev` (§5) and needs no help from the body; a
|
|
874
|
+
* trailer here would be a second, unread mechanism, and the one nobody reads is
|
|
875
|
+
* the one that rots.
|
|
876
|
+
*/
|
|
877
|
+
function adoSummaryComment(options) {
|
|
878
|
+
const label = formatIssueCoordinate(options.coordinate);
|
|
879
|
+
const where = options.summary.complete
|
|
880
|
+
? `Posted to [${label}](${options.link}).`
|
|
881
|
+
: `Posted to [${label}](${options.link}) — the full text is there.`;
|
|
882
|
+
return `${options.summary.text}\n\n${where}`;
|
|
883
|
+
}
|
|
884
|
+
function messageArgument(positional, provided) {
|
|
885
|
+
const text = positional ?? provided;
|
|
886
|
+
if (text === undefined || text.trim() === "") {
|
|
887
|
+
throw usageError("No message. Pass it as an argument, or use --text/--file/--stdin — an explanation written "
|
|
888
|
+
+ "for the reporter is usually longer than a command line wants.");
|
|
889
|
+
}
|
|
890
|
+
return text;
|
|
891
|
+
}
|
|
892
|
+
// MARK: - trail
|
|
893
|
+
/**
|
|
894
|
+
* `pharos issue trail <id|repo#n>` — the whole trail, from either end.
|
|
895
|
+
*
|
|
896
|
+
* **The output shape does not depend on which end you started from.** `from`
|
|
897
|
+
* says which was the anchor and every row still carries both sides, so nothing
|
|
898
|
+
* consuming this has to branch on the argument it passed. Read-only, and it
|
|
899
|
+
* spends no write budget — `--max-writes 0` is a perfectly good way to run it.
|
|
900
|
+
*
|
|
901
|
+
* `evidence` is the useful field. `["hyperlink","marker"]` is a complete link;
|
|
902
|
+
* one of them alone is a link that half-exists, which is the state `adopt`
|
|
903
|
+
* leaves behind when the second write fails and the state `link` repairs.
|
|
904
|
+
*/
|
|
905
|
+
async function runTrail(io, session, positionals) {
|
|
906
|
+
const target = positionals[0];
|
|
907
|
+
if (target === undefined || target === "") {
|
|
908
|
+
throw usageError("From which end? `pharos issue trail 4821` or `pharos issue trail owner/name#45`.");
|
|
909
|
+
}
|
|
910
|
+
return /^\d+$/.test(target.trim())
|
|
911
|
+
? trailFromWorkItem(io, session, Number(target.trim()))
|
|
912
|
+
: trailFromIssue(io, session, target);
|
|
913
|
+
}
|
|
914
|
+
async function trailFromWorkItem(io, session, id) {
|
|
915
|
+
const item = await session.client.workItems.get(id, { expand: "relations" });
|
|
916
|
+
const anchor = summariseWorkItem(session, item);
|
|
917
|
+
const hyperlinks = linksFromHyperlinks(id, hyperlinkUrls(item));
|
|
918
|
+
const rows = await Promise.all(hyperlinks.map(async (link) => {
|
|
919
|
+
const coordinate = { repo: link.repo, number: link.issueNumber };
|
|
920
|
+
const side = await readIssueSide(session, coordinate);
|
|
921
|
+
if (side.problem !== undefined) {
|
|
922
|
+
return {
|
|
923
|
+
workItem: anchor,
|
|
924
|
+
issue: null,
|
|
925
|
+
evidence: link.evidence,
|
|
926
|
+
twoSided: false,
|
|
927
|
+
problem: side.problem,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const merged = mergeLinks([link], side.links.filter((found) => found.workItemId === id))[0];
|
|
931
|
+
return {
|
|
932
|
+
workItem: anchor,
|
|
933
|
+
issue: summariseIssue(side.issue, coordinate),
|
|
934
|
+
evidence: merged?.evidence ?? link.evidence,
|
|
935
|
+
twoSided: merged !== undefined && isTwoSided(merged),
|
|
936
|
+
problem: null,
|
|
937
|
+
};
|
|
938
|
+
}));
|
|
939
|
+
return report(io, session, {
|
|
940
|
+
from: "workItem",
|
|
941
|
+
workItem: anchor,
|
|
942
|
+
issue: null,
|
|
943
|
+
links: rows,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
async function trailFromIssue(io, session, text) {
|
|
947
|
+
const ref = await resolveIssue(session, text, "trail");
|
|
948
|
+
const coordinate = { repo: ref.repo, number: ref.number };
|
|
949
|
+
const github = await session.github();
|
|
950
|
+
const issue = await github.issues.get(ref);
|
|
951
|
+
const comments = await github.issues.comments(ref);
|
|
952
|
+
const fromComments = linksFromComments(coordinate, comments);
|
|
953
|
+
const anchor = summariseIssue(issue, coordinate);
|
|
954
|
+
const rows = await Promise.all(fromComments.map(async (link) => {
|
|
955
|
+
let item;
|
|
956
|
+
try {
|
|
957
|
+
item = await session.client.workItems.get(link.workItemId, { expand: "relations" });
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
// A marker naming a work item that cannot be read is a real state — the
|
|
961
|
+
// item was deleted, or it is in a project this token cannot see — and
|
|
962
|
+
// reporting it as a failed command would hide the half of the trail
|
|
963
|
+
// that WAS readable.
|
|
964
|
+
return {
|
|
965
|
+
// The same keys as a readable one, with the unknown ones null. A row
|
|
966
|
+
// that changes SHAPE when something goes wrong makes the failure case
|
|
967
|
+
// the one a consumer has not written code for.
|
|
968
|
+
workItem: {
|
|
969
|
+
id: link.workItemId,
|
|
970
|
+
type: null,
|
|
971
|
+
title: null,
|
|
972
|
+
state: null,
|
|
973
|
+
assignedTo: null,
|
|
974
|
+
url: session.workItemUrl({ id: link.workItemId }),
|
|
975
|
+
},
|
|
976
|
+
issue: anchor,
|
|
977
|
+
evidence: link.evidence,
|
|
978
|
+
twoSided: false,
|
|
979
|
+
problem: failureOf(error).body["message"] ?? "the work item could not be read",
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const merged = mergeLinks([link], linksFromHyperlinks(link.workItemId, hyperlinkUrls(item)).filter((found) => found.issueNumber === coordinate.number))[0];
|
|
983
|
+
return {
|
|
984
|
+
workItem: summariseWorkItem(session, item),
|
|
985
|
+
issue: anchor,
|
|
986
|
+
evidence: merged?.evidence ?? link.evidence,
|
|
987
|
+
twoSided: merged !== undefined && isTwoSided(merged),
|
|
988
|
+
problem: null,
|
|
989
|
+
};
|
|
990
|
+
}));
|
|
991
|
+
return report(io, session, {
|
|
992
|
+
from: "issue",
|
|
993
|
+
workItem: null,
|
|
994
|
+
issue: anchor,
|
|
995
|
+
links: rows,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Read the GitHub half of a link, reporting an unreachable repository rather
|
|
1000
|
+
* than failing the whole command.
|
|
1001
|
+
*
|
|
1002
|
+
* A work item can hyperlink an issue in a repository this machine has no binding
|
|
1003
|
+
* for — somebody else adopted it, on a machine configured differently. That is
|
|
1004
|
+
* information, not a fault: the Azure DevOps end was read successfully and
|
|
1005
|
+
* saying so with the reason the other end is missing beats an exit code that
|
|
1006
|
+
* throws away what was found.
|
|
1007
|
+
*/
|
|
1008
|
+
async function readIssueSide(session, coordinate) {
|
|
1009
|
+
try {
|
|
1010
|
+
const github = await session.github();
|
|
1011
|
+
const binding = github.binding(coordinate.repo);
|
|
1012
|
+
const ref = { ...binding, number: coordinate.number };
|
|
1013
|
+
const issue = await github.issues.get(ref);
|
|
1014
|
+
const comments = await github.issues.comments(ref);
|
|
1015
|
+
return { issue, links: linksFromComments(coordinate, comments) };
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
return { links: [], problem: String(failureOf(error).body["message"] ?? error) };
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
// MARK: - drift
|
|
1022
|
+
/**
|
|
1023
|
+
* `pharos issue drift` — the report that proves the bridge is honest, and the
|
|
1024
|
+
* one thing here no other tool can produce.
|
|
1025
|
+
*
|
|
1026
|
+
* `gh` can list issues. Azure DevOps can list work items. Neither can say *these
|
|
1027
|
+
* two disagree*, because neither holds both sides — and a link nobody checks
|
|
1028
|
+
* decays quietly: an issue gets closed on GitHub and the board still shows the
|
|
1029
|
+
* work in progress; a work item is marked done and the reporter, who has no
|
|
1030
|
+
* Azure DevOps account and never will, is still waiting.
|
|
1031
|
+
*
|
|
1032
|
+
* ## What it scans, and why that is the Azure DevOps side
|
|
1033
|
+
*
|
|
1034
|
+
* Links, not issues. **A repository's unadopted issues are not drift** — on a
|
|
1035
|
+
* repo with four hundred of them, counting "not linked" as a problem would bury
|
|
1036
|
+
* the three real findings under three hundred and eighty-eight rows of noise.
|
|
1037
|
+
* What is missing from the bridge is `backfill`'s question; what is *wrong*
|
|
1038
|
+
* inside it is this one.
|
|
1039
|
+
*
|
|
1040
|
+
* So the scan starts from the board, because that is the enumerable side: a work
|
|
1041
|
+
* item carries its `Hyperlink` relations on its own row, so one WIQL and one
|
|
1042
|
+
* `workitemsbatch` return every link at once. GitHub cannot answer the
|
|
1043
|
+
* equivalent question at all — the marker lives in a comment, so asking costs a
|
|
1044
|
+
* request per issue. See `issue-scan.ts`.
|
|
1045
|
+
*
|
|
1046
|
+
* ## Four kinds, because they want four different actions
|
|
1047
|
+
*
|
|
1048
|
+
* | kind | what it means |
|
|
1049
|
+
* |---|---|
|
|
1050
|
+
* | `state` | both ends exist and disagree about whether the work is finished |
|
|
1051
|
+
* | `missing-issue` | the work item names an issue GitHub does not have |
|
|
1052
|
+
* | `one-sided` | the board has the link and the issue does not — what a failed `adopt` leaves |
|
|
1053
|
+
* | `unreadable` | the GitHub end could not be read, so nothing about this pair is known |
|
|
1054
|
+
*
|
|
1055
|
+
* The last one is **information, not a fault, and it never fails the command**
|
|
1056
|
+
* (#782). A work item can hyperlink an issue in a repository this machine has no
|
|
1057
|
+
* binding for, because somebody else adopted it on a machine configured
|
|
1058
|
+
* differently. Throwing that away along with everything the scan *did* learn
|
|
1059
|
+
* would be the wrong trade.
|
|
1060
|
+
*
|
|
1061
|
+
* ## "Still open" is read from the project, never guessed
|
|
1062
|
+
*
|
|
1063
|
+
* A process template can rename every state, so `Closed` and `Done` are this
|
|
1064
|
+
* organisation's answer rather than Azure DevOps'. The catalogue is read and the
|
|
1065
|
+
* report says which source it used — and when it cannot be read the stock names
|
|
1066
|
+
* are used with that stated, because a row is uninterpretable without knowing
|
|
1067
|
+
* what was counted as finished. Same argument `pharos query` makes for filtering.
|
|
1068
|
+
*/
|
|
1069
|
+
async function runDrift(io, session, positionals, options) {
|
|
1070
|
+
if (positionals.length > 0) {
|
|
1071
|
+
throw usageError(`\`drift\` takes no argument — it scans the whole board. Did you mean \`--repo `
|
|
1072
|
+
+ `${positionals[0]}\` to narrow it to one repository, or \`pharos issue trail `
|
|
1073
|
+
+ `${positionals[0]}\` for one pair?`, { given: positionals[0] });
|
|
1074
|
+
}
|
|
1075
|
+
// Not fatal. A token without the work-item-type read still scans fine; it just
|
|
1076
|
+
// cannot be told what "finished" means here, and saying so and using the stock
|
|
1077
|
+
// names beats refusing to answer at all.
|
|
1078
|
+
let catalog;
|
|
1079
|
+
let catalogFailure;
|
|
1080
|
+
try {
|
|
1081
|
+
catalog = await session.client.workItemTypes.states();
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
catalogFailure = error instanceof Error ? error.message : String(error);
|
|
1085
|
+
}
|
|
1086
|
+
const states = finishedStates(catalog, catalogFailure);
|
|
1087
|
+
const statement = options.wiql ?? driftWiql();
|
|
1088
|
+
let items;
|
|
1089
|
+
try {
|
|
1090
|
+
items = await session.client.wiql.queryAndFetch(statement, { expand: "relations" });
|
|
1091
|
+
}
|
|
1092
|
+
catch (error) {
|
|
1093
|
+
throw explainScanFailure(error, statement, options.wiql !== undefined);
|
|
1094
|
+
}
|
|
1095
|
+
const found = linksOnWorkItems(items, (item) => describeWorkItem(session, item), (item) => linksFromHyperlinks(item.id, hyperlinkUrls(item)));
|
|
1096
|
+
// Compared on the KEY, never with `===`. GitHub resolves a repository
|
|
1097
|
+
// case-insensitively and echoes back the spelling it was created with, so
|
|
1098
|
+
// `Contoso/Widgets` and `contoso/widgets` are one repository written two ways
|
|
1099
|
+
// — and #787 reported a perfectly good link as half-written over exactly this.
|
|
1100
|
+
const wanted = options.repo === undefined ? undefined : normalizeRepo(options.repo);
|
|
1101
|
+
const selected = wanted === undefined ? found : found.filter((link) => link.repo === wanted);
|
|
1102
|
+
const limit = options.limit;
|
|
1103
|
+
const truncated = limit !== undefined && selected.length > limit;
|
|
1104
|
+
const links = truncated ? selected.slice(0, limit) : selected;
|
|
1105
|
+
const sides = await readIssueSides(session, links);
|
|
1106
|
+
const drift = [];
|
|
1107
|
+
let healthy = 0;
|
|
1108
|
+
for (const [index, link] of links.entries()) {
|
|
1109
|
+
const verdict = judgeLink(link, sides[index], states);
|
|
1110
|
+
if (verdict.drift === undefined)
|
|
1111
|
+
healthy += 1;
|
|
1112
|
+
else
|
|
1113
|
+
drift.push(verdict.drift);
|
|
1114
|
+
}
|
|
1115
|
+
return report(io, session, {
|
|
1116
|
+
scanned: {
|
|
1117
|
+
workItems: items.length,
|
|
1118
|
+
links: selected.length,
|
|
1119
|
+
examined: links.length,
|
|
1120
|
+
repos: reposOf(links),
|
|
1121
|
+
// Reported so a surprising answer is one line to diagnose rather than a
|
|
1122
|
+
// mystery — and so a hand-written `--wiql` is on the record beside what it
|
|
1123
|
+
// found.
|
|
1124
|
+
wiql: statement,
|
|
1125
|
+
// Never silent. `truncated` is the difference between "the board is clean"
|
|
1126
|
+
// and "the first twenty were clean", and only one of those is worth acting
|
|
1127
|
+
// on.
|
|
1128
|
+
truncated,
|
|
1129
|
+
},
|
|
1130
|
+
finishedStates: {
|
|
1131
|
+
source: states.source,
|
|
1132
|
+
terminal: states.terminal,
|
|
1133
|
+
ambiguous: states.ambiguous,
|
|
1134
|
+
note: states.note,
|
|
1135
|
+
},
|
|
1136
|
+
healthy,
|
|
1137
|
+
counts: tallyDrift(drift),
|
|
1138
|
+
drift,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Read the GitHub half of every link, one repository at a time.
|
|
1143
|
+
*
|
|
1144
|
+
* **The repository is swept once rather than each issue fetched.** For a board
|
|
1145
|
+
* with four hundred links into one repository that is four requests instead of
|
|
1146
|
+
* four hundred, and it buys something a per-issue `GET` cannot: absence from a
|
|
1147
|
+
* full enumeration is *evidence* that an issue is not there, where a 404 is
|
|
1148
|
+
* equally consistent with a private repository, a rename, or a token that lost a
|
|
1149
|
+
* scope. `missing-issue` is a claim worth being sure about — it is the kind that
|
|
1150
|
+
* tells somebody a link they wrote is pointing at nothing.
|
|
1151
|
+
*
|
|
1152
|
+
* The marker read stays per issue, because that is where the marker is. It is
|
|
1153
|
+
* the expensive half and it is bounded: {@link READ_CONCURRENCY} at a time,
|
|
1154
|
+
* because GitHub's secondary rate limit is about concurrency rather than volume
|
|
1155
|
+
* and tripping it bans a token somebody else is also using.
|
|
1156
|
+
*/
|
|
1157
|
+
async function readIssueSides(session, links) {
|
|
1158
|
+
const sides = new Map();
|
|
1159
|
+
const key = (link) => `${link.repo}#${link.issueNumber}`;
|
|
1160
|
+
const byRepo = new Map();
|
|
1161
|
+
for (const link of links) {
|
|
1162
|
+
const existing = byRepo.get(link.repo);
|
|
1163
|
+
if (existing === undefined)
|
|
1164
|
+
byRepo.set(link.repo, [link]);
|
|
1165
|
+
else
|
|
1166
|
+
existing.push(link);
|
|
1167
|
+
}
|
|
1168
|
+
for (const [, group] of byRepo) {
|
|
1169
|
+
const first = group[0];
|
|
1170
|
+
let binding;
|
|
1171
|
+
let sweep;
|
|
1172
|
+
try {
|
|
1173
|
+
const github = await session.github();
|
|
1174
|
+
// Throws a `GitHubConfigError` naming the repo and the file when it is
|
|
1175
|
+
// unbound. There is deliberately no fallback to whichever `gh` account is
|
|
1176
|
+
// active (plan §2).
|
|
1177
|
+
binding = github.binding(first.spelling);
|
|
1178
|
+
sweep = await github.issues.list(binding, { state: "all" });
|
|
1179
|
+
}
|
|
1180
|
+
catch (error) {
|
|
1181
|
+
// One unreadable repository must not kill the sweep — the rest of the
|
|
1182
|
+
// board was read successfully and saying so with the reason beats an exit
|
|
1183
|
+
// code that throws away what was found (#782).
|
|
1184
|
+
const problem = String(failureOf(error).body["message"] ?? error);
|
|
1185
|
+
for (const link of group)
|
|
1186
|
+
sides.set(key(link), { problem });
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
const swept = new Map(sweep.issues.map((issue) => [issue.number, issue]));
|
|
1190
|
+
await inBatches(group, READ_CONCURRENCY, async (link) => {
|
|
1191
|
+
const ref = { ...binding, number: link.issueNumber };
|
|
1192
|
+
const issue = swept.get(link.issueNumber);
|
|
1193
|
+
if (issue === undefined) {
|
|
1194
|
+
sides.set(key(link), await explainAbsence(session, ref));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
const comments = await session.github().then((github) => github.issues.comments(ref));
|
|
1199
|
+
sides.set(key(link), {
|
|
1200
|
+
issue: factsOf(issue),
|
|
1201
|
+
markers: linksFromComments({ repo: link.spelling, number: link.issueNumber }, comments),
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
catch (error) {
|
|
1205
|
+
// The state is known and the marker is not. `markers` stays ABSENT
|
|
1206
|
+
// rather than empty, because empty would mean "read, and there is no
|
|
1207
|
+
// marker" — which is a half-written link, and reporting one on the
|
|
1208
|
+
// strength of a failed request would be inventing a finding.
|
|
1209
|
+
sides.set(key(link), {
|
|
1210
|
+
issue: factsOf(issue),
|
|
1211
|
+
problem: String(failureOf(error).body["message"] ?? error),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
return links.map((link) => sides.get(key(link)) ?? { problem: "not read" });
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* An issue number the repository's own enumeration does not contain.
|
|
1220
|
+
*
|
|
1221
|
+
* One request, and only for the numbers that are already anomalous, to tell the
|
|
1222
|
+
* three apart: a deleted or transferred issue (404), a **pull request** — which
|
|
1223
|
+
* `/issues/{n}` resolves and which is out of scope for v1, so it is a link
|
|
1224
|
+
* pointing somewhere this tool will not follow rather than a link pointing
|
|
1225
|
+
* nowhere — and anything else.
|
|
1226
|
+
*/
|
|
1227
|
+
async function explainAbsence(session, ref) {
|
|
1228
|
+
try {
|
|
1229
|
+
const github = await session.github();
|
|
1230
|
+
const issue = await github.issues.get(ref);
|
|
1231
|
+
// It answered after all: the sweep and the single read disagree, which
|
|
1232
|
+
// happens when an issue is created or transferred mid-sweep. Take the read.
|
|
1233
|
+
return { issue: factsOf(issue) };
|
|
1234
|
+
}
|
|
1235
|
+
catch (error) {
|
|
1236
|
+
if (error instanceof PullRequestNotAnIssueError) {
|
|
1237
|
+
return {
|
|
1238
|
+
problem: `${formatIssueCoordinate({ repo: ref.repo, number: ref.number })} is a pull request, not `
|
|
1239
|
+
+ "an issue. Pull requests are out of scope for v1 (plan §13.3), so this link is not "
|
|
1240
|
+
+ "checked either way.",
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
if (error instanceof GitHubNotFoundError) {
|
|
1244
|
+
return {
|
|
1245
|
+
missing: true,
|
|
1246
|
+
problem: String(failureOf(error).body["message"] ?? error),
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
return { problem: String(failureOf(error).body["message"] ?? error) };
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* A failed scan, with the clause that is most likely to blame named.
|
|
1254
|
+
*
|
|
1255
|
+
* `System.ExternalLinkCount` is queryable on every process template Azure DevOps
|
|
1256
|
+
* ships and this has not been run against a customised one, so if a project does
|
|
1257
|
+
* not expose it the statement fails to parse and the message is about a query
|
|
1258
|
+
* the caller never wrote. Handing over `--wiql` in the same breath turns "drift
|
|
1259
|
+
* is broken" into one flag — the same escape hatch `pharos query` already has,
|
|
1260
|
+
* for the same reason.
|
|
1261
|
+
*/
|
|
1262
|
+
function explainScanFailure(error, statement, handWritten) {
|
|
1263
|
+
const { kind, code, body } = failureOf(error);
|
|
1264
|
+
const message = String(body["message"] ?? "");
|
|
1265
|
+
if (handWritten || !looksLikeWiqlRejection(message))
|
|
1266
|
+
return error;
|
|
1267
|
+
return new CliError(`The drift scan's query was refused: ${message}`, kind, code, {
|
|
1268
|
+
wiql: statement,
|
|
1269
|
+
hint: "This narrows to work items carrying an external link with [System.ExternalLinkCount] > 0. "
|
|
1270
|
+
+ "If this project's process does not expose that field, pass your own statement with "
|
|
1271
|
+
+ "--wiql — it needs to SELECT [System.Id] and should return every work item that might "
|
|
1272
|
+
+ "carry a GitHub hyperlink.",
|
|
1273
|
+
cause: body,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
/** The `IssueFacts` a drift row needs, off a `GitHubIssue`. */
|
|
1277
|
+
function factsOf(issue) {
|
|
1278
|
+
return {
|
|
1279
|
+
repo: issue.repo,
|
|
1280
|
+
number: issue.number,
|
|
1281
|
+
title: issue.title,
|
|
1282
|
+
state: issue.state,
|
|
1283
|
+
stateReason: issue.stateReason ?? null,
|
|
1284
|
+
updatedAt: issue.updatedAt,
|
|
1285
|
+
url: issue.htmlUrl === "" ? issueUrl({ repo: issue.repo, number: issue.number }) : issue.htmlUrl,
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function describeWorkItem(session, item) {
|
|
1289
|
+
const fields = item.fields ?? {};
|
|
1290
|
+
const text = (name) => {
|
|
1291
|
+
const value = fields[name];
|
|
1292
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
1293
|
+
};
|
|
1294
|
+
return {
|
|
1295
|
+
id: item.id,
|
|
1296
|
+
type: text("System.WorkItemType"),
|
|
1297
|
+
title: text("System.Title"),
|
|
1298
|
+
state: text("System.State"),
|
|
1299
|
+
url: session.workItemUrl(item),
|
|
1300
|
+
};
|
|
1301
|
+
}
|
|
1302
|
+
// MARK: - backfill
|
|
1303
|
+
/**
|
|
1304
|
+
* `pharos issue backfill <owner/name>` — bulk `adopt`, for the repository
|
|
1305
|
+
* everybody actually has.
|
|
1306
|
+
*
|
|
1307
|
+
* Without this the bridge only works on a greenfield repository, which is not
|
|
1308
|
+
* where anybody is. A team with four hundred open issues cannot adopt them one
|
|
1309
|
+
* command at a time, and a feature they cannot start using is a feature they do
|
|
1310
|
+
* not have.
|
|
1311
|
+
*
|
|
1312
|
+
* ## Why this is a verb and not `adopt --all`
|
|
1313
|
+
*
|
|
1314
|
+
* `adopt` has a contract worth keeping intact: one issue, idempotent, exit 0,
|
|
1315
|
+
* and every failure is about that one issue. Bulk changes every one of those —
|
|
1316
|
+
* partial success becomes the normal outcome, and "what happened" stops being a
|
|
1317
|
+
* single answer. Overloading the argument to be sometimes-a-coordinate and
|
|
1318
|
+
* sometimes-absent would make the safe verb's shape depend on a flag.
|
|
1319
|
+
*
|
|
1320
|
+
* What it must **not** be is a second adoption. Both verbs go through
|
|
1321
|
+
* {@link performAdoption}, so the bytes written are the same from either door.
|
|
1322
|
+
*
|
|
1323
|
+
* ## Deciding what is already adopted, without four hundred requests
|
|
1324
|
+
*
|
|
1325
|
+
* The board is read first — one WIQL, one `workitemsbatch` — and every issue
|
|
1326
|
+
* number already reached by a `Hyperlink` is subtracted from the sweep. That is
|
|
1327
|
+
* correct because `adopt` writes the Azure DevOps end **first**: the half-state
|
|
1328
|
+
* it can leave is a hyperlink with no comment, never the reverse.
|
|
1329
|
+
*
|
|
1330
|
+
* The reverse is still possible if somebody deletes a relation by hand, so the
|
|
1331
|
+
* marker is re-read per issue **at adoption time** — on the handful being
|
|
1332
|
+
* written, not on all four hundred. An issue that turns out to carry a marker
|
|
1333
|
+
* already is skipped rather than adopted twice.
|
|
1334
|
+
*
|
|
1335
|
+
* ## It is resumable, and it needs no state file to be
|
|
1336
|
+
*
|
|
1337
|
+
* The link *is* the progress record. A second run reads the board again, finds
|
|
1338
|
+
* the ones the first run adopted already linked, and carries on — so a run that
|
|
1339
|
+
* stops at the write budget, at a rate limit, or because somebody pressed
|
|
1340
|
+
* ctrl-C, is resumed by running the identical command again. Candidates are
|
|
1341
|
+
* ordered by issue number ascending so that is true in the obvious way as well
|
|
1342
|
+
* as the technical one.
|
|
1343
|
+
*
|
|
1344
|
+
* ## The write budget is the throttle, on purpose
|
|
1345
|
+
*
|
|
1346
|
+
* Four hundred adoptions is eight hundred writes against a default cap of
|
|
1347
|
+
* {@link DEFAULT_WRITE_BUDGET}. Rather than discovering that halfway through,
|
|
1348
|
+
* the cost is computed up front and a run that cannot finish is **refused before
|
|
1349
|
+
* it starts** — naming both ways forward. Stopping halfway is recoverable here,
|
|
1350
|
+
* but "it did some of it" is the outcome a caller is least able to reason about,
|
|
1351
|
+
* and making somebody type the number is the point of the cap rather than a
|
|
1352
|
+
* side effect of it.
|
|
1353
|
+
*/
|
|
1354
|
+
async function runBackfill(io, session, positionals, options) {
|
|
1355
|
+
const repo = positionals[0];
|
|
1356
|
+
if (repo === undefined || repo === "") {
|
|
1357
|
+
throw usageError("Which repository? `pharos issue backfill owner/name`. It adopts the issues on that "
|
|
1358
|
+
+ "repository that the board does not already link.");
|
|
1359
|
+
}
|
|
1360
|
+
if (parseRepo(repo) === undefined) {
|
|
1361
|
+
throw usageError(`"${repo}" is not a GitHub repository. Expected owner/name, e.g. contoso/widgets — `
|
|
1362
|
+
+ "backfill takes a whole repository, not one issue.", { given: repo });
|
|
1363
|
+
}
|
|
1364
|
+
const state = issueStateFilter(options.state);
|
|
1365
|
+
const github = await session.github();
|
|
1366
|
+
const binding = github.binding(repo);
|
|
1367
|
+
// The board first: it is the cheap half, and it is what makes deciding
|
|
1368
|
+
// "already adopted" cost two requests instead of one per issue.
|
|
1369
|
+
const linked = linkedNumbersIn(await scanBoardLinks(session, options), repo);
|
|
1370
|
+
const sweep = await github.issues.list(binding, {
|
|
1371
|
+
state,
|
|
1372
|
+
...(options.labels.length === 0 ? {} : { labels: options.labels }),
|
|
1373
|
+
});
|
|
1374
|
+
const candidates = backfillCandidates(sweep.issues.map((issue) => ({
|
|
1375
|
+
number: issue.number,
|
|
1376
|
+
title: issue.title,
|
|
1377
|
+
state: issue.state,
|
|
1378
|
+
url: issue.htmlUrl,
|
|
1379
|
+
updatedAt: issue.updatedAt,
|
|
1380
|
+
})), linked);
|
|
1381
|
+
const limit = options.limit ?? candidates.length;
|
|
1382
|
+
const chosen = candidates.slice(0, limit);
|
|
1383
|
+
const needed = chosen.length * WRITES_PER_ADOPTION;
|
|
1384
|
+
const target = {
|
|
1385
|
+
repo: binding.repo,
|
|
1386
|
+
// Named because nothing in the design pairs a repository with a board (#807)
|
|
1387
|
+
// and on a machine with two clients configured nothing would say a word.
|
|
1388
|
+
// One mis-aimed `adopt` is one work item; one mis-aimed `backfill --yes` is
|
|
1389
|
+
// four hundred, each with a public comment naming a board its reporter has
|
|
1390
|
+
// nothing to do with. So the pairing is on screen before `--yes`.
|
|
1391
|
+
account: binding.account,
|
|
1392
|
+
organization: session.client.organization,
|
|
1393
|
+
project: session.client.project ?? null,
|
|
1394
|
+
};
|
|
1395
|
+
const preview = {
|
|
1396
|
+
repo: binding.repo,
|
|
1397
|
+
target,
|
|
1398
|
+
issues: {
|
|
1399
|
+
swept: sweep.issues.length,
|
|
1400
|
+
// From `gh-core`, which filters them at the boundary: every pull request
|
|
1401
|
+
// is an issue on that endpoint, and an unfiltered backfill would file work
|
|
1402
|
+
// items for the whole PR history.
|
|
1403
|
+
pullRequestsSkipped: sweep.pullRequestsSkipped,
|
|
1404
|
+
state,
|
|
1405
|
+
...(options.labels.length === 0 ? {} : { labels: options.labels }),
|
|
1406
|
+
alreadyLinked: linked.size,
|
|
1407
|
+
candidates: candidates.length,
|
|
1408
|
+
},
|
|
1409
|
+
wouldAdopt: chosen.length,
|
|
1410
|
+
...(options.limit === undefined ? {} : { limit: options.limit }),
|
|
1411
|
+
writes: {
|
|
1412
|
+
needed,
|
|
1413
|
+
perAdoption: WRITES_PER_ADOPTION,
|
|
1414
|
+
budget: session.budget.limit,
|
|
1415
|
+
remaining: session.budget.remaining,
|
|
1416
|
+
},
|
|
1417
|
+
candidates: chosen.map((candidate) => ({
|
|
1418
|
+
number: candidate.number,
|
|
1419
|
+
title: candidate.title,
|
|
1420
|
+
state: candidate.state,
|
|
1421
|
+
url: candidate.url,
|
|
1422
|
+
})),
|
|
1423
|
+
};
|
|
1424
|
+
if (chosen.length === 0) {
|
|
1425
|
+
return report(io, session, {
|
|
1426
|
+
applied: false,
|
|
1427
|
+
unchanged: true,
|
|
1428
|
+
...preview,
|
|
1429
|
+
message: candidates.length === 0
|
|
1430
|
+
? `Nothing to backfill: all ${sweep.issues.length} issues on ${binding.repo} matching `
|
|
1431
|
+
+ `state=${state} are already linked from the board.`
|
|
1432
|
+
: `--limit ${String(options.limit)} selected nothing.`,
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
const stop = approve(io, session, {
|
|
1436
|
+
what: `adopt ${chosen.length} issue${chosen.length === 1 ? "" : "s"} from ${binding.repo} onto `
|
|
1437
|
+
+ `${session.client.project ?? "this project"} — ${needed} writes, and a public comment on `
|
|
1438
|
+
+ `each issue`,
|
|
1439
|
+
would: preview,
|
|
1440
|
+
});
|
|
1441
|
+
if (stop !== undefined)
|
|
1442
|
+
return stop;
|
|
1443
|
+
// Refused BEFORE the first write rather than discovered at the last one. A run
|
|
1444
|
+
// that stops halfway is recoverable, but it is the outcome a caller can reason
|
|
1445
|
+
// about least, and the two ways forward are both one flag.
|
|
1446
|
+
if (needed > session.budget.remaining) {
|
|
1447
|
+
throw refusal(`Adopting ${chosen.length} issues costs ${needed} writes and this invocation has `
|
|
1448
|
+
+ `${session.budget.remaining} left of ${session.budget.limit}. Nothing was written.`, {
|
|
1449
|
+
...preview,
|
|
1450
|
+
hint: `Either raise the cap — \`--max-writes ${needed}\` — or take it in bites with `
|
|
1451
|
+
+ `\`--limit ${Math.max(1, Math.floor(session.budget.remaining / WRITES_PER_ADOPTION))}\`. `
|
|
1452
|
+
+ "Running it again later resumes where it stopped: the link itself is the progress "
|
|
1453
|
+
+ "record, so an adopted issue is simply no longer a candidate.",
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
const parent = options.parent === undefined ? undefined : await resolveParent(session, options.parent);
|
|
1457
|
+
const type = options.type ?? DEFAULT_WORK_ITEM_TYPE;
|
|
1458
|
+
const adopted = [];
|
|
1459
|
+
const skipped = [];
|
|
1460
|
+
const failed = [];
|
|
1461
|
+
let stopped;
|
|
1462
|
+
for (const candidate of chosen) {
|
|
1463
|
+
const ref = { ...binding, number: candidate.number };
|
|
1464
|
+
const coordinate = { repo: binding.repo, number: candidate.number };
|
|
1465
|
+
try {
|
|
1466
|
+
const outcome = await adoptOne(session, ref, coordinate, { type, parent, title: options.title });
|
|
1467
|
+
if (outcome.skipped !== undefined) {
|
|
1468
|
+
skipped.push(outcome.skipped);
|
|
1469
|
+
continue;
|
|
1470
|
+
}
|
|
1471
|
+
adopted.push(outcome.adopted);
|
|
1472
|
+
}
|
|
1473
|
+
catch (error) {
|
|
1474
|
+
const { kind, body } = failureOf(error);
|
|
1475
|
+
const row = {
|
|
1476
|
+
issue: formatIssueCoordinate(coordinate),
|
|
1477
|
+
number: candidate.number,
|
|
1478
|
+
kind,
|
|
1479
|
+
message: String(body["message"] ?? error),
|
|
1480
|
+
// Carried up verbatim when the adoption half-happened: the work item
|
|
1481
|
+
// exists, and a work item nobody can name is worse than a failed call.
|
|
1482
|
+
...(body["createdWorkItem"] === undefined
|
|
1483
|
+
? {}
|
|
1484
|
+
: { createdWorkItem: body["createdWorkItem"], halfLinked: true, recover: body["recover"] }),
|
|
1485
|
+
};
|
|
1486
|
+
failed.push(row);
|
|
1487
|
+
// **Stop on the failures that will repeat, carry on past the ones that are
|
|
1488
|
+
// about one issue.** A bad PAT, an exhausted budget and an hourly rate
|
|
1489
|
+
// limit are all true of the next three hundred issues too, so continuing
|
|
1490
|
+
// is three hundred identical errors and a longer wait for the same answer.
|
|
1491
|
+
// A 404 or a validation refusal is about this issue, and skipping past it
|
|
1492
|
+
// is what a bulk verb is for.
|
|
1493
|
+
if (WILL_REPEAT.has(kind)) {
|
|
1494
|
+
stopped = { after: adopted.length + skipped.length + failed.length, reason: kind, kindMessage: String(body["message"] ?? "") };
|
|
1495
|
+
break;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
const done = adopted.length + skipped.length;
|
|
1500
|
+
const remaining = candidates.length - done;
|
|
1501
|
+
const resume = `pharos issue backfill ${binding.repo}`
|
|
1502
|
+
+ (state === "open" ? "" : ` --state ${state}`)
|
|
1503
|
+
+ options.labels.map((label) => ` --label ${label}`).join("")
|
|
1504
|
+
+ (options.parent === undefined ? "" : ` --parent ${String(options.parent)}`)
|
|
1505
|
+
+ " --yes";
|
|
1506
|
+
return report(io, session, {
|
|
1507
|
+
applied: true,
|
|
1508
|
+
repo: binding.repo,
|
|
1509
|
+
target,
|
|
1510
|
+
adopted,
|
|
1511
|
+
skipped,
|
|
1512
|
+
failed,
|
|
1513
|
+
stopped: stopped ?? null,
|
|
1514
|
+
counts: {
|
|
1515
|
+
adopted: adopted.length,
|
|
1516
|
+
skipped: skipped.length,
|
|
1517
|
+
failed: failed.length,
|
|
1518
|
+
remaining: Math.max(0, remaining),
|
|
1519
|
+
},
|
|
1520
|
+
// Only when there is something left, so its presence means "there is more"
|
|
1521
|
+
// rather than being a line every caller has to interpret.
|
|
1522
|
+
...(remaining > 0 ? { resume } : {}),
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Failure kinds that are about the *run* rather than about one issue.
|
|
1527
|
+
*
|
|
1528
|
+
* Everything here is equally true of every remaining candidate, so continuing
|
|
1529
|
+
* would spend three hundred more requests to be told the same thing.
|
|
1530
|
+
*/
|
|
1531
|
+
const WILL_REPEAT = new Set(["auth", "config", "refused", "rateLimit", "network", "usage"]);
|
|
1532
|
+
/**
|
|
1533
|
+
* One issue, adopted or found already adopted.
|
|
1534
|
+
*
|
|
1535
|
+
* The marker read is the reason this is not just {@link performAdoption}: the
|
|
1536
|
+
* board's hyperlink index cannot see a link whose Azure DevOps end was deleted
|
|
1537
|
+
* by hand, and adopting over the top of one would put a second work item on an
|
|
1538
|
+
* issue that already names one. `adopt` makes the same check for the same
|
|
1539
|
+
* reason; here it is paid only for the issues actually being written.
|
|
1540
|
+
*/
|
|
1541
|
+
async function adoptOne(session, ref, coordinate, options) {
|
|
1542
|
+
const github = await session.github();
|
|
1543
|
+
const issue = await github.issues.get(ref);
|
|
1544
|
+
const comments = await github.issues.comments(ref);
|
|
1545
|
+
// `linksFromComments` + `mergeLinks` already collapse many marked comments to
|
|
1546
|
+
// one row per pair, so this ASKS whether the issue is claimed rather than
|
|
1547
|
+
// counting comments: `pharos issue say` posts a marked comment on every
|
|
1548
|
+
// invocation, and an issue can legitimately carry a dozen naming one work item
|
|
1549
|
+
// (#784).
|
|
1550
|
+
const existing = linksFromComments(coordinate, comments);
|
|
1551
|
+
const claimed = [...new Set(existing.map((link) => link.workItemId))];
|
|
1552
|
+
if (claimed.length > 0) {
|
|
1553
|
+
return {
|
|
1554
|
+
skipped: {
|
|
1555
|
+
issue: formatIssueCoordinate(coordinate),
|
|
1556
|
+
number: coordinate.number,
|
|
1557
|
+
reason: "already-adopted",
|
|
1558
|
+
workItemIds: claimed,
|
|
1559
|
+
detail: claimed.length === 1
|
|
1560
|
+
? `Already adopted as #${claimed[0]} — the board's link to it is missing, so `
|
|
1561
|
+
+ `\`pharos issue link ${formatIssueCoordinate(coordinate)} ${claimed[0]}\` is the `
|
|
1562
|
+
+ "repair. Creating a second work item is not."
|
|
1563
|
+
: `Carries markers for ${claimed.length} different work items (${claimed.join(", ")}); `
|
|
1564
|
+
+ "resolve which one is right before adopting anything here.",
|
|
1565
|
+
},
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
const title = options.title ?? issue.title;
|
|
1569
|
+
const outcome = await performAdoption(session, ref, {
|
|
1570
|
+
type: options.type,
|
|
1571
|
+
title,
|
|
1572
|
+
ops: adoptionOps(issue, { title, parent: options.parent }),
|
|
1573
|
+
});
|
|
1574
|
+
return {
|
|
1575
|
+
adopted: {
|
|
1576
|
+
issue: formatIssueCoordinate(coordinate),
|
|
1577
|
+
number: coordinate.number,
|
|
1578
|
+
title: issue.title,
|
|
1579
|
+
state: issue.state,
|
|
1580
|
+
workItem: { id: outcome.workItemId, type: options.type, title, url: outcome.workItemUrl },
|
|
1581
|
+
commentUrl: outcome.commentUrl,
|
|
1582
|
+
},
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/** The board's whole link index — shared with `drift`, which is what it is for. */
|
|
1586
|
+
async function scanBoardLinks(session, options) {
|
|
1587
|
+
const statement = options.wiql ?? driftWiql();
|
|
1588
|
+
let items;
|
|
1589
|
+
try {
|
|
1590
|
+
items = await session.client.wiql.queryAndFetch(statement, { expand: "relations" });
|
|
1591
|
+
}
|
|
1592
|
+
catch (error) {
|
|
1593
|
+
throw explainScanFailure(error, statement, options.wiql !== undefined);
|
|
1594
|
+
}
|
|
1595
|
+
return linksOnWorkItems(items, (item) => describeWorkItem(session, item), (item) => linksFromHyperlinks(item.id, hyperlinkUrls(item)));
|
|
1596
|
+
}
|
|
1597
|
+
/** `--state`, or the default and the argument for it. */
|
|
1598
|
+
function issueStateFilter(given) {
|
|
1599
|
+
const value = given?.trim().toLowerCase();
|
|
1600
|
+
if (value === undefined || value === "") {
|
|
1601
|
+
// **Open, not all.** The premise of this verb is a repository with four
|
|
1602
|
+
// hundred *open* issues; a closed one is history, and adopting it creates a
|
|
1603
|
+
// work item that is finished before anybody looks at it. `--state all` is
|
|
1604
|
+
// there for the migration that genuinely wants the archive.
|
|
1605
|
+
return "open";
|
|
1606
|
+
}
|
|
1607
|
+
if (value === "open" || value === "closed" || value === "all")
|
|
1608
|
+
return value;
|
|
1609
|
+
throw usageError(`--state must be open, closed or all; got "${given}".`, { given });
|
|
1610
|
+
}
|
|
1611
|
+
// MARK: - close
|
|
1612
|
+
/**
|
|
1613
|
+
* `pharos issue close <id|owner/name#45>` — the ending of the workflow, and the
|
|
1614
|
+
* one verb the plan's table listed that nothing had built.
|
|
1615
|
+
*
|
|
1616
|
+
* The value this whole feature claims (§1) is *"an issue is reported → picked up
|
|
1617
|
+
* as a work item → worked → commented on both sides in the register each
|
|
1618
|
+
* audience needs → **closed on both, each closure pointing at the other**"*.
|
|
1619
|
+
* Every arrow before the last one shipped. Without this the product has no way
|
|
1620
|
+
* to finish the thing it was built to start.
|
|
1621
|
+
*
|
|
1622
|
+
* ## Why this closes rather than composing a PR body
|
|
1623
|
+
*
|
|
1624
|
+
* §13.3 says Pharos's job is *"composing that body and verifying it fired rather
|
|
1625
|
+
* than doing the closing"* — and that sentence is inside **Decisions still
|
|
1626
|
+
* open**, under an item whose first four words are "Out of scope for v1". The
|
|
1627
|
+
* magic words only fire from a **PR merge**: §1 is explicit that the transition
|
|
1628
|
+
* "fires from a commit or a PR merge, never from closing an issue". With pull
|
|
1629
|
+
* requests out of scope there is no body to compose into, so a verb built that
|
|
1630
|
+
* way would verify a transition nothing had triggered and report "neither end
|
|
1631
|
+
* closed" every single time.
|
|
1632
|
+
*
|
|
1633
|
+
* The instinct behind it is still right, and it lands in two places:
|
|
1634
|
+
*
|
|
1635
|
+
* 1. **Which terminal state is asked, never assumed.** There is no hardcodable
|
|
1636
|
+
* `Closed` — see `terminalStateFor`. Several terminal states is a refusal
|
|
1637
|
+
* naming them and `--to`, not a guess between `Done` and `Removed`.
|
|
1638
|
+
* 2. **Both ends are re-read after the writes.** Two 200s are not proof: a
|
|
1639
|
+
* state transition can be refused by a rule the API reports as success on the
|
|
1640
|
+
* field it did apply, and `report.verified` is what was actually there
|
|
1641
|
+
* afterwards rather than what was sent.
|
|
1642
|
+
*
|
|
1643
|
+
* ## No `--yes`
|
|
1644
|
+
*
|
|
1645
|
+
* Closing is a state change and this CLI's gate is for what destroys or
|
|
1646
|
+
* overwrites — `pharos update --state Done` needs no `--yes` either, and a close
|
|
1647
|
+
* is reversible from both platforms. The one genuinely surprising case is
|
|
1648
|
+
* guarded where the surprise is: a work item hyperlinking **several** issues is
|
|
1649
|
+
* refused unless `--all` is passed, because "close #4821" reading as "close four
|
|
1650
|
+
* strangers' issues" is not what anybody typed.
|
|
1651
|
+
*/
|
|
1652
|
+
async function runClose(io, session, positionals, options) {
|
|
1653
|
+
const target = positionals[0];
|
|
1654
|
+
if (target === undefined || target === "") {
|
|
1655
|
+
throw usageError("Which pair? `pharos issue close 4821` or `pharos issue close owner/name#45`. It closes "
|
|
1656
|
+
+ "BOTH ends and says what did not move.");
|
|
1657
|
+
}
|
|
1658
|
+
// **Both of these are settled before a single call.** They are facts about
|
|
1659
|
+
// what was typed, so spending a request to discover them is only slower — and
|
|
1660
|
+
// worse, it reclassifies them: a bad `--reason` validated after the GitHub
|
|
1661
|
+
// client resolves reports as a *config* error about `repos.json`, which sends
|
|
1662
|
+
// an agent to fix a file that was never wrong.
|
|
1663
|
+
const reason = closingReason(options.reason);
|
|
1664
|
+
if (options.message !== undefined && isPharosAuthored(options.message)) {
|
|
1665
|
+
throw refusal("That closing note already carries a pharos:v1 trailer, which means it is a comment Pharos "
|
|
1666
|
+
+ "wrote. The trailer is added on the way out; never pass one in.");
|
|
1667
|
+
}
|
|
1668
|
+
let catalog;
|
|
1669
|
+
let catalogFailure;
|
|
1670
|
+
try {
|
|
1671
|
+
catalog = await session.client.workItemTypes.states();
|
|
1672
|
+
}
|
|
1673
|
+
catch (error) {
|
|
1674
|
+
catalogFailure = error instanceof Error ? error.message : String(error);
|
|
1675
|
+
}
|
|
1676
|
+
const states = finishedStates(catalog, catalogFailure);
|
|
1677
|
+
const pairs = await resolveClosePairs(session, target, options);
|
|
1678
|
+
const results = [];
|
|
1679
|
+
for (const pair of pairs) {
|
|
1680
|
+
results.push(await closePair(io, session, pair, states, options, reason));
|
|
1681
|
+
}
|
|
1682
|
+
const moved = results.filter((row) => row["wrote"].length > 0).length;
|
|
1683
|
+
const stuck = results.filter((row) => row["closed"] !== true);
|
|
1684
|
+
return report(io, session, {
|
|
1685
|
+
applied: moved > 0,
|
|
1686
|
+
...(moved === 0 ? { unchanged: true } : {}),
|
|
1687
|
+
closed: results.filter((row) => row["closed"] === true).length,
|
|
1688
|
+
// Named even at zero: "which ends did not move" is the whole deliverable of
|
|
1689
|
+
// a verb whose table row reads "verify both ends closed; report what did not".
|
|
1690
|
+
didNotClose: stuck.length,
|
|
1691
|
+
pairs: results,
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* The pairs to close, from whichever end was named.
|
|
1696
|
+
*
|
|
1697
|
+
* From an **issue**, the one work item that claims it — `soleWorkItem` already
|
|
1698
|
+
* refuses nothing and several, and both refusals are as right here as they are
|
|
1699
|
+
* for `say`.
|
|
1700
|
+
*
|
|
1701
|
+
* From a **work item**, every issue it hyperlinks. One work item legitimately
|
|
1702
|
+
* references several reported symptoms, and finishing the work does finish all
|
|
1703
|
+
* of them — but closing four strangers' issues is not what `close 4821` looks
|
|
1704
|
+
* like it does, so more than one needs `--all`.
|
|
1705
|
+
*/
|
|
1706
|
+
async function resolveClosePairs(session, target, options) {
|
|
1707
|
+
const github = await session.github();
|
|
1708
|
+
if (!/^\d+$/.test(target.trim())) {
|
|
1709
|
+
const ref = await resolveIssue(session, target, "close");
|
|
1710
|
+
const coordinate = { repo: ref.repo, number: ref.number };
|
|
1711
|
+
const comments = await github.issues.comments(ref);
|
|
1712
|
+
const workItemId = soleWorkItem(coordinate, linksFromComments(coordinate, comments));
|
|
1713
|
+
const item = await session.client.workItems.get(workItemId, { expand: "relations" });
|
|
1714
|
+
return [{ item, ref, coordinate }];
|
|
1715
|
+
}
|
|
1716
|
+
const id = Number(target.trim());
|
|
1717
|
+
const item = await session.client.workItems.get(id, { expand: "relations" });
|
|
1718
|
+
const links = linksFromHyperlinks(id, hyperlinkUrls(item));
|
|
1719
|
+
if (links.length === 0) {
|
|
1720
|
+
throw refusal(`Work item #${id} does not link any GitHub issue, so there is no other end to close. `
|
|
1721
|
+
+ `\`pharos update ${id} --state <state>\` closes the work item alone.`, { workItemId: id });
|
|
1722
|
+
}
|
|
1723
|
+
if (links.length > 1 && !options.all) {
|
|
1724
|
+
throw refusal(`Work item #${id} links ${links.length} issues `
|
|
1725
|
+
+ `(${links.map((link) => `${link.repo}#${link.issueNumber}`).join(", ")}). Closing it `
|
|
1726
|
+
+ "would close all of them, on repositories that may not be ours — pass --all if that is "
|
|
1727
|
+
+ "what you mean, or close one at a time from the issue end.", {
|
|
1728
|
+
workItemId: id,
|
|
1729
|
+
issues: links.map((link) => `${link.repo}#${link.issueNumber}`),
|
|
1730
|
+
hint: "`pharos issue trail` shows the whole trail first.",
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
return links.map((link) => {
|
|
1734
|
+
const coordinate = { repo: link.repo, number: link.issueNumber };
|
|
1735
|
+
return {
|
|
1736
|
+
item,
|
|
1737
|
+
ref: { ...github.binding(link.repo), number: link.issueNumber },
|
|
1738
|
+
coordinate,
|
|
1739
|
+
};
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Close one pair, then look again.
|
|
1744
|
+
*
|
|
1745
|
+
* **Azure DevOps first**, matching `adopt` and `link` so there is one recovery
|
|
1746
|
+
* path rather than three — and because the GitHub half is published on a
|
|
1747
|
+
* repository that may not be ours. Neither closing comment depends on a URL the
|
|
1748
|
+
* other write creates (unlike `say`, whose board comment carries the GitHub
|
|
1749
|
+
* comment's anchor), so the order is free to be the consistent one.
|
|
1750
|
+
*
|
|
1751
|
+
* Each closure points at the other, which is §1's phrase and not decoration: the
|
|
1752
|
+
* board comment names the issue, the issue comment names the work item, and a
|
|
1753
|
+
* person landing on either end can reach the other without knowing this tool
|
|
1754
|
+
* exists.
|
|
1755
|
+
*/
|
|
1756
|
+
async function closePair(io, session, pair, states, options, reason) {
|
|
1757
|
+
void io;
|
|
1758
|
+
const label = formatIssueCoordinate(pair.coordinate);
|
|
1759
|
+
const workItemId = pair.item.id;
|
|
1760
|
+
const workItemUrl = session.workItemUrl(pair.item);
|
|
1761
|
+
const url = issueUrl(pair.coordinate);
|
|
1762
|
+
const github = await session.github();
|
|
1763
|
+
const issue = await github.issues.get(pair.ref);
|
|
1764
|
+
const itemState = titleOfField(pair.item, "System.State");
|
|
1765
|
+
const itemType = titleOfField(pair.item, "System.WorkItemType");
|
|
1766
|
+
const alreadyFinished = isFinished(states, itemType, itemState);
|
|
1767
|
+
const alreadyClosed = issue.state === "closed";
|
|
1768
|
+
if (alreadyFinished && alreadyClosed) {
|
|
1769
|
+
// Idempotent, like `adopt`. The state asked for already holds, and an agent
|
|
1770
|
+
// that retries a call it cannot tell succeeded is the normal case.
|
|
1771
|
+
return {
|
|
1772
|
+
issue: label,
|
|
1773
|
+
workItem: workItemId,
|
|
1774
|
+
closed: true,
|
|
1775
|
+
wrote: [],
|
|
1776
|
+
unchanged: true,
|
|
1777
|
+
verified: { workItem: { state: itemState, finished: true }, issue: { state: issue.state } },
|
|
1778
|
+
message: `${label} and #${workItemId} are both already closed.`,
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
const targetState = alreadyFinished ? undefined : closingState(states, itemType, options);
|
|
1782
|
+
if (session.previewOnly) {
|
|
1783
|
+
return {
|
|
1784
|
+
issue: label,
|
|
1785
|
+
workItem: workItemId,
|
|
1786
|
+
closed: false,
|
|
1787
|
+
dryRun: true,
|
|
1788
|
+
wrote: [],
|
|
1789
|
+
wouldWrite: [
|
|
1790
|
+
...(alreadyFinished ? [] : ["work-item-state", "ado-comment"]),
|
|
1791
|
+
...(alreadyClosed ? [] : ["github-state", "github-comment"]),
|
|
1792
|
+
],
|
|
1793
|
+
...(targetState === undefined ? {} : { wouldMoveTo: targetState }),
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
const wrote = [];
|
|
1797
|
+
if (!alreadyFinished && targetState !== undefined) {
|
|
1798
|
+
// Guarded on the rev of the read this came from, like every other write
|
|
1799
|
+
// here. A teammate's edit lands as a conflict rather than on top of — and
|
|
1800
|
+
// `output.ts` already says out loud that a bare comment bumps System.Rev, so
|
|
1801
|
+
// a mismatch is not proof of an edit collision.
|
|
1802
|
+
await session.client.workItems.updateWithRev(workItemId, pair.item.rev, [
|
|
1803
|
+
setField("System.State", targetState),
|
|
1804
|
+
]);
|
|
1805
|
+
wrote.push("work-item-state");
|
|
1806
|
+
await session.client.comments.add(workItemId, `Closed as **${targetState}** alongside [${label}](${url}), which was closed on GitHub.`
|
|
1807
|
+
+ (options.message === undefined ? "" : `\n\n${collapse(options.message)}`));
|
|
1808
|
+
wrote.push("ado-comment");
|
|
1809
|
+
}
|
|
1810
|
+
if (!alreadyClosed) {
|
|
1811
|
+
await github.issues.setState(pair.ref, { state: "closed", stateReason: reason });
|
|
1812
|
+
wrote.push("github-state");
|
|
1813
|
+
// Composed by `gh-core` so this and the macOS app produce the same bytes,
|
|
1814
|
+
// and so the trailer travels with it — `isPharosAuthored` is total only if
|
|
1815
|
+
// nothing anywhere posts an unmarked Pharos comment.
|
|
1816
|
+
const body = composeLinkComment({
|
|
1817
|
+
repo: pair.ref.repo,
|
|
1818
|
+
issue: pair.ref.number,
|
|
1819
|
+
adoId: workItemId,
|
|
1820
|
+
workItemUrl,
|
|
1821
|
+
workItemTitle: titleOf(pair.item) ?? undefined,
|
|
1822
|
+
message: closingNote(options.message, reason),
|
|
1823
|
+
});
|
|
1824
|
+
await github.issues.comment(pair.ref, body);
|
|
1825
|
+
wrote.push("github-comment");
|
|
1826
|
+
}
|
|
1827
|
+
// **Look again rather than trusting two 200s.** A state transition can be
|
|
1828
|
+
// refused by a workflow rule on a field the API happily accepted, and the
|
|
1829
|
+
// whole row this verb answers to reads "verify both ends closed; report what
|
|
1830
|
+
// did not".
|
|
1831
|
+
const after = await session.client.workItems.get(workItemId);
|
|
1832
|
+
const afterIssue = await github.issues.get(pair.ref);
|
|
1833
|
+
const finishedNow = isFinished(states, titleOfField(after, "System.WorkItemType"), titleOfField(after, "System.State"));
|
|
1834
|
+
const closedNow = afterIssue.state === "closed";
|
|
1835
|
+
return {
|
|
1836
|
+
issue: label,
|
|
1837
|
+
workItem: workItemId,
|
|
1838
|
+
closed: finishedNow && closedNow,
|
|
1839
|
+
wrote,
|
|
1840
|
+
verified: {
|
|
1841
|
+
workItem: { state: titleOfField(after, "System.State"), finished: finishedNow },
|
|
1842
|
+
issue: { state: afterIssue.state, stateReason: afterIssue.stateReason ?? null },
|
|
1843
|
+
},
|
|
1844
|
+
...(finishedNow && closedNow
|
|
1845
|
+
? {}
|
|
1846
|
+
: {
|
|
1847
|
+
didNotClose: [
|
|
1848
|
+
...(finishedNow ? [] : ["work-item"]),
|
|
1849
|
+
...(closedNow ? [] : ["issue"]),
|
|
1850
|
+
],
|
|
1851
|
+
hint: "Both writes were accepted and one end is still open. That is usually a workflow rule "
|
|
1852
|
+
+ "on the work item type — a required field, or a transition this state cannot make "
|
|
1853
|
+
+ "directly. Nothing here retried it, because a rule will refuse it again.",
|
|
1854
|
+
}),
|
|
1855
|
+
};
|
|
1856
|
+
}
|
|
1857
|
+
/** `--to`, or the project's own answer, or a refusal saying which it could not pick. */
|
|
1858
|
+
function closingState(states, type, options) {
|
|
1859
|
+
const given = options.to?.trim();
|
|
1860
|
+
if (given !== undefined && given !== "")
|
|
1861
|
+
return given;
|
|
1862
|
+
const choice = terminalStateFor(states, type);
|
|
1863
|
+
if (choice.kind === "one")
|
|
1864
|
+
return choice.state;
|
|
1865
|
+
if (choice.kind === "several") {
|
|
1866
|
+
throw refusal(`${type ?? "This work item type"} has ${choice.states.length} finished states `
|
|
1867
|
+
+ `(${choice.states.join(", ")}) and they do not mean the same thing — "done" and `
|
|
1868
|
+
+ `"abandoned" are both closed. Name one with \`--to\`. Nothing was written.`, { states: choice.states, hint: `e.g. --to ${choice.states[0]}` });
|
|
1869
|
+
}
|
|
1870
|
+
if (choice.kind === "none") {
|
|
1871
|
+
throw refusal(`${type ?? "This work item type"} has no state in a completed category, so there is nothing `
|
|
1872
|
+
+ "to close it into. Name one with `--to` if the process uses a state this catalogue does "
|
|
1873
|
+
+ "not categorise.");
|
|
1874
|
+
}
|
|
1875
|
+
throw refusal(`${states.note} Closing needs to WRITE a state name, and guessing one would either fail with `
|
|
1876
|
+
+ "a message about a field you did not name or land in a state nobody meant — so name it "
|
|
1877
|
+
+ "with `--to`.", { hint: "`pharos types` lists the states this project's work item types actually have." });
|
|
1878
|
+
}
|
|
1879
|
+
/** GitHub's `state_reason`. Two values matter and they are not interchangeable. */
|
|
1880
|
+
function closingReason(given) {
|
|
1881
|
+
const value = given?.trim().toLowerCase().replace(/-/g, "_");
|
|
1882
|
+
if (value === undefined || value === "")
|
|
1883
|
+
return "completed";
|
|
1884
|
+
if (value === "completed" || value === "not_planned")
|
|
1885
|
+
return value;
|
|
1886
|
+
throw usageError(`--reason must be completed or not-planned; got "${given}". GitHub renders them differently, `
|
|
1887
|
+
+ "and closing somebody's report as \"completed\" when it was declined is a worse lie than "
|
|
1888
|
+
+ "saying nothing.", { given });
|
|
1889
|
+
}
|
|
1890
|
+
/** The reporter's register: what happened, in words written for them. */
|
|
1891
|
+
function closingNote(message, reason) {
|
|
1892
|
+
const opening = reason === "not_planned"
|
|
1893
|
+
? "Closing this as not planned."
|
|
1894
|
+
: "Closing this — the work tracking it is done.";
|
|
1895
|
+
const extra = message?.trim();
|
|
1896
|
+
return extra === undefined || extra === "" ? opening : `${opening}\n\n${extra}`;
|
|
1897
|
+
}
|
|
1898
|
+
function titleOfField(item, name) {
|
|
1899
|
+
const value = item.fields?.[name];
|
|
1900
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
1901
|
+
}
|
|
1902
|
+
// MARK: - edit
|
|
1903
|
+
/**
|
|
1904
|
+
* `pharos issue edit <owner/name#45>` — change the issue's own fields, behind
|
|
1905
|
+
* the lost-update guard GitHub does not offer.
|
|
1906
|
+
*
|
|
1907
|
+
* ## This one verb is not an edge verb, and that needs saying out loud
|
|
1908
|
+
*
|
|
1909
|
+
* Everything else under `pharos issue` exists because `gh` cannot join the two
|
|
1910
|
+
* platforms. **`gh issue edit` exists and is good.** So the rule at the top of
|
|
1911
|
+
* this file — do not rebuild `gh` — is met here not by refusing to build the
|
|
1912
|
+
* verb but by building the one thing `gh` does not: a guard.
|
|
1913
|
+
*
|
|
1914
|
+
* `gh issue edit --title "…"` is last-write-wins. It reads nothing, compares
|
|
1915
|
+
* nothing, and if a colleague renamed the issue while you were composing, their
|
|
1916
|
+
* rename is gone and neither of you is told. That is fine for the interactive
|
|
1917
|
+
* human `gh` is written for, and it is not fine for an agent — which reads an
|
|
1918
|
+
* issue, spends a minute thinking, and writes back into a world that moved.
|
|
1919
|
+
*
|
|
1920
|
+
* So: **if you want an unguarded edit, `gh issue edit` is right there and is
|
|
1921
|
+
* better at it.** What this adds is the refusal.
|
|
1922
|
+
*
|
|
1923
|
+
* ## The guard, and what it honestly is
|
|
1924
|
+
*
|
|
1925
|
+
* There is no conditional write. `PATCH /repos/{o}/{r}/issues/{n}` answers
|
|
1926
|
+
* **400** to `If-Match` — measured (#832), re-measured (#798) — so the Azure
|
|
1927
|
+
* DevOps `{"op":"test","path":"/rev"}` guarantee has no GitHub analogue.
|
|
1928
|
+
*
|
|
1929
|
+
* The guard is therefore a **value compare**, in `gh-core`'s
|
|
1930
|
+
* {@link IssueEditApi.fields}: re-read immediately before sending, compare the
|
|
1931
|
+
* text about to be overwritten with the text the caller believes is there,
|
|
1932
|
+
* refuse only if *that* moved. Never `updated_at` and never the ETag — both move
|
|
1933
|
+
* when somebody merely comments, which would refuse a legitimate edit for a
|
|
1934
|
+
* change that touched nothing.
|
|
1935
|
+
*
|
|
1936
|
+
* **The base defaults to the read this command already makes**, so the guard is
|
|
1937
|
+
* on by default and costs nothing extra: `edit` has to read the issue anyway —
|
|
1938
|
+
* to refuse a pull-request number, to report what changed, and to know the
|
|
1939
|
+
* current values it is not overwriting. That default protects a window of one
|
|
1940
|
+
* round trip, which is honest but small, and the output says so as
|
|
1941
|
+
* `guard.window: "in-command"`.
|
|
1942
|
+
*
|
|
1943
|
+
* `--if-title` / `--if-body` / `--if-body-file` widen it to the base the caller
|
|
1944
|
+
* actually saw, and that is where the guard earns its keep: an agent that read
|
|
1945
|
+
* the issue, thought for a minute and came back can hand over what it read.
|
|
1946
|
+
* The output then says `guard.window: "caller"`.
|
|
1947
|
+
*
|
|
1948
|
+
* Either way it is **not atomic** and nothing client-side can make it so. It
|
|
1949
|
+
* narrows the race to one round trip; it does not close it. The refusal says
|
|
1950
|
+
* that rather than leaving somebody to infer a guarantee that is not there.
|
|
1951
|
+
*
|
|
1952
|
+
* ## Why labels and assignees are three calls and not one PATCH
|
|
1953
|
+
*
|
|
1954
|
+
* `PATCH /issues/{n}` carries `labels` and `assignees` as **whole arrays**, so
|
|
1955
|
+
* routing them through it would make every label write a blind overwrite of
|
|
1956
|
+
* every label — the exact failure the guard above exists to prevent, reproduced
|
|
1957
|
+
* on a field where it would be silent. Their own add/remove sub-resources put
|
|
1958
|
+
* them back inside the append argument: adding a label cannot remove somebody
|
|
1959
|
+
* else's.
|
|
1960
|
+
*
|
|
1961
|
+
* Two things those endpoints do quietly, both measured live on #798 and both
|
|
1962
|
+
* handled by `gh-core` rather than here:
|
|
1963
|
+
*
|
|
1964
|
+
* - `POST …/labels` **creates** a repository label that does not exist. A
|
|
1965
|
+
* typo'd `--add-label` would leave grey litter on a client's repository
|
|
1966
|
+
* forever. Unknown labels are refused, with the near misses named;
|
|
1967
|
+
* `--create-label` is how you mean it.
|
|
1968
|
+
* - `POST …/assignees` **silently ignores** a login it will not assign, and
|
|
1969
|
+
* still answers 201. `ignored` in the output is that difference, and it is the
|
|
1970
|
+
* difference between telling somebody their teammate is assigned and it being
|
|
1971
|
+
* true.
|
|
1972
|
+
*
|
|
1973
|
+
* ## Order
|
|
1974
|
+
*
|
|
1975
|
+
* Fields first, then labels, then assignees — because the fields call is the one
|
|
1976
|
+
* that can **refuse**, and a refusal after the labels had already been rewritten
|
|
1977
|
+
* would leave the issue half-edited with no single command to finish it.
|
|
1978
|
+
*/
|
|
1979
|
+
async function runEdit(io, session, positionals, options) {
|
|
1980
|
+
const ref = await resolveIssue(session, positionals[0], "edit");
|
|
1981
|
+
const coordinate = formatIssueCoordinate({ repo: ref.repo, number: ref.number });
|
|
1982
|
+
const wantsFields = options.title !== undefined || options.message !== undefined || options.milestone !== undefined;
|
|
1983
|
+
const wantsLabels = options.addLabels.length > 0 || options.removeLabels.length > 0;
|
|
1984
|
+
const wantsAssignees = options.addAssignees.length > 0 || options.removeAssignees.length > 0;
|
|
1985
|
+
if (!wantsFields && !wantsLabels && !wantsAssignees) {
|
|
1986
|
+
throw usageError(`Nothing to edit on ${coordinate}. Pass --title, a body (--text/--file/--stdin), `
|
|
1987
|
+
+ "--milestone, --add-label/--remove-label or --add-assignee/--remove-assignee.", { verb: "edit" });
|
|
1988
|
+
}
|
|
1989
|
+
const github = await session.github();
|
|
1990
|
+
// Reads the issue AND refuses a pull-request number, which `gh-core` does at
|
|
1991
|
+
// this boundary rather than in each caller. Doing it first means a PR number
|
|
1992
|
+
// never reaches a write.
|
|
1993
|
+
const before = await github.issues.get(ref);
|
|
1994
|
+
const milestone = milestoneArgument(options.milestone);
|
|
1995
|
+
const guarded = wantsFields;
|
|
1996
|
+
const base = guarded
|
|
1997
|
+
? {
|
|
1998
|
+
// The caller's base where it gave one, this read's where it did not.
|
|
1999
|
+
// Both are real bases; they differ only in how wide a window they cover,
|
|
2000
|
+
// and `guard.window` below is what says which.
|
|
2001
|
+
...(options.title === undefined ? {} : { title: options.ifTitle ?? before.title }),
|
|
2002
|
+
...(options.message === undefined ? {} : { body: options.ifBody ?? before.body ?? "" }),
|
|
2003
|
+
...(milestone === undefined ? {} : { milestone: milestoneNumber(before) }),
|
|
2004
|
+
}
|
|
2005
|
+
: undefined;
|
|
2006
|
+
const plan = {
|
|
2007
|
+
issue: summariseIssue(before, { repo: ref.repo, number: ref.number }),
|
|
2008
|
+
edit: {
|
|
2009
|
+
...(options.title === undefined ? {} : { title: options.title }),
|
|
2010
|
+
...(options.message === undefined ? {} : { body: options.message }),
|
|
2011
|
+
...(milestone === undefined ? {} : { milestone }),
|
|
2012
|
+
...(options.addLabels.length === 0 ? {} : { addLabels: options.addLabels }),
|
|
2013
|
+
...(options.removeLabels.length === 0 ? {} : { removeLabels: options.removeLabels }),
|
|
2014
|
+
...(options.addAssignees.length === 0 ? {} : { addAssignees: options.addAssignees }),
|
|
2015
|
+
...(options.removeAssignees.length === 0 ? {} : { removeAssignees: options.removeAssignees }),
|
|
2016
|
+
},
|
|
2017
|
+
};
|
|
2018
|
+
const gate = approve(io, session, {
|
|
2019
|
+
what: `edit ${coordinate}`,
|
|
2020
|
+
// GitHub only. Demanding an Azure DevOps PAT for a verb that never touches
|
|
2021
|
+
// Azure DevOps is #839, and it has a test.
|
|
2022
|
+
platform: "github",
|
|
2023
|
+
would: { ...plan, wouldWrite: writeList(wantsFields, wantsLabels, wantsAssignees) },
|
|
2024
|
+
});
|
|
2025
|
+
if (gate !== undefined)
|
|
2026
|
+
return gate;
|
|
2027
|
+
const result = { issue: plan.issue, edit: plan.edit };
|
|
2028
|
+
let latest = before;
|
|
2029
|
+
if (wantsFields) {
|
|
2030
|
+
const edit = await github.edits.fields(ref, {
|
|
2031
|
+
...(options.title === undefined ? {} : { title: options.title }),
|
|
2032
|
+
...(options.message === undefined ? {} : { body: options.message }),
|
|
2033
|
+
...(milestone === undefined ? {} : { milestone }),
|
|
2034
|
+
...(base === undefined ? {} : { base }),
|
|
2035
|
+
});
|
|
2036
|
+
latest = edit.issue;
|
|
2037
|
+
result["fields"] = {
|
|
2038
|
+
changed: edit.changed,
|
|
2039
|
+
converged: edit.guard.converged,
|
|
2040
|
+
guard: {
|
|
2041
|
+
...edit.guard,
|
|
2042
|
+
window: usedCallerBase(options) ? "caller" : "in-command",
|
|
2043
|
+
note: "GitHub offers no conditional write on this endpoint — If-Match is refused with a 400 "
|
|
2044
|
+
+ "— so this is a re-read compared on the VALUE. It narrows the race to one round trip "
|
|
2045
|
+
+ "and does not close it.",
|
|
2046
|
+
},
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
if (wantsLabels) {
|
|
2050
|
+
const labels = await github.edits.labels(ref, {
|
|
2051
|
+
add: options.addLabels,
|
|
2052
|
+
remove: options.removeLabels,
|
|
2053
|
+
createMissing: options.createLabel,
|
|
2054
|
+
});
|
|
2055
|
+
result["labels"] = {
|
|
2056
|
+
now: labels.labels.map((label) => label.name),
|
|
2057
|
+
added: labels.added,
|
|
2058
|
+
removed: labels.removed,
|
|
2059
|
+
alreadyAbsent: labels.alreadyAbsent,
|
|
2060
|
+
created: labels.created,
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
if (wantsAssignees) {
|
|
2064
|
+
const assignees = await github.edits.assignees(ref, {
|
|
2065
|
+
add: options.addAssignees,
|
|
2066
|
+
remove: options.removeAssignees,
|
|
2067
|
+
});
|
|
2068
|
+
result["assignees"] = {
|
|
2069
|
+
now: assignees.assignees,
|
|
2070
|
+
added: assignees.added,
|
|
2071
|
+
removed: assignees.removed,
|
|
2072
|
+
// Named rather than buried: GitHub answered 201 for these and did not
|
|
2073
|
+
// assign them. A caller that only checks the status code reports a lie.
|
|
2074
|
+
ignored: assignees.ignored,
|
|
2075
|
+
notRemoved: assignees.notRemoved,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
result["edited"] = summariseIssue(latest, { repo: ref.repo, number: ref.number });
|
|
2079
|
+
return report(io, session, result);
|
|
2080
|
+
}
|
|
2081
|
+
/** `--milestone 4`, or `--milestone none` to clear it. */
|
|
2082
|
+
function milestoneArgument(given) {
|
|
2083
|
+
if (given === undefined)
|
|
2084
|
+
return undefined;
|
|
2085
|
+
const text = given.trim().toLowerCase();
|
|
2086
|
+
// `none` rather than an empty string, which a shell eats, and rather than `0`,
|
|
2087
|
+
// which is a plausible milestone number somebody could mean literally.
|
|
2088
|
+
if (text === "none" || text === "null")
|
|
2089
|
+
return null;
|
|
2090
|
+
if (!/^\d+$/.test(text)) {
|
|
2091
|
+
throw usageError(`"${given}" is not a milestone. Pass its NUMBER — the one in the milestone's URL — or `
|
|
2092
|
+
+ "`none` to clear it. GitHub answers 422 for a number that does not exist.", { given });
|
|
2093
|
+
}
|
|
2094
|
+
return Number(text);
|
|
2095
|
+
}
|
|
2096
|
+
function usedCallerBase(options) {
|
|
2097
|
+
return options.ifTitle !== undefined || options.ifBody !== undefined;
|
|
2098
|
+
}
|
|
2099
|
+
function writeList(fields, labels, assignees) {
|
|
2100
|
+
const out = [];
|
|
2101
|
+
if (fields)
|
|
2102
|
+
out.push("fields");
|
|
2103
|
+
if (labels)
|
|
2104
|
+
out.push("labels");
|
|
2105
|
+
if (assignees)
|
|
2106
|
+
out.push("assignees");
|
|
2107
|
+
return out;
|
|
2108
|
+
}
|
|
2109
|
+
// MARK: - shared
|
|
2110
|
+
/**
|
|
2111
|
+
* `owner/name#45` → everything needed to call GitHub about it.
|
|
2112
|
+
*
|
|
2113
|
+
* The account comes from `repos.json` and there is **no fallback to whichever
|
|
2114
|
+
* `gh` account is active** (plan §2). On the machine this was designed on the
|
|
2115
|
+
* active account was not the one owning the repository behind the project being
|
|
2116
|
+
* worked, and two accounts can each see a repository of the same name — so
|
|
2117
|
+
* guessing does not fail, it returns 200 for the wrong repository.
|
|
2118
|
+
*/
|
|
2119
|
+
async function resolveIssue(session, text, verb) {
|
|
2120
|
+
if (text === undefined || text === "") {
|
|
2121
|
+
throw usageError(`Which issue? \`pharos issue ${verb} owner/name#45\`.`);
|
|
2122
|
+
}
|
|
2123
|
+
const coordinate = assertIssueCoordinate(text);
|
|
2124
|
+
const github = await session.github();
|
|
2125
|
+
// Throws a `GitHubConfigError` naming the repo and the file when it is unbound
|
|
2126
|
+
// — reported as `kind: "config"`, exit 2, because no retry can fix it.
|
|
2127
|
+
const binding = github.binding(coordinate.repo);
|
|
2128
|
+
return { ...binding, number: coordinate.number };
|
|
2129
|
+
}
|
|
2130
|
+
/**
|
|
2131
|
+
* Post the Pharos comment: the sentence, the `AB#` mention and the trailer, all
|
|
2132
|
+
* three composed by `gh-core` so this and the macOS app produce the same bytes.
|
|
2133
|
+
*
|
|
2134
|
+
* `createdWorkItem` is passed only by `adopt`, and only because by that point
|
|
2135
|
+
* something exists that the error must name.
|
|
2136
|
+
*/
|
|
2137
|
+
async function postLinkComment(session, ref, options) {
|
|
2138
|
+
const github = await session.github();
|
|
2139
|
+
const body = composeLinkComment({
|
|
2140
|
+
repo: ref.repo,
|
|
2141
|
+
issue: ref.number,
|
|
2142
|
+
adoId: options.workItemId,
|
|
2143
|
+
workItemUrl: options.workItemUrl,
|
|
2144
|
+
workItemTitle: options.workItemTitle,
|
|
2145
|
+
});
|
|
2146
|
+
try {
|
|
2147
|
+
return await github.issues.comment(ref, body);
|
|
2148
|
+
}
|
|
2149
|
+
catch (error) {
|
|
2150
|
+
const { kind, code, body: reported } = failureOf(error);
|
|
2151
|
+
const coordinate = formatIssueCoordinate({ repo: ref.repo, number: ref.number });
|
|
2152
|
+
const recover = `pharos issue link ${coordinate} ${options.workItemId}`;
|
|
2153
|
+
const created = options.createdWorkItem;
|
|
2154
|
+
throw new CliError((created === undefined
|
|
2155
|
+
? `The Azure DevOps end of the link to ${coordinate} is written and the GitHub comment is `
|
|
2156
|
+
+ "not"
|
|
2157
|
+
: `Work item #${created} was CREATED, and the GitHub comment naming it was not posted`)
|
|
2158
|
+
+ `: ${String(reported["message"] ?? "")}`, kind, code, {
|
|
2159
|
+
// Which side said no, kept at the top level rather than only inside
|
|
2160
|
+
// `cause`: on a two-platform verb that is the first thing a caller needs,
|
|
2161
|
+
// and the wrapping must not be where it gets lost.
|
|
2162
|
+
platform: "github",
|
|
2163
|
+
issue: { repo: ref.repo, number: ref.number },
|
|
2164
|
+
workItemId: options.workItemId,
|
|
2165
|
+
...(created === undefined ? {} : { createdWorkItem: created }),
|
|
2166
|
+
wrote: ["hyperlink"],
|
|
2167
|
+
halfLinked: true,
|
|
2168
|
+
recover,
|
|
2169
|
+
hint: `Nothing needs undoing — re-run \`${recover}\` to write the missing half.`,
|
|
2170
|
+
cause: reported,
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* The `Hyperlink` relation urls on a work item.
|
|
2176
|
+
*
|
|
2177
|
+
* Filtered by `rel` rather than handed the whole array, because layer 1 of the
|
|
2178
|
+
* link IS a `Hyperlink` relation — a github.com issue URL arriving as some other
|
|
2179
|
+
* relation kind would be a different fact, and reporting it as `evidence:
|
|
2180
|
+
* ["hyperlink"]` would make the word mean two things.
|
|
2181
|
+
*/
|
|
2182
|
+
function hyperlinkUrls(item) {
|
|
2183
|
+
const relations = item.relations ?? [];
|
|
2184
|
+
return relations
|
|
2185
|
+
.filter((relation) => relation.rel === "Hyperlink")
|
|
2186
|
+
.map((relation) => relation.url)
|
|
2187
|
+
.filter((url) => typeof url === "string" && url !== "");
|
|
2188
|
+
}
|
|
2189
|
+
function summariseWorkItem(session, item) {
|
|
2190
|
+
const fields = item.fields ?? {};
|
|
2191
|
+
const text = (name) => {
|
|
2192
|
+
const value = fields[name];
|
|
2193
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
2194
|
+
};
|
|
2195
|
+
return {
|
|
2196
|
+
id: item.id,
|
|
2197
|
+
type: text("System.WorkItemType"),
|
|
2198
|
+
title: text("System.Title"),
|
|
2199
|
+
state: text("System.State"),
|
|
2200
|
+
assignedTo: displayName(fields["System.AssignedTo"]),
|
|
2201
|
+
url: session.workItemUrl(item),
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
function summariseIssue(issue, coordinate) {
|
|
2205
|
+
return {
|
|
2206
|
+
repo: issue.repo,
|
|
2207
|
+
number: issue.number,
|
|
2208
|
+
title: issue.title,
|
|
2209
|
+
state: issue.state,
|
|
2210
|
+
stateReason: issue.stateReason ?? null,
|
|
2211
|
+
author: issue.author ?? null,
|
|
2212
|
+
updatedAt: issue.updatedAt,
|
|
2213
|
+
comments: issue.commentCount,
|
|
2214
|
+
url: issue.htmlUrl === "" ? issueUrl(coordinate) : issue.htmlUrl,
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
/** `null` rather than absent: unassigned is an answer, and a common one. */
|
|
2218
|
+
function displayName(value) {
|
|
2219
|
+
if (typeof value === "string" && value !== "")
|
|
2220
|
+
return value;
|
|
2221
|
+
if (typeof value === "object" && value !== null) {
|
|
2222
|
+
const display = value.displayName;
|
|
2223
|
+
if (typeof display === "string")
|
|
2224
|
+
return display;
|
|
2225
|
+
}
|
|
2226
|
+
return null;
|
|
2227
|
+
}
|
|
2228
|
+
function titleOf(item) {
|
|
2229
|
+
const title = item.fields?.["System.Title"];
|
|
2230
|
+
return typeof title === "string" ? title : null;
|
|
2231
|
+
}
|
|
2232
|
+
function workItemArgument(raw, usage) {
|
|
2233
|
+
if (raw === undefined || raw === "")
|
|
2234
|
+
throw usageError(usage);
|
|
2235
|
+
if (!/^\d+$/.test(raw))
|
|
2236
|
+
throw usageError(`"${raw}" is not a work item id.`, { given: raw });
|
|
2237
|
+
return Number(raw);
|
|
2238
|
+
}
|
|
2239
|
+
function report(io, session, result) {
|
|
2240
|
+
if (!session.pretty)
|
|
2241
|
+
return emit(io, result);
|
|
2242
|
+
return emitText(io, render(result));
|
|
2243
|
+
}
|
|
2244
|
+
function render(result) {
|
|
2245
|
+
// Checked before `message`, because both of these carry one as a summary line
|
|
2246
|
+
// and the rows underneath it are the answer rather than decoration.
|
|
2247
|
+
if (result["drift"] !== undefined)
|
|
2248
|
+
return renderDrift(result);
|
|
2249
|
+
if (result["adopted"] !== undefined)
|
|
2250
|
+
return renderBackfill(result);
|
|
2251
|
+
if (result["pairs"] !== undefined)
|
|
2252
|
+
return renderClose(result);
|
|
2253
|
+
if (result["edited"] !== undefined)
|
|
2254
|
+
return renderEdit(result);
|
|
2255
|
+
const message = result["message"];
|
|
2256
|
+
if (typeof message === "string")
|
|
2257
|
+
return message;
|
|
2258
|
+
if (result["links"] !== undefined)
|
|
2259
|
+
return renderTrail(result);
|
|
2260
|
+
if (result["github"] !== undefined)
|
|
2261
|
+
return renderSay(result);
|
|
2262
|
+
const issue = result["issue"];
|
|
2263
|
+
const workItem = result["workItem"];
|
|
2264
|
+
const coordinate = `${String(issue?.["repo"] ?? "")}#${String(issue?.["number"] ?? "")}`;
|
|
2265
|
+
const wrote = (result["wrote"] ?? result["wouldWrite"]);
|
|
2266
|
+
const written = wrote === undefined || wrote.length === 0 ? "nothing" : wrote.join(" + ");
|
|
2267
|
+
// A guard that could not run is the one thing on this path a person has to be
|
|
2268
|
+
// told, and `--pretty` is the mode where nobody is reading the JSON that says
|
|
2269
|
+
// so. Silence would read as "checked, and nothing was already tracking it".
|
|
2270
|
+
const check = result["boardCheck"];
|
|
2271
|
+
const unchecked = check?.["ran"] === false
|
|
2272
|
+
? "\n! the board was NOT checked for a work item that already links this issue — "
|
|
2273
|
+
+ String(check["problem"] ?? "")
|
|
2274
|
+
: "";
|
|
2275
|
+
if (result["dryRun"] === true) {
|
|
2276
|
+
const id = workItem?.["id"];
|
|
2277
|
+
const naming = id === undefined ? `a new ${String(workItem?.["type"] ?? "")}` : `#${String(id)}`;
|
|
2278
|
+
return `would link ${coordinate} to ${naming} (${written})${unchecked}`;
|
|
2279
|
+
}
|
|
2280
|
+
return `${coordinate} ↔ #${String(workItem?.["id"] ?? "")} — wrote ${written}${unchecked}`;
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* What actually changed, and — the two lines nobody must miss — what GitHub
|
|
2284
|
+
* quietly did not do.
|
|
2285
|
+
*
|
|
2286
|
+
* `ignored` and `created` are printed as warnings rather than folded into the
|
|
2287
|
+
* summary, because both are cases where the call **succeeded** and the outcome
|
|
2288
|
+
* is not what was asked for. `--pretty` is the mode where nobody is reading the
|
|
2289
|
+
* JSON that says so, and silence there reads as "all of it worked".
|
|
2290
|
+
*/
|
|
2291
|
+
function renderEdit(result) {
|
|
2292
|
+
const edited = result["edited"];
|
|
2293
|
+
const coordinate = `${String(edited?.["repo"] ?? "")}#${String(edited?.["number"] ?? "")}`;
|
|
2294
|
+
const lines = [];
|
|
2295
|
+
const fields = result["fields"];
|
|
2296
|
+
const changed = fields?.["changed"] ?? [];
|
|
2297
|
+
const converged = fields?.["converged"] ?? [];
|
|
2298
|
+
if (fields !== undefined) {
|
|
2299
|
+
lines.push(changed.length === 0
|
|
2300
|
+
? `${coordinate} — no field written (GitHub already held what was asked for)`
|
|
2301
|
+
: `${coordinate} — wrote ${changed.join(" + ")}`);
|
|
2302
|
+
if (converged.length > 0) {
|
|
2303
|
+
lines.push(` ${converged.join(", ")} already matched — not sent`);
|
|
2304
|
+
}
|
|
2305
|
+
const guard = fields["guard"];
|
|
2306
|
+
if (guard?.["window"] === "in-command") {
|
|
2307
|
+
lines.push(" guarded against this command's own read only — pass --if-title/--if-body to guard "
|
|
2308
|
+
+ "against what you actually saw");
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
const labels = result["labels"];
|
|
2312
|
+
if (labels !== undefined) {
|
|
2313
|
+
lines.push(` labels: ${(labels["now"] ?? []).join(", ") || "none"}`);
|
|
2314
|
+
const created = labels["created"] ?? [];
|
|
2315
|
+
if (created.length > 0) {
|
|
2316
|
+
lines.push(`! CREATED on the repository, not just on the issue: ${created.join(", ")}`);
|
|
2317
|
+
}
|
|
2318
|
+
const absent = labels["alreadyAbsent"] ?? [];
|
|
2319
|
+
if (absent.length > 0)
|
|
2320
|
+
lines.push(` not on the issue anyway: ${absent.join(", ")}`);
|
|
2321
|
+
}
|
|
2322
|
+
const assignees = result["assignees"];
|
|
2323
|
+
if (assignees !== undefined) {
|
|
2324
|
+
const now = assignees["now"] ?? [];
|
|
2325
|
+
lines.push(` assignees: ${now.join(", ") || "none"}`);
|
|
2326
|
+
const ignored = assignees["ignored"] ?? [];
|
|
2327
|
+
if (ignored.length > 0) {
|
|
2328
|
+
// The measured trap: 201, and these were never assigned.
|
|
2329
|
+
lines.push(`! GitHub accepted the request and did NOT assign: ${ignored.join(", ")} — they need `
|
|
2330
|
+
+ "push access to the repository");
|
|
2331
|
+
}
|
|
2332
|
+
const notRemoved = assignees["notRemoved"] ?? [];
|
|
2333
|
+
if (notRemoved.length > 0)
|
|
2334
|
+
lines.push(`! still assigned after removal: ${notRemoved.join(", ")}`);
|
|
2335
|
+
}
|
|
2336
|
+
return lines.join("\n");
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Two lines for two audiences, because one line would flatten the only thing
|
|
2340
|
+
* about this verb worth seeing at a glance.
|
|
2341
|
+
*/
|
|
2342
|
+
function renderSay(result) {
|
|
2343
|
+
const issue = result["issue"];
|
|
2344
|
+
const workItem = result["workItem"];
|
|
2345
|
+
const github = result["github"];
|
|
2346
|
+
const summary = result["summary"];
|
|
2347
|
+
const coordinate = `${String(issue?.["repo"] ?? "")}#${String(issue?.["number"] ?? "")}`;
|
|
2348
|
+
const id = `#${String(workItem?.["id"] ?? "")}`;
|
|
2349
|
+
if (result["dryRun"] === true) {
|
|
2350
|
+
return `would tell ${coordinate} in full and summarise it on ${id} — nothing written`;
|
|
2351
|
+
}
|
|
2352
|
+
return [
|
|
2353
|
+
`told ${coordinate} — ${String(github?.["commentUrl"] ?? "")}`,
|
|
2354
|
+
`summarised on ${id} — ${String(summary?.["text"] ?? "")}`,
|
|
2355
|
+
].join("\n");
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Drift, one row per finding, and a headline that says what was *not* wrong.
|
|
2359
|
+
*
|
|
2360
|
+
* The healthy count leads because it is what makes the findings readable: "3
|
|
2361
|
+
* problems" is alarming out of nowhere and unremarkable out of two hundred
|
|
2362
|
+
* links. `truncated` is on the same line for the same reason — "the board is
|
|
2363
|
+
* clean" and "the first twenty were clean" must not print identically.
|
|
2364
|
+
*/
|
|
2365
|
+
function renderDrift(result) {
|
|
2366
|
+
const rows = (result["drift"] ?? []);
|
|
2367
|
+
const scanned = (result["scanned"] ?? {});
|
|
2368
|
+
const examined = Number(scanned["examined"] ?? 0);
|
|
2369
|
+
const healthy = Number(result["healthy"] ?? 0);
|
|
2370
|
+
const cut = scanned["truncated"] === true ? ` of ${String(scanned["links"])} — TRUNCATED by --limit` : "";
|
|
2371
|
+
const head = rows.length === 0
|
|
2372
|
+
? `${examined} link${examined === 1 ? "" : "s"}${cut}, none drifting`
|
|
2373
|
+
: `${rows.length} of ${examined} link${examined === 1 ? "" : "s"}${cut} drifting `
|
|
2374
|
+
+ `(${healthy} healthy)`;
|
|
2375
|
+
const states = (result["finishedStates"] ?? {});
|
|
2376
|
+
const basis = states["source"] === "fallback"
|
|
2377
|
+
? "\n! finished states were GUESSED — this project's catalogue could not be read, so a state "
|
|
2378
|
+
+ "row here may be wrong"
|
|
2379
|
+
: "";
|
|
2380
|
+
if (rows.length === 0)
|
|
2381
|
+
return head + basis;
|
|
2382
|
+
return [
|
|
2383
|
+
head + basis,
|
|
2384
|
+
...rows.map((row) => {
|
|
2385
|
+
const workItem = (row["workItem"] ?? {});
|
|
2386
|
+
const kind = String(row["kind"]);
|
|
2387
|
+
const where = `${String(row["coordinate"])} ↔ #${String(workItem["id"])}`;
|
|
2388
|
+
return ` ${kind.padEnd(13)} ${where}\n ${String(row["detail"])}`
|
|
2389
|
+
+ (row["recover"] === undefined ? "" : `\n fix: ${String(row["recover"])}`);
|
|
2390
|
+
}),
|
|
2391
|
+
].join("\n");
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* What closed, and — the part that matters — what did not.
|
|
2395
|
+
*
|
|
2396
|
+
* The verified state is printed rather than the state that was sent, because the
|
|
2397
|
+
* whole reason this verb re-reads is that those two can differ.
|
|
2398
|
+
*/
|
|
2399
|
+
function renderClose(result) {
|
|
2400
|
+
const pairs = (result["pairs"] ?? []);
|
|
2401
|
+
return pairs
|
|
2402
|
+
.map((pair) => {
|
|
2403
|
+
const verified = (pair["verified"] ?? {});
|
|
2404
|
+
const workItem = (verified["workItem"] ?? {});
|
|
2405
|
+
const issue = (verified["issue"] ?? {});
|
|
2406
|
+
const where = `${String(pair["issue"])} ↔ #${String(pair["workItem"])}`;
|
|
2407
|
+
if (pair["dryRun"] === true) {
|
|
2408
|
+
const would = (pair["wouldWrite"] ?? []);
|
|
2409
|
+
return would.length === 0
|
|
2410
|
+
? `${where} — already closed on both`
|
|
2411
|
+
: `would close ${where} (${would.join(" + ")})`;
|
|
2412
|
+
}
|
|
2413
|
+
if (pair["closed"] === true) {
|
|
2414
|
+
const wrote = (pair["wrote"] ?? []);
|
|
2415
|
+
return wrote.length === 0
|
|
2416
|
+
? `${where} — already closed on both`
|
|
2417
|
+
: `closed ${where} — ${String(workItem["state"] ?? "")} / ${String(issue["state"] ?? "")}`;
|
|
2418
|
+
}
|
|
2419
|
+
return (`DID NOT CLOSE ${where} — ${(pair["didNotClose"] ?? []).join(" and ")}`
|
|
2420
|
+
+ ` still open (${String(workItem["state"] ?? "")} / ${String(issue["state"] ?? "")})`);
|
|
2421
|
+
})
|
|
2422
|
+
.join("\n");
|
|
2423
|
+
}
|
|
2424
|
+
/** What a backfill did, or would do. One line per issue is too many at 400. */
|
|
2425
|
+
function renderBackfill(result) {
|
|
2426
|
+
const counts = (result["counts"] ?? {});
|
|
2427
|
+
const stopped = result["stopped"];
|
|
2428
|
+
const lines = [
|
|
2429
|
+
`adopted ${String(counts["adopted"] ?? 0)} from ${String(result["repo"] ?? "")}`
|
|
2430
|
+
+ ` — ${String(counts["skipped"] ?? 0)} already linked, ${String(counts["failed"] ?? 0)} failed`,
|
|
2431
|
+
];
|
|
2432
|
+
if (stopped !== null && stopped !== undefined) {
|
|
2433
|
+
lines.push(`STOPPED after ${String(stopped["after"])}: ${String(stopped["reason"])}`);
|
|
2434
|
+
}
|
|
2435
|
+
if (result["resume"] !== undefined) {
|
|
2436
|
+
lines.push(`${String(counts["remaining"] ?? 0)} left — ${String(result["resume"])}`);
|
|
2437
|
+
}
|
|
2438
|
+
return lines.join("\n");
|
|
2439
|
+
}
|
|
2440
|
+
function renderTrail(result) {
|
|
2441
|
+
const links = (result["links"] ?? []);
|
|
2442
|
+
if (links.length === 0) {
|
|
2443
|
+
const anchor = (result["workItem"] ?? result["issue"]);
|
|
2444
|
+
const name = anchor?.["id"] === undefined
|
|
2445
|
+
? `${String(anchor?.["repo"] ?? "")}#${String(anchor?.["number"] ?? "")}`
|
|
2446
|
+
: `#${String(anchor["id"])}`;
|
|
2447
|
+
return `${name} — no counterpart on the other platform`;
|
|
2448
|
+
}
|
|
2449
|
+
return links
|
|
2450
|
+
.map((link) => {
|
|
2451
|
+
const issue = link["issue"];
|
|
2452
|
+
const workItem = link["workItem"];
|
|
2453
|
+
const left = issue === null
|
|
2454
|
+
? "(unreadable)"
|
|
2455
|
+
: `${String(issue["repo"])}#${String(issue["number"])} ${String(issue["state"])}`;
|
|
2456
|
+
const right = workItem === null
|
|
2457
|
+
? "(unreadable)"
|
|
2458
|
+
: `#${String(workItem["id"])} ${String(workItem["state"] ?? "")}`.trim();
|
|
2459
|
+
const evidence = (link["evidence"] ?? []).join("+");
|
|
2460
|
+
const problem = link["problem"];
|
|
2461
|
+
const tail = typeof problem === "string" ? ` — ${problem}` : "";
|
|
2462
|
+
return `${left} ↔ ${right} [${evidence}]${tail}`;
|
|
2463
|
+
})
|
|
2464
|
+
.join("\n");
|
|
2465
|
+
}
|
|
2466
|
+
//# sourceMappingURL=issue.js.map
|