cli/docs/plans/2026-02-05-core-ide-job-runner-plan.md
Snider a1306bc321 feat/ml-integration (#2)
Co-authored-by: Charon (snider-linux) <charon@lethean.io>
Co-authored-by: Snider <snider@host.uk.com>
Co-authored-by: Virgil <virgil@lethean.io>
Co-authored-by: Claude <developers@lethean.io>
Reviewed-on: #2
Co-authored-by: Snider <snider@lethean.io>
Co-committed-by: Snider <snider@lethean.io>
2026-02-16 06:19:09 +00:00

53 KiB

Core-IDE Job Runner Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Turn core-ide into an autonomous job runner that polls GitHub for pipeline work, executes it via typed handlers, and captures JSONL training data.

Architecture: Go workspace (go.work) linking root module + core-ide module. Pluggable JobSource interface with GitHub as first adapter. JobHandler interface for each pipeline action (publish draft, resolve threads, etc.). Poller orchestrates discovery and dispatch. Journal writes JSONL. Headless mode reuses existing pkg/cli.Daemon infrastructure. Handlers live in pkg/jobrunner/ (root module), core-ide imports them via workspace.

Tech Stack: Go 1.25, GitHub REST API (via oauth2), pkg/cli.Daemon for headless, testify/assert + httptest for tests.


Task 0: Set Up Go Workspace (go.work)

Files:

  • Create: go.work

Context: The repo has two real modules — the root (forge.lthn.ai/core/cli) and core-ide (forge.lthn.ai/core/cli/internal/core-ide). Without a workspace, core-ide can't import pkg/jobrunner from the root module during local development without fragile replace directives. A go.work file makes cross-module imports resolve locally, keeps each module's go.mod clean, and lets CI build each variant independently.

Step 1: Create the workspace file

cd /Users/snider/Code/host-uk/core
go work init . ./internal/core-ide

This generates go.work:

go 1.25.5

use (
	.
	./internal/core-ide
)

Step 2: Sync dependency versions across modules

go work sync

This aligns shared dependency versions between the two modules.

Step 3: Verify the workspace

Run: go build ./... Expected: Root module builds successfully.

Run: cd internal/core-ide && go build . Expected: core-ide builds successfully.

Run: go test ./pkg/... -count=1 Expected: All existing tests pass (workspace doesn't change behaviour, just resolution).

Step 4: Add go.work.sum to gitignore

go.work.sum is generated and shouldn't be committed (it's machine-specific like go.sum but for the workspace). Check if .gitignore already excludes it:

grep -q 'go.work.sum' .gitignore || echo 'go.work.sum' >> .gitignore

Note: Whether to commit go.work itself is a choice. Committing it means all developers and CI share the same workspace layout. Since the module layout is fixed (root + core-ide), committing it is the right call — it documents the build variants explicitly.

Step 5: Commit

git add go.work .gitignore
git commit -m "build: add Go workspace for root + core-ide modules"

Task 1: Core Types (pkg/jobrunner/types.go)

Files:

  • Create: pkg/jobrunner/types.go
  • Test: pkg/jobrunner/types_test.go

Step 1: Write the test file

package jobrunner

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestPipelineSignal_RepoFullName_Good(t *testing.T) {
	s := &PipelineSignal{RepoOwner: "host-uk", RepoName: "core"}
	assert.Equal(t, "host-uk/core", s.RepoFullName())
}

func TestPipelineSignal_HasUnresolvedThreads_Good(t *testing.T) {
	s := &PipelineSignal{ThreadsTotal: 5, ThreadsResolved: 3}
	assert.True(t, s.HasUnresolvedThreads())
}

func TestPipelineSignal_HasUnresolvedThreads_Bad_AllResolved(t *testing.T) {
	s := &PipelineSignal{ThreadsTotal: 5, ThreadsResolved: 5}
	assert.False(t, s.HasUnresolvedThreads())
}

func TestActionResult_JSON_Good(t *testing.T) {
	r := &ActionResult{
		Action:    "publish_draft",
		RepoOwner: "host-uk",
		RepoName:  "core",
		PRNumber:  315,
		Success:   true,
		Timestamp: time.Date(2026, 2, 5, 12, 0, 0, 0, time.UTC),
	}
	assert.Equal(t, "publish_draft", r.Action)
	assert.True(t, r.Success)
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/ -v -count=1 Expected: FAIL — package does not exist yet.

Step 3: Write the types

package jobrunner

import (
	"context"
	"time"
)

// PipelineSignal is the structural snapshot of a child issue/PR.
// Never contains comment bodies or free text — structural signals only.
type PipelineSignal struct {
	EpicNumber      int
	ChildNumber     int
	PRNumber        int
	RepoOwner       string
	RepoName        string
	PRState         string    // OPEN, MERGED, CLOSED
	IsDraft         bool
	Mergeable       string    // MERGEABLE, CONFLICTING, UNKNOWN
	CheckStatus     string    // SUCCESS, FAILURE, PENDING
	ThreadsTotal    int
	ThreadsResolved int
	LastCommitSHA   string
	LastCommitAt    time.Time
	LastReviewAt    time.Time
}

// RepoFullName returns "owner/repo".
func (s *PipelineSignal) RepoFullName() string {
	return s.RepoOwner + "/" + s.RepoName
}

// HasUnresolvedThreads returns true if there are unresolved review threads.
func (s *PipelineSignal) HasUnresolvedThreads() bool {
	return s.ThreadsTotal > s.ThreadsResolved
}

// ActionResult carries the outcome of a handler execution.
type ActionResult struct {
	Action     string        `json:"action"`
	RepoOwner  string        `json:"repo_owner"`
	RepoName   string        `json:"repo_name"`
	EpicNumber int           `json:"epic"`
	ChildNumber int          `json:"child"`
	PRNumber   int           `json:"pr"`
	Success    bool          `json:"success"`
	Error      string        `json:"error,omitempty"`
	Timestamp  time.Time     `json:"ts"`
	Duration   time.Duration `json:"duration_ms"`
	Cycle      int           `json:"cycle"`
}

// JobSource discovers actionable work from an external system.
type JobSource interface {
	Name() string
	Poll(ctx context.Context) ([]*PipelineSignal, error)
	Report(ctx context.Context, result *ActionResult) error
}

// JobHandler processes a single pipeline signal.
type JobHandler interface {
	Name() string
	Match(signal *PipelineSignal) bool
	Execute(ctx context.Context, signal *PipelineSignal) (*ActionResult, error)
}

Step 4: Run tests

Run: go test ./pkg/jobrunner/ -v -count=1 Expected: PASS (4 tests).

Step 5: Commit

git add pkg/jobrunner/types.go pkg/jobrunner/types_test.go
git commit -m "feat(jobrunner): add core types — PipelineSignal, ActionResult, JobSource, JobHandler"

Task 2: Journal JSONL Writer (pkg/jobrunner/journal.go)

Files:

  • Create: pkg/jobrunner/journal.go
  • Test: pkg/jobrunner/journal_test.go

Step 1: Write the test

package jobrunner

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestJournal_Append_Good(t *testing.T) {
	dir := t.TempDir()
	j, err := NewJournal(dir)
	require.NoError(t, err)

	signal := &PipelineSignal{
		EpicNumber:  299,
		ChildNumber: 212,
		PRNumber:    316,
		RepoOwner:   "host-uk",
		RepoName:    "core",
		PRState:     "OPEN",
		IsDraft:     true,
		CheckStatus: "SUCCESS",
	}

	result := &ActionResult{
		Action:    "publish_draft",
		RepoOwner: "host-uk",
		RepoName:  "core",
		PRNumber:  316,
		Success:   true,
		Timestamp: time.Date(2026, 2, 5, 12, 0, 0, 0, time.UTC),
		Duration:  340 * time.Millisecond,
		Cycle:     1,
	}

	err = j.Append(signal, result)
	require.NoError(t, err)

	// Read the file back
	pattern := filepath.Join(dir, "host-uk", "core", "*.jsonl")
	files, _ := filepath.Glob(pattern)
	require.Len(t, files, 1)

	data, err := os.ReadFile(files[0])
	require.NoError(t, err)

	var entry JournalEntry
	err = json.Unmarshal([]byte(strings.TrimSpace(string(data))), &entry)
	require.NoError(t, err)

	assert.Equal(t, "publish_draft", entry.Action)
	assert.Equal(t, 316, entry.PR)
	assert.Equal(t, 299, entry.Epic)
	assert.True(t, entry.Result.Success)
}

func TestJournal_Append_Bad_NilSignal(t *testing.T) {
	dir := t.TempDir()
	j, err := NewJournal(dir)
	require.NoError(t, err)

	err = j.Append(nil, &ActionResult{})
	assert.Error(t, err)
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/ -run TestJournal -v -count=1 Expected: FAIL — NewJournal undefined.

Step 3: Write the implementation

package jobrunner

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// JournalEntry is a single JSONL record for training data.
type JournalEntry struct {
	Timestamp time.Time      `json:"ts"`
	Epic      int            `json:"epic"`
	Child     int            `json:"child"`
	PR        int            `json:"pr"`
	Repo      string         `json:"repo"`
	Action    string         `json:"action"`
	Signals   SignalSnapshot `json:"signals"`
	Result    ResultSnapshot `json:"result"`
	Cycle     int            `json:"cycle"`
}

// SignalSnapshot captures the structural state at action time.
type SignalSnapshot struct {
	PRState         string `json:"pr_state"`
	IsDraft         bool   `json:"is_draft"`
	CheckStatus     string `json:"check_status"`
	Mergeable       string `json:"mergeable"`
	ThreadsTotal    int    `json:"threads_total"`
	ThreadsResolved int    `json:"threads_resolved"`
}

// ResultSnapshot captures the action outcome.
type ResultSnapshot struct {
	Success    bool   `json:"success"`
	Error      string `json:"error,omitempty"`
	DurationMs int64  `json:"duration_ms"`
}

// Journal writes append-only JSONL files organised by repo and date.
type Journal struct {
	baseDir string
	mu      sync.Mutex
}

// NewJournal creates a journal writer rooted at baseDir.
// Files are written to baseDir/<owner>/<repo>/YYYY-MM-DD.jsonl.
func NewJournal(baseDir string) (*Journal, error) {
	if baseDir == "" {
		return nil, fmt.Errorf("journal base directory is required")
	}
	return &Journal{baseDir: baseDir}, nil
}

// Append writes a journal entry for the given signal and result.
func (j *Journal) Append(signal *PipelineSignal, result *ActionResult) error {
	if signal == nil {
		return fmt.Errorf("signal is required")
	}
	if result == nil {
		return fmt.Errorf("result is required")
	}

	entry := JournalEntry{
		Timestamp: result.Timestamp,
		Epic:      signal.EpicNumber,
		Child:     signal.ChildNumber,
		PR:        signal.PRNumber,
		Repo:      signal.RepoFullName(),
		Action:    result.Action,
		Signals: SignalSnapshot{
			PRState:         signal.PRState,
			IsDraft:         signal.IsDraft,
			CheckStatus:     signal.CheckStatus,
			Mergeable:       signal.Mergeable,
			ThreadsTotal:    signal.ThreadsTotal,
			ThreadsResolved: signal.ThreadsResolved,
		},
		Result: ResultSnapshot{
			Success:    result.Success,
			Error:      result.Error,
			DurationMs: result.Duration.Milliseconds(),
		},
		Cycle: result.Cycle,
	}

	data, err := json.Marshal(entry)
	if err != nil {
		return fmt.Errorf("marshal journal entry: %w", err)
	}
	data = append(data, '\n')

	// Build path: baseDir/owner/repo/YYYY-MM-DD.jsonl
	date := result.Timestamp.UTC().Format("2006-01-02")
	dir := filepath.Join(j.baseDir, signal.RepoOwner, signal.RepoName)

	j.mu.Lock()
	defer j.mu.Unlock()

	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create journal directory: %w", err)
	}

	path := filepath.Join(dir, date+".jsonl")
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("open journal file: %w", err)
	}
	defer f.Close()

	_, err = f.Write(data)
	return err
}

Step 4: Run tests

Run: go test ./pkg/jobrunner/ -v -count=1 Expected: PASS (all tests including Task 1).

Step 5: Commit

git add pkg/jobrunner/journal.go pkg/jobrunner/journal_test.go
git commit -m "feat(jobrunner): add JSONL journal writer for training data"

Task 3: Poller and Dispatcher (pkg/jobrunner/poller.go)

Files:

  • Create: pkg/jobrunner/poller.go
  • Test: pkg/jobrunner/poller_test.go

Step 1: Write the test

package jobrunner

import (
	"context"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type mockSource struct {
	name    string
	signals []*PipelineSignal
	reports []*ActionResult
	mu      sync.Mutex
}

func (m *mockSource) Name() string { return m.name }
func (m *mockSource) Poll(_ context.Context) ([]*PipelineSignal, error) {
	return m.signals, nil
}
func (m *mockSource) Report(_ context.Context, r *ActionResult) error {
	m.mu.Lock()
	m.reports = append(m.reports, r)
	m.mu.Unlock()
	return nil
}

type mockHandler struct {
	name     string
	matchFn  func(*PipelineSignal) bool
	executed []*PipelineSignal
	mu       sync.Mutex
}

func (m *mockHandler) Name() string { return m.name }
func (m *mockHandler) Match(s *PipelineSignal) bool {
	if m.matchFn != nil {
		return m.matchFn(s)
	}
	return true
}
func (m *mockHandler) Execute(_ context.Context, s *PipelineSignal) (*ActionResult, error) {
	m.mu.Lock()
	m.executed = append(m.executed, s)
	m.mu.Unlock()
	return &ActionResult{
		Action:    m.name,
		Success:   true,
		Timestamp: time.Now().UTC(),
	}, nil
}

func TestPoller_RunOnce_Good(t *testing.T) {
	signal := &PipelineSignal{
		PRNumber:  315,
		RepoOwner: "host-uk",
		RepoName:  "core",
		IsDraft:   true,
		PRState:   "OPEN",
	}

	source := &mockSource{name: "test", signals: []*PipelineSignal{signal}}
	handler := &mockHandler{name: "publish_draft"}
	journal, err := NewJournal(t.TempDir())
	require.NoError(t, err)

	p := NewPoller(PollerConfig{
		Sources:      []JobSource{source},
		Handlers:     []JobHandler{handler},
		Journal:      journal,
		PollInterval: time.Second,
	})

	err = p.RunOnce(context.Background())
	require.NoError(t, err)

	handler.mu.Lock()
	assert.Len(t, handler.executed, 1)
	handler.mu.Unlock()
}

func TestPoller_RunOnce_Good_NoSignals(t *testing.T) {
	source := &mockSource{name: "test", signals: nil}
	handler := &mockHandler{name: "noop"}
	journal, err := NewJournal(t.TempDir())
	require.NoError(t, err)

	p := NewPoller(PollerConfig{
		Sources:  []JobSource{source},
		Handlers: []JobHandler{handler},
		Journal:  journal,
	})

	err = p.RunOnce(context.Background())
	require.NoError(t, err)

	handler.mu.Lock()
	assert.Len(t, handler.executed, 0)
	handler.mu.Unlock()
}

func TestPoller_RunOnce_Good_NoMatchingHandler(t *testing.T) {
	signal := &PipelineSignal{PRNumber: 1, RepoOwner: "a", RepoName: "b"}
	source := &mockSource{name: "test", signals: []*PipelineSignal{signal}}
	handler := &mockHandler{
		name:    "never_match",
		matchFn: func(*PipelineSignal) bool { return false },
	}
	journal, err := NewJournal(t.TempDir())
	require.NoError(t, err)

	p := NewPoller(PollerConfig{
		Sources:  []JobSource{source},
		Handlers: []JobHandler{handler},
		Journal:  journal,
	})

	err = p.RunOnce(context.Background())
	require.NoError(t, err)

	handler.mu.Lock()
	assert.Len(t, handler.executed, 0)
	handler.mu.Unlock()
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/ -run TestPoller -v -count=1 Expected: FAIL — NewPoller undefined.

Step 3: Write the implementation

package jobrunner

import (
	"context"
	"fmt"
	"time"

	"forge.lthn.ai/core/cli/pkg/log"
)

// PollerConfig configures the job runner poller.
type PollerConfig struct {
	Sources      []JobSource
	Handlers     []JobHandler
	Journal      *Journal
	PollInterval time.Duration
	DryRun       bool
}

// Poller discovers and dispatches pipeline work.
type Poller struct {
	cfg   PollerConfig
	cycle int
}

// NewPoller creates a poller with the given configuration.
func NewPoller(cfg PollerConfig) *Poller {
	if cfg.PollInterval == 0 {
		cfg.PollInterval = 60 * time.Second
	}
	return &Poller{cfg: cfg}
}

// Run starts the polling loop. Blocks until context is cancelled.
func (p *Poller) Run(ctx context.Context) error {
	ticker := time.NewTicker(p.cfg.PollInterval)
	defer ticker.Stop()

	// Run once immediately
	if err := p.RunOnce(ctx); err != nil {
		log.Info("poller", "cycle_error", err)
	}

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			if err := p.RunOnce(ctx); err != nil {
				log.Info("poller", "cycle_error", err)
			}
		}
	}
}

// RunOnce performs a single poll-dispatch cycle across all sources.
func (p *Poller) RunOnce(ctx context.Context) error {
	p.cycle++

	for _, source := range p.cfg.Sources {
		if err := ctx.Err(); err != nil {
			return err
		}

		signals, err := source.Poll(ctx)
		if err != nil {
			log.Info("poller", "source", source.Name(), "poll_error", err)
			continue
		}

		for _, signal := range signals {
			if err := ctx.Err(); err != nil {
				return err
			}
			p.dispatch(ctx, source, signal)
		}
	}

	return nil
}

// dispatch finds the first matching handler and executes it.
// One action per signal per cycle.
func (p *Poller) dispatch(ctx context.Context, source JobSource, signal *PipelineSignal) {
	for _, handler := range p.cfg.Handlers {
		if !handler.Match(signal) {
			continue
		}

		if p.cfg.DryRun {
			log.Info("poller",
				"dry_run", handler.Name(),
				"repo", signal.RepoFullName(),
				"pr", signal.PRNumber,
			)
			return
		}

		start := time.Now()
		result, err := handler.Execute(ctx, signal)
		if err != nil {
			log.Info("poller",
				"handler", handler.Name(),
				"error", err,
				"repo", signal.RepoFullName(),
				"pr", signal.PRNumber,
			)
			return
		}

		result.Cycle = p.cycle
		result.EpicNumber = signal.EpicNumber
		result.ChildNumber = signal.ChildNumber
		result.Duration = time.Since(start)

		// Write to journal
		if p.cfg.Journal != nil {
			if err := p.cfg.Journal.Append(signal, result); err != nil {
				log.Info("poller", "journal_error", err)
			}
		}

		// Report back to source
		if err := source.Report(ctx, result); err != nil {
			log.Info("poller", "report_error", err)
		}

		return // one action per signal per cycle
	}
}

// Cycle returns the current cycle count.
func (p *Poller) Cycle() int {
	return p.cycle
}

// DryRun returns whether the poller is in dry-run mode.
func (p *Poller) DryRun() bool {
	return p.cfg.DryRun
}

// SetDryRun enables or disables dry-run mode.
func (p *Poller) SetDryRun(v bool) {
	p.cfg.DryRun = v
}

// AddSource appends a job source to the poller.
func (p *Poller) AddSource(s JobSource) {
	p.cfg.Sources = append(p.cfg.Sources, s)
}

// AddHandler appends a job handler to the poller.
func (p *Poller) AddHandler(h JobHandler) {
	p.cfg.Handlers = append(p.cfg.Handlers, h)
}

_ = fmt.Sprintf // ensure fmt imported for future use

Wait — remove that last line. The fmt import is only needed if used. Let me correct: the implementation above doesn't use fmt directly, so remove it from imports. The log package import path is forge.lthn.ai/core/cli/pkg/log.

Step 4: Run tests

Run: go test ./pkg/jobrunner/ -v -count=1 Expected: PASS (all tests).

Step 5: Commit

git add pkg/jobrunner/poller.go pkg/jobrunner/poller_test.go
git commit -m "feat(jobrunner): add Poller with multi-source dispatch and journal integration"

Task 4: GitHub Source — Signal Builder (pkg/jobrunner/github/)

Files:

  • Create: pkg/jobrunner/github/source.go
  • Create: pkg/jobrunner/github/signals.go
  • Test: pkg/jobrunner/github/source_test.go

Context: This package lives in the root go.mod (forge.lthn.ai/core/cli), NOT in the core-ide module. It uses oauth2 and the GitHub REST API (same pattern as internal/cmd/updater/github.go). Uses conditional requests (ETag/If-None-Match) to conserve rate limit.

Step 1: Write the test

package github

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestGitHubSource_Poll_Good(t *testing.T) {
	// Mock GitHub API: return one open PR that's a draft with passing checks
	mux := http.NewServeMux()

	// GET /repos/host-uk/core/issues?labels=epic&state=open
	mux.HandleFunc("/repos/host-uk/core/issues", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("labels") == "epic" {
			json.NewEncoder(w).Encode([]map[string]any{
				{
					"number": 299,
					"body":   "- [ ] #212\n- [x] #213",
					"state":  "open",
				},
			})
			return
		}
		json.NewEncoder(w).Encode([]map[string]any{})
	})

	// GET /repos/host-uk/core/pulls?state=open
	mux.HandleFunc("/repos/host-uk/core/pulls", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode([]map[string]any{
			{
				"number":             316,
				"state":              "open",
				"draft":              true,
				"mergeable_state":    "clean",
				"body":               "Closes #212",
				"head":               map[string]any{"sha": "abc123"},
			},
		})
	})

	// GET /repos/host-uk/core/commits/abc123/check-suites
	mux.HandleFunc("/repos/host-uk/core/commits/", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]any{
			"check_suites": []map[string]any{
				{"conclusion": "success", "status": "completed"},
			},
		})
	})

	server := httptest.NewServer(mux)
	defer server.Close()

	src := NewGitHubSource(Config{
		Repos:  []string{"host-uk/core"},
		APIURL: server.URL,
	})

	signals, err := src.Poll(context.Background())
	require.NoError(t, err)
	require.NotEmpty(t, signals)

	assert.Equal(t, 316, signals[0].PRNumber)
	assert.True(t, signals[0].IsDraft)
	assert.Equal(t, "host-uk", signals[0].RepoOwner)
	assert.Equal(t, "core", signals[0].RepoName)
}

