@floh-solutions/pharos-cli 0.23.0 → 0.24.0

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