resolved 52

This commit is contained in:
nrslib 2026-01-31 18:28:30 +09:00
parent 2b35021d45
commit e950a3f79c
19 changed files with 1354 additions and 60 deletions

118
README.md
View File

@ -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 <text>` | Task content — **triggers pipeline (non-interactive) mode** |
| `-i, --issue <N>` | GitHub issue number (equivalent to `#N` in interactive mode) |
| `-w, --workflow <name>` | Workflow to use |
| `-b, --branch <name>` | Branch name (auto-generated if omitted) |
| `--auto-pr` | Create PR after execution (interactive: skip confirmation, pipeline: enable PR) |
| `--repo <owner/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

View File

@ -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 <text>` | タスク内容 — **パイプライン(非対話)モードのトリガー** |
| `-i, --issue <N>` | GitHub Issue番号対話モードでは `#N` と同じ) |
| `-w, --workflow <name>` | ワークフロー指定 |
| `-b, --branch <name>` | ブランチ名指定(省略時は自動生成) |
| `--auto-pr` | PR作成対話: 確認スキップ、パイプライン: PR有効化 |
| `--repo <owner/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`

View File

@ -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

View File

@ -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

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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})');
});
});

View File

@ -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);
});
});

View File

@ -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',
}),
);
});
});
});

View File

@ -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<string | null> {
* 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<void> {
async function selectAndExecuteTask(
cwd: string,
task: string,
options?: { autoPr?: boolean; repo?: string },
): Promise<void> {
const selectedWorkflow = await selectWorkflow(cwd);
if (selectedWorkflow === null) {
@ -103,7 +117,7 @@ async function selectAndExecuteTask(cwd: string, task: string): Promise<void> {
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<void> {
} 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 <number>', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10))
.option('-w, --workflow <name>', 'Workflow to use')
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
.option('--auto-pr', 'Create PR after successful execution')
.option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('-t, --task <string>', '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();

View File

@ -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';

View File

@ -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, string>): 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<number> {
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;
}

View File

@ -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<string, unknown> = {};
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');
}

View File

@ -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<void> {
export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise<void> {
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();

13
src/exitCodes.ts Normal file
View File

@ -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;

View File

@ -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'] },
);

113
src/github/pr.ts Normal file
View File

@ -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');
}

View File

@ -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 */

View File

@ -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 */