@dac-tech-team-tw/itsaplan-cli 0.1.0 → 0.2.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 +69 -48
- package/dist/cli.js +203 -88
- package/dist/commands.js +183 -23
- package/dist/config.d.ts +9 -0
- package/dist/config.js +62 -2
- package/dist/issues.d.ts +2 -0
- package/dist/issues.js +97 -6
- package/dist/output.d.ts +12 -0
- package/dist/output.js +260 -0
- package/dist/project-config.d.ts +2 -0
- package/dist/project-config.js +56 -0
- package/package.json +2 -1
package/dist/commands.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { access, lstat, readFile, rename, rm, writeFile, } from "node:fs/promises";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { input, password } from "@inquirer/prompts";
|
|
4
|
+
import { input, password, select } from "@inquirer/prompts";
|
|
5
5
|
import { stringify } from "smol-toml";
|
|
6
6
|
import { API_BASELINE, CLI_VERSION, CliError, } from "./contracts.js";
|
|
7
7
|
import { createContext, getConfigPaths, normalizeApiUrl, readUserConfig, removeAuth, resolveApiUrl, saveAuth, } from "./config.js";
|
|
@@ -247,26 +247,142 @@ export async function runDoctor(options) {
|
|
|
247
247
|
}
|
|
248
248
|
return { data: diagnostics, meta: { apiVersion: diagnostics.serverVersion } };
|
|
249
249
|
}
|
|
250
|
+
function environmentAuthWarning() {
|
|
251
|
+
const endpoint = process.env.ITSAPLAN_URL
|
|
252
|
+
? "後續仍使用 ITSAPLAN_URL 指向的環境憑證,身份可能與已存登入不同。"
|
|
253
|
+
: "缺少 ITSAPLAN_URL,後續未帶 --api-url 的指令仍會失敗(EXPLICIT_ENDPOINT_REQUIRED)。";
|
|
254
|
+
const remedy = process.platform === "win32"
|
|
255
|
+
? "如要使用已存登入,請在目前 shell 移除 ITSAPLAN_API_KEY 與 ITSAPLAN_URL,再執行 iap auth status。"
|
|
256
|
+
: "如要暫時使用已存登入:env -u ITSAPLAN_API_KEY -u ITSAPLAN_URL iap auth status";
|
|
257
|
+
return `ITSAPLAN_API_KEY 仍優先於已儲存的登入。\n${endpoint}\n${remedy}`;
|
|
258
|
+
}
|
|
259
|
+
async function loginEffectiveAuth(apiUrl, user) {
|
|
260
|
+
const effective = {
|
|
261
|
+
source: process.env.ITSAPLAN_API_KEY !== undefined ? "env" : "unresolved",
|
|
262
|
+
apiUrl: null,
|
|
263
|
+
tokenAvailable: process.env.ITSAPLAN_API_KEY !== undefined
|
|
264
|
+
? Boolean(process.env.ITSAPLAN_API_KEY)
|
|
265
|
+
: null,
|
|
266
|
+
authenticated: false,
|
|
267
|
+
usable: null,
|
|
268
|
+
verification: "not_run",
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
// Describe the next unqualified command, not the flags used for this login.
|
|
272
|
+
if (process.env.ITSAPLAN_URL !== undefined)
|
|
273
|
+
effective.apiUrl = normalizeApiUrl(process.env.ITSAPLAN_URL);
|
|
274
|
+
const ctx = await createContext({});
|
|
275
|
+
Object.assign(effective, ctx.auth);
|
|
276
|
+
if (!ctx.auth.tokenAvailable) {
|
|
277
|
+
effective.usable = false;
|
|
278
|
+
effective.error = errorInfo(new CliError("AUTHENTICATION_REQUIRED", "目前生效的 API 服務網址沒有可用的 API key。", 3));
|
|
279
|
+
}
|
|
280
|
+
else if (ctx.auth.source === "config" && ctx.auth.apiUrl === apiUrl) {
|
|
281
|
+
Object.assign(effective, {
|
|
282
|
+
authenticated: true,
|
|
283
|
+
usable: true,
|
|
284
|
+
verification: "passed",
|
|
285
|
+
user,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
effective.usable = false;
|
|
291
|
+
effective.error = errorInfo(error);
|
|
292
|
+
}
|
|
293
|
+
return effective;
|
|
294
|
+
}
|
|
250
295
|
export async function runAuth(action, options) {
|
|
251
296
|
try {
|
|
252
297
|
if (action === "status") {
|
|
253
|
-
const
|
|
298
|
+
const config = await readUserConfig();
|
|
299
|
+
const storage = config.api_url
|
|
300
|
+
? config.credentials?.[config.api_url]?.storage
|
|
301
|
+
: undefined;
|
|
302
|
+
const saved = {
|
|
303
|
+
apiUrl: config.api_url ?? null,
|
|
304
|
+
configured: storage !== undefined,
|
|
305
|
+
storage: storage ?? null,
|
|
306
|
+
tokenAvailable: null,
|
|
307
|
+
authenticated: false,
|
|
308
|
+
verification: "not_run",
|
|
309
|
+
credentials: Object.entries(config.credentials ?? {}).map(([apiUrl, entry]) => ({ apiUrl, storage: entry.storage })),
|
|
310
|
+
};
|
|
311
|
+
const envDefined = process.env.ITSAPLAN_API_KEY !== undefined;
|
|
312
|
+
const effective = {
|
|
313
|
+
source: envDefined
|
|
314
|
+
? "env"
|
|
315
|
+
: options.apiKey !== undefined
|
|
316
|
+
? "flag"
|
|
317
|
+
: storage
|
|
318
|
+
? "config"
|
|
319
|
+
: "missing",
|
|
320
|
+
apiUrl: null,
|
|
321
|
+
tokenAvailable: envDefined
|
|
322
|
+
? Boolean(process.env.ITSAPLAN_API_KEY)
|
|
323
|
+
: options.apiKey !== undefined
|
|
324
|
+
? Boolean(options.apiKey)
|
|
325
|
+
: null,
|
|
326
|
+
authenticated: false,
|
|
327
|
+
usable: false,
|
|
328
|
+
verification: "not_run",
|
|
329
|
+
};
|
|
254
330
|
const status = {
|
|
255
|
-
|
|
331
|
+
source: effective.source,
|
|
332
|
+
apiUrl: null,
|
|
333
|
+
tokenAvailable: effective.tokenAvailable,
|
|
256
334
|
authenticated: false,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
: {}),
|
|
335
|
+
saved,
|
|
336
|
+
effective,
|
|
337
|
+
...(envDefined ? { warning: environmentAuthWarning() } : {}),
|
|
262
338
|
};
|
|
339
|
+
let checkingSaved = false;
|
|
340
|
+
let verificationStarted = false;
|
|
263
341
|
try {
|
|
342
|
+
const explicitUrl = options.apiUrl ?? process.env.ITSAPLAN_URL;
|
|
343
|
+
if (explicitUrl !== undefined)
|
|
344
|
+
effective.apiUrl = normalizeApiUrl(explicitUrl);
|
|
345
|
+
else if (!envDefined && options.apiKey === undefined)
|
|
346
|
+
effective.apiUrl = config.api_url ?? null;
|
|
347
|
+
const ctx = await createContext(options);
|
|
348
|
+
Object.assign(effective, ctx.auth);
|
|
349
|
+
Object.assign(status, ctx.auth);
|
|
350
|
+
checkingSaved =
|
|
351
|
+
ctx.auth.source === "config" && ctx.auth.apiUrl === saved.apiUrl;
|
|
352
|
+
if (!envDefined &&
|
|
353
|
+
ctx.auth.apiUrl === saved.apiUrl &&
|
|
354
|
+
ctx.auth.storage === saved.storage)
|
|
355
|
+
saved.tokenAvailable = ctx.auth.tokenAvailable;
|
|
264
356
|
if (!ctx.auth.tokenAvailable)
|
|
265
|
-
throw new CliError("AUTHENTICATION_REQUIRED", "
|
|
357
|
+
throw new CliError("AUTHENTICATION_REQUIRED", "沒有可用的 API key;請執行 auth login,或為 ITSAPLAN_API_KEY 明確指定 API 服務網址。", 3);
|
|
358
|
+
verificationStarted = true;
|
|
266
359
|
const user = identity(await ctx.client.request("/me"));
|
|
360
|
+
Object.assign(effective, {
|
|
361
|
+
authenticated: true,
|
|
362
|
+
usable: true,
|
|
363
|
+
verification: "passed",
|
|
364
|
+
user,
|
|
365
|
+
});
|
|
366
|
+
if (checkingSaved)
|
|
367
|
+
Object.assign(saved, {
|
|
368
|
+
authenticated: true,
|
|
369
|
+
verification: "passed",
|
|
370
|
+
user,
|
|
371
|
+
});
|
|
267
372
|
return { data: { ...status, authenticated: true, user } };
|
|
268
373
|
}
|
|
269
374
|
catch (error) {
|
|
375
|
+
effective.error = errorInfo(error);
|
|
376
|
+
if (verificationStarted) {
|
|
377
|
+
effective.verification = "failed";
|
|
378
|
+
if (checkingSaved)
|
|
379
|
+
saved.verification = "failed";
|
|
380
|
+
}
|
|
381
|
+
Object.assign(status, {
|
|
382
|
+
source: effective.source,
|
|
383
|
+
apiUrl: effective.apiUrl,
|
|
384
|
+
tokenAvailable: effective.tokenAvailable,
|
|
385
|
+
});
|
|
270
386
|
return {
|
|
271
387
|
data: status,
|
|
272
388
|
error: errorInfo(error),
|
|
@@ -277,7 +393,7 @@ export async function runAuth(action, options) {
|
|
|
277
393
|
if (action === "logout") {
|
|
278
394
|
const apiUrl = await resolveApiUrl(options);
|
|
279
395
|
if (!apiUrl)
|
|
280
|
-
throw new CliError("MISSING_API_URL", "
|
|
396
|
+
throw new CliError("MISSING_API_URL", "請以 --api-url 或 ITSAPLAN_URL 指定 API 服務網址。", 2);
|
|
281
397
|
const removed = await removeAuth(apiUrl);
|
|
282
398
|
return {
|
|
283
399
|
data: {
|
|
@@ -286,34 +402,70 @@ export async function runAuth(action, options) {
|
|
|
286
402
|
...removed,
|
|
287
403
|
serverRevoked: false,
|
|
288
404
|
environmentKeyStillActive: Boolean(process.env.ITSAPLAN_API_KEY),
|
|
289
|
-
warning: "
|
|
405
|
+
warning: "登出只移除本機憑證,不會撤銷伺服器 key 或清除環境變數。",
|
|
290
406
|
},
|
|
291
407
|
outcome: "applied",
|
|
292
408
|
verification: "passed",
|
|
293
409
|
};
|
|
294
410
|
}
|
|
295
411
|
if (action !== "login")
|
|
296
|
-
throw new CliError("INVALID_ACTION", "
|
|
412
|
+
throw new CliError("INVALID_ACTION", "請使用 auth login、status 或 logout。", 2);
|
|
297
413
|
if (options.storage !== undefined &&
|
|
298
414
|
options.storage !== "file" &&
|
|
299
415
|
options.storage !== "keyring")
|
|
300
|
-
throw new CliError("INVALID_STORAGE", "
|
|
416
|
+
throw new CliError("INVALID_STORAGE", "請選擇 --storage file 或 keyring。", 2);
|
|
301
417
|
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
302
418
|
if (!options.withToken && !interactive)
|
|
303
|
-
throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "
|
|
419
|
+
throw new CliError("INTERACTIVE_LOGIN_REQUIRED", "非互動登入須使用 --with-token,並指定可信的 API 服務網址。", 2);
|
|
304
420
|
let apiUrl = await resolveApiUrl(options);
|
|
305
421
|
if (!options.withToken && interactive) {
|
|
306
|
-
const
|
|
422
|
+
const source = options.apiUrl !== undefined
|
|
423
|
+
? "--api-url"
|
|
424
|
+
: process.env.ITSAPLAN_URL !== undefined
|
|
425
|
+
? "ITSAPLAN_URL"
|
|
426
|
+
: "已儲存設定";
|
|
427
|
+
const answer = await input({
|
|
428
|
+
message: `API 服務網址(API 部署網域,非網頁網址;含 https://${apiUrl ? `;預設來自 ${source}` : ""}):`,
|
|
429
|
+
...(apiUrl ? { default: apiUrl } : {}),
|
|
430
|
+
}, { output: process.stderr });
|
|
307
431
|
apiUrl = normalizeApiUrl(answer);
|
|
308
432
|
}
|
|
309
433
|
if (!apiUrl)
|
|
310
|
-
throw new CliError("MISSING_API_URL", "
|
|
434
|
+
throw new CliError("MISSING_API_URL", "非互動登入須以 --api-url 或 ITSAPLAN_URL 指定 API 服務網址。", 2);
|
|
435
|
+
if (!options.withToken &&
|
|
436
|
+
interactive &&
|
|
437
|
+
process.env.ITSAPLAN_API_KEY !== undefined) {
|
|
438
|
+
const proceed = await select({
|
|
439
|
+
message: environmentAuthWarning(),
|
|
440
|
+
choices: [
|
|
441
|
+
{
|
|
442
|
+
name: "取消,不讀取或儲存新的 key",
|
|
443
|
+
value: false,
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "繼續儲存,但不改變環境憑證的優先序",
|
|
447
|
+
value: true,
|
|
448
|
+
},
|
|
449
|
+
],
|
|
450
|
+
default: false,
|
|
451
|
+
}, { output: process.stderr });
|
|
452
|
+
if (!proceed)
|
|
453
|
+
throw new CliError("LOGIN_CANCELLED", "已取消登入;未讀取或儲存 key,也未變更環境設定。", 2);
|
|
454
|
+
}
|
|
311
455
|
const token = (options.withToken
|
|
312
456
|
? await stdinText()
|
|
313
|
-
: await password({ message: "
|
|
457
|
+
: await password({ message: "個人 API key:", mask: "*" }, { output: process.stderr })).trim();
|
|
314
458
|
if (!token || /\s/.test(token))
|
|
315
|
-
throw new CliError("INVALID_TOKEN", "
|
|
316
|
-
|
|
459
|
+
throw new CliError("INVALID_TOKEN", "請提供一組非空白且不含空白字元的 API key。", 2);
|
|
460
|
+
let user;
|
|
461
|
+
try {
|
|
462
|
+
user = identity(await new HttpClient(apiUrl, token).request("/me"));
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (error instanceof CliError && error.code === "REDIRECT_REJECTED")
|
|
466
|
+
throw new CliError(error.code, "此網址回傳重新導向,可能是網頁登入網址,而非 API 服務網址。CLI 不會跟隨重新導向,key 也未轉送。請向管理者確認 API 部署網域,再以 iap auth login --api-url <API_URL> 重試。這不代表 key 無效;同一網頁網址即使手動輸入仍會被拒絕。", error.exitCode, error.details);
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
317
469
|
const storage = options.storage === "file" ? "file" : "keyring";
|
|
318
470
|
await saveAuth(apiUrl, token, storage);
|
|
319
471
|
return {
|
|
@@ -323,10 +475,18 @@ export async function runAuth(action, options) {
|
|
|
323
475
|
authenticated: true,
|
|
324
476
|
storage,
|
|
325
477
|
user,
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
478
|
+
saved: {
|
|
479
|
+
apiUrl,
|
|
480
|
+
configured: true,
|
|
481
|
+
storage,
|
|
482
|
+
tokenAvailable: true,
|
|
483
|
+
authenticated: true,
|
|
484
|
+
verification: "passed",
|
|
485
|
+
user,
|
|
486
|
+
},
|
|
487
|
+
effective: await loginEffectiveAuth(apiUrl, user),
|
|
488
|
+
...(process.env.ITSAPLAN_API_KEY !== undefined
|
|
489
|
+
? { warning: environmentAuthWarning() }
|
|
330
490
|
: {}),
|
|
331
491
|
},
|
|
332
492
|
outcome: "applied",
|
package/dist/config.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface UserConfig {
|
|
|
5
5
|
credentials?: Record<string, {
|
|
6
6
|
storage: Storage;
|
|
7
7
|
}>;
|
|
8
|
+
project_defaults?: Record<string, string>;
|
|
8
9
|
}
|
|
9
10
|
export declare function getConfigPaths(): {
|
|
10
11
|
directory: string;
|
|
@@ -15,6 +16,14 @@ export declare function normalizeApiUrl(input: string): string;
|
|
|
15
16
|
export declare function readUserConfig(): Promise<UserConfig>;
|
|
16
17
|
export declare function resolveApiUrl(options: GlobalOptions): Promise<string | undefined>;
|
|
17
18
|
export declare function resolveProject(options: GlobalOptions): Promise<string | undefined>;
|
|
19
|
+
export interface ProjectSelection {
|
|
20
|
+
projectRef?: string;
|
|
21
|
+
source: "flag" | "env" | "directory" | "user" | "missing";
|
|
22
|
+
}
|
|
23
|
+
export declare function resolveProjectSelection(options: GlobalOptions): Promise<ProjectSelection>;
|
|
24
|
+
export declare function saveUserProject(input: string, projectRef: string | undefined): Promise<{
|
|
25
|
+
changed: boolean;
|
|
26
|
+
}>;
|
|
18
27
|
export declare function saveAuth(input: string, token: string, storage?: Storage): Promise<void>;
|
|
19
28
|
export declare function removeAuth(input: string): Promise<{
|
|
20
29
|
removed: boolean;
|
package/dist/config.js
CHANGED
|
@@ -99,6 +99,20 @@ export async function readUserConfig() {
|
|
|
99
99
|
config.credentials[normalized] = { storage: entry.storage };
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
+
if (raw.project_defaults !== undefined) {
|
|
103
|
+
if (!object(raw.project_defaults))
|
|
104
|
+
throw new CliError("INVALID_CONFIG", "Project defaults must be a table.");
|
|
105
|
+
config.project_defaults = {};
|
|
106
|
+
for (const [url, ref] of Object.entries(raw.project_defaults)) {
|
|
107
|
+
if (typeof ref !== "string" ||
|
|
108
|
+
!/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(ref))
|
|
109
|
+
throw new CliError("INVALID_CONFIG", "Saved project defaults must be full team.KEY refs.");
|
|
110
|
+
const normalized = normalizeApiUrl(url);
|
|
111
|
+
if (Object.hasOwn(config.project_defaults, normalized))
|
|
112
|
+
throw new CliError("INVALID_CONFIG", "Duplicate normalized API project endpoints.");
|
|
113
|
+
config.project_defaults[normalized] = ref;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
102
116
|
return config;
|
|
103
117
|
}
|
|
104
118
|
export async function resolveApiUrl(options) {
|
|
@@ -119,6 +133,9 @@ async function exists(path) {
|
|
|
119
133
|
}
|
|
120
134
|
}
|
|
121
135
|
export async function resolveProject(options) {
|
|
136
|
+
return (await resolveProjectSelection(options)).projectRef;
|
|
137
|
+
}
|
|
138
|
+
export async function resolveProjectSelection(options) {
|
|
122
139
|
const cwd = process.cwd();
|
|
123
140
|
const home = resolve(homedir());
|
|
124
141
|
let boundary = cwd;
|
|
@@ -159,11 +176,25 @@ export async function resolveProject(options) {
|
|
|
159
176
|
break;
|
|
160
177
|
current = dirname(current);
|
|
161
178
|
}
|
|
162
|
-
|
|
179
|
+
let chosen = options.project ?? process.env.ITSAPLAN_PROJECT ?? project;
|
|
180
|
+
let source = options.project !== undefined
|
|
181
|
+
? "flag"
|
|
182
|
+
: process.env.ITSAPLAN_PROJECT !== undefined
|
|
183
|
+
? "env"
|
|
184
|
+
: project !== undefined
|
|
185
|
+
? "directory"
|
|
186
|
+
: "missing";
|
|
187
|
+
if (chosen === undefined) {
|
|
188
|
+
const apiUrl = await resolveApiUrl(options);
|
|
189
|
+
if (apiUrl !== undefined)
|
|
190
|
+
chosen = (await readUserConfig()).project_defaults?.[apiUrl];
|
|
191
|
+
if (chosen !== undefined)
|
|
192
|
+
source = "user";
|
|
193
|
+
}
|
|
163
194
|
if (chosen !== undefined &&
|
|
164
195
|
!/^(?:[A-Za-z0-9_-]+\.)?[A-Za-z][A-Za-z0-9_-]*$/u.test(chosen))
|
|
165
196
|
throw new CliError("INVALID_PROJECT", "Use a project key or team.KEY ref.");
|
|
166
|
-
return chosen;
|
|
197
|
+
return { projectRef: chosen, source };
|
|
167
198
|
}
|
|
168
199
|
async function windowsSid() {
|
|
169
200
|
try {
|
|
@@ -275,6 +306,35 @@ async function atomicWrite(path, content) {
|
|
|
275
306
|
await prepared.discard();
|
|
276
307
|
}
|
|
277
308
|
}
|
|
309
|
+
export async function saveUserProject(input, projectRef) {
|
|
310
|
+
const apiUrl = normalizeApiUrl(input);
|
|
311
|
+
if (projectRef !== undefined &&
|
|
312
|
+
!/^[A-Za-z0-9_-]+\.[A-Za-z][A-Za-z0-9_-]*$/u.test(projectRef))
|
|
313
|
+
throw new CliError("INVALID_PROJECT", "Use a full team.KEY project ref.");
|
|
314
|
+
const config = await readUserConfig();
|
|
315
|
+
if (config.project_defaults?.[apiUrl] === projectRef)
|
|
316
|
+
return { changed: false };
|
|
317
|
+
if (projectRef === undefined) {
|
|
318
|
+
delete config.project_defaults?.[apiUrl];
|
|
319
|
+
if (Object.keys(config.project_defaults ?? {}).length === 0)
|
|
320
|
+
delete config.project_defaults;
|
|
321
|
+
}
|
|
322
|
+
else
|
|
323
|
+
config.project_defaults = {
|
|
324
|
+
...config.project_defaults,
|
|
325
|
+
[apiUrl]: projectRef,
|
|
326
|
+
};
|
|
327
|
+
await prepareDirectory();
|
|
328
|
+
const prepared = await prepareAtomicWrite(getConfigPaths().config, stringify(config));
|
|
329
|
+
try {
|
|
330
|
+
await prepared.commit();
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
await prepared.discard().catch(() => { });
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
return { changed: true };
|
|
337
|
+
}
|
|
278
338
|
async function readCredentials() {
|
|
279
339
|
const { directory, credentials } = getConfigPaths();
|
|
280
340
|
if (!(await exists(credentials)))
|
package/dist/issues.d.ts
CHANGED
package/dist/issues.js
CHANGED
|
@@ -627,9 +627,12 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
627
627
|
throw new CliError("UNSUPPORTED_FILTER_COMBINATION", "--search cannot be combined with --assignee");
|
|
628
628
|
if (options.search !== undefined && !options.search.trim())
|
|
629
629
|
throw new CliError("INVALID_SEARCH", "--search must not be empty");
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
630
|
+
const setup = options.assignee !== undefined || options.includeState
|
|
631
|
+
? await getProject(ctx, ref)
|
|
632
|
+
: undefined;
|
|
633
|
+
const assigneeUserId = options.assignee !== undefined && setup
|
|
634
|
+
? await assignee(ctx, setup, options.assignee)
|
|
635
|
+
: undefined;
|
|
633
636
|
const items = objects(await ctx.client.request(`/projects/${encodeURIComponent(ref)}/issues${options.search !== undefined ? "/search" : ""}`, {
|
|
634
637
|
query: {
|
|
635
638
|
limit,
|
|
@@ -642,14 +645,57 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
642
645
|
typeof entry.identifier !== "string" ||
|
|
643
646
|
typeof entry.title !== "string"))
|
|
644
647
|
unsupported("List response is missing issue identifiers or titles");
|
|
648
|
+
let selectedItems = items;
|
|
649
|
+
if (options.includeState && setup) {
|
|
650
|
+
if (items.some((entry) => !positive(entry.columnId)))
|
|
651
|
+
unsupported("List response is missing issue state identifiers");
|
|
652
|
+
const columns = new Map(setup.columns.map(({ id, name, stateType }) => [
|
|
653
|
+
id,
|
|
654
|
+
{ id, name, stateType },
|
|
655
|
+
]));
|
|
656
|
+
selectedItems = items.map((entry) => ({
|
|
657
|
+
...entry,
|
|
658
|
+
state: columns.get(entry.columnId) ?? {
|
|
659
|
+
id: entry.columnId,
|
|
660
|
+
unavailable: true,
|
|
661
|
+
},
|
|
662
|
+
}));
|
|
663
|
+
}
|
|
645
664
|
return {
|
|
646
|
-
data: { items: publicData(
|
|
647
|
-
meta: {
|
|
665
|
+
data: { items: publicData(selectedItems), nextCursor: null },
|
|
666
|
+
meta: {
|
|
667
|
+
limit,
|
|
668
|
+
returned: items.length,
|
|
669
|
+
completeness: "unknown",
|
|
670
|
+
...(options.outputSelected
|
|
671
|
+
? {
|
|
672
|
+
scope: {
|
|
673
|
+
projectRef: setup?.project.ref ?? ref,
|
|
674
|
+
...(options.search !== undefined
|
|
675
|
+
? { search: options.search }
|
|
676
|
+
: {}),
|
|
677
|
+
...(options.assignee !== undefined
|
|
678
|
+
? { assignee: options.assignee, assigneeUserId }
|
|
679
|
+
: {}),
|
|
680
|
+
},
|
|
681
|
+
}
|
|
682
|
+
: {}),
|
|
683
|
+
},
|
|
648
684
|
};
|
|
649
685
|
}
|
|
650
686
|
if (!["resolve", "view", "activity", "comments"].includes(action))
|
|
651
687
|
throw new CliError("UNKNOWN_ACTION", `Unsupported issue action: ${action}`);
|
|
652
688
|
const { item, ref } = await resolve(ctx, identifier, options);
|
|
689
|
+
const selectedMeta = options.outputSelected
|
|
690
|
+
? {
|
|
691
|
+
scope: {
|
|
692
|
+
issueId: item.id,
|
|
693
|
+
identifier: item.identifier,
|
|
694
|
+
...(ref ? { projectRef: ref } : {}),
|
|
695
|
+
projectId: item.projectId,
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
: {};
|
|
653
699
|
if (action === "resolve")
|
|
654
700
|
return {
|
|
655
701
|
data: publicData({
|
|
@@ -660,6 +706,7 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
660
706
|
sequenceNumber: item.sequenceNumber,
|
|
661
707
|
...(ref ? { projectRef: ref } : {}),
|
|
662
708
|
}),
|
|
709
|
+
...(options.outputSelected ? { meta: selectedMeta } : {}),
|
|
663
710
|
};
|
|
664
711
|
if (action === "activity" || action === "comments") {
|
|
665
712
|
const limit = options.limit === undefined ? 25 : integer(options.limit, "--limit", 100);
|
|
@@ -673,10 +720,27 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
673
720
|
limit,
|
|
674
721
|
returned: page.items.length,
|
|
675
722
|
completeness: page.nextCursor === null ? "complete" : "partial",
|
|
723
|
+
...selectedMeta,
|
|
724
|
+
...(options.outputSelected
|
|
725
|
+
? {
|
|
726
|
+
read: {
|
|
727
|
+
entries: {
|
|
728
|
+
status: "read_page",
|
|
729
|
+
contentRead: true,
|
|
730
|
+
filter: action === "comments" ? "comments" : "all",
|
|
731
|
+
cursor: options.cursor ?? null,
|
|
732
|
+
nextCursor: encodeCursor(page.nextCursor),
|
|
733
|
+
},
|
|
734
|
+
attachments: { status: "not_read", contentRead: false },
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
: {}),
|
|
676
738
|
},
|
|
677
739
|
};
|
|
678
740
|
}
|
|
679
741
|
const setup = await setupFor(ctx, item, ref);
|
|
742
|
+
if (selectedMeta.scope)
|
|
743
|
+
selectedMeta.scope.projectRef = String(setup.project.ref);
|
|
680
744
|
const attachments = objects(await ctx.client.request(`/issues/${item.id}/attachments`), "attachments");
|
|
681
745
|
if (attachments.some((entry) => typeof entry.id !== "string" ||
|
|
682
746
|
typeof entry.filename !== "string" ||
|
|
@@ -685,6 +749,18 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
685
749
|
unsupported("Invalid attachment metadata");
|
|
686
750
|
const data = object(enriched(item, setup), "enriched issue");
|
|
687
751
|
data.attachments = publicData(attachments);
|
|
752
|
+
const read = options.outputSelected
|
|
753
|
+
? {
|
|
754
|
+
read: {
|
|
755
|
+
comments: { status: "not_read" },
|
|
756
|
+
attachments: {
|
|
757
|
+
status: "metadata_only",
|
|
758
|
+
returned: attachments.length,
|
|
759
|
+
contentRead: false,
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
: {};
|
|
688
764
|
if (options.comments) {
|
|
689
765
|
const page = await feed(ctx, item.id, 25, undefined, true);
|
|
690
766
|
data.comments = {
|
|
@@ -694,16 +770,31 @@ export async function runIssue(ctx, action, identifier, options) {
|
|
|
694
770
|
return {
|
|
695
771
|
data,
|
|
696
772
|
meta: {
|
|
773
|
+
...selectedMeta,
|
|
697
774
|
comments: {
|
|
698
775
|
limit: 25,
|
|
699
776
|
returned: page.items.length,
|
|
700
777
|
completeness: page.nextCursor === null ? "complete" : "partial",
|
|
701
778
|
},
|
|
702
779
|
attachmentsContentRead: false,
|
|
780
|
+
...(options.outputSelected
|
|
781
|
+
? {
|
|
782
|
+
read: {
|
|
783
|
+
...read.read,
|
|
784
|
+
comments: {
|
|
785
|
+
status: "read_page",
|
|
786
|
+
nextCursor: encodeCursor(page.nextCursor),
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
: {}),
|
|
703
791
|
},
|
|
704
792
|
};
|
|
705
793
|
}
|
|
706
|
-
return {
|
|
794
|
+
return {
|
|
795
|
+
data,
|
|
796
|
+
meta: { attachmentsContentRead: false, ...selectedMeta, ...read },
|
|
797
|
+
};
|
|
707
798
|
}
|
|
708
799
|
// Shared resource workflows use the same identity, scope, input and outcome rules.
|
|
709
800
|
export { actorFor, readBody, me, integer, object, objects, positive, projectRef, publicData, getProject, resolve, setupFor, writeFailure, verificationFailed, };
|
package/dist/output.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Result } from "./contracts.js";
|
|
2
|
+
export type OutputKind = "project.list" | "project.view" | "issue.list" | "issue.view" | "issue.resolve" | "issue.comments" | "issue.activity";
|
|
3
|
+
export interface OutputOptions {
|
|
4
|
+
summary?: boolean;
|
|
5
|
+
fields?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Validate against command schemas even when the eventual result is empty. */
|
|
8
|
+
export declare function validateOutput(kind: OutputKind, options: OutputOptions): void;
|
|
9
|
+
/** Only opt-in issue list state output needs project state lookup enrichment. */
|
|
10
|
+
export declare function needsIssueStates(kind: OutputKind, options: OutputOptions): boolean;
|
|
11
|
+
/** Shape data only: serialization, redaction and fetching belong to callers. */
|
|
12
|
+
export declare function projectOutput(kind: OutputKind, result: Result, options: OutputOptions): Result;
|