@knowttl/gitea-axi 0.0.0-stage → 0.1.1

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/dist/writes.js ADDED
@@ -0,0 +1,547 @@
1
+ import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
2
+ import { constants, existsSync } from 'node:fs';
3
+ import { mkdir, open, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, isAbsolute, join } from 'node:path';
6
+ import { canonicalPath, collect, effectFor, object, read, validate, validatePayload } from './api.js';
7
+ import { CliError } from './error.js';
8
+ import { readPrivateFile, writePrivateFile } from './private-file.js';
9
+ import { verifyPull } from './pulls.js';
10
+ import { verifyWorkflow } from './repo-workflows.js';
11
+ import { actionValues, valuePlan, verifyValue } from './action-values.js';
12
+ import { verifyUploads } from './attachments.js';
13
+ function canonical(value) {
14
+ if (Array.isArray(value))
15
+ return '[' + value.map(canonical).join(',') + ']';
16
+ if (value && typeof value === 'object')
17
+ return '{' + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => JSON.stringify(key) + ':' + canonical(item)).join(',') + '}';
18
+ return JSON.stringify(value) ?? 'null';
19
+ }
20
+ const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex');
21
+ const usage = (message) => { throw new CliError('USAGE', message, 2); };
22
+ const logPath = () => process.env.GITEA_AXI_WRITE_LOG ?? join(process.platform === 'win32' ? process.env.LOCALAPPDATA ?? homedir() : process.env.XDG_STATE_HOME ?? join(homedir(), '.local/state'), 'gitea-axi', 'writes.log');
23
+ const directory = () => join(dirname(logPath()), 'write-receipts');
24
+ const command = (id, options) => 'gitea-axi write reconcile ' + id +
25
+ (options.config ? ' --config ' + shell(String(options.config)) : '') + (options.profile ? ' --profile ' + shell(String(options.profile)) : '') +
26
+ (options.origin ? ' --origin ' + shell(String(options.origin)) : '') + (options['allow-insecure-http'] ? ' --allow-insecure-http' : '');
27
+ function shell(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
28
+ async function key(root) {
29
+ await mkdir(root, { recursive: true, mode: 0o700 });
30
+ const path = join(root, 'key');
31
+ try {
32
+ const file = await open(path, 'wx', 0o600);
33
+ try {
34
+ await file.writeFile(randomBytes(32).toString('hex'));
35
+ await file.sync();
36
+ }
37
+ finally {
38
+ await file.close();
39
+ }
40
+ }
41
+ catch (error) {
42
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST'))
43
+ throw error;
44
+ }
45
+ return readPrivateFile(path, true);
46
+ }
47
+ export async function reconcile(client, id) {
48
+ if (!/^[a-f0-9-]{36}$/.test(id))
49
+ usage('Select the exact confirmation ID');
50
+ const receipt = JSON.parse(await readPrivateFile(join(directory(), id + '.json'), true));
51
+ if (receipt.origin !== client.origin)
52
+ throw new CliError('CONFIRMATION_MISMATCH', 'Reconciliation origin differs from the attempted write');
53
+ const observations = await observe(client, receipt.reads);
54
+ return { confirmation: id, operations: receipt.operations, states: receipt.states, observations,
55
+ help: 'Inspect the exact identity and requested fields in these reads before authorizing any new attempt' };
56
+ }
57
+ async function observe(client, paths) {
58
+ const observations = [];
59
+ for (const path of paths) {
60
+ if (/\/actions\/(?:secrets|variables)$/.test(path)) {
61
+ observations.push({ path, data: await actionValues(client, path) });
62
+ continue;
63
+ }
64
+ const data = await read(client, path);
65
+ if (Array.isArray(data)) {
66
+ const page = await collect(client, path);
67
+ if (page.truncated)
68
+ throw new CliError('INCOMPLETE_READ', 'Reconciliation requires a complete collection');
69
+ observations.push({ path, data: page.items });
70
+ }
71
+ else
72
+ observations.push({ path, data });
73
+ }
74
+ return observations;
75
+ }
76
+ function verifyNamed(plan, results, observations) {
77
+ const mismatch = () => { throw new CliError('READBACK_MISMATCH', 'Verification did not confirm the requested fields'); };
78
+ const collection = (path) => {
79
+ const data = observations.find(row => row.path.split('?')[0] === path)?.data;
80
+ if (!Array.isArray(data))
81
+ return mismatch();
82
+ return data.map(object);
83
+ };
84
+ const before = object(plan.preconditions);
85
+ const issueFields = (issue) => ({
86
+ title: issue.title, body: issue.body ?? '', state: issue.state,
87
+ assignees: (issue.assignees ?? []).map(row => object(row).login).sort(),
88
+ labels: (issue.labels ?? []).map(row => object(row).id).sort(),
89
+ milestone: issue.milestone == null ? null : object(issue.milestone).id,
90
+ });
91
+ const expectedIssues = new Map();
92
+ for (const [path, value] of Object.entries(before)) {
93
+ if (/\/issues\/\d+$/.test(path) && !plan.steps.some(step => step.path === path && step.method === 'DELETE')) {
94
+ const issue = object(value);
95
+ expectedIssues.set(path, { id: issue.id, number: issue.number, ...issueFields(issue) });
96
+ }
97
+ }
98
+ for (const [index, step] of plan.steps.entries()) {
99
+ if (/\/issues$/.test(step.path) && step.method === 'POST') {
100
+ const result = object(results[index]);
101
+ const actual = collection(step.path).find(row => row.id === result.id && row.number === result.number);
102
+ if (!actual || before[step.path + '?state=all&type=issues'].some(row => row.id === result.id || row.number === result.number))
103
+ mismatch();
104
+ const expected = { title: '', body: '', state: 'open', assignees: [], labels: [], milestone: null, ...object(step.payload) };
105
+ expected.assignees = [...expected.assignees].sort();
106
+ expected.labels = [...expected.labels].sort();
107
+ if (canonical(issueFields(actual)) !== canonical(expected))
108
+ mismatch();
109
+ }
110
+ const expected = expectedIssues.get(step.path);
111
+ if (expected && step.method === 'PATCH')
112
+ Object.assign(expected, object(step.payload));
113
+ const membership = /^(.*\/issues\/\d+)\/labels(?:\/(\d+))?$/.exec(step.path);
114
+ if (membership) {
115
+ const issue = expectedIssues.get(membership[1]);
116
+ const ids = new Set(issue.labels);
117
+ if (step.method === 'POST')
118
+ for (const id of object(step.payload).labels)
119
+ ids.add(id);
120
+ else
121
+ for (const id of ids)
122
+ if (String(id) === membership[2])
123
+ ids.delete(id);
124
+ issue.labels = [...ids];
125
+ }
126
+ }
127
+ for (const [path, expected] of expectedIssues) {
128
+ const actual = object(observations.find(row => row.path === path)?.data);
129
+ const { id, number, ...fields } = expected;
130
+ fields.assignees = [...fields.assignees].sort();
131
+ fields.labels = [...fields.labels].sort();
132
+ if (actual.id !== id || actual.number !== number || canonical(issueFields(actual)) !== canonical(fields))
133
+ mismatch();
134
+ }
135
+ for (const [index, step] of plan.steps.entries()) {
136
+ if (step.upload)
137
+ continue;
138
+ const pin = /^(.*\/issues)\/(\d+)\/pin$/.exec(step.path);
139
+ if (pin) {
140
+ const pins = collection(pin[1] + '/pinned');
141
+ const expected = new Set(object(plan.preconditions)[pin[1] + '/pinned'].map(row => String(row.number)));
142
+ if (step.method === 'POST')
143
+ expected.add(pin[2]);
144
+ else
145
+ expected.delete(pin[2]);
146
+ if (canonical(pins.map(row => String(row.number)).sort()) !== canonical([...expected].sort()))
147
+ mismatch();
148
+ continue;
149
+ }
150
+ const membership = /^(.*\/issues\/\d+)\/labels(?:\/(\d+))?$/.exec(step.path);
151
+ if (membership)
152
+ continue;
153
+ const deletion = /^(.*\/(?:issues|labels))\/(\d+)$/.exec(step.path);
154
+ if (step.method === 'DELETE' && deletion) {
155
+ const field = deletion[1].endsWith('/issues') ? 'number' : 'id';
156
+ const actual = collection(deletion[1]);
157
+ const previous = Object.entries(before).find(([path]) => path.split('?')[0] === deletion[1])?.[1];
158
+ const remaining = previous.filter(row => String(row[field]) !== deletion[2]);
159
+ if (actual.some(row => String(row[field]) === deletion[2]) || actual.length !== remaining.length ||
160
+ remaining.some(row => canonical(actual.find(item => item.id === row.id)) !== canonical(row)))
161
+ mismatch();
162
+ continue;
163
+ }
164
+ if (/\/issues(?:\/\d+)?$/.test(step.path))
165
+ continue;
166
+ const result = object(results[index]);
167
+ const listPath = step.method === 'POST' ? step.path : step.path.replace(/\/\d+$/, '');
168
+ const id = step.method === 'PATCH' ? before[listPath].find(row => String(row.id) === step.path.split('/').at(-1)).id : result.id;
169
+ const actual = collection(listPath).find(row => row.id === id);
170
+ if (!actual || actual.id !== id) {
171
+ mismatch();
172
+ continue;
173
+ }
174
+ for (const [field, value] of Object.entries(object(step.payload))) {
175
+ if (canonical(actual[field]) !== canonical(value))
176
+ mismatch();
177
+ }
178
+ }
179
+ }
180
+ export async function write(client, identity, plan, options, executeLocal) {
181
+ if (process.env.GITEA_AXI_READ_ONLY !== undefined && process.env.GITEA_AXI_READ_ONLY !== 'false')
182
+ throw new CliError('READ_ONLY', 'Host or native read-only policy refuses writes');
183
+ if (Boolean(options.preview) === Boolean(options.confirm))
184
+ usage('Choose exactly --preview or --confirm <confirmation>');
185
+ const localOperation = (step) => {
186
+ if (!executeLocal || !/^gitea\.local\.(?:clone|checkout|revert|composite|stack\.(?:init|add|checkout|push|submit|sync|rebase|continue|abort|link|unstack|merge))$/.test(step.local ?? '')) {
187
+ throw new CliError('READ_ONLY', 'Unclassified local effect');
188
+ }
189
+ if (process.env.GITEA_AXI_HOST_WRITE_OPERATIONS !== undefined || process.env.GITEA_AXI_CREDENTIAL_REVISION !== undefined) {
190
+ throw new CliError('READ_ONLY', 'Workspace effects are standalone only');
191
+ }
192
+ return { operation: step.local };
193
+ };
194
+ const routes = plan.steps.map(step => step.local ? localOperation(step) : validatePayload(step.method, step.path, step.payload));
195
+ const operations = routes.map(route => route.operation);
196
+ const allowed = String(options['allow-write'] ?? '').split(',');
197
+ const hosted = process.env.GITEA_AXI_HOST_WRITE_OPERATIONS;
198
+ for (const operation of operations) {
199
+ if (!allowed.includes(operation) || (hosted !== undefined && !hosted.split(',').includes(operation)) ||
200
+ (identity.writes && (!identity.writes.allowWrites || !identity.writes.operations.includes(operation)))) {
201
+ throw new CliError('READ_ONLY', 'Write policy must explicitly allow ' + operation);
202
+ }
203
+ }
204
+ if (!isAbsolute(logPath()))
205
+ usage('GITEA_AXI_WRITE_LOG must be absolute');
206
+ const root = directory();
207
+ const secret = await key(root);
208
+ const recoveries = [];
209
+ for (const previous of plan.recoverLocal ?? []) {
210
+ if (!/^[a-f0-9-]{36}$/.test(previous) || !plan.steps.every(step => /^gitea\.local\.stack\.(?:continue|abort|sync)$/.test(step.local ?? ''))) {
211
+ throw new CliError('READ_ONLY', 'Only a verified stack rebase recovery can resolve local attempts');
212
+ }
213
+ const receipt = JSON.parse(await readPrivateFile(join(root, previous + '.json'), true));
214
+ if (receipt.origin !== client.origin || receipt.localTarget !== plan.target ||
215
+ !receipt.operations.every(operation => /^gitea\.local\.stack\.(?:rebase|continue|abort|push|sync)$/.test(operation)) ||
216
+ !receipt.localGuards?.every(guard => /^[a-f0-9]{64}$/.test(guard)))
217
+ throw new CliError('READ_ONLY', 'Recovery receipt is not a workspace-bound local rebase');
218
+ recoveries.push({ id: previous, receipt });
219
+ }
220
+ const binding = createHmac('sha256', secret).update(canonical({ identity, revision: process.env.GITEA_AXI_CREDENTIAL_REVISION, origin: client.origin, plan })).digest('hex');
221
+ // Exclude credential revision and preconditions from the attempt key so rotating a credential
222
+ // or changing the remote record cannot bypass an unresolved dispatched effect.
223
+ const effectStep = (step) => {
224
+ if (step.local)
225
+ return { method: step.method, path: step.path, payload: step.payload, local: localOperation(step).operation };
226
+ const route = validatePayload(step.method, step.path, step.payload);
227
+ const segments = canonicalPath(step.path).split('/');
228
+ if (/\{(?:template_)?repo\}/.test(route.path)) {
229
+ for (const [index, segment] of route.path.split('/').entries()) {
230
+ if (/^\{(?:owner|repo|org|template_owner|template_repo)\}$/.test(segment)) {
231
+ segments[index] = encodeURIComponent(decodeURIComponent(segments[index]).toLowerCase());
232
+ }
233
+ }
234
+ }
235
+ return { method: step.method, path: segments.join('/'), ...(step.payload === undefined ? {} : { payload: step.payload }), ...(step.upload ? { upload: step.upload } : {}) };
236
+ };
237
+ const effectSteps = plan.steps.map(effectStep);
238
+ const effectDigest = (value) => plan.sensitive ? createHmac('sha256', secret).update(canonical(value)).digest('hex') : digest(value);
239
+ const attempt = effectDigest({ origin: client.origin, steps: effectSteps });
240
+ const guards = [...new Set(effectSteps.filter((_, index) => !plan.steps[index].createdPull).map(step => join(root, effectDigest({ origin: client.origin, step }) + '.attempt')))];
241
+ for (const guard of guards) {
242
+ if (existsSync(guard)) {
243
+ const previous = await readPrivateFile(guard, true);
244
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'An attempt already exists; reconcile with ' + command(previous, options));
245
+ }
246
+ }
247
+ if (options.preview) {
248
+ const id = randomUUID();
249
+ const receipt = { binding, origin: client.origin, reads: plan.reads, states: plan.steps.map(() => 'pending'), operations, attempt,
250
+ ...(plan.steps.every(step => step.local) ? { localTarget: plan.target, localGuards: guards.map(path => path.slice(root.length + 1, -8)) } : {}) };
251
+ await writePrivateFile(join(root, id + '.json'), JSON.stringify(receipt));
252
+ return { confirmation: id, origin: client.origin, target: plan.target, operations, steps: plan.steps.map(({ upload, ...step }) => ({ ...step, ...(plan.sensitive && step.payload !== undefined ? { payload: '[REDACTED]' } : {}), ...(upload ? { upload: { name: upload.name, type: upload.type, size: upload.size, sha256: upload.sha256 } } : {}) })), preconditions: plan.preconditions,
253
+ ...(plan.notes ? { notes: plan.notes } : {}), help: 'Repeat the same command with --confirm ' + id + ' instead of --preview' };
254
+ }
255
+ const id = String(options.confirm);
256
+ if (!/^[a-f0-9-]{36}$/.test(id))
257
+ usage('Use the confirmation ID returned by --preview');
258
+ const path = join(root, id + '.json');
259
+ const receipt = JSON.parse(await readPrivateFile(path, true));
260
+ if (receipt.states.some(state => state !== 'pending'))
261
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Confirmation was already dispatched; reconcile with ' + command(id, options));
262
+ if (receipt.binding !== binding)
263
+ throw new CliError('CONFIRMATION_MISMATCH', 'Credential revision, origin, target, operation, payload or preconditions changed; preview again');
264
+ const reserved = [];
265
+ const reserve = async (guard) => {
266
+ const file = await open(guard, 'wx', 0o600);
267
+ reserved.push(guard);
268
+ try {
269
+ await file.writeFile(id);
270
+ await file.sync();
271
+ }
272
+ finally {
273
+ await file.close();
274
+ }
275
+ };
276
+ for (const guard of guards) {
277
+ try {
278
+ await reserve(guard);
279
+ }
280
+ catch (error) {
281
+ await Promise.all(reserved.map(path => rm(path)));
282
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
283
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Concurrent attempt refused; reconcile with ' + command(await readPrivateFile(guard, true), options));
284
+ }
285
+ throw error;
286
+ }
287
+ }
288
+ const results = [];
289
+ const outcome = () => 'steps=' + receipt.states.join(',') + (receipt.createdPull ? '; created PR id=' + receipt.createdPull.id + ' number=' + receipt.createdPull.number : '') + (receipt.createdParent ? '; existing parent id=' + receipt.createdParent.id + (receipt.createdParent.number ? ' number=' + receipt.createdParent.number : '') : '') + '; reconcile with ' + command(id, options);
290
+ for (const [index, step] of plan.steps.entries()) {
291
+ try {
292
+ let dispatched = step;
293
+ if (step.assetParent !== undefined) {
294
+ const parent = object(results[step.assetParent]);
295
+ const field = /\/issues\/0\/assets$/.test(step.path) ? 'number' : 'id';
296
+ if (!Number.isSafeInteger(parent[field]) || Number(parent[field]) <= 0)
297
+ throw new CliError('INVALID_RESPONSE', 'Parent creation returned no usable identity');
298
+ dispatched = { ...step, path: step.path.replace('/0/assets', '/' + parent[field] + '/assets') };
299
+ if (validatePayload(dispatched.method, dispatched.path, dispatched.payload).operation !== operations[index])
300
+ throw new CliError('READ_ONLY', 'Resolved attachment operation differs from the approved plan');
301
+ if (!receipt.reads.includes(dispatched.path))
302
+ receipt.reads.push(dispatched.path);
303
+ plan.assets.path = dispatched.path;
304
+ const guard = join(root, effectDigest({ origin: client.origin, step: effectStep(dispatched) }) + '.attempt');
305
+ if (!guards.includes(guard)) {
306
+ await reserve(guard);
307
+ guards.push(guard);
308
+ }
309
+ }
310
+ if (step.createdPull) {
311
+ if (plan.pull?.action !== 'create' || index !== 1 || step.method !== 'POST' || step.path !== plan.target + '/0/requested_reviewers' || !receipt.createdPull)
312
+ throw new CliError('INVALID_RESPONSE', 'Cannot resolve the created PR for reviewer changes');
313
+ dispatched = { method: step.method, path: plan.target + '/' + receipt.createdPull.number + '/requested_reviewers', payload: step.payload };
314
+ if (validatePayload(dispatched.method, dispatched.path, dispatched.payload).operation !== operations[index])
315
+ throw new CliError('READ_ONLY', 'Resolved operation differs from the approved plan');
316
+ const guard = join(root, digest({ origin: client.origin, step: effectStep(dispatched) }) + '.attempt');
317
+ if (!guards.includes(guard)) {
318
+ await reserve(guard);
319
+ guards.push(guard);
320
+ }
321
+ }
322
+ receipt.states[index] = 'dispatched';
323
+ await writePrivateFile(path, JSON.stringify(receipt));
324
+ const journal = await open(logPath(), constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
325
+ try {
326
+ const stat = await journal.stat();
327
+ if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0)
328
+ throw new CliError('CONFIG_INSECURE', 'Write journal must be an owned private file');
329
+ await journal.writeFile(JSON.stringify({ confirmation: id, operation: operations[index], attempt, step: index, state: 'dispatched' }) + '\n');
330
+ await journal.sync();
331
+ }
332
+ finally {
333
+ await journal.close();
334
+ }
335
+ const result = dispatched.local ? await executeLocal(dispatched, results, id) :
336
+ (await client.request(dispatched.path, false, { ...dispatched, ...(plan.sensitive ? { sensitive: true } : {}) })).data;
337
+ results.push(result);
338
+ if (plan.steps.some(step => step.assetParent === index)) {
339
+ const parent = object(result);
340
+ if (!Number.isSafeInteger(parent.id) || Number(parent.id) <= 0)
341
+ throw new CliError('INVALID_RESPONSE', 'Parent creation returned no identity');
342
+ receipt.createdParent = { id: Number(parent.id), ...(typeof parent.number === 'number' ? { number: parent.number } : {}) };
343
+ }
344
+ if (index === 0 && plan.steps.some(step => step.createdPull)) {
345
+ const created = object(result);
346
+ if (!Number.isSafeInteger(created.id) || Number(created.id) <= 0 || !Number.isSafeInteger(created.number) || Number(created.number) <= 0)
347
+ throw new CliError('INVALID_RESPONSE', 'Creation did not return a PR identity');
348
+ validate(plan.target + '/' + created.number, created);
349
+ if (object(plan.preconditions)[plan.target + '?state=all'].some(row => row.id === created.id || row.number === created.number) ||
350
+ object(created.head).ref !== plan.pull.expected.head || object(created.base).ref !== plan.pull.expected.base)
351
+ throw new CliError('INVALID_RESPONSE', 'Creation did not return the intended new PR');
352
+ receipt.createdPull = { id: Number(created.id), number: Number(created.number) };
353
+ receipt.reads = [...receipt.reads, plan.target + '/' + created.number];
354
+ }
355
+ receipt.states[index] = 'completed';
356
+ await writePrivateFile(path, JSON.stringify(receipt));
357
+ }
358
+ catch (error) {
359
+ if (step.local && index === 0 && error instanceof CliError && ['STACK_LOCKED', 'CONFIRMATION_MISMATCH'].includes(error.code)) {
360
+ receipt.states[index] = 'refused';
361
+ await writePrivateFile(path, JSON.stringify(receipt));
362
+ await Promise.all(guards.map(path => rm(path)));
363
+ throw error;
364
+ }
365
+ if (step.local && error instanceof CliError)
366
+ throw new CliError(error.code, error.message + '; ' + outcome());
367
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Write may have applied; ' + outcome());
368
+ }
369
+ }
370
+ let observations;
371
+ try {
372
+ observations = await observe(client, receipt.reads);
373
+ if (plan.pull)
374
+ await verifyPull(client, plan, results, observations);
375
+ if (plan.named)
376
+ verifyNamed(plan, results, observations);
377
+ if (plan.workflow)
378
+ await verifyWorkflow(client, plan, results, observations);
379
+ if (plan.value)
380
+ verifyValue(plan, observations);
381
+ if (plan.assets)
382
+ verifyUploads(plan, results, observations);
383
+ }
384
+ catch {
385
+ throw new CliError('WRITE_OUTCOME_UNKNOWN', 'Write dispatched but verification failed; ' + outcome());
386
+ }
387
+ await Promise.all(guards.map(path => rm(path)));
388
+ for (const { id: previous, receipt: recovered } of recoveries) {
389
+ for (const name of recovered.localGuards) {
390
+ const guard = join(root, name + '.attempt');
391
+ if (existsSync(guard) && await readPrivateFile(guard, true) === previous)
392
+ await rm(guard);
393
+ }
394
+ recovered.states = recovered.states.map(state => state === 'completed' ? state : 'recovered');
395
+ await writePrivateFile(join(root, previous + '.json'), JSON.stringify(recovered));
396
+ }
397
+ return { confirmation: id, states: receipt.states, results, observations, ...(plan.notes ? { notes: plan.notes } : {}), reconcile: command(id, options) };
398
+ }
399
+ export async function rawWritePlan(client, method, path, payload) {
400
+ const route = validatePayload(method, path, payload);
401
+ if (/\/actions\/secrets\//.test(path)) {
402
+ const base = path.slice(0, path.lastIndexOf('/'));
403
+ return valuePlan(client, base, decodeURIComponent(path.slice(path.lastIndexOf('/') + 1)), method === 'DELETE' ? 'delete' : 'set', method === 'PUT' ? String(object(payload).data) : undefined);
404
+ }
405
+ let verification = path.split('?')[0];
406
+ if (method === 'DELETE' || route.operation === 'repoMergePullRequest')
407
+ verification = verification.slice(0, verification.lastIndexOf('/'));
408
+ while (!effectFor('GET', verification) || effectFor('GET', verification)?.effect !== 'read') {
409
+ verification = verification.slice(0, verification.lastIndexOf('/'));
410
+ if (!verification.startsWith('/api/v1/'))
411
+ usage('No metadata reconciliation read is available for this route');
412
+ }
413
+ if (verification.endsWith('/issues'))
414
+ verification += '?state=all&type=issues';
415
+ const preconditions = await observe(client, [verification]);
416
+ return { target: path, steps: [{ method, path, ...(payload === undefined ? {} : { payload }) }], reads: [verification], preconditions };
417
+ }
418
+ const names = (value) => [...new Set(Array.isArray(value) ? value.map(String) : [String(value)])];
419
+ export async function issueLabelPlan(client, operation, base, number, options) {
420
+ const [kind, action] = operation.split(' ');
421
+ const steps = [];
422
+ const reads = new Set();
423
+ const preconditions = {};
424
+ const snapshot = async (path) => {
425
+ const data = await read(client, path);
426
+ preconditions[path] = data;
427
+ reads.add(path);
428
+ return data;
429
+ };
430
+ const fullList = async (path) => {
431
+ const page = await collect(client, path);
432
+ if (page.truncated)
433
+ throw new CliError('INCOMPLETE_READ', 'A complete collection is required before writing');
434
+ preconditions[path] = page.items;
435
+ reads.add(path);
436
+ return page.items;
437
+ };
438
+ const add = (method, path, payload) => steps.push({ method, path, ...(payload === undefined ? {} : { payload }) });
439
+ if (kind === 'label') {
440
+ const labels = await fullList(base + '/labels');
441
+ const selected = action === 'create' ? undefined : labels.find(label => label.name === number || String(label.id) === number);
442
+ if (action !== 'create' && !selected)
443
+ usage('Select an existing exact label name or ID');
444
+ if (action === 'delete' && labels.length < 2)
445
+ usage('Deletion requires a remaining control label');
446
+ const path = base + '/labels' + (selected ? '/' + selected.id : '');
447
+ const payload = {};
448
+ if (options.name !== undefined)
449
+ payload.name = options.name;
450
+ if (action === 'create' && !payload.name)
451
+ usage('--name is required');
452
+ if (options.description !== undefined)
453
+ payload.description = options.description;
454
+ if (options.color !== undefined) {
455
+ const color = String(options.color).replace(/^#/, '').toLowerCase();
456
+ if (!/^[a-f0-9]{6}$/.test(color))
457
+ usage('--color requires six hexadecimal digits');
458
+ payload.color = color;
459
+ }
460
+ if (action === 'create' && !payload.color)
461
+ usage('--color is required');
462
+ if (action === 'edit' && !Object.keys(payload).length)
463
+ usage('Choose a label field to edit');
464
+ add(action === 'delete' ? 'DELETE' : action === 'create' ? 'POST' : 'PATCH', path, action === 'delete' ? undefined : payload);
465
+ }
466
+ else {
467
+ const path = base + '/issues' + (number ? '/' + number : '');
468
+ if (number)
469
+ await snapshot(path);
470
+ else
471
+ await fullList(base + '/issues?state=all&type=issues');
472
+ if (action === 'create' || action === 'edit') {
473
+ const payload = {};
474
+ for (const field of ['title', 'body'])
475
+ if (options[field] !== undefined)
476
+ payload[field] = options[field];
477
+ if (action === 'create' && !payload.title)
478
+ usage('--title is required');
479
+ if (options.assignee !== undefined)
480
+ payload.assignees = names(options.assignee);
481
+ if (options['add-assignee'] !== undefined || options['remove-assignee'] !== undefined) {
482
+ const assignees = new Set((object(preconditions[path]).assignees ?? []).map(value => String(object(value).login)));
483
+ if (options['add-assignee'] !== undefined)
484
+ for (const name of names(options['add-assignee']))
485
+ assignees.add(name);
486
+ if (options['remove-assignee'] !== undefined)
487
+ for (const name of names(options['remove-assignee']))
488
+ assignees.delete(name);
489
+ payload.assignees = [...assignees];
490
+ }
491
+ if (options.milestone !== undefined) {
492
+ const milestones = await fullList(base + '/milestones?state=all');
493
+ const milestone = milestones.find(row => row.title === options.milestone);
494
+ if (!milestone)
495
+ return usage('Unknown exact milestone title');
496
+ payload.milestone = milestone.id;
497
+ }
498
+ const labelIds = async (value) => {
499
+ const labels = await fullList(base + '/labels');
500
+ return names(value).map(name => {
501
+ const label = labels.find(row => row.name === name);
502
+ if (!label)
503
+ return usage('Unknown exact label name: ' + name);
504
+ return label.id;
505
+ });
506
+ };
507
+ if (options.label !== undefined)
508
+ payload.labels = await labelIds(options.label);
509
+ if (options['add-label'] !== undefined)
510
+ add('POST', path + '/labels', { labels: await labelIds(options['add-label']) });
511
+ if (options['remove-label'] !== undefined) {
512
+ await snapshot(path + '/labels');
513
+ for (const id of await labelIds(options['remove-label']))
514
+ add('DELETE', path + '/labels/' + id);
515
+ }
516
+ if (Object.keys(payload).length)
517
+ steps.unshift({ method: action === 'create' ? 'POST' : 'PATCH', path, payload });
518
+ if (!steps.length && !options.attach)
519
+ usage('Choose an issue field to edit');
520
+ }
521
+ else if (action === 'close' || action === 'reopen') {
522
+ add('PATCH', path, { state: action === 'close' ? 'closed' : 'open' });
523
+ if (options.comment !== undefined) {
524
+ await snapshot(path + '/comments');
525
+ add('POST', path + '/comments', { body: options.comment });
526
+ }
527
+ }
528
+ else if (action === 'comment') {
529
+ if (!options.body)
530
+ usage('--body is required');
531
+ await snapshot(path + '/comments');
532
+ add('POST', path + '/comments', { body: options.body });
533
+ }
534
+ else if (action === 'delete') {
535
+ const issues = await fullList(base + '/issues?state=all&type=issues');
536
+ if (!issues.some(issue => String(issue.number) !== number))
537
+ usage('Deletion requires a remaining control issue');
538
+ add('DELETE', path);
539
+ reads.delete(path);
540
+ }
541
+ else {
542
+ await fullList(base + '/issues/pinned');
543
+ add(action === 'pin' ? 'POST' : 'DELETE', path + '/pin');
544
+ }
545
+ }
546
+ return { target: base + '/' + kind + (number ? '/' + number : ''), steps, reads: [...reads], preconditions, named: true };
547
+ }