diff --git a/docs/design/api.ja.md b/docs/design/api.ja.md index 080078f..e0e1cd1 100644 --- a/docs/design/api.ja.md +++ b/docs/design/api.ja.md @@ -1,60 +1,31 @@ # Tasq API -このドキュメントでは、ユーザー向けの issue-tracker API を扱います。所有境界とコンポーネントの責務は [architecture.ja.md](architecture.ja.md) を参照してください。ローカル開発と検証は [operations.ja.md](operations.ja.md) を参照してください。 +この文書では、Issue Tracker API の動作と所有範囲を説明します。パス、HTTP メソッド、パラメーター、スキーマの正式な仕様は [Issue Tracker の OpenAPI 文書](../openapi/issue-tracker.yml)です。コンポーネントの所有範囲については [architecture.ja.md](architecture.ja.md)、ローカルでの運用と検証については [operations.ja.md](operations.ja.md)を参照してください。 -## API Surface +## 契約 -issue-tracker はユーザー向け API です。 +Issue Tracker は、Tasq が利用者向けに公開する API です。JSON の成功レスポンスは `{ "data": ..., "meta": {} }`、エラーレスポンスは `{ "error": { "code": "...", "message": "..." }, "meta": {} }` の形式です。 -現在の issue-tracker エンドポイント: +API を変更するときは、[development.ja.md](../development.ja.md)の手順に従って OpenAPI 文書と生成済みクライアントを更新します。この文書では、個々のスキーマ定義から切り離して説明した方が理解しやすい、エンドポイント横断の動作を扱います。 -- `GET /api/v1/health` -- `GET /api/v1/summary` -- `GET /api/v1/projects` -- `POST /api/v1/projects` -- `GET /api/v1/projects/{id}` -- `PATCH /api/v1/projects/{id}` -- `DELETE /api/v1/projects/{id}` -- `GET /api/v1/projects/{id}/workflow` -- `PUT /api/v1/projects/{id}/workflow` -- `POST /api/v1/projects/{id}/check` -- `DELETE /api/v1/projects/{id}/workflow` -- `GET /api/v1/issues` -- `POST /api/v1/issues` -- `POST /api/v1/issues/states` -- `GET /api/v1/queue` -- `GET /api/v1/issues/{id}` -- `PATCH /api/v1/issues/{id}` -- `GET /api/v1/issues/{issueId}/comments` -- `POST /api/v1/issues/{issueId}/comments` -- `PATCH /api/v1/comments/{id}` -- `GET /api/v1/issues/{issueId}/change-requests` -- `POST /api/v1/issues/{issueId}/change-requests` -- `GET /api/v1/change-requests/{id}` -- `PATCH /api/v1/change-requests/{id}` -- `POST /api/v1/change-requests/{id}/cancel` -- `GET /api/v1/attachments` -- `POST /api/v1/attachments` -- `GET /api/v1/attachments/{id}/content` -- `DELETE /api/v1/attachments/{id}` +## プロジェクト -`DELETE /api/v1/projects/{id}` は project と、その project が所有する issue-tracker 上の子孫データを削除します。対象は issues、その issues を参照する issue dependency edges、comments、change requests、attachment records と `$TQ_HOME/system/data/attachments` 配下の attachment files、保存済み project workflow overrides です。所有する issues に紐づく orchestrator runtime の子孫データである runs、runner events、workspace metadata、workspace setup failures も削除します。所有 issue に `running` の orchestrator run が 1 件でもある場合、endpoint は `409 Conflict` と `projects.delete.running_runs` を返し、issue-tracker と orchestrator のどちらのレコードも削除しません。途中失敗後も issue IDs を使って再実行できるように、orchestrator runtime records を issue-tracker records より先に削除します。`project.location` に記録されたユーザーの project directory や worktrees は削除・変更しません。 +すべての課題は、必ず1つのプロジェクトに所属します。プロジェクトのレスポンスには数値 ID とキーの両方が含まれますが、コマンドでは通常、プロジェクトキーを指定します。 -添付ファイルのアップロードは、`entity_type`、`entity_id`、`file` を持つ multipart form data を受け取ります。最初の実装では PNG、JPEG、GIF、WebP の画像ファイルを 5 MiB までサポートします。添付ファイルのバイト列は `$TQ_HOME/system/data/attachments` 配下に保存し、SQLite にはメタデータと相対パスを保存します。課題とコメントの本文は、`![screenshot](attachment://att_...)` のような Markdown image link で添付ファイルを参照します。 +`DELETE /api/v1/projects/{id}` は、プロジェクトと、そのプロジェクトが所有する次の Issue Tracker データを削除します。 -課題は必ず 1 つのプロジェクトに属します。`POST /api/v1/issues` は `projectId` を必須とし、初期の依存関係として `dependency_ids` を受け取ります。課題のレスポンスは `projectId` と `projectKey` の両方を返します。課題レスポンスには `dependency_ids` が含まれます。依存がない場合は空配列を返します。`GET /api/v1/issues` は任意の query parameter として `states`、`project_id`、`project_ids`、`priorities`、`assignee`、`search`、`limit`、`offset`、`sort_by`、`sort_direction` を受け取ります。project filter を省略した場合は、すべてのプロジェクトの課題を一覧表示します。`project_id` は 1 つのプロジェクトに絞り込み、`project_ids` はテーブルフィルター用にカンマ区切りの複数プロジェクトを受け取ります。`priorities` はカンマ区切りの優先度を受け取ります。`search` は課題 ID の完全一致と、課題タイトルの大文字小文字を区別しない部分一致で検索します。数値の search text は、完全一致する ID またはその文字列を含むタイトルに一致します。空文字または空白のみの `search` は無視します。検索は他のフィルターと組み合わせ、sorting と pagination より前に適用します。`sort_by` は `id`、`priority`、`created_at`、`updated_at` のみ、`sort_direction` は `asc`、`desc` のみ受け付けます。 +- 課題と、その課題を参照する依存関係 +- コメントと変更依頼 +- 添付ファイルのレコードと `$TQ_HOME/system/data/attachments` 以下のファイル +- 保存済みのプロジェクト別ワークフロー上書き -`GET /api/v1/summary` は課題ボードのカラムを返します。各課題サマリーには、システム全体のキューから見た課題の状態である `queueStatus` が含まれます。`queueStatus=backlog` は課題の status が `backlog` の状態です。`queueStatus=pending` は課題が `ready` だが、ブロック中の依存先が 1 件以上残っている状態です。`queueStatus=queued` は課題が `ready` で、ブロック中の依存先がない状態です。`queueStatus=processing` は課題の status が `in_progress` の状態です。`queueStatus=completed` は課題の status が `done` の状態です。`queueStatus=inactive` はキュー処理の対象外で、`review`、`blocked`、`failed`、`cancelled`、`duplicate` を含みます。status の定義と想定される遷移は [status.ja.md](status.ja.md) を参照してください。 +この操作は、対象課題が所有するオーケストレーターの実行時データも削除します。対象は、実行、ランナーイベント、ワークスペースのメタデータ、ワークスペース準備の失敗記録です。途中で失敗しても課題 ID が残っている状態から再試行できるように、オーケストレーターのデータを Issue Tracker のレコードより先に削除します。 -change request は、issue に対するユーザーまたは reviewer からの追加依頼です。workflow state を持つため comments とは分離します。`POST /api/v1/issues/{issueId}/change-requests` は `open` request を作成します。`GET /api/v1/issues/{issueId}/change-requests` は issue の requests を一覧し、任意の `status` と `limit` query parameters を受け取ります。`PATCH /api/v1/change-requests/{id}` は open request の本文編集または status 更新を行います。本文編集は request が `open` の間だけ許可します。`POST /api/v1/change-requests/{id}/cancel` は request を `canceled` に移します。物理 delete endpoint は公開しません。 +対象課題に `running` 状態のオーケストレーター実行がある場合、削除は `projects.delete.running_runs` を伴う `409 Conflict` を返し、何も変更しません。`project.location` に記録されたディレクトリや、そのワークツリーを削除または変更することはありません。 -許可される change request の遷移は `open -> in_progress`、`open -> canceled`、`in_progress -> resolved`、`in_progress -> canceled` です。`resolved` と `canceled` は immutable です。orchestrator は、前回 run を持つ issue の継続作業を開始するとき、`open` change requests を時系列で最大 20 件取得し、含めた request を `in_progress` に移して Codex continuation guidance に含めます。guidance は、対応済み request を `resolved` にし、`resolvedByRunId` に orchestrator run ID を設定し、結果 comment がある場合は `resultCommentId` を設定するよう agent に指示します。 +## 課題と依存関係 -### `POST /api/v1/issues` - -プロジェクト内に課題を作成します。`dependency_ids` は任意で、同じ create 操作の中で初期 dependency issue IDs を設定します。`dependency_ids` を省略するか空配列を渡すと、依存関係のない課題を作成します。API は存在しない dependency issue、自己依存、重複した dependency ID、dependency cycle を拒否します。 - -Request: +`POST /api/v1/issues` では `projectId` が必須です。任意の `dependency_ids` を指定すると、作成と同じ操作で初期の依存関係を設定できます。省略するか空配列を渡すと、依存関係のない課題を作成します。 ```json { @@ -68,53 +39,71 @@ Request: } ``` -Response: +`PATCH /api/v1/issues/{id}` で `dependency_ids` を指定した場合は、依存関係全体を置き換えます。このフィールドを省略すると既存の依存関係を維持し、空配列を渡すとすべて削除します。作成と更新のどちらでも、存在しない課題への依存、自分自身への依存、ID の重複、依存関係の循環を拒否します。 -```json -{ - "data": { - "id": 42, - "projectId": 1, - "projectKey": "tasq", - "title": "Document create dependencies", - "description": "Update API and schema docs.", - "status": "ready", - "priority": "normal", - "assignee": "docs", - "dependency_ids": [12, 18], - "createdAt": "2026-06-24T10:00:00Z", - "updatedAt": "2026-06-24T10:00:00Z" - }, - "meta": {} -} -``` +課題のレスポンスには `projectId`、`projectKey`、`dependency_ids` が含まれます。依存関係がない場合、`dependency_ids` は空配列です。 + +### 一覧と検索 + +`GET /api/v1/issues` では、`states`、`project_id`、`project_ids`、`priorities`、`assignee`、`search`、`limit`、`offset`、`sort_by`、`sort_direction` を使用できます。 + +- プロジェクトの絞り込みを省略すると、すべてのプロジェクトの課題を取得します。 +- `project_id` は1つのプロジェクトを選択し、`project_ids` はカンマ区切りで複数指定できます。 +- `priorities` は優先度をカンマ区切りで指定します。 +- `search` は課題 ID との完全一致、またはタイトルとの大文字・小文字を区別しない部分一致です。数字だけの検索文字列は、どちらにも一致する可能性があります。 +- 空または空白だけの `search` は無視します。 +- 検索と絞り込みを適用してから、並べ替えとページ分割を行います。 +- `sort_by` には `id`、`priority`、`created_at`、`updated_at`、`sort_direction` には `asc` または `desc` を指定できます。 + +## キューとサマリー + +`GET /api/v1/queue` は、実行準備ができた課題を `queued` と `pending` の配列に分けます。 + +- `queued` は、処理を妨げる依存関係がない課題です。 +- `pending` は、処理を妨げる依存関係が1つ以上ある課題で、`blocked_dependency_ids` を含みます。 + +依存先の状態が `done`、`cancelled`、`duplicate` の場合は解決済みとして扱い、それ以外は処理を妨げます。どちらの配列も優先度順(`urgent`、`high`、`normal`、`low`)、同じ優先度では課題 ID の昇順に並びます。課題一覧と同じ `project_id` による絞り込みを使用できます。 + +`GET /api/v1/summary` は、ボード表示用の情報を返します。各課題のサマリーには、キューでの位置づけを示す `queueStatus` が含まれます。 + +| `queueStatus` | 意味 | +|---|---| +| `backlog` | 課題の状態が `backlog`。 | +| `pending` | 課題は `ready` だが、処理を妨げる依存関係がある。 | +| `queued` | 課題は `ready` で、処理を妨げる依存関係がない。 | +| `processing` | 課題の状態が `in_progress`。 | +| `completed` | 課題の状態が `done`。 | +| `inactive` | `review`、`blocked`、`failed`、`cancelled`、`duplicate` など、キューの処理対象外。 | + +状態の定義と遷移については [status.ja.md](status.ja.md)を参照してください。 + +## コメントと変更依頼 + +コメントは議論を記録します。変更依頼は、利用者やレビュアーが追加で求める作業を表し、ワークフロー上の状態を持ちます。 + +課題に変更依頼を作成すると、状態は `open` になります。`open` の変更依頼は、内容を編集するか `in_progress` に移行できます。`in_progress` の変更依頼は、解決済みまたは取り消しにできます。許可する遷移は次のとおりです。 + +- `open -> in_progress` +- `open -> canceled` +- `in_progress -> resolved` +- `in_progress -> canceled` + +`resolved` と `canceled` の変更依頼は変更できません。取り消しには `POST /api/v1/change-requests/{id}/cancel` を使用し、物理削除のエンドポイントは提供しません。 + +オーケストレーターが過去の実行を持つ課題の作業を継続するときは、`open` の変更依頼を古い順に最大20件取得し、対象を `in_progress` に移して Codex の継続指示へ加えます。継続指示では、対応した変更依頼に `resolvedByRunId` を設定し、結果コメントがある場合は `resultCommentId` も設定して `resolved` にするようエージェントへ求めます。 + +## 添付ファイル -`PATCH /api/v1/issues/{id}` は、任意の full replacement field として `dependency_ids` を受け取ります。省略した場合、既存の依存関係は維持されます。空配列を渡すと、すべての依存関係を削除します。API は存在しない dependency issue、自己依存、重複した dependency ID、dependency cycle を作る更新を拒否します。 +添付ファイルのアップロードには、`entity_type`、`entity_id`、`file` を含む multipart form data を使用します。PNG、JPEG、GIF、WebP 形式の画像を5 MiBまでアップロードできます。 -`GET /api/v1/queue` は `ready` の課題を `queued` と `pending` の配列に分けて返します。`queued` は `ready` かつブロック中の依存先がない課題です。`pending` は `ready` だが、ブロック中の依存先が 1 件以上残っている課題です。満たされた依存先の status は `done`、`cancelled`、`duplicate` です。それ以外の依存先 status は課題を `pending` に残します。各配列は priority desc(`urgent`, `high`, `normal`, `low`)と ID asc で並びます。この endpoint は課題一覧と同じ `project_id` filter semantics を受け取ります。`pending` の項目には、pending の原因になっている依存先の `blocked_dependency_ids` が含まれます。 +ファイル本体は `$TQ_HOME/system/data/attachments` 以下に保存し、SQLite にはメタデータと相対パスを保存します。課題の説明とコメントでは、`![screenshot](attachment://att_...)` のような Markdown で画像を参照します。 -JSON の成功レスポンスは `{ "data": ..., "meta": {} }` を使います。JSON のエラーレスポンスは `{ "error": { "code": "...", "message": "..." }, "meta": {} }` を使います。 +## CLI からの利用 -`tq` CLI は課題 CRUD エンドポイントを次のコマンドでラップします。 +通常の課題、コメント、プロジェクト、ワークフロー操作には、型付きの `tq` コマンドを使用します。必要な Issue Tracker 操作が型付きコマンドにない場合に限り、許可リストで制限された `tq api` を使用します。 -- `tq issue list [--project ]` -- `tq issue get ` -- `tq issue create --project --title [--description ...] [--status ...] [--priority ...] [--assignee ...] [--dependency <ids>]` -- `tq issue update <id> [--title ...] [--description ...] [--status ...] [--priority ...] [--assignee ...] [--dependency <ids>] [--clear-dependencies]` -- `tq issue create ... --attach <image-path>` -- `tq issue update <id> ... --attach <image-path>` -- `tq issue close <id>` -- `tq issue cancel <id>` -- `tq issue ready <id>` -- `tq issue draft <id>` -- `tq issue rename <id> <title>` -- `tq issue edit <id> <description>` -- `tq comment add <issue-id> --body <body> [--attach <image-path>]` -- `tq comment list <issue-id>` -- `tq workflow add --project <project-key> (--file <path> | --body <text>)` -- `tq workflow remove --project <project-key>` -- `tq workflow show --project <project-key> [--json]` +生の API コマンドは、HTTP メソッドとルートの独自の許可リストを持ち、閉じた状態を既定とします。OpenAPI にルートを追加しても自動では公開しません。また、multipart リクエストの組み立てに対応していないため、`POST /api/v1/attachments` を一時的に除外しています。構文、入力検証、出力、終了ステータスについては、[tq コマンドリファレンス](../references/tq.ja.md#生の-api-リクエスト)を参照してください。 -`tq` は既定では人が読みやすい出力を使い、`--output json` が指定された場合は JSON 出力を使います。 +## オーケストレーター調査 API -orchestrator は、`--port` または `server.port` で有効化したときに、実行時調査用の任意の loopback HTTP API を公開します。課題の実行時詳細レスポンスには過去の実行サマリーが含まれます。各実行は、Codex app-server thread が永続化された後に `thread_id` を含む場合があります。 +オーケストレーターは、`--port` または `server.port` で有効にした場合に、実行時の調査に使う別のループバック HTTP API を公開できます。この API は Issue Tracker API には含まれません。課題の実行時詳細には過去の実行サマリーが含まれ、Codex app-server のスレッドを永続化した後は、各実行に `thread_id` が含まれる場合があります。 diff --git a/docs/design/api.md b/docs/design/api.md index 549c515..341218a 100644 --- a/docs/design/api.md +++ b/docs/design/api.md @@ -1,60 +1,31 @@ # Tasq API -This document covers the user-facing issue-tracker API. For ownership boundaries and component responsibilities, see [architecture.md](architecture.md). For local development and verification, see [operations.md](operations.md). +This document describes the issue-tracker API's behavior and ownership boundaries. The [issue-tracker OpenAPI document](../openapi/issue-tracker.yml) is the normative source for paths, methods, parameters, and schemas. For component ownership, see [architecture.md](architecture.md); for local operation and verification, see [operations.md](operations.md). -## API Surface +## Contract -The issue-tracker is the user-facing API. +The issue-tracker is Tasq's user-facing API. Successful JSON responses use `{ "data": ..., "meta": {} }`; JSON errors use `{ "error": { "code": "...", "message": "..." }, "meta": {} }`. -Current issue-tracker endpoints: +API changes must update the OpenAPI document and generated clients as described in [development.md](../development.md). This document records cross-endpoint behavior that is easier to understand outside individual schema definitions. -- `GET /api/v1/health` -- `GET /api/v1/summary` -- `GET /api/v1/projects` -- `POST /api/v1/projects` -- `GET /api/v1/projects/{id}` -- `PATCH /api/v1/projects/{id}` -- `DELETE /api/v1/projects/{id}` -- `GET /api/v1/projects/{id}/workflow` -- `PUT /api/v1/projects/{id}/workflow` -- `POST /api/v1/projects/{id}/check` -- `DELETE /api/v1/projects/{id}/workflow` -- `GET /api/v1/issues` -- `POST /api/v1/issues` -- `POST /api/v1/issues/states` -- `GET /api/v1/queue` -- `GET /api/v1/issues/{id}` -- `PATCH /api/v1/issues/{id}` -- `GET /api/v1/issues/{issueId}/comments` -- `POST /api/v1/issues/{issueId}/comments` -- `PATCH /api/v1/comments/{id}` -- `GET /api/v1/issues/{issueId}/change-requests` -- `POST /api/v1/issues/{issueId}/change-requests` -- `GET /api/v1/change-requests/{id}` -- `PATCH /api/v1/change-requests/{id}` -- `POST /api/v1/change-requests/{id}/cancel` -- `GET /api/v1/attachments` -- `POST /api/v1/attachments` -- `GET /api/v1/attachments/{id}/content` -- `DELETE /api/v1/attachments/{id}` +## Projects -`DELETE /api/v1/projects/{id}` deletes the project and all issue-tracker descendants owned by that project: issues, issue dependency edges that reference those issues, comments, change requests, attachment records and attachment files under `$TQ_HOME/system/data/attachments`, and stored project workflow overrides. It also deletes orchestrator runtime descendants for the owned issues: runs, runner events, workspace metadata, and workspace setup failures. If any owned issue has a `running` orchestrator run, the endpoint returns `409 Conflict` with `projects.delete.running_runs` and does not delete issue-tracker or orchestrator records. Orchestrator runtime records are deleted before issue-tracker records so a partial failure can be retried while the issue IDs are still available. It does not delete or modify the user's project directory recorded in `project.location` or any worktrees. +Every issue belongs to exactly one project. Project responses identify the project by both numeric ID and key, while commands generally accept the project key. -Attachment uploads accept multipart form data with `entity_type`, `entity_id`, and `file`. The first implementation supports PNG, JPEG, GIF, and WebP image files up to 5 MiB. Attachment bytes are stored below `$TQ_HOME/system/data/attachments`, while SQLite stores metadata and relative paths. Issue and comment text references attachments with Markdown image links such as `![screenshot](attachment://att_...)`. +`DELETE /api/v1/projects/{id}` deletes the project and all issue-tracker descendants it owns: -Issues belong to exactly one project. `POST /api/v1/issues` requires `projectId` and accepts `dependency_ids` as the initial dependency set. Issue responses include both `projectId` and `projectKey`. Issue responses include `dependency_ids`; issues without dependencies return an empty array. `GET /api/v1/issues` accepts optional `states`, `project_id`, `project_ids`, `priorities`, `assignee`, `search`, `limit`, `offset`, `sort_by`, and `sort_direction` query parameters. Omitting project filters lists issues across all projects. `project_id` limits the list to one project, while `project_ids` accepts a comma-separated set for table filters. `priorities` accepts comma-separated issue priorities. `search` matches issue IDs exactly and issue titles with case-insensitive partial matching; numeric search text matches either the exact ID or a title containing that text. Empty or whitespace-only `search` values are ignored. Search is combined with other filters before sorting and pagination. `sort_by` is limited to `id`, `priority`, `created_at`, and `updated_at`; `sort_direction` is limited to `asc` and `desc`. +- issues and dependency edges that reference those issues; +- comments and change requests; +- attachment records and files under `$TQ_HOME/system/data/attachments`; and +- stored project workflow overrides. -`GET /api/v1/summary` returns issue board columns. Each issue summary includes `queueStatus`, which is the issue state from the system-wide queue perspective. `queueStatus=backlog` means the issue status is `backlog`. `queueStatus=pending` means the issue is `ready` but still has at least one blocking dependency. `queueStatus=queued` means the issue is `ready` and has no blocking dependencies. `queueStatus=processing` means the issue status is `in_progress`. `queueStatus=completed` means the issue status is `done`. `queueStatus=inactive` means the issue is outside the queue flow, including `review`, `blocked`, `failed`, `cancelled`, and `duplicate`. See [status.md](status.md) for status definitions and expected transitions. +The operation also deletes orchestrator runtime data owned by those issues: runs, runner events, workspace metadata, and workspace setup failures. It deletes orchestrator data before issue-tracker records so a partial failure can be retried while the issue IDs still exist. -Change requests are additional user or reviewer requests for an issue. They are separate from comments because they carry workflow state. `POST /api/v1/issues/{issueId}/change-requests` creates an `open` request. `GET /api/v1/issues/{issueId}/change-requests` lists requests for the issue and accepts optional `status` and `limit` query parameters. `PATCH /api/v1/change-requests/{id}` updates an open request body or advances status. Body edits are allowed only while the request is `open`. `POST /api/v1/change-requests/{id}/cancel` moves a request to `canceled`; no physical delete endpoint is exposed. +If an owned issue has a `running` orchestrator run, deletion returns `409 Conflict` with `projects.delete.running_runs` and changes nothing. Project deletion never deletes or modifies the directory in `project.location` or its worktrees. -Allowed change request transitions are `open -> in_progress`, `open -> canceled`, `in_progress -> resolved`, and `in_progress -> canceled`. `resolved` and `canceled` requests are immutable. When the orchestrator starts continuation work for an issue that already has a previous run, it fetches up to 20 `open` change requests in chronological order, moves each included request to `in_progress`, and includes them in the Codex continuation guidance. The guidance instructs the agent to mark handled requests `resolved` with `resolvedByRunId` set to the orchestrator run ID and `resultCommentId` when a result comment is available. +## Issues and dependencies -### `POST /api/v1/issues` - -Creates an issue in a project. `dependency_ids` is optional and sets the initial dependency issue IDs during the same create operation. Omit `dependency_ids` or pass an empty array to create an issue without dependencies. The API rejects missing dependency issues, self-dependencies, duplicate dependency IDs, and dependency cycles. - -Request: +`POST /api/v1/issues` requires `projectId`. The optional `dependency_ids` field sets the initial dependency set in the same operation. Omitting it or passing an empty array creates an issue without dependencies. ```json { @@ -68,53 +39,71 @@ Request: } ``` -Response: +`PATCH /api/v1/issues/{id}` treats `dependency_ids` as a full replacement when present. Omitting the field preserves existing dependencies; passing an empty array removes all dependencies. Create and update both reject missing dependency issues, self-dependencies, duplicate IDs, and dependency cycles. -```json -{ - "data": { - "id": 42, - "projectId": 1, - "projectKey": "tasq", - "title": "Document create dependencies", - "description": "Update API and schema docs.", - "status": "ready", - "priority": "normal", - "assignee": "docs", - "dependency_ids": [12, 18], - "createdAt": "2026-06-24T10:00:00Z", - "updatedAt": "2026-06-24T10:00:00Z" - }, - "meta": {} -} -``` +Issue responses include `projectId`, `projectKey`, and `dependency_ids`. The dependency list is an empty array when the issue has no dependencies. + +### Listing and search + +`GET /api/v1/issues` supports `states`, `project_id`, `project_ids`, `priorities`, `assignee`, `search`, `limit`, `offset`, `sort_by`, and `sort_direction`. + +- Omitting project filters lists issues across every project. +- `project_id` selects one project; `project_ids` accepts a comma-separated set. +- `priorities` accepts comma-separated priorities. +- `search` matches an issue ID exactly or a title with a case-insensitive substring match. Numeric text may match either. +- Empty or whitespace-only `search` values are ignored. +- Search and filters are applied before sorting and pagination. +- `sort_by` accepts `id`, `priority`, `created_at`, or `updated_at`; `sort_direction` accepts `asc` or `desc`. + +## Queue and summary + +`GET /api/v1/queue` divides ready issues into `queued` and `pending` arrays: + +- `queued` issues have no blocking dependencies. +- `pending` issues have at least one blocking dependency and include `blocked_dependency_ids`. + +Dependencies in `done`, `cancelled`, or `duplicate` are satisfied; every other status remains blocking. Both arrays sort by priority (`urgent`, `high`, `normal`, `low`) and then by ascending issue ID. The endpoint accepts the same `project_id` filter as issue listing. + +`GET /api/v1/summary` exposes the board view. Each issue summary includes a queue-oriented `queueStatus`: + +| `queueStatus` | Meaning | +|---|---| +| `backlog` | Issue status is `backlog`. | +| `pending` | Issue is `ready` with a blocking dependency. | +| `queued` | Issue is `ready` without a blocking dependency. | +| `processing` | Issue status is `in_progress`. | +| `completed` | Issue status is `done`. | +| `inactive` | Issue is outside the queue flow, including `review`, `blocked`, `failed`, `cancelled`, and `duplicate`. | + +See [status.md](status.md) for status definitions and transition expectations. + +## Comments and change requests + +Comments record discussion. Change requests represent additional user or reviewer work and carry workflow state. + +Creating a change request under an issue sets its status to `open`. Open requests may be edited or moved to `in_progress`; in-progress requests may be resolved or canceled. The complete transition set is: + +- `open -> in_progress` +- `open -> canceled` +- `in_progress -> resolved` +- `in_progress -> canceled` + +Resolved and canceled requests are immutable. Cancellation uses `POST /api/v1/change-requests/{id}/cancel`; there is no physical delete endpoint. + +When the orchestrator continues an issue with a previous run, it fetches up to 20 open requests in chronological order, moves the included requests to `in_progress`, and adds them to the Codex continuation guidance. The guidance asks the agent to resolve handled requests with `resolvedByRunId` and, when available, `resultCommentId`. + +## Attachments -`PATCH /api/v1/issues/{id}` accepts `dependency_ids` as an optional full replacement field. When omitted, existing dependencies are preserved. Passing an empty array removes all dependencies. The API rejects missing dependency issues, self-dependencies, duplicate dependency IDs, and updates that would create a dependency cycle. +Attachment upload uses multipart form data with `entity_type`, `entity_id`, and `file`. Supported files are PNG, JPEG, GIF, and WebP images up to 5 MiB. -`GET /api/v1/queue` returns ready issues split into `queued` and `pending` arrays. `queued` issues are ready and have no blocking dependencies; `pending` issues are ready but still have at least one blocking dependency. Satisfied dependency statuses are `done`, `cancelled`, and `duplicate`; every other dependency status keeps the issue pending. Each array is sorted by priority descending (`urgent`, `high`, `normal`, `low`) and then ID ascending. The endpoint accepts the same `project_id` filter semantics as issue listing. Pending items include `blocked_dependency_ids` for dependencies that keep the issue pending. +File bytes are stored below `$TQ_HOME/system/data/attachments`; SQLite stores metadata and relative paths. Issue descriptions and comments refer to images with Markdown such as `![screenshot](attachment://att_...)`. -JSON success responses use `{ "data": ..., "meta": {} }`. JSON error responses use `{ "error": { "code": "...", "message": "..." }, "meta": {} }`. +## CLI access -The `tq` CLI wraps issue CRUD endpoints with these commands: +Use typed `tq` commands for routine issue, comment, project, and workflow operations. Use the allowlisted `tq api` command only when no typed command exposes the required issue-tracker operation. -- `tq issue list [--project <project-key>]` -- `tq issue get <id>` -- `tq issue create --project <project-key> --title <title> [--description ...] [--status ...] [--priority ...] [--assignee ...] [--dependency <ids>]` -- `tq issue update <id> [--title ...] [--description ...] [--status ...] [--priority ...] [--assignee ...] [--dependency <ids>] [--clear-dependencies]` -- `tq issue create ... --attach <image-path>` -- `tq issue update <id> ... --attach <image-path>` -- `tq issue close <id>` -- `tq issue cancel <id>` -- `tq issue ready <id>` -- `tq issue draft <id>` -- `tq issue rename <id> <title>` -- `tq issue edit <id> <description>` -- `tq comment add <issue-id> --body <body> [--attach <image-path>]` -- `tq comment list <issue-id>` -- `tq workflow add --project <project-key> (--file <path> | --body <text>)` -- `tq workflow remove --project <project-key>` -- `tq workflow show --project <project-key> [--json]` +The raw command has its own fail-closed method-and-route allowlist. It does not automatically expose new OpenAPI routes, and it temporarily excludes `POST /api/v1/attachments` because multipart request construction is not supported. See the [tq command reference](../references/tq.md#raw-api-requests) for syntax, validation, output, and exit-status behavior. -`tq` uses human-readable output by default and JSON output when `--output json` is set. +## Orchestrator inspection API -The orchestrator exposes an optional loopback HTTP API for runtime inspection when enabled with `--port` or `server.port`. Its issue runtime detail response includes historical run summaries; each run may include `thread_id` once the Codex app-server thread has been persisted. +The orchestrator can expose a separate loopback HTTP API for runtime inspection when enabled with `--port` or `server.port`. It is not part of the issue-tracker API. Issue runtime details include historical run summaries, and a run may include `thread_id` after its Codex app-server thread has been persisted. diff --git a/docs/references/tq.ja.md b/docs/references/tq.ja.md index 7a6a638..99699c2 100644 --- a/docs/references/tq.ja.md +++ b/docs/references/tq.ja.md @@ -1,16 +1,16 @@ # tq コマンドリファレンス -`tq` は issue-tracker API 用のコマンドラインクライアントです。生の HTTP リクエストを直接扱わずに、エージェント、ワークフローツール、ローカル開発用コマンドから課題の作成、確認、更新、コメント追加を行うために使います。 +`tq` は Tasq のコマンドラインクライアントです。Issue Tracker の一般的な操作に対応する型付きコマンド、ローカルサービスとマイグレーションの管理コマンド、許可リストで制限された生の API コマンドを提供します。 ## 実行方法 -ローカルの dev container 開発環境では Makefile ターゲットを使います。 +ローカルの開発コンテナでは Makefile ターゲットを使います。 ```sh make run-tq ARGS="issue list" ``` -このターゲットは、サービスプロセスを起動、停止、再起動せずに、起動済みの dev container 内でインストール済みの `tq` バイナリを実行します。既定のワークフローでは、`tq` は `$TQ_HOME/system/state.json` から issue-tracker API を解決します。 +このターゲットは、サービスプロセスを起動、停止、再起動せずに、起動済みの開発コンテナ内でインストール済みの `tq` バイナリを実行します。既定のワークフローでは、`tq` は `$TQ_HOME/system/state.json` から Issue Tracker API の接続先を解決します。 ホストだけで動かすワークフローでは、`tq` を直接実行することもできます。 @@ -21,27 +21,56 @@ TQ_HOME=./.tasq go run ./cmd/tq --api-url http://localhost:37651 issue list ## グローバルオプション ```text -tq [--api-url URL] [--output text|json] <resource|command> <action> [flags] +tq [--api-url URL] [--output text|json] <command> [args] [flags] ``` -| Option | Default | Description | +| オプション | 既定値 | 説明 | |---|---|---| -| `--api-url URL` | `TQ_API_URL`、その後 `$TQ_HOME/system/state.json`、その後 `http://localhost:37651` | issue-tracker API のベース URL。 | +| `--api-url URL` | `TQ_API_URL`、その後 `$TQ_HOME/system/state.json`、その後 `http://localhost:37651` | Issue Tracker API のベース URL。 | | `--output text\|json` | `text` | 出力形式。JSON 出力はスクリプトやエージェント向けです。 | -## リソース +## コマンド -| Resource | Actions | +| コマンド | 操作または用途 | |---|---| -| `issue` | `create`, `get`, `list`, `update` | -| `comment` | `add`, `list` | -| `project` | `add`, `remove`, `check`, `list` | -| `workflow` | `add`, `remove`, `show` | -| `migrate` | 保留中のマイグレーションを適用、`down`、`status` | -| `service` | `start`, `stop`, `status` | -| `config` | build、home、解決済み設定情報を表示 | +| `issue` | `create`、`get`、`list`、`watch`、`update`、`close`、`cancel`、`ready`、`draft`、`rename`、`edit` | +| `comment` | `add`、`list` | +| `project` | `add`、`remove`、`check`、`list` | +| `workflow` | `add`、`remove`、`show` | +| `migrate` | 未適用マイグレーションの適用、`down` によるロールバック、`status` による状態確認 | +| `service` | `start`、`stop`、`status` | +| `logs` | サービスログの表示または追跡 | +| `web` | 実行中の Web UI を開く | +| `config` | ビルド、ホーム、解決済みの設定情報を表示 | | `update` | リリースをインストールしてサービスを再起動 | | `version` | バージョン情報を表示 | +| `api` | 許可リストにある Issue Tracker API へ生のリクエストを送信 | + +## 生の API リクエスト + +Issue Tracker の操作が型付きコマンドとして提供されていない場合に、`tq api` を使用します。このコマンドは、解決済みの Issue Tracker ベース URL に生のリクエストを送信します。汎用 HTTP クライアントではありません。API の意味については [Tasq API](../design/api.ja.md)、正式なエンドポイント契約については [OpenAPI 文書](../openapi/issue-tracker.yml)を参照してください。 + +```sh +tq api GET /api/v1/issues --query states=ready + +tq api POST /api/v1/issues --header 'X-Request-ID: local-123' --data @request.json +``` + +構文は次のとおりです。 + +```text +tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-] +``` + +HTTP メソッドは大文字と小文字を区別せず、内部で大文字に正規化します。パスには、エンコードされていない `/api/v1/...` 形式の絶対パスを指定します。完全 URL、フラグメント、ドットセグメント、空のセグメント、末尾のスラッシュは使用できません。パスに直接記述したクエリは保持し、繰り返し指定した `--query key=value` は指定順で追加します。クエリの名前と値は意味を検証せず、API に渡します。 + +HTTP メソッドとパスは、CLI が明示的に保持する現行 Issue Tracker ルートの許可リストに一致する必要があります。数値 ID は正の `int64` に限定します。許可リストは閉じた状態を既定とする設計であり、サーバーにルートを追加しても、CLI の許可リストを更新するまでは使用できません。生の multipart をまだ扱えないため、`POST /api/v1/attachments` は一時的に除外しています。添付ファイルに対する `PATCH` も許可しません。 + +`--header` は繰り返し指定できます。ヘッダー名は大文字と小文字を区別せず、同名の場合は最後の値を使います。`Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Trailer`、`Upgrade`、`Proxy-Connection` など、HTTP トランスポートが管理するヘッダーは指定できません。 + +`--data` には、リテラル値、`@file`、または標準入力を示す `-` を指定できます。使用できる HTTP メソッドは `POST`、`PUT`、`PATCH` です。本文が JSON として妥当かどうかは検証しません。本文があり、`Content-Type` ヘッダーを明示しなかった場合は `application/json` を使用します。 + +書き込みや削除操作でも確認は求めません。リダイレクトは追跡せず、HTTP のタイムアウトは 10 秒です。バイナリデータや HTTP エラーの本文も含め、レスポンスのバイト列を変更せずに標準出力へ書き出します。`--output` を指定しても変換しません。終了ステータスは、HTTP `2xx` が `0`、HTTP `3xx`~`5xx` または通信失敗が `1`、使用方法・入力・許可リストのエラーが `2` です。 ## バージョン @@ -55,7 +84,7 @@ tq version ## 設定 -`tq config` は、バージョン、build profile、`TQ_HOME` override、解決済みの home directory、設定ファイルのパス、解決済み設定値を表示します。機械可読な出力には `--output json` を指定します。設定ファイルの生 YAML は表示しません。 +`tq config` は、バージョン、ビルドプロファイル、`TQ_HOME` の上書き値、解決済みのホームディレクトリ、設定ファイルのパス、解決済みの設定値を表示します。機械可読な出力には `--output json` を指定します。設定ファイルの YAML はそのまま表示しません。 ## 更新 @@ -73,13 +102,13 @@ tq update -y 既定では最新の正式リリースをインストールします。特定の正式リリースまたは prerelease tag をインストールする場合は `--tag` を渡します。 -non-empty の build profile を持つバイナリでは、generic release artifact がその profile を保持しないため、`tq update` は利用できません。 +空でないビルドプロファイルを持つバイナリでは、汎用のリリース成果物がそのプロファイルを保持しないため、`tq update` は利用できません。 ```sh tq update --tag v0.2.0-rc.1 ``` -更新フローは、サービス停止、固定のユーザーインストール先への release artifacts インストール、新しくインストールした `tq version` の確認、マイグレーション適用、サービス起動の順に進みます。いずれかの工程が失敗した場合、後続の工程は実行されません。 +更新処理は、サービス停止、固定のユーザーインストール先へのリリース成果物のインストール、新しくインストールした `tq version` の確認、マイグレーション適用、サービス起動の順に進みます。いずれかの工程が失敗した場合、後続の工程は実行されません。 ## 課題 @@ -119,16 +148,16 @@ make run-tq ARGS='issue create --project tasq --title "Write tq reference"' フラグ: -| Flag | Required | Description | +| フラグ | 必須 | 説明 | |---|---:|---| -| `--project KEY` | yes | 課題を所有するプロジェクトキー。 | -| `--title TITLE` | yes | 課題のタイトル。 | -| `--description TEXT` | no | 課題の説明。 | -| `--status STATUS` | no | 課題のステータス。省略時は `backlog` です。 | -| `--priority PRIORITY` | no | 課題の優先度。省略時は `normal` です。 | -| `--assignee NAME` | no | 担当者名。 | -| `--dependency IDS` | no | カンマ区切りの課題 ID で依存関係を設定します。空の値は拒否されます。 | -| `--attach PATH` | no | PNG、JPEG、GIF、WebP 画像をアップロードし、説明に Markdown 画像参照を追記します。 | +| `--project KEY` | はい | 課題を所有するプロジェクトキー。 | +| `--title TITLE` | はい | 課題のタイトル。 | +| `--description TEXT` | いいえ | 課題の説明。 | +| `--status STATUS` | いいえ | 課題のステータス。省略時は `backlog` です。 | +| `--priority PRIORITY` | いいえ | 課題の優先度。省略時は `normal` です。 | +| `--assignee NAME` | いいえ | 担当者名。 | +| `--dependency IDS` | いいえ | カンマ区切りの課題 ID で依存関係を設定します。空の値は拒否されます。 | +| `--attach PATH` | いいえ | PNG、JPEG、GIF、WebP 画像をアップロードし、説明に Markdown 画像参照を追記します。 | 例: @@ -148,7 +177,7 @@ make run-tq ARGS='issue update 1 --status in_progress' フラグ: -| Flag | Description | +| フラグ | 説明 | |---|---| | `--title TITLE` | 課題のタイトルを置き換えます。 | | `--description TEXT` | 課題の説明を置き換えます。 | @@ -159,7 +188,32 @@ make run-tq ARGS='issue update 1 --status in_progress' | `--clear-dependencies` | すべての依存関係を削除します。`--dependency` と同時には指定できません。 | | `--attach PATH` | PNG、JPEG、GIF、WebP 画像をアップロードし、説明に Markdown 画像参照を追記します。 | -添付参照は `![filename](attachment://<id>)` の形式です。issue-tracker は attachment content API で画像を配信し、Web UI は Markdown から画像を表示します。 +添付ファイルは `![filename](attachment://<id>)` の形式で参照します。Issue Tracker は添付ファイル取得 API で画像を配信し、Web UI は Markdown 内の参照を画像として表示します。 + +### `issue watch` + +実行準備ができた課題のキューを定期的に取得し、監視やエージェントへの割り当てに使う JSON オブジェクトを1行ずつ出力します。`event` レコードには新しく検出した課題が入り、一時的な API エラーはループを停止せず `error` レコードとして出力します。このコマンドは常に JSON Lines 形式を使い、グローバルの `--output` 設定を無視します。 + +| フラグ | 既定値 | 説明 | +|---|---:|---| +| `--interval SECONDS` | `30` | 取得間隔。正の値を指定します。 | +| `--seen-ttl SECONDS` | `900` | 同じ課題の再出力を抑止する期間。`--interval` より大きい値を指定します。 | +| `--verbose` | 無効 | 取得状況を `info` レコードとして追加出力します。 | + +### 課題操作の短縮コマンド + +次のコマンドは、`issue update` のフラグを指定せずに1つの項目を更新します。 + +```text +tq issue close <id> +tq issue cancel <id> +tq issue ready <id> +tq issue draft <id> +tq issue rename <id> <title> +tq issue edit <id> <description> +``` + +状態変更の短縮コマンドは、順に `done`、`cancelled`、`ready`、`backlog` を設定します。 ## コメント @@ -173,12 +227,12 @@ make run-tq ARGS='comment add 1 --body "Started implementation."' フラグ: -| Flag | Required | Description | +| フラグ | 必須 | 説明 | |---|---:|---| -| `--body TEXT` | yes | コメント本文。 | -| `--author NAME` | no | コメントの作成者。省略時は `TQ_AUTHOR`、その後 `USER` を使います。 | -| `--type TYPE` | no | コメント種別。省略時は `general` です。 | -| `--attach PATH` | no | PNG、JPEG、GIF、WebP 画像をアップロードし、コメント本文に Markdown 画像参照を追記します。 | +| `--body TEXT` | はい | コメント本文。 | +| `--author NAME` | いいえ | コメントの作成者。省略時は `TQ_AUTHOR`、その後 `USER` を使います。 | +| `--type TYPE` | いいえ | コメント種別。省略時は `general` です。 | +| `--attach PATH` | いいえ | PNG、JPEG、GIF、WebP 画像をアップロードし、コメント本文に Markdown 画像参照を追記します。 | ### `comment list` @@ -192,7 +246,7 @@ make run-tq ARGS="comment list 1" ### `service start` -issue-tracker、orchestrator、web をホストローカルのバックグラウンドプロセスとして起動します。サービスプロセスを起動する前に、ローカルの issue-tracker と orchestrator のデータベースを開き、保留中のマイグレーションがないか確認します。保留中のマイグレーションがある場合は、`tq migrate` の実行を促してすぐに終了します。保留中のものがなければ issue-tracker を先に起動し、health endpoint を待ってから orchestrator と web を起動します。ログは `$TQ_HOME/system/log/` 配下へ追記されます。 +Issue Tracker、オーケストレーター、Web UI をホスト上のバックグラウンドプロセスとして起動します。サービスプロセスを起動する前に、ローカルの Issue Tracker とオーケストレーターのデータベースを開き、未適用のマイグレーションがないか確認します。未適用のマイグレーションがある場合は、`tq migrate` の実行を促してすぐに終了します。なければ Issue Tracker を先に起動し、ヘルスチェックの成功を待ってからオーケストレーターと Web UI を起動します。ログは `$TQ_HOME/system/log/` 以下へ追記します。 ```sh TQ_HOME=./.tasq go run ./cmd/tq service start @@ -200,7 +254,7 @@ TQ_HOME=./.tasq go run ./cmd/tq service start 既定のサービスポート: -| Service | Port | Log | +| サービス | ポート | ログ | |---|---:|---| | issue-tracker | `37651` | `$TQ_HOME/system/log/issue-tracker.log` | | orchestrator | `37652` | `$TQ_HOME/system/log/orchestrator.log` | @@ -222,12 +276,32 @@ TQ_HOME=./.tasq go run ./cmd/tq --output json service status ### `service stop` -orchestrator を先に停止し、その後 issue-tracker を停止します。各プロセスに `SIGTERM` を送り、猶予期間内に終了しない場合は強制終了します。 +オーケストレーターを先に停止し、その後 Issue Tracker を停止します。各プロセスに `SIGTERM` を送り、猶予期間内に終了しない場合は強制終了します。 ```sh TQ_HOME=./.tasq go run ./cmd/tq service stop ``` +## ログと Web UI + +サービスログの末尾1,000行を表示します。 + +```sh +tq logs issue-tracker +tq logs orchestrator +tq logs web +``` + +表示行数は `-n LINES`、追記内容の継続表示は `-f` で指定します。`tracker` は `issue-tracker` の別名です。このコマンドは `$TQ_HOME/system/log/` 以下のファイルを読み取り、`--output json` には対応しません。 + +実行中の Web UI を既定のブラウザーで開きます。 + +```sh +tq web +``` + +Web UI の URL はローカルサービスの状態から取得します。Web UI が起動していない場合、コマンドは失敗します。 + ## プロジェクト ### `project add` @@ -242,7 +316,7 @@ make run-tq ARGS="project add ." フラグ: -| Flag | Description | +| フラグ | 説明 | |---|---| | `--key KEY` | プロジェクトキー。省略時はプロジェクトディレクトリ名から kebab-case のキーを生成します。 | @@ -280,19 +354,19 @@ make run-tq ARGS="project check tasq" ### `project remove` -プロジェクトキーを指定してプロジェクトを削除します。既定では、`project remove` は取り消し不可の操作である警告と、削除対象になるプロジェクトおよび子孫データを表示し、削除を開始する前に正確なプロジェクトキーの入力を求めます。削除では、プロジェクトと、課題、コメント、添付ファイル、ワークフロー上書き、run data などの子孫データが削除されます。 +プロジェクトキーを指定してプロジェクトを削除します。既定では、`project remove` は取り消せない操作であることと、削除対象になるプロジェクトおよび子孫データを表示し、削除を開始する前に正確なプロジェクトキーの入力を求めます。削除対象には、プロジェクト、課題、コメント、添付ファイル、ワークフロー上書き、実行データなどが含まれます。 ```sh make run-tq ARGS="project remove tasq" ``` -agent や script から使う場合は、`-y` でプロンプトをスキップできます。 +エージェントやスクリプトから使う場合は、`-y` で確認を省略できます。 ```sh make run-tq ARGS="project remove -y tasq" ``` -プロジェクトに実行中の run がある場合、削除前にコマンドは失敗し、API が返した理由を表示します。 +プロジェクトに実行中の処理がある場合、削除前にコマンドは失敗し、API が返した理由を表示します。 ## ワークフロー @@ -323,7 +397,7 @@ make run-tq ARGS="workflow show --project tasq" このコマンドは、ワークフロー解決と同じ参照順序を使います。 1. 登録済みプロジェクトの場所にある `WORKFLOW.md`。 -2. issue-tracker API に保存されたプロジェクトワークフロー。 +2. Issue Tracker API に保存されたプロジェクトワークフロー。 3. グローバルな `$TQ_HOME/WORKFLOW.md`。 テキスト出力では、`# Source: ...` ヘッダーに続けて解決済みの `WORKFLOW.md` の内容を出力します。構造化された出力には `--json` またはグローバルの `--output json` を使います。 @@ -336,7 +410,7 @@ make run-tq ARGS="workflow show --project tasq --json" ### `migrate` -`$TQ_HOME` 配下にあるローカルの issue-tracker と orchestrator のデータベースに、保留中の SQLite マイグレーションをすべて適用します。 +`$TQ_HOME` 以下にあるローカルの Issue Tracker とオーケストレーターのデータベースに、未適用の SQLite マイグレーションをすべて適用します。 ```sh make run-tq ARGS="migrate" @@ -377,6 +451,8 @@ in_progress review blocked failed +cancelled +duplicate done ``` @@ -402,4 +478,4 @@ general プロジェクトパスは、ホストローカルの絶対パスとして保存されます。つまり `project add .` は、`/workspace` のようなコンテナ内だけの実行時パスではなく、ホストマシン上でユーザーから見えるパスを記録します。 -issue-tracker API は、プロジェクトパスが絶対パスであることを検証しますが、API サーバーのファイルシステム上に存在するかどうかは検証しません。`tq project add` クライアントが、プロジェクトレコードを作成する前にローカルで存在確認を行います。 +Issue Tracker API は、プロジェクトパスが絶対パスであることを検証しますが、API サーバーのファイルシステム上に存在するかどうかは検証しません。`tq project add` が、プロジェクトレコードを作成する前にローカルで存在確認を行います。 diff --git a/docs/references/tq.md b/docs/references/tq.md index 011ce30..a8589cd 100644 --- a/docs/references/tq.md +++ b/docs/references/tq.md @@ -1,6 +1,6 @@ # tq Command Reference -`tq` is the command-line client for the issue-tracker API. It is intended for agents, workflow tools, and local development commands that need to create, inspect, update, and annotate issues without dealing with raw HTTP requests. +`tq` is the command-line client for Tasq. It provides typed commands for common issue-tracker operations, local service and migration commands, and an allowlisted raw API command. ## Invocation @@ -21,7 +21,7 @@ TQ_HOME=./.tasq go run ./cmd/tq --api-url http://localhost:37651 issue list ## Global Options ```text -tq [--api-url URL] [--output text|json] <resource|command> <action> [flags] +tq [--api-url URL] [--output text|json] <command> [args] [flags] ``` | Option | Default | Description | @@ -29,19 +29,48 @@ tq [--api-url URL] [--output text|json] <resource|command> <action> [flags] | `--api-url URL` | `TQ_API_URL`, then `$TQ_HOME/system/state.json`, then `http://localhost:37651` | Issue-tracker API base URL. | | `--output text\|json` | `text` | Output format. JSON output is intended for scripts and agents. | -## Resources +## Commands -| Resource | Actions | +| Command | Actions or purpose | |---|---| -| `issue` | `create`, `get`, `list`, `update` | +| `issue` | `create`, `get`, `list`, `watch`, `update`, `close`, `cancel`, `ready`, `draft`, `rename`, `edit` | | `comment` | `add`, `list` | | `project` | `add`, `remove`, `check`, `list` | | `workflow` | `add`, `remove`, `show` | -| `migrate` | apply pending migrations, `down`, `status` | +| `migrate` | apply pending migrations, roll back with `down`, or inspect with `status` | | `service` | `start`, `stop`, `status` | +| `logs` | show or follow service logs | +| `web` | open the running Web UI | | `config` | show build, home, and resolved configuration information | | `update` | install a release and restart services | | `version` | show version information | +| `api` | send an allowlisted raw issue-tracker API request | + +## Raw API requests + +Use `tq api` when the issue-tracker operation is not exposed by a typed command. It sends a raw request to the resolved issue-tracker base URL; it is not a general-purpose HTTP client. See [Tasq API](../design/api.md) for API semantics and the [OpenAPI document](../openapi/issue-tracker.yml) for the normative endpoint contract. + +```sh +tq api GET /api/v1/issues --query states=ready + +tq api POST /api/v1/issues --header 'X-Request-ID: local-123' --data @request.json +``` + +The command syntax is: + +```text +tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-] +``` + +Methods are case-insensitive and normalized to uppercase. The path must be an unencoded absolute `/api/v1/...` path; complete URLs, fragments, dot segments, empty segments, and trailing slashes are rejected. Query text in the path is preserved, and each repeatable `--query key=value` appends another value in order. Query names and values are passed to the API without semantic validation. + +The method and path must match the CLI's explicit allowlist of current issue-tracker routes. Numeric route IDs must be positive `int64` values. This is fail-closed: a newly added server route is unavailable until the CLI allowlist is updated. `POST /api/v1/attachments` is temporarily excluded while raw multipart support is unavailable; attachment `PATCH` is not allowed. + +`--header` may be repeated. Header names are case-insensitive, and the last value wins. Transport-managed headers, including `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, `Trailer`, `Upgrade`, and `Proxy-Connection`, are rejected. + +`--data` accepts a literal value, `@file`, or `-` for standard input and is available only for `POST`, `PUT`, and `PATCH`. The body is not validated as JSON. A request with a body defaults to `Content-Type: application/json` unless the header is supplied explicitly. + +The command does not prompt before write or delete operations, does not follow redirects, and uses a 10-second HTTP timeout. It writes response bytes unchanged to standard output, including binary data and HTTP error bodies; `--output` does not transform them. Exit status is `0` for HTTP `2xx`, `1` for HTTP `3xx`-`5xx` or transport failures, and `2` for usage, input, and allowlist errors. ## Version @@ -161,6 +190,31 @@ Flags: Attachment references use `![filename](attachment://<id>)`. The issue-tracker serves those images through the attachment content API, and the Web UI renders them from Markdown. +### `issue watch` + +Poll the ready queue and emit one JSON object per line for monitoring and agent dispatch. `event` records contain newly observed queued issues; transient API failures produce `error` records without stopping the loop. The command always uses this JSON-line protocol and ignores the global `--output` setting. + +| Flag | Default | Description | +|---|---:|---| +| `--interval SECONDS` | `30` | Polling interval. Must be positive. | +| `--seen-ttl SECONDS` | `900` | Suppress re-emitting an issue for this period. Must exceed `--interval`. | +| `--verbose` | disabled | Also emit polling details as `info` records. | + +### Issue shortcuts + +Shortcut commands update one field without requiring `issue update` flags: + +```text +tq issue close <id> +tq issue cancel <id> +tq issue ready <id> +tq issue draft <id> +tq issue rename <id> <title> +tq issue edit <id> <description> +``` + +The status shortcuts set `done`, `cancelled`, `ready`, and `backlog`, respectively. + ## Comments ### `comment add` @@ -228,6 +282,26 @@ Stop orchestrator first and issue-tracker second. Each process receives `SIGTERM TQ_HOME=./.tasq go run ./cmd/tq service stop ``` +## Logs and Web UI + +Show the last 1,000 lines of a service log: + +```sh +tq logs issue-tracker +tq logs orchestrator +tq logs web +``` + +Use `-n LINES` to change the number of lines and `-f` to follow appended output. `tracker` is an alias for `issue-tracker`. The command reads files below `$TQ_HOME/system/log/` and does not support `--output json`. + +Open the running Web UI in the default browser: + +```sh +tq web +``` + +The command reads the Web URL from local service state and fails if the Web UI is not running. + ## Projects ### `project add` @@ -377,6 +451,8 @@ in_progress review blocked failed +cancelled +duplicate done ``` diff --git a/internal/cli/tq/command.go b/internal/cli/tq/command.go index c1c7c94..1d9e68a 100644 --- a/internal/cli/tq/command.go +++ b/internal/cli/tq/command.go @@ -47,6 +47,10 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. client: client, } if err := application.route(ctx, remaining, cfg); err != nil { + var statusErr apiStatusError + if errors.As(err, &statusErr) { + return statusErr.code + } var ce cliError if errors.As(err, &ce) { return writeCLIErrorForFormat(stderr, cfg.output, ce.message, ce.code) @@ -78,6 +82,12 @@ func (a app) route(ctx context.Context, args []string, cfg config) error { return a.routeProject(ctx, args[1:], cfg) case "workflow": return a.routeWorkflow(ctx, args[1:], cfg) + case "api": + if len(args) == 1 || args[1] == "help" || args[1] == "-help" || args[1] == "--help" { + printAPIHelp(a.stdout) + return nil + } + return a.api(ctx, args[1:]) case "migrate": return a.routeMigrate(ctx, args[1:], cfg) case "web": diff --git a/internal/cli/tq/command_api.go b/internal/cli/tq/command_api.go new file mode 100644 index 0000000..5915667 --- /dev/null +++ b/internal/cli/tq/command_api.go @@ -0,0 +1,283 @@ +package tq + +import ( + "context" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type apiStatusError struct{ code int } + +func (e apiStatusError) Error() string { return "HTTP request failed" } + +type repeatedValue []string + +func (v *repeatedValue) String() string { return strings.Join(*v, ",") } +func (v *repeatedValue) Set(value string) error { + *v = append(*v, value) + return nil +} + +type apiRequest struct { + method string + path string + headers http.Header + body io.Reader +} + +func (a app) api(ctx context.Context, args []string) error { + req, err := a.parseAPIRequest(args) + if err != nil { + return err + } + response, err := a.client.doRaw(ctx, req) + if file, ok := req.body.(*os.File); ok { + _ = file.Close() + } + if err != nil { + return err + } + defer response.Body.Close() + if _, err := io.Copy(a.stdout, response.Body); err != nil { + return fmt.Errorf("write response: %w", err) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return apiStatusError{code: 1} + } + return nil +} + +func (a app) parseAPIRequest(args []string) (apiRequest, error) { + if len(args) < 2 { + return apiRequest{}, usageError("usage: tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-]") + } + method := strings.ToUpper(args[0]) + path, err := validateAPIPath(args[1]) + if err != nil { + return apiRequest{}, err + } + var queries, headers repeatedValue + fs := newFlagSet("api") + fs.Var(&queries, "query", "append query parameter") + fs.Var(&headers, "header", "set HTTP header") + data := fs.String("data", "", "request body") + if err := fs.Parse(args[2:]); err != nil { + return apiRequest{}, usageError("%s", err) + } + if len(fs.Args()) != 0 { + return apiRequest{}, usageError("usage: tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-]") + } + if !allowedAPIRoute(method, path) { + return apiRequest{}, usageError("method and path are not allowed: %s %s", method, path) + } + path, err = appendAPIQuery(path, queries) + if err != nil { + return apiRequest{}, err + } + + requestHeaders, err := parseAPIHeaders(headers) + if err != nil { + return apiRequest{}, err + } + var body io.Reader + dataProvided := false + fs.Visit(func(f *flag.Flag) { + if f.Name == "data" { + dataProvided = true + } + }) + if dataProvided { + if method != http.MethodPost && method != http.MethodPut && method != http.MethodPatch { + return apiRequest{}, usageError("--data is only allowed with POST, PUT, or PATCH") + } + body, err = apiBody(*data, a.stdin) + if err != nil { + return apiRequest{}, err + } + if requestHeaders.Get("Content-Type") == "" { + requestHeaders.Set("Content-Type", "application/json") + } + } + return apiRequest{method: method, path: path, headers: requestHeaders, body: body}, nil +} + +func apiBody(value string, stdin io.Reader) (io.Reader, error) { + if value == "-" { + return stdin, nil + } + if strings.HasPrefix(value, "@") { + file, err := os.Open(strings.TrimPrefix(value, "@")) + if err != nil { + return nil, usageError("read data file: %v", err) + } + return file, nil + } + return strings.NewReader(value), nil +} + +func appendAPIQuery(path string, queries []string) (string, error) { + if len(queries) == 0 { + return path, nil + } + parts := make([]string, 0, len(queries)) + for _, query := range queries { + key, value, ok := strings.Cut(query, "=") + if !ok { + return "", usageError("query must be key=value") + } + parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(value)) + } + separator := "?" + if strings.Contains(path, "?") { + separator = "&" + } + return path + separator + strings.Join(parts, "&"), nil +} + +func parseAPIHeaders(values []string) (http.Header, error) { + headers := make(http.Header) + for _, value := range values { + name, headerValue, ok := strings.Cut(value, ":") + name = strings.TrimSpace(name) + if !ok || name == "" { + return nil, usageError("header must be 'Name: value'") + } + if !isValidHeaderName(name) { + return nil, usageError("header name %q is invalid", name) + } + if strings.ContainsAny(headerValue, "\r\n") { + return nil, usageError("header %q must not contain a newline", name) + } + if isTransportHeader(name) { + return nil, usageError("header %q is managed by the HTTP transport", name) + } + headers.Set(name, strings.TrimSpace(headerValue)) + } + return headers, nil +} + +func isValidHeaderName(value string) bool { + for _, character := range value { + if !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + strings.ContainsRune("!#$%&'*+-.^_`|~", character)) { + return false + } + } + return value != "" +} + +func isTransportHeader(name string) bool { + switch strings.ToLower(name) { + case "host", "content-length", "transfer-encoding", "connection", "trailer", "upgrade", "proxy-connection", "keep-alive", "te": + return true + default: + return false + } +} + +func validateAPIPath(value string) (string, error) { + path, _, _ := strings.Cut(value, "?") + if !strings.HasPrefix(value, "/") || strings.Contains(value, "#") || strings.Contains(path, "%") { + return "", usageError("path must be an unencoded absolute API path without a fragment") + } + if path == "/" || strings.HasSuffix(path, "/") || strings.Contains(path, "//") { + return "", usageError("path must not contain empty segments or a trailing slash") + } + for _, segment := range strings.Split(strings.TrimPrefix(path, "/"), "/") { + if segment == "." || segment == ".." { + return "", usageError("path must not contain dot segments") + } + } + return value, nil +} + +func allowedAPIRoute(method, requestPath string) bool { + path, _, _ := strings.Cut(requestPath, "?") + for _, route := range apiRoutes { + if route.method == method && route.matches(path) { + return true + } + } + return false +} + +type apiRoute struct{ method, template string } + +func (r apiRoute) matches(path string) bool { + want, got := strings.Split(strings.TrimPrefix(r.template, "/"), "/"), strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(want) != len(got) { + return false + } + for i := range want { + if want[i] == "{id}" || want[i] == "{issueId}" { + if !isPositiveInt64(got[i]) { + return false + } + continue + } + if want[i] == "{attachmentId}" { + if got[i] == "" { + return false + } + continue + } + if want[i] != got[i] { + return false + } + } + return true +} + +func isPositiveInt64(value string) bool { + if value == "" { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + parsed, err := strconv.ParseInt(value, 10, 64) + return err == nil && parsed > 0 +} + +// apiRoutes is intentionally explicit: new server endpoints remain unavailable until reviewed here. +var apiRoutes = []apiRoute{ + {"GET", "/api/v1/health"}, {"GET", "/api/v1/summary"}, + {"GET", "/api/v1/projects"}, {"POST", "/api/v1/projects"}, {"GET", "/api/v1/projects/{id}"}, {"PATCH", "/api/v1/projects/{id}"}, {"DELETE", "/api/v1/projects/{id}"}, {"POST", "/api/v1/projects/{id}/check"}, + {"GET", "/api/v1/projects/{id}/workflow"}, {"PUT", "/api/v1/projects/{id}/workflow"}, {"DELETE", "/api/v1/projects/{id}/workflow"}, + {"GET", "/api/v1/issues"}, {"POST", "/api/v1/issues"}, {"POST", "/api/v1/issues/states"}, {"GET", "/api/v1/queue"}, {"GET", "/api/v1/issues/{id}"}, {"PATCH", "/api/v1/issues/{id}"}, + {"GET", "/api/v1/issues/{issueId}/comments"}, {"POST", "/api/v1/issues/{issueId}/comments"}, {"PATCH", "/api/v1/comments/{id}"}, + {"GET", "/api/v1/issues/{issueId}/change-requests"}, {"POST", "/api/v1/issues/{issueId}/change-requests"}, {"GET", "/api/v1/change-requests/{id}"}, {"PATCH", "/api/v1/change-requests/{id}"}, {"POST", "/api/v1/change-requests/{id}/cancel"}, + {"GET", "/api/v1/attachments"}, {"GET", "/api/v1/attachments/{attachmentId}/content"}, {"DELETE", "/api/v1/attachments/{attachmentId}"}, +} + +func (c *apiClient) doRaw(ctx context.Context, input apiRequest) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, input.method, c.baseURL+input.path, input.body) + if err != nil { + return nil, err + } + req.Header = input.headers + client := c.rawHTTPClient() + return client.Do(req) +} + +func (c *apiClient) rawHTTPClient() *http.Client { + client := http.Client{Timeout: 10 * time.Second} + if c.httpClient != nil { + client = *c.httpClient + } + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return &client +} diff --git a/internal/cli/tq/command_api_test.go b/internal/cli/tq/command_api_test.go new file mode 100644 index 0000000..c0d50fe --- /dev/null +++ b/internal/cli/tq/command_api_test.go @@ -0,0 +1,239 @@ +package tq + +import ( + "bytes" + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAllowedAPIRoutes(t *testing.T) { + expected := []apiRoute{ + {"GET", "/api/v1/health"}, {"GET", "/api/v1/summary"}, + {"GET", "/api/v1/projects"}, {"POST", "/api/v1/projects"}, {"GET", "/api/v1/projects/{id}"}, {"PATCH", "/api/v1/projects/{id}"}, {"DELETE", "/api/v1/projects/{id}"}, {"POST", "/api/v1/projects/{id}/check"}, + {"GET", "/api/v1/projects/{id}/workflow"}, {"PUT", "/api/v1/projects/{id}/workflow"}, {"DELETE", "/api/v1/projects/{id}/workflow"}, + {"GET", "/api/v1/issues"}, {"POST", "/api/v1/issues"}, {"POST", "/api/v1/issues/states"}, {"GET", "/api/v1/queue"}, {"GET", "/api/v1/issues/{id}"}, {"PATCH", "/api/v1/issues/{id}"}, + {"GET", "/api/v1/issues/{issueId}/comments"}, {"POST", "/api/v1/issues/{issueId}/comments"}, {"PATCH", "/api/v1/comments/{id}"}, + {"GET", "/api/v1/issues/{issueId}/change-requests"}, {"POST", "/api/v1/issues/{issueId}/change-requests"}, {"GET", "/api/v1/change-requests/{id}"}, {"PATCH", "/api/v1/change-requests/{id}"}, {"POST", "/api/v1/change-requests/{id}/cancel"}, + {"GET", "/api/v1/attachments"}, {"GET", "/api/v1/attachments/{attachmentId}/content"}, {"DELETE", "/api/v1/attachments/{attachmentId}"}, + } + if len(apiRoutes) != len(expected) { + t.Fatalf("allowlist has %d routes, want %d", len(apiRoutes), len(expected)) + } + expectedSet := make(map[string]struct{}, len(expected)) + for _, route := range expected { + expectedSet[apiRouteKey(route)] = struct{}{} + } + if len(expectedSet) != len(expected) { + t.Fatal("expected allowlist contains duplicate routes") + } + actualSet := make(map[string]struct{}, len(apiRoutes)) + for _, route := range apiRoutes { + actualSet[apiRouteKey(route)] = struct{}{} + } + if len(actualSet) != len(apiRoutes) { + t.Fatal("allowlist contains duplicate routes") + } + for key := range expectedSet { + if _, ok := actualSet[key]; !ok { + t.Fatalf("allowlist is missing %s", key) + } + } + tests := []struct{ method, path string }{ + {"GET", "/api/v1/health"}, {"GET", "/api/v1/summary"}, + {"GET", "/api/v1/projects"}, {"POST", "/api/v1/projects"}, {"GET", "/api/v1/projects/1"}, {"PATCH", "/api/v1/projects/1"}, {"DELETE", "/api/v1/projects/1"}, {"POST", "/api/v1/projects/1/check"}, + {"GET", "/api/v1/projects/1/workflow"}, {"PUT", "/api/v1/projects/1/workflow"}, {"DELETE", "/api/v1/projects/1/workflow"}, + {"GET", "/api/v1/issues"}, {"POST", "/api/v1/issues"}, {"POST", "/api/v1/issues/states"}, {"GET", "/api/v1/queue"}, {"GET", "/api/v1/issues/1"}, {"PATCH", "/api/v1/issues/1"}, + {"GET", "/api/v1/issues/1/comments"}, {"POST", "/api/v1/issues/1/comments"}, {"PATCH", "/api/v1/comments/1"}, + {"GET", "/api/v1/issues/1/change-requests"}, {"POST", "/api/v1/issues/1/change-requests"}, {"GET", "/api/v1/change-requests/1"}, {"PATCH", "/api/v1/change-requests/1"}, {"POST", "/api/v1/change-requests/1/cancel"}, + {"GET", "/api/v1/attachments"}, {"GET", "/api/v1/attachments/att_1/content"}, {"DELETE", "/api/v1/attachments/att_1"}, + } + for _, test := range tests { + t.Run(test.method+" "+test.path, func(t *testing.T) { + if !allowedAPIRoute(test.method, test.path) { + t.Fatal("route is not allowed") + } + }) + } +} + +func apiRouteKey(route apiRoute) string { return route.method + " " + route.template } + +func TestAPIRejectsInvalidRoutesAndPaths(t *testing.T) { + for _, args := range [][]string{ + {"POST", "/api/v1/attachments"}, {"PATCH", "/api/v1/attachments/att_1"}, {"HEAD", "/api/v1/issues"}, {"GET", "/api/v1/unknown"}, {"GET", "/api/v1/issues/0"}, {"GET", "/api/v1/issues/-1"}, {"GET", "/api/v1/issues/9223372036854775808"}, {"GET", "/api/v1/issues/abc"}, + {"GET", "https://example.test/api/v1/issues"}, {"GET", "/api/v1/issues/"}, {"GET", "/api/v1/../issues"}, {"GET", "/api//v1/issues"}, {"GET", "/api/v1/%69ssues"}, {"GET", "/api/v1/issues#part"}, + } { + _, err := (app{}).parseAPIRequest(args) + if err == nil { + t.Fatalf("args %v: expected error", args) + } + } + if _, err := (app{}).parseAPIRequest([]string{"GET", "/api/v1/issues?search=100%25"}); err != nil { + t.Fatalf("encoded query must remain permitted: %v", err) + } + for _, args := range [][]string{ + {"GET", "/api/v1/issues", "--query", "missing-value"}, + {"GET", "/api/v1/issues", "--header", "missing-colon"}, + {"GET", "/api/v1/issues", "--header", "Bad Name: value"}, + {"GET", "/api/v1/issues", "--header", "X-Test: first\nsecond"}, + } { + if _, err := (app{}).parseAPIRequest(args); err == nil { + t.Fatalf("args %v: expected error", args) + } + } +} + +func TestAPIForwardsRequestAndResponseBytes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/attachments" { + _, _ = w.Write([]byte{0x00, 0xff, '\n'}) + return + } + if r.Method != http.MethodPatch || r.URL.Path != "/api/v1/issues/42" { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + if got, want := r.URL.Query()["state"], []string{"ready", "again"}; strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("query=%v", got) + } + if r.Header.Get("X-Test") != "second" || r.Header.Get("Content-Type") != "application/json" { + t.Fatalf("headers=%v", r.Header) + } + body, _ := io.ReadAll(r.Body) + if string(body) != `{"status":"done"}` { + t.Fatalf("body=%q", body) + } + _, _ = w.Write([]byte{0x00, 0xff, '\n'}) + })) + defer server.Close() + stdout, stderr, code := runAPI(t, server.URL, strings.NewReader(""), []string{"patch", "/api/v1/issues/42?state=ready", "--query", "state=again", "--header", "X-Test: first", "--header", "x-test: second", "--data", `{"status":"done"}`}) + if code != 0 || stderr != "" || stdout != string([]byte{0x00, 0xff, '\n'}) { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + stdout, stderr, code = runAPI(t, server.URL, strings.NewReader(""), []string{"--output", "json", "GET", "/api/v1/attachments"}) + if code != 0 || stderr != "" || stdout != string([]byte{0x00, 0xff, '\n'}) { + t.Fatalf("json output must stay raw: code=%d stdout=%q stderr=%q", code, stdout, stderr) + } +} + +func TestAPIReadsDataFileAndDoesNotValidateLiteralJSON(t *testing.T) { + file := filepath.Join(t.TempDir(), "request.txt") + if err := os.WriteFile(file, []byte("from-file"), 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if string(body) != "from-file" && string(body) != "not-json" { + t.Fatalf("body=%q", body) + } + _, _ = w.Write([]byte("ok")) + })) + defer server.Close() + for _, data := range []string{"@" + file, "not-json"} { + stdout, stderr, code := runAPI(t, server.URL, strings.NewReader(""), []string{"POST", "/api/v1/issues", "--data", data}) + if code != 0 || stderr != "" || stdout != "ok" { + t.Fatalf("data=%q code=%d stdout=%q stderr=%q", data, code, stdout, stderr) + } + } +} + +func TestAPIStatusRedirectAndInputErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/health" { + w.Header().Set("Location", "/api/v1/issues") + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte("redirect")) + return + } + if r.URL.Path == "/api/v1/summary" { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("server-error")) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("bad")) + })) + defer server.Close() + for _, test := range []struct { + args []string + want string + code int + }{ + {[]string{"GET", "/api/v1/health"}, "redirect", 1}, {[]string{"GET", "/api/v1/issues"}, "bad", 1}, {[]string{"GET", "/api/v1/summary"}, "server-error", 1}, + {[]string{"GET", "/api/v1/issues", "--data", "x"}, "", 2}, {[]string{"GET", "/api/v1/issues", "--header", "Host: example.test"}, "", 2}, + } { + stdout, stderr, code := runAPI(t, server.URL, strings.NewReader(""), test.args) + if code != test.code || stdout != test.want || stderr == "" && code == 2 { + t.Fatalf("args=%v code=%d stdout=%q stderr=%q", test.args, code, stdout, stderr) + } + if code == 1 && stderr != "" { + t.Fatalf("HTTP status must not write stderr: %q", stderr) + } + } +} + +func TestAPIReadsDataFromStdinAndHonorsClientTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/issues" { + body, _ := io.ReadAll(r.Body) + if string(body) != "stdin" { + t.Fatalf("body=%q", body) + } + return + } + time.Sleep(time.Second) + })) + defer server.Close() + _, stderr, code := runAPI(t, server.URL, strings.NewReader("stdin"), []string{"POST", "/api/v1/issues", "--data", "-"}) + if code != 0 || stderr != "" { + t.Fatalf("stdin code=%d stderr=%q", code, stderr) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + _, stderr, code = runAPIContext(t, ctx, server.URL, strings.NewReader(""), []string{"GET", "/api/v1/summary"}) + if code != 1 || stderr == "" { + t.Fatalf("timeout code=%d stderr=%q", code, stderr) + } +} + +func TestAPIEmptyResponseAndConnectionFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + stdout, stderr, code := runAPI(t, server.URL, strings.NewReader(""), []string{"GET", "/api/v1/health"}) + if code != 0 || stdout != "" || stderr != "" { + t.Fatalf("204 code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + stdout, stderr, code = runAPI(t, "http://"+address, strings.NewReader(""), []string{"GET", "/api/v1/health"}) + if code != 1 || stdout != "" || stderr == "" { + t.Fatalf("connection failure code=%d stdout=%q stderr=%q", code, stdout, stderr) + } +} + +func runAPI(t *testing.T, apiURL string, stdin io.Reader, args []string) (string, string, int) { + return runAPIContext(t, context.Background(), apiURL, stdin, args) +} + +func runAPIContext(t *testing.T, ctx context.Context, apiURL string, stdin io.Reader, args []string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + code := run(ctx, append([]string{"--api-url", apiURL, "api"}, args...), stdin, &stdout, &stderr) + return stdout.String(), stderr.String(), code +} diff --git a/internal/cli/tq/command_help.go b/internal/cli/tq/command_help.go index 805eecd..962a739 100644 --- a/internal/cli/tq/command_help.go +++ b/internal/cli/tq/command_help.go @@ -6,13 +6,14 @@ import ( ) func printRootHelp(w io.Writer) { - fmt.Fprintln(w, "Usage: tq [--api-url URL] [--output text|json] <resource> <action> [flags]") + fmt.Fprintln(w, "Usage: tq [--api-url URL] [--output text|json] <resource|command> <action> [flags]") fmt.Fprintln(w) fmt.Fprintln(w, "Resources:") fmt.Fprintln(w, " issue create, get, list, update, and shortcut issue actions") fmt.Fprintln(w, " comment add and list issue comments") fmt.Fprintln(w, " project add, remove, check, and list projects") fmt.Fprintln(w, " workflow add, remove, and show project workflow resolution") + fmt.Fprintln(w, " api send an allowlisted raw issue-tracker API request") fmt.Fprintln(w, " migrate apply, roll back, and inspect local database migrations") fmt.Fprintln(w, " web open the running Web UI in the default browser") fmt.Fprintln(w, " service start, stop, and inspect local services") @@ -22,6 +23,12 @@ func printRootHelp(w io.Writer) { fmt.Fprintln(w, " update update tq from a GitHub Release and restart services") } +func printAPIHelp(w io.Writer) { + fmt.Fprintln(w, "Usage: tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-]") + fmt.Fprintln(w) + fmt.Fprintln(w, "Send a raw request to an allowlisted /api/v1 path. Response bytes are written unchanged to stdout.") +} + func printConfigHelp(w io.Writer) { fmt.Fprintln(w, "Usage: tq config") fmt.Fprintln(w)