@floh-solutions/pharos-cli 0.24.0 → 0.26.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.
Files changed (53) hide show
  1. package/README.md +97 -10
  2. package/dist/adopt-type.d.ts +58 -0
  3. package/dist/adopt-type.d.ts.map +1 -0
  4. package/dist/adopt-type.js +61 -0
  5. package/dist/adopt-type.js.map +1 -0
  6. package/dist/bridge.d.ts +156 -0
  7. package/dist/bridge.d.ts.map +1 -0
  8. package/dist/bridge.js +157 -0
  9. package/dist/bridge.js.map +1 -0
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +176 -31
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/bridge.d.ts +30 -0
  15. package/dist/commands/bridge.d.ts.map +1 -0
  16. package/dist/commands/bridge.js +255 -0
  17. package/dist/commands/bridge.js.map +1 -0
  18. package/dist/commands/comment-github.d.ts +138 -0
  19. package/dist/commands/comment-github.d.ts.map +1 -0
  20. package/dist/commands/comment-github.js +427 -0
  21. package/dist/commands/comment-github.js.map +1 -0
  22. package/dist/commands/comment.d.ts.map +1 -1
  23. package/dist/commands/comment.js +114 -19
  24. package/dist/commands/comment.js.map +1 -1
  25. package/dist/commands/doctor.d.ts +169 -0
  26. package/dist/commands/doctor.d.ts.map +1 -1
  27. package/dist/commands/doctor.js +241 -44
  28. package/dist/commands/doctor.js.map +1 -1
  29. package/dist/commands/issue.d.ts +133 -0
  30. package/dist/commands/issue.d.ts.map +1 -1
  31. package/dist/commands/issue.js +292 -110
  32. package/dist/commands/issue.js.map +1 -1
  33. package/dist/commands/setup.d.ts +45 -0
  34. package/dist/commands/setup.d.ts.map +1 -1
  35. package/dist/commands/setup.js +74 -0
  36. package/dist/commands/setup.js.map +1 -1
  37. package/dist/half-link.d.ts +14 -4
  38. package/dist/half-link.d.ts.map +1 -1
  39. package/dist/half-link.js +14 -4
  40. package/dist/half-link.js.map +1 -1
  41. package/dist/output.d.ts.map +1 -1
  42. package/dist/output.js +123 -1
  43. package/dist/output.js.map +1 -1
  44. package/dist/session.d.ts +51 -2
  45. package/dist/session.d.ts.map +1 -1
  46. package/dist/session.js +62 -11
  47. package/dist/session.js.map +1 -1
  48. package/dist/target.d.ts +40 -4
  49. package/dist/target.d.ts.map +1 -1
  50. package/dist/target.js +14 -3
  51. package/dist/target.js.map +1 -1
  52. package/package.json +4 -4
  53. package/skill/SKILL.md +167 -7
@@ -1,108 +1,10 @@
1
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";
2
+ import { GitHubNotFoundError, PullRequestNotAnIssueError, assertIssueCoordinate, composeLinkComment, formatIssueCoordinate, isPharosAuthored, isTwoSided, issueUrl, linksFromComments, linksFromHyperlinks, mergeLinks, milestoneNumber, normalizeRepo, parseRepo, } from "@floh-solutions/gh-core";
3
3
  import { DEFAULT_WRITE_BUDGET } from "../budget.js";
4
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
5
  import { CliError, emit, emitText, failureOf, refusal, usageError, } from "../output.js";
6
+ import { FALLBACK_WORK_ITEM_TYPE, resolveWorkItemType } from "../adopt-type.js";
6
7
  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
8
  export async function runIssue(io, session, positionals, options) {
107
9
  const verb = positionals[0];
108
10
  switch (verb) {
@@ -120,16 +22,28 @@ export async function runIssue(io, session, positionals, options) {
120
22
  return runBackfill(io, session, positionals.slice(1), options);
121
23
  case "close":
122
24
  return runClose(io, session, positionals.slice(1), options);
25
+ case "edit":
26
+ return runEdit(io, session, positionals.slice(1), options);
123
27
  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 });
28
+ throw usageError(`Which issue verb? ${ISSUE_VERBS.join(", ")}. Reading an issue is \`gh\`.`, { verbs: ISSUE_VERBS });
126
29
  default:
127
30
  throw usageError(`Unknown issue verb "${verb}". This owns the EDGE — ${ISSUE_VERBS.join(", ")} — and `
128
31
  + "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 });
32
+ + "one END is `gh issue close`; closing the PAIR is `pharos issue close`. Editing one "
33
+ + "unguarded is `gh issue edit`; editing it behind a lost-update guard is "
34
+ + "`pharos issue edit`.", { verbs: ISSUE_VERBS });
130
35
  }