func TestGitHubSource_Name_Good(t *testing.T) {
	src := NewGitHubSource(Config{Repos: []string{"host-uk/core"}})
	assert.Equal(t, "github", src.Name())
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/github/ -v -count=1 Expected: FAIL — package does not exist.

Step 3: Write signals.go — PR/issue data structures and signal extraction

package github

import (
	"regexp"
	"strconv"
	"strings"
	"time"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
)

// ghIssue is the minimal structure from GitHub Issues API.
type ghIssue struct {
	Number int    `json:"number"`
	Body   string `json:"body"`
	State  string `json:"state"`
}

// ghPR is the minimal structure from GitHub Pull Requests API.
type ghPR struct {
	Number        int       `json:"number"`
	State         string    `json:"state"`
	Draft         bool      `json:"draft"`
	MergeableState string  `json:"mergeable_state"`
	Body          string    `json:"body"`
	Head          ghRef     `json:"head"`
	UpdatedAt     time.Time `json:"updated_at"`
}

type ghRef struct {
	SHA string `json:"sha"`
}

// ghCheckSuites is the response from /commits/:sha/check-suites.
type ghCheckSuites struct {
	CheckSuites []ghCheckSuite `json:"check_suites"`
}

type ghCheckSuite struct {
	Conclusion string `json:"conclusion"`
	Status     string `json:"status"`
}

// ghReviewThread counts (from GraphQL or approximated from review comments).
type ghReviewCounts struct {
	Total    int
	Resolved int
}

// parseEpicChildren extracts unchecked child issue numbers from an epic body.
// Matches: - [ ] #123
var checklistRe = regexp.MustCompile(`- \[( |x)\] #(\d+)`)

func parseEpicChildren(body string) (unchecked []int, checked []int) {
	matches := checklistRe.FindAllStringSubmatch(body, -1)
	for _, m := range matches {
		num, _ := strconv.Atoi(m[2])
		if m[1] == "x" {
			checked = append(checked, num)
		} else {
			unchecked = append(unchecked, num)
		}
	}
	return
}

// findLinkedPR finds a PR that references an issue number in its body.
// Matches: Closes #123, Fixes #123, Resolves #123
func findLinkedPR(prs []ghPR, issueNumber int) *ghPR {
	pattern := strconv.Itoa(issueNumber)
	for i := range prs {
		if strings.Contains(prs[i].Body, "#"+pattern) {
			return &prs[i]
		}
	}
	return nil
}

// aggregateCheckStatus returns the overall check status from check suites.
func aggregateCheckStatus(suites []ghCheckSuite) string {
	if len(suites) == 0 {
		return "PENDING"
	}
	for _, s := range suites {
		if s.Status != "completed" {
			return "PENDING"
		}
		if s.Conclusion == "failure" || s.Conclusion == "timed_out" || s.Conclusion == "cancelled" {
			return "FAILURE"
		}
	}
	return "SUCCESS"
}

// mergeableToString normalises GitHub's mergeable_state to our enum.
func mergeableToString(state string) string {
	switch state {
	case "clean", "has_hooks", "unstable":
		return "MERGEABLE"
	case "dirty":
		return "CONFLICTING"
	default:
		return "UNKNOWN"
	}
}

// buildSignal creates a PipelineSignal from GitHub API data.
func buildSignal(owner, repo string, epic ghIssue, childNum int, pr ghPR, checks ghCheckSuites) *jobrunner.PipelineSignal {
	return &jobrunner.PipelineSignal{
		EpicNumber:    epic.Number,
		ChildNumber:   childNum,
		PRNumber:      pr.Number,
		RepoOwner:     owner,
		RepoName:      repo,
		PRState:       strings.ToUpper(pr.State),
		IsDraft:       pr.Draft,
		Mergeable:     mergeableToString(pr.MergeableState),
		CheckStatus:   aggregateCheckStatus(checks.CheckSuites),
		LastCommitSHA: pr.Head.SHA,
		LastCommitAt:  pr.UpdatedAt,
	}
}

Step 4: Write source.go — GitHubSource implementing JobSource

package github

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
	"forge.lthn.ai/core/cli/pkg/log"
	"golang.org/x/oauth2"
)

