Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/pilot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,9 @@ Examples:
}
}

// GH-2211: Wire native sub-issue linker so epic children get proper parent→child links
gwRunner.SetSubIssueLinker(client)

// GH-726: Wire processed issue persistence for gateway poller
if gwAutopilotStateStore != nil {
pollerOpts = append(pollerOpts, github.WithProcessedStore(gwAutopilotStateStore))
Expand Down
18 changes: 18 additions & 0 deletions internal/executor/epic.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,24 @@ func (r *Runner) createSubIssuesViaGitHub(ctx context.Context, plan *EpicPlan, e
// Track order → issue number for dependency resolution (GH-1794)
orderToIssueNumber[subtask.Order] = issueNumber

// GH-2211: Wire native GitHub sub-issue link (non-fatal — text marker is fallback)
if r.subIssueLinker != nil &&
plan.ParentTask != nil &&
plan.ParentTask.SourceRepo != "" &&
plan.ParentTask.SourceIssueID != "" {
if parts := strings.SplitN(plan.ParentTask.SourceRepo, "/", 2); len(parts) == 2 {
if parentNum, parseErr := strconv.Atoi(plan.ParentTask.SourceIssueID); parseErr == nil {
if linkErr := r.subIssueLinker.LinkSubIssue(ctx, parts[0], parts[1], parentNum, issueNumber); linkErr != nil {
r.log.Warn("Failed to link native sub-issue",
"parent", parentNum,
"child", issueNumber,
"error", linkErr,
)
}
}
}
}

r.log.Info("Created GitHub issue",
"subtask_order", subtask.Order,
"issue_number", issueNumber,
Expand Down
172 changes: 172 additions & 0 deletions internal/executor/epic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
Expand Down Expand Up @@ -1423,3 +1424,174 @@ func TestCreatedIssue_IdentifierField(t *testing.T) {
})
}
}

// mockSubIssueLinker records LinkSubIssue calls for test assertions.
type mockSubIssueLinker struct {
mu sync.Mutex
Calls []mockLinkSubIssueCall
ErrFn func(owner, repo string, parentNum, childNum int) error // optional error injection
}

type mockLinkSubIssueCall struct {
Owner string
Repo string
ParentNum int
ChildNum int
}

func (m *mockSubIssueLinker) LinkSubIssue(_ context.Context, owner, repo string, parentNum, childNum int) error {
m.mu.Lock()
m.Calls = append(m.Calls, mockLinkSubIssueCall{owner, repo, parentNum, childNum})
m.mu.Unlock()
if m.ErrFn != nil {
return m.ErrFn(owner, repo, parentNum, childNum)
}
return nil
}

func TestSetSubIssueLinker_WiresField(t *testing.T) {
r := NewRunner()
if r.subIssueLinker != nil {
t.Fatal("expected nil subIssueLinker before Set")
}
mock := &mockSubIssueLinker{}
r.SetSubIssueLinker(mock)
if r.subIssueLinker == nil {
t.Fatal("expected non-nil subIssueLinker after Set")
}
}

func TestCreateSubIssues_LinkerInvokedAfterGhCreate(t *testing.T) {
// Use a fake "gh" binary that echoes a fake issue URL so the CLI path succeeds.
fakeBin := t.TempDir()
script := filepath.Join(fakeBin, "gh")
err := os.WriteFile(script, []byte("#!/bin/sh\necho https://github.com/owner/testrepo/issues/42\n"), 0o755)
if err != nil {
t.Fatalf("write fake gh: %v", err)
}
origPATH := os.Getenv("PATH")
t.Setenv("PATH", fakeBin+string(filepath.ListSeparator)+origPATH)

mock := &mockSubIssueLinker{}
runner := NewRunner()
runner.SetSubIssueLinker(mock)

plan := &EpicPlan{
ParentTask: &Task{
ID: "GH-10",
SourceRepo: "owner/testrepo",
SourceIssueID: "10",
},
Subtasks: []PlannedSubtask{
{Title: "Child task", Description: "Do it", Order: 1},
},
}

ctx := context.Background()
created, err := runner.CreateSubIssues(ctx, plan, t.TempDir())
if err != nil {
t.Fatalf("CreateSubIssues failed: %v", err)
}
if len(created) != 1 {
t.Fatalf("expected 1 created issue, got %d", len(created))
}
if created[0].Number != 42 {
t.Errorf("issue number = %d, want 42", created[0].Number)
}

// Linker must have been called exactly once with the right args
if len(mock.Calls) != 1 {
t.Fatalf("expected 1 LinkSubIssue call, got %d", len(mock.Calls))
}
call := mock.Calls[0]
if call.Owner != "owner" {
t.Errorf("owner = %q, want owner", call.Owner)
}
if call.Repo != "testrepo" {
t.Errorf("repo = %q, want testrepo", call.Repo)
}
if call.ParentNum != 10 {
t.Errorf("parentNum = %d, want 10", call.ParentNum)
}
if call.ChildNum != 42 {
t.Errorf("childNum = %d, want 42", call.ChildNum)
}
}

