@floh-solutions/pharos-cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
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";
@@ -83,12 +83,13 @@ import { approve } from "../session.js";
83
83
  * `pharos issue link` command the failure itself carried:
84
84
  *
85
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.
86
+ * and the comment, and removes it when the comment lands. Local, so the
87
+ * refusal costs no round trip and it is the **only** guard left when the
88
+ * board cannot be asked at all, which is a state this deliberately does not
89
+ * treat as fatal. It also knows *which* work item this machine created and
90
+ * when, where a scan can only infer it. An entry is *verified* against Azure
91
+ * DevOps before it is believed, so a work item that was since deleted cannot
92
+ * wedge a legitimate adoption.
92
93
  * 2. **The board**, for everything the first cannot see: a retry from another
93
94
  * machine, another checkout, or after the journal was lost. Azure DevOps has
94
95
  * no query over relation URLs, so this narrows on `[System.HyperLinkCount]`
@@ -120,16 +121,28 @@ export async function runIssue(io, session, positionals, options) {
120
121
  return runBackfill(io, session, positionals.slice(1), options);
121
122
  case "close":
122
123
  return runClose(io, session, positionals.slice(1), options);
124
+ case "edit":
125
+ return runEdit(io, session, positionals.slice(1), options);
123
126
  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 });
127
+ throw usageError(`Which issue verb? ${ISSUE_VERBS.join(", ")}. Reading an issue is \`gh\`.`, { verbs: ISSUE_VERBS });
126
128
  default:
127
129
  throw usageError(`Unknown issue verb "${verb}". This owns the EDGE — ${ISSUE_VERBS.join(", ")} — and `
128
130
  + "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 });
131
+ + "one END is `gh issue close`; closing the PAIR is `pharos issue close`. Editing one "
132
+ + "unguarded is `gh issue edit`; editing it behind a lost-update guard is "
133
+ + "`pharos issue edit`.", { verbs: ISSUE_VERBS });
130
134
  }
131
135
  }
132
- const ISSUE_VERBS = ["adopt", "link", "say", "trail", "drift", "backfill", "close"];
136
+ const ISSUE_VERBS = [
137
+ "adopt",
138
+ "link",
139
+ "say",
140
+ "trail",
141
+ "drift",
142
+ "backfill",
143
+ "close",
144
+ "edit",
145
+ ];
133
146
  // MARK: - adopt
134
147
  /**
135
148
  * `pharos issue adopt <repo#n>` — the issue becomes a work item, and both ends
@@ -208,10 +221,10 @@ async function runAdopt(io, session, positionals, options) {
208
221
  * **The duplicate guard.** Refuses when a work item already tracks this issue
209
222
  * without the issue saying so, and otherwise reports what the check cost.
210
223
  *
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.
224
+ * Two sources, cheapest first see this file's header *What "already linked"
225
+ * means*. The board is the stronger one and it is what catches a retry from
226
+ * another machine; the journal is what is left when the board query cannot run,
227
+ * and it answers without a round trip.
215
228
  */
