diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index f8657355d..c440781c1 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -1470,7 +1470,7 @@ This command requires at least Git 2.45. ### git-spice review comment {#gs-review-comment} ``` -gs review comment [] [flags] +gs review comment [flags] ``` Draft or post a review comment @@ -1490,7 +1490,7 @@ If no message is given with -m, an editor is opened. **Arguments** -* `anchor`: Comment anchor: file.go, file.go:42, or file.go:42-50. +* `file[:line[-end]]`: Comment anchor: file.go, file.go:42, or file.go:42-50. **Flags** diff --git a/internal/handler/review/comment.go b/internal/handler/review/comment.go new file mode 100644 index 000000000..7f5ebe72b --- /dev/null +++ b/internal/handler/review/comment.go @@ -0,0 +1,293 @@ +package review + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/review" + "go.abhg.dev/gs/internal/reviewdiff" + "go.abhg.dev/gs/internal/spice/state" +) + +// CommentRequest describes a new root review comment. +type CommentRequest struct { + // Branch identifies the reviewed branch. + Branch string // required + + // Anchor identifies the reviewed file or lines. + Anchor Anchor // required + + // Message supplies the comment body without opening an editor. + Message string +} + +// SaveCommentDraft saves a root review comment for later publication. +func (h *DraftHandler) SaveCommentDraft( + ctx context.Context, + req *CommentRequest, +) error { + if !req.Anchor.IsLine() { + return errors.New("draft comments require a single-line file:line anchor") + } + + body, err := h.commentBody(ctx, req.Message) + if err != nil { + return err + } + draft, err := h.Store.AddReviewDraft( + ctx, + req.Branch, + review.Draft{ID: 0, Body: body, Anchor: req.Anchor}, + ) + if err != nil { + return fmt.Errorf("save draft comment: %w", err) + } + + h.Log.Infof( + "Drafted comment %s on %s.", + draft.ID, + req.Anchor, + ) + return nil +} + +// PostComment immediately starts a remote review thread. +func (h *Handler) PostComment( + ctx context.Context, + req *CommentRequest, +) error { + body, err := h.commentBody(ctx, req.Message) + if err != nil { + return err + } + + change, err := h.Service.LookupBranch(ctx, req.Branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf("branch not tracked: %s", req.Branch) + } + return fmt.Errorf("get branch: %w", err) + } + if change.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + req.Branch, + ) + } + patch, err := h.loadPatch(ctx, change.Base, req.Branch) + if err != nil { + return err + } + if req.Anchor.IsFile() && !patch.ContainsFile(req.Anchor.Path) { + return fmt.Errorf( + "review diff does not contain file %q", + req.Anchor.Path, + ) + } + if !req.Anchor.IsFile() && !patch.ContainsLineRange( + req.Anchor.Path, + req.Anchor.StartLine, + req.Anchor.EndLine, + ) { + return fmt.Errorf( + "review diff does not contain %s", + req.Anchor, + ) + } + + return h.postComment( + ctx, + change.Change.ChangeID(), + forge.SubmitReviewCommentRequest{ + Path: req.Anchor.Path, + Range: forge.ReviewThreadRange{ + StartLine: req.Anchor.StartLine, + EndLine: req.Anchor.EndLine, + }, + Body: body, + Side: forge.ReviewThreadSideRight, + }, + ) +} + +// ReplyRequest describes a reply to an existing review thread. +type ReplyRequest struct { + // Branch identifies the reviewed branch. + Branch string // required + + // ThreadID is the command-line representation of the forge thread ID. + ThreadID string // required + + // Message supplies the reply body without opening an editor. + Message string +} + +// SaveReplyDraft saves a review-thread reply for later publication. +func (h *DraftHandler) SaveReplyDraft( + ctx context.Context, + req *ReplyRequest, +) error { + body, err := h.commentBody(ctx, req.Message) + if err != nil { + return err + } + draft, err := h.Store.AddReviewDraft( + ctx, + req.Branch, + review.Draft{ID: 0, Body: body, ReplyTo: req.ThreadID}, + ) + if err != nil { + return fmt.Errorf("save draft reply: %w", err) + } + + h.Log.Infof( + "Drafted reply %s to thread %s.", + draft.ID, + req.ThreadID, + ) + return nil +} + +// PostReply immediately appends a reply to a remote review thread. +func (h *Handler) PostReply( + ctx context.Context, + req *ReplyRequest, +) error { + body, err := h.commentBody(ctx, req.Message) + if err != nil { + return err + } + + change, err := h.Service.LookupBranch(ctx, req.Branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf("branch not tracked: %s", req.Branch) + } + return fmt.Errorf("get branch: %w", err) + } + if change.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + req.Branch, + ) + } + + // ReviewThreadID is opaque. + // Recover the forge-owned value whose String form the command accepted. + var threadID forge.ReviewThreadID + for thread, err := range h.Repository.ListReviewThreads( + ctx, + change.Change.ChangeID(), + ) { + if err != nil { + return fmt.Errorf("list review threads: %w", err) + } + if thread.ID.String() == req.ThreadID { + threadID = thread.ID + break + } + } + if threadID == nil { + return fmt.Errorf("review thread %q not found", req.ThreadID) + } + + return h.postComment( + ctx, + change.Change.ChangeID(), + forge.SubmitReviewCommentRequest{ + Body: body, + ReplyTo: threadID, + }, + ) +} + +// commentBody returns a supplied comment body or opens an empty editor. +// Whitespace-only input is rejected before any draft or forge mutation. +func (h *Handler) commentBody(ctx context.Context, body string) (string, error) { + if body == "" { + var err error + body, err = h.Editor(ctx, "") + if err != nil { + return "", err + } + } + if strings.TrimSpace(body) == "" { + return "", errors.New("empty comment body, aborting") + } + return body, nil +} + +// commentBody returns a supplied draft body or opens an empty editor. +// Whitespace-only input is rejected before the draft is persisted. +func (h *DraftHandler) commentBody( + ctx context.Context, + body string, +) (string, error) { + if body == "" { + var err error + body, err = h.Editor(ctx, "") + if err != nil { + return "", err + } + } + if strings.TrimSpace(body) == "" { + return "", errors.New("empty comment body, aborting") + } + return body, nil +} + +// loadPatch parses the selected branch's review diff. +// Closing the diff reader also reports failures from the Git process. +func (h *Handler) loadPatch( + ctx context.Context, + base, branch string, +) (*reviewdiff.Patch, error) { + diff, err := h.Worktree.OpenBranchDiff(ctx, base, branch) + if err != nil { + return nil, fmt.Errorf("open diff: %w", err) + } + patch, err := reviewdiff.Parse(diff) + err = errors.Join(err, diff.Close()) + if err != nil { + return nil, fmt.Errorf("parse diff: %w", err) + } + return patch, nil +} + +// postComment submits one comment-only review and reports its new thread ID. +func (h *Handler) postComment( + ctx context.Context, + changeID forge.ChangeID, + comment forge.SubmitReviewCommentRequest, +) error { + result, err := h.Repository.SubmitReview( + ctx, + changeID, + forge.SubmitReviewRequest{ + Comments: []forge.SubmitReviewCommentRequest{comment}, + }, + ) + if err != nil { + return fmt.Errorf("post review comment: %w", err) + } + if len(result.Comments) != 1 { + return fmt.Errorf( + "post review comment: forge returned %d comment results", + len(result.Comments), + ) + } + + h.Log.Infof( + "Posted comment %s on %s.", + result.Comments[0].ThreadID.String(), + changeID, + ) + return nil +} diff --git a/internal/handler/review/edit.go b/internal/handler/review/edit.go new file mode 100644 index 000000000..46c072be3 --- /dev/null +++ b/internal/handler/review/edit.go @@ -0,0 +1,61 @@ +package review + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" +) + +// ReplaceDraftBodyRequest identifies a local draft and its new body. +type ReplaceDraftBodyRequest struct { + // Branch identifies the branch containing the draft. + Branch string // required + + // ID identifies the draft within the branch. + ID DraftID // required + + // Message supplies the new body without opening an editor. + Message string +} + +// ReplaceDraftBody edits the body of a local review draft. +func (h *DraftHandler) ReplaceDraftBody( + ctx context.Context, + req *ReplaceDraftBodyRequest, +) error { + drafts, err := h.Store.LoadReviewDrafts(ctx, req.Branch) + if err != nil { + return fmt.Errorf("load draft comments: %w", err) + } + idx := slices.IndexFunc(drafts, func(draft Draft) bool { + return draft.ID == req.ID + }) + if idx < 0 { + return fmt.Errorf("draft comment %d not found", req.ID) + } + + body := req.Message + if body == "" { + // Seed the editor with the current body so the user can revise it. + body, err = h.Editor(ctx, drafts[idx].Body) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + if err := h.Store.UpdateReviewDraftBody( + ctx, + req.Branch, + req.ID, + body, + ); err != nil { + return fmt.Errorf("save draft comment: %w", err) + } + + h.Log.Infof("Updated draft comment %d.", req.ID) + return nil +} diff --git a/internal/handler/review/forge_mocks_test.go b/internal/handler/review/forge_mocks_test.go new file mode 100644 index 000000000..c3c56b2c8 --- /dev/null +++ b/internal/handler/review/forge_mocks_test.go @@ -0,0 +1,1687 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: go.abhg.dev/gs/internal/forge (interfaces: ReviewRepository,ReviewThreadResolver) +// +// Generated by this command: +// +// mockgen -destination=forge_mocks_test.go -package=review -typed go.abhg.dev/gs/internal/forge ReviewRepository,ReviewThreadResolver +// + +// Package review is a generated GoMock package. +package review + +import ( + context "context" + iter "iter" + reflect "reflect" + + forge "go.abhg.dev/gs/internal/forge" + gomock "go.uber.org/mock/gomock" +) + +// MockReviewRepository is a mock of ReviewRepository interface. +type MockReviewRepository struct { + ctrl *gomock.Controller + recorder *MockReviewRepositoryMockRecorder + isgomock struct{} +} + +// MockReviewRepositoryMockRecorder is the mock recorder for MockReviewRepository. +type MockReviewRepositoryMockRecorder struct { + mock *MockReviewRepository +} + +// NewMockReviewRepository creates a new mock instance. +func NewMockReviewRepository(ctrl *gomock.Controller) *MockReviewRepository { + mock := &MockReviewRepository{ctrl: ctrl} + mock.recorder = &MockReviewRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReviewRepository) EXPECT() *MockReviewRepositoryMockRecorder { + return m.recorder +} + +// ChangeChecks mocks base method. +func (m *MockReviewRepository) ChangeChecks(ctx context.Context, id forge.ChangeID) ([]forge.ChangeCheck, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeChecks", ctx, id) + ret0, _ := ret[0].([]forge.ChangeCheck) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeChecks indicates an expected call of ChangeChecks. +func (mr *MockReviewRepositoryMockRecorder) ChangeChecks(ctx, id any) *MockReviewRepositoryChangeChecksCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecks", reflect.TypeOf((*MockReviewRepository)(nil).ChangeChecks), ctx, id) + return &MockReviewRepositoryChangeChecksCall{Call: call} +} + +// MockReviewRepositoryChangeChecksCall wrap *gomock.Call +type MockReviewRepositoryChangeChecksCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryChangeChecksCall) Return(arg0 []forge.ChangeCheck, arg1 error) *MockReviewRepositoryChangeChecksCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryChangeChecksCall) Do(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockReviewRepositoryChangeChecksCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryChangeChecksCall) DoAndReturn(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockReviewRepositoryChangeChecksCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeMergeability mocks base method. +func (m *MockReviewRepository) ChangeMergeability(ctx context.Context, id forge.ChangeID) (forge.ChangeMergeability, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeMergeability", ctx, id) + ret0, _ := ret[0].(forge.ChangeMergeability) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeMergeability indicates an expected call of ChangeMergeability. +func (mr *MockReviewRepositoryMockRecorder) ChangeMergeability(ctx, id any) *MockReviewRepositoryChangeMergeabilityCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeMergeability", reflect.TypeOf((*MockReviewRepository)(nil).ChangeMergeability), ctx, id) + return &MockReviewRepositoryChangeMergeabilityCall{Call: call} +} + +// MockReviewRepositoryChangeMergeabilityCall wrap *gomock.Call +type MockReviewRepositoryChangeMergeabilityCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryChangeMergeabilityCall) Return(arg0 forge.ChangeMergeability, arg1 error) *MockReviewRepositoryChangeMergeabilityCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryChangeMergeabilityCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockReviewRepositoryChangeMergeabilityCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryChangeMergeabilityCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockReviewRepositoryChangeMergeabilityCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeStatuses mocks base method. +func (m *MockReviewRepository) ChangeStatuses(ctx context.Context, ids []forge.ChangeID) ([]forge.ChangeStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeStatuses", ctx, ids) + ret0, _ := ret[0].([]forge.ChangeStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeStatuses indicates an expected call of ChangeStatuses. +func (mr *MockReviewRepositoryMockRecorder) ChangeStatuses(ctx, ids any) *MockReviewRepositoryChangeStatusesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeStatuses", reflect.TypeOf((*MockReviewRepository)(nil).ChangeStatuses), ctx, ids) + return &MockReviewRepositoryChangeStatusesCall{Call: call} +} + +// MockReviewRepositoryChangeStatusesCall wrap *gomock.Call +type MockReviewRepositoryChangeStatusesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryChangeStatusesCall) Return(arg0 []forge.ChangeStatus, arg1 error) *MockReviewRepositoryChangeStatusesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryChangeStatusesCall) Do(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockReviewRepositoryChangeStatusesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryChangeStatusesCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockReviewRepositoryChangeStatusesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommandEnvironment mocks base method. +func (m *MockReviewRepository) CommandEnvironment(ctx context.Context, id forge.ChangeID) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CommandEnvironment", ctx, id) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CommandEnvironment indicates an expected call of CommandEnvironment. +func (mr *MockReviewRepositoryMockRecorder) CommandEnvironment(ctx, id any) *MockReviewRepositoryCommandEnvironmentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommandEnvironment", reflect.TypeOf((*MockReviewRepository)(nil).CommandEnvironment), ctx, id) + return &MockReviewRepositoryCommandEnvironmentCall{Call: call} +} + +// MockReviewRepositoryCommandEnvironmentCall wrap *gomock.Call +type MockReviewRepositoryCommandEnvironmentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryCommandEnvironmentCall) Return(arg0 map[string]string, arg1 error) *MockReviewRepositoryCommandEnvironmentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryCommandEnvironmentCall) Do(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockReviewRepositoryCommandEnvironmentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryCommandEnvironmentCall) DoAndReturn(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockReviewRepositoryCommandEnvironmentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommentCountsByChange mocks base method. +func (m *MockReviewRepository) CommentCountsByChange(ctx context.Context, ids []forge.ChangeID) ([]*forge.CommentCounts, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CommentCountsByChange", ctx, ids) + ret0, _ := ret[0].([]*forge.CommentCounts) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CommentCountsByChange indicates an expected call of CommentCountsByChange. +func (mr *MockReviewRepositoryMockRecorder) CommentCountsByChange(ctx, ids any) *MockReviewRepositoryCommentCountsByChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommentCountsByChange", reflect.TypeOf((*MockReviewRepository)(nil).CommentCountsByChange), ctx, ids) + return &MockReviewRepositoryCommentCountsByChangeCall{Call: call} +} + +// MockReviewRepositoryCommentCountsByChangeCall wrap *gomock.Call +type MockReviewRepositoryCommentCountsByChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryCommentCountsByChangeCall) Return(arg0 []*forge.CommentCounts, arg1 error) *MockReviewRepositoryCommentCountsByChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryCommentCountsByChangeCall) Do(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockReviewRepositoryCommentCountsByChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryCommentCountsByChangeCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockReviewRepositoryCommentCountsByChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteChangeComment mocks base method. +func (m *MockReviewRepository) DeleteChangeComment(arg0 context.Context, arg1 forge.ChangeCommentID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChangeComment", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChangeComment indicates an expected call of DeleteChangeComment. +func (mr *MockReviewRepositoryMockRecorder) DeleteChangeComment(arg0, arg1 any) *MockReviewRepositoryDeleteChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChangeComment", reflect.TypeOf((*MockReviewRepository)(nil).DeleteChangeComment), arg0, arg1) + return &MockReviewRepositoryDeleteChangeCommentCall{Call: call} +} + +// MockReviewRepositoryDeleteChangeCommentCall wrap *gomock.Call +type MockReviewRepositoryDeleteChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryDeleteChangeCommentCall) Return(arg0 error) *MockReviewRepositoryDeleteChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryDeleteChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID) error) *MockReviewRepositoryDeleteChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryDeleteChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID) error) *MockReviewRepositoryDeleteChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditChange mocks base method. +func (m *MockReviewRepository) EditChange(ctx context.Context, id forge.ChangeID, opts forge.EditChangeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditChange", ctx, id, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// EditChange indicates an expected call of EditChange. +func (mr *MockReviewRepositoryMockRecorder) EditChange(ctx, id, opts any) *MockReviewRepositoryEditChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditChange", reflect.TypeOf((*MockReviewRepository)(nil).EditChange), ctx, id, opts) + return &MockReviewRepositoryEditChangeCall{Call: call} +} + +// MockReviewRepositoryEditChangeCall wrap *gomock.Call +type MockReviewRepositoryEditChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryEditChangeCall) Return(arg0 error) *MockReviewRepositoryEditChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryEditChangeCall) Do(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockReviewRepositoryEditChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryEditChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockReviewRepositoryEditChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangeByID mocks base method. +func (m *MockReviewRepository) FindChangeByID(ctx context.Context, id forge.ChangeID) (*forge.FindChangeItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindChangeByID", ctx, id) + ret0, _ := ret[0].(*forge.FindChangeItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindChangeByID indicates an expected call of FindChangeByID. +func (mr *MockReviewRepositoryMockRecorder) FindChangeByID(ctx, id any) *MockReviewRepositoryFindChangeByIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangeByID", reflect.TypeOf((*MockReviewRepository)(nil).FindChangeByID), ctx, id) + return &MockReviewRepositoryFindChangeByIDCall{Call: call} +} + +// MockReviewRepositoryFindChangeByIDCall wrap *gomock.Call +type MockReviewRepositoryFindChangeByIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryFindChangeByIDCall) Return(arg0 *forge.FindChangeItem, arg1 error) *MockReviewRepositoryFindChangeByIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryFindChangeByIDCall) Do(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockReviewRepositoryFindChangeByIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryFindChangeByIDCall) DoAndReturn(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockReviewRepositoryFindChangeByIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangesByBranch mocks base method. +func (m *MockReviewRepository) FindChangesByBranch(ctx context.Context, branch string, opts forge.FindChangesOptions) ([]*forge.FindChangeItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindChangesByBranch", ctx, branch, opts) + ret0, _ := ret[0].([]*forge.FindChangeItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindChangesByBranch indicates an expected call of FindChangesByBranch. +func (mr *MockReviewRepositoryMockRecorder) FindChangesByBranch(ctx, branch, opts any) *MockReviewRepositoryFindChangesByBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangesByBranch", reflect.TypeOf((*MockReviewRepository)(nil).FindChangesByBranch), ctx, branch, opts) + return &MockReviewRepositoryFindChangesByBranchCall{Call: call} +} + +// MockReviewRepositoryFindChangesByBranchCall wrap *gomock.Call +type MockReviewRepositoryFindChangesByBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryFindChangesByBranchCall) Return(arg0 []*forge.FindChangeItem, arg1 error) *MockReviewRepositoryFindChangesByBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryFindChangesByBranchCall) Do(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockReviewRepositoryFindChangesByBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryFindChangesByBranchCall) DoAndReturn(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockReviewRepositoryFindChangesByBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Forge mocks base method. +func (m *MockReviewRepository) Forge() forge.Forge { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Forge") + ret0, _ := ret[0].(forge.Forge) + return ret0 +} + +// Forge indicates an expected call of Forge. +func (mr *MockReviewRepositoryMockRecorder) Forge() *MockReviewRepositoryForgeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forge", reflect.TypeOf((*MockReviewRepository)(nil).Forge)) + return &MockReviewRepositoryForgeCall{Call: call} +} + +// MockReviewRepositoryForgeCall wrap *gomock.Call +type MockReviewRepositoryForgeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryForgeCall) Return(arg0 forge.Forge) *MockReviewRepositoryForgeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryForgeCall) Do(f func() forge.Forge) *MockReviewRepositoryForgeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryForgeCall) DoAndReturn(f func() forge.Forge) *MockReviewRepositoryForgeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeComments mocks base method. +func (m *MockReviewRepository) ListChangeComments(arg0 context.Context, arg1 forge.ChangeID, arg2 *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChangeComments", arg0, arg1, arg2) + ret0, _ := ret[0].(iter.Seq2[*forge.ListChangeCommentItem, error]) + return ret0 +} + +// ListChangeComments indicates an expected call of ListChangeComments. +func (mr *MockReviewRepositoryMockRecorder) ListChangeComments(arg0, arg1, arg2 any) *MockReviewRepositoryListChangeCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeComments", reflect.TypeOf((*MockReviewRepository)(nil).ListChangeComments), arg0, arg1, arg2) + return &MockReviewRepositoryListChangeCommentsCall{Call: call} +} + +// MockReviewRepositoryListChangeCommentsCall wrap *gomock.Call +type MockReviewRepositoryListChangeCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryListChangeCommentsCall) Return(arg0 iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewRepositoryListChangeCommentsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryListChangeCommentsCall) Do(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewRepositoryListChangeCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryListChangeCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewRepositoryListChangeCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeTemplates mocks base method. +func (m *MockReviewRepository) ListChangeTemplates(arg0 context.Context) ([]*forge.ChangeTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChangeTemplates", arg0) + ret0, _ := ret[0].([]*forge.ChangeTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListChangeTemplates indicates an expected call of ListChangeTemplates. +func (mr *MockReviewRepositoryMockRecorder) ListChangeTemplates(arg0 any) *MockReviewRepositoryListChangeTemplatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeTemplates", reflect.TypeOf((*MockReviewRepository)(nil).ListChangeTemplates), arg0) + return &MockReviewRepositoryListChangeTemplatesCall{Call: call} +} + +// MockReviewRepositoryListChangeTemplatesCall wrap *gomock.Call +type MockReviewRepositoryListChangeTemplatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryListChangeTemplatesCall) Return(arg0 []*forge.ChangeTemplate, arg1 error) *MockReviewRepositoryListChangeTemplatesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryListChangeTemplatesCall) Do(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockReviewRepositoryListChangeTemplatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryListChangeTemplatesCall) DoAndReturn(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockReviewRepositoryListChangeTemplatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListReviewThreads mocks base method. +func (m *MockReviewRepository) ListReviewThreads(arg0 context.Context, arg1 forge.ChangeID) iter.Seq2[*forge.ReviewThread, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewThreads", arg0, arg1) + ret0, _ := ret[0].(iter.Seq2[*forge.ReviewThread, error]) + return ret0 +} + +// ListReviewThreads indicates an expected call of ListReviewThreads. +func (mr *MockReviewRepositoryMockRecorder) ListReviewThreads(arg0, arg1 any) *MockReviewRepositoryListReviewThreadsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewThreads", reflect.TypeOf((*MockReviewRepository)(nil).ListReviewThreads), arg0, arg1) + return &MockReviewRepositoryListReviewThreadsCall{Call: call} +} + +// MockReviewRepositoryListReviewThreadsCall wrap *gomock.Call +type MockReviewRepositoryListReviewThreadsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryListReviewThreadsCall) Return(arg0 iter.Seq2[*forge.ReviewThread, error]) *MockReviewRepositoryListReviewThreadsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryListReviewThreadsCall) Do(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewThread, error]) *MockReviewRepositoryListReviewThreadsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryListReviewThreadsCall) DoAndReturn(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewThread, error]) *MockReviewRepositoryListReviewThreadsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListReviewerStates mocks base method. +func (m *MockReviewRepository) ListReviewerStates(arg0 context.Context, arg1 forge.ChangeID) iter.Seq2[*forge.ReviewerState, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewerStates", arg0, arg1) + ret0, _ := ret[0].(iter.Seq2[*forge.ReviewerState, error]) + return ret0 +} + +// ListReviewerStates indicates an expected call of ListReviewerStates. +func (mr *MockReviewRepositoryMockRecorder) ListReviewerStates(arg0, arg1 any) *MockReviewRepositoryListReviewerStatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewerStates", reflect.TypeOf((*MockReviewRepository)(nil).ListReviewerStates), arg0, arg1) + return &MockReviewRepositoryListReviewerStatesCall{Call: call} +} + +// MockReviewRepositoryListReviewerStatesCall wrap *gomock.Call +type MockReviewRepositoryListReviewerStatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryListReviewerStatesCall) Return(arg0 iter.Seq2[*forge.ReviewerState, error]) *MockReviewRepositoryListReviewerStatesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryListReviewerStatesCall) Do(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewerState, error]) *MockReviewRepositoryListReviewerStatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryListReviewerStatesCall) DoAndReturn(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewerState, error]) *MockReviewRepositoryListReviewerStatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MergeChange mocks base method. +func (m *MockReviewRepository) MergeChange(ctx context.Context, id forge.ChangeID, opts forge.MergeChangeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MergeChange", ctx, id, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// MergeChange indicates an expected call of MergeChange. +func (mr *MockReviewRepositoryMockRecorder) MergeChange(ctx, id, opts any) *MockReviewRepositoryMergeChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeChange", reflect.TypeOf((*MockReviewRepository)(nil).MergeChange), ctx, id, opts) + return &MockReviewRepositoryMergeChangeCall{Call: call} +} + +// MockReviewRepositoryMergeChangeCall wrap *gomock.Call +type MockReviewRepositoryMergeChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryMergeChangeCall) Return(arg0 error) *MockReviewRepositoryMergeChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryMergeChangeCall) Do(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockReviewRepositoryMergeChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryMergeChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockReviewRepositoryMergeChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewChangeMetadata mocks base method. +func (m *MockReviewRepository) NewChangeMetadata(ctx context.Context, id forge.ChangeID) (forge.ChangeMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewChangeMetadata", ctx, id) + ret0, _ := ret[0].(forge.ChangeMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewChangeMetadata indicates an expected call of NewChangeMetadata. +func (mr *MockReviewRepositoryMockRecorder) NewChangeMetadata(ctx, id any) *MockReviewRepositoryNewChangeMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewChangeMetadata", reflect.TypeOf((*MockReviewRepository)(nil).NewChangeMetadata), ctx, id) + return &MockReviewRepositoryNewChangeMetadataCall{Call: call} +} + +// MockReviewRepositoryNewChangeMetadataCall wrap *gomock.Call +type MockReviewRepositoryNewChangeMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryNewChangeMetadataCall) Return(arg0 forge.ChangeMetadata, arg1 error) *MockReviewRepositoryNewChangeMetadataCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryNewChangeMetadataCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockReviewRepositoryNewChangeMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryNewChangeMetadataCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockReviewRepositoryNewChangeMetadataCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostChangeComment mocks base method. +func (m *MockReviewRepository) PostChangeComment(arg0 context.Context, arg1 forge.ChangeID, arg2 string) (forge.ChangeCommentID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostChangeComment", arg0, arg1, arg2) + ret0, _ := ret[0].(forge.ChangeCommentID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostChangeComment indicates an expected call of PostChangeComment. +func (mr *MockReviewRepositoryMockRecorder) PostChangeComment(arg0, arg1, arg2 any) *MockReviewRepositoryPostChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostChangeComment", reflect.TypeOf((*MockReviewRepository)(nil).PostChangeComment), arg0, arg1, arg2) + return &MockReviewRepositoryPostChangeCommentCall{Call: call} +} + +// MockReviewRepositoryPostChangeCommentCall wrap *gomock.Call +type MockReviewRepositoryPostChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryPostChangeCommentCall) Return(arg0 forge.ChangeCommentID, arg1 error) *MockReviewRepositoryPostChangeCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryPostChangeCommentCall) Do(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockReviewRepositoryPostChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryPostChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockReviewRepositoryPostChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitChange mocks base method. +func (m *MockReviewRepository) SubmitChange(ctx context.Context, req forge.SubmitChangeRequest) (forge.SubmitChangeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitChange", ctx, req) + ret0, _ := ret[0].(forge.SubmitChangeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitChange indicates an expected call of SubmitChange. +func (mr *MockReviewRepositoryMockRecorder) SubmitChange(ctx, req any) *MockReviewRepositorySubmitChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitChange", reflect.TypeOf((*MockReviewRepository)(nil).SubmitChange), ctx, req) + return &MockReviewRepositorySubmitChangeCall{Call: call} +} + +// MockReviewRepositorySubmitChangeCall wrap *gomock.Call +type MockReviewRepositorySubmitChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositorySubmitChangeCall) Return(arg0 forge.SubmitChangeResult, arg1 error) *MockReviewRepositorySubmitChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositorySubmitChangeCall) Do(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockReviewRepositorySubmitChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositorySubmitChangeCall) DoAndReturn(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockReviewRepositorySubmitChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitReview mocks base method. +func (m *MockReviewRepository) SubmitReview(arg0 context.Context, arg1 forge.ChangeID, arg2 forge.SubmitReviewRequest) (forge.SubmitReviewResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitReview", arg0, arg1, arg2) + ret0, _ := ret[0].(forge.SubmitReviewResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitReview indicates an expected call of SubmitReview. +func (mr *MockReviewRepositoryMockRecorder) SubmitReview(arg0, arg1, arg2 any) *MockReviewRepositorySubmitReviewCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitReview", reflect.TypeOf((*MockReviewRepository)(nil).SubmitReview), arg0, arg1, arg2) + return &MockReviewRepositorySubmitReviewCall{Call: call} +} + +// MockReviewRepositorySubmitReviewCall wrap *gomock.Call +type MockReviewRepositorySubmitReviewCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositorySubmitReviewCall) Return(arg0 forge.SubmitReviewResult, arg1 error) *MockReviewRepositorySubmitReviewCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositorySubmitReviewCall) Do(f func(context.Context, forge.ChangeID, forge.SubmitReviewRequest) (forge.SubmitReviewResult, error)) *MockReviewRepositorySubmitReviewCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositorySubmitReviewCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.SubmitReviewRequest) (forge.SubmitReviewResult, error)) *MockReviewRepositorySubmitReviewCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateChangeComment mocks base method. +func (m *MockReviewRepository) UpdateChangeComment(arg0 context.Context, arg1 forge.ChangeCommentID, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChangeComment", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChangeComment indicates an expected call of UpdateChangeComment. +func (mr *MockReviewRepositoryMockRecorder) UpdateChangeComment(arg0, arg1, arg2 any) *MockReviewRepositoryUpdateChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChangeComment", reflect.TypeOf((*MockReviewRepository)(nil).UpdateChangeComment), arg0, arg1, arg2) + return &MockReviewRepositoryUpdateChangeCommentCall{Call: call} +} + +// MockReviewRepositoryUpdateChangeCommentCall wrap *gomock.Call +type MockReviewRepositoryUpdateChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewRepositoryUpdateChangeCommentCall) Return(arg0 error) *MockReviewRepositoryUpdateChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewRepositoryUpdateChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockReviewRepositoryUpdateChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewRepositoryUpdateChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockReviewRepositoryUpdateChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockReviewThreadResolver is a mock of ReviewThreadResolver interface. +type MockReviewThreadResolver struct { + ctrl *gomock.Controller + recorder *MockReviewThreadResolverMockRecorder + isgomock struct{} +} + +// MockReviewThreadResolverMockRecorder is the mock recorder for MockReviewThreadResolver. +type MockReviewThreadResolverMockRecorder struct { + mock *MockReviewThreadResolver +} + +// NewMockReviewThreadResolver creates a new mock instance. +func NewMockReviewThreadResolver(ctrl *gomock.Controller) *MockReviewThreadResolver { + mock := &MockReviewThreadResolver{ctrl: ctrl} + mock.recorder = &MockReviewThreadResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReviewThreadResolver) EXPECT() *MockReviewThreadResolverMockRecorder { + return m.recorder +} + +// ChangeChecks mocks base method. +func (m *MockReviewThreadResolver) ChangeChecks(ctx context.Context, id forge.ChangeID) ([]forge.ChangeCheck, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeChecks", ctx, id) + ret0, _ := ret[0].([]forge.ChangeCheck) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeChecks indicates an expected call of ChangeChecks. +func (mr *MockReviewThreadResolverMockRecorder) ChangeChecks(ctx, id any) *MockReviewThreadResolverChangeChecksCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecks", reflect.TypeOf((*MockReviewThreadResolver)(nil).ChangeChecks), ctx, id) + return &MockReviewThreadResolverChangeChecksCall{Call: call} +} + +// MockReviewThreadResolverChangeChecksCall wrap *gomock.Call +type MockReviewThreadResolverChangeChecksCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverChangeChecksCall) Return(arg0 []forge.ChangeCheck, arg1 error) *MockReviewThreadResolverChangeChecksCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverChangeChecksCall) Do(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockReviewThreadResolverChangeChecksCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverChangeChecksCall) DoAndReturn(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockReviewThreadResolverChangeChecksCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeMergeability mocks base method. +func (m *MockReviewThreadResolver) ChangeMergeability(ctx context.Context, id forge.ChangeID) (forge.ChangeMergeability, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeMergeability", ctx, id) + ret0, _ := ret[0].(forge.ChangeMergeability) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeMergeability indicates an expected call of ChangeMergeability. +func (mr *MockReviewThreadResolverMockRecorder) ChangeMergeability(ctx, id any) *MockReviewThreadResolverChangeMergeabilityCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeMergeability", reflect.TypeOf((*MockReviewThreadResolver)(nil).ChangeMergeability), ctx, id) + return &MockReviewThreadResolverChangeMergeabilityCall{Call: call} +} + +// MockReviewThreadResolverChangeMergeabilityCall wrap *gomock.Call +type MockReviewThreadResolverChangeMergeabilityCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverChangeMergeabilityCall) Return(arg0 forge.ChangeMergeability, arg1 error) *MockReviewThreadResolverChangeMergeabilityCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverChangeMergeabilityCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockReviewThreadResolverChangeMergeabilityCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverChangeMergeabilityCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockReviewThreadResolverChangeMergeabilityCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeStatuses mocks base method. +func (m *MockReviewThreadResolver) ChangeStatuses(ctx context.Context, ids []forge.ChangeID) ([]forge.ChangeStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeStatuses", ctx, ids) + ret0, _ := ret[0].([]forge.ChangeStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeStatuses indicates an expected call of ChangeStatuses. +func (mr *MockReviewThreadResolverMockRecorder) ChangeStatuses(ctx, ids any) *MockReviewThreadResolverChangeStatusesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeStatuses", reflect.TypeOf((*MockReviewThreadResolver)(nil).ChangeStatuses), ctx, ids) + return &MockReviewThreadResolverChangeStatusesCall{Call: call} +} + +// MockReviewThreadResolverChangeStatusesCall wrap *gomock.Call +type MockReviewThreadResolverChangeStatusesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverChangeStatusesCall) Return(arg0 []forge.ChangeStatus, arg1 error) *MockReviewThreadResolverChangeStatusesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverChangeStatusesCall) Do(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockReviewThreadResolverChangeStatusesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverChangeStatusesCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockReviewThreadResolverChangeStatusesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommandEnvironment mocks base method. +func (m *MockReviewThreadResolver) CommandEnvironment(ctx context.Context, id forge.ChangeID) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CommandEnvironment", ctx, id) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CommandEnvironment indicates an expected call of CommandEnvironment. +func (mr *MockReviewThreadResolverMockRecorder) CommandEnvironment(ctx, id any) *MockReviewThreadResolverCommandEnvironmentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommandEnvironment", reflect.TypeOf((*MockReviewThreadResolver)(nil).CommandEnvironment), ctx, id) + return &MockReviewThreadResolverCommandEnvironmentCall{Call: call} +} + +// MockReviewThreadResolverCommandEnvironmentCall wrap *gomock.Call +type MockReviewThreadResolverCommandEnvironmentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverCommandEnvironmentCall) Return(arg0 map[string]string, arg1 error) *MockReviewThreadResolverCommandEnvironmentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverCommandEnvironmentCall) Do(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockReviewThreadResolverCommandEnvironmentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverCommandEnvironmentCall) DoAndReturn(f func(context.Context, forge.ChangeID) (map[string]string, error)) *MockReviewThreadResolverCommandEnvironmentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommentCountsByChange mocks base method. +func (m *MockReviewThreadResolver) CommentCountsByChange(ctx context.Context, ids []forge.ChangeID) ([]*forge.CommentCounts, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CommentCountsByChange", ctx, ids) + ret0, _ := ret[0].([]*forge.CommentCounts) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CommentCountsByChange indicates an expected call of CommentCountsByChange. +func (mr *MockReviewThreadResolverMockRecorder) CommentCountsByChange(ctx, ids any) *MockReviewThreadResolverCommentCountsByChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommentCountsByChange", reflect.TypeOf((*MockReviewThreadResolver)(nil).CommentCountsByChange), ctx, ids) + return &MockReviewThreadResolverCommentCountsByChangeCall{Call: call} +} + +// MockReviewThreadResolverCommentCountsByChangeCall wrap *gomock.Call +type MockReviewThreadResolverCommentCountsByChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverCommentCountsByChangeCall) Return(arg0 []*forge.CommentCounts, arg1 error) *MockReviewThreadResolverCommentCountsByChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverCommentCountsByChangeCall) Do(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockReviewThreadResolverCommentCountsByChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverCommentCountsByChangeCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockReviewThreadResolverCommentCountsByChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteChangeComment mocks base method. +func (m *MockReviewThreadResolver) DeleteChangeComment(arg0 context.Context, arg1 forge.ChangeCommentID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteChangeComment", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteChangeComment indicates an expected call of DeleteChangeComment. +func (mr *MockReviewThreadResolverMockRecorder) DeleteChangeComment(arg0, arg1 any) *MockReviewThreadResolverDeleteChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChangeComment", reflect.TypeOf((*MockReviewThreadResolver)(nil).DeleteChangeComment), arg0, arg1) + return &MockReviewThreadResolverDeleteChangeCommentCall{Call: call} +} + +// MockReviewThreadResolverDeleteChangeCommentCall wrap *gomock.Call +type MockReviewThreadResolverDeleteChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverDeleteChangeCommentCall) Return(arg0 error) *MockReviewThreadResolverDeleteChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverDeleteChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID) error) *MockReviewThreadResolverDeleteChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverDeleteChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID) error) *MockReviewThreadResolverDeleteChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditChange mocks base method. +func (m *MockReviewThreadResolver) EditChange(ctx context.Context, id forge.ChangeID, opts forge.EditChangeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditChange", ctx, id, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// EditChange indicates an expected call of EditChange. +func (mr *MockReviewThreadResolverMockRecorder) EditChange(ctx, id, opts any) *MockReviewThreadResolverEditChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditChange", reflect.TypeOf((*MockReviewThreadResolver)(nil).EditChange), ctx, id, opts) + return &MockReviewThreadResolverEditChangeCall{Call: call} +} + +// MockReviewThreadResolverEditChangeCall wrap *gomock.Call +type MockReviewThreadResolverEditChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverEditChangeCall) Return(arg0 error) *MockReviewThreadResolverEditChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverEditChangeCall) Do(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockReviewThreadResolverEditChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverEditChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockReviewThreadResolverEditChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangeByID mocks base method. +func (m *MockReviewThreadResolver) FindChangeByID(ctx context.Context, id forge.ChangeID) (*forge.FindChangeItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindChangeByID", ctx, id) + ret0, _ := ret[0].(*forge.FindChangeItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindChangeByID indicates an expected call of FindChangeByID. +func (mr *MockReviewThreadResolverMockRecorder) FindChangeByID(ctx, id any) *MockReviewThreadResolverFindChangeByIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangeByID", reflect.TypeOf((*MockReviewThreadResolver)(nil).FindChangeByID), ctx, id) + return &MockReviewThreadResolverFindChangeByIDCall{Call: call} +} + +// MockReviewThreadResolverFindChangeByIDCall wrap *gomock.Call +type MockReviewThreadResolverFindChangeByIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverFindChangeByIDCall) Return(arg0 *forge.FindChangeItem, arg1 error) *MockReviewThreadResolverFindChangeByIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverFindChangeByIDCall) Do(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockReviewThreadResolverFindChangeByIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverFindChangeByIDCall) DoAndReturn(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockReviewThreadResolverFindChangeByIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangesByBranch mocks base method. +func (m *MockReviewThreadResolver) FindChangesByBranch(ctx context.Context, branch string, opts forge.FindChangesOptions) ([]*forge.FindChangeItem, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindChangesByBranch", ctx, branch, opts) + ret0, _ := ret[0].([]*forge.FindChangeItem) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindChangesByBranch indicates an expected call of FindChangesByBranch. +func (mr *MockReviewThreadResolverMockRecorder) FindChangesByBranch(ctx, branch, opts any) *MockReviewThreadResolverFindChangesByBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangesByBranch", reflect.TypeOf((*MockReviewThreadResolver)(nil).FindChangesByBranch), ctx, branch, opts) + return &MockReviewThreadResolverFindChangesByBranchCall{Call: call} +} + +// MockReviewThreadResolverFindChangesByBranchCall wrap *gomock.Call +type MockReviewThreadResolverFindChangesByBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverFindChangesByBranchCall) Return(arg0 []*forge.FindChangeItem, arg1 error) *MockReviewThreadResolverFindChangesByBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverFindChangesByBranchCall) Do(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockReviewThreadResolverFindChangesByBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverFindChangesByBranchCall) DoAndReturn(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockReviewThreadResolverFindChangesByBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Forge mocks base method. +func (m *MockReviewThreadResolver) Forge() forge.Forge { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Forge") + ret0, _ := ret[0].(forge.Forge) + return ret0 +} + +// Forge indicates an expected call of Forge. +func (mr *MockReviewThreadResolverMockRecorder) Forge() *MockReviewThreadResolverForgeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forge", reflect.TypeOf((*MockReviewThreadResolver)(nil).Forge)) + return &MockReviewThreadResolverForgeCall{Call: call} +} + +// MockReviewThreadResolverForgeCall wrap *gomock.Call +type MockReviewThreadResolverForgeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverForgeCall) Return(arg0 forge.Forge) *MockReviewThreadResolverForgeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverForgeCall) Do(f func() forge.Forge) *MockReviewThreadResolverForgeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverForgeCall) DoAndReturn(f func() forge.Forge) *MockReviewThreadResolverForgeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeComments mocks base method. +func (m *MockReviewThreadResolver) ListChangeComments(arg0 context.Context, arg1 forge.ChangeID, arg2 *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChangeComments", arg0, arg1, arg2) + ret0, _ := ret[0].(iter.Seq2[*forge.ListChangeCommentItem, error]) + return ret0 +} + +// ListChangeComments indicates an expected call of ListChangeComments. +func (mr *MockReviewThreadResolverMockRecorder) ListChangeComments(arg0, arg1, arg2 any) *MockReviewThreadResolverListChangeCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeComments", reflect.TypeOf((*MockReviewThreadResolver)(nil).ListChangeComments), arg0, arg1, arg2) + return &MockReviewThreadResolverListChangeCommentsCall{Call: call} +} + +// MockReviewThreadResolverListChangeCommentsCall wrap *gomock.Call +type MockReviewThreadResolverListChangeCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverListChangeCommentsCall) Return(arg0 iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewThreadResolverListChangeCommentsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverListChangeCommentsCall) Do(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewThreadResolverListChangeCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverListChangeCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockReviewThreadResolverListChangeCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeTemplates mocks base method. +func (m *MockReviewThreadResolver) ListChangeTemplates(arg0 context.Context) ([]*forge.ChangeTemplate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListChangeTemplates", arg0) + ret0, _ := ret[0].([]*forge.ChangeTemplate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListChangeTemplates indicates an expected call of ListChangeTemplates. +func (mr *MockReviewThreadResolverMockRecorder) ListChangeTemplates(arg0 any) *MockReviewThreadResolverListChangeTemplatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeTemplates", reflect.TypeOf((*MockReviewThreadResolver)(nil).ListChangeTemplates), arg0) + return &MockReviewThreadResolverListChangeTemplatesCall{Call: call} +} + +// MockReviewThreadResolverListChangeTemplatesCall wrap *gomock.Call +type MockReviewThreadResolverListChangeTemplatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverListChangeTemplatesCall) Return(arg0 []*forge.ChangeTemplate, arg1 error) *MockReviewThreadResolverListChangeTemplatesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverListChangeTemplatesCall) Do(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockReviewThreadResolverListChangeTemplatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverListChangeTemplatesCall) DoAndReturn(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockReviewThreadResolverListChangeTemplatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListReviewThreads mocks base method. +func (m *MockReviewThreadResolver) ListReviewThreads(arg0 context.Context, arg1 forge.ChangeID) iter.Seq2[*forge.ReviewThread, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewThreads", arg0, arg1) + ret0, _ := ret[0].(iter.Seq2[*forge.ReviewThread, error]) + return ret0 +} + +// ListReviewThreads indicates an expected call of ListReviewThreads. +func (mr *MockReviewThreadResolverMockRecorder) ListReviewThreads(arg0, arg1 any) *MockReviewThreadResolverListReviewThreadsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewThreads", reflect.TypeOf((*MockReviewThreadResolver)(nil).ListReviewThreads), arg0, arg1) + return &MockReviewThreadResolverListReviewThreadsCall{Call: call} +} + +// MockReviewThreadResolverListReviewThreadsCall wrap *gomock.Call +type MockReviewThreadResolverListReviewThreadsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverListReviewThreadsCall) Return(arg0 iter.Seq2[*forge.ReviewThread, error]) *MockReviewThreadResolverListReviewThreadsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverListReviewThreadsCall) Do(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewThread, error]) *MockReviewThreadResolverListReviewThreadsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverListReviewThreadsCall) DoAndReturn(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewThread, error]) *MockReviewThreadResolverListReviewThreadsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListReviewerStates mocks base method. +func (m *MockReviewThreadResolver) ListReviewerStates(arg0 context.Context, arg1 forge.ChangeID) iter.Seq2[*forge.ReviewerState, error] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListReviewerStates", arg0, arg1) + ret0, _ := ret[0].(iter.Seq2[*forge.ReviewerState, error]) + return ret0 +} + +// ListReviewerStates indicates an expected call of ListReviewerStates. +func (mr *MockReviewThreadResolverMockRecorder) ListReviewerStates(arg0, arg1 any) *MockReviewThreadResolverListReviewerStatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListReviewerStates", reflect.TypeOf((*MockReviewThreadResolver)(nil).ListReviewerStates), arg0, arg1) + return &MockReviewThreadResolverListReviewerStatesCall{Call: call} +} + +// MockReviewThreadResolverListReviewerStatesCall wrap *gomock.Call +type MockReviewThreadResolverListReviewerStatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverListReviewerStatesCall) Return(arg0 iter.Seq2[*forge.ReviewerState, error]) *MockReviewThreadResolverListReviewerStatesCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverListReviewerStatesCall) Do(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewerState, error]) *MockReviewThreadResolverListReviewerStatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverListReviewerStatesCall) DoAndReturn(f func(context.Context, forge.ChangeID) iter.Seq2[*forge.ReviewerState, error]) *MockReviewThreadResolverListReviewerStatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MergeChange mocks base method. +func (m *MockReviewThreadResolver) MergeChange(ctx context.Context, id forge.ChangeID, opts forge.MergeChangeOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MergeChange", ctx, id, opts) + ret0, _ := ret[0].(error) + return ret0 +} + +// MergeChange indicates an expected call of MergeChange. +func (mr *MockReviewThreadResolverMockRecorder) MergeChange(ctx, id, opts any) *MockReviewThreadResolverMergeChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeChange", reflect.TypeOf((*MockReviewThreadResolver)(nil).MergeChange), ctx, id, opts) + return &MockReviewThreadResolverMergeChangeCall{Call: call} +} + +// MockReviewThreadResolverMergeChangeCall wrap *gomock.Call +type MockReviewThreadResolverMergeChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverMergeChangeCall) Return(arg0 error) *MockReviewThreadResolverMergeChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverMergeChangeCall) Do(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockReviewThreadResolverMergeChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverMergeChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockReviewThreadResolverMergeChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewChangeMetadata mocks base method. +func (m *MockReviewThreadResolver) NewChangeMetadata(ctx context.Context, id forge.ChangeID) (forge.ChangeMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewChangeMetadata", ctx, id) + ret0, _ := ret[0].(forge.ChangeMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// NewChangeMetadata indicates an expected call of NewChangeMetadata. +func (mr *MockReviewThreadResolverMockRecorder) NewChangeMetadata(ctx, id any) *MockReviewThreadResolverNewChangeMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewChangeMetadata", reflect.TypeOf((*MockReviewThreadResolver)(nil).NewChangeMetadata), ctx, id) + return &MockReviewThreadResolverNewChangeMetadataCall{Call: call} +} + +// MockReviewThreadResolverNewChangeMetadataCall wrap *gomock.Call +type MockReviewThreadResolverNewChangeMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverNewChangeMetadataCall) Return(arg0 forge.ChangeMetadata, arg1 error) *MockReviewThreadResolverNewChangeMetadataCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverNewChangeMetadataCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockReviewThreadResolverNewChangeMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverNewChangeMetadataCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockReviewThreadResolverNewChangeMetadataCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostChangeComment mocks base method. +func (m *MockReviewThreadResolver) PostChangeComment(arg0 context.Context, arg1 forge.ChangeID, arg2 string) (forge.ChangeCommentID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostChangeComment", arg0, arg1, arg2) + ret0, _ := ret[0].(forge.ChangeCommentID) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostChangeComment indicates an expected call of PostChangeComment. +func (mr *MockReviewThreadResolverMockRecorder) PostChangeComment(arg0, arg1, arg2 any) *MockReviewThreadResolverPostChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostChangeComment", reflect.TypeOf((*MockReviewThreadResolver)(nil).PostChangeComment), arg0, arg1, arg2) + return &MockReviewThreadResolverPostChangeCommentCall{Call: call} +} + +// MockReviewThreadResolverPostChangeCommentCall wrap *gomock.Call +type MockReviewThreadResolverPostChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverPostChangeCommentCall) Return(arg0 forge.ChangeCommentID, arg1 error) *MockReviewThreadResolverPostChangeCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverPostChangeCommentCall) Do(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockReviewThreadResolverPostChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverPostChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockReviewThreadResolverPostChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ResolveReviewThread mocks base method. +func (m *MockReviewThreadResolver) ResolveReviewThread(arg0 context.Context, arg1 forge.ReviewThreadID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveReviewThread", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResolveReviewThread indicates an expected call of ResolveReviewThread. +func (mr *MockReviewThreadResolverMockRecorder) ResolveReviewThread(arg0, arg1 any) *MockReviewThreadResolverResolveReviewThreadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveReviewThread", reflect.TypeOf((*MockReviewThreadResolver)(nil).ResolveReviewThread), arg0, arg1) + return &MockReviewThreadResolverResolveReviewThreadCall{Call: call} +} + +// MockReviewThreadResolverResolveReviewThreadCall wrap *gomock.Call +type MockReviewThreadResolverResolveReviewThreadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverResolveReviewThreadCall) Return(arg0 error) *MockReviewThreadResolverResolveReviewThreadCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverResolveReviewThreadCall) Do(f func(context.Context, forge.ReviewThreadID) error) *MockReviewThreadResolverResolveReviewThreadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverResolveReviewThreadCall) DoAndReturn(f func(context.Context, forge.ReviewThreadID) error) *MockReviewThreadResolverResolveReviewThreadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitChange mocks base method. +func (m *MockReviewThreadResolver) SubmitChange(ctx context.Context, req forge.SubmitChangeRequest) (forge.SubmitChangeResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitChange", ctx, req) + ret0, _ := ret[0].(forge.SubmitChangeResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitChange indicates an expected call of SubmitChange. +func (mr *MockReviewThreadResolverMockRecorder) SubmitChange(ctx, req any) *MockReviewThreadResolverSubmitChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitChange", reflect.TypeOf((*MockReviewThreadResolver)(nil).SubmitChange), ctx, req) + return &MockReviewThreadResolverSubmitChangeCall{Call: call} +} + +// MockReviewThreadResolverSubmitChangeCall wrap *gomock.Call +type MockReviewThreadResolverSubmitChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverSubmitChangeCall) Return(arg0 forge.SubmitChangeResult, arg1 error) *MockReviewThreadResolverSubmitChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverSubmitChangeCall) Do(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockReviewThreadResolverSubmitChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverSubmitChangeCall) DoAndReturn(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockReviewThreadResolverSubmitChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitReview mocks base method. +func (m *MockReviewThreadResolver) SubmitReview(arg0 context.Context, arg1 forge.ChangeID, arg2 forge.SubmitReviewRequest) (forge.SubmitReviewResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitReview", arg0, arg1, arg2) + ret0, _ := ret[0].(forge.SubmitReviewResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitReview indicates an expected call of SubmitReview. +func (mr *MockReviewThreadResolverMockRecorder) SubmitReview(arg0, arg1, arg2 any) *MockReviewThreadResolverSubmitReviewCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitReview", reflect.TypeOf((*MockReviewThreadResolver)(nil).SubmitReview), arg0, arg1, arg2) + return &MockReviewThreadResolverSubmitReviewCall{Call: call} +} + +// MockReviewThreadResolverSubmitReviewCall wrap *gomock.Call +type MockReviewThreadResolverSubmitReviewCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverSubmitReviewCall) Return(arg0 forge.SubmitReviewResult, arg1 error) *MockReviewThreadResolverSubmitReviewCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverSubmitReviewCall) Do(f func(context.Context, forge.ChangeID, forge.SubmitReviewRequest) (forge.SubmitReviewResult, error)) *MockReviewThreadResolverSubmitReviewCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverSubmitReviewCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.SubmitReviewRequest) (forge.SubmitReviewResult, error)) *MockReviewThreadResolverSubmitReviewCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UnresolveReviewThread mocks base method. +func (m *MockReviewThreadResolver) UnresolveReviewThread(arg0 context.Context, arg1 forge.ReviewThreadID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnresolveReviewThread", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnresolveReviewThread indicates an expected call of UnresolveReviewThread. +func (mr *MockReviewThreadResolverMockRecorder) UnresolveReviewThread(arg0, arg1 any) *MockReviewThreadResolverUnresolveReviewThreadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnresolveReviewThread", reflect.TypeOf((*MockReviewThreadResolver)(nil).UnresolveReviewThread), arg0, arg1) + return &MockReviewThreadResolverUnresolveReviewThreadCall{Call: call} +} + +// MockReviewThreadResolverUnresolveReviewThreadCall wrap *gomock.Call +type MockReviewThreadResolverUnresolveReviewThreadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverUnresolveReviewThreadCall) Return(arg0 error) *MockReviewThreadResolverUnresolveReviewThreadCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverUnresolveReviewThreadCall) Do(f func(context.Context, forge.ReviewThreadID) error) *MockReviewThreadResolverUnresolveReviewThreadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverUnresolveReviewThreadCall) DoAndReturn(f func(context.Context, forge.ReviewThreadID) error) *MockReviewThreadResolverUnresolveReviewThreadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateChangeComment mocks base method. +func (m *MockReviewThreadResolver) UpdateChangeComment(arg0 context.Context, arg1 forge.ChangeCommentID, arg2 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateChangeComment", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateChangeComment indicates an expected call of UpdateChangeComment. +func (mr *MockReviewThreadResolverMockRecorder) UpdateChangeComment(arg0, arg1, arg2 any) *MockReviewThreadResolverUpdateChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChangeComment", reflect.TypeOf((*MockReviewThreadResolver)(nil).UpdateChangeComment), arg0, arg1, arg2) + return &MockReviewThreadResolverUpdateChangeCommentCall{Call: call} +} + +// MockReviewThreadResolverUpdateChangeCommentCall wrap *gomock.Call +type MockReviewThreadResolverUpdateChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReviewThreadResolverUpdateChangeCommentCall) Return(arg0 error) *MockReviewThreadResolverUpdateChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReviewThreadResolverUpdateChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockReviewThreadResolverUpdateChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReviewThreadResolverUpdateChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockReviewThreadResolverUpdateChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/handler/review/handler.go b/internal/handler/review/handler.go new file mode 100644 index 000000000..e8a295d0c --- /dev/null +++ b/internal/handler/review/handler.go @@ -0,0 +1,81 @@ +// Package review coordinates review-comment command workflows. +package review + +import ( + "context" + "io" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/review" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/spice/state" +) + +// Anchor identifies a file or inclusive line range for a review comment. +type Anchor = review.Anchor + +// Draft is a local review comment waiting to be published. +type Draft = review.Draft + +// DraftID identifies a local review draft within one branch. +type DraftID = review.DraftID + +// Handler coordinates local and remote review-comment workflows. +type Handler struct { + Log *silog.Logger // required + Worktree Worktree // required + Service Service // required + Store Store // required + Repository forge.ReviewRepository // required + Editor CommentEditor // required +} + +// DraftHandler coordinates workflows that only access local drafts. +type DraftHandler struct { + Log *silog.Logger // required + Store Store // required + Editor CommentEditor // required +} + +// ThreadHandler coordinates review-thread resolution changes. +type ThreadHandler struct { + Log *silog.Logger // required + Service Service // required + Repository forge.ReviewRepository // required + Resolver forge.ReviewThreadResolver // required +} + +// Worktree provides the Git operations used by review workflows. +type Worktree interface { + OpenBranchDiff(context.Context, string, string) (io.ReadCloser, error) +} + +var _ Worktree = (*git.Worktree)(nil) + +// Service provides tracked-branch information used by review workflows. +type Service interface { + LookupBranch(context.Context, string) (*spice.LookupBranchResponse, error) +} + +var _ Service = (*spice.Service)(nil) + +// Store persists branch-local review drafts. +type Store interface { + AddReviewDraft(context.Context, string, review.Draft) (review.Draft, error) + LoadReviewDrafts(context.Context, string) ([]review.Draft, error) + UpdateReviewDraftBody(context.Context, string, review.DraftID, string) error + ClearReviewDrafts(context.Context, string) error +} + +var _ Store = (*state.Store)(nil) + +// CommentEditor opens a comment body for editing. +type CommentEditor func( + ctx context.Context, + initial string, +) (string, error) + +//go:generate mockgen -destination=mocks_test.go -package=review -typed . Worktree,Service,Store +//go:generate mockgen -destination=forge_mocks_test.go -package=review -typed go.abhg.dev/gs/internal/forge ReviewRepository,ReviewThreadResolver diff --git a/internal/handler/review/handler_test.go b/internal/handler/review/handler_test.go new file mode 100644 index 000000000..f608e9639 --- /dev/null +++ b/internal/handler/review/handler_test.go @@ -0,0 +1,458 @@ +package review + +import ( + "context" + "io" + "iter" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/forge" + reviewmodel "go.abhg.dev/gs/internal/review" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice" + "go.uber.org/mock/gomock" +) + +func TestDraftHandler_SaveCommentDraft(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + handler := &DraftHandler{ + Log: silog.Nop(), + Store: store, + Editor: func(context.Context, string) (string, error) { + t.Fatal("editor should not open when a message is supplied") + return "", nil + }, + } + anchor := reviewmodel.Anchor{Path: "review.go", StartLine: 3, EndLine: 3} + wantDraft := reviewmodel.Draft{ID: 0, Body: "Use a constant.", Anchor: anchor} + wantSavedDraft := wantDraft + wantSavedDraft.ID = 1 + + store. + EXPECT(). + AddReviewDraft(gomock.Any(), "feature", wantDraft). + Return(wantSavedDraft, nil) + + err := handler.SaveCommentDraft(t.Context(), &CommentRequest{ + Branch: "feature", + Anchor: anchor, + Message: "Use a constant.", + }) + require.NoError(t, err) +} + +func TestDraftHandler_SaveReplyDraft(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + handler := &DraftHandler{ + Log: silog.Nop(), + Store: store, + Editor: func(context.Context, string) (string, error) { + return "That makes sense.", nil + }, + } + wantDraft := reviewmodel.Draft{ + ID: 0, + Body: "That makes sense.", + ReplyTo: "thread-1", + } + wantSavedDraft := wantDraft + wantSavedDraft.ID = 2 + + store. + EXPECT(). + AddReviewDraft(gomock.Any(), "feature", wantDraft). + Return(wantSavedDraft, nil) + + err := handler.SaveReplyDraft(t.Context(), &ReplyRequest{ + Branch: "feature", + ThreadID: "thread-1", + }) + require.NoError(t, err) +} + +func TestHandler_PostComment(t *testing.T) { + ctrl := gomock.NewController(t) + worktree := NewMockWorktree(ctrl) + service := NewMockService(ctrl) + repository := NewMockReviewRepository(ctrl) + handler := &Handler{ + Log: silog.Nop(), + Worktree: worktree, + Service: service, + Store: NewMockStore(ctrl), + Repository: repository, + Editor: func(context.Context, string) (string, error) { + t.Fatal("editor should not open when a message is supplied") + return "", nil + }, + } + anchor := reviewmodel.Anchor{Path: "review.go", StartLine: 3, EndLine: 3} + + service. + EXPECT(). + LookupBranch(gomock.Any(), "feature"). + Return(&spice.LookupBranchResponse{ + Base: "main", + Change: &testChangeMetadata{id: testChangeID("42")}, + }, nil) + worktree. + EXPECT(). + OpenBranchDiff(gomock.Any(), "main", "feature"). + Return(io.NopCloser(strings.NewReader(`diff --git a/review.go b/review.go +--- a/review.go ++++ b/review.go +@@ -1,2 +1,3 @@ + package review ++const answer = 42 + func check() {} +`)), nil) + repository. + EXPECT(). + SubmitReview( + gomock.Any(), + testChangeID("42"), + forge.SubmitReviewRequest{ + Comments: []forge.SubmitReviewCommentRequest{ + { + Path: "review.go", + Range: forge.ReviewThreadLine(3), + Body: "Use a constant.", + Side: forge.ReviewThreadSideRight, + }, + }, + }, + ). + Return(forge.SubmitReviewResult{ + Comments: []forge.SubmitReviewCommentResult{ + {ThreadID: testThreadID("thread-1")}, + }, + }, nil) + + err := handler.PostComment(t.Context(), &CommentRequest{ + Branch: "feature", + Anchor: anchor, + Message: "Use a constant.", + }) + require.NoError(t, err) +} + +func TestHandler_PostReply(t *testing.T) { + ctrl := gomock.NewController(t) + service := NewMockService(ctrl) + repository := NewMockReviewRepository(ctrl) + handler := &Handler{ + Log: silog.Nop(), + Worktree: NewMockWorktree(ctrl), + Service: service, + Store: NewMockStore(ctrl), + Repository: repository, + Editor: func(context.Context, string) (string, error) { + return "Updated.", nil + }, + } + threadID := testThreadID("thread-1") + + service. + EXPECT(). + LookupBranch(gomock.Any(), "feature"). + Return(&spice.LookupBranchResponse{ + Change: &testChangeMetadata{id: testChangeID("42")}, + }, nil) + repository. + EXPECT(). + ListReviewThreads(gomock.Any(), testChangeID("42")). + Return(reviewThreadSeq(&forge.ReviewThread{ID: threadID})) + repository. + EXPECT(). + SubmitReview( + gomock.Any(), + testChangeID("42"), + forge.SubmitReviewRequest{ + Comments: []forge.SubmitReviewCommentRequest{ + {ReplyTo: threadID, Body: "Updated."}, + }, + }, + ). + Return(forge.SubmitReviewResult{ + Comments: []forge.SubmitReviewCommentResult{ + {ThreadID: threadID}, + }, + }, nil) + + err := handler.PostReply(t.Context(), &ReplyRequest{ + Branch: "feature", + ThreadID: "thread-1", + }) + require.NoError(t, err) +} + +func TestDraftHandler_ReplaceDraftBody(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + handler := &DraftHandler{ + Log: silog.Nop(), + Store: store, + Editor: func(context.Context, string) (string, error) { + t.Fatal("editor should not open when a message is supplied") + return "", nil + }, + } + anchor := reviewmodel.Anchor{Path: "review.go", StartLine: 3, EndLine: 3} + + store. + EXPECT(). + LoadReviewDrafts(gomock.Any(), "feature"). + Return([]Draft{ + {ID: 2, Body: "Old body.", Anchor: anchor}, + }, nil) + store. + EXPECT(). + UpdateReviewDraftBody(gomock.Any(), "feature", DraftID(2), "New body."). + Return(nil) + + err := handler.ReplaceDraftBody( + t.Context(), + &ReplaceDraftBodyRequest{ + Branch: "feature", + ID: 2, + Message: "New body.", + }, + ) + require.NoError(t, err) +} + +func TestHandler_LoadReviewData(t *testing.T) { + ctrl := gomock.NewController(t) + store := NewMockStore(ctrl) + service := NewMockService(ctrl) + repository := NewMockReviewRepository(ctrl) + handler := &Handler{ + Log: silog.Nop(), + Worktree: NewMockWorktree(ctrl), + Service: service, + Store: store, + Repository: repository, + Editor: nil, + } + anchor := reviewmodel.Anchor{Path: "review.go", StartLine: 3, EndLine: 3} + draft := reviewmodel.Draft{ + ID: 1, + Body: "Use a constant.", + Anchor: anchor, + } + resolved := false + thread := &forge.ReviewThread{ + ID: testThreadID("thread-1"), + Path: "review.go", + Range: forge.ReviewThreadLine(3), + Side: forge.ReviewThreadSideRight, + Resolved: &resolved, + Comments: []forge.ReviewComment{ + { + ID: testCommentID("comment-1"), + Body: "Published.", + Author: "reviewer", + }, + }, + } + + store. + EXPECT(). + LoadReviewDrafts(gomock.Any(), "feature"). + Return([]Draft{draft}, nil) + service. + EXPECT(). + LookupBranch(gomock.Any(), "feature"). + Return(&spice.LookupBranchResponse{ + Change: &testChangeMetadata{id: testChangeID("42")}, + }, nil) + repository. + EXPECT(). + ListReviewThreads(gomock.Any(), testChangeID("42")). + Return(reviewThreadSeq(thread)) + + got, err := handler.LoadReviewData(t.Context(), &LoadRequest{ + Branch: "feature", + }) + require.NoError(t, err) + assert.Equal(t, "feature", got.Branch) + assert.Equal(t, []Draft{draft}, got.Drafts) + assert.Equal(t, []ListedComment{ + {Thread: *thread, Comment: thread.Comments[0]}, + }, got.Comments) +} + +func TestHandler_PublishDrafts(t *testing.T) { + ctrl := gomock.NewController(t) + worktree := NewMockWorktree(ctrl) + store := NewMockStore(ctrl) + service := NewMockService(ctrl) + repository := NewMockReviewRepository(ctrl) + handler := &Handler{ + Log: silog.Nop(), + Worktree: worktree, + Service: service, + Store: store, + Repository: repository, + Editor: nil, + } + anchor := reviewmodel.Anchor{Path: "review.go", StartLine: 3, EndLine: 3} + drafts := []Draft{ + {ID: 1, Body: "Use a constant.", Anchor: anchor}, + {ID: 2, Body: "Updated.", ReplyTo: "thread-1"}, + } + threadID := testThreadID("thread-1") + + store. + EXPECT(). + LoadReviewDrafts(gomock.Any(), "feature"). + Return(drafts, nil) + service. + EXPECT(). + LookupBranch(gomock.Any(), "feature"). + Return(&spice.LookupBranchResponse{ + Base: "main", + Change: &testChangeMetadata{id: testChangeID("42")}, + }, nil) + worktree. + EXPECT(). + OpenBranchDiff(gomock.Any(), "main", "feature"). + Return(io.NopCloser(strings.NewReader(`diff --git a/review.go b/review.go +--- a/review.go ++++ b/review.go +@@ -1,2 +1,3 @@ + package review ++const answer = 42 + func check() {} +`)), nil) + repository. + EXPECT(). + ListReviewThreads(gomock.Any(), testChangeID("42")). + Return(reviewThreadSeq(&forge.ReviewThread{ID: threadID})) + repository. + EXPECT(). + SubmitReview( + gomock.Any(), + testChangeID("42"), + forge.SubmitReviewRequest{ + Body: "Review body.", + Disposition: forge.ReviewDispositionApprove, + Comments: []forge.SubmitReviewCommentRequest{ + { + Path: "review.go", + Range: forge.ReviewThreadLine(3), + Body: "Use a constant.", + Side: forge.ReviewThreadSideRight, + }, + {ReplyTo: threadID, Body: "Updated."}, + }, + }, + ). + Return(forge.SubmitReviewResult{}, nil) + store. + EXPECT(). + ClearReviewDrafts(gomock.Any(), "feature"). + Return(nil) + + err := handler.PublishDrafts(t.Context(), &PublishDraftsRequest{ + Branch: "feature", + Body: "Review body.", + Disposition: forge.ReviewDispositionApprove, + }) + require.NoError(t, err) +} + +func TestThreadHandler_SetThreadResolution(t *testing.T) { + ctrl := gomock.NewController(t) + service := NewMockService(ctrl) + repository := NewMockReviewRepository(ctrl) + resolver := NewMockReviewThreadResolver(ctrl) + handler := &ThreadHandler{ + Log: silog.Nop(), + Service: service, + Repository: repository, + Resolver: resolver, + } + threadID := testThreadID("thread-1") + + service. + EXPECT(). + LookupBranch(gomock.Any(), "feature"). + Return(&spice.LookupBranchResponse{ + Change: &testChangeMetadata{id: testChangeID("42")}, + }, nil) + repository. + EXPECT(). + ListReviewThreads(gomock.Any(), testChangeID("42")). + Return(reviewThreadSeq(&forge.ReviewThread{ID: threadID})) + resolver. + EXPECT(). + ResolveReviewThread(gomock.Any(), threadID). + Return(nil) + + err := handler.SetThreadResolution( + t.Context(), + &SetThreadResolutionRequest{ + Branch: "feature", + ThreadID: "thread-1", + Resolved: true, + }, + ) + require.NoError(t, err) +} + +func reviewThreadSeq( + threads ...*forge.ReviewThread, +) iter.Seq2[*forge.ReviewThread, error] { + return func(yield func(*forge.ReviewThread, error) bool) { + for _, thread := range threads { + if !yield(thread, nil) { + return + } + } + } +} + +type testChangeMetadata struct { + id forge.ChangeID + navigation forge.ChangeCommentID +} + +func (*testChangeMetadata) ForgeID() string { + return "test" +} + +func (m *testChangeMetadata) ChangeID() forge.ChangeID { + return m.id +} + +func (m *testChangeMetadata) NavigationCommentID() forge.ChangeCommentID { + return m.navigation +} + +func (m *testChangeMetadata) SetNavigationCommentID(id forge.ChangeCommentID) { + m.navigation = id +} + +type testChangeID string + +func (id testChangeID) String() string { + return string(id) +} + +type testThreadID string + +func (id testThreadID) String() string { + return string(id) +} + +type testCommentID string + +func (id testCommentID) String() string { + return string(id) +} diff --git a/internal/handler/review/list.go b/internal/handler/review/list.go new file mode 100644 index 000000000..98ed9b4df --- /dev/null +++ b/internal/handler/review/list.go @@ -0,0 +1,94 @@ +package review + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/spice/state" +) + +// LoadRequest selects review comments to load. +type LoadRequest struct { + // Branch identifies the reviewed branch. + Branch string // required + + // DraftOnly omits comments already published to the forge. + DraftOnly bool + + // Unresolved omits resolved forge threads. + Unresolved bool +} + +// LoadResult contains local drafts and published review comments. +type LoadResult struct { + // Branch is the branch selected by the request. + Branch string + + // Drafts are the branch's unpublished comments. + Drafts []Draft + + // Comments are published comments paired with their owning thread. + Comments []ListedComment +} + +// ListedComment pairs a review comment with its thread location and state. +type ListedComment struct { + // Thread owns the comment's location and resolution state. + Thread forge.ReviewThread + + // Comment is one entry in Thread. + Comment forge.ReviewComment +} + +// LoadReviewData loads local drafts and remote review comments for a branch. +func (h *Handler) LoadReviewData( + ctx context.Context, + req *LoadRequest, +) (*LoadResult, error) { + drafts, err := h.Store.LoadReviewDrafts(ctx, req.Branch) + if err != nil { + return nil, fmt.Errorf("load draft comments: %w", err) + } + result := &LoadResult{ + Branch: req.Branch, + Drafts: drafts, + } + if req.DraftOnly { + return result, nil + } + + change, err := h.Service.LookupBranch(ctx, req.Branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return nil, fmt.Errorf("branch not tracked: %s", req.Branch) + } + return nil, fmt.Errorf("get branch: %w", err) + } + if change.Change == nil { + h.Log.Infof("No change request found for %s.", req.Branch) + return result, nil + } + + // Keep each comment beside its thread while consuming the forge iterator. + // The presenter needs thread-level coordinates and state for every comment. + for thread, err := range h.Repository.ListReviewThreads( + ctx, + change.Change.ChangeID(), + ) { + if err != nil { + return nil, fmt.Errorf("list review threads: %w", err) + } + if req.Unresolved && thread.Resolved != nil && *thread.Resolved { + continue + } + for _, comment := range thread.Comments { + result.Comments = append(result.Comments, ListedComment{ + Thread: *thread, + Comment: comment, + }) + } + } + return result, nil +} diff --git a/internal/handler/review/mocks_test.go b/internal/handler/review/mocks_test.go new file mode 100644 index 000000000..d6751ca4d --- /dev/null +++ b/internal/handler/review/mocks_test.go @@ -0,0 +1,324 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: go.abhg.dev/gs/internal/handler/review (interfaces: Worktree,Service,Store) +// +// Generated by this command: +// +// mockgen -destination=mocks_test.go -package=review -typed . Worktree,Service,Store +// + +// Package review is a generated GoMock package. +package review + +import ( + context "context" + io "io" + reflect "reflect" + + review "go.abhg.dev/gs/internal/review" + spice "go.abhg.dev/gs/internal/spice" + gomock "go.uber.org/mock/gomock" +) + +// MockWorktree is a mock of Worktree interface. +type MockWorktree struct { + ctrl *gomock.Controller + recorder *MockWorktreeMockRecorder + isgomock struct{} +} + +// MockWorktreeMockRecorder is the mock recorder for MockWorktree. +type MockWorktreeMockRecorder struct { + mock *MockWorktree +} + +// NewMockWorktree creates a new mock instance. +func NewMockWorktree(ctrl *gomock.Controller) *MockWorktree { + mock := &MockWorktree{ctrl: ctrl} + mock.recorder = &MockWorktreeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorktree) EXPECT() *MockWorktreeMockRecorder { + return m.recorder +} + +// OpenBranchDiff mocks base method. +func (m *MockWorktree) OpenBranchDiff(arg0 context.Context, arg1, arg2 string) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OpenBranchDiff", arg0, arg1, arg2) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OpenBranchDiff indicates an expected call of OpenBranchDiff. +func (mr *MockWorktreeMockRecorder) OpenBranchDiff(arg0, arg1, arg2 any) *MockWorktreeOpenBranchDiffCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenBranchDiff", reflect.TypeOf((*MockWorktree)(nil).OpenBranchDiff), arg0, arg1, arg2) + return &MockWorktreeOpenBranchDiffCall{Call: call} +} + +// MockWorktreeOpenBranchDiffCall wrap *gomock.Call +type MockWorktreeOpenBranchDiffCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorktreeOpenBranchDiffCall) Return(arg0 io.ReadCloser, arg1 error) *MockWorktreeOpenBranchDiffCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorktreeOpenBranchDiffCall) Do(f func(context.Context, string, string) (io.ReadCloser, error)) *MockWorktreeOpenBranchDiffCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorktreeOpenBranchDiffCall) DoAndReturn(f func(context.Context, string, string) (io.ReadCloser, error)) *MockWorktreeOpenBranchDiffCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder + isgomock struct{} +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// LookupBranch mocks base method. +func (m *MockService) LookupBranch(arg0 context.Context, arg1 string) (*spice.LookupBranchResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LookupBranch", arg0, arg1) + ret0, _ := ret[0].(*spice.LookupBranchResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LookupBranch indicates an expected call of LookupBranch. +func (mr *MockServiceMockRecorder) LookupBranch(arg0, arg1 any) *MockServiceLookupBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LookupBranch", reflect.TypeOf((*MockService)(nil).LookupBranch), arg0, arg1) + return &MockServiceLookupBranchCall{Call: call} +} + +// MockServiceLookupBranchCall wrap *gomock.Call +type MockServiceLookupBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockServiceLookupBranchCall) Return(arg0 *spice.LookupBranchResponse, arg1 error) *MockServiceLookupBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockServiceLookupBranchCall) Do(f func(context.Context, string) (*spice.LookupBranchResponse, error)) *MockServiceLookupBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockServiceLookupBranchCall) DoAndReturn(f func(context.Context, string) (*spice.LookupBranchResponse, error)) *MockServiceLookupBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockStore is a mock of Store interface. +type MockStore struct { + ctrl *gomock.Controller + recorder *MockStoreMockRecorder + isgomock struct{} +} + +// MockStoreMockRecorder is the mock recorder for MockStore. +type MockStoreMockRecorder struct { + mock *MockStore +} + +// NewMockStore creates a new mock instance. +func NewMockStore(ctrl *gomock.Controller) *MockStore { + mock := &MockStore{ctrl: ctrl} + mock.recorder = &MockStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStore) EXPECT() *MockStoreMockRecorder { + return m.recorder +} + +// AddReviewDraft mocks base method. +func (m *MockStore) AddReviewDraft(arg0 context.Context, arg1 string, arg2 review.Draft) (review.Draft, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddReviewDraft", arg0, arg1, arg2) + ret0, _ := ret[0].(review.Draft) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddReviewDraft indicates an expected call of AddReviewDraft. +func (mr *MockStoreMockRecorder) AddReviewDraft(arg0, arg1, arg2 any) *MockStoreAddReviewDraftCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddReviewDraft", reflect.TypeOf((*MockStore)(nil).AddReviewDraft), arg0, arg1, arg2) + return &MockStoreAddReviewDraftCall{Call: call} +} + +// MockStoreAddReviewDraftCall wrap *gomock.Call +type MockStoreAddReviewDraftCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreAddReviewDraftCall) Return(arg0 review.Draft, arg1 error) *MockStoreAddReviewDraftCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreAddReviewDraftCall) Do(f func(context.Context, string, review.Draft) (review.Draft, error)) *MockStoreAddReviewDraftCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreAddReviewDraftCall) DoAndReturn(f func(context.Context, string, review.Draft) (review.Draft, error)) *MockStoreAddReviewDraftCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ClearReviewDrafts mocks base method. +func (m *MockStore) ClearReviewDrafts(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClearReviewDrafts", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// ClearReviewDrafts indicates an expected call of ClearReviewDrafts. +func (mr *MockStoreMockRecorder) ClearReviewDrafts(arg0, arg1 any) *MockStoreClearReviewDraftsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearReviewDrafts", reflect.TypeOf((*MockStore)(nil).ClearReviewDrafts), arg0, arg1) + return &MockStoreClearReviewDraftsCall{Call: call} +} + +// MockStoreClearReviewDraftsCall wrap *gomock.Call +type MockStoreClearReviewDraftsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreClearReviewDraftsCall) Return(arg0 error) *MockStoreClearReviewDraftsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreClearReviewDraftsCall) Do(f func(context.Context, string) error) *MockStoreClearReviewDraftsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreClearReviewDraftsCall) DoAndReturn(f func(context.Context, string) error) *MockStoreClearReviewDraftsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// LoadReviewDrafts mocks base method. +func (m *MockStore) LoadReviewDrafts(arg0 context.Context, arg1 string) ([]review.Draft, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadReviewDrafts", arg0, arg1) + ret0, _ := ret[0].([]review.Draft) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadReviewDrafts indicates an expected call of LoadReviewDrafts. +func (mr *MockStoreMockRecorder) LoadReviewDrafts(arg0, arg1 any) *MockStoreLoadReviewDraftsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadReviewDrafts", reflect.TypeOf((*MockStore)(nil).LoadReviewDrafts), arg0, arg1) + return &MockStoreLoadReviewDraftsCall{Call: call} +} + +// MockStoreLoadReviewDraftsCall wrap *gomock.Call +type MockStoreLoadReviewDraftsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreLoadReviewDraftsCall) Return(arg0 []review.Draft, arg1 error) *MockStoreLoadReviewDraftsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreLoadReviewDraftsCall) Do(f func(context.Context, string) ([]review.Draft, error)) *MockStoreLoadReviewDraftsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreLoadReviewDraftsCall) DoAndReturn(f func(context.Context, string) ([]review.Draft, error)) *MockStoreLoadReviewDraftsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateReviewDraftBody mocks base method. +func (m *MockStore) UpdateReviewDraftBody(arg0 context.Context, arg1 string, arg2 review.DraftID, arg3 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateReviewDraftBody", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateReviewDraftBody indicates an expected call of UpdateReviewDraftBody. +func (mr *MockStoreMockRecorder) UpdateReviewDraftBody(arg0, arg1, arg2, arg3 any) *MockStoreUpdateReviewDraftBodyCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateReviewDraftBody", reflect.TypeOf((*MockStore)(nil).UpdateReviewDraftBody), arg0, arg1, arg2, arg3) + return &MockStoreUpdateReviewDraftBodyCall{Call: call} +} + +// MockStoreUpdateReviewDraftBodyCall wrap *gomock.Call +type MockStoreUpdateReviewDraftBodyCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockStoreUpdateReviewDraftBodyCall) Return(arg0 error) *MockStoreUpdateReviewDraftBodyCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockStoreUpdateReviewDraftBodyCall) Do(f func(context.Context, string, review.DraftID, string) error) *MockStoreUpdateReviewDraftBodyCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockStoreUpdateReviewDraftBodyCall) DoAndReturn(f func(context.Context, string, review.DraftID, string) error) *MockStoreUpdateReviewDraftBodyCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/handler/review/publish.go b/internal/handler/review/publish.go new file mode 100644 index 000000000..bffa03b8e --- /dev/null +++ b/internal/handler/review/publish.go @@ -0,0 +1,170 @@ +package review + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/spice/state" +) + +// PublishDraftsRequest describes a review assembled from local drafts. +type PublishDraftsRequest struct { + // Branch identifies the branch whose drafts will be published. + Branch string // required + + // Body is the optional overall review body. + Body string + + // Disposition is the optional review outcome to publish. + Disposition forge.ReviewDisposition +} + +// PublishDrafts submits every local draft for a branch as one review. +func (h *Handler) PublishDrafts( + ctx context.Context, + req *PublishDraftsRequest, +) error { + drafts, err := h.Store.LoadReviewDrafts(ctx, req.Branch) + if err != nil { + return fmt.Errorf("load draft comments: %w", err) + } + if len(drafts) == 0 { + h.Log.Infof("No draft comments to publish.") + return nil + } + + change, err := h.Service.LookupBranch(ctx, req.Branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf("branch not tracked: %s", req.Branch) + } + return fmt.Errorf("get branch: %w", err) + } + if change.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + req.Branch, + ) + } + patch, err := h.loadPatch(ctx, change.Base, req.Branch) + if err != nil { + return err + } + threadIDs, err := resolveDraftThreadIDs( + ctx, + h.Repository, + change.Change.ChangeID(), + drafts, + ) + if err != nil { + return err + } + + comments := make([]forge.SubmitReviewCommentRequest, 0, len(drafts)) + for _, draft := range drafts { + if draft.ReplyTo != "" { + comments = append(comments, forge.SubmitReviewCommentRequest{ + Body: draft.Body, + ReplyTo: threadIDs[draft.ReplyTo], + }) + continue + } + + if !patch.ContainsLineRange( + draft.Anchor.Path, + draft.Anchor.StartLine, + draft.Anchor.EndLine, + ) { + return fmt.Errorf( + "draft %s: review diff does not contain %s", + draft.ID, + draft.Anchor, + ) + } + comments = append(comments, forge.SubmitReviewCommentRequest{ + Path: draft.Anchor.Path, + Range: forge.ReviewThreadRange{ + StartLine: draft.Anchor.StartLine, + EndLine: draft.Anchor.EndLine, + }, + Body: draft.Body, + Side: forge.ReviewThreadSideRight, + }) + } + + if _, err := h.Repository.SubmitReview( + ctx, + change.Change.ChangeID(), + forge.SubmitReviewRequest{ + Body: req.Body, + Disposition: req.Disposition, + Comments: comments, + }, + ); err != nil { + return fmt.Errorf("submit review: %w", err) + } + if err := h.Store.ClearReviewDrafts(ctx, req.Branch); err != nil { + return fmt.Errorf("clear draft comments: %w", err) + } + + h.Log.Infof( + "Published %d comment(s) as review on %s.", + len(comments), + change.Change.ChangeID(), + ) + return nil +} + +// resolveDraftThreadIDs recovers opaque forge IDs for every drafted reply. +// One traversal resolves all targets before the review is submitted. +func resolveDraftThreadIDs( + ctx context.Context, + repository forge.ReviewRepository, + changeID forge.ChangeID, + drafts []Draft, +) (map[string]forge.ReviewThreadID, error) { + wanted := make(map[string]struct{}) + for _, draft := range drafts { + if draft.ReplyTo != "" { + wanted[draft.ReplyTo] = struct{}{} + } + } + if len(wanted) == 0 { + return nil, nil + } + + resolved := make(map[string]forge.ReviewThreadID, len(wanted)) + // Drafts persist the String form of opaque ReviewThreadIDs. + // Resolve every reply target in one traversal before submission. + for thread, err := range repository.ListReviewThreads(ctx, changeID) { + if err != nil { + return nil, fmt.Errorf("list review threads: %w", err) + } + id := thread.ID.String() + if _, ok := wanted[id]; ok { + resolved[id] = thread.ID + delete(wanted, id) + } + } + if len(wanted) == 0 { + return resolved, nil + } + + for _, draft := range drafts { + if draft.ReplyTo == "" { + continue + } + if _, ok := wanted[draft.ReplyTo]; ok { + return nil, fmt.Errorf( + "draft %s: review thread %q not found", + draft.ID, + draft.ReplyTo, + ) + } + } + panic("unresolved thread ID was not sourced from a draft") +} diff --git a/internal/handler/review/resolution.go b/internal/handler/review/resolution.go new file mode 100644 index 000000000..099532422 --- /dev/null +++ b/internal/handler/review/resolution.go @@ -0,0 +1,76 @@ +package review + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/spice/state" +) + +// SetThreadResolutionRequest identifies a thread and its desired state. +type SetThreadResolutionRequest struct { + // Branch identifies the reviewed branch. + Branch string // required + + // ThreadID is the command-line representation of the forge thread ID. + ThreadID string // required + + // Resolved is the desired thread resolution state. + Resolved bool +} + +// SetThreadResolution changes whether a remote review thread is resolved. +func (h *ThreadHandler) SetThreadResolution( + ctx context.Context, + req *SetThreadResolutionRequest, +) error { + change, err := h.Service.LookupBranch(ctx, req.Branch) + if err != nil { + if errors.Is(err, state.ErrNotExist) { + return fmt.Errorf("branch not tracked: %s", req.Branch) + } + return fmt.Errorf("get branch: %w", err) + } + if change.Change == nil { + return fmt.Errorf( + "no change request for %s; "+ + "submit the branch first with "+ + "'gs branch submit'", + req.Branch, + ) + } + + // ReviewThreadID is opaque. + // Recover the forge-owned value whose String form the command accepted. + var threadID forge.ReviewThreadID + for thread, err := range h.Repository.ListReviewThreads( + ctx, + change.Change.ChangeID(), + ) { + if err != nil { + return fmt.Errorf("list review threads: %w", err) + } + if thread.ID.String() == req.ThreadID { + threadID = thread.ID + break + } + } + if threadID == nil { + return fmt.Errorf("review thread %q not found", req.ThreadID) + } + + if req.Resolved { + if err := h.Resolver.ResolveReviewThread(ctx, threadID); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + h.Log.Infof("Resolved thread %s.", req.ThreadID) + return nil + } + if err := h.Resolver.UnresolveReviewThread(ctx, threadID); err != nil { + return fmt.Errorf("reopen thread: %w", err) + } + h.Log.Infof("Reopened thread %s.", req.ThreadID) + return nil +} diff --git a/internal/review/anchor.go b/internal/review/anchor.go new file mode 100644 index 000000000..57c2171c0 --- /dev/null +++ b/internal/review/anchor.go @@ -0,0 +1,102 @@ +// Package review defines local review-comment domain values. +package review + +import ( + "encoding" + "errors" + "fmt" + "strconv" + "strings" +) + +// Anchor identifies a file or inclusive postimage line range. +type Anchor struct { + Path string // required + + // StartLine and EndLine identify an inclusive postimage line range. + // Both are zero for a file-level anchor. + StartLine int + EndLine int +} + +var _ encoding.TextUnmarshaler = (*Anchor)(nil) + +// UnmarshalText parses a file, file:line, or file:start-end anchor. +func (a *Anchor) UnmarshalText(src []byte) error { + value := string(src) + if value == "" { + return errors.New("comment anchor is required") + } + + // Split from the right so paths containing a colon remain intact. + path, lineSpec, hasLine := strings.CutLast(value, ":") + if !hasLine { + // Without a line suffix, the anchor applies to the whole file. + *a = Anchor{Path: value} + return nil + } + if path == "" { + return errors.New("comment anchor path is required") + } + + before, after, hasRange := strings.Cut(lineSpec, "-") + if !hasRange { + line, err := strconv.Atoi(lineSpec) + if err != nil { + return fmt.Errorf("invalid line number in %q: %w", value, err) + } + if line <= 0 { + return fmt.Errorf( + "invalid comment anchor %q: comment anchor lines must be positive", + value, + ) + } + // A single line uses the same inclusive representation as a range. + *a = Anchor{Path: path, StartLine: line, EndLine: line} + return nil + } + + start, err := strconv.Atoi(before) + if err != nil { + return fmt.Errorf("invalid range start in %q: %w", value, err) + } + end, err := strconv.Atoi(after) + if err != nil { + return fmt.Errorf("invalid range end in %q: %w", value, err) + } + if start <= 0 || end <= 0 { + return fmt.Errorf( + "invalid comment anchor %q: comment anchor lines must be positive", + value, + ) + } + if end < start { + return fmt.Errorf( + "invalid comment anchor %q: comment anchor end must not precede start", + value, + ) + } + *a = Anchor{Path: path, StartLine: start, EndLine: end} + return nil +} + +// IsFile reports whether the anchor identifies the whole file. +func (a Anchor) IsFile() bool { + return a.StartLine == 0 +} + +// IsLine reports whether the anchor identifies exactly one line. +func (a Anchor) IsLine() bool { + return a.StartLine > 0 && a.StartLine == a.EndLine +} + +// String returns the command-line representation of the anchor. +func (a Anchor) String() string { + if a.IsFile() { + return a.Path + } + if a.IsLine() { + return fmt.Sprintf("%s:%d", a.Path, a.StartLine) + } + return fmt.Sprintf("%s:%d-%d", a.Path, a.StartLine, a.EndLine) +} diff --git a/internal/review/anchor_test.go b/internal/review/anchor_test.go new file mode 100644 index 000000000..a75c5396d --- /dev/null +++ b/internal/review/anchor_test.go @@ -0,0 +1,74 @@ +package review_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/review" +) + +func TestAnchorUnmarshalText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want review.Anchor + wantErr string + }{ + { + name: "File", + input: "main.go", + want: review.Anchor{Path: "main.go"}, + }, + { + name: "Line", + input: "main.go:42", + want: review.Anchor{ + Path: "main.go", + StartLine: 42, + EndLine: 42, + }, + }, + { + name: "Range", + input: "main.go:42-50", + want: review.Anchor{ + Path: "main.go", + StartLine: 42, + EndLine: 50, + }, + }, + { + name: "ColonInPath", + input: "path:main.go:42", + want: review.Anchor{ + Path: "path:main.go", + StartLine: 42, + EndLine: 42, + }, + }, + {name: "Empty", wantErr: "comment anchor is required"}, + {name: "EmptyPath", input: ":42", wantErr: "path is required"}, + {name: "InvalidLine", input: "main.go:nope", wantErr: "invalid line number"}, + {name: "ZeroLine", input: "main.go:0", wantErr: "lines must be positive"}, + {name: "DescendingRange", input: "main.go:50-42", wantErr: "end must not precede start"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var got review.Anchor + err := got.UnmarshalText([]byte(tt.input)) + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.input, got.String()) + }) + } +} diff --git a/internal/review/draft.go b/internal/review/draft.go new file mode 100644 index 000000000..fa4b31910 --- /dev/null +++ b/internal/review/draft.go @@ -0,0 +1,25 @@ +package review + +import "strconv" + +// DraftID identifies a local review draft within one branch. +type DraftID int + +// String returns the decimal command-line representation of the ID. +func (id DraftID) String() string { + return strconv.Itoa(int(id)) +} + +// Draft is a local review comment waiting to be published. +type Draft struct { + ID DraftID // required + Body string // required + + // Anchor identifies the location of a root comment. + // It is zero for a reply. + Anchor Anchor + + // ReplyTo is the command-line ID of the replied-to thread. + // It is empty for a root comment. + ReplyTo string +} diff --git a/internal/spice/state/review_draft.go b/internal/spice/state/review_draft.go new file mode 100644 index 000000000..3d2fafee2 --- /dev/null +++ b/internal/spice/state/review_draft.go @@ -0,0 +1,171 @@ +package state + +import ( + "context" + "errors" + "fmt" + "path" + "slices" + + "go.abhg.dev/gs/internal/review" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +const _reviewDraftsDir = "comments" + +type reviewDraftState struct { + NextID review.DraftID `json:"nextID"` + Drafts []storedReviewDraft `json:"comments"` +} + +type storedReviewDraft struct { + ID review.DraftID `json:"id"` + File string `json:"file"` + Line int `json:"line"` + Body string `json:"body"` + ThreadID string `json:"threadID,omitempty"` +} + +// AddReviewDraft assigns a branch-local ID and saves a review draft. +func (s *Store) AddReviewDraft( + ctx context.Context, + branch string, + draft review.Draft, +) (review.Draft, error) { + state, err := s.loadReviewDraftState(ctx, branch) + if err != nil { + return review.Draft{}, err + } + if state == nil { + state = &reviewDraftState{NextID: 1} + } + + draft.ID = state.NextID + state.NextID++ + state.Drafts = append(state.Drafts, storeReviewDraft(draft)) + if err := s.saveReviewDraftState(ctx, branch, state); err != nil { + return review.Draft{}, err + } + return draft, nil +} + +// UpdateReviewDraftBody replaces the body of one branch-local draft. +func (s *Store) UpdateReviewDraftBody( + ctx context.Context, + branch string, + id review.DraftID, + body string, +) error { + state, err := s.loadReviewDraftState(ctx, branch) + if err != nil { + return err + } + if state == nil { + return fmt.Errorf("draft comment %d not found", id) + } + + idx := slices.IndexFunc(state.Drafts, func(draft storedReviewDraft) bool { + return draft.ID == id + }) + if idx < 0 { + return fmt.Errorf("draft comment %d not found", id) + } + state.Drafts[idx].Body = body + return s.saveReviewDraftState(ctx, branch, state) +} + +// LoadReviewDrafts retrieves the unpublished review comments for branch. +// It returns nil when the branch has no review drafts. +func (s *Store) LoadReviewDrafts( + ctx context.Context, + branch string, +) ([]review.Draft, error) { + state, err := s.loadReviewDraftState(ctx, branch) + if err != nil || state == nil { + return nil, err + } + + drafts := make([]review.Draft, len(state.Drafts)) + for i, stored := range state.Drafts { + if stored.ThreadID != "" { + drafts[i] = review.Draft{ + ID: stored.ID, + Body: stored.Body, + ReplyTo: stored.ThreadID, + } + continue + } + + drafts[i] = review.Draft{ + ID: stored.ID, + Body: stored.Body, + Anchor: review.Anchor{ + Path: stored.File, + StartLine: stored.Line, + EndLine: stored.Line, + }, + } + } + return drafts, nil +} + +// ClearReviewDrafts removes review draft state for branch. +func (s *Store) ClearReviewDrafts(ctx context.Context, branch string) error { + if err := s.db.Delete( + ctx, + reviewDraftsJSON(branch), + fmt.Sprintf("%v: clear review drafts", branch), + ); err != nil { + return fmt.Errorf("delete review drafts: %w", err) + } + return nil +} + +func (s *Store) loadReviewDraftState( + ctx context.Context, + branch string, +) (*reviewDraftState, error) { + var state reviewDraftState + if err := s.db.Get(ctx, reviewDraftsJSON(branch), &state); err != nil { + if errors.Is(err, storage.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("get review drafts: %w", err) + } + return &state, nil +} + +func (s *Store) saveReviewDraftState( + ctx context.Context, + branch string, + state *reviewDraftState, +) error { + if err := s.db.Set( + ctx, + reviewDraftsJSON(branch), + state, + fmt.Sprintf("%v: save review drafts", branch), + ); err != nil { + return fmt.Errorf("set review drafts: %w", err) + } + return nil +} + +func storeReviewDraft(draft review.Draft) storedReviewDraft { + stored := storedReviewDraft{ + ID: draft.ID, + Body: draft.Body, + } + if draft.ReplyTo != "" { + stored.ThreadID = draft.ReplyTo + return stored + } + + stored.File = draft.Anchor.Path + stored.Line = draft.Anchor.StartLine + return stored +} + +func reviewDraftsJSON(branch string) string { + return path.Join(_reviewDraftsDir, branch) +} diff --git a/internal/spice/state/review_draft_test.go b/internal/spice/state/review_draft_test.go new file mode 100644 index 000000000..a6c875e34 --- /dev/null +++ b/internal/spice/state/review_draft_test.go @@ -0,0 +1,64 @@ +package state_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/review" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/spice/state/storage" +) + +func TestReviewDrafts(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := storage.NewDB(make(storage.MapBackend)) + store, err := state.InitStore(ctx, state.InitStoreRequest{ + DB: db, + Trunk: "main", + }) + require.NoError(t, err) + + comment, err := store.AddReviewDraft( + ctx, + "feature", + review.Draft{ + ID: 0, + Body: "comment body", + Anchor: review.Anchor{ + Path: "main.go", + StartLine: 42, + EndLine: 42, + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, review.DraftID(1), comment.ID) + + reply, err := store.AddReviewDraft( + ctx, + "feature", + review.Draft{ID: 0, Body: "reply body", ReplyTo: "thread-7"}, + ) + require.NoError(t, err) + assert.Equal(t, review.DraftID(2), reply.ID) + + require.NoError(t, store.UpdateReviewDraftBody( + ctx, + "feature", + comment.ID, + "updated body", + )) + drafts, err := store.LoadReviewDrafts(ctx, "feature") + require.NoError(t, err) + require.Len(t, drafts, 2) + assert.Equal(t, "updated body", drafts[0].Body) + assert.Equal(t, reply, drafts[1]) + + require.NoError(t, store.ClearReviewDrafts(ctx, "feature")) + drafts, err = store.LoadReviewDrafts(ctx, "feature") + require.NoError(t, err) + assert.Nil(t, drafts) +} diff --git a/internal/spice/state/staged_comment.go b/internal/spice/state/staged_comment.go deleted file mode 100644 index 11079e783..000000000 --- a/internal/spice/state/staged_comment.go +++ /dev/null @@ -1,121 +0,0 @@ -package state - -import ( - "context" - "errors" - "fmt" - "path" - - "go.abhg.dev/gs/internal/spice/state/storage" -) - -// _stagedCommentsDir is the directory holding staged comments -// for branches that have not yet been submitted as reviews. -const _stagedCommentsDir = "staged-comments" - -// StagedComment is a draft inline comment -// waiting to be batch-submitted as part of a review. -type StagedComment struct { - // ID is a local auto-increment identifier - // unique within the branch's staged comments. - ID int `json:"id"` - - // File is the file path relative to the repository root. - File string `json:"file"` - - // Line is the line number in the new version of the file. - Line int `json:"line"` - - // Body is the markdown body of the comment. - Body string `json:"body"` - - // ThreadID is set when replying to an existing thread. - // The format is forge-specific. - ThreadID string `json:"threadID,omitempty"` -} - -// StagedComments is the collection of staged comments -// for a branch. -type StagedComments struct { - // NextID is the next ID to assign - // to a new staged comment. - NextID int `json:"nextID"` - - // Comments are the staged comments. - Comments []StagedComment `json:"comments"` -} - -func (s *Store) stagedCommentsJSON(branch string) string { - return path.Join(_stagedCommentsDir, branch) -} - -// SaveStagedComments saves the staged comments -// for the given branch. -// If staged comments already exist for the branch, -// they will be overwritten. -func (s *Store) SaveStagedComments( - ctx context.Context, - branch string, - comments *StagedComments, -) error { - err := s.db.Set( - ctx, - s.stagedCommentsJSON(branch), - comments, - fmt.Sprintf( - "%v: save staged comments", branch, - ), - ) - if err != nil { - return fmt.Errorf( - "set staged comments: %w", err, - ) - } - return nil -} - -// LoadStagedComments retrieves staged comments -// for the given branch. -// Returns nil if no staged comments exist. -func (s *Store) LoadStagedComments( - ctx context.Context, - branch string, -) (*StagedComments, error) { - var comments StagedComments - err := s.db.Get( - ctx, - s.stagedCommentsJSON(branch), - &comments, - ) - if err != nil { - if errors.Is(err, storage.ErrNotExist) { - return nil, nil - } - return nil, fmt.Errorf( - "get staged comments: %w", err, - ) - } - return &comments, nil -} - -// ClearStagedComments removes staged comments -// for the given branch. -// This is a no-op if no staged comments exist. -func (s *Store) ClearStagedComments( - ctx context.Context, - branch string, -) error { - err := s.db.Delete( - ctx, - s.stagedCommentsJSON(branch), - fmt.Sprintf( - "%v: clear staged comments", branch, - ), - ) - if err != nil { - return fmt.Errorf( - "delete staged comments: %w", err, - ) - } - return nil -} diff --git a/internal/spice/state/staged_comment_test.go b/internal/spice/state/staged_comment_test.go deleted file mode 100644 index 0f8da8bc6..000000000 --- a/internal/spice/state/staged_comment_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package state_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.abhg.dev/gs/internal/silog/silogtest" - "go.abhg.dev/gs/internal/spice/state" - "go.abhg.dev/gs/internal/spice/state/storage" -) - -func TestStagedComments(t *testing.T) { - ctx := t.Context() - db := storage.NewDB(make(storage.MapBackend)) - - _, err := state.InitStore(ctx, state.InitStoreRequest{ - DB: db, - Trunk: "main", - }) - require.NoError(t, err) - - store, err := state.OpenStore(ctx, db, silogtest.New(t)) - require.NoError(t, err) - - t.Run("LoadEmpty", func(t *testing.T) { - got, err := store.LoadStagedComments(ctx, "feat") - require.NoError(t, err) - assert.Nil(t, got) - }) - - t.Run("SaveAndLoad", func(t *testing.T) { - comments := &state.StagedComments{ - NextID: 3, - Comments: []state.StagedComment{ - { - ID: 1, - File: "main.go", - Line: 42, - Body: "Consider using a const here.", - }, - { - ID: 2, - File: "handler.go", - Line: 15, - Body: "I agree with your suggestion.", - ThreadID: "thread-abc", - }, - }, - } - - err := store.SaveStagedComments(ctx, "feat", comments) - require.NoError(t, err) - - got, err := store.LoadStagedComments(ctx, "feat") - require.NoError(t, err) - require.NotNil(t, got) - - assert.Equal(t, 3, got.NextID) - assert.Len(t, got.Comments, 2) - assert.Equal(t, "main.go", got.Comments[0].File) - assert.Equal(t, 42, got.Comments[0].Line) - assert.Equal(t, "thread-abc", got.Comments[1].ThreadID) - }) - - t.Run("Overwrite", func(t *testing.T) { - comments := &state.StagedComments{ - NextID: 2, - Comments: []state.StagedComment{ - {ID: 1, File: "new.go", Line: 1, Body: "New comment"}, - }, - } - - err := store.SaveStagedComments(ctx, "feat", comments) - require.NoError(t, err) - - got, err := store.LoadStagedComments(ctx, "feat") - require.NoError(t, err) - require.NotNil(t, got) - - assert.Len(t, got.Comments, 1) - assert.Equal(t, "new.go", got.Comments[0].File) - }) - - t.Run("Clear", func(t *testing.T) { - err := store.ClearStagedComments(ctx, "feat") - require.NoError(t, err) - - got, err := store.LoadStagedComments(ctx, "feat") - require.NoError(t, err) - assert.Nil(t, got) - }) - - t.Run("ClearNonExistent", func(t *testing.T) { - // Clearing a branch with no staged comments - // should not error. - err := store.ClearStagedComments(ctx, "nonexistent") - require.NoError(t, err) - }) - - t.Run("MultipleBranches", func(t *testing.T) { - commentsA := &state.StagedComments{ - NextID: 2, - Comments: []state.StagedComment{ - {ID: 1, File: "a.go", Line: 1, Body: "A"}, - }, - } - commentsB := &state.StagedComments{ - NextID: 2, - Comments: []state.StagedComment{ - {ID: 1, File: "b.go", Line: 2, Body: "B"}, - }, - } - - require.NoError(t, - store.SaveStagedComments(ctx, "branch-a", commentsA)) - require.NoError(t, - store.SaveStagedComments(ctx, "branch-b", commentsB)) - - gotA, err := store.LoadStagedComments(ctx, "branch-a") - require.NoError(t, err) - assert.Equal(t, "a.go", gotA.Comments[0].File) - - gotB, err := store.LoadStagedComments(ctx, "branch-b") - require.NoError(t, err) - assert.Equal(t, "b.go", gotB.Comments[0].File) - }) -} diff --git a/review.go b/review.go index 591091357..2c7b334a7 100644 --- a/review.go +++ b/review.go @@ -5,7 +5,11 @@ import ( "errors" "fmt" + "github.com/alecthomas/kong" "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/review" + "go.abhg.dev/gs/internal/silog" "go.abhg.dev/gs/internal/spice" "go.abhg.dev/gs/internal/spice/state" ) @@ -20,67 +24,106 @@ type reviewCmd struct { Reopen reviewReopenCmd `cmd:"" help:"Reopen a resolved review thread"` } -// reviewRepositoryForBranch resolves the change and review-capable repository -// shared by review commands that operate on remote state. -func reviewRepositoryForBranch( - ctx context.Context, - svc *spice.Service, - forgeRepo forge.Repository, - branch string, -) (*spice.LookupBranchResponse, forge.ReviewRepository, error) { - b, err := svc.LookupBranch(ctx, branch) - if err != nil { - if errors.Is(err, state.ErrNotExist) { - return nil, nil, fmt.Errorf("branch not tracked: %s", branch) - } - return nil, nil, fmt.Errorf("get branch: %w", err) - } - if b.Change == nil { - return nil, nil, fmt.Errorf( - "no change request for %s; "+ - "submit the branch first with "+ - "'gs branch submit'", - branch, - ) - } +func (*reviewCmd) AfterApply(kctx *kong.Context) error { + return errors.Join( + kctx.BindToProvider(func( + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + gitRepo *git.Repository, + remoteRepo *remoteRepository, + ) (ReviewHandler, error) { + repository, err := requireReviewRepository(remoteRepo.Repository) + if err != nil { + return nil, err + } + return &review.Handler{ + Log: log, + Worktree: wt, + Service: svc, + Store: store, + Repository: repository, + Editor: newReviewCommentEditor(gitRepo), + }, nil + }), + kctx.BindToProvider(func( + log *silog.Logger, + store *state.Store, + gitRepo *git.Repository, + ) (ReviewDraftHandler, error) { + return &review.DraftHandler{ + Log: log, + Store: store, + Editor: newReviewCommentEditor(gitRepo), + }, nil + }), + kctx.BindToProvider(func( + log *silog.Logger, + svc *spice.Service, + remoteRepo *remoteRepository, + ) (ReviewThreadHandler, error) { + repository, err := requireReviewRepository(remoteRepo.Repository) + if err != nil { + return nil, err + } + resolver, ok := remoteRepo.Repository.(forge.ReviewThreadResolver) + if !ok { + return nil, fmt.Errorf( + "forge %q does not support review thread resolution: %w", + remoteRepo.Repository.Forge().ID(), + forge.ErrUnsupported, + ) + } + return &review.ThreadHandler{ + Log: log, + Service: svc, + Repository: repository, + Resolver: resolver, + }, nil + }), + ) +} - reviewRepo, ok := forgeRepo.(forge.ReviewRepository) - if !ok { - return nil, nil, errors.New( - "forge does not support review comments", - ) - } - return b, reviewRepo, nil +// ReviewHandler runs workflows that access review comments on a forge. +type ReviewHandler interface { + PostComment(context.Context, *review.CommentRequest) error + PostReply(context.Context, *review.ReplyRequest) error + PublishDrafts(context.Context, *review.PublishDraftsRequest) error + LoadReviewData(context.Context, *review.LoadRequest) (*review.LoadResult, error) } -// loadReviewThreadIDs indexes the forge's native thread identifiers by their -// command-line representation. ReviewThreadID is intentionally opaque, so a -// command must recover the provider-owned value before replying to or resolving -// a thread named by the user. -func loadReviewThreadIDs( - ctx context.Context, - repo forge.ReviewRepository, - changeID forge.ChangeID, -) (map[string]forge.ReviewThreadID, error) { - ids := make(map[string]forge.ReviewThreadID) - for thread, err := range repo.ListReviewThreads(ctx, changeID) { - if err != nil { - return nil, fmt.Errorf("list review threads: %w", err) - } - ids[thread.ID.String()] = thread.ID - } - return ids, nil +// ReviewDraftHandler runs workflows that only access local review drafts. +type ReviewDraftHandler interface { + SaveCommentDraft(context.Context, *review.CommentRequest) error + SaveReplyDraft(context.Context, *review.ReplyRequest) error + ReplaceDraftBody(context.Context, *review.ReplaceDraftBodyRequest) error +} + +// ReviewThreadHandler changes the resolution state of review threads. +type ReviewThreadHandler interface { + SetThreadResolution( + context.Context, + *review.SetThreadResolutionRequest, + ) error } -// reviewThreadID resolves a user-supplied thread string to the opaque ID value -// returned by the current forge. -func reviewThreadID( - ids map[string]forge.ReviewThreadID, - id string, -) (forge.ReviewThreadID, error) { - threadID, ok := ids[id] +func requireReviewRepository( + repository forge.Repository, +) (forge.ReviewRepository, error) { + reviewRepository, ok := repository.(forge.ReviewRepository) if !ok { - return nil, fmt.Errorf("review thread %q not found", id) + return nil, fmt.Errorf( + "forge %q does not support review comments: %w", + repository.Forge().ID(), + forge.ErrUnsupported, + ) + } + return reviewRepository, nil +} + +func newReviewCommentEditor(repo *git.Repository) review.CommentEditor { + return func(ctx context.Context, initial string) (string, error) { + return editReviewCommentBody(ctx, repo, initial) } - return threadID, nil } diff --git a/review_comment.go b/review_comment.go index 4b27926b1..8ae021dc9 100644 --- a/review_comment.go +++ b/review_comment.go @@ -2,28 +2,18 @@ package main import ( "context" - "errors" "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git" - "go.abhg.dev/gs/internal/reviewdiff" - "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/spice" - "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/text" - "go.abhg.dev/gs/internal/xec" ) type reviewCommentCmd struct { - Anchor string `arg:"" optional:"" help:"Comment anchor: file.go, file.go:42, or file.go:42-50."` - Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` - Draft bool `negatable:"" default:"true" help:"Save the comment as a local draft instead of posting it."` - Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to comment on. Defaults to the current branch."` + Anchor review.Anchor `arg:"" name:"file[:line[-end]]" help:"Comment anchor: file.go, file.go:42, or file.go:42-50."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Draft bool `negatable:"" default:"true" help:"Save the comment as a local draft instead of posting it."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to comment on. Defaults to the current branch."` } func (*reviewCommentCmd) Help() string { @@ -45,299 +35,25 @@ func (*reviewCommentCmd) Help() string { func (cmd *reviewCommentCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - store *state.Store, - repo *git.Repository, - forgeRepo forge.Repository, + handler ReviewHandler, + drafts ReviewDraftHandler, ) error { - branch, err := reviewBranch(ctx, wt, cmd.Branch) - if err != nil { - return err - } - - anchor, err := parseReviewCommentAnchor(cmd.Anchor) - if err != nil { - return err - } - body, err := reviewCommentBody(ctx, repo, cmd.Message, "") - if err != nil { - return err - } - - if cmd.Draft { - if anchor.Range.IsZero() || anchor.Range.StartLine != anchor.Range.EndLine { - return errors.New( - "draft comments require a single-line file:line anchor", - ) - } - return saveReviewDraft(ctx, log, store, branch, state.StagedComment{ - File: anchor.Path, - Line: anchor.Range.StartLine, - Body: body, - }) - } - - b, reviewRepo, err := reviewRepositoryForBranch( - ctx, svc, forgeRepo, branch, - ) - if err != nil { - return err - } - diff, err := wt.OpenBranchDiff(ctx, b.Base, branch) - if err != nil { - return fmt.Errorf("open diff: %w", err) - } - patch, err := reviewdiff.Parse(diff) - err = errors.Join(err, diff.Close()) - if err != nil { - return fmt.Errorf("parse diff: %w", err) - } - if anchor.Range.IsZero() && !patch.ContainsFile(anchor.Path) { - return fmt.Errorf( - "review diff does not contain file %q", - anchor.Path, - ) - } - if !anchor.Range.IsZero() && !patch.ContainsLineRange( - anchor.Path, - anchor.Range.StartLine, - anchor.Range.EndLine, - ) { - return fmt.Errorf( - "review diff does not contain %s:%d-%d", - anchor.Path, - anchor.Range.StartLine, - anchor.Range.EndLine, - ) - } - - return postReviewComment( - ctx, - log, - reviewRepo, - b.Change.ChangeID(), - forge.SubmitReviewCommentRequest{ - Path: anchor.Path, - Range: anchor.Range, - Body: body, - Side: forge.ReviewThreadSideRight, - }, - ) -} - -// reviewCommentAnchor is the parsed file or inclusive line range accepted by -// review comment. A zero range identifies the whole file. -type reviewCommentAnchor struct { - Path string - Range forge.ReviewThreadRange -} - -func parseReviewCommentAnchor(value string) (reviewCommentAnchor, error) { - if value == "" { - return reviewCommentAnchor{}, errors.New("comment anchor is required") - } - if !strings.Contains(value, ":") { - return reviewCommentAnchor{Path: value}, nil - } - - file, start, end, err := parseFileAndRange(value) - if err != nil { - return reviewCommentAnchor{}, err - } - return reviewCommentAnchor{ - Path: file, - Range: forge.ReviewThreadRange{ - StartLine: start, - EndLine: end, - }, - }, nil -} - -func reviewBranch( - ctx context.Context, - wt *git.Worktree, - branch string, -) (string, error) { - if branch != "" { - return branch, nil - } - branch, err := wt.CurrentBranch(ctx) - if err != nil { - return "", fmt.Errorf("get current branch: %w", err) - } - return branch, nil -} - -func reviewCommentBody( - ctx context.Context, - repo *git.Repository, - message string, - initial string, -) (string, error) { - body := message - if body == "" { - var err error - body, err = editReviewCommentBody(ctx, repo, initial) + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) if err != nil { - return "", err + return fmt.Errorf("get current branch: %w", err) } + cmd.Branch = branch } - if strings.TrimSpace(body) == "" { - return "", errors.New("empty comment body, aborting") - } - return body, nil -} -func saveReviewDraft( - ctx context.Context, - log *silog.Logger, - store *state.Store, - branch string, - comment state.StagedComment, -) error { - staged, err := store.LoadStagedComments(ctx, branch) - if err != nil { - return fmt.Errorf("load draft comments: %w", err) - } - if staged == nil { - staged = &state.StagedComments{NextID: 1} - } - - comment.ID = staged.NextID - staged.Comments = append(staged.Comments, comment) - staged.NextID++ - if err := store.SaveStagedComments(ctx, branch, staged); err != nil { - return fmt.Errorf("save draft comments: %w", err) - } - - if comment.ThreadID != "" { - log.Infof( - "Drafted reply %d to thread %s.", - comment.ID, - comment.ThreadID, - ) - } else { - log.Infof( - "Drafted comment %d on %s:%d.", - comment.ID, - comment.File, - comment.Line, - ) - } - return nil -} - -func postReviewComment( - ctx context.Context, - log *silog.Logger, - reviewRepo forge.ReviewRepository, - changeID forge.ChangeID, - comment forge.SubmitReviewCommentRequest, -) error { - result, err := reviewRepo.SubmitReview( - ctx, - changeID, - forge.SubmitReviewRequest{ - Comments: []forge.SubmitReviewCommentRequest{comment}, - }, - ) - if err != nil { - return fmt.Errorf("post review comment: %w", err) - } - if len(result.Comments) != 1 { - return fmt.Errorf( - "post review comment: forge returned %d comment results", - len(result.Comments), - ) - } - - log.Infof( - "Posted comment %s on %s.", - result.Comments[0].ThreadID.String(), - changeID, - ) - return nil -} - -// parseFileAndRange parses file.go:42 or file.go:42-50. -// The returned end equals start for a single-line anchor. -func parseFileAndRange( - value string, -) (file string, start, end int, err error) { - idx := strings.LastIndex(value, ":") - if idx < 0 { - return "", 0, 0, fmt.Errorf( - "expected file:line or file:start-end, got %q", value, - ) - } - file = value[:idx] - lineSpec := value[idx+1:] - - before, after, hasRange := strings.Cut(lineSpec, "-") - if !hasRange { - start, err = strconv.Atoi(lineSpec) - if err != nil { - return "", 0, 0, fmt.Errorf( - "invalid line number in %q: %w", value, err, - ) - } - if start <= 0 { - return "", 0, 0, fmt.Errorf( - "line number must be positive, got %d", start, - ) - } - return file, start, start, nil - } - - start, err = strconv.Atoi(before) - if err != nil { - return "", 0, 0, fmt.Errorf( - "invalid range start in %q: %w", value, err, - ) - } - end, err = strconv.Atoi(after) - if err != nil { - return "", 0, 0, fmt.Errorf( - "invalid range end in %q: %w", value, err, - ) - } - if start <= 0 || end <= 0 { - return "", 0, 0, fmt.Errorf( - "line numbers must be positive in %q", value, - ) - } - if end <= start { - return "", 0, 0, fmt.Errorf( - "range end must be greater than start in %q", value, - ) + req := &review.CommentRequest{ + Branch: cmd.Branch, + Anchor: cmd.Anchor, + Message: cmd.Message, } - return file, start, end, nil -} - -// editReviewCommentBody opens the configured editor with initial as its -// starting contents and returns the edited comment body. -func editReviewCommentBody( - ctx context.Context, - repo *git.Repository, - initial string, -) (string, error) { - tmpFile := filepath.Join(os.TempDir(), "GS_REVIEW_EDITMSG") - if err := os.WriteFile(tmpFile, []byte(initial), 0o644); err != nil { - return "", fmt.Errorf("write temp file: %w", err) - } - defer func() { _ = os.Remove(tmpFile) }() - - editor := gitEditor(ctx, repo) - cmd := xec.EditCommand(editor, tmpFile) - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("run editor: %w", err) - } - - content, err := os.ReadFile(tmpFile) - if err != nil { - return "", fmt.Errorf("read temp file: %w", err) + if cmd.Draft { + return drafts.SaveCommentDraft(ctx, req) } - return string(content), nil + return handler.PostComment(ctx, req) } diff --git a/review_edit.go b/review_edit.go index a30c4cf67..03b7a6d24 100644 --- a/review_edit.go +++ b/review_edit.go @@ -5,15 +5,14 @@ import ( "fmt" "go.abhg.dev/gs/internal/git" - "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/text" ) type reviewEditCmd struct { - ID int `arg:"" help:"Draft comment ID to edit."` - Message string `short:"m" placeholder:"MSG" help:"New comment body. Opens editor if not provided."` - Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch containing the draft. Defaults to the current branch."` + ID review.DraftID `arg:"" help:"Draft comment ID to edit."` + Message string `short:"m" placeholder:"MSG" help:"New comment body. Opens editor if not provided."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch containing the draft. Defaults to the current branch."` } func (*reviewEditCmd) Help() string { @@ -30,49 +29,20 @@ func (*reviewEditCmd) Help() string { func (cmd *reviewEditCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - store *state.Store, - repo *git.Repository, + handler ReviewDraftHandler, ) error { - branch, err := reviewBranch(ctx, wt, cmd.Branch) - if err != nil { - return err - } - - staged, err := store.LoadStagedComments(ctx, branch) - if err != nil { - return fmt.Errorf("load draft comments: %w", err) - } - if staged == nil { - staged = &state.StagedComments{} - } - - idx := -1 - for i, comment := range staged.Comments { - if comment.ID == cmd.ID { - idx = i - break + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) } - } - if idx < 0 { - return fmt.Errorf("draft comment %d not found", cmd.ID) - } - - body, err := reviewCommentBody( - ctx, - repo, - cmd.Message, - staged.Comments[idx].Body, - ) - if err != nil { - return err - } - staged.Comments[idx].Body = body - if err := store.SaveStagedComments(ctx, branch, staged); err != nil { - return fmt.Errorf("save draft comments: %w", err) + cmd.Branch = branch } - log.Infof("Updated draft comment %d.", cmd.ID) - return nil + return handler.ReplaceDraftBody(ctx, &review.ReplaceDraftBodyRequest{ + Branch: cmd.Branch, + ID: cmd.ID, + Message: cmd.Message, + }) } diff --git a/review_editor.go b/review_editor.go new file mode 100644 index 000000000..337c2f0b7 --- /dev/null +++ b/review_editor.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/xec" +) + +// editReviewCommentBody opens the configured editor with initial contents. +func editReviewCommentBody( + ctx context.Context, + repo *git.Repository, + initial string, +) (string, error) { + tmpFile := filepath.Join(os.TempDir(), "GIT_SPICE_REVIEW_EDITMSG.md") + if err := os.WriteFile(tmpFile, []byte(initial), 0o644); err != nil { + return "", fmt.Errorf("write temp file: %w", err) + } + defer func() { _ = os.Remove(tmpFile) }() + + cmd := xec.EditCommand(gitEditor(ctx, repo), tmpFile) + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("run editor: %w", err) + } + + content, err := os.ReadFile(tmpFile) + if err != nil { + return "", fmt.Errorf("read temp file: %w", err) + } + return string(content), nil +} diff --git a/review_list.go b/review_list.go index 2797e0e29..e7387d596 100644 --- a/review_list.go +++ b/review_list.go @@ -7,17 +7,13 @@ import ( "errors" "fmt" "io" - "strconv" "strings" "time" "github.com/alecthomas/kong" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/sliceutil" - "go.abhg.dev/gs/internal/spice" - "go.abhg.dev/gs/internal/spice/state" "go.abhg.dev/gs/internal/text" ) @@ -49,250 +45,117 @@ func (cmd *reviewListCmd) Run( kctx *kong.Context, log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - store *state.Store, - forgeRepo forge.Repository, + handler ReviewHandler, ) error { - branch, err := cmd.resolveBranch(ctx, wt) - if err != nil { - return err + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = branch } - staged, forgeComments, err := cmd.loadComments( - ctx, log, svc, store, forgeRepo, branch, - ) + response, err := handler.LoadReviewData(ctx, &review.LoadRequest{ + Branch: cmd.Branch, + DraftOnly: cmd.DraftOnly, + Unresolved: cmd.Unresolved, + }) if err != nil { return err } if cmd.JSON { - return cmd.writeJSON( - kctx.Stdout, staged, forgeComments, - ) - } - return cmd.writeText(log, branch, staged, forgeComments) -} - -func (cmd *reviewListCmd) resolveBranch( - ctx context.Context, wt *git.Worktree, -) (string, error) { - if cmd.Branch != "" { - return cmd.Branch, nil - } - branch, err := wt.CurrentBranch(ctx) - if err != nil { - return "", fmt.Errorf("get current branch: %w", err) - } - return branch, nil -} - -func (cmd *reviewListCmd) loadComments( - ctx context.Context, - log *silog.Logger, - svc *spice.Service, - store *state.Store, - forgeRepo forge.Repository, - branch string, -) ([]*state.StagedComment, []*listedReviewComment, error) { - staged, err := loadStagedComments(ctx, store, branch) - if err != nil { - return nil, nil, err - } - - if cmd.DraftOnly { - return staged, nil, nil - } - - forgeComments, err := loadForgeComments( - ctx, log, svc, forgeRepo, branch, - ) - if err != nil { - return nil, nil, err - } - - return staged, cmd.filterForge(forgeComments), nil -} - -func loadStagedComments( - ctx context.Context, - store *state.Store, - branch string, -) ([]*state.StagedComment, error) { - staged, err := store.LoadStagedComments(ctx, branch) - if err != nil { - return nil, fmt.Errorf("load draft comments: %w", err) + return writeReviewListJSON(kctx.Stdout, response) } - if staged == nil { - return nil, nil - } - - refs := make( - []*state.StagedComment, len(staged.Comments), - ) - for i := range staged.Comments { - refs[i] = &staged.Comments[i] - } - return refs, nil -} - -func loadForgeComments( - ctx context.Context, - log *silog.Logger, - svc *spice.Service, - forgeRepo forge.Repository, - branch string, -) ([]*listedReviewComment, error) { - b, err := svc.LookupBranch(ctx, branch) - if err != nil { - if errors.Is(err, state.ErrNotExist) { - return nil, fmt.Errorf( - "branch not tracked: %s", branch, - ) - } - return nil, fmt.Errorf("get branch: %w", err) - } - - if b.Change == nil { - log.Infof( - "No change request found for %s.", branch, - ) - return nil, nil - } - - reviewRepo, ok := forgeRepo.(forge.ReviewRepository) - if !ok { - log.Infof( - "Forge does not support review comments.", - ) - return nil, nil - } - - threads, err := sliceutil.CollectErr( - reviewRepo.ListReviewThreads(ctx, b.Change.ChangeID()), - ) - if err != nil { - return nil, fmt.Errorf( - "list review threads: %w", err, - ) - } - - var comments []*listedReviewComment - for _, thread := range threads { - for i := range thread.Comments { - comments = append(comments, &listedReviewComment{ - Thread: thread, - Comment: &thread.Comments[i], - }) - } - } - return comments, nil -} - -// listedReviewComment keeps a comment paired with the thread that owns its -// location and status. -type listedReviewComment struct { - Thread *forge.ReviewThread - Comment *forge.ReviewComment -} - -func (cmd *reviewListCmd) filterForge( - comments []*listedReviewComment, -) []*listedReviewComment { - if !cmd.Unresolved { - return comments - } - - var filtered []*listedReviewComment - for _, c := range comments { - if c.Thread.Resolved == nil || !*c.Thread.Resolved { - filtered = append(filtered, c) - } - } - return filtered + writeReviewListText(log, response, cmd.DraftOnly) + return nil } -// writeText prints comments in human-readable format. -func (cmd *reviewListCmd) writeText( +// writeReviewListText presents review comments in a human-readable form. +func writeReviewListText( log *silog.Logger, - branch string, - staged []*state.StagedComment, - forgeComments []*listedReviewComment, -) error { - if len(staged) > 0 { + response *review.LoadResult, + draftOnly bool, +) { + if len(response.Drafts) > 0 { log.Infof("Draft comments:") - for _, c := range staged { - writeStagedText(log, c) + for _, draft := range response.Drafts { + writeReviewDraftText(log, draft) } } - if cmd.DraftOnly && len(staged) == 0 { - log.Infof("No draft comments for %s.", branch) - return nil + if draftOnly && len(response.Drafts) == 0 { + log.Infof("No draft comments for %s.", response.Branch) + return } - if len(forgeComments) > 0 { + if len(response.Comments) > 0 { log.Infof("Comments:") - for _, c := range forgeComments { - writeForgeText(log, c) + for _, comment := range response.Comments { + writeForgeReviewCommentText(log, comment) } } - if len(forgeComments) == 0 && len(staged) == 0 { - log.Infof("No comments on %s.", branch) + if len(response.Comments) == 0 && len(response.Drafts) == 0 { + log.Infof("No comments on %s.", response.Branch) } - return nil } -func writeStagedText( - log *silog.Logger, c *state.StagedComment, -) { - location := fmt.Sprintf("%s:%d", c.File, c.Line) - if c.ThreadID != "" { - location = "reply:" + c.ThreadID +func writeReviewDraftText(log *silog.Logger, draft review.Draft) { + location := "" + if draft.ReplyTo != "" { + location = "reply:" + draft.ReplyTo + } else { + location = draft.Anchor.String() } - log.Infof(" %-4d %s", c.ID, location) - writeBodyIndented(log, c.Body) + log.Infof(" %-4s %s", draft.ID, location) + writeReviewBody(log, draft.Body) } -func writeForgeText( - log *silog.Logger, c *listedReviewComment, +func writeForgeReviewCommentText( + log *silog.Logger, + comment review.ListedComment, ) { location := fmt.Sprintf( - "%s:%d", c.Thread.Path, c.Thread.Range.StartLine, + "%s:%d", + comment.Thread.Path, + comment.Thread.Range.StartLine, ) threadInfo := "" - if c.Thread.ID != nil { - threadInfo = " [" + c.Thread.ID.String() + "]" + if comment.Thread.ID != nil { + threadInfo = " [" + comment.Thread.ID.String() + "]" } log.Infof( " %-12s %s %s %s%s", - c.Comment.ID.String(), location, c.Comment.Author, - commentStatus(c), threadInfo, + comment.Comment.ID.String(), + location, + comment.Comment.Author, + reviewCommentStatus(comment), + threadInfo, ) - writeBodyIndented(log, c.Comment.Body) + writeReviewBody(log, comment.Comment.Body) } -func writeBodyIndented(log *silog.Logger, body string) { +func writeReviewBody(log *silog.Logger, body string) { for line := range strings.SplitSeq(body, "\n") { log.Infof(" %s", line) } } -func commentStatus(c *listedReviewComment) string { - if c.Thread.Outdated != nil && *c.Thread.Outdated { +func reviewCommentStatus(comment review.ListedComment) string { + if comment.Thread.Outdated != nil && *comment.Thread.Outdated { return "outdated" } - if c.Thread.Resolved != nil && *c.Thread.Resolved { + if comment.Thread.Resolved != nil && *comment.Thread.Resolved { return "resolved" } return "open" } -// writeJSON encodes comments as NDJSON to stdout. -func (cmd *reviewListCmd) writeJSON( +// writeReviewListJSON encodes review comments as NDJSON. +func writeReviewListJSON( w io.Writer, - staged []*state.StagedComment, - forgeComments []*listedReviewComment, + response *review.LoadResult, ) (retErr error) { bufw := bufio.NewWriter(w) defer func() { @@ -300,126 +163,129 @@ func (cmd *reviewListCmd) writeJSON( }() enc := json.NewEncoder(bufw) - for _, c := range staged { - if err := enc.Encode(stagedToJSON(c)); err != nil { - return fmt.Errorf("encode staged: %w", err) + for _, draft := range response.Drafts { + if err := enc.Encode(reviewDraftToJSON(draft)); err != nil { + return fmt.Errorf("encode draft: %w", err) } } - for _, c := range forgeComments { - if err := enc.Encode(forgeToJSON(c)); err != nil { + for _, comment := range response.Comments { + if err := enc.Encode(forgeReviewCommentToJSON(comment)); err != nil { return fmt.Errorf("encode forge: %w", err) } } return nil } -func stagedToJSON(c *state.StagedComment) jsonComment { +func reviewDraftToJSON(draft review.Draft) jsonComment { comment := jsonComment{ - Kind: "draft", - ID: strconv.Itoa(c.ID), - Body: c.Body, - ThreadID: c.ThreadID, + Kind: "draft", + ID: draft.ID.String(), + Body: draft.Body, } - if c.ThreadID == "" { + if draft.ReplyTo != "" { + comment.ThreadID = draft.ReplyTo + return comment + } + + comment.Path = draft.Anchor.Path + comment.Line = draft.Anchor.StartLine + if draft.Anchor.IsFile() { + comment.Scope = "file" + } else { comment.Scope = "line" - comment.Path = c.File - comment.Line = c.Line + if !draft.Anchor.IsLine() { + comment.Range = &jsonCommentRange{ + Start: draft.Anchor.StartLine, + End: draft.Anchor.EndLine, + } + } } return comment } -func forgeToJSON(c *listedReviewComment) jsonComment { +func forgeReviewCommentToJSON(comment review.ListedComment) jsonComment { var createdAt *time.Time - if !c.Comment.CreatedAt.IsZero() { - createdAt = &c.Comment.CreatedAt + if !comment.Comment.CreatedAt.IsZero() { + createdAt = &comment.Comment.CreatedAt } scope := "line" - if c.Thread.Range.IsZero() { + if comment.Thread.Range.IsZero() { scope = "file" } - comment := jsonComment{ + result := jsonComment{ Kind: "forge", - ID: c.Comment.ID.String(), + ID: comment.Comment.ID.String(), Scope: scope, - Path: c.Thread.Path, - Line: c.Thread.Range.StartLine, - CommitSHA: c.Thread.CommitHash.String(), - Body: c.Comment.Body, - ThreadID: c.Thread.ID.String(), - Author: c.Comment.Author, - Resolved: c.Thread.Resolved, - Stale: c.Thread.Outdated, - Status: commentStatus(c), + Path: comment.Thread.Path, + Line: comment.Thread.Range.StartLine, + CommitSHA: comment.Thread.CommitHash.String(), + Body: comment.Comment.Body, + ThreadID: comment.Thread.ID.String(), + Author: comment.Comment.Author, + Resolved: comment.Thread.Resolved, + Stale: comment.Thread.Outdated, + Status: reviewCommentStatus(comment), CreatedAt: createdAt, } if scope == "line" { - comment.Side = c.Thread.Side.String() - if c.Thread.Range.StartLine != c.Thread.Range.EndLine { - comment.Range = &jsonCommentRange{ - Start: c.Thread.Range.StartLine, - End: c.Thread.Range.EndLine, + result.Side = comment.Thread.Side.String() + if comment.Thread.Range.StartLine != comment.Thread.Range.EndLine { + result.Range = &jsonCommentRange{ + Start: comment.Thread.Range.StartLine, + End: comment.Thread.Range.EndLine, } } } - return comment + return result } -// jsonComment is the JSON representation -// of a comment for --json output. +// jsonComment is the JSON representation of a review comment. type jsonComment struct { // Kind is "draft" or "forge". Kind string `json:"kind"` - // ID is the comment identifier. - // For draft comments: a branch-local integer encoded as a string. - // For forge comments: forge-specific ID. + // ID is a branch-local integer for drafts and a forge ID otherwise. ID string `json:"id"` // Scope is "file" or "line". - // It is omitted for draft replies, which inherit their thread's scope. + // Draft replies omit it because they inherit their thread's scope. Scope string `json:"scope,omitempty"` - // Path is the file path relative to the repo root. + // Path is relative to the repository root. Path string `json:"path,omitempty"` - // Line is the line number in the file. + // Line is the first line of a line-scoped comment. Line int `json:"line,omitempty"` - // Range is set when a line comment spans more than one line. + // Range is set when a line comment spans multiple lines. Range *jsonCommentRange `json:"range,omitempty"` // Side is the diff side for a line comment. Side string `json:"side,omitempty"` - // CommitSHA is the reviewed revision against which the thread was created. - // It is empty when the forge does not expose that revision. + // CommitSHA is the reviewed revision that owns the thread. CommitSHA string `json:"commitSHA,omitempty"` - // Body is the full markdown body of the comment. + // Body is the full Markdown comment body. Body string `json:"body"` - // ThreadID is the thread identifier, if any. + // ThreadID identifies the owning forge thread. ThreadID string `json:"threadID,omitempty"` - // Author is the username of the comment author. - // Only set for forge comments. + // Author is set for forge comments. Author string `json:"author,omitempty"` - // Resolved reports whether the thread is resolved. - // It is omitted when the forge does not expose resolution state. + // Resolved is omitted when the forge does not expose resolution state. Resolved *bool `json:"resolved,omitempty"` - // Stale reports whether the thread belongs to an earlier revision. - // It is omitted when the forge does not expose outdated state. + // Stale is omitted when the forge does not expose outdated state. Stale *bool `json:"stale,omitempty"` - // Status is "open", "resolved", or "outdated". - // Only set for forge comments. + // Status is "open", "resolved", or "outdated" for forge comments. Status string `json:"status,omitempty"` - // CreatedAt is the time the comment was created. - // Only set for forge comments. + // CreatedAt is set for forge comments with a creation timestamp. CreatedAt *time.Time `json:"createdAt,omitempty"` } diff --git a/review_list_test.go b/review_list_test.go index a8868d3bf..024cd0796 100644 --- a/review_list_test.go +++ b/review_list_test.go @@ -7,25 +7,27 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/handler/review" ) func TestReviewListJSONUnsupportedThreadState(t *testing.T) { var stdout bytes.Buffer - err := new(reviewListCmd).writeJSON( + err := writeReviewListJSON( &stdout, - nil, - []*listedReviewComment{ - { - Thread: &forge.ReviewThread{ - ID: testReviewThreadID("thread-1"), - Path: "review.go", - Range: forge.ReviewThreadLine(3), - Side: forge.ReviewThreadSideRight, - }, - Comment: &forge.ReviewComment{ - ID: testReviewCommentID("comment-1"), - Body: "Consider a constant.", - Author: "reviewer", + &review.LoadResult{ + Comments: []review.ListedComment{ + { + Thread: forge.ReviewThread{ + ID: testReviewThreadID("thread-1"), + Path: "review.go", + Range: forge.ReviewThreadLine(3), + Side: forge.ReviewThreadSideRight, + }, + Comment: forge.ReviewComment{ + ID: testReviewCommentID("comment-1"), + Body: "Consider a constant.", + Author: "reviewer", + }, }, }, }, diff --git a/review_publish.go b/review_publish.go index c994b96e5..eea18a192 100644 --- a/review_publish.go +++ b/review_publish.go @@ -2,22 +2,18 @@ package main import ( "context" - "errors" "fmt" "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git" - "go.abhg.dev/gs/internal/reviewdiff" - "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/spice" - "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/text" ) type reviewPublishCmd struct { Body string `placeholder:"BODY" help:"Overall review body."` - Approve bool `help:"Mark the review as approved."` - RequestChanges bool `name:"request-changes" help:"Mark the review as requesting changes."` + Approve bool `xor:"review-disposition" help:"Mark the review as approved."` + RequestChanges bool `name:"request-changes" xor:"review-disposition" help:"Mark the review as requesting changes."` Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose draft comments to publish. Defaults to the current branch."` } @@ -36,118 +32,15 @@ func (*reviewPublishCmd) Help() string { func (cmd *reviewPublishCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - store *state.Store, - forgeRepo forge.Repository, + handler ReviewHandler, ) error { - branch := cmd.Branch - if branch == "" { - var err error - branch, err = wt.CurrentBranch(ctx) + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) if err != nil { return fmt.Errorf("get current branch: %w", err) } - } - - staged, err := store.LoadStagedComments(ctx, branch) - if err != nil { - return fmt.Errorf("load draft comments: %w", err) - } - if staged == nil { - staged = &state.StagedComments{} - } - - if len(staged.Comments) == 0 { - log.Infof("No draft comments to publish.") - return nil - } - - b, err := svc.LookupBranch(ctx, branch) - if err != nil { - if errors.Is(err, state.ErrNotExist) { - return fmt.Errorf( - "branch not tracked: %s", branch, - ) - } - return fmt.Errorf("get branch: %w", err) - } - - if b.Change == nil { - return fmt.Errorf( - "no change request for %s; "+ - "submit the branch first with "+ - "'gs branch submit'", - branch, - ) - } - - reviewRepo, ok := forgeRepo.(forge.ReviewRepository) - if !ok { - return errors.New( - "forge does not support review comments", - ) - } - - // Draft roots use the selected branch's postimage coordinates. Parse the - // review diff once so every root can be checked before anything is sent. - diff, err := wt.OpenBranchDiff(ctx, b.Base, branch) - if err != nil { - return fmt.Errorf("open diff: %w", err) - } - patch, err := reviewdiff.Parse(diff) - err = errors.Join(err, diff.Close()) - if err != nil { - return fmt.Errorf("parse diff: %w", err) - } - - var threadIDs map[string]forge.ReviewThreadID - for _, comment := range staged.Comments { - if comment.ThreadID == "" { - continue - } - threadIDs, err = loadReviewThreadIDs( - ctx, reviewRepo, b.Change.ChangeID(), - ) - if err != nil { - return err - } - break - } - - var comments []forge.SubmitReviewCommentRequest - for _, sc := range staged.Comments { - if sc.ThreadID != "" { - threadID, err := reviewThreadID(threadIDs, sc.ThreadID) - if err != nil { - return fmt.Errorf("draft %d: %w", sc.ID, err) - } - comments = append(comments, - forge.SubmitReviewCommentRequest{ - Body: sc.Body, - ReplyTo: threadID, - }, - ) - continue - } - - if !patch.ContainsLine(sc.File, sc.Line) { - return fmt.Errorf( - "draft %d: review diff does not contain %s:%d", - sc.ID, - sc.File, - sc.Line, - ) - } - comments = append(comments, - forge.SubmitReviewCommentRequest{ - Path: sc.File, - Range: forge.ReviewThreadLine(sc.Line), - Body: sc.Body, - Side: forge.ReviewThreadSideRight, - }, - ) + cmd.Branch = branch } disposition := forge.ReviewDispositionNone @@ -157,28 +50,9 @@ func (cmd *reviewPublishCmd) Run( disposition = forge.ReviewDispositionRequestChanges } - if _, err := reviewRepo.SubmitReview( - ctx, - b.Change.ChangeID(), - forge.SubmitReviewRequest{ - Body: cmd.Body, - Disposition: disposition, - Comments: comments, - }, - ); err != nil { - return fmt.Errorf("submit review: %w", err) - } - - if err := store.ClearStagedComments( - ctx, branch, - ); err != nil { - return fmt.Errorf("clear draft comments: %w", err) - } - - log.Infof( - "Published %d comment(s) as review on %s.", - len(comments), - b.Change.ChangeID(), - ) - return nil + return handler.PublishDrafts(ctx, &review.PublishDraftsRequest{ + Branch: cmd.Branch, + Body: cmd.Body, + Disposition: disposition, + }) } diff --git a/review_reply.go b/review_reply.go index 926a7da75..07e22b6a1 100644 --- a/review_reply.go +++ b/review_reply.go @@ -2,12 +2,10 @@ package main import ( "context" + "fmt" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git" - "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/spice" - "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/text" ) @@ -32,56 +30,25 @@ func (*reviewReplyCmd) Help() string { func (cmd *reviewReplyCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - store *state.Store, - repo *git.Repository, - forgeRepo forge.Repository, + handler ReviewHandler, + drafts ReviewDraftHandler, ) error { - branch, err := reviewBranch(ctx, wt, cmd.Branch) - if err != nil { - return err - } - body, err := reviewCommentBody(ctx, repo, cmd.Message, "") - if err != nil { - return err - } - - if cmd.Draft { - return saveReviewDraft(ctx, log, store, branch, state.StagedComment{ - Body: body, - ThreadID: cmd.ThreadID, - }) + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = branch } - b, reviewRepo, err := reviewRepositoryForBranch( - ctx, svc, forgeRepo, branch, - ) - if err != nil { - return err + req := &review.ReplyRequest{ + Branch: cmd.Branch, + ThreadID: cmd.ThreadID, + Message: cmd.Message, } - threadIDs, err := loadReviewThreadIDs( - ctx, - reviewRepo, - b.Change.ChangeID(), - ) - if err != nil { - return err - } - threadID, err := reviewThreadID(threadIDs, cmd.ThreadID) - if err != nil { - return err + if cmd.Draft { + return drafts.SaveReplyDraft(ctx, req) } - - return postReviewComment( - ctx, - log, - reviewRepo, - b.Change.ChangeID(), - forge.SubmitReviewCommentRequest{ - Body: body, - ReplyTo: threadID, - }, - ) + return handler.PostReply(ctx, req) } diff --git a/review_resolution.go b/review_resolution.go index bb0cd19b6..f9ebbafdf 100644 --- a/review_resolution.go +++ b/review_resolution.go @@ -2,13 +2,10 @@ package main import ( "context" - "errors" "fmt" - "go.abhg.dev/gs/internal/forge" "go.abhg.dev/gs/internal/git" - "go.abhg.dev/gs/internal/silog" - "go.abhg.dev/gs/internal/spice" + "go.abhg.dev/gs/internal/handler/review" "go.abhg.dev/gs/internal/text" ) @@ -28,21 +25,22 @@ func (*reviewResolveCmd) Help() string { func (cmd *reviewResolveCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - forgeRepo forge.Repository, + handler ReviewThreadHandler, ) error { - return setReviewThreadResolved( - ctx, - log, - wt, - svc, - forgeRepo, - cmd.Branch, - cmd.ThreadID, - true, - ) + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + cmd.Branch = branch + } + + return handler.SetThreadResolution(ctx, &review.SetThreadResolutionRequest{ + Branch: cmd.Branch, + ThreadID: cmd.ThreadID, + Resolved: true, + }) } type reviewReopenCmd struct { @@ -61,73 +59,20 @@ func (*reviewReopenCmd) Help() string { func (cmd *reviewReopenCmd) Run( ctx context.Context, - log *silog.Logger, wt *git.Worktree, - svc *spice.Service, - forgeRepo forge.Repository, + handler ReviewThreadHandler, ) error { - return setReviewThreadResolved( - ctx, - log, - wt, - svc, - forgeRepo, - cmd.Branch, - cmd.ThreadID, - false, - ) -} - -func setReviewThreadResolved( - ctx context.Context, - log *silog.Logger, - wt *git.Worktree, - svc *spice.Service, - forgeRepo forge.Repository, - branch string, - thread string, - resolved bool, -) error { - branch, err := reviewBranch(ctx, wt, branch) - if err != nil { - return err - } - b, reviewRepo, err := reviewRepositoryForBranch( - ctx, svc, forgeRepo, branch, - ) - if err != nil { - return err - } - resolver, ok := forgeRepo.(forge.ReviewThreadResolver) - if !ok { - return errors.New( - "forge does not support review thread resolution", - ) - } - - threadIDs, err := loadReviewThreadIDs( - ctx, - reviewRepo, - b.Change.ChangeID(), - ) - if err != nil { - return err - } - threadID, err := reviewThreadID(threadIDs, thread) - if err != nil { - return err - } - - if resolved { - if err := resolver.ResolveReviewThread(ctx, threadID); err != nil { - return fmt.Errorf("resolve thread: %w", err) + if cmd.Branch == "" { + branch, err := wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) } - log.Infof("Resolved thread %s.", thread) - return nil + cmd.Branch = branch } - if err := resolver.UnresolveReviewThread(ctx, threadID); err != nil { - return fmt.Errorf("reopen thread: %w", err) - } - log.Infof("Reopened thread %s.", thread) - return nil + + return handler.SetThreadResolution(ctx, &review.SetThreadResolutionRequest{ + Branch: cmd.Branch, + ThreadID: cmd.ThreadID, + Resolved: false, + }) } diff --git a/review_test.go b/review_test.go new file mode 100644 index 000000000..78f0eb533 --- /dev/null +++ b/review_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/forge/forgetest" + "go.uber.org/mock/gomock" +) + +func TestRequireReviewRepository_unsupported(t *testing.T) { + ctrl := gomock.NewController(t) + remote := forgetest.NewMockRepository(ctrl) + remoteForge := forgetest.NewMockForge(ctrl) + + remote. + EXPECT(). + Forge(). + Return(remoteForge) + remoteForge. + EXPECT(). + ID(). + Return("test") + + got, err := requireReviewRepository(remote) + require.Error(t, err) + assert.ErrorIs(t, err, forge.ErrUnsupported) + assert.EqualError( + t, + err, + `forge "test" does not support review comments: unsupported operation`, + ) + assert.Nil(t, got) +} diff --git a/testdata/help/review_comment.txt b/testdata/help/review_comment.txt index 4141f0b23..9f0c6e0e7 100644 --- a/testdata/help/review_comment.txt +++ b/testdata/help/review_comment.txt @@ -1,4 +1,4 @@ -Usage: gs review comment [] [flags] +Usage: gs review comment [flags] Draft or post a review comment @@ -15,7 +15,7 @@ immediately. If no message is given with -m, an editor is opened. Arguments: - [] Comment anchor: file.go, file.go:42, or file.go:42-50. + Comment anchor: file.go, file.go:42, or file.go:42-50. Flags: -m, --message=MSG Comment body. Opens editor if not provided. diff --git a/testdata/script/review_list.txt b/testdata/script/review_list.txt index bda00cbc8..2aaa90e1c 100644 --- a/testdata/script/review_list.txt +++ b/testdata/script/review_list.txt @@ -30,9 +30,7 @@ stderr 'Posted comment' # list should show the comment gs review list -stderr 'Comments:' -stderr 'main.go:3' -stderr 'Consider using a constant' +cmp stderr $WORK/golden/list.txt # list on a branch with no CR should say so gs bc -m 'Another branch' feature2 @@ -45,3 +43,7 @@ package main func main() { println("hello") } +-- golden/list.txt -- +INF Comments: +INF 2 main.go:3 alice open [thread-2] +INF Consider using a constant. diff --git a/testdata/script/review_publish.txt b/testdata/script/review_publish.txt index 51d6e1e37..18e0c999a 100644 --- a/testdata/script/review_publish.txt +++ b/testdata/script/review_publish.txt @@ -41,6 +41,10 @@ stderr 'feature.go:7' gs review publish stderr 'Published 2 comment' +# review dispositions are mutually exclusive +! gs review publish --approve --request-changes +stderr '--approve and --request-changes' + # draft comments should be cleared after publication gs review list --draft-only stderr 'No draft comments' diff --git a/testdata/script/review_scopes.txt b/testdata/script/review_scopes.txt index ba8b1de0c..8b32d09c3 100644 --- a/testdata/script/review_scopes.txt +++ b/testdata/script/review_scopes.txt @@ -47,7 +47,7 @@ cmp stdout $WORK/golden/reviews.yaml # Missing anchor is rejected. ! gs review comment -m 'oops' --no-draft -stderr 'comment anchor is required' +stderr 'expected ""' -- repo/main.go -- package main