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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows.
- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does with its project open. Services saved in Settings › Services with Global selected run in your home directory and show on lich's home screen, the place for a `lazydocker` you keep open whatever the project. A service lives as long as its project's tab: closing the tab stops it, and reopening the project starts the ones saved to start with lich. Closing the window leaves them running, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list.

- **Send a pull request's review threads to its session.** On the Pulls screen, an open review thread has Send to session beside Reply and Resolve, in the Conversation tab and in the diff, and the Conversation tab can send every open thread at once. The threads go as one prompt to the session in the branch's checkout, which lich opens when there is none, with each thread's file, lines, link and every comment in it. lich then answers each thread on GitHub with "Fixing this. It'll be in the next commit." and resolves it once the pull request is pushed again, replying with the commit it landed in; until then the thread reads Sent, waiting for a push. Any push resolves it, even one that did not touch it.

### Changed

- 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
18 changes: 18 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ opencodeprofile.LoginCommand(ProfileSlugOptions) string
opencodeprofile.Profiles(None) []ProfileInfo
opencodeprofile.UpdateProfile(UpdateProfileOptions) Profile
patchnotes.Current(PatchNotesOptions) PatchNotes
project.AwaitThreadFix(ThreadFixOptions) None
project.BaseFreshness(PathBranchOptions) BaseFreshness
project.BaseStatus(PathOptions) *BaseStatus
project.BranchRules(PathBranchOptions) *BranchRules
Expand Down Expand Up @@ -93,6 +94,7 @@ project.RestoreLines(RestoreLinesOptions) None
project.RevertLines(RevertLinesOptions) RevertResult
project.Search(SearchOptions) SearchResult
project.SubmitReview(SubmitReviewOptions) None
project.ThreadFixes(None) []ThreadFix
project.Tree(PathOptions) FileListing
project.WorktreeAdopted(PathOptions) bool
project.WorktreeDirty(PathOptions) bool
Expand Down Expand Up @@ -247,6 +249,7 @@ event sessions-filed SessionsFiled
event terminal:data:<id> string
event terminal:exit:<id> SessionExit
event theme-selection ThemeSelection
event thread-fixes []ThreadFix
event window-close None
event window-raise None

Expand Down Expand Up @@ -356,6 +359,13 @@ PatchNotes {
groups []PatchNotesGroup
}

ThreadFixOptions {
path string
number int
threadId string
commentId int64
}

