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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 10 additions & 5 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/project/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions internal/project/pr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/en.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/es.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/ja.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/ko.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/pt_br.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/ru.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion internal/prompt/zh_cn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion internal/relay/merged.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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
Expand Down
94 changes: 94 additions & 0 deletions internal/relay/mergewatch.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading