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
42 changes: 35 additions & 7 deletions daemon/internal/ctlserver/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import (
)

type ctlRequest struct {
Action string `json:"action"`
SessionID string `json:"session_id,omitempty"`
PRURL string `json:"pr_url,omitempty"` // for add_pr
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr
Action string `json:"action"`
SessionID string `json:"session_id,omitempty"`
PRURL string `json:"pr_url,omitempty"` // for add_pr
PRKey string `json:"pr_key,omitempty"` // "owner/repo#N" for remove_pr, cycle_pr_autopilot, set_merge_method
MergeMethod string `json:"merge_method,omitempty"` // for set_merge_method
}

type ctlResponse struct {
Expand All @@ -27,6 +28,7 @@ type ctlResponse struct {
PRs []pr.TrackedPR `json:"prs,omitempty"`
Event string `json:"event,omitempty"`
AutopilotMode string `json:"autopilot_mode,omitempty"`
NewRepo bool `json:"new_repo,omitempty"` // true when add_pr is the first PR for this repo
}

type Handler struct {
Expand Down Expand Up @@ -70,6 +72,8 @@ func (h *Handler) Handle(conn net.Conn) {
h.handleRemovePR(conn, req.PRKey)
case "cycle_pr_autopilot":
h.handleCyclePRAutopilot(conn, req.PRKey)
case "set_merge_method":
h.handleSetMergeMethod(conn, req.PRKey, req.MergeMethod)
}
}
}
Expand Down Expand Up @@ -155,20 +159,44 @@ func (h *Handler) handleAddPR(conn net.Conn, url string) {
writeJSON(conn, ctlResponse{OK: &f})
return
}
tracked, err := h.prPoll.AddFromURL(url)
tracked, newRepo, err := h.prPoll.AddFromURL(url)
if err != nil {
log.Printf("ctl: add_pr failed: %v", err)
f := false
writeJSON(conn, ctlResponse{OK: &f})
return
}
log.Printf("ctl: added PR %s/%s#%d", tracked.Owner, tracked.Repo, tracked.Number)
log.Printf("ctl: added PR %s/%s#%d (newRepo=%v)", tracked.Owner, tracked.Repo, tracked.Number, newRepo)
ok := true
writeJSON(conn, ctlResponse{OK: &ok})
writeJSON(conn, ctlResponse{OK: &ok, NewRepo: newRepo})
// Trigger immediate poll for the new PR.
go h.prPoll.Poll()
}

func (h *Handler) handleSetMergeMethod(conn net.Conn, key, method string) {
if h.prPoll == nil {
f := false
writeJSON(conn, ctlResponse{OK: &f})
return
}
parts := strings.SplitN(key, "#", 2)
if len(parts) != 2 {
f := false
writeJSON(conn, ctlResponse{OK: &f})
return
}
ownerRepo := strings.SplitN(parts[0], "/", 2)
if len(ownerRepo) != 2 {
f := false
writeJSON(conn, ctlResponse{OK: &f})
return
}
var number int
fmt.Sscanf(parts[1], "%d", &number)
ok := h.prPoll.SetMergeMethod(ownerRepo[0], ownerRepo[1], number, method)
writeJSON(conn, ctlResponse{OK: &ok})
}