// Config for the GitHub job source.
type Config struct {
	Repos  []string // "owner/repo" format
	APIURL string   // override for testing (default: https://api.github.com)
}

// GitHubSource polls GitHub for pipeline signals.
type GitHubSource struct {
	cfg    Config
	client *http.Client
	etags  map[string]string // URL -> ETag for conditional requests
}

// NewGitHubSource creates a GitHub job source.
func NewGitHubSource(cfg Config) *GitHubSource {
	if cfg.APIURL == "" {
		cfg.APIURL = "https://api.github.com"
	}

	var client *http.Client
	token := os.Getenv("GITHUB_TOKEN")
	if token != "" {
		ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
		client = oauth2.NewClient(context.Background(), ts)
	} else {
		client = http.DefaultClient
	}

	return &GitHubSource{
		cfg:    cfg,
		client: client,
		etags:  make(map[string]string),
	}
}

func (g *GitHubSource) Name() string { return "github" }

// Poll scans all configured repos for actionable pipeline signals.
func (g *GitHubSource) Poll(ctx context.Context) ([]*jobrunner.PipelineSignal, error) {
	var all []*jobrunner.PipelineSignal

	for _, repoSpec := range g.cfg.Repos {
		parts := strings.SplitN(repoSpec, "/", 2)
		if len(parts) != 2 {
			continue
		}
		owner, repo := parts[0], parts[1]

		signals, err := g.pollRepo(ctx, owner, repo)
		if err != nil {
			log.Info("github_source", "repo", repoSpec, "error", err)
			continue
		}
		all = append(all, signals...)
	}

	return all, nil
}