131
36
  }
132
- const ISSUE_VERBS = ["adopt", "link", "say", "trail", "drift", "backfill", "close"];
37
+ const ISSUE_VERBS = [
38
+ "adopt",
39
+ "link",
40
+ "say",
41
+ "trail",
42
+ "drift",
43
+ "backfill",
44
+ "close",
45
+ "edit",
46
+ ];
133
47
  // MARK: - adopt
134
48
  /**
135
49
  * `pharos issue adopt <repo#n>` — the issue becomes a work item, and both ends
@@ -157,7 +71,10 @@ async function runAdopt(io, session, positionals, options) {
157
71
  // when it finds one; a second work item is the damage this whole verb has to
158
72
  // avoid.
159
73
  const boardCheck = await refuseIfAlreadyTracked(session, coordinate);
160
- const type = options.type ?? DEFAULT_WORK_ITEM_TYPE;
74
+ // **RESOLVED, NOT NAMED** (#872). `"Issue"` was hardcoded here and is right
75
+ // on Basic by accident — on Agile it means an impediment. See `adopt-type.ts`.
76
+ const chosen = await resolveWorkItemType(session, issue, { type: options.type });
77
+ const type = chosen.type;
161
78
  const title = options.title ?? issue.title;
162
79
  const url = issueUrl(coordinate);
163
80
  const parent = options.parent === undefined ? undefined : await resolveParent(session, options.parent);
@@ -208,10 +125,10 @@ async function runAdopt(io, session, positionals, options) {
208
125
  * **The duplicate guard.** Refuses when a work item already tracks this issue
209
126
  * without the issue saying so, and otherwise reports what the check cost.
210
127
  *
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.
128
+ * Two sources, cheapest first see this file's header *What "already linked"
129
+ * means*. The board is the stronger one and it is what catches a retry from
130
+ * another machine; the journal is what is left when the board query cannot run,
131
+ * and it answers without a round trip.
215
132
  */
216
133
  async function refuseIfAlreadyTracked(session, coordinate) {
217
134
  const key = halfLinkKey(session, coordinate);
@@ -1441,7 +1358,7 @@ async function runBackfill(io, session, positionals, options) {
1441
1358
  });
1442
1359
  }
1443
1360
  const parent = options.parent === undefined ? undefined : await resolveParent(session, options.parent);
1444
- const type = options.type ?? DEFAULT_WORK_ITEM_TYPE;
1361
+ const type = options.type ?? FALLBACK_WORK_ITEM_TYPE;
1445
1362
  const adopted = [];
1446
1363
  const skipped = [];
1447
1364
  const failed = [];
@@ -1886,6 +1803,213 @@ function titleOfField(item, name) {
1886
1803
  const value = item.fields?.[name];
1887
1804
  return typeof value === "string" && value !== "" ? value : null;
1888
1805
  }
