From 644186133b6cf9948c4a8406ffb31aa7e26ac135 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Feb 2026 15:11:33 +0000 Subject: [PATCH] refactor: extract shared utilities for URL building, prompts, and name resolution - Add resolveByName() generic factory in resolve.ts to eliminate duplicated name-to-ID lookup patterns (used by resolvePriorityId, resolveResolutionId) - Add url.ts with centralized Backlog web URL builders (issueUrl, projectUrl, pullRequestUrl, repositoryUrl, wikiUrl, dashboardUrl) replacing scattered template literals across 8 command files - Add prompt.ts with promptRequired() helper replacing duplicated prompt-then-validate-then-exit pattern across 15+ command files - Apply new utilities to all affected command files (browse, issue/*, pr/*, project/*, wiki/*, repo/*, category/*, team/*, milestone/*, webhook/*, status-type/*, issue-type/*) - Add comprehensive tests for all new utilities (url.test.ts, prompt.test.ts, resolveByName tests in resolve.test.ts) - Update claude.md to document new utility modules, URL builder patterns, and promptRequired usage https://claude.ai/code/session_018Xzap56B6tbcxdSx2DWuq1 --- claude.md | 113 +++++++++++++++--- packages/cli/src/commands/browse.ts | 25 ++-- packages/cli/src/commands/category/create.ts | 11 +- .../cli/src/commands/issue-type/create.ts | 23 +--- packages/cli/src/commands/issue/comment.ts | 7 +- packages/cli/src/commands/issue/create.ts | 43 ++----- packages/cli/src/commands/issue/view.ts | 3 +- packages/cli/src/commands/milestone/create.ts | 11 +- packages/cli/src/commands/pr/comment.ts | 7 +- packages/cli/src/commands/pr/create.ts | 34 +----- packages/cli/src/commands/pr/view.ts | 3 +- packages/cli/src/commands/project/create.ts | 21 +--- packages/cli/src/commands/project/view.ts | 3 +- packages/cli/src/commands/repo/view.ts | 3 +- .../cli/src/commands/status-type/create.ts | 23 +--- packages/cli/src/commands/team/create.ts | 11 +- packages/cli/src/commands/webhook/create.ts | 21 +--- packages/cli/src/commands/wiki/create.ts | 21 +--- packages/cli/src/commands/wiki/view.ts | 3 +- packages/cli/src/utils/prompt.test.ts | 64 ++++++++++ packages/cli/src/utils/prompt.ts | 29 +++++ packages/cli/src/utils/resolve.test.ts | 54 +++++++++ packages/cli/src/utils/resolve.ts | 79 +++++++----- packages/cli/src/utils/url.test.ts | 75 ++++++++++++ packages/cli/src/utils/url.ts | 51 ++++++++ 25 files changed, 485 insertions(+), 253 deletions(-) create mode 100644 packages/cli/src/utils/prompt.test.ts create mode 100644 packages/cli/src/utils/prompt.ts create mode 100644 packages/cli/src/utils/url.test.ts create mode 100644 packages/cli/src/utils/url.ts diff --git a/claude.md b/claude.md index bdea5aa..f0dabb0 100644 --- a/claude.md +++ b/claude.md @@ -59,10 +59,20 @@ src/commands/ completion.ts — シェル補完スクリプト生成 ``` +``` +src/utils/ + client.ts — API クライアント生成(getClient) + resolve.ts — 名前→ID 解決(resolveByName ファクトリ + 個別関数) + format.ts — 表示フォーマット(テーブル行・日付・パディング) + url.ts — Backlog Web URL 構築(issueUrl, projectUrl 等) + prompt.ts — インタラクティブプロンプト(promptRequired) +``` + 新しいコマンドを追加する手順: 1. `commands//` にコマンドファイルを作成(`defineCommand` を使用) 2. グループの `index.ts` の `subCommands` に遅延 import を追加 3. 新しいグループの場合は `src/index.ts` にも追加 +4. URL 構築は `#utils/url.ts`、プロンプトは `#utils/prompt.ts`、名前解決は `#utils/resolve.ts` を使用 ## API クライアント @@ -84,16 +94,58 @@ API Key(クエリパラメータ)と OAuth 2.0(Bearer トークン)の ### 名前解決 CLI ではユーザーフレンドリーな名前を使い、API リクエスト時に内部で ID に変換する。 +名前解決ロジックは `src/utils/resolve.ts` に集約。 + +共通パターンは `resolveByName()` ジェネリックファクトリで実装し、重複を排除している: + +```ts +// 汎用: エンドポイントからリストを取得し、nameField で検索して id を返す +resolveByName(client, endpoint, nameField, value, label) -| CLI での入力 | API での送信 | 変換元 API | +// 特殊ケース(ユーザー、ステータス等)は専用関数を用意 +resolveUserId(client, username) // @me 対応、userId/name 両方で検索 +resolveStatusId(client, projectKey, name) // プロジェクト固有ステータス +``` + +| CLI での入力 | API での送信 | 解決関数 | |--------------|-------------|-----------| -| ステータス名(例: `処理中`) | `statusId` | `GET /api/v2/projects/:key/statuses` | -| 課題種別名(例: `バグ`) | `issueTypeId` | `GET /api/v2/projects/:key/issueTypes` | -| 優先度名(例: `高`) | `priorityId` | `GET /api/v2/priorities` | -| カテゴリ名 | `categoryId` | `GET /api/v2/projects/:key/categories` | -| マイルストーン名 | `milestoneId` | `GET /api/v2/projects/:key/versions` | -| ユーザー名 / `@me` | `userId` / `assigneeId` | `GET /api/v2/users` / `GET /api/v2/users/myself` | -| 完了理由名 | `resolutionId` | `GET /api/v2/resolutions` | +| ステータス名(例: `処理中`) | `statusId` | `resolveStatusId` | +| 課題種別名(例: `バグ`) | `issueTypeId` | `resolveIssueTypeId` | +| 優先度名(例: `高`) | `priorityId` | `resolvePriorityId`(`resolveByName` 使用) | +| ユーザー名 / `@me` | `userId` / `assigneeId` | `resolveUserId` | +| 完了理由名 | `resolutionId` | `resolveResolutionId`(`resolveByName` 使用) | + +### URL 構築 + +Backlog の Web URL 構築は `src/utils/url.ts` に集約。コマンドファイルでは直接テンプレートリテラルで URL を組み立てず、専用関数を使用する: + +```ts +import { issueUrl, projectUrl, pullRequestUrl, repositoryUrl, wikiUrl, dashboardUrl, buildBacklogUrl } from "#utils/url.ts"; + +issueUrl(host, "PROJ-123") // → https://host/view/PROJ-123 +projectUrl(host, "PROJ") // → https://host/projects/PROJ +pullRequestUrl(host, "PROJ", "repo", 42) // → https://host/git/PROJ/repo/pullRequests/42 +wikiUrl(host, 999) // → https://host/alias/wiki/999 +repositoryUrl(host, "PROJ", "repo") // → https://host/git/PROJ/repo +dashboardUrl(host) // → https://host/dashboard +buildBacklogUrl(host, "/custom/path") // → https://host/custom/path +``` + +### インタラクティブプロンプト + +必須引数が省略された場合、対話的にプロンプトを表示してユーザーに入力を求める。 +共通パターンは `src/utils/prompt.ts` の `promptRequired` で実装: + +```ts +import { promptRequired } from "#utils/prompt.ts"; + +// 既存の値があればそのまま返し、なければプロンプト表示。空入力時は process.exit(1) +const name = await promptRequired("Project name:", args.name); +``` + +- TTY 接続時のみ有効 +- `--no-input` フラグで無効化 +- 選択式のフィールドはリスト選択 UI を提供 ### 出力形式 @@ -105,14 +157,6 @@ CLI ではユーザーフレンドリーな名前を使い、API リクエスト | `--jq '.[]'` | jq 変換済み出力 | 高度なフィルタ | | `--template '{{.Key}}'` | Go template | カスタムフォーマット | -### インタラクティブモード - -必須引数が省略された場合、対話的にプロンプトを表示してユーザーに入力を求める。 - -- TTY 接続時のみ有効 -- `--no-input` フラグで無効化 -- 選択式のフィールドはリスト選択 UI を提供 - ### プロジェクトコンテキスト グローバルな `--project` フラグは持たない。Backlog の課題キーは `PROJECT-123` 形式で @@ -204,6 +248,31 @@ bun run test --filter=@repo/config # 特定パッケージ #### 3. `packages/cli`(優先度: 中) +**`src/utils/resolve.ts`** — 名前→ID 解決ロジック + +| テスト観点 | 具体例 | +|---|---| +| `resolveByName` 汎用検索 | リスト内の名前一致で ID を返す | +| `resolveByName` 見つからない場合 | 利用可能な名前一覧を含むエラー | +| `resolveUserId` の `@me` 対応 | `/users/myself` から ID を取得 | +| `extractProjectKey` | `PROJECT-123` → `PROJECT` | + +**`src/utils/url.ts`** — Backlog Web URL 構築 + +| テスト観点 | 具体例 | +|---|---| +| `issueUrl` | `issueUrl("host", "PROJ-1")` → `https://host/view/PROJ-1` | +| `pullRequestUrl` | プロジェクト・リポジトリ・PR番号から URL を構築 | +| `dashboardUrl` | ダッシュボード URL の生成 | + +**`src/utils/prompt.ts`** — インタラクティブプロンプト + +| テスト観点 | 具体例 | +|---|---| +| 既存値あり | プロンプト表示せずそのまま返す | +| 既存値なし | `consola.prompt` でユーザー入力を取得 | +| 空入力 | エラーメッセージ表示 + `process.exit(1)` | + **`src/commands/config/get.ts`** — `getNestedValue` ヘルパー | テスト観点 | 具体例 | @@ -235,6 +304,18 @@ packages/config/src/ space.test.ts config.ts config.test.ts + +packages/cli/src/utils/ + resolve.ts + resolve.test.ts + format.ts + format.test.ts + client.ts + client.test.ts + url.ts + url.test.ts + prompt.ts + prompt.test.ts ``` ### テストの書き方 diff --git a/packages/cli/src/commands/browse.ts b/packages/cli/src/commands/browse.ts index 044108f..8dad8c1 100644 --- a/packages/cli/src/commands/browse.ts +++ b/packages/cli/src/commands/browse.ts @@ -1,6 +1,12 @@ import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { + buildBacklogUrl, + dashboardUrl, + issueUrl, + projectUrl, +} from "#utils/url.ts"; export default defineCommand({ meta: { @@ -42,25 +48,28 @@ export default defineCommand({ if (args.target) { // If target looks like an issue key (e.g., PROJECT-123), open the issue if (/^[A-Z][A-Z0-9_]+-\d+$/.test(args.target)) { - url = `https://${host}/view/${args.target}`; + url = issueUrl(host, args.target); } else { // Treat as a path - url = `https://${host}/${args.target}`; + url = buildBacklogUrl(host, `/${args.target}`); } } else if (args.project) { if (args.issues) { - url = `https://${host}/find/${args.project}`; + url = buildBacklogUrl(host, `/find/${args.project}`); } else if (args.wiki) { - url = `https://${host}/wiki/${args.project}`; + url = buildBacklogUrl(host, `/wiki/${args.project}`); } else if (args.git) { - url = `https://${host}/git/${args.project}`; + url = buildBacklogUrl(host, `/git/${args.project}`); } else if (args.settings) { - url = `https://${host}/EditProject.action?project.key=${args.project}`; + url = buildBacklogUrl( + host, + `/EditProject.action?project.key=${args.project}`, + ); } else { - url = `https://${host}/projects/${args.project}`; + url = projectUrl(host, args.project); } } else { - url = `https://${host}/dashboard`; + url = dashboardUrl(host); } consola.info(`Opening ${url}`); diff --git a/packages/cli/src/commands/category/create.ts b/packages/cli/src/commands/category/create.ts index 0646f8e..77589cf 100644 --- a/packages/cli/src/commands/category/create.ts +++ b/packages/cli/src/commands/category/create.ts @@ -2,6 +2,7 @@ import type { BacklogCategory } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -24,15 +25,7 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - - if (!name) { - name = await consola.prompt("Category name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Category name is required."); - return process.exit(1); - } - } + const name = await promptRequired("Category name:", args.name); const category = await client( `/projects/${args.project}/categories`, diff --git a/packages/cli/src/commands/issue-type/create.ts b/packages/cli/src/commands/issue-type/create.ts index c016763..f9e3aa5 100644 --- a/packages/cli/src/commands/issue-type/create.ts +++ b/packages/cli/src/commands/issue-type/create.ts @@ -2,6 +2,7 @@ import type { BacklogIssueType } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -28,26 +29,8 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - let color = args.color; - - if (!name) { - name = await consola.prompt("Issue type name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Issue type name is required."); - return process.exit(1); - } - } - - if (!color) { - color = await consola.prompt("Display color (#hex):", { - type: "text", - }); - if (typeof color !== "string" || !color) { - consola.error("Display color is required."); - return process.exit(1); - } - } + const name = await promptRequired("Issue type name:", args.name); + const color = await promptRequired("Display color (#hex):", args.color); const issueType = await client( `/projects/${args.project}/issueTypes`, diff --git a/packages/cli/src/commands/issue/comment.ts b/packages/cli/src/commands/issue/comment.ts index dbe2381..06078fc 100644 --- a/packages/cli/src/commands/issue/comment.ts +++ b/packages/cli/src/commands/issue/comment.ts @@ -2,6 +2,7 @@ import type { BacklogComment } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -36,11 +37,7 @@ export default defineCommand({ // Prompt for body if not provided if (!content) { - content = await consola.prompt("Comment body:", { type: "text" }); - if (typeof content !== "string" || !content) { - consola.error("Comment body is required."); - return process.exit(1); - } + content = await promptRequired("Comment body:"); } const comment = await client( diff --git a/packages/cli/src/commands/issue/create.ts b/packages/cli/src/commands/issue/create.ts index a50eb3d..07b09cb 100644 --- a/packages/cli/src/commands/issue/create.ts +++ b/packages/cli/src/commands/issue/create.ts @@ -2,12 +2,14 @@ import type { BacklogIssue } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; import { resolveIssueTypeId, resolvePriorityId, resolveProjectId, resolveUserId, } from "#utils/resolve.ts"; +import { issueUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -62,41 +64,10 @@ export default defineCommand({ const { client, host } = await getClient(); // Resolve required fields — prompt interactively if missing - let projectKey = args.project; - if (!projectKey) { - projectKey = await consola.prompt("Project key:", { type: "text" }); - if (typeof projectKey !== "string" || !projectKey) { - consola.error("Project key is required."); - return process.exit(1); - } - } - - let title = args.title; - if (!title) { - title = await consola.prompt("Issue title:", { type: "text" }); - if (typeof title !== "string" || !title) { - consola.error("Issue title is required."); - return process.exit(1); - } - } - - let typeName = args.type; - if (!typeName) { - typeName = await consola.prompt("Issue type:", { type: "text" }); - if (typeof typeName !== "string" || !typeName) { - consola.error("Issue type is required."); - return process.exit(1); - } - } - - let priorityName = args.priority; - if (!priorityName) { - priorityName = await consola.prompt("Priority:", { type: "text" }); - if (typeof priorityName !== "string" || !priorityName) { - consola.error("Priority is required."); - return process.exit(1); - } - } + const projectKey = await promptRequired("Project key:", args.project); + const title = await promptRequired("Issue title:", args.title); + const typeName = await promptRequired("Issue type:", args.type); + const priorityName = await promptRequired("Priority:", args.priority); // Resolve description from stdin if "-" let description = args.description; @@ -145,7 +116,7 @@ export default defineCommand({ consola.success(`Created ${issue.issueKey}: ${issue.summary}`); if (args.web) { - const url = `https://${host}/view/${issue.issueKey}`; + const url = issueUrl(host, issue.issueKey); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); } diff --git a/packages/cli/src/commands/issue/view.ts b/packages/cli/src/commands/issue/view.ts index ba93592..8c0321c 100644 --- a/packages/cli/src/commands/issue/view.ts +++ b/packages/cli/src/commands/issue/view.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; import { formatDate } from "#utils/format.ts"; +import { issueUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -30,7 +31,7 @@ export default defineCommand({ const issue = await client(`/issues/${args.issueKey}`); if (args.web) { - const url = `https://${host}/view/${issue.issueKey}`; + const url = issueUrl(host, issue.issueKey); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); return; diff --git a/packages/cli/src/commands/milestone/create.ts b/packages/cli/src/commands/milestone/create.ts index 1f38ba6..faf73db 100644 --- a/packages/cli/src/commands/milestone/create.ts +++ b/packages/cli/src/commands/milestone/create.ts @@ -2,6 +2,7 @@ import type { BacklogMilestone } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -37,15 +38,7 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - - if (!name) { - name = await consola.prompt("Milestone name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Milestone name is required."); - return process.exit(1); - } - } + const name = await promptRequired("Milestone name:", args.name); const body: Record = { name }; diff --git a/packages/cli/src/commands/pr/comment.ts b/packages/cli/src/commands/pr/comment.ts index 88cba3f..13b1a5b 100644 --- a/packages/cli/src/commands/pr/comment.ts +++ b/packages/cli/src/commands/pr/comment.ts @@ -2,6 +2,7 @@ import type { BacklogPullRequestComment } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -48,11 +49,7 @@ export default defineCommand({ // Prompt for body if not provided if (!content) { - content = await consola.prompt("Comment body:", { type: "text" }); - if (typeof content !== "string" || !content) { - consola.error("Comment body is required."); - return process.exit(1); - } + content = await promptRequired("Comment body:"); } const comment = await client( diff --git a/packages/cli/src/commands/pr/create.ts b/packages/cli/src/commands/pr/create.ts index 15bc8d8..d906a79 100644 --- a/packages/cli/src/commands/pr/create.ts +++ b/packages/cli/src/commands/pr/create.ts @@ -2,7 +2,9 @@ import type { BacklogPullRequest } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; import { resolveUserId } from "#utils/resolve.ts"; +import { pullRequestUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -58,34 +60,10 @@ export default defineCommand({ async run({ args }) { const { client, host } = await getClient(); - let title = args.title; - let base = args.base; - let branch = args.branch; - // Prompt for required fields if not provided - if (!title) { - title = await consola.prompt("PR title:", { type: "text" }); - if (typeof title !== "string" || !title) { - consola.error("Title is required."); - return process.exit(1); - } - } - - if (!base) { - base = await consola.prompt("Base branch:", { type: "text" }); - if (typeof base !== "string" || !base) { - consola.error("Base branch is required."); - return process.exit(1); - } - } - - if (!branch) { - branch = await consola.prompt("Source branch:", { type: "text" }); - if (typeof branch !== "string" || !branch) { - consola.error("Source branch is required."); - return process.exit(1); - } - } + const title = await promptRequired("PR title:", args.title); + const base = await promptRequired("Base branch:", args.base); + const branch = await promptRequired("Source branch:", args.branch); const body: Record = { summary: title, @@ -114,7 +92,7 @@ export default defineCommand({ consola.success(`Created PR #${pr.number}: ${pr.summary}`); if (args.web) { - const url = `https://${host}/git/${args.project}/${args.repo}/pullRequests/${pr.number}`; + const url = pullRequestUrl(host, args.project, args.repo, pr.number); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); } diff --git a/packages/cli/src/commands/pr/view.ts b/packages/cli/src/commands/pr/view.ts index b742609..1edc604 100644 --- a/packages/cli/src/commands/pr/view.ts +++ b/packages/cli/src/commands/pr/view.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; import { formatDate } from "#utils/format.ts"; +import { pullRequestUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -43,7 +44,7 @@ export default defineCommand({ const pr = await client(`${basePath}/${args.number}`); if (args.web) { - const url = `https://${host}/git/${args.project}/${args.repo}/pullRequests/${pr.number}`; + const url = pullRequestUrl(host, args.project, args.repo, pr.number); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); return; diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 6c14237..915bf42 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -2,6 +2,7 @@ import type { BacklogProject } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -39,24 +40,8 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - let key = args.key; - - if (!name) { - name = await consola.prompt("Project name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Project name is required."); - return process.exit(1); - } - } - - if (!key) { - key = await consola.prompt("Project key:", { type: "text" }); - if (typeof key !== "string" || !key) { - consola.error("Project key is required."); - return process.exit(1); - } - } + const name = await promptRequired("Project name:", args.name); + const key = await promptRequired("Project key:", args.key); const body: Record = { name, diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index 39413bc..4d7ccbc 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -2,6 +2,7 @@ import type { BacklogProject } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { projectUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -27,7 +28,7 @@ export default defineCommand({ ); if (args.web) { - const url = `https://${host}/projects/${project.projectKey}`; + const url = projectUrl(host, project.projectKey); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); return; diff --git a/packages/cli/src/commands/repo/view.ts b/packages/cli/src/commands/repo/view.ts index 4ccf115..770558c 100644 --- a/packages/cli/src/commands/repo/view.ts +++ b/packages/cli/src/commands/repo/view.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; import { formatDate } from "#utils/format.ts"; +import { repositoryUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -34,7 +35,7 @@ export default defineCommand({ ); if (args.web) { - const url = `https://${host}/git/${args.project}/${repo.name}`; + const url = repositoryUrl(host, args.project, repo.name); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); return; diff --git a/packages/cli/src/commands/status-type/create.ts b/packages/cli/src/commands/status-type/create.ts index 7087190..e069615 100644 --- a/packages/cli/src/commands/status-type/create.ts +++ b/packages/cli/src/commands/status-type/create.ts @@ -2,6 +2,7 @@ import type { BacklogStatus } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -28,26 +29,8 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - let color = args.color; - - if (!name) { - name = await consola.prompt("Status name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Status name is required."); - return process.exit(1); - } - } - - if (!color) { - color = await consola.prompt("Display color (#hex):", { - type: "text", - }); - if (typeof color !== "string" || !color) { - consola.error("Display color is required."); - return process.exit(1); - } - } + const name = await promptRequired("Status name:", args.name); + const color = await promptRequired("Display color (#hex):", args.color); const status = await client( `/projects/${args.project}/statuses`, diff --git a/packages/cli/src/commands/team/create.ts b/packages/cli/src/commands/team/create.ts index 3492fbf..6016251 100644 --- a/packages/cli/src/commands/team/create.ts +++ b/packages/cli/src/commands/team/create.ts @@ -2,6 +2,7 @@ import type { BacklogTeam } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -22,15 +23,7 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - - if (!name) { - name = await consola.prompt("Team name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Team name is required."); - return process.exit(1); - } - } + const name = await promptRequired("Team name:", args.name); const body: Record = { name }; diff --git a/packages/cli/src/commands/webhook/create.ts b/packages/cli/src/commands/webhook/create.ts index 5a4cdfd..40dd2ae 100644 --- a/packages/cli/src/commands/webhook/create.ts +++ b/packages/cli/src/commands/webhook/create.ts @@ -2,6 +2,7 @@ import type { BacklogWebhook } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; export default defineCommand({ meta: { @@ -41,24 +42,8 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - let hookUrl = args["hook-url"]; - - if (!name) { - name = await consola.prompt("Webhook name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Webhook name is required."); - return process.exit(1); - } - } - - if (!hookUrl) { - hookUrl = await consola.prompt("Hook URL:", { type: "text" }); - if (typeof hookUrl !== "string" || !hookUrl) { - consola.error("Hook URL is required."); - return process.exit(1); - } - } + const name = await promptRequired("Webhook name:", args.name); + const hookUrl = await promptRequired("Hook URL:", args["hook-url"]); const body: Record = { name, hookUrl }; diff --git a/packages/cli/src/commands/wiki/create.ts b/packages/cli/src/commands/wiki/create.ts index a6770d3..c77db18 100644 --- a/packages/cli/src/commands/wiki/create.ts +++ b/packages/cli/src/commands/wiki/create.ts @@ -2,6 +2,7 @@ import type { BacklogWiki } from "@repo/api"; import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; +import { promptRequired } from "#utils/prompt.ts"; import { resolveProjectId } from "#utils/resolve.ts"; export default defineCommand({ @@ -34,24 +35,8 @@ export default defineCommand({ async run({ args }) { const { client } = await getClient(); - let name = args.name; - let body = args.body; - - if (!name) { - name = await consola.prompt("Page name:", { type: "text" }); - if (typeof name !== "string" || !name) { - consola.error("Page name is required."); - return process.exit(1); - } - } - - if (!body) { - body = await consola.prompt("Page content:", { type: "text" }); - if (typeof body !== "string" || !body) { - consola.error("Page content is required."); - return process.exit(1); - } - } + const name = await promptRequired("Page name:", args.name); + const body = await promptRequired("Page content:", args.body); const projectId = await resolveProjectId(client, args.project); diff --git a/packages/cli/src/commands/wiki/view.ts b/packages/cli/src/commands/wiki/view.ts index ae8372c..8f18e08 100644 --- a/packages/cli/src/commands/wiki/view.ts +++ b/packages/cli/src/commands/wiki/view.ts @@ -3,6 +3,7 @@ import { defineCommand } from "citty"; import consola from "consola"; import { getClient } from "#utils/client.ts"; import { formatDate } from "#utils/format.ts"; +import { wikiUrl } from "#utils/url.ts"; export default defineCommand({ meta: { @@ -26,7 +27,7 @@ export default defineCommand({ const wiki = await client(`/wikis/${args["wiki-id"]}`); if (args.web) { - const url = `https://${host}/alias/wiki/${wiki.id}`; + const url = wikiUrl(host, wiki.id); consola.info(`Opening ${url}`); Bun.spawn(["open", url]); return; diff --git a/packages/cli/src/utils/prompt.test.ts b/packages/cli/src/utils/prompt.test.ts new file mode 100644 index 0000000..2421d51 --- /dev/null +++ b/packages/cli/src/utils/prompt.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("consola", () => ({ + default: { + prompt: vi.fn(), + error: vi.fn(), + }, +})); + +import consola from "consola"; +import { promptRequired } from "#utils/prompt.ts"; + +describe("promptRequired", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("既存の値がある場合はそのまま返す", async () => { + const result = await promptRequired("Label:", "existing-value"); + expect(result).toBe("existing-value"); + expect(consola.prompt).not.toHaveBeenCalled(); + }); + + it("既存の値がない場合はプロンプトを表示する", async () => { + vi.mocked(consola.prompt).mockResolvedValue("user-input" as never); + + const result = await promptRequired("Label:"); + expect(consola.prompt).toHaveBeenCalledWith("Label:", { type: "text" }); + expect(result).toBe("user-input"); + }); + + it("既存の値が undefined の場合はプロンプトを表示する", async () => { + vi.mocked(consola.prompt).mockResolvedValue("prompted" as never); + + const result = await promptRequired("Label:", undefined); + expect(consola.prompt).toHaveBeenCalled(); + expect(result).toBe("prompted"); + }); + + it("プロンプトで空文字が入力された場合は process.exit(1) を呼ぶ", async () => { + vi.mocked(consola.prompt).mockResolvedValue("" as never); + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + + await promptRequired("Label:"); + + expect(consola.error).toHaveBeenCalledWith("Label is required."); + expect(mockExit).toHaveBeenCalledWith(1); + mockExit.mockRestore(); + }); + + it("ラベル末尾のコロンを除去してエラーメッセージを生成する", async () => { + vi.mocked(consola.prompt).mockResolvedValue("" as never); + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + + await promptRequired("Project key:"); + + expect(consola.error).toHaveBeenCalledWith("Project key is required."); + mockExit.mockRestore(); + }); +}); diff --git a/packages/cli/src/utils/prompt.ts b/packages/cli/src/utils/prompt.ts new file mode 100644 index 0000000..5aed24b --- /dev/null +++ b/packages/cli/src/utils/prompt.ts @@ -0,0 +1,29 @@ +import consola from "consola"; + +/** + * Prompts the user for a required text input if not already provided. + * + * Returns the existing value if non-empty, otherwise shows an interactive prompt. + * Exits with code 1 if the user provides no value. + * + * @param label - The prompt label shown to the user (e.g., "Project key:"). + * @param existing - The value already provided via CLI argument. + * @returns The resolved non-empty string value. + */ +export async function promptRequired( + label: string, + existing?: string, +): Promise { + if (existing) { + return existing; + } + + const value = await consola.prompt(label, { type: "text" }); + + if (typeof value !== "string" || !value) { + consola.error(`${label.replace(/:$/, "")} is required.`); + return process.exit(1); + } + + return value; +} diff --git a/packages/cli/src/utils/resolve.test.ts b/packages/cli/src/utils/resolve.test.ts index a42433e..8171cd4 100644 --- a/packages/cli/src/utils/resolve.test.ts +++ b/packages/cli/src/utils/resolve.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { BacklogClient } from "#utils/client.ts"; import { extractProjectKey, + resolveByName, resolveClosedStatusId, resolveIssueTypeId, resolveOpenStatusId, @@ -21,6 +22,59 @@ function createMockClient(responses: Record): BacklogClient { }) as unknown as BacklogClient; } +describe("resolveByName", () => { + it("名前でアイテムを検索してIDを返す", async () => { + const client = createMockClient({ + "/items": [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + ], + }); + + const id = await resolveByName<{ id: number; name: string }>( + client, + "/items", + "name", + "Beta", + "Item", + ); + expect(id).toBe(2); + }); + + it("見つからない場合は利用可能な名前一覧を含むエラーを投げる", async () => { + const client = createMockClient({ + "/items": [ + { id: 1, name: "Alpha" }, + { id: 2, name: "Beta" }, + ], + }); + + await expect( + resolveByName<{ id: number; name: string }>( + client, + "/items", + "name", + "Gamma", + "Item", + ), + ).rejects.toThrow('Item "Gamma" not found. Available: Alpha, Beta'); + }); + + it("空リストの場合は空の Available を含むエラーを投げる", async () => { + const client = createMockClient({ "/items": [] }); + + await expect( + resolveByName<{ id: number; name: string }>( + client, + "/items", + "name", + "X", + "Item", + ), + ).rejects.toThrow('Item "X" not found. Available: '); + }); +}); + describe("extractProjectKey", () => { it("extracts project key from issue key", () => { expect(extractProjectKey("PROJECT-123")).toBe("PROJECT"); diff --git a/packages/cli/src/utils/resolve.ts b/packages/cli/src/utils/resolve.ts index aa48a10..dac33bf 100644 --- a/packages/cli/src/utils/resolve.ts +++ b/packages/cli/src/utils/resolve.ts @@ -8,6 +8,33 @@ import type { } from "@repo/api"; import type { BacklogClient } from "#utils/client.ts"; +/** + * Generic name-to-ID resolver factory. + * + * Fetches a list from the given endpoint, finds an item by matching `nameField`, + * and returns the item's `id`. Throws a descriptive error listing available names + * when no match is found. + */ +export async function resolveByName( + client: BacklogClient, + endpoint: string, + nameField: keyof T & string, + value: string, + label: string, +): Promise { + const items = await client(endpoint); + const item = items.find((i) => (i[nameField] as unknown as string) === value); + + if (!item) { + const names = items + .map((i) => i[nameField] as unknown as string) + .join(", "); + throw new Error(`${label} "${value}" not found. Available: ${names}`); + } + + return item.id; +} + /** * Resolves a project key to a project ID. */ @@ -50,15 +77,13 @@ export async function resolvePriorityId( client: BacklogClient, name: string, ): Promise { - const priorities = await client("/priorities"); - const priority = priorities.find((p: BacklogPriority) => p.name === name); - - if (!priority) { - const names = priorities.map((p: BacklogPriority) => p.name).join(", "); - throw new Error(`Priority "${name}" not found. Available: ${names}`); - } - - return priority.id; + return resolveByName( + client, + "/priorities", + "name", + name, + "Priority", + ); } /** @@ -69,19 +94,19 @@ export async function resolveStatusId( projectKey: string, name: string, ): Promise { - const statuses = await client( + const items = await client( `/projects/${projectKey}/statuses`, ); - const status = statuses.find((s: BacklogStatus) => s.name === name); + const item = items.find((s) => s.name === name); - if (!status) { - const names = statuses.map((s: BacklogStatus) => s.name).join(", "); + if (!item) { + const names = items.map((s) => s.name).join(", "); throw new Error( `Status "${name}" not found in project ${projectKey}. Available: ${names}`, ); } - return status.id; + return item.id; } /** @@ -131,19 +156,19 @@ export async function resolveIssueTypeId( projectKey: string, name: string, ): Promise { - const types = await client( + const items = await client( `/projects/${projectKey}/issueTypes`, ); - const issueType = types.find((t: BacklogIssueType) => t.name === name); + const item = items.find((t) => t.name === name); - if (!issueType) { - const names = types.map((t: BacklogIssueType) => t.name).join(", "); + if (!item) { + const names = items.map((t) => t.name).join(", "); throw new Error( `Issue type "${name}" not found in project ${projectKey}. Available: ${names}`, ); } - return issueType.id; + return item.id; } /** @@ -153,17 +178,13 @@ export async function resolveResolutionId( client: BacklogClient, name: string, ): Promise { - const resolutions = await client("/resolutions"); - const resolution = resolutions.find( - (r: BacklogResolution) => r.name === name, + return resolveByName( + client, + "/resolutions", + "name", + name, + "Resolution", ); - - if (!resolution) { - const names = resolutions.map((r: BacklogResolution) => r.name).join(", "); - throw new Error(`Resolution "${name}" not found. Available: ${names}`); - } - - return resolution.id; } /** diff --git a/packages/cli/src/utils/url.test.ts b/packages/cli/src/utils/url.test.ts new file mode 100644 index 0000000..3b00b8d --- /dev/null +++ b/packages/cli/src/utils/url.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + buildBacklogUrl, + dashboardUrl, + issueUrl, + projectUrl, + pullRequestUrl, + repositoryUrl, + wikiUrl, +} from "#utils/url.ts"; + +describe("buildBacklogUrl", () => { + it("ホスト名とパスからURLを構築する", () => { + expect(buildBacklogUrl("example.backlog.com", "/view/PROJ-1")).toBe( + "https://example.backlog.com/view/PROJ-1", + ); + }); + + it("パスにクエリパラメータを含む場合もそのまま結合する", () => { + expect( + buildBacklogUrl( + "example.backlog.com", + "/EditProject.action?project.key=PROJ", + ), + ).toBe("https://example.backlog.com/EditProject.action?project.key=PROJ"); + }); +}); + +describe("issueUrl", () => { + it("課題キーからURLを構築する", () => { + expect(issueUrl("example.backlog.com", "PROJ-123")).toBe( + "https://example.backlog.com/view/PROJ-123", + ); + }); +}); + +describe("projectUrl", () => { + it("プロジェクトキーからURLを構築する", () => { + expect(projectUrl("example.backlog.com", "PROJ")).toBe( + "https://example.backlog.com/projects/PROJ", + ); + }); +}); + +describe("pullRequestUrl", () => { + it("プルリクエストのURLを構築する", () => { + expect(pullRequestUrl("example.backlog.com", "PROJ", "my-repo", 42)).toBe( + "https://example.backlog.com/git/PROJ/my-repo/pullRequests/42", + ); + }); +}); + +describe("repositoryUrl", () => { + it("リポジトリのURLを構築する", () => { + expect(repositoryUrl("example.backlog.com", "PROJ", "my-repo")).toBe( + "https://example.backlog.com/git/PROJ/my-repo", + ); + }); +}); + +describe("wikiUrl", () => { + it("WikiページIDからURLを構築する", () => { + expect(wikiUrl("example.backlog.com", 999)).toBe( + "https://example.backlog.com/alias/wiki/999", + ); + }); +}); + +describe("dashboardUrl", () => { + it("ダッシュボードのURLを構築する", () => { + expect(dashboardUrl("example.backlog.com")).toBe( + "https://example.backlog.com/dashboard", + ); + }); +}); diff --git a/packages/cli/src/utils/url.ts b/packages/cli/src/utils/url.ts new file mode 100644 index 0000000..f2636c0 --- /dev/null +++ b/packages/cli/src/utils/url.ts @@ -0,0 +1,51 @@ +/** + * Builds a full Backlog web URL for the given resource path. + * + * @param host - Backlog space hostname (e.g., "example.backlog.com"). + * @param path - Resource path (e.g., "/view/PROJ-1"). + */ +export function buildBacklogUrl(host: string, path: string): string { + return `https://${host}${path}`; +} + +/** Returns the URL for an issue page. */ +export function issueUrl(host: string, issueKey: string): string { + return buildBacklogUrl(host, `/view/${issueKey}`); +} + +/** Returns the URL for a project page. */ +export function projectUrl(host: string, projectKey: string): string { + return buildBacklogUrl(host, `/projects/${projectKey}`); +} + +/** Returns the URL for a pull request page. */ +export function pullRequestUrl( + host: string, + projectKey: string, + repoName: string, + prNumber: number, +): string { + return buildBacklogUrl( + host, + `/git/${projectKey}/${repoName}/pullRequests/${prNumber}`, + ); +} + +/** Returns the URL for a repository page. */ +export function repositoryUrl( + host: string, + projectKey: string, + repoName: string, +): string { + return buildBacklogUrl(host, `/git/${projectKey}/${repoName}`); +} + +/** Returns the URL for a wiki page. */ +export function wikiUrl(host: string, wikiId: number): string { + return buildBacklogUrl(host, `/alias/wiki/${wikiId}`); +} + +/** Returns the URL for the dashboard. */ +export function dashboardUrl(host: string): string { + return buildBacklogUrl(host, "/dashboard"); +}