@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.
- package/README.md +97 -10
- package/dist/adopt-type.d.ts +58 -0
- package/dist/adopt-type.d.ts.map +1 -0
- package/dist/adopt-type.js +61 -0
- package/dist/adopt-type.js.map +1 -0
- package/dist/bridge.d.ts +156 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +157 -0
- package/dist/bridge.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +176 -31
- package/dist/cli.js.map +1 -1
- package/dist/commands/bridge.d.ts +30 -0
- package/dist/commands/bridge.d.ts.map +1 -0
- package/dist/commands/bridge.js +255 -0
- package/dist/commands/bridge.js.map +1 -0
- package/dist/commands/comment-github.d.ts +138 -0
- package/dist/commands/comment-github.d.ts.map +1 -0
- package/dist/commands/comment-github.js +427 -0
- package/dist/commands/comment-github.js.map +1 -0
- package/dist/commands/comment.d.ts.map +1 -1
- package/dist/commands/comment.js +114 -19
- package/dist/commands/comment.js.map +1 -1
- package/dist/commands/doctor.d.ts +169 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +241 -44
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/issue.d.ts +133 -0
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +292 -110
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/setup.d.ts +45 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +74 -0
- package/dist/commands/setup.js.map +1 -1
- package/dist/half-link.d.ts +14 -4
- package/dist/half-link.d.ts.map +1 -1
- package/dist/half-link.js +14 -4
- package/dist/half-link.js.map +1 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +123 -1
- package/dist/output.js.map +1 -1
- package/dist/session.d.ts +51 -2
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +62 -11
- package/dist/session.js.map +1 -1
- package/dist/target.d.ts +40 -4
- package/dist/target.d.ts.map +1 -1
- package/dist/target.js +14 -3
- package/dist/target.js.map +1 -1
- package/package.json +4 -4
- package/skill/SKILL.md +167 -7
package/dist/commands/issue.js
CHANGED
|
@@ -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(
|
|
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`.
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
212
|
-
*
|
|
213
|
-
* the journal
|
|
214
|
-
*
|
|
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 ??
|
|
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.
|