func (g *GitHubSource) pollRepo(ctx context.Context, owner, repo string) ([]*jobrunner.PipelineSignal, error) {
	// 1. Fetch epic issues
	epics, err := g.fetchEpics(ctx, owner, repo)
	if err != nil {
		return nil, err
	}

	// 2. Fetch open PRs
	prs, err := g.fetchPRs(ctx, owner, repo)
	if err != nil {
		return nil, err
	}

	var signals []*jobrunner.PipelineSignal

	for _, epic := range epics {
		unchecked, _ := parseEpicChildren(epic.Body)
		for _, childNum := range unchecked {
			pr := findLinkedPR(prs, childNum)
			if pr == nil {
				continue // no PR yet for this child
			}

			checks, err := g.fetchCheckSuites(ctx, owner, repo, pr.Head.SHA)
			if err != nil {
				log.Info("github_source", "pr", pr.Number, "check_error", err)
				checks = ghCheckSuites{}
			}

			signals = append(signals, buildSignal(owner, repo, epic, childNum, *pr, checks))
		}
	}

	return signals, nil
}

func (g *GitHubSource) fetchEpics(ctx context.Context, owner, repo string) ([]ghIssue, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/issues?labels=epic&state=open&per_page=100", g.cfg.APIURL, owner, repo)
	var issues []ghIssue
	return issues, g.getJSON(ctx, url, &issues)
}

