From c3e3fd7d07f8613775808f0939e388b9cbc162eb Mon Sep 17 00:00:00 2001 From: omartelo Date: Sat, 24 Oct 2026 08:06:50 -0300 Subject: [PATCH] feat(relay): announce a pull request merged outside lich A merge made on github.com, with gh in a terminal or by an agent left every session in its checkout believing the pull request was still open. The relay now reads, each minute, the pull request of every checkout a live agent runs in, and announces one it saw open and now sees merged through AnnounceMerge, which remembers what it announced so the Pulls merge and the watch never tell it twice. The notice no longer says the merge came from lich. --- CHANGELOG.md | 1 + docs/ceilings.md | 15 ++-- internal/project/pr.go | 15 ++++ internal/project/pr_test.go | 22 +++++ internal/prompt/en.go | 2 +- internal/prompt/es.go | 2 +- internal/prompt/ja.go | 2 +- internal/prompt/ko.go | 2 +- internal/prompt/pt_br.go | 2 +- internal/prompt/ru.go | 2 +- internal/prompt/zh_cn.go | 2 +- internal/relay/merged.go | 21 ++++- internal/relay/mergewatch.go | 94 +++++++++++++++++++ internal/relay/mergewatch_test.go | 145 ++++++++++++++++++++++++++++++ internal/relay/relay.go | 8 ++ main.go | 3 + 16 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 internal/relay/mergewatch.go create mode 100644 internal/relay/mergewatch_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f358685a0..a97957615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- A pull request merged outside lich, on github.com, with `gh` in a terminal or by an agent, is now announced to the agents in its branch's checkout as one merged from Pulls is, within a minute of the merge. They used to keep believing it was still open. The agents running there when lich sees it open and then merged hear it; one parked at that moment, or a pull request that merged before lich last started, does not. - Resolve conflicts and Fix CI errors in Pulls no longer take you to the session they hand the pull request to. Pulls stays on screen so you can move to the next pull request, the session starts in the sidebar, and a toast says when the agent got the task, with Open to go to it. A session whose start needs your answer first, such as whether to resume its last conversation, still comes up. Open in Session still goes to the session. - The quick filters over the pull request list in Pulls (All, Ready, Drafts, Failing) are lighter: small text with the selected one shaded, as in the dock's tabs, sharing the list's width. A filter with nothing in it is faded and Failing's count turns red when a check fails. They no longer run past the list's edge with a monospace interface font. diff --git a/docs/ceilings.md b/docs/ceilings.md index 51b22ecba..1b9ca69b3 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -320,11 +320,16 @@ work when nobody knows it and that the call site never shows. The mechanism and prompt lost on the way (the mod reloading as the response lands) is reported unread, not sent twice. A hook that drops the prompt is reported undelivered with its reason, except at a session that was mid-turn, whose ack lich stops waiting for after the receipt window. -- **Only a merge lich makes is announced, and only to a card running at that moment** (`internal/relay`, - `AnnounceMerge`; called from the Pulls screen's merge): a pull request merged on github.com, with `gh` in a - terminal or by an agent leaves every session in its checkout believing it is still open, and so does one merged - while the card was parked or never opened. The session that opened one of the checkout's sessions hears it - too, wherever it runs, but only that one: the session that opened the opener does not. The notice also lands as a delivery like any other, so a cleanup +- **A merge is announced only to a card running at that moment, and one made outside lich up to a minute late** + (`internal/relay`, `AnnounceMerge`; called from the Pulls screen's merge and by `RunMergeWatch`): a merge on + github.com, with `gh` in a terminal or by an agent is found by reading, each minute, the pull request of every + checkout a live agent runs in, one `gh pr view` per checkout. It is announced only when one read saw the pull + request open and a later one sees it merged, so a pull request that merged while lich was closed, or while every + agent in its checkout was parked, is never announced; a card parked while the merge is seen, or never opened, + stays believing it is open. What was read open and what was announced live in memory, so a restart forgets both: + a merge already announced is not repeated, because the next read finds it merged without having seen it open. + The session that opened one of the checkout's sessions hears it too, wherever it runs, but only that one: the + session that opened the opener does not. The notice also lands as a delivery like any other, so a cleanup clicked in the second before it starts a turn finds no session mid-turn, asks nothing, and removes the checkout with the notice still on its way. - **Only a Claude Code subagent becomes a lich card** (lich-plugin's `hooks/agent-cards.js`, its diff --git a/internal/project/pr.go b/internal/project/pr.go index cc112a08e..81fac3f56 100644 --- a/internal/project/pr.go +++ b/internal/project/pr.go @@ -158,6 +158,21 @@ func (s *Service) PullRequestDetail(_ context.Context, opts api.NumberOptions) ( return detail, nil } +// BranchPullRequest returns the pull request of the branch checked out at path +// whatever its state, nil when the branch has none: the merge watch +// (internal/relay) needs to see it go from open to merged, which the badge's +// OPEN-only read hides. +func (s *Service) BranchPullRequest(path string) (*PRDetail, error) { + out, err := s.gh(prLookupTimeout, path, "pr", "view", "--json", "number,title,state,headRefName,baseRefName") + if errors.Is(err, errNoPullRequest) { + return nil, nil + } + if err != nil { + return nil, err + } + return parsePRDetail(out, false) +} + // parsePRDetail decodes gh's JSON and reduces the check rollup. With openOnly it // returns nil for a non-OPEN PR — gh still reports a merged/closed branch PR, // but the badge wants only an actionable one — matching parsePullRequest's diff --git a/internal/project/pr_test.go b/internal/project/pr_test.go index cb6622a0d..2b8bb0b3c 100644 --- a/internal/project/pr_test.go +++ b/internal/project/pr_test.go @@ -525,6 +525,28 @@ func TestPullRequestDetailFlow(t *testing.T) { }) } +func TestBranchPullRequestKeepsAMergedOne(t *testing.T) { + t.Parallel() + gh := &fakeGH{out: []byte(`{"number":7,"title":"t","state":"MERGED","headRefName":"feat","baseRefName":"main"}`)} + pr, err := withGH(gh).BranchPullRequest("/repo") + if err != nil { + t.Fatalf("BranchPullRequest: %v", err) + } + if pr == nil || pr.Number != 7 || pr.State != "MERGED" || pr.HeadRefName != "feat" || pr.BaseRefName != "main" { + t.Fatalf("BranchPullRequest = %+v, want the merged PR", pr) + } + if gh.dir != "/repo" || gh.timeout != prLookupTimeout { + t.Errorf("wrong scope: dir %q timeout %v", gh.dir, gh.timeout) + } + + if pr, err := withGH(&fakeGH{err: errNoPullRequest}).BranchPullRequest("/repo"); err != nil || pr != nil { + t.Errorf("no PR = %+v, %v, want nil, nil", pr, err) + } + if _, err := withGH(&fakeGH{err: errTest}).BranchPullRequest("/repo"); !errors.Is(err, errTest) { + t.Errorf("failed lookup err = %v, want %v", err, errTest) + } +} + // TestPullRequestBadgeRunsThroughTheRunner pins the badge to the same runner as // the rest: it used to shell out to gh on its own, which left it running as // whatever account gh had active while the screen beside it used the project's. diff --git a/internal/prompt/en.go b/internal/prompt/en.go index 3c5b5d732..2af9147c1 100644 --- a/internal/prompt/en.go +++ b/internal/prompt/en.go @@ -61,7 +61,7 @@ var en = Catalog{ Other: "lich sessions %s finished", }, BlockedSummary: "lich session %q is waiting on a permission prompt", - MergeNotice: "[lich] Pull request #%[1]d %[2]q (branch %[3]s) was merged into %[4]s from lich. " + + MergeNotice: "[lich] Pull request #%[1]d %[2]q (branch %[3]s) was merged into %[4]s. " + "This is a notice, not a task: update whatever you keep about this work's state, and do nothing else.", MergeSummary: "Pull request #%[1]d merged into %[2]s", LateNotice: "[lich] Scheduled for %[1]s, delivered %[2]s late.\n\n", diff --git a/internal/prompt/es.go b/internal/prompt/es.go index 872a75b35..9fb1aaa2e 100644 --- a/internal/prompt/es.go +++ b/internal/prompt/es.go @@ -60,7 +60,7 @@ var es = Catalog{ Other: "las sesiones de lich %s terminaron", }, BlockedSummary: "la sesión de lich %q está esperando una confirmación de permiso", - MergeNotice: "[lich] El pull request #%[1]d %[2]q (rama %[3]s) se fusionó en %[4]s desde lich. " + + MergeNotice: "[lich] El pull request #%[1]d %[2]q (rama %[3]s) se fusionó en %[4]s. " + "Esto es un aviso, no una tarea: actualiza lo que guardes sobre el estado de este trabajo y no hagas nada más.", MergeSummary: "Pull request #%[1]d fusionado en %[2]s", LateNotice: "[lich] Programado para %[1]s, entregado con %[2]s de retraso.\n\n", diff --git a/internal/prompt/ja.go b/internal/prompt/ja.go index 449cff02c..81f67ad1a 100644 --- a/internal/prompt/ja.go +++ b/internal/prompt/ja.go @@ -60,7 +60,7 @@ var ja = Catalog{ Other: "lich セッション %s が完了しました", }, BlockedSummary: "lich セッション %q が権限の確認を待っています", - MergeNotice: "[lich] プルリクエスト #%[1]d %[2]q(ブランチ %[3]s)が lich から %[4]s にマージされました。" + + MergeNotice: "[lich] プルリクエスト #%[1]d %[2]q(ブランチ %[3]s)が %[4]s にマージされました。" + "これは通知であり、タスクではありません。この作業の状態について記録しているものを更新し、ほかには何もしないでください。", MergeSummary: "プルリクエスト #%[1]d が %[2]s にマージされました", LateNotice: "[lich] %[1]s に予定されていたものが、%[2]s 遅れて届きました。\n\n", diff --git a/internal/prompt/ko.go b/internal/prompt/ko.go index 580de8baf..9ec935459 100644 --- a/internal/prompt/ko.go +++ b/internal/prompt/ko.go @@ -56,7 +56,7 @@ var ko = Catalog{ Other: "lich 세션 %s 완료", }, BlockedSummary: "lich 세션 %q이(가) 권한 확인을 기다리는 중", - MergeNotice: "[lich] 풀 리퀘스트 #%[1]d %[2]q(브랜치 %[3]s)이(가) lich에서 %[4]s에 병합되었습니다. " + + MergeNotice: "[lich] 풀 리퀘스트 #%[1]d %[2]q(브랜치 %[3]s)이(가) %[4]s에 병합되었습니다. " + "이것은 작업이 아니라 알림입니다. 이 작업의 상태에 대해 기록해 둔 내용을 갱신하고, 그 밖에는 아무것도 하지 마세요.", MergeSummary: "풀 리퀘스트 #%[1]d이(가) %[2]s에 병합됨", LateNotice: "[lich] %[1]s에 예약되었고, %[2]s 늦게 전달되었습니다.\n\n", diff --git a/internal/prompt/pt_br.go b/internal/prompt/pt_br.go index c9f1082bf..0969f53a8 100644 --- a/internal/prompt/pt_br.go +++ b/internal/prompt/pt_br.go @@ -60,7 +60,7 @@ var ptBR = Catalog{ Other: "as sessões do lich %s terminaram", }, BlockedSummary: "a sessão do lich %q está esperando uma confirmação de permissão", - MergeNotice: "[lich] O pull request #%[1]d %[2]q (branch %[3]s) foi mergeado em %[4]s pelo lich. " + + MergeNotice: "[lich] O pull request #%[1]d %[2]q (branch %[3]s) foi mergeado em %[4]s. " + "Isto é um aviso, não uma tarefa: atualize o que você guarda sobre o estado deste trabalho e não faça mais nada.", MergeSummary: "Pull request #%[1]d mergeado em %[2]s", LateNotice: "[lich] Agendado para %[1]s, entregue com %[2]s de atraso.\n\n", diff --git a/internal/prompt/ru.go b/internal/prompt/ru.go index ab336f2e2..3bc89a81b 100644 --- a/internal/prompt/ru.go +++ b/internal/prompt/ru.go @@ -69,7 +69,7 @@ var ru = Catalog{ Other: "Работа завершена в lich: %s", }, BlockedSummary: "Сессия lich %q ждёт подтверждения разрешения", - MergeNotice: "[lich] Pull request #%[1]d %[2]q (ветка %[3]s) слит в %[4]s из lich. " + + MergeNotice: "[lich] Pull request #%[1]d %[2]q (ветка %[3]s) слит в %[4]s. " + "Это уведомление, а не задача: обновите то, что вы храните о состоянии этой работы, и больше ничего не делайте.", MergeSummary: "Pull request #%[1]d слит в %[2]s", LateNotice: "[lich] Запланировано на %[1]s, доставлено с опозданием на %[2]s.\n\n", diff --git a/internal/prompt/zh_cn.go b/internal/prompt/zh_cn.go index 0c6794a45..729622c8f 100644 --- a/internal/prompt/zh_cn.go +++ b/internal/prompt/zh_cn.go @@ -55,7 +55,7 @@ var zhCN = Catalog{ Other: "lich 会话 %s 已完成", }, BlockedSummary: "lich 会话 %q 正在等待权限确认", - MergeNotice: "[lich] 拉取请求 #%[1]d %[2]q(分支 %[3]s)已通过 lich 合并到 %[4]s。" + + MergeNotice: "[lich] 拉取请求 #%[1]d %[2]q(分支 %[3]s)已合并到 %[4]s。" + "这是一条通知,不是任务:请更新你所记录的这项工作的状态,不要做其他任何事。", MergeSummary: "拉取请求 #%[1]d 已合并到 %[2]s", LateNotice: "[lich] 原定于 %[1]s 发送,延迟 %[2]s 送达。\n\n", diff --git a/internal/relay/merged.go b/internal/relay/merged.go index 2c75847d1..d7b82a45c 100644 --- a/internal/relay/merged.go +++ b/internal/relay/merged.go @@ -12,7 +12,9 @@ import ( ) // AnnounceMerge tells every agent running in opts.Checkout that the pull request on -// its branch was merged from lich's own screen. An agent keeps notes on the work +// its branch was merged, from lich's own screen or, as the merge watch finds, +// anywhere else (mergewatch.go). A merge already announced is not announced +// again. An agent keeps notes on the work // it did there, and without this the last thing they say about it is that it is // waiting on a merge, which a later session reads back as true long after the // checkout is gone. The session that opened one of those agents hears it too, @@ -32,6 +34,9 @@ func (s *Service) AnnounceMerge(ctx context.Context, opts api.AnnounceMergeOptio if err != nil { return api.None{}, fmt.Errorf("read sessions: %w", err) } + if !s.firstToldOf(opts.Checkout, opts.Number) { + return api.None{}, nil + } lang := s.lang() message := mergeNotice(lang, opts.Number, opts.Title, opts.Branch, opts.Base) summary := fmt.Sprintf(prompt.For(lang).MergeSummary, opts.Number, opts.Base) @@ -46,6 +51,20 @@ func (s *Service) AnnounceMerge(ctx context.Context, opts api.AnnounceMergeOptio return api.None{}, nil } +// firstToldOf records the merge of pull request number at checkout as +// announced and answers whether it was not yet: the Pulls screen announces its +// own merge and the merge watch reads it a moment later, in either order. +func (s *Service) firstToldOf(checkout string, number int) bool { + key := fmt.Sprint(checkout, "\x00", number) + s.mu.Lock() + defer s.mu.Unlock() + if s.mergesTold[key] { + return false + } + s.mergesTold[key] = true + return true +} + // mergeAudience is the live provider sessions running in checkout, and the live // provider sessions that opened any session there, each once. An opener hears // it even when the session it opened is parked: the work it handed out is diff --git a/internal/relay/mergewatch.go b/internal/relay/mergewatch.go new file mode 100644 index 000000000..35506b3a4 --- /dev/null +++ b/internal/relay/mergewatch.go @@ -0,0 +1,94 @@ +package relay + +import ( + "context" + "log/slog" + "time" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/store" +) + +// mergeWatchTick is how often the merge watch reads the pull request of every +// checkout an agent runs in: one gh call per checkout, for a notice that +// reads as fine a minute late. +const mergeWatchTick = time.Minute + +// prOpen and prMerged are the states gh reports a pull request in. +const ( + prOpen = "OPEN" + prMerged = "MERGED" +) + +// BranchPullRequest reads the pull request of the branch checked out at path, +// whatever its state, nil for none (internal/project implements it). +type BranchPullRequest func(path string) (*api.PRDetail, error) + +// RunMergeWatch announces a merge made outside lich (on github.com, with gh in +// a terminal, by an agent) the way the Pulls screen announces its own, until +// the process ends. Started once at launch: nothing tells lich of such a +// merge, so it reads every checkout an agent runs in each mergeWatchTick. +func (s *Service) RunMergeWatch(read BranchPullRequest) { + ticker := time.NewTicker(mergeWatchTick) + defer ticker.Stop() + for range ticker.C { + s.watchMerges(read) + } +} + +// watchMerges reads each checkout's pull request once and announces the ones +// that merged since the last read. A failed read (gh missing, not a GitHub +// repo) is left for the next tick and keeps what the last one saw. +func (s *Service) watchMerges(read BranchPullRequest) { + projects, err := s.sessions.LoadState(context.Background(), api.None{}) + if err != nil { + slog.Warn("relay: read sessions for the merge watch", "err", err) + return + } + for _, checkout := range s.agentCheckouts(projects) { + pr, err := read(checkout) + if err != nil { + slog.Debug("relay: read the pull request of a checkout", "checkout", checkout, "err", err) + continue + } + if !s.mergedSinceLastRead(checkout, pr) { + continue + } + opts := api.AnnounceMergeOptions{Checkout: checkout, Number: pr.Number, Title: pr.Title, Branch: pr.HeadRefName, Base: pr.BaseRefName} + if _, err := s.AnnounceMerge(context.Background(), opts); err != nil { + slog.Warn("relay: announce a merge made outside lich", "checkout", checkout, "err", err) + } + } +} + +// agentCheckouts is every checkout a live provider session runs in, each once. +func (s *Service) agentCheckouts(projects []store.Project) []string { + seen := map[string]bool{} + var checkouts []string + for _, p := range projects { + for _, sess := range p.Sessions { + c := sessionCwd(sess.Path, p.Path) + if !seen[c] && s.runsAgent(sess.ID, sess.Kind) { + seen[c] = true + checkouts = append(checkouts, c) + } + } + } + return checkouts +} + +// mergedSinceLastRead records the pull request read at checkout and answers +// whether it is the one the last read found open, now merged. One first read +// already merged is not announced: it may have merged long before lich +// started, and its agents have heard of it then or never will. +func (s *Service) mergedSinceLastRead(checkout string, pr *api.PRDetail) bool { + s.mu.Lock() + defer s.mu.Unlock() + was, open := s.openPRs[checkout] + if pr != nil && pr.State == prOpen { + s.openPRs[checkout] = pr.Number + return false + } + delete(s.openPRs, checkout) + return open && pr != nil && pr.State == prMerged && pr.Number == was +} diff --git a/internal/relay/mergewatch_test.go b/internal/relay/mergewatch_test.go new file mode 100644 index 000000000..4680b6c30 --- /dev/null +++ b/internal/relay/mergewatch_test.go @@ -0,0 +1,145 @@ +package relay + +import ( + "errors" + "strings" + "sync" + "testing" + + "github.com/lichdotdev/lich/api" +) + +// fakePRs answers the merge watch's reads with what each checkout's branch has +// at the moment, and counts the reads. +type fakePRs struct { + mu sync.Mutex + prs map[string]*api.PRDetail + err error + reads map[string]int +} + +func (f *fakePRs) set(checkout string, pr *api.PRDetail) { + f.mu.Lock() + defer f.mu.Unlock() + f.prs[checkout] = pr +} + +func (f *fakePRs) read(path string) (*api.PRDetail, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.reads[path]++ + return f.prs[path], f.err +} + +func newFakePRs() *fakePRs { + return &fakePRs{prs: map[string]*api.PRDetail{}, reads: map[string]int{}} +} + +func featPR(state string) *api.PRDetail { + return &api.PRDetail{Number: 42, Title: "Add merge notice", State: state, HeadRefName: "feat/merge", BaseRefName: "main"} +} + +func TestMergeWatchAnnouncesAMergeMadeOutsideLich(t *testing.T) { + t.Parallel() + term := newFakeTerminal("agent") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + prs.set("/wt/feat", featPR(prOpen)) + + svc.watchMerges(prs.read) + prs.set("/wt/feat", featPR(prMerged)) + svc.watchMerges(prs.read) + + if !awaitWritten(term, "agent", "#42") { + t.Fatalf("typed at agent = %q, want the merge notice", term.written("agent")) + } + svc.watchMerges(prs.read) + if got := strings.Count(term.written("agent"), "#42"); got != 1 { + t.Fatalf("agent heard the notice %d times, want once", got) + } +} + +func TestMergeWatchReadsOnlyCheckoutsAnAgentRunsIn(t *testing.T) { + t.Parallel() + // "shell" and "parked" are in /wt/feat too; only "other" makes /wt/fix live. + term := newFakeTerminal("shell", "other") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + + svc.watchMerges(prs.read) + + if prs.reads["/wt/fix"] != 1 || prs.reads["/wt/feat"] != 0 || prs.reads["/src/lich"] != 0 { + t.Fatalf("reads = %v, want /wt/fix alone", prs.reads) + } +} + +func TestMergeWatchLeavesAPullRequestFirstSeenMerged(t *testing.T) { + t.Parallel() + term := newFakeTerminal("agent") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + prs.set("/wt/feat", featPR(prMerged)) + + svc.watchMerges(prs.read) + + if awaitWritten(term, "agent", "#42") { + t.Fatalf("typed at agent = %q, want nothing: it merged before the watch saw it open", term.written("agent")) + } +} + +func TestMergeWatchLeavesAClosedPullRequest(t *testing.T) { + t.Parallel() + term := newFakeTerminal("agent") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + prs.set("/wt/feat", featPR(prOpen)) + svc.watchMerges(prs.read) + prs.set("/wt/feat", featPR("CLOSED")) + svc.watchMerges(prs.read) + prs.set("/wt/feat", featPR(prMerged)) + svc.watchMerges(prs.read) + + if awaitWritten(term, "agent", "#42") { + t.Fatalf("typed at agent = %q, want nothing for a closed PR", term.written("agent")) + } +} + +func TestMergeWatchKeepsWhatItSawThroughAFailedRead(t *testing.T) { + t.Parallel() + term := newFakeTerminal("agent") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + prs.set("/wt/feat", featPR(prOpen)) + svc.watchMerges(prs.read) + prs.err = errors.New("gh is not signed in to GitHub.") + svc.watchMerges(prs.read) + prs.err = nil + prs.set("/wt/feat", featPR(prMerged)) + svc.watchMerges(prs.read) + + if !awaitWritten(term, "agent", "#42") { + t.Fatalf("typed at agent = %q, want the merge notice", term.written("agent")) + } +} + +func TestMergeWatchAndThePullsMergeAnnounceOnce(t *testing.T) { + t.Parallel() + term := newFakeTerminal("agent") + svc := newRelay(merging(), term, nil) + prs := newFakePRs() + prs.set("/wt/feat", featPR(prOpen)) + svc.watchMerges(prs.read) + + if _, err := svc.AnnounceMerge(t.Context(), api.AnnounceMergeOptions{Checkout: "/wt/feat", Number: 42, Title: "Add merge notice", Branch: "feat/merge", Base: "main"}); err != nil { + t.Fatalf("AnnounceMerge: %v", err) + } + prs.set("/wt/feat", featPR(prMerged)) + svc.watchMerges(prs.read) + + if !awaitWritten(term, "agent", "#42") { + t.Fatalf("typed at agent = %q, want the merge notice", term.written("agent")) + } + if got := strings.Count(term.written("agent"), "#42"); got != 1 { + t.Fatalf("agent heard the notice %d times, want once", got) + } +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go index 899c25670..721d66ab9 100644 --- a/internal/relay/relay.go +++ b/internal/relay/relay.go @@ -420,6 +420,12 @@ type Service struct { workerFinished func(workerID string) error // raiseWindow brings lich's window to the front (SetRaiseWindow). raiseWindow func(sessionID string) + // openPRs is the open pull request the merge watch last read at each + // checkout (mergewatch.go). + openPRs map[string]int + // mergesTold is the merges already announced, by checkout and number, so + // the merge watch and the Pulls screen's merge never announce one twice. + mergesTold map[string]bool } // Plugins is what the relay needs to know about the companion plugin @@ -451,6 +457,8 @@ func New(sessions Sessions, term Terminal, events Events) *Service { collectors: make(map[string][]chan struct{}), nudgeTimer: make(map[string]*time.Timer), nudging: make(map[string]*sync.Mutex), + openPRs: make(map[string]int), + mergesTold: make(map[string]bool), sessions: sessions, term: term, events: events, diff --git a/main.go b/main.go index 1323e5e0b..e28700952 100644 --- a/main.go +++ b/main.go @@ -458,6 +458,9 @@ func registerServices(db *store.Service, term *terminal.Service, svcs *services. // The scheduled prompts are the relay's other clock: nobody calls in for // them, they come due. go rl.RunSchedules() + // A merge made outside lich reaches its agents through the relay's own + // read of their checkouts' pull requests. + go rl.RunMergeWatch(proj.BranchPullRequest) // A turn a usage limit ended is picked up again once it resets, through // the same scheduled prompt. Codex reports no end for that turn, so the // terminal watches its open turns for one.