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
48 changes: 48 additions & 0 deletions internal/forge/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,57 @@ func (g *GitForge) ListComments(ctx context.Context, repo string, number int) ([
Author: r.Author.Login, Body: r.Body, Kind: "review",
})
}
// Inline review-thread comments (left on specific diff lines) are NOT included
// in `pr view`'s comments/reviews — they live on the REST pulls/comments
// endpoint. Fetch them too so line-level code feedback also drives follow-ups.
// Best-effort: a failure here must not drop the issue/review comments above.
cs = append(cs, g.listReviewThreadComments(ctx, repo, number)...)
return cs, nil
}

// listReviewThreadComments returns inline (diff-line) review comments via the
// REST API. Each carries its file:line so the follow-up knows where the request
// applies. Returns nil on any error — the caller treats inline comments as a
// best-effort enrichment over the top-level comments.
func (g *GitForge) listReviewThreadComments(ctx context.Context, repo string, number int) []Comment {
out, err := g.gh(ctx, "api",
"repos/"+repo+"/pulls/"+strconv.Itoa(number)+"/comments?per_page=100",
"-H", "Accept: application/vnd.github+json")
if err != nil {
return nil
}
var raw []struct {
User struct {
Login string `json:"login"`
} `json:"user"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
Path string `json:"path"`
Line int `json:"line"`
}
if err := json.Unmarshal([]byte(out), &raw); err != nil {
return nil
}
var cs []Comment
for _, c := range raw {
if strings.TrimSpace(c.Body) == "" {
continue
}
body := c.Body
if c.Path != "" {
loc := c.Path
if c.Line > 0 {
loc += ":" + strconv.Itoa(c.Line)
}
body = "On " + loc + ":\n" + body
}
cs = append(cs, Comment{
ExternalID: c.HTMLURL, Author: c.User.Login, Body: body, Kind: "review_comment",
})
}
return cs
}

// ---- helpers ----

func ghStatus(state string, isDraft bool) string {
Expand Down
50 changes: 50 additions & 0 deletions internal/orch/orch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,56 @@ func TestConflictingPR_SpawnsRebaseFollowup(t *testing.T) {
}
}

func TestFailingChecksPR_SpawnsCIFollowup(t *testing.T) {
o, st := newTestOrch(t)
addTarget(t, st, "local", model.TargetLocal, 4)
o.RegisterProvider(agent.NewFake(model.AgentClaude, true, nil))
f := forge.NewFake()
o.SetForge(f)

obj, _, _ := o.CreateObjective(NewObjectiveSpec{Title: "x", Prompt: "p"})
pr := &model.PullRequest{ObjectiveID: obj.ID, Repo: "octo/repo", Number: 9, Branch: "orcha/impl-x",
BaseBranch: "main", HeadSHA: "sha0", Status: model.PROpen}
_ = st.CreatePR(pr)

// GitHub reports the open PR's checks as failing.
f.SetPRState("octo/repo", 9, forge.PRState{Number: 9, Status: "open", HeadSHA: "sha1", ChecksState: "failing"})

if _, err := o.RefreshPR(context.Background(), pr.ID); err != nil {
t.Fatalf("refresh: %v", err)
}
spawned, err := o.ProcessFeedback(context.Background(), pr.ID)
if err != nil {
t.Fatalf("process feedback: %v", err)
}
if len(spawned) != 1 {
t.Fatalf("a failing-CI PR should spawn one CI follow-up, got %d", len(spawned))
}
if spawned[0].Role != model.RoleCIFollowup || !strings.Contains(spawned[0].Goal, "checks are failing") {
t.Fatalf("follow-up should be a CI follow-up tasked to fix checks, got role=%s goal=%q", spawned[0].Role, spawned[0].Goal)
}

// While that follow-up is still active, re-observing the failure does NOT
// dispatch a duplicate.
_, _ = o.RefreshPR(context.Background(), pr.ID)
again, _ := o.ProcessFeedback(context.Background(), pr.ID)
if len(again) != 0 {
t.Fatalf("a duplicate follow-up must not spawn while one is active, got %d", len(again))
}

// The follow-up pushes a new head that is still red -> a fresh failure
// (new head SHA) re-dispatches.
for _, s := range []model.SessionStatus{model.SessionStarting, model.SessionRunning, model.SessionSucceeded} {
_, _ = st.UpdateSessionStatus(spawned[0].ID, s)
}
f.SetPRState("octo/repo", 9, forge.PRState{Number: 9, Status: "open", HeadSHA: "sha2", ChecksState: "failing"})
_, _ = o.RefreshPR(context.Background(), pr.ID)
retry, _ := o.ProcessFeedback(context.Background(), pr.ID)
if len(retry) != 1 {
t.Fatalf("a new failing head should re-dispatch a CI follow-up, got %d", len(retry))
}
}

func TestUpdatePR_ForcePushViaMCP(t *testing.T) {
o, st := newTestOrch(t)
o.RegisterProvider(agent.NewFake(model.AgentClaude, true, nil))
Expand Down
23 changes: 22 additions & 1 deletion internal/orch/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ func (o *Orchestrator) UpdatePR(ctx context.Context, prID string, spec UpdateSpe
o.audit(pr.ObjectiveID, spec.SessionID, "force_push", spec.ForceReason, model.JSONMap{"pr_id": prID})
}
if spec.Comment != "" {
_ = f.Comment(ctx, pr.Repo, pr.Number, spec.Comment)
// Tag it as orcha's own so the PR monitor doesn't re-ingest our comment as
// actionable feedback and spawn a follow-up reacting to ourselves.
_ = f.Comment(ctx, pr.Repo, pr.Number, spec.Comment+"\n\n"+orchaBotMarker)
o.audit(pr.ObjectiveID, spec.SessionID, "pr_comment", "left comment", model.JSONMap{"pr_id": prID})
}
o.audit(pr.ObjectiveID, spec.SessionID, "pr_updated",
Expand Down Expand Up @@ -334,6 +336,25 @@ func (o *Orchestrator) RefreshPR(ctx context.Context, prID string) (*model.PullR
Actionable: true,
}})
}
// Failing CI on an open PR needs a fix. Record it as actionable feedback
// (deduped by head SHA, so it fires once per failing head and again only if a
// new push is still red) — ProcessFeedback then spawns a ci_followup that
// inspects the failing checks and pushes a fix. Same active-follow-up guard as
// the conflict path so we don't dispatch a duplicate while one is in flight.
if err == nil && (updated.Status == model.PROpen || updated.Status == model.PRDraft) &&
updated.ChecksState == model.ChecksFailing && !o.hasActivePRFollowup(updated.ObjectiveID, prID) {
_ = o.IngestFeedback(ctx, prID, []model.PRFeedback{{
Kind: model.FeedbackCheckFailure,
ExternalID: "check_failure@" + updated.HeadSHA,
Body: fmt.Sprintf("CI checks are failing on this PR (head %s). In this PR-branch checkout "+
"(origin points at the PR repo and gh is authenticated), inspect the failing checks — "+
"`gh pr checks %d` to list them, then `gh run view <run-id> --log-failed` for the failing "+
"job logs — reproduce the failure, fix the root cause, re-run the relevant build/tests to "+
"confirm they pass, commit, then call update_pr to push the fix.",
updated.HeadSHA, updated.Number),
Actionable: true,
}})
}
return updated, err
}

Expand Down
Loading