@floh-solutions/pharos-cli 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +318 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +111 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/cli.d.ts +4 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +235 -31
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts +11 -2
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +406 -4
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/issue.d.ts +40 -0
- package/dist/commands/issue.d.ts.map +1 -0
- package/dist/commands/issue.js +2188 -0
- package/dist/commands/issue.js.map +1 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +120 -3
- package/dist/commands/setup.js.map +1 -1
- package/dist/half-link.d.ts +147 -0
- package/dist/half-link.d.ts.map +1 -0
- package/dist/half-link.js +247 -0
- package/dist/half-link.js.map +1 -0
- package/dist/issue-scan.d.ts +383 -0
- package/dist/issue-scan.d.ts.map +1 -0
- package/dist/issue-scan.js +455 -0
- package/dist/issue-scan.js.map +1 -0
- package/dist/output.d.ts +18 -1
- package/dist/output.d.ts.map +1 -1
- package/dist/output.js +171 -0
- package/dist/output.js.map +1 -1
- package/dist/session.d.ts +50 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +70 -0
- package/dist/session.js.map +1 -1
- package/package.json +4 -3
- package/skill/SKILL.md +275 -9
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { normalizeRepo } from "@floh-solutions/gh-core";
|
|
4
|
+
/**
|
|
5
|
+
* `~/.config/pharos/half-links.json` — the one thing this CLI remembers between
|
|
6
|
+
* invocations, and it exists because of the one state the durable layers cannot
|
|
7
|
+
* describe.
|
|
8
|
+
*
|
|
9
|
+
* ## What it is for
|
|
10
|
+
*
|
|
11
|
+
* `adopt` writes Azure DevOps first (`issue.ts` ▸ the write order), so the
|
|
12
|
+
* failure it can leave is always the same shape: the work item created, the
|
|
13
|
+
* GitHub comment not. In that state **both** indexes the bridge normally reads
|
|
14
|
+
* are silent about it — the marker was never posted, and Azure DevOps offers no
|
|
15
|
+
* query over relation URLs. An agent that retries the verb it just ran, which
|
|
16
|
+
* the CLI's own docs told it was safe, got a **second work item**: its own id,
|
|
17
|
+
* its own place in a sprint, its own notifications, on a client's board, and
|
|
18
|
+
* nothing in this app removes it.
|
|
19
|
+
*
|
|
20
|
+
* So the moment a work item is created, before the comment is attempted, the
|
|
21
|
+
* intent is written down here; the moment the comment lands it is taken back
|
|
22
|
+
* out. What remains is exactly the set of adoptions that half-happened, and the
|
|
23
|
+
* next `adopt` of the same issue refuses instead of creating a second one.
|
|
24
|
+
*
|
|
25
|
+
* **Written before the comment rather than in the failure handler.** A `catch`
|
|
26
|
+
* covers an HTTP error and nothing else — ctrl-C during a slow POST, a killed
|
|
27
|
+
* process, a laptop lid — and those leave precisely the same orphan. The write
|
|
28
|
+
* is local, so paying for it on the success path costs nothing worth measuring.
|
|
29
|
+
*
|
|
30
|
+
* ## Why a file, when nothing else here has one
|
|
31
|
+
*
|
|
32
|
+
* `issue.ts` argues that this CLI keeps no state and therefore cannot be stale,
|
|
33
|
+
* and that stays true of the **link**: the marker and the hyperlink are read
|
|
34
|
+
* fresh every time and this file is never consulted for what a link *is*. What
|
|
35
|
+
* it records is a local event — "this machine created a work item and did not
|
|
36
|
+
* finish" — which is not a fact about the link at all, and is not derivable
|
|
37
|
+
* from either platform. A stale entry can only ever cost one extra read, never
|
|
38
|
+
* a wrong answer: whether the work item it names is really still half-linked is
|
|
39
|
+
* decided by reading that work item, in `issue.ts`, and never trusted from
|
|
40
|
+
* here.
|
|
41
|
+
*
|
|
42
|
+
* It is also **not the only guard**, and must not be treated as one. The board
|
|
43
|
+
* scan in `issue.ts` catches the retry that arrives on a different machine, and
|
|
44
|
+
* this catches the retry that arrives before Azure DevOps' query index has
|
|
45
|
+
* caught up with the create — seconds, which is exactly when a retry happens.
|
|
46
|
+
* Neither subsumes the other.
|
|
47
|
+
*
|
|
48
|
+
* ## Never fatal
|
|
49
|
+
*
|
|
50
|
+
* Every operation here swallows its own I/O errors. A read-only `HOME`, a
|
|
51
|
+
* corrupt file, two invocations racing on the same path — none of that is a
|
|
52
|
+
* reason to fail an adoption that would otherwise work, and the half-linked
|
|
53
|
+
* error still carries `recover` regardless. The cost of losing an entry is one
|
|
54
|
+
* missed refusal; the cost of throwing here would be a verb that stops working
|
|
55
|
+
* because of a cache.
|
|
56
|
+
*/
|
|
57
|
+
/** The schema version written into the file. Additive changes keep it at 1. */
|
|
58
|
+
export const HALF_LINKS_VERSION = 1;
|
|
59
|
+
/**
|
|
60
|
+
* How many entries are kept. Nothing normal accumulates here — an entry is
|
|
61
|
+
* removed as soon as the link completes — so this is a backstop against a
|
|
62
|
+
* script looping on a broken GitHub token, not a working limit. Oldest first
|
|
63
|
+
* out.
|
|
64
|
+
*/
|
|
65
|
+
export const HALF_LINKS_LIMIT = 200;
|
|
66
|
+
/**
|
|
67
|
+
* `$PHAROS_HALF_LINKS_FILE`, else `$XDG_CONFIG_HOME/pharos/half-links.json`,
|
|
68
|
+
* else `$HOME/.config/pharos/half-links.json` — beside `repos.json`, which is
|
|
69
|
+
* the path `pharos setup` prints and the one people already know.
|
|
70
|
+
*
|
|
71
|
+
* **`HOME` is read from the invocation's environment and there is no
|
|
72
|
+
* `homedir()` fallback.** Same rule the GitHub client states about `PATH`: the
|
|
73
|
+
* environment this run was given is the environment it acts in. The practical
|
|
74
|
+
* consequence is that a test harness that passes an explicit env gets no
|
|
75
|
+
* journal rather than silently writing into the developer's real home
|
|
76
|
+
* directory — an accidental write there would be a file nobody knows exists,
|
|
77
|
+
* refusing adoptions in a shell nobody connected to a test run.
|
|
78
|
+
*/
|
|
79
|
+
export function halfLinksPath(env) {
|
|
80
|
+
const override = env["PHAROS_HALF_LINKS_FILE"]?.trim();
|
|
81
|
+
if (override !== undefined && override !== "")
|
|
82
|
+
return override;
|
|
83
|
+
const xdg = env["XDG_CONFIG_HOME"]?.trim();
|
|
84
|
+
if (xdg !== undefined && xdg !== "")
|
|
85
|
+
return join(xdg, "pharos", "half-links.json");
|
|
86
|
+
const home = env["HOME"]?.trim();
|
|
87
|
+
if (home !== undefined && home !== "")
|
|
88
|
+
return join(home, ".config", "pharos", "half-links.json");
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Decode the file, keeping only entries that are completely well formed.
|
|
93
|
+
*
|
|
94
|
+
* **A bad row is dropped, never thrown over.** This file is a convenience the
|
|
95
|
+
* bridge can live without, and refusing to parse it would turn one corrupt
|
|
96
|
+
* character into "adopt no longer works" — the opposite of what it is for. The
|
|
97
|
+
* caller cannot tell an empty file from an unreadable one, deliberately: in
|
|
98
|
+
* both cases the answer is "this machine remembers nothing", and the board scan
|
|
99
|
+
* is what covers that.
|
|
100
|
+
*/
|
|
101
|
+
export function parseHalfLinks(text) {
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(text);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const rows = parsed?.pending;
|
|
110
|
+
if (!Array.isArray(rows))
|
|
111
|
+
return [];
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
const entry = readEntry(row);
|
|
115
|
+
if (entry !== undefined)
|
|
116
|
+
out.push(entry);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** Encode the file. Newest last, which is the order entries are added in. */
|
|
121
|
+
export function formatHalfLinks(entries) {
|
|
122
|
+
return `${JSON.stringify({ version: HALF_LINKS_VERSION, pending: entries }, undefined, 2)}\n`;
|
|
123
|
+
}
|
|
124
|
+
/** Does this entry name that issue, on that board? */
|
|
125
|
+
export function matchesHalfLink(entry, key) {
|
|
126
|
+
return (entry.repo === normalizeRepo(key.repo)
|
|
127
|
+
&& entry.issue === key.issue
|
|
128
|
+
&& entry.organization === key.organization
|
|
129
|
+
&& entry.project === key.project);
|
|
130
|
+
}
|
|
131
|
+
export function findHalfLink(entries, key) {
|
|
132
|
+
// Last wins. An issue can only be half-adopted onto one board at a time, so a
|
|
133
|
+
// second entry means the first was superseded by a later attempt — and the
|
|
134
|
+
// later work item is the one a refusal should name.
|
|
135
|
+
return [...entries].reverse().find((entry) => matchesHalfLink(entry, key));
|
|
136
|
+
}
|
|
137
|
+
/** The list with this entry added, replacing any earlier one for the same key. */
|
|
138
|
+
export function withHalfLink(entries, entry) {
|
|
139
|
+
const kept = entries.filter((existing) => !matchesHalfLink(existing, entry));
|
|
140
|
+
kept.push(entry);
|
|
141
|
+
return kept.slice(Math.max(0, kept.length - HALF_LINKS_LIMIT));
|
|
142
|
+
}
|
|
143
|
+
/** The list with every entry for this key removed. */
|
|
144
|
+
export function withoutHalfLink(entries, key) {
|
|
145
|
+
return entries.filter((entry) => !matchesHalfLink(entry, key));
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The journal, as one invocation uses it.
|
|
149
|
+
*
|
|
150
|
+
* Every method is best-effort by construction — see the header. `record` and
|
|
151
|
+
* `forget` are read-modify-write and two concurrent adoptions can therefore
|
|
152
|
+
* lose one entry to the other; the file is replaced by `rename` so it is never
|
|
153
|
+
* half-written, and a lost entry costs a refusal this machine would have made,
|
|
154
|
+
* which the board scan still makes.
|
|
155
|
+
*/
|
|
156
|
+
export class HalfLinkJournal {
|
|
157
|
+
path;
|
|
158
|
+
constructor(env) {
|
|
159
|
+
this.path = halfLinksPath(env);
|
|
160
|
+
}
|
|
161
|
+
async read() {
|
|
162
|
+
if (this.path === undefined)
|
|
163
|
+
return [];
|
|
164
|
+
try {
|
|
165
|
+
return parseHalfLinks(await readFile(this.path, "utf8"));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** The entry for this issue on this board, if this machine left one. */
|
|
172
|
+
async find(key) {
|
|
173
|
+
return findHalfLink(await this.read(), key);
|
|
174
|
+
}
|
|
175
|
+
/** Write down an adoption that is about to try its second half. */
|
|
176
|
+
async record(entry) {
|
|
177
|
+
await this.#write(withHalfLink(await this.read(), { ...entry, repo: normalizeRepo(entry.repo) }));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Take an entry back out — the link completed, or the work item it named
|
|
181
|
+
* turned out not to be there any more.
|
|
182
|
+
*/
|
|
183
|
+
async forget(key) {
|
|
184
|
+
const entries = await this.read();
|
|
185
|
+
const kept = withoutHalfLink(entries, key);
|
|
186
|
+
// Nothing to say and nothing to write. Worth checking: `adopt` calls this on
|
|
187
|
+
// its ordinary success path, and on a machine that has never half-linked
|
|
188
|
+
// anything that would otherwise create the file for no reason.
|
|
189
|
+
if (kept.length === entries.length)
|
|
190
|
+
return;
|
|
191
|
+
await this.#write(kept);
|
|
192
|
+
}
|
|
193
|
+
async #write(entries) {
|
|
194
|
+
const path = this.path;
|
|
195
|
+
if (path === undefined)
|
|
196
|
+
return;
|
|
197
|
+
// A distinct temp name per process: `rename` is atomic, so a reader sees the
|
|
198
|
+
// old file or the new one and never a truncated one — but two writers
|
|
199
|
+
// sharing a temp path would corrupt each other's copy before either
|
|
200
|
+
// renamed.
|
|
201
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
202
|
+
try {
|
|
203
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
204
|
+
await writeFile(temporary, formatHalfLinks(entries), { mode: 0o600 });
|
|
205
|
+
await rename(temporary, path);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
try {
|
|
209
|
+
await unlink(temporary);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Nothing left to try, and an adoption must not fail over a cache file.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function readEntry(row) {
|
|
218
|
+
if (typeof row !== "object" || row === null || Array.isArray(row))
|
|
219
|
+
return undefined;
|
|
220
|
+
const record = row;
|
|
221
|
+
const repo = record["repo"];
|
|
222
|
+
const issue = record["issue"];
|
|
223
|
+
const workItem = record["workItem"];
|
|
224
|
+
const organization = record["organization"];
|
|
225
|
+
const project = record["project"];
|
|
226
|
+
const at = record["at"];
|
|
227
|
+
if (typeof repo !== "string" || repo === "")
|
|
228
|
+
return undefined;
|
|
229
|
+
if (!isPositiveInt(issue) || !isPositiveInt(workItem))
|
|
230
|
+
return undefined;
|
|
231
|
+
if (typeof organization !== "string" || organization === "")
|
|
232
|
+
return undefined;
|
|
233
|
+
if (project !== null && typeof project !== "string")
|
|
234
|
+
return undefined;
|
|
235
|
+
return {
|
|
236
|
+
repo: normalizeRepo(repo),
|
|
237
|
+
issue,
|
|
238
|
+
workItem,
|
|
239
|
+
organization,
|
|
240
|
+
project,
|
|
241
|
+
at: typeof at === "string" ? at : "",
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function isPositiveInt(value) {
|
|
245
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=half-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"half-link.js","sourceRoot":"","sources":["../src/half-link.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC9E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoDG;AAEH,+EAA+E;AAC/E,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAEpC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAG,CAAC;AA8BpC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAAC,GAAsB;IAClD,MAAM,QAAQ,GAAG,GAAG,CAAC,wBAAwB,CAAC,EAAE,IAAI,EAAE,CAAC;IACvD,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAC;IAC/D,MAAM,GAAG,GAAG,GAAG,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;IACnF,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC;IACjC,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;IACjG,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAY,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,MAAM,IAAI,GAAI,MAAuC,EAAE,OAAO,CAAC;IAC/D,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,EAAE,CAAC;IAEpC,MAAM,GAAG,GAAe,EAAE,CAAC;IAC3B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,KAAK,KAAK,SAAS;YAAE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC3C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,eAAe,CAAC,OAA4B;IAC1D,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,OAAO,EAAE,kBAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,CAAC,IAAI,CAAC;AAChG,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,eAAe,CAAC,KAAe,EAAE,GAAgB;IAC/D,OAAO,CACL,KAAK,CAAC,IAAI,KAAK,aAAa,CAAC,GAAG,CAAC,IAAI,CAAC;WACnC,KAAK,CAAC,KAAK,KAAK,GAAG,CAAC,KAAK;WACzB,KAAK,CAAC,YAAY,KAAK,GAAG,CAAC,YAAY;WACvC,KAAK,CAAC,OAAO,KAAK,GAAG,CAAC,OAAO,CACjC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,YAAY,CAC1B,OAA4B,EAC5B,GAAgB;IAEhB,8EAA8E;IAC9E,2EAA2E;IAC3E,oDAAoD;IACpD,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AAC7E,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAC,OAA4B,EAAE,KAAe;IACxE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC,CAAC;IAC7E,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACjB,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,MAAM,GAAG,gBAAgB,CAAC,CAAC,CAAC;AACjE,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,eAAe,CAAC,OAA4B,EAAE,GAAgB;IAC5E,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,eAAe,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AACjE,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,OAAO,eAAe;IACjB,IAAI,CAAqB;IAElC,YAAY,GAAsB;QAChC,IAAI,CAAC,IAAI,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IACjC,CAAC;IAED,KAAK,CAAC,IAAI;QACR,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS;YAAE,OAAO,EAAE,CAAC;QACvC,IAAI,CAAC;YACH,OAAO,cAAc,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;QAC3D,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,IAAI,CAAC,GAAgB;QACzB,OAAO,YAAY,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,GAAG,CAAC,CAAC;IAC9C,CAAC;IAED,mEAAmE;IACnE,KAAK,CAAC,MAAM,CAAC,KAAe;QAC1B,MAAM,IAAI,CAAC,MAAM,CAAC,YAAY,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,EAAE,GAAG,KAAK,EAAE,IAAI,EAAE,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;IACpG,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,MAAM,CAAC,GAAgB;QAC3B,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,eAAe,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;QAC3C,6EAA6E;QAC7E,yEAAyE;QACzE,+DAA+D;QAC/D,IAAI,IAAI,CAAC,MAAM,KAAK,OAAO,CAAC,MAAM;YAAE,OAAO;QAC3C,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,OAA4B;QACvC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACvB,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO;QAC/B,6EAA6E;QAC7E,sEAAsE;QACtE,oEAAoE;QACpE,WAAW;QACX,MAAM,SAAS,GAAG,GAAG,IAAI,IAAI,OAAO,CAAC,GAAG,MAAM,CAAC;QAC/C,IAAI,CAAC;YACH,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YAC7D,MAAM,SAAS,CAAC,SAAS,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;YACtE,MAAM,MAAM,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,SAAS,CAAC,CAAC;YAC1B,CAAC;YAAC,MAAM,CAAC;gBACP,wEAAwE;YAC1E,CAAC;QACH,CAAC;IACH,CAAC;CACF;AAED,SAAS,SAAS,CAAC,GAAY;IAC7B,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC;IACpF,MAAM,MAAM,GAAG,GAA8B,CAAC;IAE9C,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC;IAC5B,MAAM,KAAK,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IAC9B,MAAM,QAAQ,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC;IACpC,MAAM,YAAY,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC;IAC5C,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAClC,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;IAExB,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC9D,IAAI,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAC;IACxE,IAAI,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC9E,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAEtE,OAAO;QACL,IAAI,EAAE,aAAa,CAAC,IAAI,CAAC;QACzB,KAAK;QACL,QAAQ;QACR,YAAY;QACZ,OAAO;QACP,EAAE,EAAE,OAAO,EAAE,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC;AAC/E,CAAC"}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { type IssueLink, type LinkEvidence } from "@floh-solutions/gh-core";
|
|
2
|
+
import type { StateCatalog } from "@floh-solutions/ado-core";
|
|
3
|
+
/**
|
|
4
|
+
* The join behind `pharos issue drift` and `pharos issue backfill`, with no I/O
|
|
5
|
+
* in it.
|
|
6
|
+
*
|
|
7
|
+
* **Separate from the command for the same reason `wiql-build.ts` is** — this is
|
|
8
|
+
* where the bugs are. Every interesting question here is a comparison between
|
|
9
|
+
* two records that came from two different systems, and comparisons are exactly
|
|
10
|
+
* what can be proved without a network: which spelling of a repository counts as
|
|
11
|
+
* the same repository, what "still open" means on a board that renamed its
|
|
12
|
+
* states, whether one piece of evidence on each of two layers is one link or
|
|
13
|
+
* two. The command file around this reads, calls, and prints.
|
|
14
|
+
*
|
|
15
|
+
* ## The one idea both verbs are built on
|
|
16
|
+
*
|
|
17
|
+
* **The Azure DevOps side is the enumerable one, and that is what makes a
|
|
18
|
+
* 400-issue repository affordable.**
|
|
19
|
+
*
|
|
20
|
+
* GitHub cannot answer "which of my issues has Pharos adopted" — the answer
|
|
21
|
+
* lives in a comment on each issue, so asking costs one request per issue and a
|
|
22
|
+
* backfill would spend four hundred requests deciding what to do before doing
|
|
23
|
+
* anything. Azure DevOps *can*: a work item carries its `Hyperlink` relations on
|
|
24
|
+
* its own row, so one WIQL plus one `workitemsbatch` returns every link on the
|
|
25
|
+
* board at once.
|
|
26
|
+
*
|
|
27
|
+
* That index is correct as an answer to "what is already adopted" because
|
|
28
|
+
* `adopt` writes the Azure DevOps end **first** (`issue.ts` ▸ the write order).
|
|
29
|
+
* The half-state it can leave is a hyperlink with no comment, never a comment
|
|
30
|
+
* with no hyperlink — so an issue missing from this index was either never
|
|
31
|
+
* adopted, or adopted by something that died before its first write. Both mean
|
|
32
|
+
* the same thing to a backfill.
|
|
33
|
+
*
|
|
34
|
+
* The mirror-image state is still possible if somebody deletes the relation by
|
|
35
|
+
* hand, and it is why {@link backfillCandidates} produces *candidates* rather
|
|
36
|
+
* than a work list: the per-issue marker read that would catch it happens at
|
|
37
|
+
* adoption time, on the handful being written, instead of on all four hundred.
|
|
38
|
+
*
|
|
39
|
+
* ## Two traps peers already paid for
|
|
40
|
+
*
|
|
41
|
+
* 1. **Repositories are compared on {@link normalizeRepo}, never with `===`.**
|
|
42
|
+
* GitHub resolves a repository case-insensitively and echoes back whatever
|
|
43
|
+
* spelling it was created with, so `Contoso/Widgets` and `contoso/widgets`
|
|
44
|
+
* are one repository and two strings. The app track reported a perfectly good
|
|
45
|
+
* link as half-written over exactly this (#787).
|
|
46
|
+
* 2. **Evidence is counted nowhere.** `["marker","mention"]` is two pieces of
|
|
47
|
+
* evidence and one side — both are GitHub's — so a link with no `Hyperlink`
|
|
48
|
+
* at all passes a length check (#782 found this in its own code). Whether
|
|
49
|
+
* both platforms carry a link is `gh-core`'s `isTwoSided`, and whether the
|
|
50
|
+
* GitHub half was even *looked at* is {@link IssueSide.markers} being present
|
|
51
|
+
* rather than empty.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* The statement `drift` runs when nobody passed `--wiql`.
|
|
55
|
+
*
|
|
56
|
+
* **BOTH `System.HyperLinkCount` AND `System.ExternalLinkCount`, and the reason
|
|
57
|
+
* this reads as belt-and-braces is that it once was neither.**
|
|
58
|
+
*
|
|
59
|
+
* This narrowed on `ExternalLinkCount` alone, on the stated grounds that it was
|
|
60
|
+
* the *broader* filter and that a false negative here is far worse than a false
|
|
61
|
+
* positive. The asymmetry argument is right. The premise was wrong, and it made
|
|
62
|
+
* the command silently useless: **the two counters are disjoint, not nested.**
|
|
63
|
+
* Azure DevOps counts each relation kind separately —
|
|
64
|
+
* `RelatedLinkCount` (work item ↔ work item), `HyperLinkCount` (a plain
|
|
65
|
+
* `Hyperlink`), `ExternalLinkCount` (an `ArtifactLink`: a commit, PR or build),
|
|
66
|
+
* `AttachedFileCount`.
|
|
67
|
+
*
|
|
68
|
+
* `adopt` writes a **`Hyperlink`** — it must, because minting the native
|
|
69
|
+
* `vstfs:///GitHub/Issue/…` artifact URI needs a connection id that is behind a
|
|
70
|
+
* 401 on our scopes (§2). So every pair this tool creates increments
|
|
71
|
+
* `HyperLinkCount` and leaves `ExternalLinkCount` at zero, and the old default
|
|
72
|
+
* could **never** see a link Pharos had made. It answered "no drift" forever,
|
|
73
|
+
* exit 0, which is the failure mode a drift detector must not have.
|
|
74
|
+
*
|
|
75
|
+
* Measured on the live board (#801, re-verified): an adopted pair read
|
|
76
|
+
* `HyperLinkCount 1, ExternalLinkCount 0`, and the two work items that DID
|
|
77
|
+
* match `ExternalLinkCount > 0` each carried an `ArtifactLink` and no
|
|
78
|
+
* `Hyperlink`.
|
|
79
|
+
*
|
|
80
|
+
* Both are kept rather than swapping one for the other, because a client whose
|
|
81
|
+
* org *does* have the Azure Boards GitHub connection gets a native artifact
|
|
82
|
+
* link in addition to ours (§2) — narrowing to hyperlinks alone would then miss
|
|
83
|
+
* exactly the boards that are best integrated.
|
|
84
|
+
*
|
|
85
|
+
* There is no way to ask WIQL about the *contents* of a relation — it queries
|
|
86
|
+
* fields, and a `Hyperlink`'s URL is not one. So this narrows to work items that
|
|
87
|
+
* have external links at all and the URLs are parsed afterwards, in
|
|
88
|
+
* {@link linksOnWorkItems}.
|
|
89
|
+
*
|
|
90
|
+
* `[System.TeamProject] = @project` is not redundant with the project in the
|
|
91
|
+
* URL: WIQL happily returns items from every project the token can read, and a
|
|
92
|
+
* drift report that reached across an organisation would be answering a question
|
|
93
|
+
* nobody asked. Same argument `buildWiql` already makes.
|
|
94
|
+
*/
|
|
95
|
+
export declare function driftWiql(): string;
|
|
96
|
+
/**
|
|
97
|
+
* The statement `adopt` runs **before creating anything**, to find a work item
|
|
98
|
+
* that already links the issue in front of it.
|
|
99
|
+
*
|
|
100
|
+
* ## Why this is not `driftWiql`
|
|
101
|
+
*
|
|
102
|
+
* Two narrower choices, and both are the point.
|
|
103
|
+
*
|
|
104
|
+
* **`HyperLinkCount` alone.** `drift` asks "is any link here broken", so it
|
|
105
|
+
* takes both counters — a client whose organisation *does* have the Azure
|
|
106
|
+
* Boards connection gets a native `ArtifactLink` in addition to ours, and
|
|
107
|
+
* missing those would miss the best-integrated boards. This asks a different
|
|
108
|
+
* question: *did Pharos already adopt this issue*, and Pharos writes a
|
|
109
|
+
* `Hyperlink`, always, because the connection id an artifact link needs is
|
|
110
|
+
* behind a 401 on our scopes (§2). So `ExternalLinkCount` here would add every
|
|
111
|
+
* work item that ever had a commit, a PR or a build attached — which on a real
|
|
112
|
+
* board is most of them — to a query that runs on the way to a single
|
|
113
|
+
* adoption. Measured on the live board (#801): the adopted pair answered
|
|
114
|
+
* `HyperLinkCount 1, ExternalLinkCount 0`, and `[System.HyperLinkCount] > 0`
|
|
115
|
+
* returned it while `[System.ExternalLinkCount] > 0` returned two other items
|
|
116
|
+
* and not it.
|
|
117
|
+
*
|
|
118
|
+
* **Capped, newest first.** `drift` is a deliberate sweep and reads the whole
|
|
119
|
+
* board; this is a guard on a one-issue verb and must not turn it into one. The
|
|
120
|
+
* state it exists to catch is a work item written minutes ago, so ordering by
|
|
121
|
+
* `ChangedDate` descending and stopping at {@link ADOPT_GUARD_LIMIT} keeps the
|
|
122
|
+
* cost flat while looking exactly where the answer is. A board with more
|
|
123
|
+
* hyperlinked work items than that is not a wrong answer — it is a partial one,
|
|
124
|
+
* and `adopt` says so rather than reporting a clean check.
|
|
125
|
+
*
|
|
126
|
+
* There is still no way to ask WIQL about the *contents* of a relation, so the
|
|
127
|
+
* URLs are matched afterwards, client-side, by {@link claimsOn}. That is the
|
|
128
|
+
* same narrow-then-filter shape `drift` uses and the same reason.
|
|
129
|
+
*/
|
|
130
|
+
export declare function adoptGuardWiql(): string;
|
|
131
|
+
/**
|
|
132
|
+
* How many hyperlinked work items `adopt`'s guard looks at. Two
|
|
133
|
+
* `workitemsbatch` calls at the 200-per-request chunk, on top of the WIQL.
|
|
134
|
+
*/
|
|
135
|
+
export declare const ADOPT_GUARD_LIMIT = 400;
|
|
136
|
+
/** A work item, reduced to what a drift row needs to say about it. */
|
|
137
|
+
export interface LinkedWorkItem {
|
|
138
|
+
readonly id: number;
|
|
139
|
+
readonly type: string | null;
|
|
140
|
+
readonly title: string | null;
|
|
141
|
+
readonly state: string | null;
|
|
142
|
+
readonly url: string;
|
|
143
|
+
}
|
|
144
|
+
/** One end of the bridge as the board records it: a work item and an issue it names. */
|
|
145
|
+
export interface ScannedLink {
|
|
146
|
+
readonly workItem: LinkedWorkItem;
|
|
147
|
+
/** Lower-cased. The spelling the board wrote is in {@link ScannedLink.spelling}. */
|
|
148
|
+
readonly repo: string;
|
|
149
|
+
/** The repository as the `Hyperlink` spelled it, for putting in front of a person. */
|
|
150
|
+
readonly spelling: string;
|
|
151
|
+
readonly issueNumber: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Every GitHub issue link on a batch of work items, flattened.
|
|
155
|
+
*
|
|
156
|
+
* `hyperlinks` is a function rather than a field because reading relations off a
|
|
157
|
+
* `WorkItem` is the command's business — this file is not allowed to care what
|
|
158
|
+
* shape Azure DevOps returns.
|
|
159
|
+
*/
|
|
160
|
+
export declare function linksOnWorkItems<T>(items: readonly T[], describe: (item: T) => LinkedWorkItem, hyperlinks: (item: T) => readonly IssueLink[]): ScannedLink[];
|
|
161
|
+
/**
|
|
162
|
+
* The links that name one particular issue — "who on this board already claims
|
|
163
|
+
* it".
|
|
164
|
+
*
|
|
165
|
+
* Compared on {@link normalizeRepo}, never with `===`: GitHub resolves a
|
|
166
|
+
* repository case-insensitively and echoes back whichever spelling it was
|
|
167
|
+
* created with, so a `Hyperlink` written as `Contoso/Widgets` and a coordinate
|
|
168
|
+
* typed as `contoso/widgets` are one issue and two strings. #787 reported a
|
|
169
|
+
* perfectly good link as half-written over exactly this, and here the same slip
|
|
170
|
+
* would be worse: it would report *no* claim, and a second work item is what
|
|
171
|
+
* follows from that.
|
|
172
|
+
*/
|
|
173
|
+
export declare function claimsOn(links: readonly ScannedLink[], coordinate: {
|
|
174
|
+
repo: string;
|
|
175
|
+
number: number;
|
|
176
|
+
}): ScannedLink[];
|
|
177
|
+
/** The repositories a set of links touches, lower-cased and de-duplicated. */
|
|
178
|
+
export declare function reposOf(links: readonly ScannedLink[]): string[];
|
|
179
|
+
/**
|
|
180
|
+
* How the finished states were decided, so a report can say it.
|
|
181
|
+
*
|
|
182
|
+
* The same argument `pharos query`'s `Openness` makes, for a different purpose:
|
|
183
|
+
* there it filters, here it *classifies*, and a classification is even less
|
|
184
|
+
* interpretable without its basis. "Three closed issues whose work items are
|
|
185
|
+
* still open" means nothing until you know what this project calls finished —
|
|
186
|
+
* and Azure DevOps lets a process template rename every state.
|
|
187
|
+
*/
|
|
188
|
+
export interface FinishedStates {
|
|
189
|
+
/** `categories` read the project. `fallback` guessed and says so. */
|
|
190
|
+
readonly source: "categories" | "fallback";
|
|
191
|
+
/** Terminal state names across every type. */
|
|
192
|
+
readonly terminal: readonly string[];
|
|
193
|
+
/** Per type, which is the only right answer when a name is ambiguous. */
|
|
194
|
+
readonly byType: ReadonlyMap<string, readonly string[]>;
|
|
195
|
+
/** State names that mean finished on one work item type and not on another. */
|
|
196
|
+
readonly ambiguous: readonly string[];
|
|
197
|
+
readonly note: string;
|
|
198
|
+
}
|
|
199
|
+
/** The catalogue as this file needs it, or the stock names with a warning attached. */
|
|
200
|
+
export declare function finishedStates(catalog: StateCatalog | undefined, failure?: string): FinishedStates;
|
|
201
|
+
/**
|
|
202
|
+
* Is this work item finished?
|
|
203
|
+
*
|
|
204
|
+
* **Its own type's list first**, because that is the whole reason `StateCatalog`
|
|
205
|
+
* carries `byType`: `Resolved` can be terminal on a Bug and open on a User
|
|
206
|
+
* Story, and one flat list gets one of them wrong. The flat list is the fallback
|
|
207
|
+
* for a type the catalogue does not know — a work item type added since, or a
|
|
208
|
+
* batch that came back with no `System.WorkItemType` at all.
|
|
209
|
+
*
|
|
210
|
+
* **An unknown state counts as open**, matching `stateCatalog`'s own rule and
|
|
211
|
+
* for its reason: the two mistakes are not equal. Calling a finished work item
|
|
212
|
+
* open puts a row in front of somebody who can dismiss it; calling an open one
|
|
213
|
+
* finished hides drift, which is the one thing this command exists to surface.
|
|
214
|
+
*/
|
|
215
|
+
export declare function isFinished(states: FinishedStates, type: string | null, state: string | null): boolean;
|
|
216
|
+
/**
|
|
217
|
+
* Which state `close` should move a work item of this type into — or why it
|
|
218
|
+
* cannot know.
|
|
219
|
+
*
|
|
220
|
+
* **There is no hardcodable "Closed".** A process template renames every state,
|
|
221
|
+
* so writing `System.State = "Closed"` onto a board that calls it `Done` either
|
|
222
|
+
* fails with a message about a query nobody wrote, or — worse, on a board that
|
|
223
|
+
* has both — succeeds into a state nobody meant. This is the same measurement
|
|
224
|
+
* `pharos query` rests on, used to *act* rather than to filter, which raises the
|
|
225
|
+
* cost of getting it wrong.
|
|
226
|
+
*
|
|
227
|
+
* So the answer is the type's own terminal states, and **ambiguity is refused
|
|
228
|
+
* rather than guessed**: `Done` and `Removed` are both finished and they mean
|
|
229
|
+
* opposite things about the work. `Session.resolveWiki` breaks ties exactly this
|
|
230
|
+
* far and no further, for the same reason.
|
|
231
|
+
*/
|
|
232
|
+
export type TerminalChoice = {
|
|
233
|
+
readonly kind: "one";
|
|
234
|
+
readonly state: string;
|
|
235
|
+
} | {
|
|
236
|
+
readonly kind: "several";
|
|
237
|
+
readonly states: readonly string[];
|
|
238
|
+
} | {
|
|
239
|
+
readonly kind: "none";
|
|
240
|
+
} | {
|
|
241
|
+
readonly kind: "unknown";
|
|
242
|
+
};
|
|
243
|
+
export declare function terminalStateFor(states: FinishedStates, type: string | null): TerminalChoice;
|
|
244
|
+
/** What one issue looks like once it has been read, or why it could not be. */
|
|
245
|
+
export interface IssueSide {
|
|
246
|
+
/** Absent when the issue could not be read at all. */
|
|
247
|
+
readonly issue?: IssueFacts | undefined;
|
|
248
|
+
/**
|
|
249
|
+
* The links this issue's own comments carry, or **absent** when the comments
|
|
250
|
+
* were not read.
|
|
251
|
+
*
|
|
252
|
+
* Absent and empty are different answers and conflating them is the bug: an
|
|
253
|
+
* empty array means "read, and Pharos has never commented here", which is a
|
|
254
|
+
* half-written link; absent means nobody looked, and reporting that as a
|
|
255
|
+
* half-written link would flag every issue whose comments a run skipped. The
|
|
256
|
+
* app track drew the same distinction from the other direction (#787: a
|
|
257
|
+
* missing marker in a lazily-filled mirror is not evidence of a missing
|
|
258
|
+
* marker).
|
|
259
|
+
*/
|
|
260
|
+
readonly markers?: readonly IssueLink[] | undefined;
|
|
261
|
+
/** GitHub answered 404: the issue is gone, transferred, or never existed. */
|
|
262
|
+
readonly missing?: boolean;
|
|
263
|
+
/** Why the read failed, when it did. Prose, for a person. */
|
|
264
|
+
readonly problem?: string | undefined;
|
|
265
|
+
}
|
|
266
|
+
/** An issue, reduced to what a drift row needs. */
|
|
267
|
+
export interface IssueFacts {
|
|
268
|
+
readonly repo: string;
|
|
269
|
+
readonly number: number;
|
|
270
|
+
readonly title: string;
|
|
271
|
+
readonly state: "open" | "closed";
|
|
272
|
+
readonly stateReason: string | null;
|
|
273
|
+
readonly updatedAt: string;
|
|
274
|
+
readonly url: string;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The four ways a link can be wrong, and they are deliberately four rather than
|
|
278
|
+
* one "broken" flag: each wants a different action from whoever reads it.
|
|
279
|
+
*/
|
|
280
|
+
export type DriftKind =
|
|
281
|
+
/** Both ends exist and disagree about whether the work is finished. */
|
|
282
|
+
"state"
|
|
283
|
+
/** The work item names an issue GitHub says is not there. */
|
|
284
|
+
| "missing-issue"
|
|
285
|
+
/** One platform carries the link and the other does not. */
|
|
286
|
+
| "one-sided"
|
|
287
|
+
/** The GitHub end could not be read, so nothing about this pair is known. */
|
|
288
|
+
| "unreadable";
|
|
289
|
+
export type DriftDirection =
|
|
290
|
+
/** Closed on GitHub, still being worked on the board. */
|
|
291
|
+
"issue-closed"
|
|
292
|
+
/** Finished on the board, still open in front of the reporter. */
|
|
293
|
+
| "work-item-finished";
|
|
294
|
+
export interface DriftRow {
|
|
295
|
+
readonly kind: DriftKind;
|
|
296
|
+
readonly direction?: DriftDirection;
|
|
297
|
+
readonly workItem: LinkedWorkItem;
|
|
298
|
+
readonly issue: (IssueFacts & {
|
|
299
|
+
readonly coordinate: string;
|
|
300
|
+
}) | null;
|
|
301
|
+
/** The coordinate as written, even when the issue itself could not be read. */
|
|
302
|
+
readonly coordinate: string;
|
|
303
|
+
readonly evidence: readonly LinkEvidence[];
|
|
304
|
+
/** Which platform is missing its half. Only on `one-sided`. */
|
|
305
|
+
readonly missing?: "hyperlink" | "marker";
|
|
306
|
+
readonly problem?: string;
|
|
307
|
+
/** One sentence a person can act on. */
|
|
308
|
+
readonly detail: string;
|
|
309
|
+
/** A literal command that repairs it, where one exists. */
|
|
310
|
+
readonly recover?: string;
|
|
311
|
+
}
|
|
312
|
+
export interface PairVerdict {
|
|
313
|
+
/** The row, or `undefined` when the pair is healthy. */
|
|
314
|
+
readonly drift?: DriftRow;
|
|
315
|
+
/** True when both ends were read and agree. */
|
|
316
|
+
readonly healthy: boolean;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Judge one link.
|
|
320
|
+
*
|
|
321
|
+
* **Order matters, and it is worst-first.** A pair whose issue cannot be read
|
|
322
|
+
* has no state to compare and no marker to look for, so reporting it as three
|
|
323
|
+
* problems would be reporting one problem three times — and the three rows would
|
|
324
|
+
* not even agree, because two of them would be inferred from an absence. Each
|
|
325
|
+
* link produces at most one row.
|
|
326
|
+
*/
|
|
327
|
+
export declare function judgeLink(link: ScannedLink, side: IssueSide, states: FinishedStates): PairVerdict;
|
|
328
|
+
/** The counts a drift report leads with, so nobody has to tally the rows. */
|
|
329
|
+
export declare function tallyDrift(rows: readonly DriftRow[]): Record<DriftKind, number>;
|
|
330
|
+
export interface BackfillCandidate {
|
|
331
|
+
readonly number: number;
|
|
332
|
+
readonly title: string;
|
|
333
|
+
readonly state: "open" | "closed";
|
|
334
|
+
readonly url: string;
|
|
335
|
+
readonly updatedAt: string;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Which issues in a repository the board has never heard of.
|
|
339
|
+
*
|
|
340
|
+
* `linked` is the set of issue numbers already reached by a `Hyperlink` — the
|
|
341
|
+
* index this file's header argues for, and the reason this costs two Azure
|
|
342
|
+
* DevOps calls instead of one GitHub call per issue.
|
|
343
|
+
*
|
|
344
|
+
* **Ordered by issue number ascending**, which is a decision rather than an
|
|
345
|
+
* accident. The sweep arrives in `updated_at` order, so taking the first `n` of
|
|
346
|
+
* *that* would adopt a different set every run as issues get touched — and a
|
|
347
|
+
* `--limit` that means something different each time is a `--limit` nobody can
|
|
348
|
+
* resume against. Oldest issue first is stable, obvious, and makes a second run
|
|
349
|
+
* carry on where the first stopped.
|
|
350
|
+
*/
|
|
351
|
+
export declare function backfillCandidates(issues: readonly BackfillCandidate[], linked: ReadonlySet<number>): BackfillCandidate[];
|
|
352
|
+
/** The issue numbers already linked from the board, for one repository. */
|
|
353
|
+
export declare function linkedNumbersIn(links: readonly ScannedLink[], repo: string): Set<number>;
|
|
354
|
+
/** How many writes adopting `count` issues costs. One create, one comment, each. */
|
|
355
|
+
export declare const WRITES_PER_ADOPTION = 2;
|
|
356
|
+
/**
|
|
357
|
+
* Run `work` over `items`, at most `limit` at a time, keeping the input order.
|
|
358
|
+
*
|
|
359
|
+
* `drift` reads two endpoints per link and a board with four hundred of them is
|
|
360
|
+
* the case this milestone exists for, so doing them one at a time is minutes of
|
|
361
|
+
* waiting. `Promise.all` over all of them is the other extreme and it is worse:
|
|
362
|
+
* GitHub's secondary rate limit is explicitly about concurrency rather than
|
|
363
|
+
* volume, and tripping it turns a diagnostic into an hour's ban on a token
|
|
364
|
+
* somebody else is also using.
|
|
365
|
+
*
|
|
366
|
+
* Rejections are **not** caught here. A caller that wants a failed read to
|
|
367
|
+
* become a row rather than an exception says so in `work` — which `drift` does,
|
|
368
|
+
* because one unreadable repository must not kill a sweep (#782).
|
|
369
|
+
*/
|
|
370
|
+
export declare function inBatches<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
371
|
+
/** How many issue reads `drift` has in flight at once. */
|
|
372
|
+
export declare const READ_CONCURRENCY = 6;
|
|
373
|
+
/**
|
|
374
|
+
* A WIQL failure that names the field this file introduced.
|
|
375
|
+
*
|
|
376
|
+
* `System.ExternalLinkCount` is queryable on every process template Azure DevOps
|
|
377
|
+
* ships, and this has not been run against a customised one. If a project
|
|
378
|
+
* somehow does not expose it the statement fails to parse, which reads as "drift
|
|
379
|
+
* is broken" — so the error says which clause is suspect and hands over the
|
|
380
|
+
* escape hatch `pharos query` already established.
|
|
381
|
+
*/
|
|
382
|
+
export declare function looksLikeWiqlRejection(message: string): boolean;
|
|
383
|
+
//# sourceMappingURL=issue-scan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"issue-scan.d.ts","sourceRoot":"","sources":["../src/issue-scan.ts"],"names":[],"mappings":"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"}
|