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
5 changes: 5 additions & 0 deletions api/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ var (
// instead of the screen that made the change telling the others.
PullRequestChangedEvent = defineEvent[PRChanged]("pull-request-changed")

// BranchPullRequestEvent is every read of a checkout's branch pull request,
// changed or not: the backend is the one reader of it, and a window that
// asked for a recheck hears its answer here.
BranchPullRequestEvent = defineEvent[BranchPullRequest]("branch-pull-request")

// ServicesEvent carries every project service and its state, emitted once a
// change to either is true: a save, a delete, a start, an exit.
ServicesEvent = defineEvent[[]Service]("services")
Expand Down
23 changes: 16 additions & 7 deletions api/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ var (
ProjectWorktreeAdopted = define[PathOptions, bool]("project.WorktreeAdopted")
ProjectGitHubAccounts = define[None, []string]("project.GitHubAccounts")
ProjectIssue = define[NumberOptions, Issue]("project.Issue")
ProjectPullRequest = define[PathOptions, *PullRequest]("project.PullRequest")
ProjectRecheckPullRequest = define[PathOptions, None]("project.RecheckPullRequest")
ProjectListPullRequests = define[ListPullRequestsOptions, []PRSummary]("project.ListPullRequests")
ProjectPullRequestDetail = define[NumberOptions, *PRDetail]("project.PullRequestDetail")
ProjectPullRequestConversation = define[NumberOptions, *PRConversation]("project.PullRequestConversation")
Expand Down Expand Up @@ -353,13 +353,22 @@ type OpenedProject struct {
Path string `json:"path"`
}

// PullRequest identifies the open GitHub pull request for a work tree's current
// branch, as reported by the gh CLI. State is one of gh's OPEN, CLOSED, MERGED;
// only OPEN reaches the window (see parsePullRequest).
// PullRequest is the GitHub pull request of a work tree's current branch, as
// reported by the gh CLI, whatever its state: one of gh's OPEN, CLOSED, MERGED.
type PullRequest struct {
Number int `json:"number"`
URL string `json:"url"`
State string `json:"state"`
Number int `json:"number"`
URL string `json:"url"`
Title string `json:"title"`
State string `json:"state"`
HeadRefName string `json:"headRefName"`
BaseRefName string `json:"baseRefName"`
}

// BranchPullRequest is one read of the pull request of the branch checked out
// at Path; PR is nil when the branch has none.
type BranchPullRequest struct {
Path string `json:"path"`
PR *PullRequest `json:"pr"`
}

// DiffStats is what one status read says about a work tree: its uncommitted
Expand Down
23 changes: 16 additions & 7 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,14 @@ project.Missing(PathsOptions) []string
project.Open(None) *OpenedProject
project.PickFile(PickFileOptions) string
project.PickSaveFile(PickSaveFileOptions) string
project.PullRequest(PathOptions) *PullRequest
project.PullRequestConflicts(PRConflictsOptions) []string
project.PullRequestConversation(NumberOptions) *PRConversation
project.PullRequestDetail(NumberOptions) *PRDetail
project.PullRequestDiff(NumberOptions) string
project.PullRequestMergeBase(NumberOptions) string
project.ReadFile(CheckoutFileOptions) string
project.ReadFileAt(ReadFileAtOptions) string
project.RecheckPullRequest(PathOptions) None
project.Relocate(ProjectIDOptions) *OpenedProject
project.RemoveWorktree(RemoveWorktreeOptions) None
project.ReplyToReviewThread(PRCommentBodyOptions) None
Expand Down Expand Up @@ -211,6 +211,7 @@ themes.UpdateFromGit(UpdateThemeOptions) ThemeGitResult
tray.SetLabels(TrayLabels) None

event appupdate-progress AppUpdateProgress
event branch-pull-request BranchPullRequest
event claude-profile-login ProfileLogin
event codex-profile-login ProfileLogin
event lich-plugin-profiles PluginProfiles
Expand Down Expand Up @@ -543,12 +544,6 @@ PickSaveFileOptions {
defaultName string
}

PullRequest {
number int
url string
state string
}

PRConflictsOptions {
path string
number int
Expand Down Expand Up @@ -1383,6 +1378,11 @@ AppUpdateProgress {
total int64
}

BranchPullRequest {
path string
pr *PullRequest
}

ProfileLogin {
profile string
output,omitempty string
Expand Down Expand Up @@ -1723,6 +1723,15 @@ ThemeSource {

Syntax = map[string]SyntaxStyle

PullRequest {
number int
url string
title string
state string
headRefName string
baseRefName string
}

PluginProfileFailure {
profile string
name string
Expand Down
24 changes: 13 additions & 11 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,17 +321,19 @@ work when nobody knows it and that the call site never shows. The mechanism and
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.
- **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.
(`internal/relay`, `AnnounceMerge`; called from the Pulls screen's merge and by the pull request watch,
`internal/project` `RunBranchPRWatch`): a merge on github.com, with `gh` in a terminal or by an agent is found by
the watch, the one reader of a checkout's pull request, which the cards' badges hear too. Each minute it reads,
one `gh pr view` each, every checkout a live agent runs in and every one whose last read found an open pull
request; any other is read only when a window asks (its branch or head moved, a turn ended there, the window came
back into focus). A merge is announced only when one read saw the pull request open and a later one sees it
merged, so one that merged while lich was closed, or before any read saw it open, is never announced; a card
parked while the merge is seen, or never opened, stays believing it is open. What was read 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
`docs/agent-cards.md`): the plugin's mod takes the model's general-purpose `Agent` call and runs it as a lich
session with `lich open --subagent --prompt`, in the background. The card opens in the asking session's checkout
Expand Down
201 changes: 201 additions & 0 deletions internal/project/branchpr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package project

import (
"context"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"os"
"slices"
"sync"
"time"

"github.com/lichdotdev/lich/api"
"github.com/lichdotdev/lich/internal/events"
)

// The pull request of each checkout's branch has one reader, here. The cards'
// badges hear every read as api.BranchPullRequestEvent, and a merge made
// outside lich (on github.com, with gh in a terminal, by an agent) is told to
// the agents in its checkout from the same reads, the way the Pulls screen's
// merge is.

// branchPRTick is how often the watch reads the pull request of every checkout
// it follows: one gh call per checkout, for a badge and a merge notice that
// read as fine a minute late.
const branchPRTick = time.Minute

// prMerged is the state gh reports a merged pull request in.
const prMerged = "MERGED"

// branchPRs is what the watch last read at each checkout, and which reads are
// running. A path in last was read at least once; its value is nil for a
// branch with no pull request.
type branchPRs struct {
mu sync.Mutex
last map[string]*PullRequest
reading map[string]bool
again map[string]bool
}

// SetAgentCheckouts wires the checkouts an agent runs in, which the watch reads
// every tick: a pull request opens there whether or not a window is looking.
func (s *Service) SetAgentCheckouts(checkouts func() []string) { s.agentCheckouts = checkouts }

// SetAnnounceMerge wires who tells a checkout's agents that its pull request
// merged (internal/relay AnnounceMerge).
func (s *Service) SetAnnounceMerge(announce func(context.Context, api.AnnounceMergeOptions) (api.None, error)) {
s.announceMerge = announce
}

// RecheckPullRequest reads the pull request of opts.Path's branch again, behind
// the call: the answer arrives as api.BranchPullRequestEvent. A window asks
// when it has seen the branch or the head move, a turn end, or anything else
// gh would answer differently after.
func (s *Service) RecheckPullRequest(_ context.Context, opts api.PathOptions) (api.None, error) {
go s.readBranchPR(opts.Path)
return api.None{}, nil
}

// RunBranchPRWatch reads the followed checkouts every branchPRTick, for as long
// as lich runs.
func (s *Service) RunBranchPRWatch() {
ticker := time.NewTicker(branchPRTick)
defer ticker.Stop()
for range ticker.C {
for _, path := range s.followedCheckouts() {
s.readBranchPR(path)
}
}
}

// followedCheckouts is every checkout an agent runs in, and every one whose
// last read found an open pull request, which may merge or close with nobody
// in the checkout. A checkout with neither is read only when a window asks.
func (s *Service) followedCheckouts() []string {
var paths []string
if s.agentCheckouts != nil {
paths = s.agentCheckouts()
}
s.prs.mu.Lock()
defer s.prs.mu.Unlock()
for path, pr := range s.prs.last {
if pr != nil && pr.State == prOpen && !slices.Contains(paths, path) {
paths = append(paths, path)
}
}
return paths
}

// readBranchPR reads path's pull request, one read at a time per path. One
// asked for while another runs is read again once it ends, since the branch
// may have moved under the first.
func (s *Service) readBranchPR(path string) {
if !s.prs.claim(path) {
return
}
for {
s.recordBranchPR(path)
if !s.prs.release(path) {
return
}
}
}

// recordBranchPR reads path's pull request, reports it and announces a merge
// the read before did not see. A failed read reports nothing and keeps what the
// last one saw, except for a checkout that is gone, which is forgotten.
func (s *Service) recordBranchPR(path string) {
pr, err := s.BranchPullRequest(path)
if err != nil {
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
s.prs.forget(path)
return
}
slog.Debug("project: read the pull request of a checkout", "path", path, "err", err)
return
}
merged := s.prs.record(path, pr)
if s.emit != nil {
events.Send(s.emit, api.BranchPullRequestEvent, api.BranchPullRequest{Path: path, PR: pr})
}
if !merged || s.announceMerge == nil {
return
}
opts := api.AnnounceMergeOptions{Checkout: path, Number: pr.Number, Title: pr.Title, Branch: pr.HeadRefName, Base: pr.BaseRefName}
if _, err := s.announceMerge(context.Background(), opts); err != nil {
slog.Warn("project: announce a merge made outside lich", "path", path, "err", err)
}
}

// BranchPullRequest returns the pull request of the branch checked out at path
// whatever its state, nil when the branch has none.
func (s *Service) BranchPullRequest(path string) (*PullRequest, error) {
out, err := s.gh(prLookupTimeout, path, "pr", "view", "--json", "number,url,title,state,headRefName,baseRefName")
if errors.Is(err, errNoPullRequest) {
return nil, nil
}
if err != nil {
return nil, err
}
var pr PullRequest
if err := json.Unmarshal(out, &pr); err != nil {
return nil, errUnreadableAnswer(err)
}
// gh emits `{}` in some no-PR states.
if pr.Number == 0 {
return nil, nil
}
return &pr, nil
}

// claim marks a read of path running and answers whether the caller is the one
// to run it; otherwise the running one reads again when it ends.
func (b *branchPRs) claim(path string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.reading == nil {
b.reading, b.again = map[string]bool{}, map[string]bool{}
}
if b.reading[path] {
b.again[path] = true
return false
}
b.reading[path] = true
return true
}

// release ends a read of path and answers whether another was asked for
// meanwhile, which the caller then runs.
func (b *branchPRs) release(path string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.again[path] {
delete(b.again, path)
return true
}
delete(b.reading, path)
return false
}

// record keeps pr as path's last read and answers whether it is the pull
// request the read before found open, now merged. One first read already
// merged is not announced: it may have merged long before lich started, and
// its agents heard of it then or never will.
func (b *branchPRs) record(path string, pr *PullRequest) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.last == nil {
b.last = map[string]*PullRequest{}
}
prev := b.last[path]
b.last[path] = pr
return prev != nil && prev.State == prOpen && pr != nil && pr.State == prMerged && pr.Number == prev.Number
}

func (b *branchPRs) forget(path string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.last, path)
}
Loading
Loading