func TestCreateSubIssues_LinkerErrorIsNonFatal(t *testing.T) {
// Fake "gh" binary returns a valid URL; linker returns an error.
// CreateSubIssues must still succeed (linker error is warn-only).
fakeBin := t.TempDir()
script := filepath.Join(fakeBin, "gh")
err := os.WriteFile(script, []byte("#!/bin/sh\necho https://github.com/owner/testrepo/issues/99\n"), 0o755)
if err != nil {
t.Fatalf("write fake gh: %v", err)
}
origPATH := os.Getenv("PATH")
t.Setenv("PATH", fakeBin+string(filepath.ListSeparator)+origPATH)

mock := &mockSubIssueLinker{
ErrFn: func(_, _ string, _, _ int) error {
return fmt.Errorf("graphql mutation failed")
},
}
runner := NewRunner()
runner.SetSubIssueLinker(mock)

plan := &EpicPlan{
ParentTask: &Task{
ID: "GH-5",
SourceRepo: "owner/testrepo",
SourceIssueID: "5",
},
Subtasks: []PlannedSubtask{
{Title: "Child", Description: "child", Order: 1},
},
}

ctx := context.Background()
created, err := runner.CreateSubIssues(ctx, plan, t.TempDir())
if err != nil {
t.Fatalf("CreateSubIssues must succeed even when linker errors: %v", err)
}
if len(created) != 1 {
t.Fatalf("expected 1 created issue, got %d", len(created))
}
// Linker was called (and returned error) but creation succeeded
if len(mock.Calls) != 1 {
t.Errorf("expected linker called once, got %d", len(mock.Calls))
}
}

func TestCreateSubIssues_LinkerSkippedWhenSourceRepoEmpty(t *testing.T) {
// When SourceRepo is empty, linker must NOT be called even if set.
fakeBin := t.TempDir()
script := filepath.Join(fakeBin, "gh")
err := os.WriteFile(script, []byte("#!/bin/sh\necho https://github.com/owner/testrepo/issues/7\n"), 0o755)
if err != nil {
t.Fatalf("write fake gh: %v", err)
}
origPATH := os.Getenv("PATH")
t.Setenv("PATH", fakeBin+string(filepath.ListSeparator)+origPATH)

mock := &mockSubIssueLinker{}
runner := NewRunner()
runner.SetSubIssueLinker(mock)

plan := &EpicPlan{
ParentTask: &Task{
ID: "GH-3",
// SourceRepo intentionally empty
},
Subtasks: []PlannedSubtask{
{Title: "Child", Description: "child", Order: 1},
},
}

ctx := context.Background()
_, _ = runner.CreateSubIssues(ctx, plan, t.TempDir())

if len(mock.Calls) != 0 {
t.Errorf("linker must not be called when SourceRepo is empty, got %d calls", len(mock.Calls))
}
}
16 changes: 16 additions & 0 deletions internal/executor/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,12 @@ type SubIssueCreator interface {
CreateIssue(ctx context.Context, parentID, title, body string, labels []string) (identifier string, url string, err error)
}

// SubIssueLinker links a child issue to a parent issue using GitHub's native sub-issue API (GH-2211).
// *github.Client satisfies this interface via its LinkSubIssue method.
type SubIssueLinker interface {
LinkSubIssue(ctx context.Context, owner, repo string, parentNum, childNum int) error
}

// Runner executes development tasks using an AI backend (Claude Code, OpenCode, etc.).
// It manages task lifecycle including branch creation, AI invocation,
// progress tracking, PR creation, and execution recording. Runner is safe for
Expand Down Expand Up @@ -329,6 +335,8 @@ type Runner struct {
worktreeManager *WorktreeManager // Optional worktree manager with pool support
// GH-1471: SubIssueCreator for non-GitHub adapters
subIssueCreator SubIssueCreator // Optional creator for sub-issues in external trackers
// GH-2211: SubIssueLinker for native GitHub sub-issue API linking
subIssueLinker SubIssueLinker // Optional linker for native GitHub parent→child wiring
// GH-1599: Execution log store for milestone entries
logStore *memory.Store // Optional log store for writing execution milestones
// GH-1811: Learning system (self-improvement)
Expand Down Expand Up @@ -629,6 +637,14 @@ func (r *Runner) SetSubIssueCreator(creator SubIssueCreator) {
r.subIssueCreator = creator
}

// SetSubIssueLinker sets the linker for native GitHub sub-issue linking (GH-2211).
// When set, createSubIssuesViaGitHub will call LinkSubIssue after each child issue is
// created to establish the native parent→child relationship. Failures are non-fatal
// (warn-level log only) — the text "Parent: GH-N" body marker remains as fallback.
func (r *Runner) SetSubIssueLinker(linker SubIssueLinker) {
r.subIssueLinker = linker
}

// SetIntentJudge sets the intent judge for diff-vs-ticket alignment verification (GH-624).
func (r *Runner) SetIntentJudge(judge *IntentJudge) {
r.intentJudge = judge
Expand Down
Loading