func (h *Handler) handleCyclePRAutopilot(conn net.Conn, key string) {
if h.prPoll == nil {
f := false
Expand Down
2 changes: 1 addition & 1 deletion daemon/internal/hookserver/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ func (h *Handler) handlePostToolUse(req hookRequest) {
return
}
log.Printf("hook: PostToolUse detected PR URL: %s", url)
if _, err := h.prPoll.AddFromURL(url); err != nil {
if _, _, err := h.prPoll.AddFromURL(url); err != nil {
log.Printf("hook: auto-add PR failed: %v", err)
} else {
log.Printf("hook: auto-added PR %s", url)
Expand Down
43 changes: 23 additions & 20 deletions daemon/internal/pr/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ type TrackedPR struct {
Timeline []PREvent `json:"timeline"`

// Tracking config
AutopilotMode string `json:"autopilot_mode"` // "off", "auto", "yolo"
Hammer bool `json:"hammer"` // auto-fix CI failures
HammerCount int `json:"hammer_count"` // fix attempts so far
MaxHammer int `json:"max_hammer"` // max fix attempts (default 3)
MergeMethod string `json:"merge_method"` // "squash", "merge", "rebase", "aviator"
RunReview bool `json:"run_review"` // run code-review skill on creation
AutopilotMode string `json:"autopilot_mode"` // "off", "auto", "yolo"
Hammer bool `json:"hammer"` // auto-fix CI failures
HammerCount int `json:"hammer_count"` // fix attempts so far
MaxHammer int `json:"max_hammer"` // max fix attempts (default 3)
MergeMethod string `json:"merge_method"` // "squash", "merge", "rebase", "aviator", "" = unset
MergeTriggered bool `json:"merge_triggered"` // true once auto-merge has been fired; resets on check regression
RunReview bool `json:"run_review"` // run code-review skill on creation
}

// PR autopilot modes.
Expand All @@ -87,14 +88,25 @@ func (pr *TrackedPR) ShouldAutoMerge() bool {
if pr.Mergeable != "MERGEABLE" {
return false
}
// Merge method must be configured — auto-merge is blocked until the user picks one.
if pr.MergeMethod == "" {
return false
}

// All checks must pass (ignore still-running ones).
// No check may be failing.
for _, c := range pr.Checks {
if c.Conclusion == "FAILURE" {
return false
}
}
// At least one check must have completed.

if pr.AutopilotMode == PRYolo {
// YOLO: no checks required, no approval required.
// Repos with no CI (empty Checks) can still be merged.
return true
}

// AUTO: at least one completed check required.
hasCompleted := false
for _, c := range pr.Checks {
if c.Status == "COMPLETED" {
Expand All @@ -106,22 +118,13 @@ func (pr *TrackedPR) ShouldAutoMerge() bool {
return false
}

// Check approval.
hasApproval := false
// AUTO: needs at least one approval.
for _, r := range pr.Reviews {
if r.State == "APPROVED" {
hasApproval = true
break
return true
}
}

if pr.AutopilotMode == PRYolo {
// YOLO doesn't need human approval.
return true
}

// AUTO needs at least one approval.
return hasApproval
return false
}

// ShouldHammer returns true if the daemon should spawn a fix-CI agent.
Expand Down
3 changes: 3 additions & 0 deletions daemon/internal/pr/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ func TestShouldAutoMerge_AutoWithApprovalAndGreenChecks(t *testing.T) {
pr := TrackedPR{
AutopilotMode: PRAuto,
Mergeable: "MERGEABLE",
MergeMethod: "squash",
Checks: []Check{{Conclusion: "SUCCESS", Status: "COMPLETED"}},
Reviews: []Review{{State: "APPROVED"}},
}
Expand Down Expand Up @@ -188,6 +189,7 @@ func TestShouldAutoMerge_YoloWithGreenChecks(t *testing.T) {
pr := TrackedPR{
AutopilotMode: PRYolo,
Mergeable: "MERGEABLE",
MergeMethod: "squash",
Checks: []Check{{Conclusion: "SUCCESS", Status: "COMPLETED"}},
// No reviews — YOLO doesn't need them.
}
Expand Down Expand Up @@ -289,6 +291,7 @@ func TestShouldAutoMerge_MixedChecksAllGreenAndInProgress(t *testing.T) {
pr := TrackedPR{
AutopilotMode: PRAuto,
Mergeable: "MERGEABLE",
MergeMethod: "squash",
Checks: []Check{
{Name: "ci", Status: "COMPLETED", Conclusion: "SUCCESS"},
{Name: "deploy", Status: "IN_PROGRESS", Conclusion: ""},
Expand Down
98 changes: 80 additions & 18 deletions daemon/internal/pr/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,68 +13,104 @@ import (

// Poller fetches PR data from GitHub via the gh CLI.
type Poller struct {
mu sync.RWMutex
tracked map[string]*TrackedPR // "owner/repo#number" → PR
onChange func() // called when PR state changes
mu sync.RWMutex
tracked map[string]*TrackedPR // "owner/repo#number" → PR
repoMethods map[string]string // "owner/repo" → preferred merge method
onChange func() // called when PR state changes

storePath string // persistence path (~/.csm/prs.json)
}

// NewPoller creates a PR poller.
func NewPoller(storePath string, onChange func()) *Poller {
p := &Poller{
tracked: make(map[string]*TrackedPR),
onChange: onChange,
storePath: storePath,
tracked: make(map[string]*TrackedPR),
repoMethods: make(map[string]string),
onChange: onChange,
storePath: storePath,
}
p.load()
return p
}

// Add starts tracking a PR.
func (p *Poller) Add(owner, repo string, number int) *TrackedPR {
// Add starts tracking a PR. Returns the PR and a bool indicating whether this
// is the first PR seen from this repo (newRepo=true means no merge method is
// configured yet and the caller should prompt the user to pick one).
func (p *Poller) Add(owner, repo string, number int) (*TrackedPR, bool) {
key := fmt.Sprintf("%s/%s#%d", owner, repo, number)
repoKey := fmt.Sprintf("%s/%s", owner, repo)
p.mu.Lock()
defer p.mu.Unlock()

if pr, ok := p.tracked[key]; ok {
return pr
return pr, false
}

method := p.repoMethods[repoKey] // "" if repo is new
newRepo := method == ""

pr := &TrackedPR{
Owner: owner,
Repo: repo,
Number: number,
AutopilotMode: PRAuto,
Hammer: true,
MaxHammer: 3,
MergeMethod: "squash",
MergeMethod: method,
Timeline: []PREvent{{Time: time.Now(), Icon: "📝", Message: "Added to tracking"}},
}
p.tracked[key] = pr
p.save()
if p.onChange != nil {
p.onChange()
}
return pr
return pr, newRepo
}

// SetMergeMethod updates the merge method for a tracked PR and stores the
// preference for the repo so future PRs inherit it.
func (p *Poller) SetMergeMethod(owner, repo string, number int, method string) bool {
key := fmt.Sprintf("%s/%s#%d", owner, repo, number)
repoKey := fmt.Sprintf("%s/%s", owner, repo)
p.mu.Lock()
defer p.mu.Unlock()

pr, ok := p.tracked[key]
if !ok {
return false
}
pr.MergeMethod = method
p.repoMethods[repoKey] = method
pr.Timeline = append(pr.Timeline, PREvent{
Time: time.Now(),
Icon: "⚙",
Message: fmt.Sprintf("Merge method → %s", method),
})
p.save()
if p.onChange != nil {
p.onChange()
}
return true
}

// AddFromURL parses a GitHub PR URL and starts tracking.
func (p *Poller) AddFromURL(url string) (*TrackedPR, error) {
// Returns the PR, a newRepo flag, and any parse error.
func (p *Poller) AddFromURL(url string) (*TrackedPR, bool, error) {
// Parse: https://github.com/owner/repo/pull/123
url = strings.TrimSpace(url)
url = strings.TrimSuffix(url, "/")
parts := strings.Split(url, "/")
if len(parts) < 5 || parts[len(parts)-2] != "pull" {
return nil, fmt.Errorf("invalid PR URL: %s", url)
return nil, false, fmt.Errorf("invalid PR URL: %s", url)
}
owner := parts[len(parts)-4]
repo := parts[len(parts)-3]
var number int
if _, err := fmt.Sscanf(parts[len(parts)-1], "%d", &number); err != nil {
return nil, fmt.Errorf("invalid PR number in URL: %s", url)
return nil, false, fmt.Errorf("invalid PR number in URL: %s", url)
}
return p.Add(owner, repo, number), nil
pr, newRepo := p.Add(owner, repo, number)
return pr, newRepo, nil
}

// Remove stops tracking a PR.
Expand Down Expand Up @@ -312,8 +348,14 @@ func (p *Poller) pollOne(owner, repo string, number int) bool {
})
}

// Auto-merge if conditions met.
if pr.ShouldAutoMerge() {
// Reset MergeTriggered if checks have regressed so we can re-fire later.
if pr.State == StateChecksFailing || pr.State == StateChecksRunning {
pr.MergeTriggered = false
}

// Auto-merge once — don't re-fire on every poll cycle.
if pr.ShouldAutoMerge() && !pr.MergeTriggered {
pr.MergeTriggered = true
go p.triggerMerge(pr)
}

Expand Down Expand Up @@ -475,11 +517,27 @@ func defaultGhBin() string {

// --- persistence ---

// pollerStore is the on-disk format for prs.json.
type pollerStore struct {
PRs map[string]*TrackedPR `json:"prs"`
RepoMethods map[string]string `json:"repo_methods,omitempty"`
}

func (p *Poller) load() {
data, err := os.ReadFile(p.storePath)
if err != nil {
return
}
// Try new wrapper format first.
var store pollerStore
if err := json.Unmarshal(data, &store); err == nil && store.PRs != nil {
p.tracked = store.PRs
if store.RepoMethods != nil {
p.repoMethods = store.RepoMethods
}
return
}
// Backward compat: old format was a bare map[string]*TrackedPR.
var prs map[string]*TrackedPR
if err := json.Unmarshal(data, &prs); err != nil {
return
Expand All @@ -488,7 +546,11 @@ func (p *Poller) load() {
}

func (p *Poller) save() {
data, err := json.MarshalIndent(p.tracked, "", " ")
store := pollerStore{
PRs: p.tracked,
RepoMethods: p.repoMethods,
}
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return
}
Expand Down
Loading
Loading