@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.
- package/README.md +97 -10
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -7
- package/dist/cli.js.map +1 -1
- package/dist/commands/comment-github.d.ts +138 -0
- package/dist/commands/comment-github.d.ts.map +1 -0
- package/dist/commands/comment-github.js +427 -0
- package/dist/commands/comment-github.js.map +1 -0
- package/dist/commands/comment.d.ts.map +1 -1
- package/dist/commands/comment.js +114 -19
- package/dist/commands/comment.js.map +1 -1
- package/dist/commands/doctor.d.ts +60 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +87 -24
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/issue.d.ts +35 -0
- package/dist/commands/issue.d.ts.map +1 -1
- package/dist/commands/issue.js +293 -15
- package/dist/commands/issue.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 +3 -3
- package/skill/SKILL.md +159 -7
package/dist/commands/issue.js
CHANGED
|
@@ -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,
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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(
|
|
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`.
|
|
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 = [
|
|
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
|
|
212
|
-
*
|
|
213
|
-
* the journal
|
|
214
|
-
*
|
|
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.
|