216
229
  async function refuseIfAlreadyTracked(session, coordinate) {
217
230
  const key = halfLinkKey(session, coordinate);
@@ -1886,6 +1899,213 @@ function titleOfField(item, name) {
1886
1899
  const value = item.fields?.[name];
1887
1900
  return typeof value === "string" && value !== "" ? value : null;
1888
1901
  }
1902
+ // MARK: - edit
1903
+ /**
1904
+ * `pharos issue edit <owner/name#45>` — change the issue's own fields, behind
1905
+ * the lost-update guard GitHub does not offer.
1906
+ *
1907
+ * ## This one verb is not an edge verb, and that needs saying out loud
1908
+ *
1909
+ * Everything else under `pharos issue` exists because `gh` cannot join the two
1910
+ * platforms. **`gh issue edit` exists and is good.** So the rule at the top of
1911
+ * this file — do not rebuild `gh` — is met here not by refusing to build the
1912
+ * verb but by building the one thing `gh` does not: a guard.
1913
+ *
1914
+ * `gh issue edit --title "…"` is last-write-wins. It reads nothing, compares
1915
+ * nothing, and if a colleague renamed the issue while you were composing, their
1916
+ * rename is gone and neither of you is told. That is fine for the interactive
1917
+ * human `gh` is written for, and it is not fine for an agent — which reads an
1918
+ * issue, spends a minute thinking, and writes back into a world that moved.
1919
+ *
1920
+ * So: **if you want an unguarded edit, `gh issue edit` is right there and is
1921
+ * better at it.** What this adds is the refusal.
1922
+ *
1923
+ * ## The guard, and what it honestly is
1924
+ *
1925
+ * There is no conditional write. `PATCH /repos/{o}/{r}/issues/{n}` answers
1926
+ * **400** to `If-Match` — measured (#832), re-measured (#798) — so the Azure
1927
+ * DevOps `{"op":"test","path":"/rev"}` guarantee has no GitHub analogue.
1928
+ *
1929
+ * The guard is therefore a **value compare**, in `gh-core`'s
1930
+ * {@link IssueEditApi.fields}: re-read immediately before sending, compare the
1931
+ * text about to be overwritten with the text the caller believes is there,
1932
+ * refuse only if *that* moved. Never `updated_at` and never the ETag — both move
1933
+ * when somebody merely comments, which would refuse a legitimate edit for a
1934
+ * change that touched nothing.
1935
+ *
1936
+ * **The base defaults to the read this command already makes**, so the guard is
1937
+ * on by default and costs nothing extra: `edit` has to read the issue anyway —
1938
+ * to refuse a pull-request number, to report what changed, and to know the
1939
+ * current values it is not overwriting. That default protects a window of one
1940
+ * round trip, which is honest but small, and the output says so as
1941
+ * `guard.window: "in-command"`.
1942
+ *
1943
+ * `--if-title` / `--if-body` / `--if-body-file` widen it to the base the caller
1944
+ * actually saw, and that is where the guard earns its keep: an agent that read
1945
+ * the issue, thought for a minute and came back can hand over what it read.
1946
+ * The output then says `guard.window: "caller"`.
1947
+ *
1948
+ * Either way it is **not atomic** and nothing client-side can make it so. It
1949
+ * narrows the race to one round trip; it does not close it. The refusal says
1950
+ * that rather than leaving somebody to infer a guarantee that is not there.
1951
+ *
1952
+ * ## Why labels and assignees are three calls and not one PATCH
1953
+ *
1954
+ * `PATCH /issues/{n}` carries `labels` and `assignees` as **whole arrays**, so
1955
+ * routing them through it would make every label write a blind overwrite of
1956
+ * every label — the exact failure the guard above exists to prevent, reproduced
1957
+ * on a field where it would be silent. Their own add/remove sub-resources put
1958
+ * them back inside the append argument: adding a label cannot remove somebody
1959
+ * else's.
1960
+ *
1961
+ * Two things those endpoints do quietly, both measured live on #798 and both
1962
+ * handled by `gh-core` rather than here:
1963
+ *
1964
+ * - `POST …/labels` **creates** a repository label that does not exist. A
1965
+ * typo'd `--add-label` would leave grey litter on a client's repository
1966
+ * forever. Unknown labels are refused, with the near misses named;
1967
+ * `--create-label` is how you mean it.
1968
+ * - `POST …/assignees` **silently ignores** a login it will not assign, and
1969
+ * still answers 201. `ignored` in the output is that difference, and it is the
1970
+ * difference between telling somebody their teammate is assigned and it being
1971
+ * true.
1972
+ *
1973
+ * ## Order
1974
+ *
1975
+ * Fields first, then labels, then assignees — because the fields call is the one
1976
+ * that can **refuse**, and a refusal after the labels had already been rewritten
1977
+ * would leave the issue half-edited with no single command to finish it.
1978
+ */
1979
+ async function runEdit(io, session, positionals, options) {
1980
+ const ref = await resolveIssue(session, positionals[0], "edit");
1981
+ const coordinate = formatIssueCoordinate({ repo: ref.repo, number: ref.number });
1982
+ const wantsFields = options.title !== undefined || options.message !== undefined || options.milestone !== undefined;
1983
+ const wantsLabels = options.addLabels.length > 0 || options.removeLabels.length > 0;
1984
+ const wantsAssignees = options.addAssignees.length > 0 || options.removeAssignees.length > 0;
1985
+ if (!wantsFields && !wantsLabels && !wantsAssignees) {
1986
+ throw usageError(`Nothing to edit on ${coordinate}. Pass --title, a body (--text/--file/--stdin), `
1987
+ + "--milestone, --add-label/--remove-label or --add-assignee/--remove-assignee.", { verb: "edit" });
1988
+ }
1989
+ const github = await session.github();
1990
+ // Reads the issue AND refuses a pull-request number, which `gh-core` does at
1991
+ // this boundary rather than in each caller. Doing it first means a PR number
1992
+ // never reaches a write.
1993
+ const before = await github.issues.get(ref);
1994
+ const milestone = milestoneArgument(options.milestone);
1995
+ const guarded = wantsFields;
1996
+ const base = guarded
1997
+ ? {
1998
+ // The caller's base where it gave one, this read's where it did not.
1999
+ // Both are real bases; they differ only in how wide a window they cover,
2000
+ // and `guard.window` below is what says which.
2001
+ ...(options.title === undefined ? {} : { title: options.ifTitle ?? before.title }),
2002
+ ...(options.message === undefined ? {} : { body: options.ifBody ?? before.body ?? "" }),
2003
+ ...(milestone === undefined ? {} : { milestone: milestoneNumber(before) }),
2004
+ }
2005
+ : undefined;
2006
+ const plan = {
2007
+ issue: summariseIssue(before, { repo: ref.repo, number: ref.number }),
2008
+ edit: {
2009
+ ...(options.title === undefined ? {} : { title: options.title }),
2010
+ ...(options.message === undefined ? {} : { body: options.message }),
2011
+ ...(milestone === undefined ? {} : { milestone }),
2012
+ ...(options.addLabels.length === 0 ? {} : { addLabels: options.addLabels }),
2013
+ ...(options.removeLabels.length === 0 ? {} : { removeLabels: options.removeLabels }),
2014
+ ...(options.addAssignees.length === 0 ? {} : { addAssignees: options.addAssignees }),
2015
+ ...(options.removeAssignees.length === 0 ? {} : { removeAssignees: options.removeAssignees }),
2016
+ },
2017
+ };
2018
+ const gate = approve(io, session, {
2019
+ what: `edit ${coordinate}`,
2020
+ // GitHub only. Demanding an Azure DevOps PAT for a verb that never touches
2021
+ // Azure DevOps is #839, and it has a test.
2022
+ platform: "github",
2023
+ would: { ...plan, wouldWrite: writeList(wantsFields, wantsLabels, wantsAssignees) },
2024
+ });
2025
+ if (gate !== undefined)
2026
+ return gate;
2027
+ const result = { issue: plan.issue, edit: plan.edit };
2028
+ let latest = before;
2029
+ if (wantsFields) {
2030
+ const edit = await github.edits.fields(ref, {
2031
+ ...(options.title === undefined ? {} : { title: options.title }),
2032
+ ...(options.message === undefined ? {} : { body: options.message }),
2033
+ ...(milestone === undefined ? {} : { milestone }),
2034
+ ...(base === undefined ? {} : { base }),
2035
+ });
2036
+ latest = edit.issue;
2037
+ result["fields"] = {
2038
+ changed: edit.changed,
2039
+ converged: edit.guard.converged,
2040
+ guard: {
2041
+ ...edit.guard,
2042
+ window: usedCallerBase(options) ? "caller" : "in-command",
2043
+ note: "GitHub offers no conditional write on this endpoint — If-Match is refused with a 400 "
2044
+ + "— so this is a re-read compared on the VALUE. It narrows the race to one round trip "
2045
+ + "and does not close it.",
2046
+ },
2047
+ };
2048
+ }
2049
+ if (wantsLabels) {
2050
+ const labels = await github.edits.labels(ref, {
2051
+ add: options.addLabels,
2052
+ remove: options.removeLabels,
2053
+ createMissing: options.createLabel,
2054
+ });
2055
+ result["labels"] = {
2056
+ now: labels.labels.map((label) => label.name),
2057
+ added: labels.added,
2058
+ removed: labels.removed,
2059
+ alreadyAbsent: labels.alreadyAbsent,
2060
+ created: labels.created,
2061
+ };
2062
+ }
2063
+ if (wantsAssignees) {
2064
+ const assignees = await github.edits.assignees(ref, {
2065
+ add: options.addAssignees,
2066
+ remove: options.removeAssignees,
2067
+ });
2068
+ result["assignees"] = {
2069
+ now: assignees.assignees,
2070
+ added: assignees.added,
2071
+ removed: assignees.removed,
2072
+ // Named rather than buried: GitHub answered 201 for these and did not
2073
+ // assign them. A caller that only checks the status code reports a lie.
2074
+ ignored: assignees.ignored,
2075
+ notRemoved: assignees.notRemoved,
2076
+ };
2077
+ }
2078
+ result["edited"] = summariseIssue(latest, { repo: ref.repo, number: ref.number });
2079
+ return report(io, session, result);
2080
+ }
2081
+ /** `--milestone 4`, or `--milestone none` to clear it. */
2082
+ function milestoneArgument(given) {
2083
+ if (given === undefined)
2084
+ return undefined;
2085
+ const text = given.trim().toLowerCase();
2086
+ // `none` rather than an empty string, which a shell eats, and rather than `0`,
2087
+ // which is a plausible milestone number somebody could mean literally.
2088
+ if (text === "none" || text === "null")
2089
+ return null;
2090
+ if (!/^\d+$/.test(text)) {
2091
+ throw usageError(`"${given}" is not a milestone. Pass its NUMBER — the one in the milestone's URL — or `
2092
+ + "`none` to clear it. GitHub answers 422 for a number that does not exist.", { given });
2093
+ }
2094
+ return Number(text);
2095
+ }
2096
+ function usedCallerBase(options) {
2097
+ return options.ifTitle !== undefined || options.ifBody !== undefined;
2098
+ }
2099
+ function writeList(fields, labels, assignees) {
2100
+ const out = [];
2101
+ if (fields)
2102
+ out.push("fields");
2103
+ if (labels)
2104
+ out.push("labels");
2105
+ if (assignees)
2106
+ out.push("assignees");
2107
+ return out;
2108
+ }
1889
2109
  // MARK: - shared
1890
2110
  /**
1891
2111
  * `owner/name#45` → everything needed to call GitHub about it.
@@ -2030,6 +2250,8 @@ function render(result) {
2030
2250
  return renderBackfill(result);
2031
2251
  if (result["pairs"] !== undefined)
2032
2252
  return renderClose(result);
2253
+ if (result["edited"] !== undefined)
2254
+ return renderEdit(result);
2033
2255
  const message = result["message"];
2034
2256
  if (typeof message === "string")
2035
2257
  return message;
@@ -2057,6 +2279,62 @@ function render(result) {
2057
2279
  }
2058
2280
  return `${coordinate} ↔ #${String(workItem?.["id"] ?? "")} — wrote ${written}${unchecked}`;
2059
2281
  }
2282
+ /**
2283
+ * What actually changed, and — the two lines nobody must miss — what GitHub
2284
+ * quietly did not do.
2285
+ *
2286
+ * `ignored` and `created` are printed as warnings rather than folded into the
2287
+ * summary, because both are cases where the call **succeeded** and the outcome
2288
+ * is not what was asked for. `--pretty` is the mode where nobody is reading the
2289
+ * JSON that says so, and silence there reads as "all of it worked".
2290
+ */
2291
+ function renderEdit(result) {
2292
+ const edited = result["edited"];
2293
+ const coordinate = `${String(edited?.["repo"] ?? "")}#${String(edited?.["number"] ?? "")}`;
2294
+ const lines = [];
2295
+ const fields = result["fields"];
2296
+ const changed = fields?.["changed"] ?? [];
2297
+ const converged = fields?.["converged"] ?? [];
2298
+ if (fields !== undefined) {
2299
+ lines.push(changed.length === 0
2300
+ ? `${coordinate} — no field written (GitHub already held what was asked for)`
2301
+ : `${coordinate} — wrote ${changed.join(" + ")}`);
2302
+ if (converged.length > 0) {
2303
+ lines.push(` ${converged.join(", ")} already matched — not sent`);
2304
+ }
2305
+ const guard = fields["guard"];
2306
+ if (guard?.["window"] === "in-command") {
2307
+ lines.push(" guarded against this command's own read only — pass --if-title/--if-body to guard "
2308
+ + "against what you actually saw");
2309
+ }
2310
+ }
2311
+ const labels = result["labels"];
2312
+ if (labels !== undefined) {
2313
+ lines.push(` labels: ${(labels["now"] ?? []).join(", ") || "none"}`);
2314
+ const created = labels["created"] ?? [];
2315
+ if (created.length > 0) {
2316
+ lines.push(`! CREATED on the repository, not just on the issue: ${created.join(", ")}`);
2317
+ }
2318
+ const absent = labels["alreadyAbsent"] ?? [];
2319
+ if (absent.length > 0)
2320
+ lines.push(` not on the issue anyway: ${absent.join(", ")}`);
2321
+ }
2322
+ const assignees = result["assignees"];
2323
+ if (assignees !== undefined) {
2324
+ const now = assignees["now"] ?? [];
2325
+ lines.push(` assignees: ${now.join(", ") || "none"}`);
2326
+ const ignored = assignees["ignored"] ?? [];
2327
+ if (ignored.length > 0) {
2328
+ // The measured trap: 201, and these were never assigned.
2329
+ lines.push(`! GitHub accepted the request and did NOT assign: ${ignored.join(", ")} — they need `
2330
+ + "push access to the repository");
2331
+ }
2332
+ const notRemoved = assignees["notRemoved"] ?? [];
2333
+ if (notRemoved.length > 0)
2334
+ lines.push(`! still assigned after removal: ${notRemoved.join(", ")}`);
2335
+ }
2336
+ return lines.join("\n");
2337
+ }
2060
2338
  /**
2061
2339
  * Two lines for two audiences, because one line would flatten the only thing
2062
2340
  * about this verb worth seeing at a glance.