From cc52dd85ed07ca0a394221ee23c65fac58129092 Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 30 Jul 2026 17:31:30 +0900 Subject: [PATCH 1/5] feat(cli): add raw API command Add a fail-closed tq api command with strict route validation, repeatable query and header options, request body input, raw response forwarding, and status-based exit codes. Include CLI tests and synchronized English and Japanese API and command references. --- docs/design/api.ja.md | 4 + docs/design/api.md | 4 + docs/references/tq.ja.md | 27 +++ docs/references/tq.md | 27 +++ internal/cli/tq/command.go | 10 + internal/cli/tq/command_api.go | 283 ++++++++++++++++++++++++++++ internal/cli/tq/command_api_test.go | 180 ++++++++++++++++++ internal/cli/tq/command_help.go | 9 +- 8 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 internal/cli/tq/command_api.go create mode 100644 internal/cli/tq/command_api_test.go diff --git a/docs/design/api.ja.md b/docs/design/api.ja.md index 080078fa..4b1c65ad 100644 --- a/docs/design/api.ja.md +++ b/docs/design/api.ja.md @@ -117,4 +117,8 @@ JSON の成功レスポンスは `{ "data": ..., "meta": {} }` を使います `tq` は既定では人が読みやすい出力を使い、`--output json` が指定された場合は JSON 出力を使います。 +`tq api ` は、同じ issue-tracker ベース URL 解決を使う、制約付きの生 API 呼び出しです。method と route template の許可リストは CLI 内で管理し、API に route が増えても fail-closed になります。現在の許可リストは上記 endpoint のうち一時的に除外する `POST /api/v1/attachments` 以外を対象にします。attachment の `PATCH` は公開しません。エンコードされていない厳格な `/api/v1/...` path だけを受け付け、method は大文字に正規化します。path 内の生 query と、指定順に追加する繰り返し指定可能な `--query key=value` を使えます。`--header 'Name: value'` も繰り返し指定でき、同名は最後の値を使用します。transport が管理する header は拒否します。`--data value|@file|-` は `POST`、`PUT`、`PATCH` に限定し、content type を省略した場合は JSON を使います。 + +このコマンドは redirect を追跡せず、破壊的な操作でも確認を求めません。timeout は 10 秒で、envelope の解析や出力変換を行わずにレスポンスのバイト列をコピーします。HTTP `2xx` は終了ステータス `0`、受信した `3xx`-`5xx` レスポンスは本文コピー後に `1`、transport 失敗は `1`、入力・許可リストのエラーは `2` です。 + orchestrator は、`--port` または `server.port` で有効化したときに、実行時調査用の任意の loopback HTTP API を公開します。課題の実行時詳細レスポンスには過去の実行サマリーが含まれます。各実行は、Codex app-server thread が永続化された後に `thread_id` を含む場合があります。 diff --git a/docs/design/api.md b/docs/design/api.md index 549c515d..cf01b88a 100644 --- a/docs/design/api.md +++ b/docs/design/api.md @@ -117,4 +117,8 @@ The `tq` CLI wraps issue CRUD endpoints with these commands: `tq` uses human-readable output by default and JSON output when `--output json` is set. +`tq api ` provides a constrained raw escape hatch for the same issue-tracker base URL resolution. Its method and route-template allowlist is maintained in the CLI and fails closed when the API gains a route. The current allowlist covers every endpoint above except the temporary exclusion `POST /api/v1/attachments`; attachment `PATCH` is not exposed. It accepts only strict unencoded `/api/v1/...` paths, normalizes methods to uppercase, permits raw query text plus ordered repeated `--query key=value`, and accepts repeated `--header 'Name: value'` with last-value-wins semantics. It rejects transport-managed headers. `--data value|@file|-` is limited to `POST`, `PUT`, and `PATCH`, and defaults its content type to JSON when omitted. + +The command does not follow redirects or prompt for destructive operations, uses a 10-second timeout, and copies response bytes without envelope parsing or output formatting. HTTP `2xx` exits with status `0`; received `3xx`-`5xx` responses exit `1` after copying their bodies; transport failures exit `1`; input and allowlist failures exit `2`. + 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. diff --git a/docs/references/tq.ja.md b/docs/references/tq.ja.md index 7a6a638d..baac2a99 100644 --- a/docs/references/tq.ja.md +++ b/docs/references/tq.ja.md @@ -29,6 +29,8 @@ tq [--api-url URL] [--output text|json] [flags] | `--api-url URL` | `TQ_API_URL`、その後 `$TQ_HOME/system/state.json`、その後 `http://localhost:37651` | issue-tracker API のベース URL。 | | `--output text\|json` | `text` | 出力形式。JSON 出力はスクリプトやエージェント向けです。 | +`tq api` のレスポンスは `--output` で変換しません。常にレスポンスのバイト列をそのまま出力します。 + ## リソース | Resource | Actions | @@ -42,6 +44,31 @@ tq [--api-url URL] [--output text|json] [flags] | `config` | build、home、解決済み設定情報を表示 | | `update` | リリースをインストールしてサービスを再起動 | | `version` | バージョン情報を表示 | +| `api` | 許可リストにある issue-tracker API へ生のリクエストを送信 | + +## 生の API リクエスト + +`tq api` は、解決済みの issue-tracker ベース URL に対して生のリクエストを送信します。型付きの `tq` コマンドにない API 操作が必要なエージェントのワークフローで使用します。 + +```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 [--query key=value] [--header 'Name: value'] [--data value|@file|-] +``` + +method は大文字に正規化します。path はエンコードされていない絶対 `/api/v1/...` path でなければなりません。完全 URL、fragment、dot segment、空 segment、末尾の slash は拒否します。path に含めた query は保持し、繰り返し指定した `--query key=value` は指定順で追加します。query の名前と値に意味的な検証は行わず、API に渡します。 + +method と path は、CLI が明示的に持つ現行 issue-tracker route の許可リストに一致する必要があります。数値 ID は正の `int64` に限定します。これは fail-closed の設計であり、server に route を追加しても CLI の許可リストを更新するまで使用できません。生の multipart をまだ扱えないため、`POST /api/v1/attachments` は一時的に除外しています。attachment の `PATCH` も許可しません。 + +`--header` は繰り返し指定できます。header 名は大文字小文字を区別せず、同名の場合は最後の値を使います。`Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Trailer`、`Upgrade`、`Proxy-Connection` など、transport が管理する header は拒否します。`--data` はリテラル値、`@file`、標準入力を示す `-` を受け付け、`POST`、`PUT`、`PATCH` でだけ使用できます。body があり `Content-Type` を明示しない場合は `application/json` を使います。 + +書き込みや削除操作でも確認は求めません。redirect は追跡せず、標準の HTTP timeout は 10 秒です。バイナリやエラー本文を含め、レスポンスのバイト列を受信したまま標準出力へ書き出します。終了ステータスは、HTTP `2xx` が `0`、HTTP `3xx`-`5xx` と transport 失敗が `1`、usage・入力・許可リストのエラーが `2` です。 ## バージョン diff --git a/docs/references/tq.md b/docs/references/tq.md index 011ce307..d8a8ffc4 100644 --- a/docs/references/tq.md +++ b/docs/references/tq.md @@ -29,6 +29,8 @@ tq [--api-url URL] [--output text|json] [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. | +`--output` does not transform the response of `tq api`; that command always copies response bytes unchanged. + ## Resources | Resource | Actions | @@ -42,6 +44,31 @@ tq [--api-url URL] [--output text|json] [flags] | `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 + +`tq api` sends a raw request to the already-resolved issue-tracker base URL. It is useful for agent workflows that need an API operation not exposed by a typed `tq` command. + +```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 [--query key=value] [--header 'Name: value'] [--data value|@file|-] +``` + +Methods are 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`. A request body defaults to `Content-Type: application/json` unless supplied explicitly. + +The command does not prompt before write or delete operations, follows no redirects, and uses the standard 10-second HTTP timeout. It writes response bytes exactly as received to standard output, including binary data and error bodies. Exit status is `0` for HTTP `2xx`, `1` for HTTP `3xx`-`5xx` or transport failures, and `2` for usage, input, and allowlist errors. ## Version diff --git a/internal/cli/tq/command.go b/internal/cli/tq/command.go index c1c7c943..1d9e68a7 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 00000000..59156673 --- /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 [--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 [--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 00000000..6eeaa879 --- /dev/null +++ b/internal/cli/tq/command_api_test.go @@ -0,0 +1,180 @@ +package tq + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestAllowedAPIRoutes(t *testing.T) { + 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 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) + } + client, err := newAPIClient(server.URL) + if err != nil { + t.Fatal(err) + } + client.httpClient.Timeout = 20 * time.Millisecond + _, err = client.doRaw(context.Background(), apiRequest{method: http.MethodGet, path: "/api/v1/summary", headers: make(http.Header)}) + if err == nil { + t.Fatal("expected timeout") + } +} + +func runAPI(t *testing.T, apiURL string, stdin io.Reader, args []string) (string, string, int) { + t.Helper() + var stdout, stderr bytes.Buffer + code := run(context.Background(), 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 805eecd6..962a739f 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] [flags]") + fmt.Fprintln(w, "Usage: tq [--api-url URL] [--output text|json] [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 [--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) From 184736dab54294624b18716529b19f2716ee209c Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 30 Jul 2026 17:36:58 +0900 Subject: [PATCH 2/5] test(cli): cover API allowlist and transport failures Assert the complete method and route-template allowlist, and add CLI coverage for empty 204 responses, request timeouts, and connection failures. --- internal/cli/tq/command_api_test.go | 66 ++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/internal/cli/tq/command_api_test.go b/internal/cli/tq/command_api_test.go index 6eeaa879..6982824a 100644 --- a/internal/cli/tq/command_api_test.go +++ b/internal/cli/tq/command_api_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "net" "net/http" "net/http/httptest" "os" @@ -14,6 +15,37 @@ import ( ) 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"}, @@ -32,6 +64,8 @@ func TestAllowedAPIRoutes(t *testing.T) { } } +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"}, @@ -161,14 +195,36 @@ func TestAPIReadsDataFromStdinAndHonorsClientTimeout(t *testing.T) { if code != 0 || stderr != "" { t.Fatalf("stdin code=%d stderr=%q", code, stderr) } - client, err := newAPIClient(server.URL) + originalTimeout := apiClientTimeout + apiClientTimeout = 20 * time.Millisecond + t.Cleanup(func() { apiClientTimeout = originalTimeout }) + _, stderr, code = runAPI(t, 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) } - client.httpClient.Timeout = 20 * time.Millisecond - _, err = client.doRaw(context.Background(), apiRequest{method: http.MethodGet, path: "/api/v1/summary", headers: make(http.Header)}) - if err == nil { - t.Fatal("expected timeout") + 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) } } From 702182eda45271ffc3624866f845f9170992428f Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 30 Jul 2026 17:37:25 +0900 Subject: [PATCH 3/5] test(cli): make API timeout injectable Keep the production timeout at ten seconds while allowing CLI transport tests to inject a shorter timeout without mutating the client after construction. --- internal/cli/tq/client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cli/tq/client.go b/internal/cli/tq/client.go index ebf50b37..f4c06313 100644 --- a/internal/cli/tq/client.go +++ b/internal/cli/tq/client.go @@ -24,6 +24,8 @@ type apiClient struct { httpClient *http.Client } +var apiClientTimeout = 10 * time.Second + type apiResponse[T any] struct { Data T `json:"data"` } @@ -70,7 +72,7 @@ func newAPIClient(baseURL string) (*apiClient, error) { } return &apiClient{ baseURL: baseURL, - httpClient: &http.Client{Timeout: 10 * time.Second}, + httpClient: &http.Client{Timeout: apiClientTimeout}, }, nil } From 4a3c4acda7a58de80e0bf70fbe9a037a22b1ba22 Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 30 Jul 2026 17:39:44 +0900 Subject: [PATCH 4/5] test(cli): use context deadlines for API timeout coverage Keep the production HTTP client timeout fixed at ten seconds and exercise shorter request deadlines through the command context instead of a mutable package global. --- internal/cli/tq/client.go | 4 +--- internal/cli/tq/command_api_test.go | 13 ++++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/cli/tq/client.go b/internal/cli/tq/client.go index f4c06313..ebf50b37 100644 --- a/internal/cli/tq/client.go +++ b/internal/cli/tq/client.go @@ -24,8 +24,6 @@ type apiClient struct { httpClient *http.Client } -var apiClientTimeout = 10 * time.Second - type apiResponse[T any] struct { Data T `json:"data"` } @@ -72,7 +70,7 @@ func newAPIClient(baseURL string) (*apiClient, error) { } return &apiClient{ baseURL: baseURL, - httpClient: &http.Client{Timeout: apiClientTimeout}, + httpClient: &http.Client{Timeout: 10 * time.Second}, }, nil } diff --git a/internal/cli/tq/command_api_test.go b/internal/cli/tq/command_api_test.go index 6982824a..c0d50feb 100644 --- a/internal/cli/tq/command_api_test.go +++ b/internal/cli/tq/command_api_test.go @@ -195,10 +195,9 @@ func TestAPIReadsDataFromStdinAndHonorsClientTimeout(t *testing.T) { if code != 0 || stderr != "" { t.Fatalf("stdin code=%d stderr=%q", code, stderr) } - originalTimeout := apiClientTimeout - apiClientTimeout = 20 * time.Millisecond - t.Cleanup(func() { apiClientTimeout = originalTimeout }) - _, stderr, code = runAPI(t, server.URL, strings.NewReader(""), []string{"GET", "/api/v1/summary"}) + 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) } @@ -229,8 +228,12 @@ func TestAPIEmptyResponseAndConnectionFailure(t *testing.T) { } 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(context.Background(), append([]string{"--api-url", apiURL, "api"}, args...), stdin, &stdout, &stderr) + code := run(ctx, append([]string{"--api-url", apiURL, "api"}, args...), stdin, &stdout, &stderr) return stdout.String(), stderr.String(), code } From 215f3a55284395d0b39fad800c11357ca5ac47b5 Mon Sep 17 00:00:00 2001 From: Jiro Date: Thu, 30 Jul 2026 17:49:40 +0900 Subject: [PATCH 5/5] docs: clarify API and CLI reference boundaries Separate API design responsibilities from CLI reference details, fill missing commands and states, reduce duplication and inconsistencies, and synchronize the English and Japanese documentation with more natural Japanese wording. --- docs/design/api.ja.md | 161 ++++++++++++++++++--------------------- docs/design/api.md | 161 ++++++++++++++++++--------------------- docs/references/tq.ja.md | 153 ++++++++++++++++++++++++------------- docs/references/tq.md | 73 +++++++++++++++--- 4 files changed, 308 insertions(+), 240 deletions(-) diff --git a/docs/design/api.ja.md b/docs/design/api.ja.md index 4b1c65ad..e0e1cd19 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,57 +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` を使用し、物理削除のエンドポイントは提供しません。 -`PATCH /api/v1/issues/{id}` は、任意の full replacement field として `dependency_ids` を受け取ります。省略した場合、既存の依存関係は維持されます。空配列を渡すと、すべての依存関係を削除します。API は存在しない dependency issue、自己依存、重複した dependency ID、dependency cycle を作る更新を拒否します。 +オーケストレーターが過去の実行を持つ課題の作業を継続するときは、`open` の変更依頼を古い順に最大20件取得し、対象を `in_progress` に移して Codex の継続指示へ加えます。継続指示では、対応した変更依頼に `resolvedByRunId` を設定し、結果コメントがある場合は `resultCommentId` も設定して `resolved` にするようエージェントへ求めます。 -`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` が含まれます。 +## 添付ファイル -JSON の成功レスポンスは `{ "data": ..., "meta": {} }` を使います。JSON のエラーレスポンスは `{ "error": { "code": "...", "message": "..." }, "meta": {} }` を使います。 +添付ファイルのアップロードには、`entity_type`、`entity_id`、`file` を含む multipart form data を使用します。PNG、JPEG、GIF、WebP 形式の画像を5 MiBまでアップロードできます。 -`tq` CLI は課題 CRUD エンドポイントを次のコマンドでラップします。 +ファイル本体は `$TQ_HOME/system/data/attachments` 以下に保存し、SQLite にはメタデータと相対パスを保存します。課題の説明とコメントでは、`![screenshot](attachment://att_...)` のような Markdown で画像を参照します。 -- `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]` +## CLI からの利用 -`tq` は既定では人が読みやすい出力を使い、`--output json` が指定された場合は JSON 出力を使います。 +通常の課題、コメント、プロジェクト、ワークフロー操作には、型付きの `tq` コマンドを使用します。必要な Issue Tracker 操作が型付きコマンドにない場合に限り、許可リストで制限された `tq api` を使用します。 -`tq api <method> <path>` は、同じ issue-tracker ベース URL 解決を使う、制約付きの生 API 呼び出しです。method と route template の許可リストは CLI 内で管理し、API に route が増えても fail-closed になります。現在の許可リストは上記 endpoint のうち一時的に除外する `POST /api/v1/attachments` 以外を対象にします。attachment の `PATCH` は公開しません。エンコードされていない厳格な `/api/v1/...` path だけを受け付け、method は大文字に正規化します。path 内の生 query と、指定順に追加する繰り返し指定可能な `--query key=value` を使えます。`--header 'Name: value'` も繰り返し指定でき、同名は最後の値を使用します。transport が管理する header は拒否します。`--data value|@file|-` は `POST`、`PUT`、`PATCH` に限定し、content type を省略した場合は JSON を使います。 +生の API コマンドは、HTTP メソッドとルートの独自の許可リストを持ち、閉じた状態を既定とします。OpenAPI にルートを追加しても自動では公開しません。また、multipart リクエストの組み立てに対応していないため、`POST /api/v1/attachments` を一時的に除外しています。構文、入力検証、出力、終了ステータスについては、[tq コマンドリファレンス](../references/tq.ja.md#生の-api-リクエスト)を参照してください。 -このコマンドは redirect を追跡せず、破壊的な操作でも確認を求めません。timeout は 10 秒で、envelope の解析や出力変換を行わずにレスポンスのバイト列をコピーします。HTTP `2xx` は終了ステータス `0`、受信した `3xx`-`5xx` レスポンスは本文コピー後に `1`、transport 失敗は `1`、入力・許可リストのエラーは `2` です。 +## オーケストレーター調査 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 cf01b88a..341218ab 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,57 +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. -`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. +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`. -`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. +## Attachments -JSON success responses use `{ "data": ..., "meta": {} }`. JSON error responses use `{ "error": { "code": "...", "message": "..." }, "meta": {} }`. +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. -The `tq` CLI wraps issue CRUD endpoints with these commands: +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_...)`. -- `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]` +## CLI access -`tq` uses human-readable output by default and JSON output when `--output json` is set. +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 api <method> <path>` provides a constrained raw escape hatch for the same issue-tracker base URL resolution. Its method and route-template allowlist is maintained in the CLI and fails closed when the API gains a route. The current allowlist covers every endpoint above except the temporary exclusion `POST /api/v1/attachments`; attachment `PATCH` is not exposed. It accepts only strict unencoded `/api/v1/...` paths, normalizes methods to uppercase, permits raw query text plus ordered repeated `--query key=value`, and accepts repeated `--header 'Name: value'` with last-value-wins semantics. It rejects transport-managed headers. `--data value|@file|-` is limited to `POST`, `PUT`, and `PATCH`, and defaults its content type to JSON when omitted. +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. -The command does not follow redirects or prompt for destructive operations, uses a 10-second timeout, and copies response bytes without envelope parsing or output formatting. HTTP `2xx` exits with status `0`; received `3xx`-`5xx` responses exit `1` after copying their bodies; transport failures exit `1`; input and allowlist failures exit `2`. +## 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 baac2a99..99699c20 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,34 +21,34 @@ 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 出力はスクリプトやエージェント向けです。 | -`tq api` のレスポンスは `--output` で変換しません。常にレスポンスのバイト列をそのまま出力します。 +## コマンド -## リソース - -| 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 API へ生のリクエストを送信 | ## 生の API リクエスト -`tq api` は、解決済みの issue-tracker ベース URL に対して生のリクエストを送信します。型付きの `tq` コマンドにない 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 @@ -62,13 +62,15 @@ tq api POST /api/v1/issues --header 'X-Request-ID: local-123' --data @request.js tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-] ``` -method は大文字に正規化します。path はエンコードされていない絶対 `/api/v1/...` path でなければなりません。完全 URL、fragment、dot segment、空 segment、末尾の slash は拒否します。path に含めた query は保持し、繰り返し指定した `--query key=value` は指定順で追加します。query の名前と値に意味的な検証は行わず、API に渡します。 +HTTP メソッドは大文字と小文字を区別せず、内部で大文字に正規化します。パスには、エンコードされていない `/api/v1/...` 形式の絶対パスを指定します。完全 URL、フラグメント、ドットセグメント、空のセグメント、末尾のスラッシュは使用できません。パスに直接記述したクエリは保持し、繰り返し指定した `--query key=value` は指定順で追加します。クエリの名前と値は意味を検証せず、API に渡します。 + +HTTP メソッドとパスは、CLI が明示的に保持する現行 Issue Tracker ルートの許可リストに一致する必要があります。数値 ID は正の `int64` に限定します。許可リストは閉じた状態を既定とする設計であり、サーバーにルートを追加しても、CLI の許可リストを更新するまでは使用できません。生の multipart をまだ扱えないため、`POST /api/v1/attachments` は一時的に除外しています。添付ファイルに対する `PATCH` も許可しません。 -method と path は、CLI が明示的に持つ現行 issue-tracker route の許可リストに一致する必要があります。数値 ID は正の `int64` に限定します。これは fail-closed の設計であり、server に route を追加しても CLI の許可リストを更新するまで使用できません。生の multipart をまだ扱えないため、`POST /api/v1/attachments` は一時的に除外しています。attachment の `PATCH` も許可しません。 +`--header` は繰り返し指定できます。ヘッダー名は大文字と小文字を区別せず、同名の場合は最後の値を使います。`Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Trailer`、`Upgrade`、`Proxy-Connection` など、HTTP トランスポートが管理するヘッダーは指定できません。 -`--header` は繰り返し指定できます。header 名は大文字小文字を区別せず、同名の場合は最後の値を使います。`Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Trailer`、`Upgrade`、`Proxy-Connection` など、transport が管理する header は拒否します。`--data` はリテラル値、`@file`、標準入力を示す `-` を受け付け、`POST`、`PUT`、`PATCH` でだけ使用できます。body があり `Content-Type` を明示しない場合は `application/json` を使います。 +`--data` には、リテラル値、`@file`、または標準入力を示す `-` を指定できます。使用できる HTTP メソッドは `POST`、`PUT`、`PATCH` です。本文が JSON として妥当かどうかは検証しません。本文があり、`Content-Type` ヘッダーを明示しなかった場合は `application/json` を使用します。 -書き込みや削除操作でも確認は求めません。redirect は追跡せず、標準の HTTP timeout は 10 秒です。バイナリやエラー本文を含め、レスポンスのバイト列を受信したまま標準出力へ書き出します。終了ステータスは、HTTP `2xx` が `0`、HTTP `3xx`-`5xx` と transport 失敗が `1`、usage・入力・許可リストのエラーが `2` です。 +書き込みや削除操作でも確認は求めません。リダイレクトは追跡せず、HTTP のタイムアウトは 10 秒です。バイナリデータや HTTP エラーの本文も含め、レスポンスのバイト列を変更せずに標準出力へ書き出します。`--output` を指定しても変換しません。終了ステータスは、HTTP `2xx` が `0`、HTTP `3xx`~`5xx` または通信失敗が `1`、使用方法・入力・許可リストのエラーが `2` です。 ## バージョン @@ -82,7 +84,7 @@ tq version ## 設定 -`tq config` は、バージョン、build profile、`TQ_HOME` override、解決済みの home directory、設定ファイルのパス、解決済み設定値を表示します。機械可読な出力には `--output json` を指定します。設定ファイルの生 YAML は表示しません。 +`tq config` は、バージョン、ビルドプロファイル、`TQ_HOME` の上書き値、解決済みのホームディレクトリ、設定ファイルのパス、解決済みの設定値を表示します。機械可読な出力には `--output json` を指定します。設定ファイルの YAML はそのまま表示しません。 ## 更新 @@ -100,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` の確認、マイグレーション適用、サービス起動の順に進みます。いずれかの工程が失敗した場合、後続の工程は実行されません。 ## 課題 @@ -146,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 画像参照を追記します。 | 例: @@ -175,7 +177,7 @@ make run-tq ARGS='issue update 1 --status in_progress' フラグ: -| Flag | Description | +| フラグ | 説明 | |---|---| | `--title TITLE` | 課題のタイトルを置き換えます。 | | `--description TEXT` | 課題の説明を置き換えます。 | @@ -186,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` を設定します。 ## コメント @@ -200,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` @@ -219,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 @@ -227,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` | @@ -249,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` @@ -269,7 +316,7 @@ make run-tq ARGS="project add ." フラグ: -| Flag | Description | +| フラグ | 説明 | |---|---| | `--key KEY` | プロジェクトキー。省略時はプロジェクトディレクトリ名から kebab-case のキーを生成します。 | @@ -307,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 が返した理由を表示します。 ## ワークフロー @@ -350,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` を使います。 @@ -363,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" @@ -404,6 +451,8 @@ in_progress review blocked failed +cancelled +duplicate done ``` @@ -429,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 d8a8ffc4..a8589cd0 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,18 +29,18 @@ 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. | -`--output` does not transform the response of `tq api`; that command always copies response bytes unchanged. +## Commands -## Resources - -| 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 | @@ -48,7 +48,7 @@ tq [--api-url URL] [--output text|json] <resource|command> <action> [flags] ## Raw API requests -`tq api` sends a raw request to the already-resolved issue-tracker base URL. It is useful for agent workflows that need an API operation not exposed by a typed `tq` command. +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 @@ -62,13 +62,15 @@ The command syntax is: tq api <method> <path> [--query key=value] [--header 'Name: value'] [--data value|@file|-] ``` -Methods are 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. +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`. A request body defaults to `Content-Type: application/json` unless supplied explicitly. +`--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, follows no redirects, and uses the standard 10-second HTTP timeout. It writes response bytes exactly as received to standard output, including binary data and error bodies. Exit status is `0` for HTTP `2xx`, `1` for HTTP `3xx`-`5xx` or transport failures, and `2` for usage, input, and allowlist errors. +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 @@ -188,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` @@ -255,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` @@ -404,6 +451,8 @@ in_progress review blocked failed +cancelled +duplicate done ```