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
168 changes: 168 additions & 0 deletions backend/internal/lifecycle/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,174 @@ func TestPRObservation_DedupPersistsAcrossPRs(t *testing.T) {
}
}

func TestApplyTrackerFacts_TerminalStateMarksTerminated(t *testing.T) {
for _, state := range []domain.NormalizedIssueState{domain.IssueDone, domain.IssueCancelled} {
t.Run(string(state), func(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: state},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
got := st.sessions["mer-1"]
if !got.IsTerminated || got.Activity.State != domain.ActivityExited {
t.Fatalf("want terminated/exited for state %q, got %+v", state, got)
}
if len(msg.msgs) != 0 {
t.Fatalf("terminal state should not nudge, got %v", msg.msgs)
}
})
}
}

func TestApplyTrackerFacts_AssigneeChangedIsLogOnly(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen, Assignee: "someone-else"},
Changed: ports.TrackerChanged{Assignee: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if st.sessions["mer-1"] != before {
t.Fatalf("assignee-only change must not mutate the session row, got %+v", st.sessions["mer-1"])
}
if len(msg.msgs) != 0 {
t.Fatalf("assignee-only change must not nudge, got %v", msg.msgs)
}
}

func TestApplyTrackerFacts_NewBotCommentNudges(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "human-1", Author: "alice", Body: "human chime-in, must NOT nudge", IsBot: false},
{ID: "bot-1", Author: "ci-bot[bot]", Body: "please rerun the migration", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("want one bot-mention nudge, got %d: %v", len(msg.msgs), msg.msgs)
}
if !strings.Contains(msg.msgs[0], "please rerun the migration") {
t.Fatalf("nudge should include the bot comment body, got %q", msg.msgs[0])
}
if strings.Contains(msg.msgs[0], "human chime-in") {
t.Fatalf("nudge must not include human comments, got %q", msg.msgs[0])
}
}

func TestApplyTrackerFacts_NudgeSuppressedOnRepeat(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "bot-1", Author: "ci-bot[bot]", Body: "please rerun the migration", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("first ApplyTrackerFacts: %v", err)
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("second ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("repeat observation must dedup; got %d nudges: %v", len(msg.msgs), msg.msgs)
}

// A genuinely new bot comment still fires.
o.Comments = append(o.Comments, ports.TrackerCommentObservation{ID: "bot-2", Author: "ci-bot[bot]", Body: "now check the seed", IsBot: true})
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("third ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 2 {
t.Fatalf("new bot comment id should re-fire, got %d: %v", len(msg.msgs), msg.msgs)
}
}