PathBranchOptions {
path string
branch string
Expand Down Expand Up @@ -636,6 +646,14 @@ SubmitReviewOptions {
comments []ReviewComment
}

ThreadFix {
threadId string
path string
number int
commentId int64
head string
}

FileListing {
files []string
cut bool
Expand Down
32 changes: 32 additions & 0 deletions api/threadfix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package api

var (
ProjectAwaitThreadFix = define[ThreadFixOptions, None]("project.AwaitThreadFix")
ProjectThreadFixes = define[None, []ThreadFix]("project.ThreadFixes")

// ThreadFixesEvent carries every review thread waiting for its fix, emitted
// once one starts waiting or stops: resolved by a push, or dropped with
// its pull request.
ThreadFixesEvent = defineEvent[[]ThreadFix]("thread-fixes")
)

// ThreadFixOptions names a review thread just handed to a session: ThreadID is
// its GraphQL node id (what resolving takes), CommentID the database id of a
// comment in it (what a reply takes).
type ThreadFixOptions struct {
Path string `json:"path"`
Number int `json:"number"`
ThreadID string `json:"threadId"`
CommentID int64 `json:"commentId"`
}

// ThreadFix is a review thread waiting for the pull request's next push, which
// resolves it. Head is the commit the pull request stood on when the thread
// was sent.
type ThreadFix struct {
ThreadID string `json:"threadId"`
Path string `json:"path"`
Number int `json:"number"`
CommentID int64 `json:"commentId"`
Head string `json:"head"`
}
8 changes: 8 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,14 @@ work when nobody knows it and that the call site never shows. The mechanism and
not hold it, under ReadFile's size cap. A relative image
shows its alt text (the renderer fetches nothing) and raw HTML is left out. A new version of the file puts
the card back on its diff, as it reopens or folds a card by its size.
- **Any push resolves a thread sent to a session** (`internal/project/threadfix.go`): Send to session on the
Pulls screen replies on the thread at once and resolves it when the pull request's head first moves after the
send, replying with that head's short sha. lich cannot tell the commit that fixed the thread from one that did
not, so an unrelated push resolves it too, and a fix that never gets pushed leaves it waiting. The head is read
once a minute per waiting pull request, so the resolve lands up to a minute after the push. A pull request
that closes, or a checkout that is gone, drops its threads without a word on GitHub. A thread whose resolve
GitHub keeps refusing (deleted since) is retried every minute until its pull request closes. The replies are
written in English, whatever the interface and prompt languages: the reviewer reading them is someone else.

## Persistence and history

Expand Down
3 changes: 3 additions & 0 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ type Service struct {
baseRefs refetchGuard
// emit reports a pull request write (prchanged.go); nil reports nothing.
emit func(name string, data any)
// fixes keeps the review threads waiting for a push (threadfix.go); nil
// keeps nothing.
fixes threadFixStore
}

// New returns a project service using the given picker.
Expand Down
196 changes: 196 additions & 0 deletions internal/project/threadfix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package project

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

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

// A review thread handed to a session (the Pulls screen's Send to session) is
// answered on GitHub at once and resolved once the pull request moves: the
// first head after the send is taken as the fix. Resolving at the send would
// mark a thread done before any code changed; waiting for the push trades that
// for the opposite risk, an unrelated push resolving it (docs/ceilings.md).
//
// The replies are English on purpose: the reviewer reading them is someone
// else, whose language lich has no setting for.
const (
threadFixingReply = "Fixing this. It'll be in the next commit."
threadFixedReply = "Fixed in %s."
// threadFixPoll is how often the waiting pull requests are read, one gh
// call each, and only while something waits.
threadFixPoll = time.Minute
// shortSHA is the length git itself abbreviates a commit to, which GitHub
// links in a comment.
shortSHA = 7
prOpen = "OPEN"
)

// threadFixStore keeps the waiting threads across restarts; main wires the
// workspace store, and nil keeps nothing.
type threadFixStore interface {
ThreadFixes() ([]api.ThreadFix, error)
SaveThreadFix(api.ThreadFix) error
DeleteThreadFix(threadID string) error
}

// SetThreadFixes wires where the waiting threads are kept.
func (s *Service) SetThreadFixes(store threadFixStore) { s.fixes = store }

type prTip struct {
HeadRefOID string `json:"headRefOid"`
State string `json:"state"`
}

type prKey struct {
path string
number int
}

func (s *Service) readTip(path string, number int) (prTip, error) {
out, err := s.gh(prReadTimeout, path, prArgs("view", number, "--json", "headRefOid,state")...)
if err != nil {
return prTip{}, err
}
var head prTip
if err := json.Unmarshal(out, &head); err != nil {
return prTip{}, fmt.Errorf("could not read the pull request's head: %w", err)
}
return head, nil
}

// AwaitThreadFix answers a thread just handed to a session and has it wait for
// the pull request's next push. The head is read here rather than taken from
// the window, whose read may predate a push that would resolve the thread at
// once.
func (s *Service) AwaitThreadFix(ctx context.Context, opts api.ThreadFixOptions) (api.None, error) {
if opts.ThreadID == "" || opts.Number <= 0 || opts.CommentID == 0 {
return api.None{}, errors.New("a pull request, a thread and one of its comments are required")
}
if s.fixes == nil {
return api.None{}, errors.New("nowhere to keep the thread while it waits")
}
head, err := s.readTip(opts.Path, opts.Number)
if err != nil {
return api.None{}, err
}
if head.State != prOpen {
return api.None{}, errors.New("the pull request is not open")
}
reply := api.PRCommentBodyOptions{Path: opts.Path, Number: opts.Number, CommentID: opts.CommentID, Body: threadFixingReply}
if _, err := s.ReplyToReviewThread(ctx, reply); err != nil {
return api.None{}, err
}
fix := api.ThreadFix{ThreadID: opts.ThreadID, Path: opts.Path, Number: opts.Number, CommentID: opts.CommentID, Head: head.HeadRefOID}
if err := s.fixes.SaveThreadFix(fix); err != nil {
return api.None{}, err
}
s.emitThreadFixes()
return api.None{}, nil
}

// ThreadFixes is every thread waiting for its fix.
func (s *Service) ThreadFixes(_ context.Context, _ api.None) ([]api.ThreadFix, error) {
if s.fixes == nil {
return nil, nil
}
return s.fixes.ThreadFixes()
}

// RunThreadFixWatch settles the waiting threads every threadFixPoll, for as
// long as lich runs.
func (s *Service) RunThreadFixWatch() {
ticker := time.NewTicker(threadFixPoll)
defer ticker.Stop()
for range ticker.C {
s.checkThreadFixes()
}
}

// checkThreadFixes reads each waiting pull request once and settles its
// threads. A read or a write that fails leaves the thread waiting for the next
// pass.
func (s *Service) checkThreadFixes() {
fixes, err := s.ThreadFixes(context.Background(), api.None{})
if err != nil {
slog.Warn("thread fixes: list", "err", err)
return
}
heads := map[prKey]prTip{}
unread := map[prKey]bool{}
changed := false
for _, f := range fixes {
key := prKey{f.Path, f.Number}
if unread[key] {
continue
}
head, ok := heads[key]
if !ok {
if head, err = s.waitingHead(f); err != nil {
slog.Warn("thread fixes: read the pull request", "path", f.Path, "number", f.Number, "err", err)
unread[key] = true
continue
}
heads[key] = head
}
settled, err := s.settleThreadFix(f, head)
if err != nil {
slog.Warn("thread fixes: settle", "thread", f.ThreadID, "err", err)
}
changed = changed || settled
}
if changed {
s.emitThreadFixes()
}
}

// waitingHead reads f's pull request, as closed when its checkout is gone:
// gh has nothing left to run in, and nothing will ever push from it.
func (s *Service) waitingHead(f api.ThreadFix) (prTip, error) {
if _, err := os.Stat(f.Path); errors.Is(err, fs.ErrNotExist) {
return prTip{}, nil
}
return s.readTip(f.Path, f.Number)
}

// settleThreadFix resolves f once its pull request moved past the head it was
// sent at, and drops it once the pull request closed; it reports whether f
// stopped waiting. Resolving goes first: a reply that fails after it is
// retried with a resolve that changes nothing, never a second reply.
func (s *Service) settleThreadFix(f api.ThreadFix, head prTip) (bool, error) {
if head.State != prOpen {
return true, s.fixes.DeleteThreadFix(f.ThreadID)
}
if head.HeadRefOID == f.Head {
return false, nil
}
if _, err := s.ResolveReviewThread(context.Background(), api.ResolveThreadOptions{Path: f.Path, ThreadID: f.ThreadID, Resolved: true}); err != nil {
return false, err
}
sha := head.HeadRefOID[:min(shortSHA, len(head.HeadRefOID))]
reply := api.PRCommentBodyOptions{Path: f.Path, Number: f.Number, CommentID: f.CommentID, Body: fmt.Sprintf(threadFixedReply, sha)}
if _, err := s.ReplyToReviewThread(context.Background(), reply); err != nil {
return false, err
}
return true, s.fixes.DeleteThreadFix(f.ThreadID)
}

func (s *Service) emitThreadFixes() {
if s.emit == nil {
return
}
fixes, err := s.ThreadFixes(context.Background(), api.None{})
if err != nil {
slog.Warn("thread fixes: list", "err", err)
return
}
events.Send(s.emit, api.ThreadFixesEvent, fixes)
}
Loading
Loading