func (g *GitHubSource) fetchPRs(ctx context.Context, owner, repo string) ([]ghPR, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/pulls?state=open&per_page=100", g.cfg.APIURL, owner, repo)
	var prs []ghPR
	return prs, g.getJSON(ctx, url, &prs)
}

func (g *GitHubSource) fetchCheckSuites(ctx context.Context, owner, repo, sha string) (ghCheckSuites, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/commits/%s/check-suites", g.cfg.APIURL, owner, repo, sha)
	var result ghCheckSuites
	return result, g.getJSON(ctx, url, &result)
}

// getJSON performs a GET with conditional request support.
func (g *GitHubSource) getJSON(ctx context.Context, url string, out any) error {
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return err
	}
	req.Header.Set("Accept", "application/vnd.github+json")

	if etag, ok := g.etags[url]; ok {
		req.Header.Set("If-None-Match", etag)
	}

	resp, err := g.client.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// Store ETag for next request
	if etag := resp.Header.Get("ETag"); etag != "" {
		g.etags[url] = etag
	}

	if resp.StatusCode == http.StatusNotModified {
		return nil // no change since last poll
	}

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
	}

	return json.NewDecoder(resp.Body).Decode(out)
}

// Report is a no-op for GitHub (actions are performed directly via API).
func (g *GitHubSource) Report(_ context.Context, _ *jobrunner.ActionResult) error {
	return nil
}

Step 5: Run tests

Run: go test ./pkg/jobrunner/github/ -v -count=1 Expected: PASS.

Step 6: Commit

git add pkg/jobrunner/github/
git commit -m "feat(jobrunner): add GitHub source adapter with ETag conditional requests"

Task 5: Publish Draft Handler (pkg/jobrunner/handlers/)

Files:

  • Create: pkg/jobrunner/handlers/publish_draft.go
  • Test: pkg/jobrunner/handlers/publish_draft_test.go

Context: Handlers live in pkg/jobrunner/handlers/ (root module). They use net/http to call GitHub REST API directly. Each handler implements jobrunner.JobHandler.

Step 1: Write the test

package handlers

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestPublishDraft_Match_Good(t *testing.T) {
	h := NewPublishDraft(nil)
	signal := &jobrunner.PipelineSignal{
		IsDraft:     true,
		PRState:     "OPEN",
		CheckStatus: "SUCCESS",
	}
	assert.True(t, h.Match(signal))
}

func TestPublishDraft_Match_Bad_NotDraft(t *testing.T) {
	h := NewPublishDraft(nil)
	signal := &jobrunner.PipelineSignal{
		IsDraft:     false,
		PRState:     "OPEN",
		CheckStatus: "SUCCESS",
	}
	assert.False(t, h.Match(signal))
}

func TestPublishDraft_Match_Bad_ChecksFailing(t *testing.T) {
	h := NewPublishDraft(nil)
	signal := &jobrunner.PipelineSignal{
		IsDraft:     true,
		PRState:     "OPEN",
		CheckStatus: "FAILURE",
	}
	assert.False(t, h.Match(signal))
}

