diff --git a/README.md b/README.md index 5e322d9..c9d64dc 100644 --- a/README.md +++ b/README.md @@ -24,26 +24,18 @@ npm install -g takt ## Quick Start ```bash -# Run a task (will prompt for workflow selection and optional isolated clone) +# Run a task (prompts for workflow selection, worktree, and PR creation) takt "Add a login feature" -# Run a GitHub issue as a task -takt "#6" +# Run a GitHub issue as a task (both are equivalent) +takt '#6' +takt --issue 6 -# Add a task via AI conversation -takt add +# Interactive mode — refine task requirements with AI, then execute +takt -# Run all pending tasks -takt run - -# Watch for tasks and auto-execute -takt watch - -# List task branches (merge or delete) -takt list - -# Switch workflow -takt switch +# Pipeline mode (non-interactive, for scripts and CI) +takt --task "fix the auth bug" --auto-pr ``` ### What happens when you run a task @@ -75,6 +67,14 @@ Choose `y` to run in a `git clone --shared` isolated environment, keeping your w **3. Execution** — The selected workflow orchestrates multiple agents to complete the task. +**4. PR creation** (after worktree execution) + +``` +? Create pull request? (y/N) +``` + +If `--auto-pr` is specified, the PR is created automatically without asking. + ### Recommended workflows | Workflow | Best for | @@ -87,11 +87,54 @@ Choose `y` to run in a `git clone --shared` isolated environment, keeping your w ## Commands +### Interactive Mode (default) + +The standard mode for everyday development. Workflow selection, worktree creation, and PR creation are handled interactively. + +```bash +# Run a task +takt "Add a login feature" + +# Run a GitHub issue as a task (both are equivalent) +takt '#6' +takt --issue 6 + +# Interactive mode — refine task requirements with AI before executing +takt + +# Run a task and automatically create a PR (skip the confirmation prompt) +takt '#6' --auto-pr +``` + +When `--auto-pr` is not specified, you will be asked whether to create a PR after a successful worktree execution. + +### Pipeline Mode (`--task`) + +Specifying `--task` enters pipeline mode — fully non-interactive, suitable for scripts and CI integration. TAKT automatically creates a branch, runs the workflow, commits, and pushes. + +```bash +# Run a task in pipeline mode +takt --task "fix the auth bug" + +# Pipeline mode + automatic PR creation +takt --task "fix the auth bug" --auto-pr + +# Attach GitHub issue context +takt --task "fix the auth bug" --issue 99 --auto-pr + +# Specify workflow and branch +takt --task "fix the auth bug" -w magi -b feat/fix-auth + +# Specify repository (for PR creation) +takt --task "fix the auth bug" --auto-pr --repo owner/repo +``` + +In pipeline mode, PRs are **not** created unless `--auto-pr` is explicitly specified. + +### Subcommands + | Command | Description | |---------|-------------| -| `takt "task"` | Execute task with current workflow (session auto-continued) | -| `takt "#N"` | Execute GitHub issue #N as a task | -| `takt` | Interactive task input mode | | `takt run` | Run all pending tasks from `.takt/tasks/` | | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) | | `takt add` | Add a new task via AI conversation | @@ -102,6 +145,17 @@ Choose `y` to run in a `git clone --shared` isolated environment, keeping your w | `takt config` | Configure permission mode | | `takt --help` | Show help | +### Options + +| Option | Description | +|--------|-------------| +| `-t, --task ` | Task content — **triggers pipeline (non-interactive) mode** | +| `-i, --issue ` | GitHub issue number (equivalent to `#N` in interactive mode) | +| `-w, --workflow ` | Workflow to use | +| `-b, --branch ` | Branch name (auto-generated if omitted) | +| `--auto-pr` | Create PR after execution (interactive: skip confirmation, pipeline: enable PR) | +| `--repo ` | Repository for PR creation | + ## Workflows TAKT uses YAML-based workflow definitions with rule-based routing. Builtin workflows are embedded in the package; user workflows in `~/.takt/workflows/` take priority. Use `takt eject` to copy a builtin to `~/.takt/` for customization. @@ -286,8 +340,27 @@ provider: claude # Default provider: claude or codex model: sonnet # Default model (optional) trusted_directories: - /path/to/trusted/dir + +# Pipeline execution settings (optional) +# Customize branch naming, commit messages, and PR body for pipeline mode. +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} ``` +**Pipeline template variables:** + +| Variable | Available in | Description | +|----------|-------------|-------------| +| `{title}` | commit message | Issue title | +| `{issue}` | commit message, PR body | Issue number | +| `{issue_body}` | PR body | Issue body text | +| `{report}` | PR body | Workflow execution report | + **Model Resolution Priority:** 1. Workflow step `model` (highest priority) 2. Custom agent `model` @@ -299,12 +372,13 @@ trusted_directories: ### Interactive Workflow -When running `takt "task"`, you are prompted to: +When running `takt "Add a feature"`, you are prompted to: 1. **Select a workflow** - Choose from available workflows (arrow keys, ESC to cancel) -2. **Create an isolated clone** (optional) - Optionally run the task in a `git clone --shared` for isolation +2. **Create an isolated clone** (optional) - Run the task in a `git clone --shared` for isolation +3. **Create a pull request** (after worktree execution) - Create a PR from the task branch -This interactive flow ensures each task runs with the right workflow and isolation level. +If `--auto-pr` is specified, the PR confirmation is skipped and the PR is created automatically. ### Adding Custom Workflows diff --git a/docs/README.ja.md b/docs/README.ja.md index 102cc69..2492b16 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -20,31 +20,23 @@ npm install -g takt ## クイックスタート ```bash -# タスクを実行(ワークフロー選択プロンプトが表示されます) -takt "ログイン機能を追加して" +# タスクを実行(ワークフロー選択・worktree・PR作成を対話的に案内) +takt ログイン機能を追加して -# GitHub Issueをタスクとして実行 -takt "#6" +# GitHub Issueをタスクとして実行(どちらも同じ) +takt '#6' +takt --issue 6 -# AI会話でタスクを追加 -takt add +# 対話モードでAIとタスク要件を詰めてから実行 +takt -# 保留中のタスクをすべて実行 -takt run - -# タスクを監視して自動実行 -takt watch - -# タスクブランチ一覧(マージ・削除) -takt list - -# ワークフローを切り替え -takt switch +# パイプライン実行(非対話・スクリプト/CI向け) +takt --task "バグを修正して" --auto-pr ``` ### タスク実行の流れ -`takt "ログイン機能を追加して"` を実行すると、以下の対話フローが表示されます: +`takt ログイン機能を追加して` を実行すると、以下の対話フローが表示されます: **1. ワークフロー選択** @@ -71,6 +63,14 @@ Select workflow: **3. 実行** — 選択したワークフローが複数のエージェントを連携させてタスクを完了します。 +**4. PR作成**(worktree実行後) + +``` +? Create pull request? (y/N) +``` + +`--auto-pr` を指定している場合は確認なしで自動作成されます。 + ### おすすめワークフロー | ワークフロー | おすすめ用途 | @@ -83,11 +83,54 @@ Select workflow: ## コマンド一覧 +### 対話モード(デフォルト) + +日常の開発で使う基本モード。ワークフロー選択、worktree作成、PR作成を対話的に確認します。 + +```bash +# タスクを実行 +takt ログイン機能を追加して + +# GitHub Issueをタスクとして実行(どちらも同じ) +takt '#6' +takt --issue 6 + +# 対話モードでAIとタスク要件を詰めてから実行 +takt + +# タスクを実行してPRを自動作成(確認プロンプトをスキップ) +takt '#6' --auto-pr +``` + +`--auto-pr` を指定しない場合、worktreeでの実行成功後に「PR作成する?」と確認されます。 + +### パイプラインモード(`--task`) + +`--task` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ワークフロー実行 → commit & push を自動で行います。スクリプトやCI連携に適しています。 + +```bash +# タスクをパイプライン実行 +takt --task "バグを修正" + +# パイプライン実行 + PR自動作成 +takt --task "バグを修正" --auto-pr + +# Issue情報を紐付け +takt --task "バグを修正" --issue 99 --auto-pr + +# ワークフロー・ブランチ指定 +takt --task "バグを修正" -w magi -b feat/fix-bug + +# リポジトリ指定(PR作成時) +takt --task "バグを修正" --auto-pr --repo owner/repo +``` + +パイプラインモードでは `--auto-pr` を指定しない限りPRは作成されません。 + +### サブコマンド + | コマンド | 説明 | |---------|------| -| `takt "タスク"` | 現在のワークフローでタスクを実行(セッション自動継続) | -| `takt "#N"` | GitHub Issue #Nをタスクとして実行 | -| `takt` | 対話式タスク入力モード | | `takt run` | `.takt/tasks/` の保留中タスクをすべて実行 | | `takt watch` | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) | | `takt add` | AI会話で新しいタスクを追加 | @@ -98,6 +141,17 @@ Select workflow: | `takt config` | パーミッションモードを設定 | | `takt --help` | ヘルプを表示 | +### オプション + +| オプション | 説明 | +|-----------|------| +| `-t, --task ` | タスク内容 — **パイプライン(非対話)モードのトリガー** | +| `-i, --issue ` | GitHub Issue番号(対話モードでは `#N` と同じ) | +| `-w, --workflow ` | ワークフロー指定 | +| `-b, --branch ` | ブランチ名指定(省略時は自動生成) | +| `--auto-pr` | PR作成(対話: 確認スキップ、パイプライン: PR有効化) | +| `--repo ` | リポジトリ指定(PR作成時) | + ## ワークフロー TAKTはYAMLベースのワークフロー定義とルールベースルーティングを使用します。ビルトインワークフローはパッケージに埋め込まれており、`~/.takt/workflows/` のユーザーワークフローが優先されます。`takt eject` でビルトインを`~/.takt/`にコピーしてカスタマイズできます。 @@ -282,8 +336,27 @@ provider: claude # デフォルトプロバイダー: claude または c model: sonnet # デフォルトモデル(オプション) trusted_directories: - /path/to/trusted/dir + +# パイプライン実行設定(オプション) +# ブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。 +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} ``` +**パイプラインテンプレート変数:** + +| 変数 | 使用可能箇所 | 説明 | +|------|-------------|------| +| `{title}` | コミットメッセージ | Issueタイトル | +| `{issue}` | コミットメッセージ、PR本文 | Issue番号 | +| `{issue_body}` | PR本文 | Issue本文 | +| `{report}` | PR本文 | ワークフロー実行レポート | + **モデル解決の優先順位:** 1. ワークフローステップの `model`(最優先) 2. カスタムエージェントの `model` diff --git a/resources/global/en/config.yaml b/resources/global/en/config.yaml index 2d8628d..3e8a12e 100644 --- a/resources/global/en/config.yaml +++ b/resources/global/en/config.yaml @@ -27,6 +27,16 @@ provider: claude # OpenAI API key (optional, overridden by TAKT_OPENAI_API_KEY env var) # openai_api_key: "" +# Pipeline execution settings (optional) +# Customize branch naming, commit messages, and PR body for pipeline mode (--task). +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} + # Debug settings (optional) # debug: # enabled: false diff --git a/resources/global/ja/config.yaml b/resources/global/ja/config.yaml index 788906d..d017451 100644 --- a/resources/global/ja/config.yaml +++ b/resources/global/ja/config.yaml @@ -27,6 +27,16 @@ provider: claude # OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能) # openai_api_key: "" +# パイプライン実行設定 (オプション) +# パイプラインモード (--task) のブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。 +# pipeline: +# default_branch_prefix: "takt/" +# commit_message_template: "feat: {title} (#{issue})" +# pr_body_template: | +# ## Summary +# {issue_body} +# Closes #{issue} + # デバッグ設定 (オプション) # debug: # enabled: false diff --git a/src/__tests__/exitCodes.test.ts b/src/__tests__/exitCodes.test.ts new file mode 100644 index 0000000..e06e219 --- /dev/null +++ b/src/__tests__/exitCodes.test.ts @@ -0,0 +1,37 @@ +/** + * Tests for exit codes + */ + +import { describe, it, expect } from 'vitest'; +import { + EXIT_SUCCESS, + EXIT_GENERAL_ERROR, + EXIT_ISSUE_FETCH_FAILED, + EXIT_WORKFLOW_FAILED, + EXIT_GIT_OPERATION_FAILED, + EXIT_PR_CREATION_FAILED, +} from '../exitCodes.js'; + +describe('exit codes', () => { + it('should have distinct values', () => { + const codes = [ + EXIT_SUCCESS, + EXIT_GENERAL_ERROR, + EXIT_ISSUE_FETCH_FAILED, + EXIT_WORKFLOW_FAILED, + EXIT_GIT_OPERATION_FAILED, + EXIT_PR_CREATION_FAILED, + ]; + const unique = new Set(codes); + expect(unique.size).toBe(codes.length); + }); + + it('should match expected values from spec', () => { + expect(EXIT_SUCCESS).toBe(0); + expect(EXIT_GENERAL_ERROR).toBe(1); + expect(EXIT_ISSUE_FETCH_FAILED).toBe(2); + expect(EXIT_WORKFLOW_FAILED).toBe(3); + expect(EXIT_GIT_OPERATION_FAILED).toBe(4); + expect(EXIT_PR_CREATION_FAILED).toBe(5); + }); +}); diff --git a/src/__tests__/github-pr.test.ts b/src/__tests__/github-pr.test.ts new file mode 100644 index 0000000..19e1c0c --- /dev/null +++ b/src/__tests__/github-pr.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for github/pr module + * + * Tests buildPrBody formatting. + * createPullRequest/pushBranch call `gh`/`git` CLI, not unit-tested here. + */ + +import { describe, it, expect } from 'vitest'; +import { buildPrBody } from '../github/pr.js'; +import type { GitHubIssue } from '../github/issue.js'; + +describe('buildPrBody', () => { + it('should build body with issue and report', () => { + const issue: GitHubIssue = { + number: 99, + title: 'Add login feature', + body: 'Implement username/password authentication.', + labels: [], + comments: [], + }; + + const result = buildPrBody(issue, 'Workflow `default` completed.'); + + expect(result).toContain('## Summary'); + expect(result).toContain('Implement username/password authentication.'); + expect(result).toContain('## Execution Report'); + expect(result).toContain('Workflow `default` completed.'); + expect(result).toContain('Closes #99'); + }); + + it('should use title when body is empty', () => { + const issue: GitHubIssue = { + number: 10, + title: 'Fix bug', + body: '', + labels: [], + comments: [], + }; + + const result = buildPrBody(issue, 'Done.'); + + expect(result).toContain('Fix bug'); + expect(result).toContain('Closes #10'); + }); + + it('should build body without issue', () => { + const result = buildPrBody(undefined, 'Task completed.'); + + expect(result).toContain('## Summary'); + expect(result).toContain('## Execution Report'); + expect(result).toContain('Task completed.'); + expect(result).not.toContain('Closes'); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts new file mode 100644 index 0000000..d2dc554 --- /dev/null +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for loadGlobalConfig default values when config.yaml is missing + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { vi } from 'vitest'; + +// Mock the home directory to use a temp directory +const testHomeDir = join(tmpdir(), `takt-gc-test-${Date.now()}`); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: () => testHomeDir, + }; +}); + +// Import after mocks are set up +const { loadGlobalConfig, saveGlobalConfig } = await import('../config/globalConfig.js'); +const { getGlobalConfigPath } = await import('../config/paths.js'); + +describe('loadGlobalConfig', () => { + beforeEach(() => { + mkdirSync(testHomeDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testHomeDir)) { + rmSync(testHomeDir, { recursive: true }); + } + }); + + it('should return default values when config.yaml does not exist', () => { + const config = loadGlobalConfig(); + + expect(config.language).toBe('en'); + expect(config.trustedDirectories).toEqual([]); + expect(config.defaultWorkflow).toBe('default'); + expect(config.logLevel).toBe('info'); + expect(config.provider).toBe('claude'); + expect(config.model).toBeUndefined(); + expect(config.debug).toBeUndefined(); + expect(config.pipeline).toBeUndefined(); + }); + + it('should return a fresh copy each time (no shared reference)', () => { + const config1 = loadGlobalConfig(); + const config2 = loadGlobalConfig(); + + config1.trustedDirectories.push('/tmp/test'); + expect(config2.trustedDirectories).toEqual([]); + }); + + it('should load from config.yaml when it exists', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: ja\nprovider: codex\nlog_level: debug\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.language).toBe('ja'); + expect(config.provider).toBe('codex'); + expect(config.logLevel).toBe('debug'); + }); + + it('should load pipeline config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + [ + 'language: en', + 'pipeline:', + ' default_branch_prefix: "feat/"', + ' commit_message_template: "fix: {title} (#{issue})"', + ].join('\n'), + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.pipeline).toBeDefined(); + expect(config.pipeline!.defaultBranchPrefix).toBe('feat/'); + expect(config.pipeline!.commitMessageTemplate).toBe('fix: {title} (#{issue})'); + expect(config.pipeline!.prBodyTemplate).toBeUndefined(); + }); + + it('should save and reload pipeline config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + // Create minimal config first + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.pipeline = { + defaultBranchPrefix: 'takt/', + commitMessageTemplate: 'feat: {title} (#{issue})', + }; + saveGlobalConfig(config); + + const reloaded = loadGlobalConfig(); + expect(reloaded.pipeline).toBeDefined(); + expect(reloaded.pipeline!.defaultBranchPrefix).toBe('takt/'); + expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})'); + }); +}); diff --git a/src/__tests__/initialization-noninteractive.test.ts b/src/__tests__/initialization-noninteractive.test.ts new file mode 100644 index 0000000..391445c --- /dev/null +++ b/src/__tests__/initialization-noninteractive.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for initGlobalDirs non-interactive mode + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock the home directory to use a temp directory +const testHomeDir = join(tmpdir(), `takt-init-ni-test-${Date.now()}`); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: () => testHomeDir, + }; +}); + +// Mock the prompt to track if it was called +const mockSelectOption = vi.fn().mockResolvedValue('en'); +vi.mock('../prompt/index.js', () => ({ + selectOptionWithDefault: mockSelectOption, +})); + +// Import after mocks are set up +const { initGlobalDirs, needsLanguageSetup } = await import('../config/initialization.js'); +const { getGlobalConfigPath, getGlobalConfigDir } = await import('../config/paths.js'); + +describe('initGlobalDirs with non-interactive mode', () => { + beforeEach(() => { + mkdirSync(testHomeDir, { recursive: true }); + mockSelectOption.mockClear(); + }); + + afterEach(() => { + if (existsSync(testHomeDir)) { + rmSync(testHomeDir, { recursive: true }); + } + }); + + it('should skip prompts when nonInteractive is true', async () => { + expect(needsLanguageSetup()).toBe(true); + + await initGlobalDirs({ nonInteractive: true }); + + // Prompts should NOT have been called + expect(mockSelectOption).not.toHaveBeenCalled(); + // Config should still not exist (we use defaults via loadGlobalConfig fallback) + expect(existsSync(getGlobalConfigPath())).toBe(false); + }); + + it('should create global config directory even in non-interactive mode', async () => { + await initGlobalDirs({ nonInteractive: true }); + + expect(existsSync(getGlobalConfigDir())).toBe(true); + }); +}); diff --git a/src/__tests__/pipelineExecution.test.ts b/src/__tests__/pipelineExecution.test.ts new file mode 100644 index 0000000..eaf0f3b --- /dev/null +++ b/src/__tests__/pipelineExecution.test.ts @@ -0,0 +1,348 @@ +/** + * Tests for pipeline execution + * + * Tests the orchestration logic with mocked dependencies. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock all external dependencies +const mockFetchIssue = vi.fn(); +const mockCheckGhCli = vi.fn().mockReturnValue({ available: true }); +vi.mock('../github/issue.js', () => ({ + fetchIssue: mockFetchIssue, + formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) => + `## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}` + ), + checkGhCli: mockCheckGhCli, +})); + +const mockCreatePullRequest = vi.fn(); +const mockPushBranch = vi.fn(); +const mockBuildPrBody = vi.fn(() => 'Default PR body'); +vi.mock('../github/pr.js', () => ({ + createPullRequest: mockCreatePullRequest, + pushBranch: mockPushBranch, + buildPrBody: mockBuildPrBody, +})); + +const mockExecuteTask = vi.fn(); +vi.mock('../commands/taskExecution.js', () => ({ + executeTask: mockExecuteTask, +})); + +// Mock loadGlobalConfig +const mockLoadGlobalConfig = vi.fn(); +vi.mock('../config/globalConfig.js', () => ({ + loadGlobalConfig: mockLoadGlobalConfig, +})); + +// Mock execFileSync for git operations +const mockExecFileSync = vi.fn(); +vi.mock('node:child_process', () => ({ + execFileSync: mockExecFileSync, +})); + +// Mock UI +vi.mock('../utils/ui.js', () => ({ + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), +})); + +// Mock debug logger +vi.mock('../utils/debug.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +const { executePipeline } = await import('../commands/pipelineExecution.js'); + +describe('executePipeline', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: git operations succeed + mockExecFileSync.mockReturnValue('abc1234\n'); + // Default: no pipeline config + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + }); + }); + + it('should return exit code 2 when neither --issue nor --task is specified', async () => { + const exitCode = await executePipeline({ + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(2); + }); + + it('should return exit code 2 when gh CLI is not available', async () => { + mockCheckGhCli.mockReturnValueOnce({ available: false, error: 'gh not found' }); + + const exitCode = await executePipeline({ + issueNumber: 99, + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(2); + }); + + it('should return exit code 2 when issue fetch fails', async () => { + mockFetchIssue.mockImplementationOnce(() => { + throw new Error('Issue not found'); + }); + + const exitCode = await executePipeline({ + issueNumber: 999, + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(2); + }); + + it('should return exit code 3 when workflow fails', async () => { + mockFetchIssue.mockReturnValueOnce({ + number: 99, + title: 'Test issue', + body: 'Test body', + labels: [], + comments: [], + }); + mockExecuteTask.mockResolvedValueOnce(false); + + const exitCode = await executePipeline({ + issueNumber: 99, + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(3); + }); + + it('should return exit code 0 on successful task-only execution', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(0); + expect(mockExecuteTask).toHaveBeenCalledWith( + 'Fix the bug', + '/tmp/test', + 'default', + ); + }); + + it('should return exit code 5 when PR creation fails', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + mockCreatePullRequest.mockReturnValueOnce({ success: false, error: 'PR failed' }); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + workflow: 'default', + autoPr: true, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(5); + }); + + it('should create PR with correct branch when --auto-pr', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' }); + + const exitCode = await executePipeline({ + task: 'Fix the bug', + workflow: 'default', + branch: 'fix/my-branch', + autoPr: true, + repo: 'owner/repo', + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(0); + expect(mockCreatePullRequest).toHaveBeenCalledWith( + '/tmp/test', + expect.objectContaining({ + branch: 'fix/my-branch', + repo: 'owner/repo', + }), + ); + }); + + it('should use --task when both --task and positional task are provided', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + + const exitCode = await executePipeline({ + task: 'From --task flag', + workflow: 'magi', + autoPr: false, + cwd: '/tmp/test', + }); + + expect(exitCode).toBe(0); + expect(mockExecuteTask).toHaveBeenCalledWith( + 'From --task flag', + '/tmp/test', + 'magi', + ); + }); + + describe('PipelineConfig template expansion', () => { + it('should use commit_message_template when configured', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + pipeline: { + commitMessageTemplate: 'fix: {title} (#{issue})', + }, + }); + + mockFetchIssue.mockReturnValueOnce({ + number: 42, + title: 'Login broken', + body: 'Cannot login.', + labels: [], + comments: [], + }); + mockExecuteTask.mockResolvedValueOnce(true); + + await executePipeline({ + issueNumber: 42, + workflow: 'default', + branch: 'test-branch', + autoPr: false, + cwd: '/tmp/test', + }); + + // Verify commit was called with expanded template + const commitCall = mockExecFileSync.mock.calls.find( + (call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'commit', + ); + expect(commitCall).toBeDefined(); + expect((commitCall![1] as string[])[2]).toBe('fix: Login broken (#42)'); + }); + + it('should use default_branch_prefix when configured', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + pipeline: { + defaultBranchPrefix: 'feat/', + }, + }); + + mockFetchIssue.mockReturnValueOnce({ + number: 10, + title: 'Add feature', + body: 'Please add.', + labels: [], + comments: [], + }); + mockExecuteTask.mockResolvedValueOnce(true); + + await executePipeline({ + issueNumber: 10, + workflow: 'default', + autoPr: false, + cwd: '/tmp/test', + }); + + // Verify checkout -b was called with prefix + const checkoutCall = mockExecFileSync.mock.calls.find( + (call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === '-b', + ); + expect(checkoutCall).toBeDefined(); + const branchName = (checkoutCall![1] as string[])[2]; + expect(branchName).toMatch(/^feat\/issue-10-\d+$/); + }); + + it('should use pr_body_template when configured for PR creation', async () => { + mockLoadGlobalConfig.mockReturnValue({ + language: 'en', + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + pipeline: { + prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', + }, + }); + + mockFetchIssue.mockReturnValueOnce({ + number: 50, + title: 'Fix auth', + body: 'Auth is broken.', + labels: [], + comments: [], + }); + mockExecuteTask.mockResolvedValueOnce(true); + mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' }); + + await executePipeline({ + issueNumber: 50, + workflow: 'default', + branch: 'fix-auth', + autoPr: true, + cwd: '/tmp/test', + }); + + // When prBodyTemplate is set, buildPrBody (mock) should NOT be called + // Instead, the template is expanded directly + expect(mockCreatePullRequest).toHaveBeenCalledWith( + '/tmp/test', + expect.objectContaining({ + body: '## Summary\nAuth is broken.\n\nCloses #50', + }), + ); + }); + + it('should fall back to buildPrBody when no template is configured', async () => { + mockExecuteTask.mockResolvedValueOnce(true); + mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' }); + + await executePipeline({ + task: 'Fix bug', + workflow: 'default', + branch: 'fix-branch', + autoPr: true, + cwd: '/tmp/test', + }); + + // Should use buildPrBody (the mock) + expect(mockBuildPrBody).toHaveBeenCalled(); + expect(mockCreatePullRequest).toHaveBeenCalledWith( + '/tmp/test', + expect.objectContaining({ + body: 'Default PR body', + }), + ); + }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 8ae340c..86c2dee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,11 +5,16 @@ * * Usage: * takt {task} - Execute task with current workflow (continues session) + * takt #99 - Execute task from GitHub issue * takt run - Run all pending tasks from .takt/tasks/ * takt switch - Switch workflow interactively * takt clear - Clear agent conversation sessions (reset to initial state) * takt --help - Show help * takt config - Select permission mode interactively + * + * Pipeline (non-interactive): + * takt --task "fix bug" -w magi --auto-pr + * takt --task "fix bug" --issue 99 --auto-pr */ import { createRequire } from 'node:module'; @@ -34,6 +39,7 @@ import { watchTasks, listTasks, interactiveMode, + executePipeline, } from './commands/index.js'; import { listWorkflows } from './config/workflowLoader.js'; import { selectOptionWithDefault, confirm } from './prompt/index.js'; @@ -43,6 +49,7 @@ import { summarizeTaskName } from './task/summarize.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; import { checkForUpdates } from './utils/updateNotifier.js'; import { resolveIssueTask, isIssueReference } from './github/issue.js'; +import { createPullRequest, buildPrBody } from './github/pr.js'; const require = createRequire(import.meta.url); const { version: cliVersion } = require('../package.json') as { version: string }; @@ -54,6 +61,9 @@ checkForUpdates(); /** Resolved cwd shared across commands via preAction hook */ let resolvedCwd = ''; +/** Whether pipeline mode is active (--task specified, set in preAction) */ +let pipelineMode = false; + export interface WorktreeConfirmationResult { execCwd: string; isWorktree: boolean; @@ -95,7 +105,11 @@ async function selectWorkflow(cwd: string): Promise { * Execute a task with workflow selection, optional worktree, and auto-commit. * Shared by direct task execution and interactive mode. */ -async function selectAndExecuteTask(cwd: string, task: string): Promise { +async function selectAndExecuteTask( + cwd: string, + task: string, + options?: { autoPr?: boolean; repo?: string }, +): Promise { const selectedWorkflow = await selectWorkflow(cwd); if (selectedWorkflow === null) { @@ -103,7 +117,7 @@ async function selectAndExecuteTask(cwd: string, task: string): Promise { return; } - const { execCwd, isWorktree } = await confirmAndCreateWorktree(cwd, task); + const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(cwd, task); log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree }); const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd); @@ -115,6 +129,26 @@ async function selectAndExecuteTask(cwd: string, task: string): Promise { } else if (!commitResult.success) { error(`Auto-commit failed: ${commitResult.message}`); } + + // PR creation: --auto-pr → create automatically, otherwise ask + if (commitResult.success && commitResult.commitHash && branch) { + const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); + if (shouldCreatePr) { + info('Creating pull request...'); + const prBody = buildPrBody(undefined, `Workflow \`${selectedWorkflow}\` completed successfully.`); + const prResult = createPullRequest(execCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + repo: options?.repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } + } + } } if (!taskSuccess) { @@ -157,11 +191,24 @@ program .description('TAKT: Task Agent Koordination Tool') .version(cliVersion); +// --- Global options --- +program + .option('-i, --issue ', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10)) + .option('-w, --workflow ', 'Workflow to use') + .option('-b, --branch ', 'Branch name (auto-generated if omitted)') + .option('--auto-pr', 'Create PR after successful execution') + .option('--repo ', 'Repository (defaults to current)') + .option('-t, --task ', 'Task content (triggers pipeline/non-interactive mode)'); + // Common initialization for all commands program.hook('preAction', async () => { resolvedCwd = resolve(process.cwd()); - await initGlobalDirs(); + // Pipeline mode: triggered by --task (non-interactive) + const rootOpts = program.opts(); + pipelineMode = rootOpts.task !== undefined; + + await initGlobalDirs({ nonInteractive: pipelineMode }); initProjectDirs(resolvedCwd); const verbose = isVerboseMode(resolvedCwd); @@ -181,7 +228,7 @@ program.hook('preAction', async () => { setLogLevel(config.logLevel); } - log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose }); + log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose, pipelineMode }); }); // --- Subcommands --- @@ -248,7 +295,7 @@ program await switchConfig(resolvedCwd, key); }); -// --- Default action: task execution or interactive mode --- +// --- Default action: task execution, interactive mode, or pipeline --- /** * Check if the input is a task description (should execute directly) @@ -265,9 +312,50 @@ function isDirectTask(input: string): boolean { return false; } + program .argument('[task]', 'Task to execute (or GitHub issue reference like "#6")') .action(async (task?: string) => { + const opts = program.opts(); + + // --- Pipeline mode (non-interactive): triggered by --task --- + if (pipelineMode) { + const exitCode = await executePipeline({ + issueNumber: opts.issue as number | undefined, + task: opts.task as string, + workflow: (opts.workflow as string | undefined) ?? DEFAULT_WORKFLOW_NAME, + branch: opts.branch as string | undefined, + autoPr: opts.autoPr === true, + repo: opts.repo as string | undefined, + cwd: resolvedCwd, + }); + + if (exitCode !== 0) { + process.exit(exitCode); + } + return; + } + + // --- Normal (interactive) mode --- + + const prOptions = { + autoPr: opts.autoPr === true, + repo: opts.repo as string | undefined, + }; + + // Resolve --issue N to task text (same as #N) + const issueFromOption = opts.issue as number | undefined; + if (issueFromOption) { + try { + const resolvedTask = resolveIssueTask(`#${issueFromOption}`); + await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions); + } catch (e) { + error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } + return; + } + if (task && isDirectTask(task)) { // Resolve #N issue references to task text let resolvedTask: string = task; @@ -281,7 +369,7 @@ program } } - await selectAndExecuteTask(resolvedCwd, resolvedTask); + await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions); return; } @@ -292,7 +380,7 @@ program return; } - await selectAndExecuteTask(resolvedCwd, result.task); + await selectAndExecuteTask(resolvedCwd, result.task, prOptions); }); program.parse(); diff --git a/src/commands/index.ts b/src/commands/index.ts index 99bc37e..c761fad 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -12,3 +12,4 @@ export { switchWorkflow } from './workflow.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; export { listTasks } from './listTasks.js'; export { interactiveMode } from './interactive.js'; +export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; diff --git a/src/commands/pipelineExecution.ts b/src/commands/pipelineExecution.ts new file mode 100644 index 0000000..bcff55c --- /dev/null +++ b/src/commands/pipelineExecution.ts @@ -0,0 +1,240 @@ +/** + * Pipeline execution flow + * + * Orchestrates the full pipeline: + * 1. Fetch issue content + * 2. Create branch + * 3. Run workflow + * 4. Commit & push + * 5. Create PR + */ + +import { execFileSync } from 'node:child_process'; +import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../github/issue.js'; +import { createPullRequest, pushBranch, buildPrBody } from '../github/pr.js'; +import { executeTask } from './taskExecution.js'; +import { loadGlobalConfig } from '../config/globalConfig.js'; +import { info, error, success, status } from '../utils/ui.js'; +import { createLogger } from '../utils/debug.js'; +import type { PipelineConfig } from '../models/types.js'; +import { + EXIT_ISSUE_FETCH_FAILED, + EXIT_WORKFLOW_FAILED, + EXIT_GIT_OPERATION_FAILED, + EXIT_PR_CREATION_FAILED, +} from '../exitCodes.js'; + +const log = createLogger('pipeline'); + +export interface PipelineExecutionOptions { + /** GitHub issue number */ + issueNumber?: number; + /** Task content (alternative to issue) */ + task?: string; + /** Workflow name */ + workflow: string; + /** Branch name (auto-generated if omitted) */ + branch?: string; + /** Whether to create a PR after successful execution */ + autoPr: boolean; + /** Repository in owner/repo format */ + repo?: string; + /** Working directory */ + cwd: string; +} + +/** + * Expand template variables in a string. + * Supported: {title}, {issue}, {issue_body}, {report} + */ +function expandTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match); +} + +/** Generate a branch name for pipeline execution */ +function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, issueNumber?: number): string { + const prefix = pipelineConfig?.defaultBranchPrefix ?? 'takt/'; + const timestamp = Math.floor(Date.now() / 1000); + if (issueNumber) { + return `${prefix}issue-${issueNumber}-${timestamp}`; + } + return `${prefix}pipeline-${timestamp}`; +} + +/** Create and checkout a new branch */ +function createBranch(cwd: string, branch: string): void { + execFileSync('git', ['checkout', '-b', branch], { + cwd, + stdio: 'pipe', + }); +} + +/** Stage all changes and create a commit */ +function commitChanges(cwd: string, message: string): string | undefined { + execFileSync('git', ['add', '-A'], { cwd, stdio: 'pipe' }); + + const statusOutput = execFileSync('git', ['status', '--porcelain'], { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }); + + if (!statusOutput.trim()) { + return undefined; + } + + execFileSync('git', ['commit', '-m', message], { cwd, stdio: 'pipe' }); + + return execFileSync('git', ['rev-parse', '--short', 'HEAD'], { + cwd, + stdio: 'pipe', + encoding: 'utf-8', + }).trim(); +} + +/** Build commit message from template or defaults */ +function buildCommitMessage( + pipelineConfig: PipelineConfig | undefined, + issue: GitHubIssue | undefined, + taskText: string | undefined, +): string { + const template = pipelineConfig?.commitMessageTemplate; + if (template && issue) { + return expandTemplate(template, { + title: issue.title, + issue: String(issue.number), + }); + } + // Default commit message + return issue + ? `feat: ${issue.title} (#${issue.number})` + : `takt: ${taskText ?? 'pipeline task'}`; +} + +/** Build PR body from template or defaults */ +function buildPipelinePrBody( + pipelineConfig: PipelineConfig | undefined, + issue: GitHubIssue | undefined, + report: string, +): string { + const template = pipelineConfig?.prBodyTemplate; + if (template && issue) { + return expandTemplate(template, { + title: issue.title, + issue: String(issue.number), + issue_body: issue.body || issue.title, + report, + }); + } + return buildPrBody(issue, report); +} + +/** + * Execute the full pipeline. + * + * Returns a process exit code (0 on success, 2-5 on specific failures). + */ +export async function executePipeline(options: PipelineExecutionOptions): Promise { + const { cwd, workflow, autoPr } = options; + const globalConfig = loadGlobalConfig(); + const pipelineConfig = globalConfig.pipeline; + let issue: GitHubIssue | undefined; + let task: string; + + // --- Step 1: Resolve task content --- + if (options.issueNumber) { + info(`Fetching issue #${options.issueNumber}...`); + try { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + error(ghStatus.error ?? 'gh CLI is not available'); + return EXIT_ISSUE_FETCH_FAILED; + } + issue = fetchIssue(options.issueNumber); + task = formatIssueAsTask(issue); + success(`Issue #${options.issueNumber} fetched: "${issue.title}"`); + } catch (err) { + error(`Failed to fetch issue #${options.issueNumber}: ${err instanceof Error ? err.message : String(err)}`); + return EXIT_ISSUE_FETCH_FAILED; + } + } else if (options.task) { + task = options.task; + } else { + error('Either --issue or --task must be specified'); + return EXIT_ISSUE_FETCH_FAILED; + } + + // --- Step 2: Create branch --- + const branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber); + info(`Creating branch: ${branch}`); + try { + createBranch(cwd, branch); + success(`Branch created: ${branch}`); + } catch (err) { + error(`Failed to create branch: ${err instanceof Error ? err.message : String(err)}`); + return EXIT_GIT_OPERATION_FAILED; + } + + // --- Step 3: Run workflow --- + info(`Running workflow: ${workflow}`); + log.info('Pipeline workflow execution starting', { workflow, branch, issueNumber: options.issueNumber }); + + const taskSuccess = await executeTask(task, cwd, workflow); + + if (!taskSuccess) { + error(`Workflow '${workflow}' failed`); + return EXIT_WORKFLOW_FAILED; + } + success(`Workflow '${workflow}' completed`); + + // --- Step 4: Commit & push --- + const commitMessage = buildCommitMessage(pipelineConfig, issue, options.task); + + info('Committing changes...'); + try { + const commitHash = commitChanges(cwd, commitMessage); + if (commitHash) { + success(`Changes committed: ${commitHash}`); + } else { + info('No changes to commit'); + } + + info(`Pushing to origin/${branch}...`); + pushBranch(cwd, branch); + success(`Pushed to origin/${branch}`); + } catch (err) { + error(`Git operation failed: ${err instanceof Error ? err.message : String(err)}`); + return EXIT_GIT_OPERATION_FAILED; + } + + // --- Step 5: Create PR (if --auto-pr) --- + if (autoPr) { + info('Creating pull request...'); + const prTitle = issue ? issue.title : (options.task ?? 'Pipeline task'); + const report = `Workflow \`${workflow}\` completed successfully.`; + const prBody = buildPipelinePrBody(pipelineConfig, issue, report); + + const prResult = createPullRequest(cwd, { + branch, + title: prTitle, + body: prBody, + repo: options.repo, + }); + + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + return EXIT_PR_CREATION_FAILED; + } + } + + // --- Summary --- + console.log(); + status('Issue', issue ? `#${issue.number} "${issue.title}"` : 'N/A'); + status('Branch', branch); + status('Workflow', workflow); + status('Result', 'Success', 'green'); + + return 0; +} diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts index 5e21abb..83386aa 100644 --- a/src/config/globalConfig.ts +++ b/src/config/globalConfig.ts @@ -12,14 +12,22 @@ import type { GlobalConfig, DebugConfig, Language } from '../models/types.js'; import { getGlobalConfigPath, getProjectConfigPath } from './paths.js'; import { DEFAULT_LANGUAGE } from '../constants.js'; +/** Create default global configuration (fresh instance each call) */ +function createDefaultGlobalConfig(): GlobalConfig { + return { + language: DEFAULT_LANGUAGE, + trustedDirectories: [], + defaultWorkflow: 'default', + logLevel: 'info', + provider: 'claude', + }; +} + /** Load global configuration */ export function loadGlobalConfig(): GlobalConfig { const configPath = getGlobalConfigPath(); if (!existsSync(configPath)) { - throw new Error( - `Global config not found: ${configPath}\n` + - 'Run takt once to initialize the configuration.' - ); + return createDefaultGlobalConfig(); } const content = readFileSync(configPath, 'utf-8'); const raw = parseYaml(content); @@ -39,6 +47,11 @@ export function loadGlobalConfig(): GlobalConfig { disabledBuiltins: parsed.disabled_builtins, anthropicApiKey: parsed.anthropic_api_key, openaiApiKey: parsed.openai_api_key, + pipeline: parsed.pipeline ? { + defaultBranchPrefix: parsed.pipeline.default_branch_prefix, + commitMessageTemplate: parsed.pipeline.commit_message_template, + prBodyTemplate: parsed.pipeline.pr_body_template, + } : undefined, }; } @@ -73,6 +86,15 @@ export function saveGlobalConfig(config: GlobalConfig): void { if (config.openaiApiKey) { raw.openai_api_key = config.openaiApiKey; } + if (config.pipeline) { + const pipelineRaw: Record = {}; + if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix; + if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate; + if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate; + if (Object.keys(pipelineRaw).length > 0) { + raw.pipeline = pipelineRaw; + } + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); } diff --git a/src/config/initialization.ts b/src/config/initialization.ts index a4f1f6a..49c107d 100644 --- a/src/config/initialization.ts +++ b/src/config/initialization.ts @@ -76,16 +76,32 @@ export async function promptProviderSelection(): Promise<'claude' | 'codex'> { return result; } +/** Options for global directory initialization */ +export interface InitGlobalDirsOptions { + /** Skip interactive prompts (CI/non-TTY environments) */ + nonInteractive?: boolean; +} + /** * Initialize global takt directory structure with language selection. * On first run, creates config.yaml from language template. * Agents/workflows are NOT copied — they are loaded via builtin fallback. + * + * In non-interactive mode (pipeline mode or no TTY), skips prompts + * and uses default values so takt works in pipeline/CI environments without config.yaml. */ -export async function initGlobalDirs(): Promise { +export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise { ensureDir(getGlobalConfigDir()); ensureDir(getGlobalLogsDir()); if (needsLanguageSetup()) { + const isInteractive = !options?.nonInteractive && process.stdin.isTTY === true; + + if (!isInteractive) { + // Pipeline / non-interactive: skip prompts, use defaults via loadGlobalConfig() fallback + return; + } + const lang = await promptLanguageSelection(); const provider = await promptProviderSelection(); diff --git a/src/exitCodes.ts b/src/exitCodes.ts new file mode 100644 index 0000000..b9e7836 --- /dev/null +++ b/src/exitCodes.ts @@ -0,0 +1,13 @@ +/** + * Process exit codes for takt CLI + * + * Fine-grained exit codes allow pipelines to distinguish + * between different failure modes. + */ + +export const EXIT_SUCCESS = 0; +export const EXIT_GENERAL_ERROR = 1; +export const EXIT_ISSUE_FETCH_FAILED = 2; +export const EXIT_WORKFLOW_FAILED = 3; +export const EXIT_GIT_OPERATION_FAILED = 4; +export const EXIT_PR_CREATION_FAILED = 5; diff --git a/src/github/issue.ts b/src/github/issue.ts index 27ba148..3aae03f 100644 --- a/src/github/issue.ts +++ b/src/github/issue.ts @@ -5,7 +5,7 @@ * for workflow execution or task creation. */ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import { createLogger } from '../utils/debug.js'; const log = createLogger('github'); @@ -31,11 +31,11 @@ export interface GhCliStatus { */ export function checkGhCli(): GhCliStatus { try { - execSync('gh auth status', { stdio: 'pipe' }); + execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' }); return { available: true }; } catch { try { - execSync('gh --version', { stdio: 'pipe' }); + execFileSync('gh', ['--version'], { stdio: 'pipe' }); return { available: false, error: 'gh CLI is installed but not authenticated. Run `gh auth login` first.', @@ -56,8 +56,9 @@ export function checkGhCli(): GhCliStatus { export function fetchIssue(issueNumber: number): GitHubIssue { log.debug('Fetching issue', { issueNumber }); - const raw = execSync( - `gh issue view ${issueNumber} --json number,title,body,labels,comments`, + const raw = execFileSync( + 'gh', + ['issue', 'view', String(issueNumber), '--json', 'number,title,body,labels,comments'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); diff --git a/src/github/pr.ts b/src/github/pr.ts new file mode 100644 index 0000000..3b3034a --- /dev/null +++ b/src/github/pr.ts @@ -0,0 +1,113 @@ +/** + * GitHub Pull Request utilities + * + * Creates PRs via `gh` CLI for CI/CD integration. + */ + +import { execFileSync } from 'node:child_process'; +import { createLogger } from '../utils/debug.js'; +import { checkGhCli, type GitHubIssue } from './issue.js'; + +const log = createLogger('github-pr'); + +export interface CreatePrOptions { + /** Branch to create PR from */ + branch: string; + /** PR title */ + title: string; + /** PR body (markdown) */ + body: string; + /** Base branch (default: repo default branch) */ + base?: string; + /** Repository in owner/repo format (optional, uses current repo if omitted) */ + repo?: string; +} + +export interface CreatePrResult { + success: boolean; + /** PR URL on success */ + url?: string; + /** Error message on failure */ + error?: string; +} + +/** + * Push a branch to origin. + * Throws on failure. + */ +export function pushBranch(cwd: string, branch: string): void { + log.info('Pushing branch to origin', { branch }); + execFileSync('git', ['push', 'origin', branch], { + cwd, + stdio: 'pipe', + }); +} + +/** + * Create a Pull Request via `gh pr create`. + */ +export function createPullRequest(cwd: string, options: CreatePrOptions): CreatePrResult { + const ghStatus = checkGhCli(); + if (!ghStatus.available) { + return { success: false, error: ghStatus.error ?? 'gh CLI is not available' }; + } + + const args = [ + 'pr', 'create', + '--title', options.title, + '--body', options.body, + '--head', options.branch, + ]; + + if (options.base) { + args.push('--base', options.base); + } + + if (options.repo) { + args.push('--repo', options.repo); + } + + log.info('Creating PR', { branch: options.branch, title: options.title }); + + try { + const output = execFileSync('gh', args, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const url = output.trim(); + log.info('PR created', { url }); + + return { success: true, url }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error('PR creation failed', { error: errorMessage }); + return { success: false, error: errorMessage }; + } +} + +/** + * Build PR body from issue and execution report. + */ +export function buildPrBody(issue: GitHubIssue | undefined, report: string): string { + const parts: string[] = []; + + parts.push('## Summary'); + if (issue) { + parts.push(''); + parts.push(issue.body || issue.title); + } + + parts.push(''); + parts.push('## Execution Report'); + parts.push(''); + parts.push(report); + + if (issue) { + parts.push(''); + parts.push(`Closes #${issue.number}`); + } + + return parts.join('\n'); +} diff --git a/src/models/schemas.ts b/src/models/schemas.ts index 42e1fe7..1272032 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -159,6 +159,13 @@ export const DebugConfigSchema = z.object({ /** Language setting schema */ export const LanguageSchema = z.enum(['en', 'ja']); +/** Pipeline execution config schema */ +export const PipelineConfigSchema = z.object({ + default_branch_prefix: z.string().optional(), + commit_message_template: z.string().optional(), + pr_body_template: z.string().optional(), +}); + /** Global config schema */ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), @@ -176,6 +183,8 @@ export const GlobalConfigSchema = z.object({ anthropic_api_key: z.string().optional(), /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openai_api_key: z.string().optional(), + /** Pipeline execution settings */ + pipeline: PipelineConfigSchema.optional(), }); /** Project config schema */ diff --git a/src/models/types.ts b/src/models/types.ts index f9d31a3..13f7ef0 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -178,6 +178,16 @@ export interface DebugConfig { /** Language setting for takt */ export type Language = 'en' | 'ja'; +/** Pipeline execution configuration */ +export interface PipelineConfig { + /** Branch name prefix for pipeline-created branches (default: "takt/") */ + defaultBranchPrefix?: string; + /** Commit message template. Variables: {title}, {issue} */ + commitMessageTemplate?: string; + /** PR body template. Variables: {issue_body}, {report}, {issue} */ + prBodyTemplate?: string; +} + /** Global configuration for takt */ export interface GlobalConfig { language: Language; @@ -195,6 +205,8 @@ export interface GlobalConfig { anthropicApiKey?: string; /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ openaiApiKey?: string; + /** Pipeline execution settings */ + pipeline?: PipelineConfig; } /** Project-level configuration */