resolved 52
This commit is contained in:
parent
2b35021d45
commit
e950a3f79c
118
README.md
118
README.md
@ -24,26 +24,18 @@ npm install -g takt
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```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"
|
takt "Add a login feature"
|
||||||
|
|
||||||
# Run a GitHub issue as a task
|
# Run a GitHub issue as a task (both are equivalent)
|
||||||
takt "#6"
|
takt '#6'
|
||||||
|
takt --issue 6
|
||||||
|
|
||||||
# Add a task via AI conversation
|
# Interactive mode — refine task requirements with AI, then execute
|
||||||
takt add
|
takt
|
||||||
|
|
||||||
# Run all pending tasks
|
# Pipeline mode (non-interactive, for scripts and CI)
|
||||||
takt run
|
takt --task "fix the auth bug" --auto-pr
|
||||||
|
|
||||||
# Watch for tasks and auto-execute
|
|
||||||
takt watch
|
|
||||||
|
|
||||||
# List task branches (merge or delete)
|
|
||||||
takt list
|
|
||||||
|
|
||||||
# Switch workflow
|
|
||||||
takt switch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### What happens when you run a task
|
### 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.
|
**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
|
### Recommended workflows
|
||||||
|
|
||||||
| Workflow | Best for |
|
| Workflow | Best for |
|
||||||
@ -87,11 +87,54 @@ Choose `y` to run in a `git clone --shared` isolated environment, keeping your w
|
|||||||
|
|
||||||
## Commands
|
## 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 |
|
| 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 run` | Run all pending tasks from `.takt/tasks/` |
|
||||||
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
|
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
|
||||||
| `takt add` | Add a new task via AI conversation |
|
| `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 config` | Configure permission mode |
|
||||||
| `takt --help` | Show help |
|
| `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
|
## 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.
|
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)
|
model: sonnet # Default model (optional)
|
||||||
trusted_directories:
|
trusted_directories:
|
||||||
- /path/to/trusted/dir
|
- /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:**
|
**Model Resolution Priority:**
|
||||||
1. Workflow step `model` (highest priority)
|
1. Workflow step `model` (highest priority)
|
||||||
2. Custom agent `model`
|
2. Custom agent `model`
|
||||||
@ -299,12 +372,13 @@ trusted_directories:
|
|||||||
|
|
||||||
### Interactive Workflow
|
### 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)
|
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
|
### Adding Custom Workflows
|
||||||
|
|
||||||
|
|||||||
@ -20,31 +20,23 @@ npm install -g takt
|
|||||||
## クイックスタート
|
## クイックスタート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# タスクを実行(ワークフロー選択プロンプトが表示されます)
|
# タスクを実行(ワークフロー選択・worktree・PR作成を対話的に案内)
|
||||||
takt "ログイン機能を追加して"
|
takt ログイン機能を追加して
|
||||||
|
|
||||||
# GitHub Issueをタスクとして実行
|
# GitHub Issueをタスクとして実行(どちらも同じ)
|
||||||
takt "#6"
|
takt '#6'
|
||||||
|
takt --issue 6
|
||||||
|
|
||||||
# AI会話でタスクを追加
|
# 対話モードでAIとタスク要件を詰めてから実行
|
||||||
takt add
|
takt
|
||||||
|
|
||||||
# 保留中のタスクをすべて実行
|
# パイプライン実行(非対話・スクリプト/CI向け)
|
||||||
takt run
|
takt --task "バグを修正して" --auto-pr
|
||||||
|
|
||||||
# タスクを監視して自動実行
|
|
||||||
takt watch
|
|
||||||
|
|
||||||
# タスクブランチ一覧(マージ・削除)
|
|
||||||
takt list
|
|
||||||
|
|
||||||
# ワークフローを切り替え
|
|
||||||
takt switch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### タスク実行の流れ
|
### タスク実行の流れ
|
||||||
|
|
||||||
`takt "ログイン機能を追加して"` を実行すると、以下の対話フローが表示されます:
|
`takt ログイン機能を追加して` を実行すると、以下の対話フローが表示されます:
|
||||||
|
|
||||||
**1. ワークフロー選択**
|
**1. ワークフロー選択**
|
||||||
|
|
||||||
@ -71,6 +63,14 @@ Select workflow:
|
|||||||
|
|
||||||
**3. 実行** — 選択したワークフローが複数のエージェントを連携させてタスクを完了します。
|
**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 run` | `.takt/tasks/` の保留中タスクをすべて実行 |
|
||||||
| `takt watch` | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) |
|
| `takt watch` | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) |
|
||||||
| `takt add` | AI会話で新しいタスクを追加 |
|
| `takt add` | AI会話で新しいタスクを追加 |
|
||||||
@ -98,6 +141,17 @@ Select workflow:
|
|||||||
| `takt config` | パーミッションモードを設定 |
|
| `takt config` | パーミッションモードを設定 |
|
||||||
| `takt --help` | ヘルプを表示 |
|
| `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/`にコピーしてカスタマイズできます。
|
TAKTはYAMLベースのワークフロー定義とルールベースルーティングを使用します。ビルトインワークフローはパッケージに埋め込まれており、`~/.takt/workflows/` のユーザーワークフローが優先されます。`takt eject` でビルトインを`~/.takt/`にコピーしてカスタマイズできます。
|
||||||
@ -282,8 +336,27 @@ provider: claude # デフォルトプロバイダー: claude または c
|
|||||||
model: sonnet # デフォルトモデル(オプション)
|
model: sonnet # デフォルトモデル(オプション)
|
||||||
trusted_directories:
|
trusted_directories:
|
||||||
- /path/to/trusted/dir
|
- /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`(最優先)
|
1. ワークフローステップの `model`(最優先)
|
||||||
2. カスタムエージェントの `model`
|
2. カスタムエージェントの `model`
|
||||||
|
|||||||
@ -27,6 +27,16 @@ provider: claude
|
|||||||
# OpenAI API key (optional, overridden by TAKT_OPENAI_API_KEY env var)
|
# OpenAI API key (optional, overridden by TAKT_OPENAI_API_KEY env var)
|
||||||
# openai_api_key: ""
|
# 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 settings (optional)
|
||||||
# debug:
|
# debug:
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|||||||
@ -27,6 +27,16 @@ provider: claude
|
|||||||
# OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能)
|
# OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能)
|
||||||
# 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:
|
# debug:
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|||||||
37
src/__tests__/exitCodes.test.ts
Normal file
37
src/__tests__/exitCodes.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/__tests__/github-pr.test.ts
Normal file
54
src/__tests__/github-pr.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/__tests__/globalConfig-defaults.test.ts
Normal file
114
src/__tests__/globalConfig-defaults.test.ts
Normal 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})');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/__tests__/initialization-noninteractive.test.ts
Normal file
59
src/__tests__/initialization-noninteractive.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
348
src/__tests__/pipelineExecution.test.ts
Normal file
348
src/__tests__/pipelineExecution.test.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
src/cli.ts
102
src/cli.ts
@ -5,11 +5,16 @@
|
|||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* takt {task} - Execute task with current workflow (continues session)
|
* 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 run - Run all pending tasks from .takt/tasks/
|
||||||
* takt switch - Switch workflow interactively
|
* takt switch - Switch workflow interactively
|
||||||
* takt clear - Clear agent conversation sessions (reset to initial state)
|
* takt clear - Clear agent conversation sessions (reset to initial state)
|
||||||
* takt --help - Show help
|
* takt --help - Show help
|
||||||
* takt config - Select permission mode interactively
|
* 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';
|
import { createRequire } from 'node:module';
|
||||||
@ -34,6 +39,7 @@ import {
|
|||||||
watchTasks,
|
watchTasks,
|
||||||
listTasks,
|
listTasks,
|
||||||
interactiveMode,
|
interactiveMode,
|
||||||
|
executePipeline,
|
||||||
} from './commands/index.js';
|
} from './commands/index.js';
|
||||||
import { listWorkflows } from './config/workflowLoader.js';
|
import { listWorkflows } from './config/workflowLoader.js';
|
||||||
import { selectOptionWithDefault, confirm } from './prompt/index.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 { DEFAULT_WORKFLOW_NAME } from './constants.js';
|
||||||
import { checkForUpdates } from './utils/updateNotifier.js';
|
import { checkForUpdates } from './utils/updateNotifier.js';
|
||||||
import { resolveIssueTask, isIssueReference } from './github/issue.js';
|
import { resolveIssueTask, isIssueReference } from './github/issue.js';
|
||||||
|
import { createPullRequest, buildPrBody } from './github/pr.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { version: cliVersion } = require('../package.json') as { version: string };
|
const { version: cliVersion } = require('../package.json') as { version: string };
|
||||||
@ -54,6 +61,9 @@ checkForUpdates();
|
|||||||
/** Resolved cwd shared across commands via preAction hook */
|
/** Resolved cwd shared across commands via preAction hook */
|
||||||
let resolvedCwd = '';
|
let resolvedCwd = '';
|
||||||
|
|
||||||
|
/** Whether pipeline mode is active (--task specified, set in preAction) */
|
||||||
|
let pipelineMode = false;
|
||||||
|
|
||||||
export interface WorktreeConfirmationResult {
|
export interface WorktreeConfirmationResult {
|
||||||
execCwd: string;
|
execCwd: string;
|
||||||
isWorktree: boolean;
|
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.
|
* Execute a task with workflow selection, optional worktree, and auto-commit.
|
||||||
* Shared by direct task execution and interactive mode.
|
* 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);
|
const selectedWorkflow = await selectWorkflow(cwd);
|
||||||
|
|
||||||
if (selectedWorkflow === null) {
|
if (selectedWorkflow === null) {
|
||||||
@ -103,7 +117,7 @@ async function selectAndExecuteTask(cwd: string, task: string): Promise<void> {
|
|||||||
return;
|
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 });
|
log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree });
|
||||||
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd);
|
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) {
|
} else if (!commitResult.success) {
|
||||||
error(`Auto-commit failed: ${commitResult.message}`);
|
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) {
|
if (!taskSuccess) {
|
||||||
@ -157,11 +191,24 @@ program
|
|||||||
.description('TAKT: Task Agent Koordination Tool')
|
.description('TAKT: Task Agent Koordination Tool')
|
||||||
.version(cliVersion);
|
.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
|
// Common initialization for all commands
|
||||||
program.hook('preAction', async () => {
|
program.hook('preAction', async () => {
|
||||||
resolvedCwd = resolve(process.cwd());
|
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);
|
initProjectDirs(resolvedCwd);
|
||||||
|
|
||||||
const verbose = isVerboseMode(resolvedCwd);
|
const verbose = isVerboseMode(resolvedCwd);
|
||||||
@ -181,7 +228,7 @@ program.hook('preAction', async () => {
|
|||||||
setLogLevel(config.logLevel);
|
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 ---
|
// --- Subcommands ---
|
||||||
@ -248,7 +295,7 @@ program
|
|||||||
await switchConfig(resolvedCwd, key);
|
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)
|
* Check if the input is a task description (should execute directly)
|
||||||
@ -265,9 +312,50 @@ function isDirectTask(input: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
|
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
|
||||||
.action(async (task?: string) => {
|
.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)) {
|
if (task && isDirectTask(task)) {
|
||||||
// Resolve #N issue references to task text
|
// Resolve #N issue references to task text
|
||||||
let resolvedTask: string = task;
|
let resolvedTask: string = task;
|
||||||
@ -281,7 +369,7 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await selectAndExecuteTask(resolvedCwd, resolvedTask);
|
await selectAndExecuteTask(resolvedCwd, resolvedTask, prOptions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,7 +380,7 @@ program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await selectAndExecuteTask(resolvedCwd, result.task);
|
await selectAndExecuteTask(resolvedCwd, result.task, prOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
@ -12,3 +12,4 @@ export { switchWorkflow } from './workflow.js';
|
|||||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
|
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
|
||||||
export { listTasks } from './listTasks.js';
|
export { listTasks } from './listTasks.js';
|
||||||
export { interactiveMode } from './interactive.js';
|
export { interactiveMode } from './interactive.js';
|
||||||
|
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js';
|
||||||
|
|||||||
240
src/commands/pipelineExecution.ts
Normal file
240
src/commands/pipelineExecution.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -12,14 +12,22 @@ import type { GlobalConfig, DebugConfig, Language } from '../models/types.js';
|
|||||||
import { getGlobalConfigPath, getProjectConfigPath } from './paths.js';
|
import { getGlobalConfigPath, getProjectConfigPath } from './paths.js';
|
||||||
import { DEFAULT_LANGUAGE } from '../constants.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 */
|
/** Load global configuration */
|
||||||
export function loadGlobalConfig(): GlobalConfig {
|
export function loadGlobalConfig(): GlobalConfig {
|
||||||
const configPath = getGlobalConfigPath();
|
const configPath = getGlobalConfigPath();
|
||||||
if (!existsSync(configPath)) {
|
if (!existsSync(configPath)) {
|
||||||
throw new Error(
|
return createDefaultGlobalConfig();
|
||||||
`Global config not found: ${configPath}\n` +
|
|
||||||
'Run takt once to initialize the configuration.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
const raw = parseYaml(content);
|
const raw = parseYaml(content);
|
||||||
@ -39,6 +47,11 @@ export function loadGlobalConfig(): GlobalConfig {
|
|||||||
disabledBuiltins: parsed.disabled_builtins,
|
disabledBuiltins: parsed.disabled_builtins,
|
||||||
anthropicApiKey: parsed.anthropic_api_key,
|
anthropicApiKey: parsed.anthropic_api_key,
|
||||||
openaiApiKey: parsed.openai_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) {
|
if (config.openaiApiKey) {
|
||||||
raw.openai_api_key = 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');
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,16 +76,32 @@ export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
|||||||
return result;
|
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.
|
* Initialize global takt directory structure with language selection.
|
||||||
* On first run, creates config.yaml from language template.
|
* On first run, creates config.yaml from language template.
|
||||||
* Agents/workflows are NOT copied — they are loaded via builtin fallback.
|
* 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(getGlobalConfigDir());
|
||||||
ensureDir(getGlobalLogsDir());
|
ensureDir(getGlobalLogsDir());
|
||||||
|
|
||||||
if (needsLanguageSetup()) {
|
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 lang = await promptLanguageSelection();
|
||||||
const provider = await promptProviderSelection();
|
const provider = await promptProviderSelection();
|
||||||
|
|
||||||
|
|||||||
13
src/exitCodes.ts
Normal file
13
src/exitCodes.ts
Normal 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;
|
||||||
@ -5,7 +5,7 @@
|
|||||||
* for workflow execution or task creation.
|
* for workflow execution or task creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createLogger } from '../utils/debug.js';
|
import { createLogger } from '../utils/debug.js';
|
||||||
|
|
||||||
const log = createLogger('github');
|
const log = createLogger('github');
|
||||||
@ -31,11 +31,11 @@ export interface GhCliStatus {
|
|||||||
*/
|
*/
|
||||||
export function checkGhCli(): GhCliStatus {
|
export function checkGhCli(): GhCliStatus {
|
||||||
try {
|
try {
|
||||||
execSync('gh auth status', { stdio: 'pipe' });
|
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
||||||
return { available: true };
|
return { available: true };
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
execSync('gh --version', { stdio: 'pipe' });
|
execFileSync('gh', ['--version'], { stdio: 'pipe' });
|
||||||
return {
|
return {
|
||||||
available: false,
|
available: false,
|
||||||
error: 'gh CLI is installed but not authenticated. Run `gh auth login` first.',
|
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 {
|
export function fetchIssue(issueNumber: number): GitHubIssue {
|
||||||
log.debug('Fetching issue', { issueNumber });
|
log.debug('Fetching issue', { issueNumber });
|
||||||
|
|
||||||
const raw = execSync(
|
const raw = execFileSync(
|
||||||
`gh issue view ${issueNumber} --json number,title,body,labels,comments`,
|
'gh',
|
||||||
|
['issue', 'view', String(issueNumber), '--json', 'number,title,body,labels,comments'],
|
||||||
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
113
src/github/pr.ts
Normal file
113
src/github/pr.ts
Normal 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');
|
||||||
|
}
|
||||||
@ -159,6 +159,13 @@ export const DebugConfigSchema = z.object({
|
|||||||
/** Language setting schema */
|
/** Language setting schema */
|
||||||
export const LanguageSchema = z.enum(['en', 'ja']);
|
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 */
|
/** Global config schema */
|
||||||
export const GlobalConfigSchema = z.object({
|
export const GlobalConfigSchema = z.object({
|
||||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||||
@ -176,6 +183,8 @@ export const GlobalConfigSchema = z.object({
|
|||||||
anthropic_api_key: z.string().optional(),
|
anthropic_api_key: z.string().optional(),
|
||||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||||
openai_api_key: z.string().optional(),
|
openai_api_key: z.string().optional(),
|
||||||
|
/** Pipeline execution settings */
|
||||||
|
pipeline: PipelineConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Project config schema */
|
/** Project config schema */
|
||||||
|
|||||||
@ -178,6 +178,16 @@ export interface DebugConfig {
|
|||||||
/** Language setting for takt */
|
/** Language setting for takt */
|
||||||
export type Language = 'en' | 'ja';
|
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 */
|
/** Global configuration for takt */
|
||||||
export interface GlobalConfig {
|
export interface GlobalConfig {
|
||||||
language: Language;
|
language: Language;
|
||||||
@ -195,6 +205,8 @@ export interface GlobalConfig {
|
|||||||
anthropicApiKey?: string;
|
anthropicApiKey?: string;
|
||||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
|
/** Pipeline execution settings */
|
||||||
|
pipeline?: PipelineConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project-level configuration */
|
/** Project-level configuration */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user