Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 97 additions & 16 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<group>/` にコマンドファイルを作成(`defineCommand` を使用)
2. グループの `index.ts` の `subCommands` に遅延 import を追加
3. 新しいグループの場合は `src/index.ts` にも追加
4. URL 構築は `#utils/url.ts`、プロンプトは `#utils/prompt.ts`、名前解決は `#utils/resolve.ts` を使用

## API クライアント

Expand All @@ -84,16 +94,58 @@ API Key(クエリパラメータ)と OAuth 2.0(Bearer トークン)の
### 名前解決

CLI ではユーザーフレンドリーな名前を使い、API リクエスト時に内部で ID に変換する。
名前解決ロジックは `src/utils/resolve.ts` に集約。

共通パターンは `resolveByName<T>()` ジェネリックファクトリで実装し、重複を排除している:

```ts
// 汎用: エンドポイントからリストを取得し、nameField で検索して id を返す
resolveByName<T>(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 を提供

### 出力形式

Expand All @@ -105,14 +157,6 @@ CLI ではユーザーフレンドリーな名前を使い、API リクエスト
| `--jq '.[]'` | jq 変換済み出力 | 高度なフィルタ |
| `--template '{{.Key}}'` | Go template | カスタムフォーマット |

### インタラクティブモード

必須引数が省略された場合、対話的にプロンプトを表示してユーザーに入力を求める。

- TTY 接続時のみ有効
- `--no-input` フラグで無効化
- 選択式のフィールドはリスト選択 UI を提供

### プロジェクトコンテキスト

グローバルな `--project` フラグは持たない。Backlog の課題キーは `PROJECT-123` 形式で
Expand Down Expand Up @@ -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` ヘルパー

| テスト観点 | 具体例 |
Expand Down Expand Up @@ -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
```

### テストの書き方
Expand Down
25 changes: 17 additions & 8 deletions packages/cli/src/commands/browse.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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}`);
Expand Down
11 changes: 2 additions & 9 deletions packages/cli/src/commands/category/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<BacklogCategory>(
`/projects/${args.project}/categories`,
Expand Down
23 changes: 3 additions & 20 deletions packages/cli/src/commands/issue-type/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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<BacklogIssueType>(
`/projects/${args.project}/issueTypes`,
Expand Down
7 changes: 2 additions & 5 deletions packages/cli/src/commands/issue/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<BacklogComment>(
Expand Down
43 changes: 7 additions & 36 deletions packages/cli/src/commands/issue/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/issue/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -30,7 +31,7 @@ export default defineCommand({
const issue = await client<BacklogIssue>(`/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;
Expand Down
11 changes: 2 additions & 9 deletions packages/cli/src/commands/milestone/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string, unknown> = { name };

Expand Down
Loading