1806
+ // MARK: - edit
1807
+ /**
1808
+ * `pharos issue edit <owner/name#45>` — change the issue's own fields, behind
1809
+ * the lost-update guard GitHub does not offer.
1810
+ *
1811
+ * ## This one verb is not an edge verb, and that needs saying out loud
1812
+ *
1813
+ * Everything else under `pharos issue` exists because `gh` cannot join the two
1814
+ * platforms. **`gh issue edit` exists and is good.** So the rule at the top of
1815
+ * this file — do not rebuild `gh` — is met here not by refusing to build the
1816
+ * verb but by building the one thing `gh` does not: a guard.
1817
+ *
1818
+ * `gh issue edit --title "…"` is last-write-wins. It reads nothing, compares
1819
+ * nothing, and if a colleague renamed the issue while you were composing, their
1820
+ * rename is gone and neither of you is told. That is fine for the interactive
1821
+ * human `gh` is written for, and it is not fine for an agent — which reads an
1822
+ * issue, spends a minute thinking, and writes back into a world that moved.
1823
+ *
1824
+ * So: **if you want an unguarded edit, `gh issue edit` is right there and is
1825
+ * better at it.** What this adds is the refusal.
1826
+ *
1827
+ * ## The guard, and what it honestly is
1828
+ *
1829
+ * There is no conditional write. `PATCH /repos/{o}/{r}/issues/{n}` answers
1830
+ * **400** to `If-Match` — measured (#832), re-measured (#798) — so the Azure
1831
+ * DevOps `{"op":"test","path":"/rev"}` guarantee has no GitHub analogue.
1832
+ *
1833
+ * The guard is therefore a **value compare**, in `gh-core`'s
1834
+ * {@link IssueEditApi.fields}: re-read immediately before sending, compare the
1835
+ * text about to be overwritten with the text the caller believes is there,
1836
+ * refuse only if *that* moved. Never `updated_at` and never the ETag — both move
1837
+ * when somebody merely comments, which would refuse a legitimate edit for a
1838
+ * change that touched nothing.
1839
+ *
1840
+ * **The base defaults to the read this command already makes**, so the guard is
1841
+ * on by default and costs nothing extra: `edit` has to read the issue anyway —
1842
+ * to refuse a pull-request number, to report what changed, and to know the
1843
+ * current values it is not overwriting. That default protects a window of one
1844
+ * round trip, which is honest but small, and the output says so as
1845
+ * `guard.window: "in-command"`.
1846
+ *
1847
+ * `--if-title` / `--if-body` / `--if-body-file` widen it to the base the caller
1848
+ * actually saw, and that is where the guard earns its keep: an agent that read
1849
+ * the issue, thought for a minute and came back can hand over what it read.
1850
+ * The output then says `guard.window: "caller"`.
1851
+ *
1852
+ * Either way it is **not atomic** and nothing client-side can make it so. It
1853
+ * narrows the race to one round trip; it does not close it. The refusal says
1854
+ * that rather than leaving somebody to infer a guarantee that is not there.
1855
+ *
1856
+ * ## Why labels and assignees are three calls and not one PATCH
1857
+ *
1858
+ * `PATCH /issues/{n}` carries `labels` and `assignees` as **whole arrays**, so
1859
+ * routing them through it would make every label write a blind overwrite of
1860
+ * every label — the exact failure the guard above exists to prevent, reproduced
1861
+ * on a field where it would be silent. Their own add/remove sub-resources put
1862
+ * them back inside the append argument: adding a label cannot remove somebody
1863
+ * else's.
1864
+ *
1865
+ * Two things those endpoints do quietly, both measured live on #798 and both
1866
+ * handled by `gh-core` rather than here:
1867
+ *
1868
+ * - `POST …/labels` **creates** a repository label that does not exist. A
1869
+ * typo'd `--add-label` would leave grey litter on a client's repository
1870
+ * forever. Unknown labels are refused, with the near misses named;
1871
+ * `--create-label` is how you mean it.
1872
+ * - `POST …/assignees` **silently ignores** a login it will not assign, and
1873
+ * still answers 201. `ignored` in the output is that difference, and it is the
1874
+ * difference between telling somebody their teammate is assigned and it being
1875
+ * true.
1876
+ *
1877
+ * ## Order
1878
+ *
1879
+ * Fields first, then labels, then assignees — because the fields call is the one
1880
+ * that can **refuse**, and a refusal after the labels had already been rewritten
1881
+ * would leave the issue half-edited with no single command to finish it.
1882
+ */
1883
+ async function runEdit(io, session, positionals, options) {
1884
+ const ref = await resolveIssue(session, positionals[0], "edit");
1885
+ const coordinate = formatIssueCoordinate({ repo: ref.repo, number: ref.number });
1886
+ const wantsFields = options.title !== undefined || options.message !== undefined || options.milestone !== undefined;
1887
+ const wantsLabels = options.addLabels.length > 0 || options.removeLabels.length > 0;
1888
+ const wantsAssignees = options.addAssignees.length > 0 || options.removeAssignees.length > 0;
1889
+ if (!wantsFields && !wantsLabels && !wantsAssignees) {
1890
+ throw usageError(`Nothing to edit on ${coordinate}. Pass --title, a body (--text/--file/--stdin), `
1891
+ + "--milestone, --add-label/--remove-label or --add-assignee/--remove-assignee.", { verb: "edit" });
1892
+ }
1893
+ const github = await session.github();
1894
+ // Reads the issue AND refuses a pull-request number, which `gh-core` does at
1895
+ // this boundary rather than in each caller. Doing it first means a PR number
1896
+ // never reaches a write.
1897
+ const before = await github.issues.get(ref);
1898
+ const milestone = milestoneArgument(options.milestone);
1899
+ const guarded = wantsFields;
1900
+ const base = guarded
1901
+ ? {
1902
+ // The caller's base where it gave one, this read's where it did not.
1903
+ // Both are real bases; they differ only in how wide a window they cover,
1904
+ // and `guard.window` below is what says which.
1905
+ ...(options.title === undefined ? {} : { title: options.ifTitle ?? before.title }),
1906
+ ...(options.message === undefined ? {} : { body: options.ifBody ?? before.body ?? "" }),
1907
+ ...(milestone === undefined ? {} : { milestone: milestoneNumber(before) }),
1908
+ }
1909
+ : undefined;
1910
+ const plan = {
1911
+ issue: summariseIssue(before, { repo: ref.repo, number: ref.number }),
1912
+ edit: {
1913
+ ...(options.title === undefined ? {} : { title: options.title }),
1914
+ ...(options.message === undefined ? {} : { body: options.message }),
1915
+ ...(milestone === undefined ? {} : { milestone }),
1916
+ ...(options.addLabels.length === 0 ? {} : { addLabels: options.addLabels }),
1917
+ ...(options.removeLabels.length === 0 ? {} : { removeLabels: options.removeLabels }),
1918
+ ...(options.addAssignees.length === 0 ? {} : { addAssignees: options.addAssignees }),
1919
+ ...(options.removeAssignees.length === 0 ? {} : { removeAssignees: options.removeAssignees }),
1920
+ },
1921
+ };
1922
+ const gate = approve(io, session, {
1923
+ what: `edit ${coordinate}`,
1924
+ // GitHub only. Demanding an Azure DevOps PAT for a verb that never touches
1925
+ // Azure DevOps is #839, and it has a test.
1926
+ platform: "github",
1927
+ would: { ...plan, wouldWrite: writeList(wantsFields, wantsLabels, wantsAssignees) },
1928
+ });
1929
+ if (gate !== undefined)
1930
+ return gate;
1931
+ const result = { issue: plan.issue, edit: plan.edit };
1932
+ let latest = before;
1933
+ if (wantsFields) {
1934
+ const edit = await github.edits.fields(ref, {
1935
+ ...(options.title === undefined ? {} : { title: options.title }),
1936
+ ...(options.message === undefined ? {} : { body: options.message }),
1937
+ ...(milestone === undefined ? {} : { milestone }),
1938
+ ...(base === undefined ? {} : { base }),
1939
+ });
1940
+ latest = edit.issue;
1941
+ result["fields"] = {
1942
+ changed: edit.changed,
1943
+ converged: edit.guard.converged,
1944
+ guard: {
1945
+ ...edit.guard,
1946
+ window: usedCallerBase(options) ? "caller" : "in-command",
1947
+ note: "GitHub offers no conditional write on this endpoint — If-Match is refused with a 400 "
1948
+ + "— so this is a re-read compared on the VALUE. It narrows the race to one round trip "
1949
+ + "and does not close it.",
1950
+ },
1951
+ };
1952
+ }
1953
+ if (wantsLabels) {
1954
+ const labels = await github.edits.labels(ref, {
1955
+ add: options.addLabels,
1956
+ remove: options.removeLabels,
1957
+ createMissing: options.createLabel,
1958
+ });
1959
+ result["labels"] = {
1960
+ now: labels.labels.map((label) => label.name),
1961
+ added: labels.added,
1962
+ removed: labels.removed,
1963
+ alreadyAbsent: labels.alreadyAbsent,
1964
+ created: labels.created,
1965
+ };
1966
+ }
1967
+ if (wantsAssignees) {
1968
+ const assignees = await github.edits.assignees(ref, {
1969
+ add: options.addAssignees,
1970
+ remove: options.removeAssignees,
1971
+ });
1972
+ result["assignees"] = {
1973
+ now: assignees.assignees,
1974
+ added: assignees.added,
1975
+ removed: assignees.removed,
1976
+ // Named rather than buried: GitHub answered 201 for these and did not
1977
+ // assign them. A caller that only checks the status code reports a lie.
1978
+ ignored: assignees.ignored,
1979
+ notRemoved: assignees.notRemoved,
1980
+ };
1981
+ }
1982
+ result["edited"] = summariseIssue(latest, { repo: ref.repo, number: ref.number });
1983
+ return report(io, session, result);
1984
+ }
1985
+ /** `--milestone 4`, or `--milestone none` to clear it. */
1986
+ function milestoneArgument(given) {
1987
+ if (given === undefined)
1988
+ return undefined;
1989
+ const text = given.trim().toLowerCase();
1990
+ // `none` rather than an empty string, which a shell eats, and rather than `0`,
1991
+ // which is a plausible milestone number somebody could mean literally.
1992
+ if (text === "none" || text === "null")
1993
+ return null;
1994
+ if (!/^\d+$/.test(text)) {
1995
+ throw usageError(`"${given}" is not a milestone. Pass its NUMBER — the one in the milestone's URL — or `
1996
+ + "`none` to clear it. GitHub answers 422 for a number that does not exist.", { given });
1997
+ }
1998
+ return Number(text);
1999
+ }
2000
+ function usedCallerBase(options) {
2001
+ return options.ifTitle !== undefined || options.ifBody !== undefined;
2002
+ }
2003
+ function writeList(fields, labels, assignees) {
2004
+ const out = [];
2005
+ if (fields)
2006
+ out.push("fields");
2007
+ if (labels)
2008
+ out.push("labels");
2009
+ if (assignees)
2010
+ out.push("assignees");
2011
+ return out;
2012
+ }
1889
2013
  // MARK: - shared