func TestPublishDraft_Execute_Good(t *testing.T) {
	var calledURL string
	var calledMethod string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calledURL = r.URL.Path
		calledMethod = r.Method
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"number":316}`))
	}))
	defer server.Close()

	h := NewPublishDraft(&http.Client{})
	h.apiURL = server.URL

	signal := &jobrunner.PipelineSignal{
		PRNumber:  316,
		RepoOwner: "host-uk",
		RepoName:  "core",
		IsDraft:   true,
		PRState:   "OPEN",
	}

	result, err := h.Execute(context.Background(), signal)
	require.NoError(t, err)
	assert.True(t, result.Success)
	assert.Equal(t, "publish_draft", result.Action)
	assert.Equal(t, "/repos/host-uk/core/pulls/316", calledURL)
	assert.Equal(t, "PATCH", calledMethod)
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/handlers/ -run TestPublishDraft -v -count=1 Expected: FAIL — package does not exist.

Step 3: Write the implementation

package handlers

import (
	"bytes"
	"context"
	"fmt"
	"net/http"
	"time"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
)

// PublishDraft marks a draft PR as ready for review.
type PublishDraft struct {
	client *http.Client
	apiURL string
}

// NewPublishDraft creates a publish_draft handler.
// Pass nil client to use http.DefaultClient.
func NewPublishDraft(client *http.Client) *PublishDraft {
	if client == nil {
		client = http.DefaultClient
	}
	return &PublishDraft{
		client: client,
		apiURL: "https://api.github.com",
	}
}

func (h *PublishDraft) Name() string { return "publish_draft" }

// Match returns true for open draft PRs with passing checks.
func (h *PublishDraft) Match(s *jobrunner.PipelineSignal) bool {
	return s.IsDraft && s.PRState == "OPEN" && s.CheckStatus == "SUCCESS"
}

// Execute calls PATCH /repos/:owner/:repo/pulls/:number with draft=false.
func (h *PublishDraft) Execute(ctx context.Context, s *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) {
	url := fmt.Sprintf("%s/repos/%s/%s/pulls/%d", h.apiURL, s.RepoOwner, s.RepoName, s.PRNumber)
	body := bytes.NewBufferString(`{"draft":false}`)

	req, err := http.NewRequestWithContext(ctx, "PATCH", url, body)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	result := &jobrunner.ActionResult{
		Action:    "publish_draft",
		RepoOwner: s.RepoOwner,
		RepoName:  s.RepoName,
		PRNumber:  s.PRNumber,
		Timestamp: time.Now().UTC(),
	}

	if resp.StatusCode >= 200 && resp.StatusCode < 300 {
		result.Success = true
	} else {
		result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
	}

	return result, nil
}

Step 4: Run tests

Run: go test ./pkg/jobrunner/handlers/ -v -count=1 Expected: PASS.

Step 5: Commit

git add pkg/jobrunner/handlers/
git commit -m "feat(jobrunner): add publish_draft handler"

Task 6: Send Fix Command Handler

Files:

  • Create: pkg/jobrunner/handlers/send_fix_command.go
  • Test: pkg/jobrunner/handlers/send_fix_command_test.go

Step 1: Write the test

package handlers

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSendFixCommand_Match_Good_Conflicting(t *testing.T) {
	h := NewSendFixCommand(nil)
	signal := &jobrunner.PipelineSignal{
		PRState:   "OPEN",
		Mergeable: "CONFLICTING",
	}
	assert.True(t, h.Match(signal))
}

func TestSendFixCommand_Match_Good_UnresolvedThreads(t *testing.T) {
	h := NewSendFixCommand(nil)
	signal := &jobrunner.PipelineSignal{
		PRState:         "OPEN",
		Mergeable:       "MERGEABLE",
		ThreadsTotal:    3,
		ThreadsResolved: 1,
		CheckStatus:     "FAILURE",
	}
	assert.True(t, h.Match(signal))
}

func TestSendFixCommand_Match_Bad_Clean(t *testing.T) {
	h := NewSendFixCommand(nil)
	signal := &jobrunner.PipelineSignal{
		PRState:         "OPEN",
		Mergeable:       "MERGEABLE",
		CheckStatus:     "SUCCESS",
		ThreadsTotal:    0,
		ThreadsResolved: 0,
	}
	assert.False(t, h.Match(signal))
}

func TestSendFixCommand_Execute_Good_Conflict(t *testing.T) {
	var postedBody map[string]string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		json.NewDecoder(r.Body).Decode(&postedBody)
		w.WriteHeader(http.StatusCreated)
		w.Write([]byte(`{"id":1}`))
	}))
	defer server.Close()

	h := NewSendFixCommand(&http.Client{})
	h.apiURL = server.URL

	signal := &jobrunner.PipelineSignal{
		PRNumber:  296,
		RepoOwner: "host-uk",
		RepoName:  "core",
		PRState:   "OPEN",
		Mergeable: "CONFLICTING",
	}

	result, err := h.Execute(context.Background(), signal)
	require.NoError(t, err)
	assert.True(t, result.Success)
	assert.Contains(t, postedBody["body"], "fix the merge conflict")
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/handlers/ -run TestSendFixCommand -v -count=1 Expected: FAIL — NewSendFixCommand undefined.

Step 3: Write the implementation

package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
)

// SendFixCommand comments on a PR to request a fix.
type SendFixCommand struct {
	client *http.Client
	apiURL string
}

func NewSendFixCommand(client *http.Client) *SendFixCommand {
	if client == nil {
		client = http.DefaultClient
	}
	return &SendFixCommand{client: client, apiURL: "https://api.github.com"}
}

func (h *SendFixCommand) Name() string { return "send_fix_command" }

// Match returns true for open PRs that are conflicting OR have unresolved
// review threads with failing checks (indicating reviews need fixing).
func (h *SendFixCommand) Match(s *jobrunner.PipelineSignal) bool {
	if s.PRState != "OPEN" {
		return false
	}
	if s.Mergeable == "CONFLICTING" {
		return true
	}
	if s.HasUnresolvedThreads() && s.CheckStatus == "FAILURE" {
		return true
	}
	return false
}

// Execute posts a comment with the appropriate fix command.
func (h *SendFixCommand) Execute(ctx context.Context, s *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) {
	msg := "Can you fix the code reviews?"
	if s.Mergeable == "CONFLICTING" {
		msg = "Can you fix the merge conflict?"
	}

	url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", h.apiURL, s.RepoOwner, s.RepoName, s.PRNumber)
	payload, _ := json.Marshal(map[string]string{"body": msg})

	req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/vnd.github+json")
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	result := &jobrunner.ActionResult{
		Action:    "send_fix_command",
		RepoOwner: s.RepoOwner,
		RepoName:  s.RepoName,
		PRNumber:  s.PRNumber,
		Timestamp: time.Now().UTC(),
	}

	if resp.StatusCode == http.StatusCreated {
		result.Success = true
	} else {
		result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
	}

	return result, nil
}

Step 4: Run tests

Run: go test ./pkg/jobrunner/handlers/ -v -count=1 Expected: PASS.

Step 5: Commit

git add pkg/jobrunner/handlers/send_fix_command.go pkg/jobrunner/handlers/send_fix_command_test.go
git commit -m "feat(jobrunner): add send_fix_command handler"

Task 7: Remaining Handlers (enable_auto_merge, tick_parent, close_child)

Files:

  • Create: pkg/jobrunner/handlers/enable_auto_merge.go + test
  • Create: pkg/jobrunner/handlers/tick_parent.go + test
  • Create: pkg/jobrunner/handlers/close_child.go + test

Context: Same pattern as Tasks 5-6. Each handler: Match checks signal conditions, Execute calls GitHub REST API. Tests use httptest.

Step 1: Write tests for all three (one test file per handler, same pattern as above)

enable_auto_merge:

  • Match: PRState=OPEN && Mergeable=MERGEABLE && CheckStatus=SUCCESS && !IsDraft && ThreadsTotal==ThreadsResolved
  • Execute: PUT /repos/:owner/:repo/pulls/:number/merge with merge_method=squash — actually, auto-merge uses gh api to enable. For REST: POST /repos/:owner/:repo/pulls/:number/merge — No. Auto-merge is enabled via GraphQL enablePullRequestAutoMerge. For REST fallback, use: PATCH /repos/:owner/:repo/pulls/:number — No, that's not right either.

Actually, auto-merge via REST requires: PUT /repos/:owner/:repo/pulls/:number/auto_merge. This is not a standard GitHub REST endpoint. Auto-merge is enabled via the GraphQL API:

mutation { enablePullRequestAutoMerge(input: {pullRequestId: "..."}) { ... } }

Simpler approach: Shell out to gh pr merge --auto <number> -R owner/repo. This is what the pipeline flow does today. Let's use os/exec with the gh CLI.

// enable_auto_merge.go
package handlers

import (
	"context"
	"fmt"
	"os/exec"
	"time"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
)

type EnableAutoMerge struct{}

func NewEnableAutoMerge() *EnableAutoMerge { return &EnableAutoMerge{} }

func (h *EnableAutoMerge) Name() string { return "enable_auto_merge" }

func (h *EnableAutoMerge) Match(s *jobrunner.PipelineSignal) bool {
	return s.PRState == "OPEN" &&
		!s.IsDraft &&
		s.Mergeable == "MERGEABLE" &&
		s.CheckStatus == "SUCCESS" &&
		!s.HasUnresolvedThreads()
}

func (h *EnableAutoMerge) Execute(ctx context.Context, s *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) {
	cmd := exec.CommandContext(ctx, "gh", "pr", "merge", "--auto",
		fmt.Sprintf("%d", s.PRNumber),
		"-R", s.RepoFullName(),
	)
	output, err := cmd.CombinedOutput()

	result := &jobrunner.ActionResult{
		Action:    "enable_auto_merge",
		RepoOwner: s.RepoOwner,
		RepoName:  s.RepoName,
		PRNumber:  s.PRNumber,
		Timestamp: time.Now().UTC(),
	}

	if err != nil {
		result.Error = fmt.Sprintf("%v: %s", err, string(output))
	} else {
		result.Success = true
	}

	return result, nil
}

tick_parent and close_child follow the same gh CLI pattern:

  • tick_parent: Reads epic issue body, checks the child's checkbox, updates via gh issue edit
  • close_child: gh issue close <number> -R owner/repo

Step 2-5: Same TDD cycle as Tasks 5-6. Write test, verify fail, implement, verify pass, commit.

For brevity, the exact test code follows the same pattern. Key test assertions:

  • tick_parent: Verify gh issue edit is called with updated body
  • close_child: Verify gh issue close is called
  • enable_auto_merge: Verify gh pr merge --auto is called

Testability: Use a command factory variable for mocking exec.Command:

// In each handler file:
var execCommand = exec.CommandContext

// In tests:
originalExecCommand := execCommand
defer func() { execCommand = originalExecCommand }()
execCommand = func(ctx context.Context, name string, args ...string) *exec.Cmd {
    // return a mock command
}

Step 6: Commit

git add pkg/jobrunner/handlers/enable_auto_merge.go pkg/jobrunner/handlers/enable_auto_merge_test.go
git add pkg/jobrunner/handlers/tick_parent.go pkg/jobrunner/handlers/tick_parent_test.go
git add pkg/jobrunner/handlers/close_child.go pkg/jobrunner/handlers/close_child_test.go
git commit -m "feat(jobrunner): add enable_auto_merge, tick_parent, close_child handlers"

Task 8: Resolve Threads Handler

Files:

  • Create: pkg/jobrunner/handlers/resolve_threads.go
  • Test: pkg/jobrunner/handlers/resolve_threads_test.go

Context: This handler is special — it needs GraphQL to resolve review threads (no REST endpoint exists). Use a minimal GraphQL client (raw net/http POST to https://api.github.com/graphql).

Step 1: Write the test

package handlers

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestResolveThreads_Match_Good(t *testing.T) {
	h := NewResolveThreads(nil)
	signal := &jobrunner.PipelineSignal{
		PRState:         "OPEN",
		ThreadsTotal:    3,
		ThreadsResolved: 1,
	}
	assert.True(t, h.Match(signal))
}

func TestResolveThreads_Match_Bad_AllResolved(t *testing.T) {
	h := NewResolveThreads(nil)
	signal := &jobrunner.PipelineSignal{
		PRState:         "OPEN",
		ThreadsTotal:    3,
		ThreadsResolved: 3,
	}
	assert.False(t, h.Match(signal))
}

func TestResolveThreads_Execute_Good(t *testing.T) {
	callCount := 0
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		callCount++
		var req map[string]any
		json.NewDecoder(r.Body).Decode(&req)

		query := req["query"].(string)

		// First call: fetch threads
		if callCount == 1 {
			json.NewEncoder(w).Encode(map[string]any{
				"data": map[string]any{
					"repository": map[string]any{
						"pullRequest": map[string]any{
							"reviewThreads": map[string]any{
								"nodes": []map[string]any{
									{"id": "PRRT_1", "isResolved": false},
									{"id": "PRRT_2", "isResolved": true},
								},
							},
						},
					},
				},
			})
			return
		}

		// Subsequent calls: resolve thread
		json.NewEncoder(w).Encode(map[string]any{
			"data": map[string]any{
				"resolveReviewThread": map[string]any{
					"thread": map[string]any{"isResolved": true},
				},
			},
		})
	}))
	defer server.Close()

	h := NewResolveThreads(&http.Client{})
	h.graphqlURL = server.URL

	signal := &jobrunner.PipelineSignal{
		PRNumber:        315,
		RepoOwner:       "host-uk",
		RepoName:        "core",
		PRState:         "OPEN",
		ThreadsTotal:    2,
		ThreadsResolved: 1,
	}

	result, err := h.Execute(context.Background(), signal)
	require.NoError(t, err)
	assert.True(t, result.Success)
	assert.Equal(t, 2, callCount) // 1 fetch + 1 resolve (only PRRT_1 unresolved)
}

Step 2: Run test to verify it fails

Run: go test ./pkg/jobrunner/handlers/ -run TestResolveThreads -v -count=1 Expected: FAIL — NewResolveThreads undefined.

Step 3: Write the implementation

package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"forge.lthn.ai/core/cli/pkg/jobrunner"
)

// ResolveThreads resolves all unresolved review threads on a PR.
type ResolveThreads struct {
	client     *http.Client
	graphqlURL string
}

func NewResolveThreads(client *http.Client) *ResolveThreads {
	if client == nil {
		client = http.DefaultClient
	}
	return &ResolveThreads{
		client:     client,
		graphqlURL: "https://api.github.com/graphql",
	}
}

func (h *ResolveThreads) Name() string { return "resolve_threads" }

func (h *ResolveThreads) Match(s *jobrunner.PipelineSignal) bool {
	return s.PRState == "OPEN" && s.HasUnresolvedThreads()
}

func (h *ResolveThreads) Execute(ctx context.Context, s *jobrunner.PipelineSignal) (*jobrunner.ActionResult, error) {
	// 1. Fetch unresolved thread IDs
	threadIDs, err := h.fetchUnresolvedThreads(ctx, s.RepoOwner, s.RepoName, s.PRNumber)
	if err != nil {
		return nil, fmt.Errorf("fetch threads: %w", err)
	}

	// 2. Resolve each thread
	resolved := 0
	for _, id := range threadIDs {
		if err := h.resolveThread(ctx, id); err != nil {
			// Log but continue — some threads may not be resolvable
			continue
		}
		resolved++
	}

	result := &jobrunner.ActionResult{
		Action:    "resolve_threads",
		RepoOwner: s.RepoOwner,
		RepoName:  s.RepoName,
		PRNumber:  s.PRNumber,
		Success:   resolved > 0,
		Timestamp: time.Now().UTC(),
	}

	if resolved == 0 && len(threadIDs) > 0 {
		result.Error = fmt.Sprintf("0/%d threads resolved", len(threadIDs))
	}

	return result, nil
}

func (h *ResolveThreads) fetchUnresolvedThreads(ctx context.Context, owner, repo string, pr int) ([]string, error) {
	query := fmt.Sprintf(`{
		repository(owner: %q, name: %q) {
			pullRequest(number: %d) {
				reviewThreads(first: 100) {
					nodes { id isResolved }
				}
			}
		}
	}`, owner, repo, pr)

	resp, err := h.graphql(ctx, query)
	if err != nil {
		return nil, err
	}

	type thread struct {
		ID         string `json:"id"`
		IsResolved bool   `json:"isResolved"`
	}
	var result struct {
		Data struct {
			Repository struct {
				PullRequest struct {
					ReviewThreads struct {
						Nodes []thread `json:"nodes"`
					} `json:"reviewThreads"`
				} `json:"pullRequest"`
			} `json:"repository"`
		} `json:"data"`
	}

	if err := json.Unmarshal(resp, &result); err != nil {
		return nil, err
	}

	var ids []string
	for _, t := range result.Data.Repository.PullRequest.ReviewThreads.Nodes {
		if !t.IsResolved {
			ids = append(ids, t.ID)
		}
	}
	return ids, nil
}

func (h *ResolveThreads) resolveThread(ctx context.Context, threadID string) error {
	mutation := fmt.Sprintf(`mutation {
		resolveReviewThread(input: {threadId: %q}) {
			thread { isResolved }
		}
	}`, threadID)

	_, err := h.graphql(ctx, mutation)
	return err
}

func (h *ResolveThreads) graphql(ctx context.Context, query string) (json.RawMessage, error) {
	payload, _ := json.Marshal(map[string]string{"query": query})

	req, err := http.NewRequestWithContext(ctx, "POST", h.graphqlURL, bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("GraphQL HTTP %d", resp.StatusCode)
	}

	var raw json.RawMessage
	err = json.NewDecoder(resp.Body).Decode(&raw)
	return raw, err
}

Step 4: Run tests

Run: go test ./pkg/jobrunner/handlers/ -v -count=1 Expected: PASS.

Step 5: Commit

git add pkg/jobrunner/handlers/resolve_threads.go pkg/jobrunner/handlers/resolve_threads_test.go
git commit -m "feat(jobrunner): add resolve_threads handler with GraphQL"

Task 9: Headless Mode in core-ide

Files:

  • Modify: internal/core-ide/main.go

Context: core-ide currently always creates a Wails app. We need to branch: headless starts the poller + MCP bridge directly; desktop mode keeps the existing Wails app with poller as an optional service.

Note: core-ide has its own go.mod (forge.lthn.ai/core/cli/internal/core-ide). The jobrunner package lives in the root module. We need to add the root module as a dependency of core-ide, OR move the handler wiring into the root module. Simplest approach: core-ide imports forge.lthn.ai/core/cli/pkg/jobrunner — this requires adding the root module as a dependency in core-ide's go.mod.

Step 1: Update core-ide go.mod

Run: cd /Users/snider/Code/host-uk/core/internal/core-ide && go get forge.lthn.ai/core/cli/pkg/jobrunner

If this fails because the package isn't published yet, use a replace directive temporarily:

replace forge.lthn.ai/core/cli => ../..

Then go mod tidy.

Step 2: Modify main.go

Add --headless flag parsing, hasDisplay() detection, and the headless startup path.

The headless path:

  1. Create cli.Daemon with PID file + health server
  2. Create Journal at ~/.core/journal/
  3. Create GitHubSource with repos from config/env
  4. Create all handlers
  5. Create Poller with sources + handlers + journal
  6. Start daemon, run poller in goroutine, block on daemon.Run(ctx)

The desktop path:

  • Existing Wails app code, unchanged for now
  • Poller can be added as a Wails service later
// At top of main():
headless := false
for _, arg := range os.Args[1:] {
    if arg == "--headless" {
        headless = true
    }
}

if headless || !hasDisplay() {
    startHeadless()
    return
}
// ... existing Wails app code ...

Step 3: Run core-ide with --headless --dry-run to verify

Run: cd /Users/snider/Code/host-uk/core/internal/core-ide && go run . --headless --dry-run Expected: Starts, logs poll cycle, exits cleanly on Ctrl+C.

Step 4: Commit

git add internal/core-ide/main.go internal/core-ide/go.mod internal/core-ide/go.sum
git commit -m "feat(core-ide): add headless mode with job runner poller"

Task 10: Register Handlers as MCP Tools

Files:

  • Modify: internal/core-ide/mcp_bridge.go

Context: Register each JobHandler as an MCP tool so they're callable via the HTTP API (POST /mcp/call). This lets external tools invoke handlers manually.

Step 1: Add handler registration to MCPBridge

Add a handlers field and register them in ServiceStartup. Add a job_* prefix to distinguish from webview tools.

// In handleMCPTools — append job handler tools to the tool list
// In handleMCPCall — add a job_* dispatch path

Step 2: Test via curl

Run: curl -X POST http://localhost:9877/mcp/call -d '{"tool":"job_publish_draft","params":{"pr":316,"owner":"host-uk","repo":"core"}}' Expected: Returns handler result JSON.

Step 3: Commit

git add internal/core-ide/mcp_bridge.go
git commit -m "feat(core-ide): register job handlers as MCP tools"

Task 11: Updater Integration in core-ide

Files:

  • Modify: internal/core-ide/main.go (headless startup path)

Context: Wire the existing internal/cmd/updater package into core-ide's headless startup. Check for updates on startup, auto-apply in headless mode.

Step 1: Add updater to headless startup

// In startHeadless(), before starting poller:
updaterSvc, err := updater.NewUpdateService(updater.UpdateServiceConfig{
    RepoURL:        "https://forge.lthn.ai/core/cli",
    Channel:        "alpha",
    CheckOnStartup: updater.CheckAndUpdateOnStartup,
})
if err == nil {
    _ = updaterSvc.Start() // will auto-update and restart if newer version exists
}

Step 2: Test by running headless

Run: core-ide --headless — should check for updates on startup, then start polling.

Step 3: Commit

git add internal/core-ide/main.go
git commit -m "feat(core-ide): integrate updater for headless auto-update"

Task 12: Systemd Service File

Files:

  • Create: internal/core-ide/build/linux/core-ide.service

Step 1: Write the systemd unit

[Unit]
Description=Core IDE Job Runner
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/core-ide --headless
Restart=always
RestartSec=10
Environment=CORE_DAEMON=1
Environment=GITHUB_TOKEN=

[Install]
WantedBy=multi-user.target

Step 2: Add to nfpm.yaml so it's included in the Linux package:

In internal/core-ide/build/linux/nfpm/nfpm.yaml, add to contents:

- src: ../core-ide.service
  dst: /etc/systemd/system/core-ide.service
  type: config

Step 3: Commit

git add internal/core-ide/build/linux/core-ide.service internal/core-ide/build/linux/nfpm/nfpm.yaml
git commit -m "feat(core-ide): add systemd service for headless mode"

Task 13: Run Full Test Suite

Step 1: Run all jobrunner tests

Run: go test ./pkg/jobrunner/... -v -count=1 Expected: All tests pass.

Step 2: Run core-ide build

Run: cd /Users/snider/Code/host-uk/core/internal/core-ide && go build -o /dev/null . Expected: Builds without errors.

Step 3: Run dry-run integration test

Run: cd /Users/snider/Code/host-uk/core/internal/core-ide && go run . --headless --dry-run Expected: Polls GitHub, logs signals, takes no actions, exits on Ctrl+C.


Batch Execution Plan

Batch Tasks Description
0 0 Go workspace setup
1 1-2 Core types + Journal
2 3-4 Poller + GitHub Source
3 5-8 All handlers
4 9-11 core-ide integration (headless, MCP, updater)
5 12-13 Systemd + verification