@dac-tech-team-tw/itsaplan-cli 0.2.0 → 0.3.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 +29 -5
- package/dist/cli.js +67 -2
- package/dist/commands.js +9 -2
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/initiatives.d.ts +16 -0
- package/dist/initiatives.js +241 -0
- package/dist/output.js +1 -0
- package/dist/writes.d.ts +3 -3
- package/dist/writes.js +6 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# itsaplan-cli
|
|
2
2
|
|
|
3
|
-
It's a Plan 的獨立 CLI,入口為 `iap` 與 `itsaplan`。Node.js 22+;API 基線 1.9.0
|
|
3
|
+
It's a Plan 的獨立 CLI,入口為 `iap` 與 `itsaplan`。Node.js 22+;API 基線 1.10.0,現行規格為 [OpenAPI 1.10.0](its-a-plan-api-v1.10.0.yaml),[1.9.0](its-a-plan-api-1.yaml) 僅供歷史比較。公開 npm 套件為 `@dac-tech-team-tw/itsaplan-cli`,原始碼維持 private;目前採本機手動發布,GitHub Actions 負責 CI。
|
|
4
4
|
|
|
5
5
|
## 安裝
|
|
6
6
|
|
|
@@ -109,6 +109,8 @@ iap issue view MKT-42 --project acme.MKT --fields identifier,title,priority --js
|
|
|
109
109
|
|
|
110
110
|
`--fields` 為資料相對點路徑:list/feed 作用於每個 item,view/resolve 作用於 data;巢狀陣列維持原形,例如 `columns.id,columns.name`。欄位名稱依各命令的固定 schema 驗證,空集合仍能抓到錯字;未知或不安全路徑於 API 請求前拒絕。整個已知欄位(如 `customFields`)可直接選取;未支援的新 server 欄位請用完整輸出。`meta.output` 標明模式、選取欄位(適用)與 `missing` 路徑:已選但 server 未提供的欄位不補 null;null 保留原意,未選欄位不出現在資料中。
|
|
111
111
|
|
|
112
|
+
Issue 詳情完整輸出保留伺服器提供的 `shareExpiresAt`;它是分享設定的到期時間,不是憑證。既有 `shareToken`/`shareExtended` 仍依輸出策略移除。
|
|
113
|
+
|
|
112
114
|
選取不會移除 envelope、分頁游標及既有 meta。Issue list 的 `meta.scope` 描述本次專案與搜尋/負責人範圍;取得 setup 時使用其 canonical ref,否則保留請求 ref。其餘 issue 讀取的 scope 保留 issueId、identifier、projectId 及可得 projectRef;view 即使只指定 `--id`,也保留後續取得的 canonical projectRef。Issue view 的 `meta.read.comments` 區分 `not_read` 與 `read_page`,後者另附 nextCursor;其 limit、returned、completeness 統一讀取 `meta.comments`。附件為 `metadata_only`、`contentRead:false`。Comments/activity 的 `meta.read.entries` 描述 status、contentRead、filter 與輸入/下一頁游標,筆數與完整性統一讀取既有 `meta.limit/returned/completeness`,其 attachments 為 `not_read`。`--fields` 不能移除這些讀取範圍標記。`nextCursor:null` 仍不代表 issue list 完整。
|
|
113
115
|
|
|
114
116
|
Issue list 摘要或選取 `state`/`state.*`,會共用最多一次額外 project setup GET;搭配負責人篩選則重用既有 setup。List 的 state 僅支援 id/name/stateType/unavailable;color、WIP 等完整欄位請用 project view 或 issue view 查詢。未知 columnId 保留 ID 並標示 `unavailable:true`,不猜測狀態;即使只選 `state.name`,仍保留診斷用 id/unavailable,且將 name 標為 missing。只選其他欄位不增加這項請求。投影不改變其他既有讀取流程,不代表減少 HTTP payload,也不新增持久快取。多票共用同一份已讀 workflow 上下文即可;修改前仍須讀取最新相關資訊,既有寫入 preflight 不使用該上下文取代查證。
|
|
@@ -134,6 +136,21 @@ Edit 支援 title、body、state/state-type/column-id、assignee/remove-assignee
|
|
|
134
136
|
|
|
135
137
|
Dry-run 可以讀取驗證但不寫入,顯示身份、request 與 diff;權限/WIP 等仍以真正寫入為準。正常寫入不再詢問 y/n。寫入不重試;timeout/5xx/回應損壞可能已套用,須先讀 issue/feed 查證,不能直接重送。
|
|
136
138
|
|
|
139
|
+
## Initiative 管理
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
iap initiative list --project acme.MKT --status active --page 1 --limit 25 --json
|
|
143
|
+
iap initiative view 301 --project acme.MKT --json
|
|
144
|
+
iap initiative create --project acme.MKT --title '產品規劃' --body-file plan.md --owner @me --agent codex --dry-run --json
|
|
145
|
+
printf '更新後的規劃\n' | iap initiative edit 301 --project acme.MKT --body-file - --add-label Roadmap --agent codex --dry-run --json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Initiative 使用內部正整數 ID,不是 `KEY-42`。List/create 依既有專案來源優先序解析專案;view/edit 核對選定專案的 numeric projectId。List 使用伺服器原生 `--page` 與 `--limit`(1–100,預設 25),支援 `--status`、`--search`、`--sort`、`--dir`;結果保留 `total`、`page`、`pageSize`、`nextPage`,依 `nextPage` 繼續查詢,不提供 cursor 或 `--all`。`--summary`/`--fields` 不支援 initiative。
|
|
149
|
+
|
|
150
|
+
Create/edit 支援 title、description、status、owner、priority、startDate、targetDate 與 labels;`--body` 與 `--body-file` 互斥,`--body-file -` 明確讀 stdin。Owner 可使用 `@me`;owner、priority 與日期可用對應 `--remove-*` 清空,不能同時設定與清空。Edit 的 `--add-label`/`--remove-label` 保留既有集合,但讀改寫並非原子操作。名稱必須精確且不歧義。
|
|
151
|
+
|
|
152
|
+
所有寫入及 dry-run 均要求 actor。只有明確提供 description 才處理署名;未提供 description 的 create 不會產生只有署名的正文。Dry-run 只讀,列出 identity、request 與適用 diff;實際權限由伺服器裁定。寫入不自動重送;`unknown` 或 `applied/failed` 時先查證並保留已取得的 initiativeId,同名 initiative 不足以證明建立成功。不提供 delete、活動、附件或跨資源關聯操作。
|
|
153
|
+
|
|
137
154
|
## 文件發布與 ticket 關聯
|
|
138
155
|
|
|
139
156
|
```sh
|
|
@@ -181,23 +198,25 @@ iap api /projects/acme.MKT --method GET --json
|
|
|
181
198
|
iap api /some-resource --method POST --input payload.json --dry-run --json
|
|
182
199
|
```
|
|
183
200
|
|
|
184
|
-
共用可信 endpoint、認證、redaction 與結果契約。禁止 raw 寫入 `/issues`、`/projects/{project}/issues` 及其子路徑,也禁止 `/comments`、`/checklists`、`/worklogs`、`/attachments` 等 issue 子資源寫入;包含 move、archive、fields 與 bulk labels。另禁止 `/projects/{project}/documents` 及其子路徑的 raw
|
|
201
|
+
共用可信 endpoint、認證、redaction 與結果契約。禁止 raw 寫入 `/issues`、`/projects/{project}/issues` 及其子路徑,也禁止 `/comments`、`/checklists`、`/worklogs`、`/attachments` 等 issue 子資源寫入;包含 move、archive、fields 與 bulk labels。另禁止 `/projects/{project}/documents`、`/projects/{project}/initiatives`、`/initiatives`、`/initiative-attachments` 及其子路徑的 raw 寫入,避免繞過來源宣告。請走高階指令;尚未提供的上述 mutation 暫不支援,GET/HEAD 仍可用。其他 raw write 不保證 agent footer,僅在使用者明確授權該操作時執行。
|
|
185
202
|
|
|
186
203
|
## JSON 契約
|
|
187
204
|
|
|
188
205
|
`--json` 可放在指令前後;stdout 恰一個 JSON 文件,診斷走 stderr。所有家族用 CLI envelope,非直接 API pass-through;raw `data` 是移除敏感欄位後的 API 回應。
|
|
189
206
|
|
|
190
207
|
```json
|
|
191
|
-
{"ok":true,"data":{"id":123,"identifier":"MKT-42"},"meta":{"schemaVersion":1,"apiVersion":"1.
|
|
208
|
+
{"ok":true,"data":{"id":123,"identifier":"MKT-42"},"meta":{"schemaVersion":1,"apiVersion":"1.10.0"}}
|
|
192
209
|
```
|
|
193
210
|
|
|
194
|
-
|
|
211
|
+
一般列表 `data` 為 `{ "items": [], "nextCursor": null }`,`meta` 包含 returned、completeness 及適用的 limit。PR 與 initiative 清單使用真實 `nextPage`,不提供假 cursor;`null` cursor 不等於所有列表皆完整。doctor/auth/config/init/project/issue/initiative/document/api 全部遵循相同 envelope;上方各家族命令可直接加 `--json`。
|
|
212
|
+
|
|
213
|
+
一般命令的 `meta.apiVersion` 是 CLI 契約基線,不是部署版本探測;doctor 成功取得版本時則回報 server 實際版本。Doctor 的 `baselineStatus: "tested"` 僅表示版本與基線相符(允許 `v` 前綴),不代表全部 API 均已實測;不同版本標記 `unverified`,不因此拒絕連線。
|
|
195
214
|
|
|
196
215
|
```json
|
|
197
216
|
{"ok":false,"error":{"code":"ACTOR_REQUIRED","message":"Provide --agent or --human","retryable":false},"outcome":"not_applied","verification":"not_run","data":null,"meta":{"schemaVersion":1}}
|
|
198
217
|
```
|
|
199
218
|
|
|
200
|
-
寫入根層 `outcome`:applied/not_applied/unknown;`verification`:passed/failed/not_run。Dry-run 成功是 not_applied/not_run。已知寫入但驗證失敗為 applied/failed、`ok:false` 並保留已取得 ID。未知結果為 unknown/not_run,applied/unknown 的 retryable 永遠 false。寫入 `data` 回報 action、issueId、commentId/documentId/linkId(適用)、changed 與可得 updatedAt;預覽另有 dryRun、request 及適用的 diff,`meta.actor` 記錄來源。
|
|
219
|
+
寫入根層 `outcome`:applied/not_applied/unknown;`verification`:passed/failed/not_run。Dry-run 成功是 not_applied/not_run。已知寫入但驗證失敗為 applied/failed、`ok:false` 並保留已取得 ID。未知結果為 unknown/not_run,applied/unknown 的 retryable 永遠 false。寫入 `data` 回報 action、issueId、initiativeId、commentId/documentId/linkId(適用)、changed 與可得 updatedAt;預覽另有 dryRun、request 及適用的 diff,`meta.actor` 記錄來源。
|
|
201
220
|
|
|
202
221
|
Raw 與高階 issue 寫入共用失敗分類,不以其他 HTTP status 推翻 transport 明確回報的 `unknown`。例如 410/412 未被判定為確定拒絕時,維持 `unknown`/exit 6,而不是同時宣稱未套用。
|
|
203
222
|
|
|
@@ -225,6 +244,11 @@ npm pack --dry-run --json
|
|
|
225
244
|
|
|
226
245
|
### 已執行驗證
|
|
227
246
|
|
|
247
|
+
- #17:build、型別檢查與 37 個 initiative/raw API 相關測試通過;本機 HTTP fixture 實際執行 list、view、create 預覽、create、edit、view,並驗證 stdin 與預覽不寫入。未呼叫正式 API 或發布 npm 套件。
|
|
248
|
+
- PR #19 第一輪 review 修正:`pnpm build` 與 `pnpm exec tsx --test test/cli.test.ts test/initiatives-cli.test.ts` 通過(4 個測試)。實際 CLI 驗證缺少 edit ID、create 未知選項會回報 `not_applied/not_run`,list/view 的輸入錯誤不標成寫入;無需 API 即可執行該 regression。
|
|
249
|
+
|
|
250
|
+
- #16:build、型別檢查與 88 個 doctor/output/issues/實際 CLI 相關測試通過。本機 HTTP fixture 驗證 doctor 的 server 版本、list/search 的 parent 投影及 view 的 `shareExpiresAt`;`npm pack --dry-run --ignore-scripts --json` 僅包含 README、package.json 與 dist。未呼叫正式 API 或發布 npm 套件。
|
|
251
|
+
|
|
228
252
|
- #11/#12(含 PR #15 review 修正):build、型別檢查與 63 個相關 output/issue/實際 CLI 測試通過,涵蓋 internal ID view 的 canonical 專案範圍、list state 欄位驗證與未知狀態巢狀投影診斷。Linux 本機 HTTP fixture 的相同雙票流程(compact JSON,含 LF)完整輸出累計 28531 bytes、11 次 API/5 次 CLI;分階段輸出 18825 bytes、12 次 API/5 次 CLI。正文、自訂欄位、checklists、討論及附件 metadata 保持一致;多一次 API 為搜尋可讀狀態的 setup GET。未量測 tokenizer/model token,未呼叫正式 API 或發布 npm 套件。
|
|
229
253
|
|
|
230
254
|
- #10 help 繁體中文化:打包安裝檢查不再依賴英文 `Usage:` 文案;Linux 隔離安裝 tarball 後,`iap`/`itsaplan` 的 help、version 與無認證 doctor JSON 驗證通過,未發布 npm 套件或呼叫正式 API。
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runIssue } from "./issues.js";
|
|
|
7
7
|
import { runDoctor, runAuth, runProject, runInit, runApi } from "./commands.js";
|
|
8
8
|
import { runDocument, runIssueDocument, } from "./documents.js";
|
|
9
9
|
import { runDevelopment } from "./development.js";
|
|
10
|
+
import { runInitiative } from "./initiatives.js";
|
|
10
11
|
import { runProjectConfig } from "./project-config.js";
|
|
11
12
|
import { validateOutput, projectOutput, needsIssueStates, } from "./output.js";
|
|
12
13
|
const program = new Command()
|
|
@@ -96,7 +97,8 @@ const wrap = (handler) => async (...args) => {
|
|
|
96
97
|
"logout",
|
|
97
98
|
"init",
|
|
98
99
|
].includes(command.name()) ||
|
|
99
|
-
(
|
|
100
|
+
(["document", "initiative"].includes(command.parent?.name() ?? "") &&
|
|
101
|
+
command.name() === "create") ||
|
|
100
102
|
(command.name() === "project" &&
|
|
101
103
|
["set", "unset"].includes(command.parent?.name() ?? "") &&
|
|
102
104
|
command.parent?.parent?.name() === "config") ||
|
|
@@ -295,6 +297,67 @@ issue
|
|
|
295
297
|
project: globals(c).project,
|
|
296
298
|
...issueOutputOptions(c),
|
|
297
299
|
})));
|
|
300
|
+
const initiative = program
|
|
301
|
+
.command("initiative")
|
|
302
|
+
.description("列出、檢視、建立與編輯專案計畫");
|
|
303
|
+
initiative
|
|
304
|
+
.command("list")
|
|
305
|
+
.description("列出專案計畫;使用頁碼瀏覽結果")
|
|
306
|
+
.option("--page <number>", "頁碼,從 1 開始")
|
|
307
|
+
.option("--limit <number>", "每頁數量,範圍為 1–100")
|
|
308
|
+
.option("--status <statuses>", "依狀態篩選,多個狀態以逗號分隔")
|
|
309
|
+
.option("--search <text>", "搜尋標題")
|
|
310
|
+
.option("--sort <field>", "排序欄位:title、priority、targetDate、owner")
|
|
311
|
+
.option("--dir <direction>", "排序方向:asc 或 desc")
|
|
312
|
+
.addHelpText("after", `
|
|
313
|
+
範例使用虛構的專案參照:
|
|
314
|
+
iap initiative list --project acme.MKT --status active --page 1 --json`)
|
|
315
|
+
.action(wrap(async (c) => runInitiative(await createContext(globals(c)), "list", undefined, {
|
|
316
|
+
...c.opts(),
|
|
317
|
+
project: globals(c).project,
|
|
318
|
+
})));
|
|
319
|
+
initiative
|
|
320
|
+
.command("view <initiative-id>")
|
|
321
|
+
.description("依內部數值 ID 檢視計畫")
|
|
322
|
+
.addHelpText("after", `
|
|
323
|
+
範例使用虛構 ID:
|
|
324
|
+
iap initiative view 123 --project acme.MKT --json`)
|
|
325
|
+
.action(wrap(async (c) => runInitiative(await createContext(globals(c)), "view", c.args[0], {
|
|
326
|
+
project: globals(c).project,
|
|
327
|
+
})));
|
|
328
|
+
for (const action of ["create", "edit"]) {
|
|
329
|
+
const command = initiative
|
|
330
|
+
.command(action === "create" ? "create" : "edit <initiative-id>")
|
|
331
|
+
.description(action === "create" ? "建立專案計畫" : "只更新明確指定的計畫欄位")
|
|
332
|
+
.option("--title <text>", "計畫標題")
|
|
333
|
+
.option("--body <text>", "Markdown 內容")
|
|
334
|
+
.option("--body-file <file|->", "UTF-8 Markdown 檔案,或明確指定標準輸入 -")
|
|
335
|
+
.option("--status <status>", "計畫狀態:proposed、planned、active、completed、canceled")
|
|
336
|
+
.option("--owner <username|@me>", "指派專案成員為負責人")
|
|
337
|
+
.option("--priority <priority>", "優先級:urgent、high、medium、low")
|
|
338
|
+
.option("--start-date <date>", "開始日期,格式為 YYYY-MM-DD")
|
|
339
|
+
.option("--target-date <date>", "目標日期,格式為 YYYY-MM-DD")
|
|
340
|
+
.option("--agent <name>", "宣告代理:codex 或 claude")
|
|
341
|
+
.option("--human", "明確宣告人工作業;不可與代理環境變數並用")
|
|
342
|
+
.option("--dry-run", "只讀取與驗證;顯示確切請求但不寫入");
|
|
343
|
+
if (action === "create")
|
|
344
|
+
command.option("--label <name>", "加入精確標籤,可重複指定", (value, previous) => [...previous, value], []);
|
|
345
|
+
else
|
|
346
|
+
command
|
|
347
|
+
.option("--remove-owner", "清除負責人")
|
|
348
|
+
.option("--remove-priority", "清除優先級")
|
|
349
|
+
.option("--remove-start-date", "清除開始日期")
|
|
350
|
+
.option("--remove-target-date", "清除目標日期")
|
|
351
|
+
.option("--add-label <name>", "加入精確標籤,可重複指定", (value, previous) => [...previous, value], [])
|
|
352
|
+
.option("--remove-label <name>", "移除精確標籤,可重複指定", (value, previous) => [...previous, value], []);
|
|
353
|
+
command.addHelpText("after", action === "create"
|
|
354
|
+
? "\n範例使用虛構資料:\n iap initiative create --project acme.MKT --title 計畫 --human --dry-run"
|
|
355
|
+
: "\n範例使用虛構 ID:\n iap initiative edit 123 --project acme.MKT --status active --human --dry-run");
|
|
356
|
+
command.action(wrap(async (c) => runInitiative(await createContext(globals(c)), action, c.args[0], {
|
|
357
|
+
...c.opts(),
|
|
358
|
+
project: globals(c).project,
|
|
359
|
+
})));
|
|
360
|
+
}
|
|
298
361
|
const document = program
|
|
299
362
|
.command("document")
|
|
300
363
|
.description("發布 Markdown 文件;專案可見不等於網際網路公開");
|
|
@@ -379,7 +442,7 @@ for (const action of [
|
|
|
379
442
|
}
|
|
380
443
|
program
|
|
381
444
|
.command("api <relative-path>")
|
|
382
|
-
.description("原始 API
|
|
445
|
+
.description("原始 API 入口;工作項目、文件與計畫寫入必須使用高階指令")
|
|
383
446
|
.addOption(new Option("--method <method>", "HTTP 方法")
|
|
384
447
|
.choices(["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"])
|
|
385
448
|
.default("GET"))
|
|
@@ -409,6 +472,8 @@ catch (error) {
|
|
|
409
472
|
((words.includes("issue") &&
|
|
410
473
|
["edit", "comment", "link-document", "link-pr"].some((action) => words.includes(action))) ||
|
|
411
474
|
(words.includes("document") && words.includes("create")) ||
|
|
475
|
+
(words.includes("initiative") &&
|
|
476
|
+
["create", "edit"].includes(words.slice(words.indexOf("initiative") + 1).find((word) => ["list", "view", "create", "edit"].includes(word)) ?? "")) ||
|
|
412
477
|
words.includes("init") ||
|
|
413
478
|
(words.includes("auth") &&
|
|
414
479
|
(words.includes("login") || words.includes("logout"))) ||
|
package/dist/commands.js
CHANGED
|
@@ -589,6 +589,9 @@ function protectedResourceWrite(path) {
|
|
|
589
589
|
return "issue";
|
|
590
590
|
if (/^\/projects\/[^/]+\/documents(?:\/|$)/i.test(pathname))
|
|
591
591
|
return "document";
|
|
592
|
+
if (/^\/(?:initiatives|initiative-attachments)(?:\/|$)/i.test(pathname) ||
|
|
593
|
+
/^\/projects\/[^/]+\/initiatives(?:\/|$)/i.test(pathname))
|
|
594
|
+
return "initiative";
|
|
592
595
|
return undefined;
|
|
593
596
|
}
|
|
594
597
|
export async function runApi(ctx, path, options) {
|
|
@@ -603,9 +606,13 @@ export async function runApi(ctx, path, options) {
|
|
|
603
606
|
if (resource)
|
|
604
607
|
throw new CliError(resource === "issue"
|
|
605
608
|
? "RAW_ISSUE_WRITE_FORBIDDEN"
|
|
606
|
-
:
|
|
609
|
+
: resource === "document"
|
|
610
|
+
? "RAW_DOCUMENT_WRITE_FORBIDDEN"
|
|
611
|
+
: "RAW_INITIATIVE_WRITE_FORBIDDEN", resource === "issue"
|
|
607
612
|
? "Raw writes to issues and their child resources are disabled; use supported issue commands. Other issue mutations are not yet supported."
|
|
608
|
-
:
|
|
613
|
+
: resource === "document"
|
|
614
|
+
? "Raw Docs writes are disabled; use supported document and issue link-document commands. Other Docs mutations are not yet supported."
|
|
615
|
+
: "Raw initiative writes are disabled; use supported initiative commands. Other initiative mutations are not yet supported.", 2);
|
|
609
616
|
if (!write && options.input !== undefined)
|
|
610
617
|
throw new CliError("INPUT_NOT_SUPPORTED", "GET and HEAD do not accept --input.", 2);
|
|
611
618
|
let body;
|
package/dist/contracts.d.ts
CHANGED
package/dist/contracts.js
CHANGED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Context, type Result } from "./contracts.js";
|
|
2
|
+
import { type IssueOptions } from "./issues.js";
|
|
3
|
+
export interface InitiativeOptions extends IssueOptions {
|
|
4
|
+
page?: string | number;
|
|
5
|
+
status?: string;
|
|
6
|
+
sort?: string;
|
|
7
|
+
dir?: string;
|
|
8
|
+
owner?: string;
|
|
9
|
+
removeOwner?: boolean;
|
|
10
|
+
startDate?: string;
|
|
11
|
+
removeStartDate?: boolean;
|
|
12
|
+
targetDate?: string;
|
|
13
|
+
removeTargetDate?: boolean;
|
|
14
|
+
label?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare function runInitiative(ctx: Context, action: string, initiativeId: string | undefined, options: InitiativeOptions): Promise<Result>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { CliError } from "./contracts.js";
|
|
3
|
+
import { getProject, integer, me, object, objects, positive, projectRef, publicData, readBody } from "./issues.js";
|
|
4
|
+
import { applied, preview, verificationFailed, withWrite, writeFailure } from "./writes.js";
|
|
5
|
+
const statuses = ["proposed", "planned", "active", "completed", "canceled"];
|
|
6
|
+
const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
function unsupported(message) {
|
|
8
|
+
throw new CliError("UNSUPPORTED_RESPONSE", message);
|
|
9
|
+
}
|
|
10
|
+
function selectedRef(ctx, options) {
|
|
11
|
+
const ref = options.project ?? ctx.options.project ?? ctx.project;
|
|
12
|
+
return ref === undefined ? undefined : projectRef(ref);
|
|
13
|
+
}
|
|
14
|
+
async function selectedProject(ctx, options) {
|
|
15
|
+
const ref = selectedRef(ctx, options);
|
|
16
|
+
if (ref === undefined)
|
|
17
|
+
throw new CliError("PROJECT_REQUIRED", "Provide --project team.KEY or configure a project");
|
|
18
|
+
return getProject(ctx, ref);
|
|
19
|
+
}
|
|
20
|
+
function initiative(value, id) {
|
|
21
|
+
const item = object(value, "initiative");
|
|
22
|
+
if (!positive(item.id) || !positive(item.projectId))
|
|
23
|
+
unsupported("Initiative response is missing stable identifiers");
|
|
24
|
+
if (id !== undefined && item.id !== id)
|
|
25
|
+
unsupported("Initiative response has a different ID");
|
|
26
|
+
return item;
|
|
27
|
+
}
|
|
28
|
+
async function projectFor(ctx, options, item) {
|
|
29
|
+
let ref = selectedRef(ctx, options);
|
|
30
|
+
if (ref === undefined) {
|
|
31
|
+
const projects = objects(await ctx.client.request("/projects"), "projects");
|
|
32
|
+
const matches = projects.filter((project) => project.id === item.projectId);
|
|
33
|
+
if (matches.length !== 1 || typeof matches[0].ref !== "string")
|
|
34
|
+
unsupported("Cannot resolve the initiative project; provide --project team.KEY");
|
|
35
|
+
ref = matches[0].ref;
|
|
36
|
+
}
|
|
37
|
+
const setup = await getProject(ctx, ref);
|
|
38
|
+
if (setup.project.id !== item.projectId)
|
|
39
|
+
throw new CliError("PROJECT_CONFLICT", "The initiative does not belong to the selected project");
|
|
40
|
+
return setup;
|
|
41
|
+
}
|
|
42
|
+
function canonical(setup) {
|
|
43
|
+
return projectRef(String(setup.project.ref));
|
|
44
|
+
}
|
|
45
|
+
function validateStatus(value, multiple) {
|
|
46
|
+
const values = multiple ? value.split(",") : [value];
|
|
47
|
+
if (!values.length || values.some((entry) => !statuses.includes(entry)))
|
|
48
|
+
throw new CliError("INVALID_STATUS", `--status must be ${statuses.join("|")}${multiple ? " or a comma-separated subset" : ""}`);
|
|
49
|
+
}
|
|
50
|
+
function labelId(setup, input) {
|
|
51
|
+
const matches = setup.labels.filter((entry) => entry.name === input || String(entry.id) === input);
|
|
52
|
+
if (!matches.length)
|
|
53
|
+
throw new CliError("VALUE_NOT_FOUND", `Label ${JSON.stringify(input)} is not available in this project`);
|
|
54
|
+
if (matches.length !== 1)
|
|
55
|
+
throw new CliError("AMBIGUOUS_VALUE", `Label ${JSON.stringify(input)} is ambiguous`);
|
|
56
|
+
return Number(matches[0].id);
|
|
57
|
+
}
|
|
58
|
+
async function ownerId(ctx, setup, value) {
|
|
59
|
+
const input = value === "@me" ? String((await me(ctx)).id) : value.replace(/^@/, "");
|
|
60
|
+
const matches = setup.assignees.filter((entry) => entry.kind === "member" && (entry.userId === input || entry.username === input));
|
|
61
|
+
if (!matches.length)
|
|
62
|
+
throw new CliError("VALUE_NOT_FOUND", `Owner ${JSON.stringify(value)} is not available in this project`);
|
|
63
|
+
if (matches.length !== 1)
|
|
64
|
+
throw new CliError("AMBIGUOUS_VALUE", `Owner ${JSON.stringify(value)} is ambiguous`);
|
|
65
|
+
return String(matches[0].userId);
|
|
66
|
+
}
|
|
67
|
+
function signedDescription(body, actor, editing) {
|
|
68
|
+
let value = body;
|
|
69
|
+
if (actor.kind === "agent") {
|
|
70
|
+
value = value.trimEnd();
|
|
71
|
+
const footer = /(?:^|\r?\n[ \t]*\r?\n)— (?:by|last edited by) (?:Codex|Claude) via iap$/u;
|
|
72
|
+
while (footer.test(value))
|
|
73
|
+
value = value.replace(footer, "").trimEnd();
|
|
74
|
+
value = `${value}\n\n— ${editing ? "last edited by" : "by"} ${actor.name === "codex" ? "Codex" : "Claude"} via iap`;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function date(value, flag) {
|
|
79
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00Z`)) || new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) !== value)
|
|
80
|
+
throw new CliError("INVALID_DATE", `${flag} must be a valid YYYY-MM-DD date`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
async function fields(ctx, setup, options, actor, item) {
|
|
84
|
+
for (const [value, remove, flag] of [
|
|
85
|
+
[options.owner, options.removeOwner, "owner"],
|
|
86
|
+
[options.priority, options.removePriority, "priority"],
|
|
87
|
+
[options.startDate, options.removeStartDate, "start-date"],
|
|
88
|
+
[options.targetDate, options.removeTargetDate, "target-date"],
|
|
89
|
+
]) {
|
|
90
|
+
if (value !== undefined && remove)
|
|
91
|
+
throw new CliError("CONFLICTING_ARGUMENTS", `--${flag} and --remove-${flag} conflict`);
|
|
92
|
+
}
|
|
93
|
+
const body = {};
|
|
94
|
+
if (options.title !== undefined) {
|
|
95
|
+
if (!options.title.trim())
|
|
96
|
+
throw new CliError("INVALID_TITLE", "--title must not be empty");
|
|
97
|
+
body.title = options.title;
|
|
98
|
+
}
|
|
99
|
+
const description = await readBody(options);
|
|
100
|
+
if (description !== undefined)
|
|
101
|
+
body.description = signedDescription(description, actor, item !== undefined);
|
|
102
|
+
if (options.status !== undefined) {
|
|
103
|
+
validateStatus(options.status, false);
|
|
104
|
+
body.status = options.status;
|
|
105
|
+
}
|
|
106
|
+
if (options.owner !== undefined)
|
|
107
|
+
body.ownerUserId = await ownerId(ctx, setup, options.owner);
|
|
108
|
+
if (options.removeOwner)
|
|
109
|
+
body.ownerUserId = null;
|
|
110
|
+
if (options.priority !== undefined) {
|
|
111
|
+
if (!["urgent", "high", "medium", "low"].includes(options.priority))
|
|
112
|
+
throw new CliError("INVALID_PRIORITY", "--priority must be urgent|high|medium|low");
|
|
113
|
+
body.priority = options.priority;
|
|
114
|
+
}
|
|
115
|
+
if (options.removePriority)
|
|
116
|
+
body.priority = null;
|
|
117
|
+
if (options.startDate !== undefined)
|
|
118
|
+
body.startDate = date(options.startDate, "--start-date");
|
|
119
|
+
if (options.removeStartDate)
|
|
120
|
+
body.startDate = null;
|
|
121
|
+
if (options.targetDate !== undefined)
|
|
122
|
+
body.targetDate = date(options.targetDate, "--target-date");
|
|
123
|
+
if (options.removeTargetDate)
|
|
124
|
+
body.targetDate = null;
|
|
125
|
+
const add = [...(options.label ?? []), ...(options.addLabel ?? [])].map((name) => labelId(setup, name));
|
|
126
|
+
const remove = (options.removeLabel ?? []).map((name) => labelId(setup, name));
|
|
127
|
+
if (add.some((id) => remove.includes(id)))
|
|
128
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "The same label cannot be added and removed");
|
|
129
|
+
if (add.length || remove.length) {
|
|
130
|
+
if (item && (!Array.isArray(item.labelIds) || !item.labelIds.every(positive)))
|
|
131
|
+
unsupported("Initiative response is missing labelIds; cannot preserve existing labels");
|
|
132
|
+
body.labelIds = [...new Set([...(item ? item.labelIds.filter((id) => !remove.includes(id)) : []), ...add])];
|
|
133
|
+
}
|
|
134
|
+
return body;
|
|
135
|
+
}
|
|
136
|
+
function matches(value, id, projectId, body) {
|
|
137
|
+
if (!record(value) || !positive(value.id) || value.id !== id || value.projectId !== projectId)
|
|
138
|
+
return false;
|
|
139
|
+
return Object.entries(body).every(([key, expected]) => {
|
|
140
|
+
const actual = value[key];
|
|
141
|
+
if (key === "labelIds" && Array.isArray(actual) && Array.isArray(expected))
|
|
142
|
+
return actual.every(positive) && isDeepStrictEqual([...actual].sort((a, b) => a - b), [...expected].sort((a, b) => a - b));
|
|
143
|
+
return isDeepStrictEqual(actual, expected);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function list(ctx, options) {
|
|
147
|
+
const page = integer(options.page ?? 1, "--page");
|
|
148
|
+
const pageSize = integer(options.limit ?? 25, "--limit", 100);
|
|
149
|
+
if (options.status !== undefined)
|
|
150
|
+
validateStatus(options.status, true);
|
|
151
|
+
if (options.sort !== undefined && !["title", "priority", "targetDate", "owner"].includes(options.sort))
|
|
152
|
+
throw new CliError("INVALID_SORT", "--sort must be title|priority|targetDate|owner");
|
|
153
|
+
if (options.dir !== undefined && !["asc", "desc"].includes(options.dir))
|
|
154
|
+
throw new CliError("INVALID_DIRECTION", "--dir must be asc|desc");
|
|
155
|
+
const setup = await selectedProject(ctx, options);
|
|
156
|
+
const response = object(await ctx.client.request(`/projects/${encodeURIComponent(canonical(setup))}/initiatives`, { query: { page, pageSize, status: options.status, search: options.search, sort: options.sort, dir: options.dir } }), "initiative page");
|
|
157
|
+
const items = objects(response.items, "initiative items");
|
|
158
|
+
if (!Number.isSafeInteger(response.total) || response.total < 0 || response.page !== page || response.pageSize !== pageSize || items.length > pageSize || items.some((item) => !positive(item.id) || item.projectId !== setup.project.id))
|
|
159
|
+
unsupported("Invalid initiative page or foreign initiative");
|
|
160
|
+
const total = response.total;
|
|
161
|
+
const offset = (page - 1) * pageSize;
|
|
162
|
+
if (!Number.isSafeInteger(offset))
|
|
163
|
+
unsupported("Initiative page offset is invalid");
|
|
164
|
+
const nextPage = offset + pageSize < total ? page + 1 : null;
|
|
165
|
+
return { data: { items: publicData(items), total, page, pageSize, nextPage }, meta: { scope: { projectId: setup.project.id, projectRef: canonical(setup) }, limit: pageSize, returned: items.length, completeness: page === 1 && items.length === total ? "complete" : "partial" } };
|
|
166
|
+
}
|
|
167
|
+
async function writeInitiative(ctx, action, identifier, options, actor) {
|
|
168
|
+
const editing = action === "edit";
|
|
169
|
+
if (!editing && identifier !== undefined)
|
|
170
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "initiative create does not accept an ID");
|
|
171
|
+
let item;
|
|
172
|
+
let setup;
|
|
173
|
+
if (editing) {
|
|
174
|
+
if (identifier === undefined)
|
|
175
|
+
throw new CliError("INITIATIVE_REQUIRED", "Provide a numeric initiative ID");
|
|
176
|
+
const id = integer(identifier, "initiative ID");
|
|
177
|
+
item = initiative(await ctx.client.request(`/initiatives/${id}`), id);
|
|
178
|
+
setup = await projectFor(ctx, options, item);
|
|
179
|
+
}
|
|
180
|
+
else
|
|
181
|
+
setup = await selectedProject(ctx, options);
|
|
182
|
+
const body = await fields(ctx, setup, options, actor, item);
|
|
183
|
+
if (!editing && body.title === undefined)
|
|
184
|
+
throw new CliError("TITLE_REQUIRED", "Provide --title");
|
|
185
|
+
if (editing && Object.keys(body).length === 0)
|
|
186
|
+
throw new CliError("NO_CHANGES", "Provide at least one initiative edit field");
|
|
187
|
+
if (item)
|
|
188
|
+
for (const key of Object.keys(body))
|
|
189
|
+
if (!(key in item))
|
|
190
|
+
unsupported(`Initiative response is missing ${key}; cannot build a trustworthy diff`);
|
|
191
|
+
const path = editing ? `/initiatives/${item.id}` : `/projects/${encodeURIComponent(canonical(setup))}/initiatives`;
|
|
192
|
+
const method = editing ? "PATCH" : "POST";
|
|
193
|
+
const diff = Object.fromEntries(Object.entries(body).map(([key, value]) => [key, { before: item ? item[key] : null, after: value }]));
|
|
194
|
+
const data = { action, projectId: setup.project.id, projectRef: canonical(setup), ...(item ? { initiativeId: item.id } : {}), changed: false, diff };
|
|
195
|
+
if (options.dryRun)
|
|
196
|
+
return preview(ctx, actor, data, path, body, [], method);
|
|
197
|
+
let response;
|
|
198
|
+
try {
|
|
199
|
+
response = await ctx.client.request(path, { method, body });
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
return writeFailure(error, data, actor, "initiative");
|
|
203
|
+
}
|
|
204
|
+
data.changed = true;
|
|
205
|
+
if (!editing && record(response) && positive(response.id))
|
|
206
|
+
data.initiativeId = response.id;
|
|
207
|
+
if (matches(response, data.initiativeId, setup.project.id, body))
|
|
208
|
+
return applied(data, actor);
|
|
209
|
+
if (positive(data.initiativeId)) {
|
|
210
|
+
try {
|
|
211
|
+
if (matches(await ctx.client.request(`/initiatives/${data.initiativeId}`), data.initiativeId, setup.project.id, body))
|
|
212
|
+
return applied(data, actor);
|
|
213
|
+
}
|
|
214
|
+
catch { /* The accepted write retains its ID even if verification fails. */ }
|
|
215
|
+
}
|
|
216
|
+
return verificationFailed(data, actor, "initiative");
|
|
217
|
+
}
|
|
218
|
+
export async function runInitiative(ctx, action, initiativeId, options) {
|
|
219
|
+
if (action === "list") {
|
|
220
|
+
if (initiativeId !== undefined)
|
|
221
|
+
throw new CliError("CONFLICTING_ARGUMENTS", "initiative list does not accept an ID");
|
|
222
|
+
return list(ctx, options);
|
|
223
|
+
}
|
|
224
|
+
if (action === "view") {
|
|
225
|
+
if (initiativeId === undefined)
|
|
226
|
+
throw new CliError("INITIATIVE_REQUIRED", "Provide a numeric initiative ID");
|
|
227
|
+
const id = integer(initiativeId, "initiative ID");
|
|
228
|
+
const item = initiative(await ctx.client.request(`/initiatives/${id}`), id);
|
|
229
|
+
const setup = await projectFor(ctx, options, item);
|
|
230
|
+
return { data: { initiative: publicData(item), projectRef: canonical(setup) }, meta: { scope: { initiativeId: id, projectId: setup.project.id, projectRef: canonical(setup) } } };
|
|
231
|
+
}
|
|
232
|
+
if (action === "create" || action === "edit") {
|
|
233
|
+
const result = await withWrite(options, (actor) => writeInitiative(ctx, action, initiativeId, options, actor));
|
|
234
|
+
if (record(result.data)) {
|
|
235
|
+
const { initiativeId: id, projectId, projectRef: ref } = result.data;
|
|
236
|
+
result.meta = { ...result.meta, scope: { ...(id !== undefined ? { initiativeId: id } : {}), projectId, projectRef: ref } };
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
throw new CliError("INVALID_ARGUMENT", "Unknown initiative action");
|
|
241
|
+
}
|
package/dist/output.js
CHANGED
|
@@ -54,6 +54,7 @@ const schemas = {
|
|
|
54
54
|
},
|
|
55
55
|
"issue.list": {
|
|
56
56
|
...issueFields,
|
|
57
|
+
parent: scalarFields("id identifier title"),
|
|
57
58
|
state: scalarFields("id name stateType unavailable"),
|
|
58
59
|
},
|
|
59
60
|
"issue.resolve": scalarFields("id issueId projectId identifier sequenceNumber projectRef"),
|
package/dist/writes.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Actor, type Context, type ObjectData, type Result } from "./contra
|
|
|
2
2
|
import { type IssueOptions } from "./issues.js";
|
|
3
3
|
/** Preflight failures are never mistaken for a write with an unknown outcome. */
|
|
4
4
|
export declare function withWrite(options: IssueOptions, operation: (actor: Actor) => Promise<Result>): Promise<Result>;
|
|
5
|
-
export declare function preview(ctx: Context, actor: Actor, data: ObjectData, path: string, body: ObjectData, warnings?: string[]): Promise<Result>;
|
|
6
|
-
export declare function writeFailure(error: unknown, data: ObjectData, actor: Actor): Result;
|
|
7
|
-
export declare function verificationFailed(data: ObjectData, actor: Actor): Result;
|
|
5
|
+
export declare function preview(ctx: Context, actor: Actor, data: ObjectData, path: string, body: ObjectData, warnings?: string[], method?: string): Promise<Result>;
|
|
6
|
+
export declare function writeFailure(error: unknown, data: ObjectData, actor: Actor, resource?: string): Result;
|
|
7
|
+
export declare function verificationFailed(data: ObjectData, actor: Actor, resource?: string): Result;
|
|
8
8
|
export declare function applied(data: ObjectData, actor: Actor): Result;
|
package/dist/writes.js
CHANGED
|
@@ -26,13 +26,13 @@ export async function withWrite(options, operation) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
export async function preview(ctx, actor, data, path, body, warnings = []) {
|
|
29
|
+
export async function preview(ctx, actor, data, path, body, warnings = [], method = "POST") {
|
|
30
30
|
return {
|
|
31
31
|
data: {
|
|
32
32
|
...data,
|
|
33
33
|
dryRun: true,
|
|
34
34
|
identity: await me(ctx),
|
|
35
|
-
request: { method
|
|
35
|
+
request: { method, path, body },
|
|
36
36
|
warnings: [
|
|
37
37
|
"Preview sends no writes and does not guarantee server permissions or concurrent state.",
|
|
38
38
|
...warnings,
|
|
@@ -43,14 +43,14 @@ export async function preview(ctx, actor, data, path, body, warnings = []) {
|
|
|
43
43
|
verification: "not_run",
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
export function writeFailure(error, data, actor) {
|
|
47
|
-
const result = issueWriteFailure(error, data, actor,
|
|
46
|
+
export function writeFailure(error, data, actor, resource = "document or relation") {
|
|
47
|
+
const result = issueWriteFailure(error, data, actor, resource);
|
|
48
48
|
if (result.outcome === "unknown")
|
|
49
49
|
delete data.changed;
|
|
50
50
|
return result;
|
|
51
51
|
}
|
|
52
|
-
export function verificationFailed(data, actor) {
|
|
53
|
-
return issueVerificationFailed(data, actor,
|
|
52
|
+
export function verificationFailed(data, actor, resource = "document or relation") {
|
|
53
|
+
return issueVerificationFailed(data, actor, resource);
|
|
54
54
|
}
|
|
55
55
|
export function applied(data, actor) {
|
|
56
56
|
return { data, meta: { actor }, outcome: "applied", verification: "passed" };
|