1890
2014
  /**
1891
2015
  * `owner/name#45` → everything needed to call GitHub about it.
@@ -2030,6 +2154,8 @@ function render(result) {
2030
2154
  return renderBackfill(result);
2031
2155
  if (result["pairs"] !== undefined)
2032
2156
  return renderClose(result);
2157
+ if (result["edited"] !== undefined)
2158
+ return renderEdit(result);
2033
2159
  const message = result["message"];
2034
2160
  if (typeof message === "string")
2035
2161
  return message;
@@ -2057,6 +2183,62 @@ function render(result) {
2057
2183
  }
2058
2184
  return `${coordinate} ↔ #${String(workItem?.["id"] ?? "")} — wrote ${written}${unchecked}`;
2059
2185
  }
2186
+ /**
2187
+ * What actually changed, and — the two lines nobody must miss — what GitHub
2188
+ * quietly did not do.
2189
+ *
2190
+ * `ignored` and `created` are printed as warnings rather than folded into the
2191
+ * summary, because both are cases where the call **succeeded** and the outcome
2192
+ * is not what was asked for. `--pretty` is the mode where nobody is reading the
2193
+ * JSON that says so, and silence there reads as "all of it worked".
2194
+ */
2195
+ function renderEdit(result) {
2196
+ const edited = result["edited"];
2197
+ const coordinate = `${String(edited?.["repo"] ?? "")}#${String(edited?.["number"] ?? "")}`;
2198
+ const lines = [];
2199
+ const fields = result["fields"];
2200
+ const changed = fields?.["changed"] ?? [];
2201
+ const converged = fields?.["converged"] ?? [];
2202
+ if (fields !== undefined) {
2203
+ lines.push(changed.length === 0
2204
+ ? `${coordinate} — no field written (GitHub already held what was asked for)`
2205
+ : `${coordinate} — wrote ${changed.join(" + ")}`);
2206
+ if (converged.length > 0) {
2207
+ lines.push(` ${converged.join(", ")} already matched — not sent`);
2208
+ }
2209
+ const guard = fields["guard"];
2210
+ if (guard?.["window"] === "in-command") {
2211
+ lines.push(" guarded against this command's own read only — pass --if-title/--if-body to guard "
2212
+ + "against what you actually saw");
2213
+ }
2214
+ }
2215
+ const labels = result["labels"];
2216
+ if (labels !== undefined) {
2217
+ lines.push(` labels: ${(labels["now"] ?? []).join(", ") || "none"}`);
2218
+ const created = labels["created"] ?? [];
2219
+ if (created.length > 0) {
2220
+ lines.push(`! CREATED on the repository, not just on the issue: ${created.join(", ")}`);
2221
+ }
2222
+ const absent = labels["alreadyAbsent"] ?? [];
2223
+ if (absent.length > 0)
2224
+ lines.push(` not on the issue anyway: ${absent.join(", ")}`);
2225
+ }
2226
+ const assignees = result["assignees"];
2227
+ if (assignees !== undefined) {
2228
+ const now = assignees["now"] ?? [];
2229
+ lines.push(` assignees: ${now.join(", ") || "none"}`);
2230
+ const ignored = assignees["ignored"] ?? [];
2231
+ if (ignored.length > 0) {
2232
+ // The measured trap: 201, and these were never assigned.
2233
+ lines.push(`! GitHub accepted the request and did NOT assign: ${ignored.join(", ")} — they need `
2234
+ + "push access to the repository");
2235
+ }
2236
+ const notRemoved = assignees["notRemoved"] ?? [];
2237
+ if (notRemoved.length > 0)
2238
+ lines.push(`! still assigned after removal: ${notRemoved.join(", ")}`);
2239
+ }
2240
+ return lines.join("\n");
2241
+ }
2060
2242
  /**
2061
2243
  * Two lines for two audiences, because one line would flatten the only thing
2062
2244
  * about this verb worth seeing at a glance.