func TestApplyTrackerFacts_BotCommentWithEmptyIDIsIgnored(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
// Bot comment lacks an ID — without one we cannot dedup, and the
// zero-value signature collides with m.react.seen's empty default and
// would silently suppress every future nudge for this issue. The
// reducer must skip it entirely.
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "", Author: "ci-bot[bot]", Body: "no id, must be skipped", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 0 {
t.Fatalf("bot comment with empty ID must not nudge, got %v", msg.msgs)
}
// A subsequent, properly-formed bot comment must still nudge — the
// earlier empty-ID entry must not have polluted the dedup signature.
o.Comments = []ports.TrackerCommentObservation{
{ID: "bot-1", Author: "ci-bot[bot]", Body: "now with an id", IsBot: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("second ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 1 {
t.Fatalf("follow-up bot comment with real ID should nudge, got %d: %v", len(msg.msgs), msg.msgs)
}
}

func TestApplyTrackerFacts_NotFetchedIsNoop(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
before := st.sessions["mer-1"]
if err := m.ApplyTrackerFacts(ctx, "mer-1", ports.TrackerObservation{Fetched: false}); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if st.sessions["mer-1"] != before {
t.Fatalf("not-fetched observation must not mutate state")
}
if len(msg.msgs) != 0 {
t.Fatalf("not-fetched observation must not nudge")
}
}

func TestApplyTrackerFacts_TerminatedSessionDoesNotRefireOrNudge(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = domain.SessionRecord{ID: "mer-1", ProjectID: "mer", IsTerminated: true, Activity: domain.Activity{State: domain.ActivityExited}}
o := ports.TrackerObservation{
Fetched: true,
Issue: ports.TrackerIssueObservation{URL: "https://github.com/o/r/issues/1", State: domain.IssueOpen},
Comments: []ports.TrackerCommentObservation{
{ID: "bot-1", Body: "x", IsBot: true},
},
Changed: ports.TrackerChanged{Comments: true},
}
if err := m.ApplyTrackerFacts(ctx, "mer-1", o); err != nil {
t.Fatalf("ApplyTrackerFacts: %v", err)
}
if len(msg.msgs) != 0 {
t.Fatalf("terminated session must not receive nudges, got %v", msg.msgs)
}
}

func TestPRObservation_RetriesAfterMessengerFailure(t *testing.T) {
m, st, msg := newManager()
st.sessions["mer-1"] = working("mer-1")
Expand Down
77 changes: 77 additions & 0 deletions backend/internal/lifecycle/reactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lifecycle
import (
"context"
"encoding/json"
"log/slog"
"strings"
"sync"

Expand Down Expand Up @@ -154,6 +155,82 @@ func scmToPRObservation(o ports.SCMObservation) ports.PRObservation {
return pr
}

// ApplyTrackerFacts reacts to a fetched Tracker issue observation. It owns the
// issue-driven side of session lifecycle and the initial bot-mention nudge;
// it does NOT persist tracker rows (the future Tracker observer in #35 owns
// the read-side persistence path).
//
// Reactions today:
// - Issue terminal (state == done or cancelled) → MarkTerminated. The
// reducer is idempotent — repeat observations on an already-terminated
// session are no-ops because MarkTerminated skips when IsTerminated.
// - Assignee changed → log only. No session-state reaction yet; the policy
// for "assignee changed away from AO" is reserved for the write-side work
// tracked by #40.
// - New bot comment → one-time nudge using the same sendOnce + dedup
// signature pattern as the SCM lane. Dedup is in-memory only for now;
// cross-restart persistence lands with the Tracker observer (issue #35)
// when issue-row signature storage is on the table.
func (m *Manager) ApplyTrackerFacts(ctx context.Context, id domain.SessionID, o ports.TrackerObservation) error {
if !o.Fetched {
return nil
}
if isTerminalTrackerState(o.Issue.State) {
return m.MarkTerminated(ctx, id)
}
rec, ok, err := m.store.GetSession(ctx, id)
if err != nil || !ok {
return err
}
if rec.IsTerminated || rec.Activity.State == domain.ActivityWaitingInput {
return nil
}
if o.Changed.Assignee {
slog.Default().Info("lifecycle: tracker issue assignee changed",
"session", id, "issue", o.Issue.URL, "assignee", o.Issue.Assignee)
}
if o.Changed.Comments {
bodies, ids := newBotCommentContent(o.Comments)
if len(ids) > 0 {
msg := "A bot left a new comment on your tracker issue. Address it and update the session."
if joined := strings.Join(bodies, "\n\n"); strings.TrimSpace(joined) != "" {
msg += "\n\n" + joined
}
// Empty prURL routes sendOnce through its in-memory-only branch:
// the PR-row signature load/persist is skipped, so the dedup
// survives only for the lifetime of this Manager. Cross-restart
// persistence ships with #35.
return m.sendOnce(ctx, id, "", "tracker-bot:"+o.Issue.URL, strings.Join(ids, ","), msg, 0)
}
}
return nil
}

func isTerminalTrackerState(state domain.NormalizedIssueState) bool {
return state == domain.IssueDone || state == domain.IssueCancelled
}

func newBotCommentContent(comments []ports.TrackerCommentObservation) ([]string, []string) {
bodies := make([]string, 0, len(comments))
ids := make([]string, 0, len(comments))
for _, c := range comments {
if !c.IsBot {
continue
}
// Both an ID and a body are required: ID anchors the dedup
// signature (an empty ID collapses to "" which collides with
// the zero value of m.react.seen[key] and silently suppresses
// the nudge), and a body is what we actually need to surface
// to the agent.
if c.ID == "" || strings.TrimSpace(c.Body) == "" {
continue
}
bodies = append(bodies, c.Body)
ids = append(ids, c.ID)
}
return bodies, ids
}

func firstSCMNonEmpty(a, b string) string {
if strings.TrimSpace(a) != "" {
return a
Expand Down
Loading
Loading