diff --git a/cmd/pilot/main.go b/cmd/pilot/main.go index 1afffc37..64d45ca5 100644 --- a/cmd/pilot/main.go +++ b/cmd/pilot/main.go @@ -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)) diff --git a/internal/executor/epic.go b/internal/executor/epic.go index 211abd1c..b0c8149e 100644 --- a/internal/executor/epic.go +++ b/internal/executor/epic.go @@ -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, diff --git a/internal/executor/epic_test.go b/internal/executor/epic_test.go index a1829817..c3922118 100644 --- a/internal/executor/epic_test.go +++ b/internal/executor/epic_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "reflect" "strings" + "sync" "testing" "time" ) @@ -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)) + } +} diff --git a/internal/executor/runner.go b/internal/executor/runner.go index 79ba0d09..cd6ffa78 100644 --- a/internal/executor/runner.go +++ b/internal/executor/runner.go @@ -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 @@ -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) @@ -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