From dca7e44cbfff9803150bcdf74676d790844547e8 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Tue, 23 Jun 2026 10:41:07 -0400 Subject: [PATCH 1/6] restack: add Options parameter to RestackBranch/Stack/Downstack Introduces restack.Options{AutoResolve} and threads an opts *Options parameter through the high-level restack entry points (RestackBranch, RestackStack, RestackDownstack) and the merge/sync RestackHandler interfaces, with all current callers passing nil. This is foundational plumbing for the restack auto-resolve feature. Landing it on a branch below the consuming stacks lets every branch inherit the 3-arg signature, rather than the signature change living on a branch parallel to its callers. --- branch_restack.go | 2 +- downstack_restack.go | 2 +- internal/handler/merge/handler.go | 5 ++-- internal/handler/merge/handler_test.go | 6 ++--- internal/handler/merge/mocks_test.go | 13 ++++++----- internal/handler/restack/branch.go | 5 +++- internal/handler/restack/branch_test.go | 10 ++++---- internal/handler/restack/downstack.go | 5 +++- internal/handler/restack/downstack_test.go | 4 ++-- internal/handler/restack/handler.go | 27 ++++++++++++++++++++++ internal/handler/restack/stack.go | 5 +++- internal/handler/sync/handler.go | 4 ++-- internal/handler/sync/handler_test.go | 2 +- internal/handler/sync/mocks_test.go | 12 +++++----- stack_restack.go | 2 +- upstack_restack.go | 6 ++--- 16 files changed, 74 insertions(+), 36 deletions(-) diff --git a/branch_restack.go b/branch_restack.go index 32b8dc590..f84b803b3 100644 --- a/branch_restack.go +++ b/branch_restack.go @@ -32,5 +32,5 @@ func (cmd *branchRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) e } func (cmd *branchRestackCmd) Run(ctx context.Context, handler RestackHandler) error { - return handler.RestackBranch(ctx, cmd.Branch) + return handler.RestackBranch(ctx, cmd.Branch, nil) } diff --git a/downstack_restack.go b/downstack_restack.go index e3f95a420..87a273f65 100644 --- a/downstack_restack.go +++ b/downstack_restack.go @@ -44,5 +44,5 @@ func (cmd *downstackRestackCmd) Run( return errors.New("nothing to restack below trunk") } - return handler.RestackDownstack(ctx, cmd.Branch) + return handler.RestackDownstack(ctx, cmd.Branch, nil) } diff --git a/internal/handler/merge/handler.go b/internal/handler/merge/handler.go index 8172b2334..0f921e882 100644 --- a/internal/handler/merge/handler.go +++ b/internal/handler/merge/handler.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "go.abhg.dev/gs/internal/handler/restack" "go.abhg.dev/gs/internal/handler/submit" "go.abhg.dev/gs/internal/handler/sync" @@ -36,7 +37,7 @@ type Service interface { // RestackHandler restacks branches after their bases are merged. type RestackHandler interface { - RestackBranch(context.Context, string) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error } // SubmitHandler updates change requests after branch restacks. @@ -599,7 +600,7 @@ func (e *mergePlanExecutor) prepareForMerge( return fmt.Errorf("verify restacked: %w", err) } - if err := e.Restack.RestackBranch(ctx, item.branch); err != nil { + if err := e.Restack.RestackBranch(ctx, item.branch, nil); err != nil { return fmt.Errorf("restack branch: %w", err) } diff --git a/internal/handler/merge/handler_test.go b/internal/handler/merge/handler_test.go index 317d2a05b..f1d1f165f 100644 --- a/internal/handler/merge/handler_test.go +++ b/internal/handler/merge/handler_test.go @@ -317,10 +317,10 @@ func TestExecutePlan_retargets(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat2"). + RestackBranch(gomock.Any(), "feat2", gomock.Nil()). Return(nil) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat3"). + RestackBranch(gomock.Any(), "feat3", gomock.Nil()). Return(nil) mockSubmit := NewMockSubmitHandler(ctrl) @@ -396,7 +396,7 @@ func TestExecutePlan_waitsForPreparedChangeHeadBeforeChecks(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "feat2"). + RestackBranch(gomock.Any(), "feat2", gomock.Nil()). Return(nil) mockSubmit := NewMockSubmitHandler(ctrl) diff --git a/internal/handler/merge/mocks_test.go b/internal/handler/merge/mocks_test.go index 9d4523a57..c59410e48 100644 --- a/internal/handler/merge/mocks_test.go +++ b/internal/handler/merge/mocks_test.go @@ -13,6 +13,7 @@ import ( reflect "reflect" git "go.abhg.dev/gs/internal/git" + restack "go.abhg.dev/gs/internal/handler/restack" submit "go.abhg.dev/gs/internal/handler/submit" sync "go.abhg.dev/gs/internal/handler/sync" spice "go.abhg.dev/gs/internal/spice" @@ -207,17 +208,17 @@ func (m *MockRestackHandler) EXPECT() *MockRestackHandlerMockRecorder { } // RestackBranch mocks base method. -func (m *MockRestackHandler) RestackBranch(arg0 context.Context, arg1 string) error { +func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string, opts *restack.Options) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RestackBranch", arg0, arg1) + ret := m.ctrl.Call(m, "RestackBranch", ctx, branch, opts) ret0, _ := ret[0].(error) return ret0 } // RestackBranch indicates an expected call of RestackBranch. -func (mr *MockRestackHandlerMockRecorder) RestackBranch(arg0, arg1 any) *MockRestackHandlerRestackBranchCall { +func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch, opts any) *MockRestackHandlerRestackBranchCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), arg0, arg1) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch, opts) return &MockRestackHandlerRestackBranchCall{Call: call} } @@ -233,13 +234,13 @@ func (c *MockRestackHandlerRestackBranchCall) Return(arg0 error) *MockRestackHan } // Do rewrite *gomock.Call.Do -func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/internal/handler/restack/branch.go b/internal/handler/restack/branch.go index 8d880183f..889d9a272 100644 --- a/internal/handler/restack/branch.go +++ b/internal/handler/restack/branch.go @@ -3,10 +3,13 @@ package restack import "context" // RestackBranch restacks the given branch onto its base. -func (h *Handler) RestackBranch(ctx context.Context, branch string) error { +func (h *Handler) RestackBranch( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, ContinueCommand: []string{"branch", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/restack/branch_test.go b/internal/handler/restack/branch_test.go index 95664e688..c7435831c 100644 --- a/internal/handler/restack/branch_test.go +++ b/internal/handler/restack/branch_test.go @@ -47,7 +47,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "feature")) + require.NoError(t, handler.RestackBranch(t.Context(), "feature", nil)) assert.Contains(t, logBuffer.String(), "feature: restacked on main") }) @@ -82,7 +82,7 @@ func TestHandler_RestackBranch(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "feature")) + require.NoError(t, handler.RestackBranch(t.Context(), "feature", nil)) }) t.Run("UntrackedBranch", func(t *testing.T) { @@ -113,7 +113,7 @@ func TestHandler_RestackBranch(t *testing.T) { Service: mockService, } - err := handler.RestackBranch(t.Context(), "untracked") + err := handler.RestackBranch(t.Context(), "untracked", nil) require.Error(t, err) assert.ErrorContains(t, err, "untracked branch") assert.Contains(t, logBuffer.String(), "untracked: branch not tracked: run '"+cli.Name()+" branch track") @@ -146,7 +146,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - require.NoError(t, handler.RestackBranch(t.Context(), "already-restacked")) + require.NoError(t, handler.RestackBranch(t.Context(), "already-restacked", nil)) assert.Contains(t, logBuffer.String(), "already-restacked: branch does not need to be restacked.") }) @@ -177,7 +177,7 @@ func TestHandler_RestackBranch(t *testing.T) { Store: statetest.NewMemoryStore(t, "main", "", log), Service: mockService, } - err := handler.RestackBranch(t.Context(), "feature") + err := handler.RestackBranch(t.Context(), "feature", nil) require.Error(t, err) assert.ErrorContains(t, err, "restack branch") assert.ErrorIs(t, err, unexpectedErr) diff --git a/internal/handler/restack/downstack.go b/internal/handler/restack/downstack.go index e4df6f7ab..60c0ab42d 100644 --- a/internal/handler/restack/downstack.go +++ b/internal/handler/restack/downstack.go @@ -4,11 +4,14 @@ import "context" // RestackDownstack restacks the downstack of the given branch. // This includes the branch itself. -func (h *Handler) RestackDownstack(ctx context.Context, branch string) error { +func (h *Handler) RestackDownstack( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, Scope: ScopeDownstack, ContinueCommand: []string{"downstack", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/restack/downstack_test.go b/internal/handler/restack/downstack_test.go index fa633e067..a99ac3399 100644 --- a/internal/handler/restack/downstack_test.go +++ b/internal/handler/restack/downstack_test.go @@ -54,7 +54,7 @@ func TestHandler_RestackDownstack(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackDownstack(t.Context(), "feature3")) + require.NoError(t, handler.RestackDownstack(t.Context(), "feature3", nil)) assert.Contains(t, logBuffer.String(), "feature1: restacked on main") assert.Contains(t, logBuffer.String(), "feature2: restacked on feature1") assert.Contains(t, logBuffer.String(), "feature3: restacked on feature2") @@ -99,6 +99,6 @@ func TestHandler_RestackDownstack(t *testing.T) { Service: mockService, } - require.NoError(t, handler.RestackDownstack(t.Context(), "feature2")) + require.NoError(t, handler.RestackDownstack(t.Context(), "feature2", nil)) }) } diff --git a/internal/handler/restack/handler.go b/internal/handler/restack/handler.go index 7ff1f6d45..93d0dadc1 100644 --- a/internal/handler/restack/handler.go +++ b/internal/handler/restack/handler.go @@ -93,6 +93,33 @@ type Request struct { // // Defaults to ScopeBranch. Scope Scope + + // AutoResolve, if non-nil, overrides + // [Handler.DefaultAutoResolve] for this invocation. A true + // value enables the configured resolver; a false value disables + // it even when configured. + AutoResolve *bool +} + +// Options are optional parameters shared by the high-level restack +// entry points ([Handler.RestackBranch], [Handler.RestackStack], +// [Handler.RestackDownstack]). +type Options struct { + // AutoResolve, if non-nil, overrides + // [Handler.DefaultAutoResolve] for this invocation. A true + // value enables the configured resolver; a false value disables + // it even when configured. + AutoResolve *bool +} + +// autoResolvePtr returns the AutoResolve pointer if opts is +// non-nil, or nil. Callers pass the result through to +// [Request.AutoResolve]. +func (o *Options) autoResolvePtr() *bool { + if o == nil { + return nil + } + return o.AutoResolve } // Restack restacks one or more branches according to the request. diff --git a/internal/handler/restack/stack.go b/internal/handler/restack/stack.go index f37ad358b..592e99eab 100644 --- a/internal/handler/restack/stack.go +++ b/internal/handler/restack/stack.go @@ -5,11 +5,14 @@ import "context" // RestackStack restacks the stack of the given branch. // This includes all upstack and downtrack branches, // as well as the branch itself. -func (h *Handler) RestackStack(ctx context.Context, branch string) error { +func (h *Handler) RestackStack( + ctx context.Context, branch string, opts *Options, +) error { _, err := h.Restack(ctx, &Request{ Branch: branch, Scope: ScopeStack, ContinueCommand: []string{"stack", "restack"}, + AutoResolve: opts.autoResolvePtr(), }) return err } diff --git a/internal/handler/sync/handler.go b/internal/handler/sync/handler.go index 4aa7da792..af116412d 100644 --- a/internal/handler/sync/handler.go +++ b/internal/handler/sync/handler.go @@ -78,7 +78,7 @@ type DeleteHandler interface { // RestackHandler allows restacking branches after sync // has removed their downstack branches. type RestackHandler interface { - RestackBranch(ctx context.Context, branch string) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error RestackUpstack(ctx context.Context, branch string, opts *restack.UpstackOptions) error } @@ -983,7 +983,7 @@ func (h *Handler) restackDeletedBranchUpstack( return fmt.Errorf("restack upstack %q: %w", target, err) } case mode.Includes(spice.RestackAboves): - if err := h.Restack.RestackBranch(ctx, target); err != nil { + if err := h.Restack.RestackBranch(ctx, target, nil); err != nil { return fmt.Errorf("restack branch %q: %w", target, err) } case mode.Includes(spice.RestackNone): diff --git a/internal/handler/sync/handler_test.go b/internal/handler/sync/handler_test.go index b6450f281..415897d24 100644 --- a/internal/handler/sync/handler_test.go +++ b/internal/handler/sync/handler_test.go @@ -368,7 +368,7 @@ func TestHandler_SyncTrunk_restackDeletedUpstacks(t *testing.T) { mockRestack := NewMockRestackHandler(ctrl) mockRestack.EXPECT(). - RestackBranch(gomock.Any(), "child"). + RestackBranch(gomock.Any(), "child", gomock.Nil()). Return(nil) mockAutostash := NewMockAutostashHandler(ctrl) diff --git a/internal/handler/sync/mocks_test.go b/internal/handler/sync/mocks_test.go index 51c7adcf5..de0af2909 100644 --- a/internal/handler/sync/mocks_test.go +++ b/internal/handler/sync/mocks_test.go @@ -782,17 +782,17 @@ func (m *MockRestackHandler) EXPECT() *MockRestackHandlerMockRecorder { } // RestackBranch mocks base method. -func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string) error { +func (m *MockRestackHandler) RestackBranch(ctx context.Context, branch string, opts *restack.Options) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RestackBranch", ctx, branch) + ret := m.ctrl.Call(m, "RestackBranch", ctx, branch, opts) ret0, _ := ret[0].(error) return ret0 } // RestackBranch indicates an expected call of RestackBranch. -func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch any) *MockRestackHandlerRestackBranchCall { +func (mr *MockRestackHandlerMockRecorder) RestackBranch(ctx, branch, opts any) *MockRestackHandlerRestackBranchCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RestackBranch", reflect.TypeOf((*MockRestackHandler)(nil).RestackBranch), ctx, branch, opts) return &MockRestackHandlerRestackBranchCall{Call: call} } @@ -808,13 +808,13 @@ func (c *MockRestackHandlerRestackBranchCall) Return(arg0 error) *MockRestackHan } // Do rewrite *gomock.Call.Do -func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) Do(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string) error) *MockRestackHandlerRestackBranchCall { +func (c *MockRestackHandlerRestackBranchCall) DoAndReturn(f func(context.Context, string, *restack.Options) error) *MockRestackHandlerRestackBranchCall { c.Call = c.Call.DoAndReturn(f) return c } diff --git a/stack_restack.go b/stack_restack.go index 88ef562be..7d7b4d689 100644 --- a/stack_restack.go +++ b/stack_restack.go @@ -88,5 +88,5 @@ func (cmd *stackRestackCmd) Run( return err } - return handler.RestackStack(ctx, cmd.Branch) + return handler.RestackStack(ctx, cmd.Branch, nil) } diff --git a/upstack_restack.go b/upstack_restack.go index f6e6e0520..4def1ada7 100644 --- a/upstack_restack.go +++ b/upstack_restack.go @@ -34,10 +34,10 @@ func (*upstackRestackCmd) Help() string { // RestackHandler implements high level restack operations. type RestackHandler interface { RestackUpstack(ctx context.Context, branch string, opts *restack.UpstackOptions) error - RestackDownstack(ctx context.Context, branch string) error + RestackDownstack(ctx context.Context, branch string, opts *restack.Options) error Restack(context.Context, *restack.Request) (int, error) - RestackStack(ctx context.Context, branch string) error - RestackBranch(ctx context.Context, branch string) error + RestackStack(ctx context.Context, branch string, opts *restack.Options) error + RestackBranch(ctx context.Context, branch string, opts *restack.Options) error } func (cmd *upstackRestackCmd) AfterApply(ctx context.Context, wt *git.Worktree) error { From b382179d327bbf7d0977e3eab52e534abae73c57 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 09:33:52 -0400 Subject: [PATCH 2/6] branch comment: add inline comment support for code review Add inline comment infrastructure across all forges (GitHub, GitLab, Bitbucket, ShamHub) with three optional interfaces: - WithInlineComments: list, post, and batch-submit inline comments - WithThreadResolution: resolve/unresolve review threads - WithCommentEdit: edit existing review comments Refactor Bitbucket and GitLab implementations to use typed gateway client methods (CommentCreate, CommentResolve, MergeRequestDiscussionCreate, etc.) in place of raw HTTP calls, and add reply support for Bitbucket inline comments via the Parent field on CommentCreateRequest. Introduce the diffmap package for mapping working-tree line numbers to diff coordinates, and staged comment storage in the spice state for local comment management before batch submission. Regenerate forge mocks to reflect the updated MergeChange signature and the new inline-comment interfaces. --- internal/diffmap/mapper.go | 281 +++ internal/diffmap/mapper_test.go | 198 ++ internal/forge/bitbucket/inline_comment.go | 333 +++ internal/forge/bitbucket/operations_test.go | 159 ++ internal/forge/forge.go | 134 +- internal/forge/forgetest/mocks.go | 2109 ++++++++++++++++- internal/forge/github/inline_comment.go | 377 +++ internal/forge/gitlab/inline_comment.go | 418 ++++ internal/forge/shamhub/comment.go | 24 +- internal/forge/shamhub/inline_comment.go | 459 ++++ .../UpdateComment/updated-comment | 2 +- .../TestIntegration/ChangeComments/branch | 2 +- .../TestIntegration/ChangeComments/comments | 20 +- .../ChangesStates/closedBranch | 2 +- .../ChangesStates/mergedBranch | 2 +- .../TestIntegration/ChangesStates/openBranch | 2 +- .../CommentCountsByChange/branch | 2 +- .../TemplatesPresent/empty-template | 2 +- .../TemplatesPresent/non-empty-template | 2 +- .../SubmitBaseDoesNotExist/base-branch | 2 +- .../SubmitBaseDoesNotExist/branch | 2 +- .../AddAssignee/branch-no-assignee | 2 +- .../branch-no-assignee-one-by-one | 2 +- .../SubmitWithAssignee/branch-with-assignee | 2 +- .../TestIntegration/SubmitEditBase/base | 2 +- .../TestIntegration/SubmitEditBase/branch | 2 +- .../TestIntegration/SubmitEditChange/branch | 2 +- .../SubmitEditChange/firstCommitHash | 2 +- .../TestIntegration/SubmitEditDraft/branch | 2 +- .../TestIntegration/SubmitEditLabels/branch | 2 +- .../TestIntegration/SubmitEditLabels/label1 | 2 +- .../TestIntegration/SubmitEditLabels/label2 | 2 +- .../TestIntegration/SubmitEditLabels/label3 | 2 +- .../AddReviewer/branch-no-reviewer | 2 +- .../branch-no-reviewer-one-by-one | 2 +- .../SubmitWithReviewer/branch-with-reviewer | 2 +- .../shamhub/testdata/TestIntegration/apiURL | 2 +- .../shamhub/testdata/TestIntegration/gitURL | 2 +- .../testdata/TestIntegration/pushRepoURL | 2 +- .../shamhub/testdata/TestIntegration/repoURL | 2 +- .../shamhub/testdata/TestIntegration/token | 2 +- .../ChangeChecksState/Failed.yaml | 27 +- .../ChangeChecksState/Passed.yaml | 27 +- .../ChangeChecksState/Pending.yaml | 27 +- .../TestIntegration/ChangeComments.yaml | 180 +- .../TestIntegration/ChangesStates.yaml | 56 +- .../CommentCountsByChange.yaml | 26 +- .../FindChangesByBranchDoesNotExist.yaml | 6 +- .../ListChangeTemplates/NoTemplates.yaml | 6 +- .../ListChangeTemplates/TemplatesPresent.yaml | 14 +- .../SubmitBaseDoesNotExist.yaml | 16 +- .../SubmitChangeFromPushRepository.yaml | 22 +- .../SubmitEditAssignees/AddAssignee.yaml | 48 +- .../AddAssigneesOneByOne.yaml | 42 +- .../SubmitWithAssignee.yaml | 48 +- .../TestIntegration/SubmitEditBase.yaml | 50 +- .../TestIntegration/SubmitEditChange.yaml | 48 +- .../TestIntegration/SubmitEditDraft.yaml | 78 +- .../TestIntegration/SubmitEditLabels.yaml | 66 +- .../SubmitEditReviewers/AddReviewer.yaml | 54 +- .../AddReviewersOneByOne.yaml | 54 +- .../SubmitWithReviewer.yaml | 30 +- internal/gateway/bitbucket/api.go | 46 + internal/gateway/gitlab/api.go | 178 +- internal/spice/state/staged_comment.go | 121 + internal/spice/state/staged_comment_test.go | 128 + 66 files changed, 5400 insertions(+), 570 deletions(-) create mode 100644 internal/diffmap/mapper.go create mode 100644 internal/diffmap/mapper_test.go create mode 100644 internal/forge/bitbucket/inline_comment.go create mode 100644 internal/forge/github/inline_comment.go create mode 100644 internal/forge/gitlab/inline_comment.go create mode 100644 internal/forge/shamhub/inline_comment.go create mode 100644 internal/spice/state/staged_comment.go create mode 100644 internal/spice/state/staged_comment_test.go diff --git a/internal/diffmap/mapper.go b/internal/diffmap/mapper.go new file mode 100644 index 000000000..d309eae77 --- /dev/null +++ b/internal/diffmap/mapper.go @@ -0,0 +1,281 @@ +// Package diffmap maps working-tree line numbers +// to diff hunk positions for inline code review comments. +package diffmap + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "slices" + "strconv" + "strings" +) + +// Mapper maps file:line references to diff coordinates. +type Mapper struct { + // files maps file paths to their diff hunks. + files map[string]*fileDiff +} + +type fileDiff struct { + // hunks are the diff hunks for the file, + // in order of appearance. + hunks []hunk +} + +type hunk struct { + // newStart is the starting line number + // in the new version of the file. + newStart int + + // newCount is the number of lines + // in the new version of the hunk. + newCount int + + // oldStart is the starting line number + // in the old version of the file. + oldStart int + + // oldCount is the number of lines + // in the old version of the hunk. + oldCount int + + // lines are the diff lines in the hunk. + lines []diffLine +} + +type diffLine struct { + // op is the diff operation: ' ', '+', or '-'. + op byte + + // newLineNo is the line number in the new file. + // Zero for deleted lines. + newLineNo int + + // oldLineNo is the line number in the old file. + // Zero for added lines. + oldLineNo int +} + +// New creates a Mapper from unified diff output. +// The diff should be the output of git diff base...HEAD. +func New(diff []byte) (*Mapper, error) { + m := &Mapper{ + files: make(map[string]*fileDiff), + } + + scanner := bufio.NewScanner(bytes.NewReader(diff)) + var currentFile string + var currentHunk *hunk + + for scanner.Scan() { + line := scanner.Text() + + // Detect file header: "diff --git a/path b/path" + if strings.HasPrefix(line, "diff --git ") { + currentFile = "" + currentHunk = nil + continue + } + + // Detect new file path: "+++ b/path" + if path, ok := strings.CutPrefix(line, "+++ b/"); ok { + currentFile = path + if _, ok := m.files[currentFile]; !ok { + m.files[currentFile] = &fileDiff{} + } + continue + } + + // Detect rename/copy target: + // "rename to path" or "copy to path" + if path, ok := strings.CutPrefix(line, "rename to "); ok { + currentFile = path + if _, ok := m.files[currentFile]; !ok { + m.files[currentFile] = &fileDiff{} + } + continue + } + if path, ok := strings.CutPrefix(line, "copy to "); ok { + currentFile = path + if _, ok := m.files[currentFile]; !ok { + m.files[currentFile] = &fileDiff{} + } + continue + } + + // Skip if no file context yet. + if currentFile == "" { + continue + } + + // Detect hunk header: "@@ -old,count +new,count @@" + if strings.HasPrefix(line, "@@ ") { + h, err := parseHunkHeader(line) + if err != nil { + return nil, fmt.Errorf( + "parse hunk header %q: %w", + line, err, + ) + } + fd := m.files[currentFile] + fd.hunks = append(fd.hunks, h) + currentHunk = &fd.hunks[len(fd.hunks)-1] + continue + } + + // Process diff lines within a hunk. + if currentHunk == nil || len(line) == 0 { + continue + } + + op := line[0] + switch op { + case ' ': + // Context line: present in both old and new. + dl := diffLine{ + op: ' ', + oldLineNo: currentHunk.oldStart + countOp(currentHunk.lines, ' ', '-'), + newLineNo: currentHunk.newStart + countOp(currentHunk.lines, ' ', '+'), + } + currentHunk.lines = append(currentHunk.lines, dl) + case '+': + // Added line: only in new. + dl := diffLine{ + op: '+', + newLineNo: currentHunk.newStart + countOp(currentHunk.lines, ' ', '+'), + } + currentHunk.lines = append(currentHunk.lines, dl) + case '-': + // Deleted line: only in old. + dl := diffLine{ + op: '-', + oldLineNo: currentHunk.oldStart + countOp(currentHunk.lines, ' ', '-'), + } + currentHunk.lines = append(currentHunk.lines, dl) + case '\\': + // "\ No newline at end of file" — skip. + default: + // Unknown line type — skip. + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan diff: %w", err) + } + + return m, nil +} + +// Map converts a working-tree file:line reference +// to diff coordinates suitable for forge inline comments. +// +// Returns the file path, the diff line number, +// and the side ("LEFT" or "RIGHT"). +// +// Returns an error if the file or line +// is not part of the diff. +func (m *Mapper) Map(file string, line int) ( + path string, diffLine int, side string, err error, +) { + fd, ok := m.files[file] + if !ok { + return "", 0, "", + fmt.Errorf("file %q not in diff", file) + } + + for _, h := range fd.hunks { + for _, dl := range h.lines { + if dl.newLineNo == line && + (dl.op == '+' || dl.op == ' ') { + return file, dl.newLineNo, "RIGHT", nil + } + } + } + + return "", 0, "", fmt.Errorf( + "line %d of %q not in diff", line, file, + ) +} + +// Files returns all file paths present in the diff. +func (m *Mapper) Files() []string { + var files []string + for f := range m.files { + files = append(files, f) + } + return files +} + +// parseHunkHeader parses "@@ -old,count +new,count @@". +func parseHunkHeader(line string) (hunk, error) { + // Strip "@@ " prefix and " @@..." suffix. + line = strings.TrimPrefix(line, "@@ ") + idx := strings.Index(line, " @@") + if idx < 0 { + return hunk{}, + errors.New("missing @@ terminator") + } + line = line[:idx] + + parts := strings.SplitN(line, " ", 2) + if len(parts) != 2 { + return hunk{}, + errors.New("expected old and new ranges") + } + + oldStart, oldCount, err := parseRange( + strings.TrimPrefix(parts[0], "-"), + ) + if err != nil { + return hunk{}, + fmt.Errorf("parse old range: %w", err) + } + + newStart, newCount, err := parseRange( + strings.TrimPrefix(parts[1], "+"), + ) + if err != nil { + return hunk{}, + fmt.Errorf("parse new range: %w", err) + } + + return hunk{ + oldStart: oldStart, + oldCount: oldCount, + newStart: newStart, + newCount: newCount, + }, nil +} + +// parseRange parses "start,count" or "start" (count=1). +func parseRange(s string) (start, count int, err error) { + parts := strings.SplitN(s, ",", 2) + start, err = strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, + fmt.Errorf("parse start: %w", err) + } + if len(parts) == 1 { + return start, 1, nil + } + count, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, + fmt.Errorf("parse count: %w", err) + } + return start, count, nil +} + +// countOp counts lines in the hunk +// that match any of the given operations. +func countOp(lines []diffLine, ops ...byte) int { + n := 0 + for _, l := range lines { + if slices.Contains(ops, l.op) { + n++ + } + } + return n +} diff --git a/internal/diffmap/mapper_test.go b/internal/diffmap/mapper_test.go new file mode 100644 index 000000000..6abbb5fe1 --- /dev/null +++ b/internal/diffmap/mapper_test.go @@ -0,0 +1,198 @@ +package diffmap + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMapper_Map(t *testing.T) { + tests := []struct { + name string + diff string + file string + line int + wantPath string + wantLine int + wantSide string + wantErr string + }{ + { + name: "AddedLine", + diff: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main + ++func hello() {} + func main() {} +`, + file: "main.go", + line: 3, + wantPath: "main.go", + wantLine: 3, + wantSide: "RIGHT", + }, + { + name: "ContextLine", + diff: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main + ++func hello() {} + func main() {} +`, + file: "main.go", + line: 1, + wantPath: "main.go", + wantLine: 1, + wantSide: "RIGHT", + }, + { + name: "MultipleHunks", + diff: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main + ++import "fmt" + func main() {} +@@ -10,3 +11,4 @@ + func foo() {} + ++func bar() {} + func baz() {} +`, + file: "main.go", + line: 13, + wantPath: "main.go", + wantLine: 13, + wantSide: "RIGHT", + }, + { + name: "FileNotInDiff", + diff: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main + ++func hello() {} + func main() {} +`, + file: "other.go", + line: 1, + wantErr: `file "other.go" not in diff`, + }, + { + name: "LineNotInDiff", + diff: `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,3 +1,4 @@ + package main + ++func hello() {} + func main() {} +`, + file: "main.go", + line: 100, + wantErr: `line 100 of "main.go" not in diff`, + }, + { + name: "MultipleFiles", + diff: `diff --git a/a.go b/a.go +--- a/a.go ++++ b/a.go +@@ -1,2 +1,3 @@ + package a ++func A() {} + +diff --git a/b.go b/b.go +--- a/b.go ++++ b/b.go +@@ -1,2 +1,3 @@ + package b ++func B() {} + +`, + file: "b.go", + line: 2, + wantPath: "b.go", + wantLine: 2, + wantSide: "RIGHT", + }, + { + name: "NewFile", + diff: `diff --git a/new.go b/new.go +new file mode 100644 +--- /dev/null ++++ b/new.go +@@ -0,0 +1,3 @@ ++package new ++ ++func New() {} +`, + file: "new.go", + line: 1, + wantPath: "new.go", + wantLine: 1, + wantSide: "RIGHT", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m, err := New([]byte(tt.diff)) + require.NoError(t, err) + + path, line, side, err := m.Map(tt.file, tt.line) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantPath, path) + assert.Equal(t, tt.wantLine, line) + assert.Equal(t, tt.wantSide, side) + }) + } +} + +func TestMapper_Files(t *testing.T) { + diff := `diff --git a/a.go b/a.go +--- a/a.go ++++ b/a.go +@@ -1,2 +1,3 @@ + package a ++func A() {} + +diff --git a/b.go b/b.go +--- a/b.go ++++ b/b.go +@@ -1,2 +1,3 @@ + package b ++func B() {} + +` + m, err := New([]byte(diff)) + require.NoError(t, err) + + files := m.Files() + assert.Len(t, files, 2) + assert.Contains(t, files, "a.go") + assert.Contains(t, files, "b.go") +} + +func TestNew_EmptyDiff(t *testing.T) { + m, err := New([]byte("")) + require.NoError(t, err) + assert.Empty(t, m.Files()) +} diff --git a/internal/forge/bitbucket/inline_comment.go b/internal/forge/bitbucket/inline_comment.go new file mode 100644 index 000000000..6772a091f --- /dev/null +++ b/internal/forge/bitbucket/inline_comment.go @@ -0,0 +1,333 @@ +package bitbucket + +import ( + "context" + "fmt" + "strconv" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/gateway/bitbucket" +) + +var ( + _ forge.WithInlineComments = (*Repository)(nil) + _ forge.WithThreadResolution = (*Repository)(nil) + _ forge.WithCommentEdit = (*Repository)(nil) +) + +// ListInlineComments lists inline/review comments on a PR +// by filtering comments that have inline position data. +func (r *Repository) ListInlineComments( + ctx context.Context, + id forge.ChangeID, +) ([]*forge.InlineComment, error) { + prID := mustPR(id).Number + + var comments []*forge.InlineComment + opts := &bitbucket.CommentListOptions{} + for { + page, _, err := r.client.CommentList( + ctx, r.workspace, r.repo, prID, opts, + ) + if err != nil { + return nil, fmt.Errorf( + "list comments: %w", err, + ) + } + + for _, c := range page.Values { + if c.Inline == nil { + continue + } + + line := 0 + if c.Inline.To != nil { + line = *c.Inline.To + } + + // Thread ID encodes comment ID and PR ID + // for resolve/unresolve operations. + threadID := formatThreadID(c.ID, prID) + + comments = append(comments, + &forge.InlineComment{ + ID: &PRComment{ + ID: c.ID, + PRID: prID, + }, + ThreadID: threadID, + Path: c.Inline.Path, + Line: line, + Body: c.Content.Raw, + Author: c.User.DisplayName, + Resolved: c.Resolution != nil, + }) + } + + if page.Next == "" { + break + } + opts.PageURL = page.Next + } + + return comments, nil +} + +// SubmitReview posts a batch of inline comments +// as individual comments on a PR. +// Bitbucket does not have a native batch review API, +// so each comment is posted separately. +func (r *Repository) SubmitReview( + ctx context.Context, + id forge.ChangeID, + req forge.ReviewRequest, +) error { + prID := mustPR(id).Number + + for _, c := range req.Comments { + if err := r.submitOneComment( + ctx, prID, c, + ); err != nil { + return err + } + } + + r.log.Debug("Submitted review", + "pr", prID, + "comments", len(req.Comments), + ) + return nil +} + +func (r *Repository) submitOneComment( + ctx context.Context, + prID int64, + c forge.InlineCommentRequest, +) error { + if c.ThreadID != "" { + parentID, _, err := parseThreadID(c.ThreadID) + if err != nil { + return fmt.Errorf("parse thread ID: %w", err) + } + _, err = r.replyToComment( + ctx, prID, parentID, c.Body, + ) + return err + } + _, err := r.postInlineCommentAPI( + ctx, prID, c.Path, c.Line, c.Body, + ) + return err +} + +// PostInlineComment posts a single inline comment on a PR. +// If req.ThreadID is set, posts a reply to that thread +// instead of creating a new inline comment. +func (r *Repository) PostInlineComment( + ctx context.Context, + id forge.ChangeID, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + prID := mustPR(id).Number + + if req.ThreadID != "" { + return r.postReply(ctx, prID, req) + } + return r.postNewInlineComment(ctx, prID, req) +} + +func (r *Repository) postNewInlineComment( + ctx context.Context, + prID int64, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + comment, err := r.postInlineCommentAPI( + ctx, prID, req.Path, req.Line, req.Body, + ) + if err != nil { + return nil, err + } + + return &forge.InlineComment{ + ID: &PRComment{ + ID: comment.ID, + PRID: prID, + }, + ThreadID: formatThreadID(comment.ID, prID), + Path: req.Path, + Line: req.Line, + Body: req.Body, + Author: comment.User.DisplayName, + }, nil +} + +func (r *Repository) postReply( + ctx context.Context, + prID int64, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + parentID, _, err := parseThreadID(req.ThreadID) + if err != nil { + return nil, fmt.Errorf("parse thread ID: %w", err) + } + + comment, err := r.replyToComment( + ctx, prID, parentID, req.Body, + ) + if err != nil { + return nil, err + } + + return &forge.InlineComment{ + ID: &PRComment{ + ID: comment.ID, + PRID: prID, + }, + ThreadID: req.ThreadID, + Body: req.Body, + Author: comment.User.DisplayName, + }, nil +} + +func (r *Repository) replyToComment( + ctx context.Context, + prID, parentID int64, + body string, +) (*bitbucket.Comment, error) { + comment, _, err := r.client.CommentCreate( + ctx, r.workspace, r.repo, prID, + &bitbucket.CommentCreateRequest{ + Content: bitbucket.Content{Raw: body}, + Parent: &bitbucket.CommentRef{ID: parentID}, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "reply to comment %d: %w", + parentID, err, + ) + } + return comment, nil +} + +func (r *Repository) postInlineCommentAPI( + ctx context.Context, + prID int64, + filePath string, + line int, + body string, +) (*bitbucket.Comment, error) { + to := line + comment, _, err := r.client.CommentCreate( + ctx, r.workspace, r.repo, prID, + &bitbucket.CommentCreateRequest{ + Content: bitbucket.Content{Raw: body}, + Inline: &bitbucket.Inline{ + Path: filePath, + To: &to, + }, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "create inline comment on %s:%d: %w", + filePath, line, err, + ) + } + return comment, nil +} + +// ResolveThread marks an inline comment as resolved. +func (r *Repository) ResolveThread( + ctx context.Context, + threadID string, +) error { + commentID, prID, err := parseThreadID(threadID) + if err != nil { + return err + } + + if _, _, err := r.client.CommentResolve( + ctx, r.workspace, r.repo, prID, commentID, + &bitbucket.CommentResolveRequest{ + Resolution: &bitbucket.Resolution{Type: "resolved"}, + }, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + + r.log.Debug("Resolved thread", "threadID", threadID) + return nil +} + +// UnresolveThread marks an inline comment as unresolved. +func (r *Repository) UnresolveThread( + ctx context.Context, + threadID string, +) error { + commentID, prID, err := parseThreadID(threadID) + if err != nil { + return err + } + + if _, _, err := r.client.CommentResolve( + ctx, r.workspace, r.repo, prID, commentID, + &bitbucket.CommentResolveRequest{ + Resolution: nil, + }, + ); err != nil { + return fmt.Errorf("unresolve thread: %w", err) + } + + r.log.Debug("Unresolved thread", "threadID", threadID) + return nil +} + +// EditComment updates the body of an existing comment. +func (r *Repository) EditComment( + ctx context.Context, + id forge.ChangeCommentID, + body string, +) error { + comment := mustPRComment(id) + return r.updateComment(ctx, comment.PRID, comment.ID, body) +} + +// Thread ID encoding for Bitbucket: +// "commentID:prID" + +func formatThreadID(commentID, prID int64) string { + return strconv.FormatInt(commentID, 10) + + ":" + strconv.FormatInt(prID, 10) +} + +func parseThreadID(threadID string) ( + commentID, prID int64, err error, +) { + for i := len(threadID) - 1; i >= 0; i-- { + if threadID[i] == ':' { + commentID, err = strconv.ParseInt( + threadID[:i], 10, 64, + ) + if err != nil { + return 0, 0, fmt.Errorf( + "parse thread ID %q: %w", + threadID, err, + ) + } + prID, err = strconv.ParseInt( + threadID[i+1:], 10, 64, + ) + if err != nil { + return 0, 0, fmt.Errorf( + "parse thread ID %q: %w", + threadID, err, + ) + } + return commentID, prID, nil + } + } + return 0, 0, fmt.Errorf( + "invalid thread ID format: %q", threadID, + ) +} diff --git a/internal/forge/bitbucket/operations_test.go b/internal/forge/bitbucket/operations_test.go index e2cf4566b..9f7b6845a 100644 --- a/internal/forge/bitbucket/operations_test.go +++ b/internal/forge/bitbucket/operations_test.go @@ -675,6 +675,165 @@ func TestStateToAPI(t *testing.T) { } } +func TestPostInlineComment(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, r *http.Request, + ) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, + "/pullrequests/1/comments", + ) + + var req bitbucket.CommentCreateRequest + require.NoError(t, + json.NewDecoder(r.Body).Decode(&req), + ) + assert.Equal(t, "new comment", req.Content.Raw) + assert.Equal(t, "file.go", req.Inline.Path) + require.NotNil(t, req.Inline.To) + assert.Equal(t, 42, *req.Inline.To) + + resp := bitbucket.Comment{ + ID: 10, + Content: bitbucket.Content{Raw: req.Content.Raw}, + Inline: &bitbucket.Inline{ + Path: req.Inline.Path, + To: req.Inline.To, + }, + User: bitbucket.User{DisplayName: "alice"}, + } + assert.NoError(t, + json.NewEncoder(w).Encode(resp), + ) + }), + ) + defer srv.Close() + + repo := newTestRepository(srv.URL) + posted, err := repo.PostInlineComment( + t.Context(), + &PR{Number: 1}, + forge.InlineCommentRequest{ + Path: "file.go", + Line: 42, + Body: "new comment", + }, + ) + require.NoError(t, err) + + assert.Equal(t, "new comment", posted.Body) + assert.Equal(t, "file.go", posted.Path) + assert.Equal(t, 42, posted.Line) +} + +func TestPostInlineComment_reply(t *testing.T) { + srv := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, r *http.Request, + ) { + assert.Equal(t, http.MethodPost, r.Method) + + // Decode as raw JSON to verify structure. + var raw map[string]json.RawMessage + require.NoError(t, + json.NewDecoder(r.Body).Decode(&raw), + ) + + // Must have parent.id, not inline. + assert.Contains(t, raw, "parent", + "reply must include parent field", + ) + assert.NotContains(t, raw, "inline", + "reply must not include inline field", + ) + + var parent bitbucket.CommentRef + require.NoError(t, + json.Unmarshal(raw["parent"], &parent), + ) + assert.Equal(t, int64(99), parent.ID) + + resp := bitbucket.Comment{ + ID: 20, + Content: bitbucket.Content{Raw: "reply body"}, + User: bitbucket.User{DisplayName: "bob"}, + } + assert.NoError(t, + json.NewEncoder(w).Encode(resp), + ) + }), + ) + defer srv.Close() + + repo := newTestRepository(srv.URL) + posted, err := repo.PostInlineComment( + t.Context(), + &PR{Number: 1}, + forge.InlineCommentRequest{ + Body: "reply body", + ThreadID: "99:1", // commentID:prID + }, + ) + require.NoError(t, err) + + assert.Equal(t, "reply body", posted.Body) + assert.Equal(t, "99:1", posted.ThreadID) +} + +func TestSubmitReview_withReply(t *testing.T) { + var requests []map[string]json.RawMessage + srv := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, r *http.Request, + ) { + var raw map[string]json.RawMessage + require.NoError(t, + json.NewDecoder(r.Body).Decode(&raw), + ) + requests = append(requests, raw) + + resp := bitbucket.Comment{ + ID: int64(len(requests)), + Content: bitbucket.Content{Raw: "ok"}, + } + assert.NoError(t, + json.NewEncoder(w).Encode(resp), + ) + }), + ) + defer srv.Close() + + repo := newTestRepository(srv.URL) + err := repo.SubmitReview( + t.Context(), + &PR{Number: 1}, + forge.ReviewRequest{ + Comments: []forge.InlineCommentRequest{ + { + Path: "a.go", + Line: 10, + Body: "new comment", + }, + { + Body: "thread reply", + ThreadID: "55:1", + }, + }, + }, + ) + require.NoError(t, err) + require.Len(t, requests, 2) + + // First request: new inline comment. + assert.Contains(t, requests[0], "inline") + assert.NotContains(t, requests[0], "parent") + + // Second request: reply to thread. + assert.Contains(t, requests[1], "parent") + assert.NotContains(t, requests[1], "inline") +} + func newTestRepository(baseURL string) *Repository { token := &AuthenticationToken{AccessToken: "test"} tokenSource, err := newGatewayTokenSource(token) diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 3c855cc5d..8184391a3 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -13,6 +13,7 @@ import ( "regexp" "strings" "sync" + "time" "go.abhg.dev/gs/internal/git" "go.abhg.dev/gs/internal/git/giturl" @@ -142,7 +143,7 @@ type WithCommentFormat interface { CommentFormat() CommentFormat } -//go:generate mockgen -destination=forgetest/mocks.go -package forgetest -typed . Forge,RepositoryID,Repository +//go:generate mockgen -destination=forgetest/mocks.go -package forgetest -typed -write_package_comment=false . Forge,RepositoryID,Repository,WithInlineComments,WithThreadResolution,WithCommentEdit // TODO: // Forge should become a struct with multiple interfaces or funcctions @@ -938,3 +939,134 @@ func (s ChangeCheckState) GoString() string { return fmt.Sprintf("ChangeCheckState(%d)", int(s)) } } + +// Inline comment types and optional interfaces + +// InlineCommentRequest describes a new inline comment +// to post on a change diff. +type InlineCommentRequest struct { + // Path is the file path relative to the repository root. + Path string + + // Line is the line number in the new version of the file. + Line int + + // Body is the markdown body of the comment. + Body string + + // Side indicates which side of the diff the comment + // applies to. Use "LEFT" for the old version + // or "RIGHT" (default) for the new version. + Side string + + // ThreadID is set when replying to an existing thread. + // The format is forge-specific. + ThreadID string +} + +// InlineComment is a comment on a specific line of a diff. +type InlineComment struct { + // ID is the forge-specific comment identifier. + ID ChangeCommentID + + // ThreadID is the forge-specific thread identifier. + ThreadID string + + // Path is the file path relative to the repository root. + Path string + + // Line is the line number in the diff. + Line int + + // Body is the markdown body of the comment. + Body string + + // Author is the username of the comment author. + Author string + + // Resolved indicates the comment thread is resolved. + Resolved bool + + // Outdated indicates the comment is on an outdated diff. + Outdated bool + + // CreatedAt is the time the comment was created. + CreatedAt time.Time +} + +// ReviewEvent specifies the type of review being submitted. +type ReviewEvent int + +const ( + // ReviewComment submits a review with comments only. + ReviewComment ReviewEvent = iota + + // ReviewApprove submits an approving review. + ReviewApprove + + // ReviewRequestChanges submits a review + // requesting changes. + ReviewRequestChanges +) + +// ReviewRequest is a batch of inline comments +// submitted together as a single review. +type ReviewRequest struct { + // Body is the overall review body (optional). + Body string + + // Comments are the inline comments in the review. + Comments []InlineCommentRequest + + // Event is the review event type. + Event ReviewEvent +} + +// WithInlineComments is an optional interface +// for forges that support inline/diff comments +// and code reviews. +type WithInlineComments interface { + Repository + + // ListInlineComments lists inline/review comments + // on a change. + ListInlineComments( + ctx context.Context, id ChangeID, + ) ([]*InlineComment, error) + + // SubmitReview posts a batch of inline comments + // as a single review. + SubmitReview( + ctx context.Context, id ChangeID, req ReviewRequest, + ) error + + // PostInlineComment posts a single inline comment + // outside of a batch review. + PostInlineComment( + ctx context.Context, id ChangeID, + req InlineCommentRequest, + ) (*InlineComment, error) +} + +// WithThreadResolution is an optional interface +// for forges that support resolving comment threads. +type WithThreadResolution interface { + Repository + + // ResolveThread marks a comment thread as resolved. + ResolveThread(ctx context.Context, threadID string) error + + // UnresolveThread marks a comment thread as unresolved. + UnresolveThread(ctx context.Context, threadID string) error +} + +// WithCommentEdit is an optional interface +// for forges that support editing existing comments. +type WithCommentEdit interface { + Repository + + // EditComment updates the body of an existing comment. + EditComment( + ctx context.Context, id ChangeCommentID, body string, + ) error +} diff --git a/internal/forge/forgetest/mocks.go b/internal/forge/forgetest/mocks.go index a146405a7..b9ae2a647 100644 --- a/internal/forge/forgetest/mocks.go +++ b/internal/forge/forgetest/mocks.go @@ -1,12 +1,11 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: go.abhg.dev/gs/internal/forge (interfaces: Forge,RepositoryID,Repository) +// Source: go.abhg.dev/gs/internal/forge (interfaces: Forge,RepositoryID,Repository,WithInlineComments,WithThreadResolution,WithCommentEdit) // // Generated by this command: // -// mockgen -destination=forgetest/mocks.go -package forgetest -typed . Forge,RepositoryID,Repository +// mockgen -destination=forgetest/mocks.go -package forgetest -typed -write_package_comment=false . Forge,RepositoryID,Repository,WithInlineComments,WithThreadResolution,WithCommentEdit // -// Package forgetest is a generated GoMock package. package forgetest import ( @@ -709,80 +708,41 @@ func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return m.recorder } -// ChangeChecks mocks base method. -func (m *MockRepository) ChangeChecks(ctx context.Context, id forge.ChangeID) ([]forge.ChangeCheck, error) { +// ChangeChecksState mocks base method. +func (m *MockRepository) ChangeChecksState(ctx context.Context, id forge.ChangeID) (forge.ChecksState, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ChangeChecks", ctx, id) - ret0, _ := ret[0].([]forge.ChangeCheck) + ret := m.ctrl.Call(m, "ChangeChecksState", ctx, id) + ret0, _ := ret[0].(forge.ChecksState) ret1, _ := ret[1].(error) return ret0, ret1 } -// ChangeChecks indicates an expected call of ChangeChecks. -func (mr *MockRepositoryMockRecorder) ChangeChecks(ctx, id any) *MockRepositoryChangeChecksCall { +// ChangeChecksState indicates an expected call of ChangeChecksState. +func (mr *MockRepositoryMockRecorder) ChangeChecksState(ctx, id any) *MockRepositoryChangeChecksStateCall { mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecks", reflect.TypeOf((*MockRepository)(nil).ChangeChecks), ctx, id) - return &MockRepositoryChangeChecksCall{Call: call} + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecksState", reflect.TypeOf((*MockRepository)(nil).ChangeChecksState), ctx, id) + return &MockRepositoryChangeChecksStateCall{Call: call} } -// MockRepositoryChangeChecksCall wrap *gomock.Call -type MockRepositoryChangeChecksCall struct { +// MockRepositoryChangeChecksStateCall wrap *gomock.Call +type MockRepositoryChangeChecksStateCall struct { *gomock.Call } // Return rewrite *gomock.Call.Return -func (c *MockRepositoryChangeChecksCall) Return(arg0 []forge.ChangeCheck, arg1 error) *MockRepositoryChangeChecksCall { +func (c *MockRepositoryChangeChecksStateCall) Return(arg0 forge.ChecksState, arg1 error) *MockRepositoryChangeChecksStateCall { c.Call = c.Call.Return(arg0, arg1) return c } // Do rewrite *gomock.Call.Do -func (c *MockRepositoryChangeChecksCall) Do(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockRepositoryChangeChecksCall { +func (c *MockRepositoryChangeChecksStateCall) Do(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockRepositoryChangeChecksStateCall { c.Call = c.Call.Do(f) return c } // DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRepositoryChangeChecksCall) DoAndReturn(f func(context.Context, forge.ChangeID) ([]forge.ChangeCheck, error)) *MockRepositoryChangeChecksCall { - c.Call = c.Call.DoAndReturn(f) - return c -} - -// ChangeMergeability mocks base method. -func (m *MockRepository) 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 *MockRepositoryMockRecorder) ChangeMergeability(ctx, id any) *MockRepositoryChangeMergeabilityCall { - mr.mock.ctrl.T.Helper() - call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeMergeability", reflect.TypeOf((*MockRepository)(nil).ChangeMergeability), ctx, id) - return &MockRepositoryChangeMergeabilityCall{Call: call} -} - -// MockRepositoryChangeMergeabilityCall wrap *gomock.Call -type MockRepositoryChangeMergeabilityCall struct { - *gomock.Call -} - -// Return rewrite *gomock.Call.Return -func (c *MockRepositoryChangeMergeabilityCall) Return(arg0 forge.ChangeMergeability, arg1 error) *MockRepositoryChangeMergeabilityCall { - c.Call = c.Call.Return(arg0, arg1) - return c -} - -// Do rewrite *gomock.Call.Do -func (c *MockRepositoryChangeMergeabilityCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockRepositoryChangeMergeabilityCall { - c.Call = c.Call.Do(f) - return c -} - -// DoAndReturn rewrite *gomock.Call.DoAndReturn -func (c *MockRepositoryChangeMergeabilityCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMergeability, error)) *MockRepositoryChangeMergeabilityCall { +func (c *MockRepositoryChangeChecksStateCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockRepositoryChangeChecksStateCall { c.Call = c.Call.DoAndReturn(f) return c } @@ -1326,3 +1286,2042 @@ func (c *MockRepositoryUpdateChangeCommentCall) DoAndReturn(f func(context.Conte c.Call = c.Call.DoAndReturn(f) return c } + +// MockWithInlineComments is a mock of WithInlineComments interface. +type MockWithInlineComments struct { + ctrl *gomock.Controller + recorder *MockWithInlineCommentsMockRecorder + isgomock struct{} +} + +// MockWithInlineCommentsMockRecorder is the mock recorder for MockWithInlineComments. +type MockWithInlineCommentsMockRecorder struct { + mock *MockWithInlineComments +} + +// NewMockWithInlineComments creates a new mock instance. +func NewMockWithInlineComments(ctrl *gomock.Controller) *MockWithInlineComments { + mock := &MockWithInlineComments{ctrl: ctrl} + mock.recorder = &MockWithInlineCommentsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWithInlineComments) EXPECT() *MockWithInlineCommentsMockRecorder { + return m.recorder +} + +// ChangeChecksState mocks base method. +func (m *MockWithInlineComments) ChangeChecksState(ctx context.Context, id forge.ChangeID) (forge.ChecksState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeChecksState", ctx, id) + ret0, _ := ret[0].(forge.ChecksState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeChecksState indicates an expected call of ChangeChecksState. +func (mr *MockWithInlineCommentsMockRecorder) ChangeChecksState(ctx, id any) *MockWithInlineCommentsChangeChecksStateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecksState", reflect.TypeOf((*MockWithInlineComments)(nil).ChangeChecksState), ctx, id) + return &MockWithInlineCommentsChangeChecksStateCall{Call: call} +} + +// MockWithInlineCommentsChangeChecksStateCall wrap *gomock.Call +type MockWithInlineCommentsChangeChecksStateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsChangeChecksStateCall) Return(arg0 forge.ChecksState, arg1 error) *MockWithInlineCommentsChangeChecksStateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsChangeChecksStateCall) Do(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithInlineCommentsChangeChecksStateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsChangeChecksStateCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithInlineCommentsChangeChecksStateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeStatuses mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) ChangeStatuses(ctx, ids any) *MockWithInlineCommentsChangeStatusesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeStatuses", reflect.TypeOf((*MockWithInlineComments)(nil).ChangeStatuses), ctx, ids) + return &MockWithInlineCommentsChangeStatusesCall{Call: call} +} + +// MockWithInlineCommentsChangeStatusesCall wrap *gomock.Call +type MockWithInlineCommentsChangeStatusesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsChangeStatusesCall) Return(arg0 []forge.ChangeStatus, arg1 error) *MockWithInlineCommentsChangeStatusesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsChangeStatusesCall) Do(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithInlineCommentsChangeStatusesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsChangeStatusesCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithInlineCommentsChangeStatusesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommentCountsByChange mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) CommentCountsByChange(ctx, ids any) *MockWithInlineCommentsCommentCountsByChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommentCountsByChange", reflect.TypeOf((*MockWithInlineComments)(nil).CommentCountsByChange), ctx, ids) + return &MockWithInlineCommentsCommentCountsByChangeCall{Call: call} +} + +// MockWithInlineCommentsCommentCountsByChangeCall wrap *gomock.Call +type MockWithInlineCommentsCommentCountsByChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsCommentCountsByChangeCall) Return(arg0 []*forge.CommentCounts, arg1 error) *MockWithInlineCommentsCommentCountsByChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsCommentCountsByChangeCall) Do(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithInlineCommentsCommentCountsByChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsCommentCountsByChangeCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithInlineCommentsCommentCountsByChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteChangeComment mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) DeleteChangeComment(arg0, arg1 any) *MockWithInlineCommentsDeleteChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChangeComment", reflect.TypeOf((*MockWithInlineComments)(nil).DeleteChangeComment), arg0, arg1) + return &MockWithInlineCommentsDeleteChangeCommentCall{Call: call} +} + +// MockWithInlineCommentsDeleteChangeCommentCall wrap *gomock.Call +type MockWithInlineCommentsDeleteChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsDeleteChangeCommentCall) Return(arg0 error) *MockWithInlineCommentsDeleteChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsDeleteChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID) error) *MockWithInlineCommentsDeleteChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsDeleteChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID) error) *MockWithInlineCommentsDeleteChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditChange mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) EditChange(ctx, id, opts any) *MockWithInlineCommentsEditChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditChange", reflect.TypeOf((*MockWithInlineComments)(nil).EditChange), ctx, id, opts) + return &MockWithInlineCommentsEditChangeCall{Call: call} +} + +// MockWithInlineCommentsEditChangeCall wrap *gomock.Call +type MockWithInlineCommentsEditChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsEditChangeCall) Return(arg0 error) *MockWithInlineCommentsEditChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsEditChangeCall) Do(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithInlineCommentsEditChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsEditChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithInlineCommentsEditChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangeByID mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) FindChangeByID(ctx, id any) *MockWithInlineCommentsFindChangeByIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangeByID", reflect.TypeOf((*MockWithInlineComments)(nil).FindChangeByID), ctx, id) + return &MockWithInlineCommentsFindChangeByIDCall{Call: call} +} + +// MockWithInlineCommentsFindChangeByIDCall wrap *gomock.Call +type MockWithInlineCommentsFindChangeByIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsFindChangeByIDCall) Return(arg0 *forge.FindChangeItem, arg1 error) *MockWithInlineCommentsFindChangeByIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsFindChangeByIDCall) Do(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithInlineCommentsFindChangeByIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsFindChangeByIDCall) DoAndReturn(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithInlineCommentsFindChangeByIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangesByBranch mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) FindChangesByBranch(ctx, branch, opts any) *MockWithInlineCommentsFindChangesByBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangesByBranch", reflect.TypeOf((*MockWithInlineComments)(nil).FindChangesByBranch), ctx, branch, opts) + return &MockWithInlineCommentsFindChangesByBranchCall{Call: call} +} + +// MockWithInlineCommentsFindChangesByBranchCall wrap *gomock.Call +type MockWithInlineCommentsFindChangesByBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsFindChangesByBranchCall) Return(arg0 []*forge.FindChangeItem, arg1 error) *MockWithInlineCommentsFindChangesByBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsFindChangesByBranchCall) Do(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithInlineCommentsFindChangesByBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsFindChangesByBranchCall) DoAndReturn(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithInlineCommentsFindChangesByBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Forge mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) Forge() *MockWithInlineCommentsForgeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forge", reflect.TypeOf((*MockWithInlineComments)(nil).Forge)) + return &MockWithInlineCommentsForgeCall{Call: call} +} + +// MockWithInlineCommentsForgeCall wrap *gomock.Call +type MockWithInlineCommentsForgeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsForgeCall) Return(arg0 forge.Forge) *MockWithInlineCommentsForgeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsForgeCall) Do(f func() forge.Forge) *MockWithInlineCommentsForgeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsForgeCall) DoAndReturn(f func() forge.Forge) *MockWithInlineCommentsForgeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeComments mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) ListChangeComments(arg0, arg1, arg2 any) *MockWithInlineCommentsListChangeCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeComments", reflect.TypeOf((*MockWithInlineComments)(nil).ListChangeComments), arg0, arg1, arg2) + return &MockWithInlineCommentsListChangeCommentsCall{Call: call} +} + +// MockWithInlineCommentsListChangeCommentsCall wrap *gomock.Call +type MockWithInlineCommentsListChangeCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsListChangeCommentsCall) Return(arg0 iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithInlineCommentsListChangeCommentsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsListChangeCommentsCall) Do(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithInlineCommentsListChangeCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsListChangeCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithInlineCommentsListChangeCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeTemplates mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) ListChangeTemplates(arg0 any) *MockWithInlineCommentsListChangeTemplatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeTemplates", reflect.TypeOf((*MockWithInlineComments)(nil).ListChangeTemplates), arg0) + return &MockWithInlineCommentsListChangeTemplatesCall{Call: call} +} + +// MockWithInlineCommentsListChangeTemplatesCall wrap *gomock.Call +type MockWithInlineCommentsListChangeTemplatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsListChangeTemplatesCall) Return(arg0 []*forge.ChangeTemplate, arg1 error) *MockWithInlineCommentsListChangeTemplatesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsListChangeTemplatesCall) Do(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithInlineCommentsListChangeTemplatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsListChangeTemplatesCall) DoAndReturn(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithInlineCommentsListChangeTemplatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListInlineComments mocks base method. +func (m *MockWithInlineComments) ListInlineComments(ctx context.Context, id forge.ChangeID) ([]*forge.InlineComment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListInlineComments", ctx, id) + ret0, _ := ret[0].([]*forge.InlineComment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListInlineComments indicates an expected call of ListInlineComments. +func (mr *MockWithInlineCommentsMockRecorder) ListInlineComments(ctx, id any) *MockWithInlineCommentsListInlineCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListInlineComments", reflect.TypeOf((*MockWithInlineComments)(nil).ListInlineComments), ctx, id) + return &MockWithInlineCommentsListInlineCommentsCall{Call: call} +} + +// MockWithInlineCommentsListInlineCommentsCall wrap *gomock.Call +type MockWithInlineCommentsListInlineCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsListInlineCommentsCall) Return(arg0 []*forge.InlineComment, arg1 error) *MockWithInlineCommentsListInlineCommentsCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsListInlineCommentsCall) Do(f func(context.Context, forge.ChangeID) ([]*forge.InlineComment, error)) *MockWithInlineCommentsListInlineCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsListInlineCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID) ([]*forge.InlineComment, error)) *MockWithInlineCommentsListInlineCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MergeChange mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) MergeChange(ctx, id, opts any) *MockWithInlineCommentsMergeChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeChange", reflect.TypeOf((*MockWithInlineComments)(nil).MergeChange), ctx, id, opts) + return &MockWithInlineCommentsMergeChangeCall{Call: call} +} + +// MockWithInlineCommentsMergeChangeCall wrap *gomock.Call +type MockWithInlineCommentsMergeChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsMergeChangeCall) Return(arg0 error) *MockWithInlineCommentsMergeChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsMergeChangeCall) Do(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithInlineCommentsMergeChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsMergeChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithInlineCommentsMergeChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewChangeMetadata mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) NewChangeMetadata(ctx, id any) *MockWithInlineCommentsNewChangeMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewChangeMetadata", reflect.TypeOf((*MockWithInlineComments)(nil).NewChangeMetadata), ctx, id) + return &MockWithInlineCommentsNewChangeMetadataCall{Call: call} +} + +// MockWithInlineCommentsNewChangeMetadataCall wrap *gomock.Call +type MockWithInlineCommentsNewChangeMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsNewChangeMetadataCall) Return(arg0 forge.ChangeMetadata, arg1 error) *MockWithInlineCommentsNewChangeMetadataCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsNewChangeMetadataCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithInlineCommentsNewChangeMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsNewChangeMetadataCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithInlineCommentsNewChangeMetadataCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostChangeComment mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) PostChangeComment(arg0, arg1, arg2 any) *MockWithInlineCommentsPostChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostChangeComment", reflect.TypeOf((*MockWithInlineComments)(nil).PostChangeComment), arg0, arg1, arg2) + return &MockWithInlineCommentsPostChangeCommentCall{Call: call} +} + +// MockWithInlineCommentsPostChangeCommentCall wrap *gomock.Call +type MockWithInlineCommentsPostChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsPostChangeCommentCall) Return(arg0 forge.ChangeCommentID, arg1 error) *MockWithInlineCommentsPostChangeCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsPostChangeCommentCall) Do(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithInlineCommentsPostChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsPostChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithInlineCommentsPostChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostInlineComment mocks base method. +func (m *MockWithInlineComments) PostInlineComment(ctx context.Context, id forge.ChangeID, req forge.InlineCommentRequest) (*forge.InlineComment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostInlineComment", ctx, id, req) + ret0, _ := ret[0].(*forge.InlineComment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostInlineComment indicates an expected call of PostInlineComment. +func (mr *MockWithInlineCommentsMockRecorder) PostInlineComment(ctx, id, req any) *MockWithInlineCommentsPostInlineCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostInlineComment", reflect.TypeOf((*MockWithInlineComments)(nil).PostInlineComment), ctx, id, req) + return &MockWithInlineCommentsPostInlineCommentCall{Call: call} +} + +// MockWithInlineCommentsPostInlineCommentCall wrap *gomock.Call +type MockWithInlineCommentsPostInlineCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsPostInlineCommentCall) Return(arg0 *forge.InlineComment, arg1 error) *MockWithInlineCommentsPostInlineCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsPostInlineCommentCall) Do(f func(context.Context, forge.ChangeID, forge.InlineCommentRequest) (*forge.InlineComment, error)) *MockWithInlineCommentsPostInlineCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsPostInlineCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.InlineCommentRequest) (*forge.InlineComment, error)) *MockWithInlineCommentsPostInlineCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitChange mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) SubmitChange(ctx, req any) *MockWithInlineCommentsSubmitChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitChange", reflect.TypeOf((*MockWithInlineComments)(nil).SubmitChange), ctx, req) + return &MockWithInlineCommentsSubmitChangeCall{Call: call} +} + +// MockWithInlineCommentsSubmitChangeCall wrap *gomock.Call +type MockWithInlineCommentsSubmitChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsSubmitChangeCall) Return(arg0 forge.SubmitChangeResult, arg1 error) *MockWithInlineCommentsSubmitChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsSubmitChangeCall) Do(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithInlineCommentsSubmitChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsSubmitChangeCall) DoAndReturn(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithInlineCommentsSubmitChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitReview mocks base method. +func (m *MockWithInlineComments) SubmitReview(ctx context.Context, id forge.ChangeID, req forge.ReviewRequest) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubmitReview", ctx, id, req) + ret0, _ := ret[0].(error) + return ret0 +} + +// SubmitReview indicates an expected call of SubmitReview. +func (mr *MockWithInlineCommentsMockRecorder) SubmitReview(ctx, id, req any) *MockWithInlineCommentsSubmitReviewCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitReview", reflect.TypeOf((*MockWithInlineComments)(nil).SubmitReview), ctx, id, req) + return &MockWithInlineCommentsSubmitReviewCall{Call: call} +} + +// MockWithInlineCommentsSubmitReviewCall wrap *gomock.Call +type MockWithInlineCommentsSubmitReviewCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsSubmitReviewCall) Return(arg0 error) *MockWithInlineCommentsSubmitReviewCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsSubmitReviewCall) Do(f func(context.Context, forge.ChangeID, forge.ReviewRequest) error) *MockWithInlineCommentsSubmitReviewCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsSubmitReviewCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.ReviewRequest) error) *MockWithInlineCommentsSubmitReviewCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateChangeComment mocks base method. +func (m *MockWithInlineComments) 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 *MockWithInlineCommentsMockRecorder) UpdateChangeComment(arg0, arg1, arg2 any) *MockWithInlineCommentsUpdateChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChangeComment", reflect.TypeOf((*MockWithInlineComments)(nil).UpdateChangeComment), arg0, arg1, arg2) + return &MockWithInlineCommentsUpdateChangeCommentCall{Call: call} +} + +// MockWithInlineCommentsUpdateChangeCommentCall wrap *gomock.Call +type MockWithInlineCommentsUpdateChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithInlineCommentsUpdateChangeCommentCall) Return(arg0 error) *MockWithInlineCommentsUpdateChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithInlineCommentsUpdateChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithInlineCommentsUpdateChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithInlineCommentsUpdateChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithInlineCommentsUpdateChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWithThreadResolution is a mock of WithThreadResolution interface. +type MockWithThreadResolution struct { + ctrl *gomock.Controller + recorder *MockWithThreadResolutionMockRecorder + isgomock struct{} +} + +// MockWithThreadResolutionMockRecorder is the mock recorder for MockWithThreadResolution. +type MockWithThreadResolutionMockRecorder struct { + mock *MockWithThreadResolution +} + +// NewMockWithThreadResolution creates a new mock instance. +func NewMockWithThreadResolution(ctrl *gomock.Controller) *MockWithThreadResolution { + mock := &MockWithThreadResolution{ctrl: ctrl} + mock.recorder = &MockWithThreadResolutionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWithThreadResolution) EXPECT() *MockWithThreadResolutionMockRecorder { + return m.recorder +} + +// ChangeChecksState mocks base method. +func (m *MockWithThreadResolution) ChangeChecksState(ctx context.Context, id forge.ChangeID) (forge.ChecksState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeChecksState", ctx, id) + ret0, _ := ret[0].(forge.ChecksState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeChecksState indicates an expected call of ChangeChecksState. +func (mr *MockWithThreadResolutionMockRecorder) ChangeChecksState(ctx, id any) *MockWithThreadResolutionChangeChecksStateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecksState", reflect.TypeOf((*MockWithThreadResolution)(nil).ChangeChecksState), ctx, id) + return &MockWithThreadResolutionChangeChecksStateCall{Call: call} +} + +// MockWithThreadResolutionChangeChecksStateCall wrap *gomock.Call +type MockWithThreadResolutionChangeChecksStateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionChangeChecksStateCall) Return(arg0 forge.ChecksState, arg1 error) *MockWithThreadResolutionChangeChecksStateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionChangeChecksStateCall) Do(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithThreadResolutionChangeChecksStateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionChangeChecksStateCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithThreadResolutionChangeChecksStateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeStatuses mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) ChangeStatuses(ctx, ids any) *MockWithThreadResolutionChangeStatusesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeStatuses", reflect.TypeOf((*MockWithThreadResolution)(nil).ChangeStatuses), ctx, ids) + return &MockWithThreadResolutionChangeStatusesCall{Call: call} +} + +// MockWithThreadResolutionChangeStatusesCall wrap *gomock.Call +type MockWithThreadResolutionChangeStatusesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionChangeStatusesCall) Return(arg0 []forge.ChangeStatus, arg1 error) *MockWithThreadResolutionChangeStatusesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionChangeStatusesCall) Do(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithThreadResolutionChangeStatusesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionChangeStatusesCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithThreadResolutionChangeStatusesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommentCountsByChange mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) CommentCountsByChange(ctx, ids any) *MockWithThreadResolutionCommentCountsByChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommentCountsByChange", reflect.TypeOf((*MockWithThreadResolution)(nil).CommentCountsByChange), ctx, ids) + return &MockWithThreadResolutionCommentCountsByChangeCall{Call: call} +} + +// MockWithThreadResolutionCommentCountsByChangeCall wrap *gomock.Call +type MockWithThreadResolutionCommentCountsByChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionCommentCountsByChangeCall) Return(arg0 []*forge.CommentCounts, arg1 error) *MockWithThreadResolutionCommentCountsByChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionCommentCountsByChangeCall) Do(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithThreadResolutionCommentCountsByChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionCommentCountsByChangeCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithThreadResolutionCommentCountsByChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteChangeComment mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) DeleteChangeComment(arg0, arg1 any) *MockWithThreadResolutionDeleteChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChangeComment", reflect.TypeOf((*MockWithThreadResolution)(nil).DeleteChangeComment), arg0, arg1) + return &MockWithThreadResolutionDeleteChangeCommentCall{Call: call} +} + +// MockWithThreadResolutionDeleteChangeCommentCall wrap *gomock.Call +type MockWithThreadResolutionDeleteChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionDeleteChangeCommentCall) Return(arg0 error) *MockWithThreadResolutionDeleteChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionDeleteChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID) error) *MockWithThreadResolutionDeleteChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionDeleteChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID) error) *MockWithThreadResolutionDeleteChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditChange mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) EditChange(ctx, id, opts any) *MockWithThreadResolutionEditChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditChange", reflect.TypeOf((*MockWithThreadResolution)(nil).EditChange), ctx, id, opts) + return &MockWithThreadResolutionEditChangeCall{Call: call} +} + +// MockWithThreadResolutionEditChangeCall wrap *gomock.Call +type MockWithThreadResolutionEditChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionEditChangeCall) Return(arg0 error) *MockWithThreadResolutionEditChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionEditChangeCall) Do(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithThreadResolutionEditChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionEditChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithThreadResolutionEditChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangeByID mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) FindChangeByID(ctx, id any) *MockWithThreadResolutionFindChangeByIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangeByID", reflect.TypeOf((*MockWithThreadResolution)(nil).FindChangeByID), ctx, id) + return &MockWithThreadResolutionFindChangeByIDCall{Call: call} +} + +// MockWithThreadResolutionFindChangeByIDCall wrap *gomock.Call +type MockWithThreadResolutionFindChangeByIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionFindChangeByIDCall) Return(arg0 *forge.FindChangeItem, arg1 error) *MockWithThreadResolutionFindChangeByIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionFindChangeByIDCall) Do(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithThreadResolutionFindChangeByIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionFindChangeByIDCall) DoAndReturn(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithThreadResolutionFindChangeByIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangesByBranch mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) FindChangesByBranch(ctx, branch, opts any) *MockWithThreadResolutionFindChangesByBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangesByBranch", reflect.TypeOf((*MockWithThreadResolution)(nil).FindChangesByBranch), ctx, branch, opts) + return &MockWithThreadResolutionFindChangesByBranchCall{Call: call} +} + +// MockWithThreadResolutionFindChangesByBranchCall wrap *gomock.Call +type MockWithThreadResolutionFindChangesByBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionFindChangesByBranchCall) Return(arg0 []*forge.FindChangeItem, arg1 error) *MockWithThreadResolutionFindChangesByBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionFindChangesByBranchCall) Do(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithThreadResolutionFindChangesByBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionFindChangesByBranchCall) DoAndReturn(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithThreadResolutionFindChangesByBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Forge mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) Forge() *MockWithThreadResolutionForgeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forge", reflect.TypeOf((*MockWithThreadResolution)(nil).Forge)) + return &MockWithThreadResolutionForgeCall{Call: call} +} + +// MockWithThreadResolutionForgeCall wrap *gomock.Call +type MockWithThreadResolutionForgeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionForgeCall) Return(arg0 forge.Forge) *MockWithThreadResolutionForgeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionForgeCall) Do(f func() forge.Forge) *MockWithThreadResolutionForgeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionForgeCall) DoAndReturn(f func() forge.Forge) *MockWithThreadResolutionForgeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeComments mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) ListChangeComments(arg0, arg1, arg2 any) *MockWithThreadResolutionListChangeCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeComments", reflect.TypeOf((*MockWithThreadResolution)(nil).ListChangeComments), arg0, arg1, arg2) + return &MockWithThreadResolutionListChangeCommentsCall{Call: call} +} + +// MockWithThreadResolutionListChangeCommentsCall wrap *gomock.Call +type MockWithThreadResolutionListChangeCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionListChangeCommentsCall) Return(arg0 iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithThreadResolutionListChangeCommentsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionListChangeCommentsCall) Do(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithThreadResolutionListChangeCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionListChangeCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithThreadResolutionListChangeCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeTemplates mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) ListChangeTemplates(arg0 any) *MockWithThreadResolutionListChangeTemplatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeTemplates", reflect.TypeOf((*MockWithThreadResolution)(nil).ListChangeTemplates), arg0) + return &MockWithThreadResolutionListChangeTemplatesCall{Call: call} +} + +// MockWithThreadResolutionListChangeTemplatesCall wrap *gomock.Call +type MockWithThreadResolutionListChangeTemplatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionListChangeTemplatesCall) Return(arg0 []*forge.ChangeTemplate, arg1 error) *MockWithThreadResolutionListChangeTemplatesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionListChangeTemplatesCall) Do(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithThreadResolutionListChangeTemplatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionListChangeTemplatesCall) DoAndReturn(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithThreadResolutionListChangeTemplatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MergeChange mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) MergeChange(ctx, id, opts any) *MockWithThreadResolutionMergeChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeChange", reflect.TypeOf((*MockWithThreadResolution)(nil).MergeChange), ctx, id, opts) + return &MockWithThreadResolutionMergeChangeCall{Call: call} +} + +// MockWithThreadResolutionMergeChangeCall wrap *gomock.Call +type MockWithThreadResolutionMergeChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionMergeChangeCall) Return(arg0 error) *MockWithThreadResolutionMergeChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionMergeChangeCall) Do(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithThreadResolutionMergeChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionMergeChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithThreadResolutionMergeChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewChangeMetadata mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) NewChangeMetadata(ctx, id any) *MockWithThreadResolutionNewChangeMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewChangeMetadata", reflect.TypeOf((*MockWithThreadResolution)(nil).NewChangeMetadata), ctx, id) + return &MockWithThreadResolutionNewChangeMetadataCall{Call: call} +} + +// MockWithThreadResolutionNewChangeMetadataCall wrap *gomock.Call +type MockWithThreadResolutionNewChangeMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionNewChangeMetadataCall) Return(arg0 forge.ChangeMetadata, arg1 error) *MockWithThreadResolutionNewChangeMetadataCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionNewChangeMetadataCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithThreadResolutionNewChangeMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionNewChangeMetadataCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithThreadResolutionNewChangeMetadataCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostChangeComment mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) PostChangeComment(arg0, arg1, arg2 any) *MockWithThreadResolutionPostChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostChangeComment", reflect.TypeOf((*MockWithThreadResolution)(nil).PostChangeComment), arg0, arg1, arg2) + return &MockWithThreadResolutionPostChangeCommentCall{Call: call} +} + +// MockWithThreadResolutionPostChangeCommentCall wrap *gomock.Call +type MockWithThreadResolutionPostChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionPostChangeCommentCall) Return(arg0 forge.ChangeCommentID, arg1 error) *MockWithThreadResolutionPostChangeCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionPostChangeCommentCall) Do(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithThreadResolutionPostChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionPostChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithThreadResolutionPostChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ResolveThread mocks base method. +func (m *MockWithThreadResolution) ResolveThread(ctx context.Context, threadID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveThread", ctx, threadID) + ret0, _ := ret[0].(error) + return ret0 +} + +// ResolveThread indicates an expected call of ResolveThread. +func (mr *MockWithThreadResolutionMockRecorder) ResolveThread(ctx, threadID any) *MockWithThreadResolutionResolveThreadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveThread", reflect.TypeOf((*MockWithThreadResolution)(nil).ResolveThread), ctx, threadID) + return &MockWithThreadResolutionResolveThreadCall{Call: call} +} + +// MockWithThreadResolutionResolveThreadCall wrap *gomock.Call +type MockWithThreadResolutionResolveThreadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionResolveThreadCall) Return(arg0 error) *MockWithThreadResolutionResolveThreadCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionResolveThreadCall) Do(f func(context.Context, string) error) *MockWithThreadResolutionResolveThreadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionResolveThreadCall) DoAndReturn(f func(context.Context, string) error) *MockWithThreadResolutionResolveThreadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitChange mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) SubmitChange(ctx, req any) *MockWithThreadResolutionSubmitChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitChange", reflect.TypeOf((*MockWithThreadResolution)(nil).SubmitChange), ctx, req) + return &MockWithThreadResolutionSubmitChangeCall{Call: call} +} + +// MockWithThreadResolutionSubmitChangeCall wrap *gomock.Call +type MockWithThreadResolutionSubmitChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionSubmitChangeCall) Return(arg0 forge.SubmitChangeResult, arg1 error) *MockWithThreadResolutionSubmitChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionSubmitChangeCall) Do(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithThreadResolutionSubmitChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionSubmitChangeCall) DoAndReturn(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithThreadResolutionSubmitChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UnresolveThread mocks base method. +func (m *MockWithThreadResolution) UnresolveThread(ctx context.Context, threadID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UnresolveThread", ctx, threadID) + ret0, _ := ret[0].(error) + return ret0 +} + +// UnresolveThread indicates an expected call of UnresolveThread. +func (mr *MockWithThreadResolutionMockRecorder) UnresolveThread(ctx, threadID any) *MockWithThreadResolutionUnresolveThreadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnresolveThread", reflect.TypeOf((*MockWithThreadResolution)(nil).UnresolveThread), ctx, threadID) + return &MockWithThreadResolutionUnresolveThreadCall{Call: call} +} + +// MockWithThreadResolutionUnresolveThreadCall wrap *gomock.Call +type MockWithThreadResolutionUnresolveThreadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionUnresolveThreadCall) Return(arg0 error) *MockWithThreadResolutionUnresolveThreadCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionUnresolveThreadCall) Do(f func(context.Context, string) error) *MockWithThreadResolutionUnresolveThreadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionUnresolveThreadCall) DoAndReturn(f func(context.Context, string) error) *MockWithThreadResolutionUnresolveThreadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateChangeComment mocks base method. +func (m *MockWithThreadResolution) 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 *MockWithThreadResolutionMockRecorder) UpdateChangeComment(arg0, arg1, arg2 any) *MockWithThreadResolutionUpdateChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChangeComment", reflect.TypeOf((*MockWithThreadResolution)(nil).UpdateChangeComment), arg0, arg1, arg2) + return &MockWithThreadResolutionUpdateChangeCommentCall{Call: call} +} + +// MockWithThreadResolutionUpdateChangeCommentCall wrap *gomock.Call +type MockWithThreadResolutionUpdateChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithThreadResolutionUpdateChangeCommentCall) Return(arg0 error) *MockWithThreadResolutionUpdateChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithThreadResolutionUpdateChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithThreadResolutionUpdateChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithThreadResolutionUpdateChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithThreadResolutionUpdateChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWithCommentEdit is a mock of WithCommentEdit interface. +type MockWithCommentEdit struct { + ctrl *gomock.Controller + recorder *MockWithCommentEditMockRecorder + isgomock struct{} +} + +// MockWithCommentEditMockRecorder is the mock recorder for MockWithCommentEdit. +type MockWithCommentEditMockRecorder struct { + mock *MockWithCommentEdit +} + +// NewMockWithCommentEdit creates a new mock instance. +func NewMockWithCommentEdit(ctrl *gomock.Controller) *MockWithCommentEdit { + mock := &MockWithCommentEdit{ctrl: ctrl} + mock.recorder = &MockWithCommentEditMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWithCommentEdit) EXPECT() *MockWithCommentEditMockRecorder { + return m.recorder +} + +// ChangeChecksState mocks base method. +func (m *MockWithCommentEdit) ChangeChecksState(ctx context.Context, id forge.ChangeID) (forge.ChecksState, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeChecksState", ctx, id) + ret0, _ := ret[0].(forge.ChecksState) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeChecksState indicates an expected call of ChangeChecksState. +func (mr *MockWithCommentEditMockRecorder) ChangeChecksState(ctx, id any) *MockWithCommentEditChangeChecksStateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeChecksState", reflect.TypeOf((*MockWithCommentEdit)(nil).ChangeChecksState), ctx, id) + return &MockWithCommentEditChangeChecksStateCall{Call: call} +} + +// MockWithCommentEditChangeChecksStateCall wrap *gomock.Call +type MockWithCommentEditChangeChecksStateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditChangeChecksStateCall) Return(arg0 forge.ChecksState, arg1 error) *MockWithCommentEditChangeChecksStateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditChangeChecksStateCall) Do(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithCommentEditChangeChecksStateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditChangeChecksStateCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChecksState, error)) *MockWithCommentEditChangeChecksStateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ChangeStatuses mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) ChangeStatuses(ctx, ids any) *MockWithCommentEditChangeStatusesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeStatuses", reflect.TypeOf((*MockWithCommentEdit)(nil).ChangeStatuses), ctx, ids) + return &MockWithCommentEditChangeStatusesCall{Call: call} +} + +// MockWithCommentEditChangeStatusesCall wrap *gomock.Call +type MockWithCommentEditChangeStatusesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditChangeStatusesCall) Return(arg0 []forge.ChangeStatus, arg1 error) *MockWithCommentEditChangeStatusesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditChangeStatusesCall) Do(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithCommentEditChangeStatusesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditChangeStatusesCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]forge.ChangeStatus, error)) *MockWithCommentEditChangeStatusesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// CommentCountsByChange mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) CommentCountsByChange(ctx, ids any) *MockWithCommentEditCommentCountsByChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommentCountsByChange", reflect.TypeOf((*MockWithCommentEdit)(nil).CommentCountsByChange), ctx, ids) + return &MockWithCommentEditCommentCountsByChangeCall{Call: call} +} + +// MockWithCommentEditCommentCountsByChangeCall wrap *gomock.Call +type MockWithCommentEditCommentCountsByChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditCommentCountsByChangeCall) Return(arg0 []*forge.CommentCounts, arg1 error) *MockWithCommentEditCommentCountsByChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditCommentCountsByChangeCall) Do(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithCommentEditCommentCountsByChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditCommentCountsByChangeCall) DoAndReturn(f func(context.Context, []forge.ChangeID) ([]*forge.CommentCounts, error)) *MockWithCommentEditCommentCountsByChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteChangeComment mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) DeleteChangeComment(arg0, arg1 any) *MockWithCommentEditDeleteChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteChangeComment", reflect.TypeOf((*MockWithCommentEdit)(nil).DeleteChangeComment), arg0, arg1) + return &MockWithCommentEditDeleteChangeCommentCall{Call: call} +} + +// MockWithCommentEditDeleteChangeCommentCall wrap *gomock.Call +type MockWithCommentEditDeleteChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditDeleteChangeCommentCall) Return(arg0 error) *MockWithCommentEditDeleteChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditDeleteChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID) error) *MockWithCommentEditDeleteChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditDeleteChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID) error) *MockWithCommentEditDeleteChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditChange mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) EditChange(ctx, id, opts any) *MockWithCommentEditEditChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditChange", reflect.TypeOf((*MockWithCommentEdit)(nil).EditChange), ctx, id, opts) + return &MockWithCommentEditEditChangeCall{Call: call} +} + +// MockWithCommentEditEditChangeCall wrap *gomock.Call +type MockWithCommentEditEditChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditEditChangeCall) Return(arg0 error) *MockWithCommentEditEditChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditEditChangeCall) Do(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithCommentEditEditChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditEditChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.EditChangeOptions) error) *MockWithCommentEditEditChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// EditComment mocks base method. +func (m *MockWithCommentEdit) EditComment(ctx context.Context, id forge.ChangeCommentID, body string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "EditComment", ctx, id, body) + ret0, _ := ret[0].(error) + return ret0 +} + +// EditComment indicates an expected call of EditComment. +func (mr *MockWithCommentEditMockRecorder) EditComment(ctx, id, body any) *MockWithCommentEditEditCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EditComment", reflect.TypeOf((*MockWithCommentEdit)(nil).EditComment), ctx, id, body) + return &MockWithCommentEditEditCommentCall{Call: call} +} + +// MockWithCommentEditEditCommentCall wrap *gomock.Call +type MockWithCommentEditEditCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditEditCommentCall) Return(arg0 error) *MockWithCommentEditEditCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditEditCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithCommentEditEditCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditEditCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithCommentEditEditCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangeByID mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) FindChangeByID(ctx, id any) *MockWithCommentEditFindChangeByIDCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangeByID", reflect.TypeOf((*MockWithCommentEdit)(nil).FindChangeByID), ctx, id) + return &MockWithCommentEditFindChangeByIDCall{Call: call} +} + +// MockWithCommentEditFindChangeByIDCall wrap *gomock.Call +type MockWithCommentEditFindChangeByIDCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditFindChangeByIDCall) Return(arg0 *forge.FindChangeItem, arg1 error) *MockWithCommentEditFindChangeByIDCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditFindChangeByIDCall) Do(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithCommentEditFindChangeByIDCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditFindChangeByIDCall) DoAndReturn(f func(context.Context, forge.ChangeID) (*forge.FindChangeItem, error)) *MockWithCommentEditFindChangeByIDCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// FindChangesByBranch mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) FindChangesByBranch(ctx, branch, opts any) *MockWithCommentEditFindChangesByBranchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindChangesByBranch", reflect.TypeOf((*MockWithCommentEdit)(nil).FindChangesByBranch), ctx, branch, opts) + return &MockWithCommentEditFindChangesByBranchCall{Call: call} +} + +// MockWithCommentEditFindChangesByBranchCall wrap *gomock.Call +type MockWithCommentEditFindChangesByBranchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditFindChangesByBranchCall) Return(arg0 []*forge.FindChangeItem, arg1 error) *MockWithCommentEditFindChangesByBranchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditFindChangesByBranchCall) Do(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithCommentEditFindChangesByBranchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditFindChangesByBranchCall) DoAndReturn(f func(context.Context, string, forge.FindChangesOptions) ([]*forge.FindChangeItem, error)) *MockWithCommentEditFindChangesByBranchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Forge mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) Forge() *MockWithCommentEditForgeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Forge", reflect.TypeOf((*MockWithCommentEdit)(nil).Forge)) + return &MockWithCommentEditForgeCall{Call: call} +} + +// MockWithCommentEditForgeCall wrap *gomock.Call +type MockWithCommentEditForgeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditForgeCall) Return(arg0 forge.Forge) *MockWithCommentEditForgeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditForgeCall) Do(f func() forge.Forge) *MockWithCommentEditForgeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditForgeCall) DoAndReturn(f func() forge.Forge) *MockWithCommentEditForgeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeComments mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) ListChangeComments(arg0, arg1, arg2 any) *MockWithCommentEditListChangeCommentsCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeComments", reflect.TypeOf((*MockWithCommentEdit)(nil).ListChangeComments), arg0, arg1, arg2) + return &MockWithCommentEditListChangeCommentsCall{Call: call} +} + +// MockWithCommentEditListChangeCommentsCall wrap *gomock.Call +type MockWithCommentEditListChangeCommentsCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditListChangeCommentsCall) Return(arg0 iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithCommentEditListChangeCommentsCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditListChangeCommentsCall) Do(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithCommentEditListChangeCommentsCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditListChangeCommentsCall) DoAndReturn(f func(context.Context, forge.ChangeID, *forge.ListChangeCommentsOptions) iter.Seq2[*forge.ListChangeCommentItem, error]) *MockWithCommentEditListChangeCommentsCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ListChangeTemplates mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) ListChangeTemplates(arg0 any) *MockWithCommentEditListChangeTemplatesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListChangeTemplates", reflect.TypeOf((*MockWithCommentEdit)(nil).ListChangeTemplates), arg0) + return &MockWithCommentEditListChangeTemplatesCall{Call: call} +} + +// MockWithCommentEditListChangeTemplatesCall wrap *gomock.Call +type MockWithCommentEditListChangeTemplatesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditListChangeTemplatesCall) Return(arg0 []*forge.ChangeTemplate, arg1 error) *MockWithCommentEditListChangeTemplatesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditListChangeTemplatesCall) Do(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithCommentEditListChangeTemplatesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditListChangeTemplatesCall) DoAndReturn(f func(context.Context) ([]*forge.ChangeTemplate, error)) *MockWithCommentEditListChangeTemplatesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MergeChange mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) MergeChange(ctx, id, opts any) *MockWithCommentEditMergeChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MergeChange", reflect.TypeOf((*MockWithCommentEdit)(nil).MergeChange), ctx, id, opts) + return &MockWithCommentEditMergeChangeCall{Call: call} +} + +// MockWithCommentEditMergeChangeCall wrap *gomock.Call +type MockWithCommentEditMergeChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditMergeChangeCall) Return(arg0 error) *MockWithCommentEditMergeChangeCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditMergeChangeCall) Do(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithCommentEditMergeChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditMergeChangeCall) DoAndReturn(f func(context.Context, forge.ChangeID, forge.MergeChangeOptions) error) *MockWithCommentEditMergeChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewChangeMetadata mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) NewChangeMetadata(ctx, id any) *MockWithCommentEditNewChangeMetadataCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewChangeMetadata", reflect.TypeOf((*MockWithCommentEdit)(nil).NewChangeMetadata), ctx, id) + return &MockWithCommentEditNewChangeMetadataCall{Call: call} +} + +// MockWithCommentEditNewChangeMetadataCall wrap *gomock.Call +type MockWithCommentEditNewChangeMetadataCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditNewChangeMetadataCall) Return(arg0 forge.ChangeMetadata, arg1 error) *MockWithCommentEditNewChangeMetadataCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditNewChangeMetadataCall) Do(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithCommentEditNewChangeMetadataCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditNewChangeMetadataCall) DoAndReturn(f func(context.Context, forge.ChangeID) (forge.ChangeMetadata, error)) *MockWithCommentEditNewChangeMetadataCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PostChangeComment mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) PostChangeComment(arg0, arg1, arg2 any) *MockWithCommentEditPostChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostChangeComment", reflect.TypeOf((*MockWithCommentEdit)(nil).PostChangeComment), arg0, arg1, arg2) + return &MockWithCommentEditPostChangeCommentCall{Call: call} +} + +// MockWithCommentEditPostChangeCommentCall wrap *gomock.Call +type MockWithCommentEditPostChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditPostChangeCommentCall) Return(arg0 forge.ChangeCommentID, arg1 error) *MockWithCommentEditPostChangeCommentCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditPostChangeCommentCall) Do(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithCommentEditPostChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditPostChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeID, string) (forge.ChangeCommentID, error)) *MockWithCommentEditPostChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SubmitChange mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) SubmitChange(ctx, req any) *MockWithCommentEditSubmitChangeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitChange", reflect.TypeOf((*MockWithCommentEdit)(nil).SubmitChange), ctx, req) + return &MockWithCommentEditSubmitChangeCall{Call: call} +} + +// MockWithCommentEditSubmitChangeCall wrap *gomock.Call +type MockWithCommentEditSubmitChangeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditSubmitChangeCall) Return(arg0 forge.SubmitChangeResult, arg1 error) *MockWithCommentEditSubmitChangeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditSubmitChangeCall) Do(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithCommentEditSubmitChangeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditSubmitChangeCall) DoAndReturn(f func(context.Context, forge.SubmitChangeRequest) (forge.SubmitChangeResult, error)) *MockWithCommentEditSubmitChangeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// UpdateChangeComment mocks base method. +func (m *MockWithCommentEdit) 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 *MockWithCommentEditMockRecorder) UpdateChangeComment(arg0, arg1, arg2 any) *MockWithCommentEditUpdateChangeCommentCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateChangeComment", reflect.TypeOf((*MockWithCommentEdit)(nil).UpdateChangeComment), arg0, arg1, arg2) + return &MockWithCommentEditUpdateChangeCommentCall{Call: call} +} + +// MockWithCommentEditUpdateChangeCommentCall wrap *gomock.Call +type MockWithCommentEditUpdateChangeCommentCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWithCommentEditUpdateChangeCommentCall) Return(arg0 error) *MockWithCommentEditUpdateChangeCommentCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWithCommentEditUpdateChangeCommentCall) Do(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithCommentEditUpdateChangeCommentCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWithCommentEditUpdateChangeCommentCall) DoAndReturn(f func(context.Context, forge.ChangeCommentID, string) error) *MockWithCommentEditUpdateChangeCommentCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/forge/github/inline_comment.go b/internal/forge/github/inline_comment.go new file mode 100644 index 000000000..57018cf5e --- /dev/null +++ b/internal/forge/github/inline_comment.go @@ -0,0 +1,377 @@ +package github + +import ( + "context" + "fmt" + "time" + + "github.com/shurcooL/githubv4" + "go.abhg.dev/gs/internal/forge" +) + +var ( + _ forge.WithInlineComments = (*Repository)(nil) + _ forge.WithThreadResolution = (*Repository)(nil) + _ forge.WithCommentEdit = (*Repository)(nil) +) + +// ListInlineComments lists inline/review comments on a PR +// by querying the reviewThreads connection. +func (r *Repository) ListInlineComments( + ctx context.Context, + id forge.ChangeID, +) ([]*forge.InlineComment, error) { + pr := mustPR(id) + gqlID, err := r.graphQLID(ctx, pr) + if err != nil { + return nil, err + } + + type commentNode struct { + ID githubv4.ID `graphql:"id"` + Body string `graphql:"body"` + URL string `graphql:"url"` + Author struct { + Login string `graphql:"login"` + } `graphql:"author"` + CreatedAt githubv4.DateTime `graphql:"createdAt"` + Outdated bool `graphql:"outdated"` + } + + type threadNode struct { + ID githubv4.ID `graphql:"id"` + IsResolved bool `graphql:"isResolved"` + Path string `graphql:"path"` + Line *int `graphql:"line"` + Comments struct { + Nodes []commentNode `graphql:"nodes"` + } `graphql:"comments(first: 100)"` + } + + var q struct { + Node struct { + PullRequest struct { + ReviewThreads struct { + PageInfo struct { + HasNextPage bool `graphql:"hasNextPage"` + EndCursor githubv4.String `graphql:"endCursor"` + } `graphql:"pageInfo"` + Nodes []threadNode `graphql:"nodes"` + } `graphql:"reviewThreads(first: $first, after: $after)"` + } `graphql:"... on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]any{ + "id": gqlID, + "first": githubv4.Int(100), + "after": (*githubv4.String)(nil), + } + + var comments []*forge.InlineComment + for { + if err := r.client.Query(ctx, &q, variables); err != nil { + return nil, fmt.Errorf("list inline comments: %w", err) + } + + for _, thread := range q.Node.PullRequest.ReviewThreads.Nodes { + threadID := fmt.Sprint(thread.ID) + line := 0 + if thread.Line != nil { + line = *thread.Line + } + + for _, c := range thread.Comments.Nodes { + comments = append(comments, &forge.InlineComment{ + ID: &PRComment{ + GQLID: c.ID, + URL: c.URL, + }, + ThreadID: threadID, + Path: thread.Path, + Line: line, + Body: c.Body, + Author: c.Author.Login, + Resolved: thread.IsResolved, + Outdated: c.Outdated, + CreatedAt: c.CreatedAt.Time, + }) + } + } + + pi := q.Node.PullRequest.ReviewThreads.PageInfo + if !pi.HasNextPage { + break + } + variables["after"] = pi.EndCursor + } + + return comments, nil +} + +// SubmitReview posts a batch of inline comments +// as a single review on a PR. +func (r *Repository) SubmitReview( + ctx context.Context, + id forge.ChangeID, + req forge.ReviewRequest, +) error { + pr := mustPR(id) + gqlID, err := r.graphQLID(ctx, pr) + if err != nil { + return err + } + + // Map review event to GitHub's enum. + var event githubv4.PullRequestReviewEvent + switch req.Event { + case forge.ReviewApprove: + event = githubv4.PullRequestReviewEventApprove + case forge.ReviewRequestChanges: + event = githubv4.PullRequestReviewEventRequestChanges + default: + event = githubv4.PullRequestReviewEventComment + } + + // Build thread inputs for inline comments. + var threads []*githubv4.DraftPullRequestReviewThread + for _, c := range req.Comments { + side := githubv4.DiffSideRight + if c.Side == "LEFT" { + side = githubv4.DiffSideLeft + } + threads = append(threads, + &githubv4.DraftPullRequestReviewThread{ + Path: new( + githubv4.String(c.Path), + ), + Line: new( + githubv4.Int(c.Line), + ), + Side: &side, + Body: githubv4.String(c.Body), + }) + } + + var m struct { + AddPullRequestReview struct { + PullRequestReview struct { + ID githubv4.ID `graphql:"id"` + } `graphql:"pullRequestReview"` + } `graphql:"addPullRequestReview(input: $input)"` + } + + input := githubv4.AddPullRequestReviewInput{ + PullRequestID: gqlID, + Event: &event, + Threads: &threads, + } + if req.Body != "" { + input.Body = new(githubv4.String(req.Body)) + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return fmt.Errorf("submit review: %w", err) + } + + r.log.Debug("Submitted review", + "pr", pr.Number, + "comments", len(req.Comments), + ) + return nil +} + +// PostInlineComment posts a single inline comment +// on a PR by creating a review thread. +func (r *Repository) PostInlineComment( + ctx context.Context, + id forge.ChangeID, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + pr := mustPR(id) + gqlID, err := r.graphQLID(ctx, pr) + if err != nil { + return nil, err + } + + // If replying to an existing thread, use addPullRequestReviewComment. + if req.ThreadID != "" { + return r.replyToThread(ctx, req) + } + + side := githubv4.DiffSideRight + if req.Side == "LEFT" { + side = githubv4.DiffSideLeft + } + + var m struct { + AddPullRequestReviewThread struct { + Thread struct { + ID githubv4.ID `graphql:"id"` + Comments struct { + Nodes []struct { + ID githubv4.ID `graphql:"id"` + URL string `graphql:"url"` + CreatedAt githubv4.DateTime `graphql:"createdAt"` + } `graphql:"nodes"` + } `graphql:"comments(first: 1)"` + } `graphql:"thread"` + } `graphql:"addPullRequestReviewThread(input: $input)"` + } + + input := githubv4.AddPullRequestReviewThreadInput{ + PullRequestID: &gqlID, + Path: new( + githubv4.String(req.Path), + ), + Line: new( + githubv4.Int(req.Line), + ), + Side: &side, + Body: githubv4.String(req.Body), + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return nil, fmt.Errorf("post inline comment: %w", err) + } + + thread := m.AddPullRequestReviewThread.Thread + threadID := fmt.Sprint(thread.ID) + + var commentID forge.ChangeCommentID + var createdAt time.Time + if len(thread.Comments.Nodes) > 0 { + n := thread.Comments.Nodes[0] + commentID = &PRComment{GQLID: n.ID, URL: n.URL} + createdAt = n.CreatedAt.Time + } + + r.log.Debug("Posted inline comment", + "pr", pr.Number, + "path", req.Path, + "line", req.Line, + ) + + return &forge.InlineComment{ + ID: commentID, + ThreadID: threadID, + Path: req.Path, + Line: req.Line, + Body: req.Body, + CreatedAt: createdAt, + }, nil +} + +// replyToThread adds a reply to an existing review thread. +func (r *Repository) replyToThread( + ctx context.Context, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + var m struct { + AddPullRequestReviewThreadReply struct { + Comment struct { + ID githubv4.ID `graphql:"id"` + URL string `graphql:"url"` + CreatedAt githubv4.DateTime `graphql:"createdAt"` + } `graphql:"comment"` + } `graphql:"addPullRequestReviewThreadReply(input: $input)"` + } + + input := githubv4.AddPullRequestReviewThreadReplyInput{ + PullRequestReviewThreadID: githubv4.ID(req.ThreadID), + Body: githubv4.String(req.Body), + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return nil, fmt.Errorf("reply to thread: %w", err) + } + + n := m.AddPullRequestReviewThreadReply.Comment + return &forge.InlineComment{ + ID: &PRComment{GQLID: n.ID, URL: n.URL}, + ThreadID: req.ThreadID, + Path: req.Path, + Line: req.Line, + Body: req.Body, + CreatedAt: n.CreatedAt.Time, + }, nil +} + +// ResolveThread marks a review thread as resolved. +func (r *Repository) ResolveThread( + ctx context.Context, + threadID string, +) error { + var m struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID `graphql:"id"` + } `graphql:"thread"` + } `graphql:"resolveReviewThread(input: $input)"` + } + + input := githubv4.ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + + r.log.Debug("Resolved thread", "threadID", threadID) + return nil +} + +// UnresolveThread marks a review thread as unresolved. +func (r *Repository) UnresolveThread( + ctx context.Context, + threadID string, +) error { + var m struct { + UnresolveReviewThread struct { + Thread struct { + ID githubv4.ID `graphql:"id"` + } `graphql:"thread"` + } `graphql:"unresolveReviewThread(input: $input)"` + } + + input := githubv4.UnresolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return fmt.Errorf("unresolve thread: %w", err) + } + + r.log.Debug("Unresolved thread", "threadID", threadID) + return nil +} + +// EditComment updates the body of an existing review comment. +func (r *Repository) EditComment( + ctx context.Context, + id forge.ChangeCommentID, + body string, +) error { + cid := mustPRComment(id) + + var m struct { + UpdatePullRequestReviewComment struct { + PullRequestReviewComment struct { + ID githubv4.ID `graphql:"id"` + } `graphql:"pullRequestReviewComment"` + } `graphql:"updatePullRequestReviewComment(input: $input)"` + } + + input := githubv4.UpdatePullRequestReviewCommentInput{ + PullRequestReviewCommentID: cid.GQLID, + Body: githubv4.String(body), + } + + if err := r.client.Mutate(ctx, &m, input, nil); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + r.log.Debug("Edited comment", "url", cid.URL) + return nil +} diff --git a/internal/forge/gitlab/inline_comment.go b/internal/forge/gitlab/inline_comment.go new file mode 100644 index 000000000..cbfa86790 --- /dev/null +++ b/internal/forge/gitlab/inline_comment.go @@ -0,0 +1,418 @@ +package gitlab + +import ( + "context" + "fmt" + "strconv" + "time" + + "go.abhg.dev/gs/internal/forge" + "go.abhg.dev/gs/internal/gateway/gitlab" +) + +var ( + _ forge.WithInlineComments = (*Repository)(nil) + _ forge.WithThreadResolution = (*Repository)(nil) + _ forge.WithCommentEdit = (*Repository)(nil) +) + +// ListInlineComments lists inline/review comments on a MR +// by fetching discussions that have position information. +func (r *Repository) ListInlineComments( + ctx context.Context, + id forge.ChangeID, +) ([]*forge.InlineComment, error) { + mr := mustMR(id) + + opts := &gitlab.ListMergeRequestDiscussionsOptions{ + ListOptions: gitlab.ListOptions{PerPage: 100}, + } + + var comments []*forge.InlineComment + for { + discussions, resp, err := r.client.MergeRequestDiscussionList( + ctx, r.repoID, mr.Number, opts, + ) + if err != nil { + return nil, fmt.Errorf( + "list discussions: %w", err, + ) + } + + for _, disc := range discussions { + if len(disc.Notes) == 0 { + continue + } + // Only include discussions that are resolvable + // (i.e., code review / diff discussions). + root := disc.Notes[0] + if !root.Resolvable { + continue + } + + for _, note := range disc.Notes { + path := "" + var line int + if note.Position != nil { + path = note.Position.NewPath + if note.Position.NewLine != 0 { + line = int(note.Position.NewLine) + } + } + + createdAt := time.Time{} + if note.CreatedAt != nil { + createdAt = *note.CreatedAt + } + + comments = append(comments, + &forge.InlineComment{ + ID: &MRComment{ + Number: note.ID, + MRNumber: mr.Number, + }, + ThreadID: formatThreadID( + disc.ID, mr.Number, + ), + Path: path, + Line: line, + Body: note.Body, + Author: note.Author.Username, + Resolved: root.Resolved, + Outdated: note.Position != nil && note.Position.NewPath == "", + CreatedAt: createdAt, + }) + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = int64(resp.NextPage) + } + + return comments, nil +} + +// SubmitReview posts a batch of inline comments +// as individual discussions on a MR. +// GitLab does not have a native batch review API, +// so each comment is posted as a separate discussion. +func (r *Repository) SubmitReview( + ctx context.Context, + id forge.ChangeID, + req forge.ReviewRequest, +) error { + mr := mustMR(id) + + // Get diff refs for positioning. + diffRefs, err := r.diffRefs(ctx, mr.Number) + if err != nil { + return err + } + + for _, c := range req.Comments { + if c.ThreadID != "" { + // Reply to existing thread. + discID, _, perr := parseThreadID(c.ThreadID) + if perr != nil { + return perr + } + body := c.Body + if _, _, err := r.client.MergeRequestDiscussionNoteCreate( + ctx, r.repoID, mr.Number, discID, + &gitlab.AddMergeRequestDiscussionNoteOptions{ + Body: &body, + }, + ); err != nil { + return fmt.Errorf( + "reply to thread %s: %w", + c.ThreadID, err, + ) + } + continue + } + + opts := newDiscussionOptions(c.Body, c.Path, c.Line, diffRefs) + if _, _, err := r.client.MergeRequestDiscussionCreate( + ctx, r.repoID, mr.Number, opts, + ); err != nil { + return fmt.Errorf( + "create discussion on %s:%d: %w", + c.Path, c.Line, err, + ) + } + } + + r.log.Debug("Submitted review", + "mr", mr.Number, + "comments", len(req.Comments), + ) + return nil +} + +// PostInlineComment posts a single inline comment +// on a MR as a new discussion. +func (r *Repository) PostInlineComment( + ctx context.Context, + id forge.ChangeID, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + mr := mustMR(id) + + // Reply to existing thread. + if req.ThreadID != "" { + discID, _, err := parseThreadID(req.ThreadID) + if err != nil { + return nil, err + } + body := req.Body + note, _, err := r.client.MergeRequestDiscussionNoteCreate( + ctx, r.repoID, mr.Number, discID, + &gitlab.AddMergeRequestDiscussionNoteOptions{ + Body: &body, + }, + ) + if err != nil { + return nil, fmt.Errorf( + "reply to thread: %w", err, + ) + } + + createdAt := time.Time{} + if note.CreatedAt != nil { + createdAt = *note.CreatedAt + } + return &forge.InlineComment{ + ID: &MRComment{ + Number: note.ID, + MRNumber: mr.Number, + }, + ThreadID: req.ThreadID, + Path: req.Path, + Line: req.Line, + Body: req.Body, + CreatedAt: createdAt, + }, nil + } + + // New discussion. + diffRefs, err := r.diffRefs(ctx, mr.Number) + if err != nil { + return nil, err + } + + disc, _, err := r.client.MergeRequestDiscussionCreate( + ctx, r.repoID, mr.Number, + newDiscussionOptions(req.Body, req.Path, req.Line, diffRefs), + ) + if err != nil { + return nil, fmt.Errorf("create discussion: %w", err) + } + + var noteID int64 + if len(disc.Notes) > 0 { + noteID = disc.Notes[0].ID + } + + r.log.Debug("Posted inline comment", + "mr", mr.Number, + "path", req.Path, + "line", req.Line, + ) + + return &forge.InlineComment{ + ID: &MRComment{ + Number: noteID, + MRNumber: mr.Number, + }, + ThreadID: formatThreadID(disc.ID, mr.Number), + Path: req.Path, + Line: req.Line, + Body: req.Body, + }, nil +} + +func newDiscussionOptions( + body, path string, line int, + refs *gitlab.MergeRequestDiffRefs, +) *gitlab.CreateMergeRequestDiscussionOptions { + textType := "text" + lineN := int64(line) + return &gitlab.CreateMergeRequestDiscussionOptions{ + Body: &body, + Position: &gitlab.PositionOptions{ + BaseSHA: &refs.BaseSha, + HeadSHA: &refs.HeadSha, + StartSHA: &refs.StartSha, + PositionType: &textType, + NewPath: &path, + OldPath: &path, + NewLine: &lineN, + }, + } +} + +// diffRefs fetches the diff refs for a MR, +// needed for positioning inline comments. +func (r *Repository) diffRefs( + ctx context.Context, + mrNumber int64, +) (*gitlab.MergeRequestDiffRefs, error) { + mr, _, err := r.client.MergeRequestGet( + ctx, r.repoID, mrNumber, nil, + ) + if err != nil { + return nil, fmt.Errorf("get merge request: %w", err) + } + return &mr.DiffRefs, nil +} + +// ResolveThread marks a discussion thread as resolved. +func (r *Repository) ResolveThread( + ctx context.Context, + threadID string, +) error { + // Thread ID format: "discussion_id:mr_number" + discID, mrNumber, err := parseThreadID(threadID) + if err != nil { + return err + } + + resolved := true + if _, _, err := r.client.MergeRequestDiscussionResolve( + ctx, r.repoID, mrNumber, discID, + &gitlab.ResolveMergeRequestDiscussionOptions{ + Resolved: &resolved, + }, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + + r.log.Debug("Resolved thread", "threadID", threadID) + return nil +} + +// UnresolveThread marks a discussion thread as unresolved. +func (r *Repository) UnresolveThread( + ctx context.Context, + threadID string, +) error { + discID, mrNumber, err := parseThreadID(threadID) + if err != nil { + return err + } + + resolved := false + if _, _, err := r.client.MergeRequestDiscussionResolve( + ctx, r.repoID, mrNumber, discID, + &gitlab.ResolveMergeRequestDiscussionOptions{ + Resolved: &resolved, + }, + ); err != nil { + return fmt.Errorf("unresolve thread: %w", err) + } + + r.log.Debug("Unresolved thread", "threadID", threadID) + return nil +} + +// EditComment updates the body of an existing MR comment. +func (r *Repository) EditComment( + ctx context.Context, + id forge.ChangeCommentID, + body string, +) error { + cid := mustMRComment(id) + + // Find the discussion containing the note + // so we can use the discussion-level update API. + discID, err := r.findDiscussionForNote( + ctx, cid.MRNumber, cid.Number, + ) + if err != nil { + return err + } + + if _, _, err := r.client.MergeRequestDiscussionNoteUpdate( + ctx, r.repoID, cid.MRNumber, discID, cid.Number, + &gitlab.UpdateMergeRequestDiscussionNoteOptions{ + Body: &body, + }, + ); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + r.log.Debug("Edited comment", "noteID", cid.Number) + return nil +} + +// findDiscussionForNote finds the discussion ID +// that contains a specific note. +func (r *Repository) findDiscussionForNote( + ctx context.Context, + mrNumber, noteID int64, +) (string, error) { + opts := &gitlab.ListMergeRequestDiscussionsOptions{ + ListOptions: gitlab.ListOptions{PerPage: 100}, + } + + for { + discussions, resp, err := r.client.MergeRequestDiscussionList( + ctx, r.repoID, mrNumber, opts, + ) + if err != nil { + return "", fmt.Errorf( + "list discussions: %w", err, + ) + } + + for _, disc := range discussions { + for _, note := range disc.Notes { + if note.ID == noteID { + return disc.ID, nil + } + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = int64(resp.NextPage) + } + + return "", fmt.Errorf( + "note %d not found in MR %d", noteID, mrNumber, + ) +} + +// threadID format for GitLab: "discussion_id:mr_number" +// This encodes both the discussion ID and MR number +// so resolve/unresolve can work without extra context. + +func formatThreadID(discID string, mrNumber int64) string { + return discID + ":" + strconv.FormatInt(mrNumber, 10) +} + +func parseThreadID(threadID string) ( + discID string, mrNumber int64, err error, +) { + for i := len(threadID) - 1; i >= 0; i-- { + if threadID[i] == ':' { + discID = threadID[:i] + mrNumber, err = strconv.ParseInt( + threadID[i+1:], 10, 64, + ) + if err != nil { + return "", 0, fmt.Errorf( + "parse thread ID %q: %w", + threadID, err, + ) + } + return discID, mrNumber, nil + } + } + return "", 0, fmt.Errorf( + "invalid thread ID format: %q", threadID, + ) +} diff --git a/internal/forge/shamhub/comment.go b/internal/forge/shamhub/comment.go index 9dfaf358a..899192c36 100644 --- a/internal/forge/shamhub/comment.go +++ b/internal/forge/shamhub/comment.go @@ -8,6 +8,7 @@ import ( "iter" "slices" "strconv" + "time" "go.abhg.dev/gs/internal/forge" ) @@ -135,11 +136,32 @@ type shamComment struct { Change int Body string - // Resolvable indicates this is a code review comment that can be resolved. + // Resolvable indicates this is a code review comment + // that can be resolved. Resolvable bool // Resolved indicates this comment has been resolved. Resolved bool + + // Inline comment fields (optional). + + // Path is the file path for inline comments. + Path string + + // Line is the line number for inline comments. + Line int + + // Side is "LEFT" or "RIGHT" for inline comments. + Side string + + // ThreadID groups inline comments into threads. + ThreadID string + + // Author is the comment author username. + Author string + + // CreatedAt is the comment creation timestamp. + CreatedAt time.Time } var ( diff --git a/internal/forge/shamhub/inline_comment.go b/internal/forge/shamhub/inline_comment.go new file mode 100644 index 000000000..b9d64781d --- /dev/null +++ b/internal/forge/shamhub/inline_comment.go @@ -0,0 +1,459 @@ +package shamhub + +import ( + "context" + "fmt" + "strconv" + "time" + + "go.abhg.dev/gs/internal/forge" +) + +var ( + _ forge.WithInlineComments = (*forgeRepository)(nil) + _ forge.WithThreadResolution = (*forgeRepository)(nil) + _ forge.WithCommentEdit = (*forgeRepository)(nil) +) + +// Inline comment handlers + +var ( + _ = shamhubRESTHandler( + "GET /{owner}/{repo}/inline-comments", + (*ShamHub).handleListInlineComments, + ) + _ = shamhubRESTHandler( + "POST /{owner}/{repo}/inline-comments", + (*ShamHub).handlePostInlineComment, + ) + _ = shamhubRESTHandler( + "POST /{owner}/{repo}/reviews", + (*ShamHub).handleSubmitReview, + ) + _ = shamhubRESTHandler( + "POST /{owner}/{repo}/threads/{threadID}/resolve", + (*ShamHub).handleResolveThread, + ) + _ = shamhubRESTHandler( + "POST /{owner}/{repo}/threads/{threadID}/unresolve", + (*ShamHub).handleUnresolveThread, + ) + _ = shamhubRESTHandler( + "PATCH /{owner}/{repo}/comments/{id}/edit", + (*ShamHub).handleEditComment, + ) +) + +// List inline comments + +type listInlineCommentsRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + Change int `form:"change,required" json:"-"` +} + +type listInlineCommentsResponse struct { + Items []inlineCommentItem `json:"items"` +} + +type inlineCommentItem struct { + ID int `json:"id"` + ThreadID string `json:"threadID"` + Path string `json:"path"` + Line int `json:"line"` + Body string `json:"body"` + Author string `json:"author"` + Resolved bool `json:"resolved"` + Outdated bool `json:"outdated"` + CreatedAt time.Time `json:"createdAt"` +} + +func (sh *ShamHub) handleListInlineComments( + _ context.Context, + req *listInlineCommentsRequest, +) (*listInlineCommentsResponse, error) { + sh.mu.RLock() + defer sh.mu.RUnlock() + + var items []inlineCommentItem + for _, c := range sh.comments { + if c.Change != req.Change || c.Path == "" { + continue + } + items = append(items, inlineCommentItem{ + ID: c.ID, + ThreadID: c.ThreadID, + Path: c.Path, + Line: c.Line, + Body: c.Body, + Author: c.Author, + Resolved: c.Resolved, + Outdated: false, + CreatedAt: c.CreatedAt, + }) + } + + return &listInlineCommentsResponse{Items: items}, nil +} + +func (r *forgeRepository) ListInlineComments( + ctx context.Context, + id forge.ChangeID, +) ([]*forge.InlineComment, error) { + u := r.apiURL.JoinPath(r.owner, r.repo, "inline-comments") + q := u.Query() + q.Set("change", strconv.Itoa(int(id.(ChangeID)))) + u.RawQuery = q.Encode() + + var res listInlineCommentsResponse + if err := r.client.Get(ctx, u.String(), &res); err != nil { + return nil, fmt.Errorf("list inline comments: %w", err) + } + + var comments []*forge.InlineComment + for _, item := range res.Items { + comments = append(comments, &forge.InlineComment{ + ID: ChangeCommentID(item.ID), + ThreadID: item.ThreadID, + Path: item.Path, + Line: item.Line, + Body: item.Body, + Author: item.Author, + Resolved: item.Resolved, + Outdated: item.Outdated, + CreatedAt: item.CreatedAt, + }) + } + return comments, nil +} + +// Post inline comment + +type postInlineCommentRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + + Change int `json:"change"` + Path string `json:"path"` + Line int `json:"line"` + Body string `json:"body"` + Side string `json:"side"` + ThreadID string `json:"threadID,omitempty"` +} + +type postInlineCommentResponse struct { + ID int `json:"id"` + ThreadID string `json:"threadID"` + CreatedAt time.Time `json:"createdAt"` +} + +func (sh *ShamHub) handlePostInlineComment( + _ context.Context, + req *postInlineCommentRequest, +) (*postInlineCommentResponse, error) { + sh.mu.Lock() + defer sh.mu.Unlock() + + threadID := req.ThreadID + if threadID == "" { + // New thread: generate a thread ID. + threadID = fmt.Sprintf("thread-%d", len(sh.comments)+1) + } + + now := time.Now() + comment := shamComment{ + ID: len(sh.comments) + 1, + Change: req.Change, + Body: req.Body, + Path: req.Path, + Line: req.Line, + Side: req.Side, + ThreadID: threadID, + Resolvable: true, + Author: "test-user", + CreatedAt: now, + } + sh.comments = append(sh.comments, comment) + + return &postInlineCommentResponse{ + ID: comment.ID, + ThreadID: threadID, + CreatedAt: now, + }, nil +} + +func (r *forgeRepository) PostInlineComment( + ctx context.Context, + id forge.ChangeID, + req forge.InlineCommentRequest, +) (*forge.InlineComment, error) { + u := r.apiURL.JoinPath( + r.owner, r.repo, "inline-comments", + ) + body := postInlineCommentRequest{ + Change: int(id.(ChangeID)), + Path: req.Path, + Line: req.Line, + Body: req.Body, + Side: req.Side, + ThreadID: req.ThreadID, + } + + var res postInlineCommentResponse + if err := r.client.Post( + ctx, u.String(), body, &res, + ); err != nil { + return nil, fmt.Errorf("post inline comment: %w", err) + } + + return &forge.InlineComment{ + ID: ChangeCommentID(res.ID), + ThreadID: res.ThreadID, + Path: req.Path, + Line: req.Line, + Body: req.Body, + CreatedAt: res.CreatedAt, + }, nil +} + +// Submit review (batch) + +type submitReviewRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + + Change int `json:"change"` + Body string `json:"body,omitempty"` + Event int `json:"event"` + Comments []submitReviewCommentRequest `json:"comments"` +} + +type submitReviewCommentRequest struct { + Path string `json:"path"` + Line int `json:"line"` + Body string `json:"body"` + Side string `json:"side,omitempty"` + ThreadID string `json:"threadID,omitempty"` +} + +type submitReviewResponse struct { + CommentIDs []int `json:"commentIDs"` +} + +func (sh *ShamHub) handleSubmitReview( + _ context.Context, + req *submitReviewRequest, +) (*submitReviewResponse, error) { + sh.mu.Lock() + defer sh.mu.Unlock() + + now := time.Now() + var ids []int + for _, c := range req.Comments { + threadID := c.ThreadID + if threadID == "" { + threadID = fmt.Sprintf( + "thread-%d", len(sh.comments)+1, + ) + } + + comment := shamComment{ + ID: len(sh.comments) + 1, + Change: req.Change, + Body: c.Body, + Path: c.Path, + Line: c.Line, + Side: c.Side, + ThreadID: threadID, + Resolvable: true, + Author: "test-user", + CreatedAt: now, + } + sh.comments = append(sh.comments, comment) + ids = append(ids, comment.ID) + } + + return &submitReviewResponse{CommentIDs: ids}, nil +} + +func (r *forgeRepository) SubmitReview( + ctx context.Context, + id forge.ChangeID, + req forge.ReviewRequest, +) error { + u := r.apiURL.JoinPath(r.owner, r.repo, "reviews") + + var comments []submitReviewCommentRequest + for _, c := range req.Comments { + comments = append(comments, submitReviewCommentRequest{ + Path: c.Path, + Line: c.Line, + Body: c.Body, + Side: c.Side, + ThreadID: c.ThreadID, + }) + } + + body := submitReviewRequest{ + Change: int(id.(ChangeID)), + Body: req.Body, + Event: int(req.Event), + Comments: comments, + } + + var res submitReviewResponse + if err := r.client.Post( + ctx, u.String(), body, &res, + ); err != nil { + return fmt.Errorf("submit review: %w", err) + } + + return nil +} + +// Resolve/unresolve threads + +type resolveThreadRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + ThreadID string `path:"threadID" json:"-"` +} + +type resolveThreadResponse struct{} + +func (sh *ShamHub) handleResolveThread( + _ context.Context, + req *resolveThreadRequest, +) (*resolveThreadResponse, error) { + sh.mu.Lock() + defer sh.mu.Unlock() + + found := false + for i, c := range sh.comments { + if c.ThreadID == req.ThreadID { + sh.comments[i].Resolved = true + found = true + } + } + if !found { + return nil, notFoundErrorf( + "thread %s not found", req.ThreadID, + ) + } + + return &resolveThreadResponse{}, nil +} + +func (sh *ShamHub) handleUnresolveThread( + _ context.Context, + req *resolveThreadRequest, +) (*resolveThreadResponse, error) { + sh.mu.Lock() + defer sh.mu.Unlock() + + found := false + for i, c := range sh.comments { + if c.ThreadID == req.ThreadID { + sh.comments[i].Resolved = false + found = true + } + } + if !found { + return nil, notFoundErrorf( + "thread %s not found", req.ThreadID, + ) + } + + return &resolveThreadResponse{}, nil +} + +func (r *forgeRepository) ResolveThread( + ctx context.Context, + threadID string, +) error { + u := r.apiURL.JoinPath( + r.owner, r.repo, "threads", threadID, "resolve", + ) + + var res resolveThreadResponse + if err := r.client.Post( + ctx, u.String(), struct{}{}, &res, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + + return nil +} + +func (r *forgeRepository) UnresolveThread( + ctx context.Context, + threadID string, +) error { + u := r.apiURL.JoinPath( + r.owner, r.repo, "threads", threadID, "unresolve", + ) + + var res resolveThreadResponse + if err := r.client.Post( + ctx, u.String(), struct{}{}, &res, + ); err != nil { + return fmt.Errorf("unresolve thread: %w", err) + } + + return nil +} + +// Edit comment + +type editCommentRequest struct { + Owner string `path:"owner" json:"-"` + Repo string `path:"repo" json:"-"` + ID int `path:"id" json:"-"` + + Body string `json:"body"` +} + +type editCommentResponse struct { + ID int `json:"id"` +} + +func (sh *ShamHub) handleEditComment( + _ context.Context, + req *editCommentRequest, +) (*editCommentResponse, error) { + sh.mu.Lock() + defer sh.mu.Unlock() + + for i, c := range sh.comments { + if c.ID == req.ID { + sh.comments[i].Body = req.Body + return &editCommentResponse{ID: req.ID}, nil + } + } + + return nil, notFoundErrorf( + "comment %d not found", req.ID, + ) +} + +func (r *forgeRepository) EditComment( + ctx context.Context, + id forge.ChangeCommentID, + body string, +) error { + cid := int(id.(ChangeCommentID)) + u := r.apiURL.JoinPath( + r.owner, r.repo, "comments", + strconv.Itoa(cid), "edit", + ) + + req := editCommentRequest{Body: body} + var res editCommentResponse + if err := r.client.Patch( + ctx, u.String(), req, &res, + ); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + return nil +} diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/UpdateComment/updated-comment b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/UpdateComment/updated-comment index 85760ee02..dda3adda6 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/UpdateComment/updated-comment +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/UpdateComment/updated-comment @@ -1 +1 @@ -"uXAhcpF8V9XZWbQom2aI0nM6Q9fbiqpd" \ No newline at end of file +"YJgNsP025jRY5hQX6mh2mIfiwifvz6G8" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/branch b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/branch index 3d68dade2..0bc036d75 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/branch @@ -1 +1 @@ -"uO3v3Jiu" \ No newline at end of file +"pmwWlNhh" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/comments b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/comments index e57864939..cb58610d5 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/comments +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangeComments/comments @@ -1,12 +1,12 @@ [ - "M9TCBnUn2veSjGnStFRLruw4FhpFbpPr", - "ooErWPQY4wHcWm8bJDNOeA2G2BHnMcl1", - "DU3TnJBDljtJOjrPNPgfJRo44fzPxWxZ", - "WeEXYW6yjMMf0jmnnze12omw5CmTHrDt", - "S4IQsdn1dgnUfOJEFb5JEm7kVxLd8oil", - "JNpsluBOeFN8cwri2EMfI8BrYjbArxPf", - "FgUJanBcocO1ablzVyYVjDh6fUZbZXyI", - "qp1FOZUVrehYPmU311YMEQDrHlJ2BJ40", - "sODVHSvcIOhqxyrkgurNH9HMl2b2LSg6", - "k7NLLLvg5wMj1tasfkFfqdbhqfa8Ah0E" + "K067WOW7dQdrliaKS0tKylcfDgP5GhVp", + "dsCMPgmyz0KVBG501PPi2ocxE5sq7UfQ", + "3zyUZ2FZcxkOgp4FBvJfBKTnh9fdpNa3", + "im86H34osfgrDc7E4gdx7gE3XXcTXmPX", + "fgt7AG4rAoQp1gGEw8G2U2lbe0qWrTmp", + "KMuajrgxFADVDZhIFdzUBVzpAm9ee7xR", + "6El2lmQr5Ea5Hq9L0fLVfCSATL6qaGJl", + "ha2n0xHKCbk6xogserVQ8vOR2o2gbNH8", + "Dr8DblxCuohfDzo0gUkdZdTuRxz8uIfz", + "lr6i3eENW1lJd9i6NNxcjZCditYNWUqF" ] \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/closedBranch b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/closedBranch index d616a2169..6f5fee334 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/closedBranch +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/closedBranch @@ -1 +1 @@ -"IEtiDhFs" \ No newline at end of file +"mzMsv5Rh" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/mergedBranch b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/mergedBranch index 2142ea6ab..3d9a25268 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/mergedBranch +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/mergedBranch @@ -1 +1 @@ -"C5hzQ5Ik" \ No newline at end of file +"gvgMWiD8" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/openBranch b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/openBranch index 7c13c61bf..7e77aa963 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/openBranch +++ b/internal/forge/shamhub/testdata/TestIntegration/ChangesStates/openBranch @@ -1 +1 @@ -"jMfwXkYK" \ No newline at end of file +"QohFltlh" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/CommentCountsByChange/branch b/internal/forge/shamhub/testdata/TestIntegration/CommentCountsByChange/branch index 778cc9b7f..1b0a87a96 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/CommentCountsByChange/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/CommentCountsByChange/branch @@ -1 +1 @@ -"HQWHaq9z" \ No newline at end of file +"wleC7vBI" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/empty-template b/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/empty-template index 3da52d175..305090d1d 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/empty-template +++ b/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/empty-template @@ -1 +1 @@ -"1HQqVNZV.md" \ No newline at end of file +"np6KTBsj.md" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/non-empty-template b/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/non-empty-template index 0c924f438..ef691a56a 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/non-empty-template +++ b/internal/forge/shamhub/testdata/TestIntegration/ListChangeTemplates/TemplatesPresent/non-empty-template @@ -1 +1 @@ -"yE3Lj2v5.md" \ No newline at end of file +"aW2r2kGY.md" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/base-branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/base-branch index b8e719155..4d6f27de3 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/base-branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/base-branch @@ -1 +1 @@ -"9s9oMX6C" \ No newline at end of file +"ExQcEmr1" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/branch index 02926e09f..a8a9575a0 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitBaseDoesNotExist/branch @@ -1 +1 @@ -"vY4xsORW" \ No newline at end of file +"bcKIEKS3" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssignee/branch-no-assignee b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssignee/branch-no-assignee index b92100789..93c1210d2 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssignee/branch-no-assignee +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssignee/branch-no-assignee @@ -1 +1 @@ -"66MtCOhk" \ No newline at end of file +"4D8sFmyC" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne/branch-no-assignee-one-by-one b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne/branch-no-assignee-one-by-one index e678e3903..97d4a3716 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne/branch-no-assignee-one-by-one +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne/branch-no-assignee-one-by-one @@ -1 +1 @@ -"Op9WQIRi" \ No newline at end of file +"lEG1MUxo" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/SubmitWithAssignee/branch-with-assignee b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/SubmitWithAssignee/branch-with-assignee index d3f1200a7..6aba0ca56 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/SubmitWithAssignee/branch-with-assignee +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditAssignees/SubmitWithAssignee/branch-with-assignee @@ -1 +1 @@ -"lE3mefp0" \ No newline at end of file +"UQDEDpcg" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/base b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/base index 5a0613317..bd79f7cc0 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/base +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/base @@ -1 +1 @@ -"t8XzNN0X" \ No newline at end of file +"P2inVOao" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/branch index 133b93253..39462bd3d 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditBase/branch @@ -1 +1 @@ -"9YbzXae4" \ No newline at end of file +"mKnU8EV4" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/branch index 879ccaf2e..d5094a646 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/branch @@ -1 +1 @@ -"RowsHzO8" \ No newline at end of file +"cI6Rfmnn" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/firstCommitHash b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/firstCommitHash index 68000fd15..7e986537e 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/firstCommitHash +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditChange/firstCommitHash @@ -1 +1 @@ -"1ee3894a24c031b5d053a0b8bb25a9182fd77604" \ No newline at end of file +"1e2c32dc4f5cfec9674dac2d9d7d0274a63c15b0" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditDraft/branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditDraft/branch index 5bfde7014..3bd5a7d4f 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditDraft/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditDraft/branch @@ -1 +1 @@ -"pCyU7Tpe" \ No newline at end of file +"R4gPQ4SL" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/branch b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/branch index 82678979f..d24723898 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/branch +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/branch @@ -1 +1 @@ -"jbBnJuHT" \ No newline at end of file +"4HOIehwu" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label1 b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label1 index de5114824..ca667f6f4 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label1 +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label1 @@ -1 +1 @@ -"QcHb0icn" \ No newline at end of file +"2VGRqO7j" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label2 b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label2 index 7b3d9d81e..2939cac77 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label2 +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label2 @@ -1 +1 @@ -"820svnw8" \ No newline at end of file +"dF2bQUKv" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label3 b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label3 index c09704b58..5a7982702 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label3 +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditLabels/label3 @@ -1 +1 @@ -"bUnh1ONJ" \ No newline at end of file +"bSgDWsqi" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewer/branch-no-reviewer b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewer/branch-no-reviewer index 876aafd30..d834b007c 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewer/branch-no-reviewer +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewer/branch-no-reviewer @@ -1 +1 @@ -"qS0oWVxR" \ No newline at end of file +"BXaFutk3" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne/branch-no-reviewer-one-by-one b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne/branch-no-reviewer-one-by-one index df22e5ade..c34fa70ab 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne/branch-no-reviewer-one-by-one +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne/branch-no-reviewer-one-by-one @@ -1 +1 @@ -"sN0uOM0J" \ No newline at end of file +"EwqrlAsh" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/SubmitWithReviewer/branch-with-reviewer b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/SubmitWithReviewer/branch-with-reviewer index 9bf39d178..ad68d67bc 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/SubmitWithReviewer/branch-with-reviewer +++ b/internal/forge/shamhub/testdata/TestIntegration/SubmitEditReviewers/SubmitWithReviewer/branch-with-reviewer @@ -1 +1 @@ -"4oj7KI1p" \ No newline at end of file +"df7WoDco" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/apiURL b/internal/forge/shamhub/testdata/TestIntegration/apiURL index 38ca6faef..4103c7885 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/apiURL +++ b/internal/forge/shamhub/testdata/TestIntegration/apiURL @@ -1 +1 @@ -"http://127.0.0.1:56373" \ No newline at end of file +"http://127.0.0.1:53569" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/gitURL b/internal/forge/shamhub/testdata/TestIntegration/gitURL index 0d906a389..01ae293af 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/gitURL +++ b/internal/forge/shamhub/testdata/TestIntegration/gitURL @@ -1 +1 @@ -"http://127.0.0.1:56374" \ No newline at end of file +"http://127.0.0.1:53570" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/pushRepoURL b/internal/forge/shamhub/testdata/TestIntegration/pushRepoURL index 03fa24f07..ed207937f 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/pushRepoURL +++ b/internal/forge/shamhub/testdata/TestIntegration/pushRepoURL @@ -1 +1 @@ -"http://127.0.0.1:56374/abhinav-fork/test-repo.git" \ No newline at end of file +"http://127.0.0.1:53570/abhinav-fork/test-repo.git" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/repoURL b/internal/forge/shamhub/testdata/TestIntegration/repoURL index 4551e0131..4c5e37538 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/repoURL +++ b/internal/forge/shamhub/testdata/TestIntegration/repoURL @@ -1 +1 @@ -"http://127.0.0.1:56374/abhinav/test-repo.git" \ No newline at end of file +"http://127.0.0.1:53570/abhinav/test-repo.git" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/TestIntegration/token b/internal/forge/shamhub/testdata/TestIntegration/token index a5ee830ce..8e0625d81 100644 --- a/internal/forge/shamhub/testdata/TestIntegration/token +++ b/internal/forge/shamhub/testdata/TestIntegration/token @@ -1 +1 @@ -"444a9baa5f8f8904" \ No newline at end of file +"1ec8a66d2ad7f057" \ No newline at end of file diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Failed.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Failed.yaml index 5b25e03ce..b345987f7 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Failed.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Failed.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 88 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"subject":"Checks qP4Hn8gu","body":"Checks state test","base":"main","head":"qP4Hn8gu"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 19, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/19" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/19" } headers: Content-Length: @@ -34,10 +34,10 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 56 - host: 127.0.0.1:56373 - body: '{"name":"git-spice integration failed","state":"failed"}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/19/checks + content_length: 18 + host: 127.0.0.1:53569 + body: '{"state":"failed"}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/19/checks method: POST response: proto: HTTP/1.1 @@ -60,22 +60,17 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/19/checks + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/19/checks method: GET response: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 90 + content_length: 24 body: | { - "checks": [ - { - "name": "git-spice integration failed", - "state": "failed" - } - ] + "state": "failed" } headers: Content-Length: diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Passed.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Passed.yaml index 00384b82c..aa5b9649a 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Passed.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Passed.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 88 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"subject":"Checks f0XMOiaa","body":"Checks state test","base":"main","head":"f0XMOiaa"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 9, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/9" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/9" } headers: Content-Length: @@ -34,10 +34,10 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 56 - host: 127.0.0.1:56373 - body: '{"name":"git-spice integration passed","state":"passed"}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/9/checks + content_length: 18 + host: 127.0.0.1:53569 + body: '{"state":"passed"}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/9/checks method: POST response: proto: HTTP/1.1 @@ -60,22 +60,17 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/9/checks + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/9/checks method: GET response: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 90 + content_length: 24 body: | { - "checks": [ - { - "name": "git-spice integration passed", - "state": "passed" - } - ] + "state": "passed" } headers: Content-Length: diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Pending.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Pending.yaml index e480c13a4..5ed5de929 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Pending.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeChecksState/Pending.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 88 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"subject":"Checks MGWUNTrV","body":"Checks state test","base":"main","head":"MGWUNTrV"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 3, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/3" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/3" } headers: Content-Length: @@ -34,10 +34,10 @@ interactions: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 58 - host: 127.0.0.1:56373 - body: '{"name":"git-spice integration pending","state":"pending"}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/3/checks + content_length: 19 + host: 127.0.0.1:53569 + body: '{"state":"pending"}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/3/checks method: POST response: proto: HTTP/1.1 @@ -60,22 +60,17 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/3/checks + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3/checks method: GET response: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 92 + content_length: 25 body: | { - "checks": [ - { - "name": "git-spice integration pending", - "state": "pending" - } - ] + "state": "pending" } headers: Content-Length: diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeComments.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeComments.yaml index 889179595..edfe47a02 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeComments.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangeComments.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 92 - host: 127.0.0.1:56373 - body: '{"subject":"Testing uO3v3Jiu","body":"Test PR for comments","base":"main","head":"uO3v3Jiu"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing pmwWlNhh","body":"Test PR for comments","base":"main","head":"pmwWlNhh"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 80 body: | { - "number": 4, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/4" + "number": 5, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/5" } headers: Content-Length: @@ -28,16 +28,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.3975ms + duration: 43.473041ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"M9TCBnUn2veSjGnStFRLruw4FhpFbpPr"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"K067WOW7dQdrliaKS0tKylcfDgP5GhVp"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -55,16 +55,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 123.292µs + duration: 345.417µs - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"ooErWPQY4wHcWm8bJDNOeA2G2BHnMcl1"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"dsCMPgmyz0KVBG501PPi2ocxE5sq7UfQ"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -82,16 +82,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.531375ms + duration: 158.667µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"DU3TnJBDljtJOjrPNPgfJRo44fzPxWxZ"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"3zyUZ2FZcxkOgp4FBvJfBKTnh9fdpNa3"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -109,16 +109,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 84.25µs + duration: 417.5µs - id: 4 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"WeEXYW6yjMMf0jmnnze12omw5CmTHrDt"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"im86H34osfgrDc7E4gdx7gE3XXcTXmPX"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -136,16 +136,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 482.875µs + duration: 224µs - id: 5 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"S4IQsdn1dgnUfOJEFb5JEm7kVxLd8oil"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"fgt7AG4rAoQp1gGEw8G2U2lbe0qWrTmp"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -163,16 +163,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 224.083µs + duration: 361.833µs - id: 6 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"JNpsluBOeFN8cwri2EMfI8BrYjbArxPf"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"KMuajrgxFADVDZhIFdzUBVzpAm9ee7xR"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -190,16 +190,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 103.5µs + duration: 699.25µs - id: 7 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"FgUJanBcocO1ablzVyYVjDh6fUZbZXyI"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"6El2lmQr5Ea5Hq9L0fLVfCSATL6qaGJl"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -217,16 +217,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 257.333µs + duration: 261.167µs - id: 8 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"qp1FOZUVrehYPmU311YMEQDrHlJ2BJ40"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"ha2n0xHKCbk6xogserVQ8vOR2o2gbNH8"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -244,16 +244,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 140.333µs + duration: 164.25µs - id: 9 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"sODVHSvcIOhqxyrkgurNH9HMl2b2LSg6"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"Dr8DblxCuohfDzo0gUkdZdTuRxz8uIfz"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -271,16 +271,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 121.5µs + duration: 562.292µs - id: 10 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 60 - host: 127.0.0.1:56373 - body: '{"changeNumber":4,"body":"k7NLLLvg5wMj1tasfkFfqdbhqfa8Ah0E"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments + host: 127.0.0.1:53569 + body: '{"changeNumber":5,"body":"lr6i3eENW1lJd9i6NNxcjZCditYNWUqF"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments method: POST response: proto: HTTP/1.1 @@ -298,16 +298,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 115.833µs + duration: 373.291µs - id: 11 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 43 - host: 127.0.0.1:56373 - body: '{"body":"uXAhcpF8V9XZWbQom2aI0nM6Q9fbiqpd"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments/1 + host: 127.0.0.1:53569 + body: '{"body":"YJgNsP025jRY5hQX6mh2mIfiwifvz6G8"}' + url: http://127.0.0.1:53569/abhinav/test-repo/comments/1 method: PATCH response: proto: HTTP/1.1 @@ -325,15 +325,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.85ms + duration: 1.350167ms - id: 12 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/comments/2 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/comments/2 method: DELETE response: proto: HTTP/1.1 @@ -349,16 +349,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 450µs + duration: 287.416µs - id: 13 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 22 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"body":"should fail"}' - url: http://127.0.0.1:56373/abhinav/test-repo/comments/2 + url: http://127.0.0.1:53569/abhinav/test-repo/comments/2 method: PATCH response: proto: HTTP/1.1 @@ -374,22 +374,22 @@ interactions: - text/plain; charset=utf-8 status: 404 Not Found code: 404 - duration: 314.5µs + duration: 821.25µs - id: 14 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: change: - - "4" + - "5" limit: - "3" offset: - "0" - url: http://127.0.0.1:56373/abhinav/test-repo/comments?change=4&limit=3&offset=0 + url: http://127.0.0.1:53569/abhinav/test-repo/comments?change=5&limit=3&offset=0 method: GET response: proto: HTTP/1.1 @@ -401,15 +401,15 @@ interactions: "items": [ { "id": 1, - "body": "uXAhcpF8V9XZWbQom2aI0nM6Q9fbiqpd" + "body": "YJgNsP025jRY5hQX6mh2mIfiwifvz6G8" }, { "id": 3, - "body": "DU3TnJBDljtJOjrPNPgfJRo44fzPxWxZ" + "body": "3zyUZ2FZcxkOgp4FBvJfBKTnh9fdpNa3" }, { "id": 4, - "body": "WeEXYW6yjMMf0jmnnze12omw5CmTHrDt" + "body": "im86H34osfgrDc7E4gdx7gE3XXcTXmPX" } ], "offset": 3, @@ -422,22 +422,22 @@ interactions: - application/json status: 200 OK code: 200 - duration: 387.292µs + duration: 583.25µs - id: 15 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: change: - - "4" + - "5" limit: - "3" offset: - "3" - url: http://127.0.0.1:56373/abhinav/test-repo/comments?change=4&limit=3&offset=3 + url: http://127.0.0.1:53569/abhinav/test-repo/comments?change=5&limit=3&offset=3 method: GET response: proto: HTTP/1.1 @@ -449,15 +449,15 @@ interactions: "items": [ { "id": 5, - "body": "S4IQsdn1dgnUfOJEFb5JEm7kVxLd8oil" + "body": "fgt7AG4rAoQp1gGEw8G2U2lbe0qWrTmp" }, { "id": 6, - "body": "JNpsluBOeFN8cwri2EMfI8BrYjbArxPf" + "body": "KMuajrgxFADVDZhIFdzUBVzpAm9ee7xR" }, { "id": 7, - "body": "FgUJanBcocO1ablzVyYVjDh6fUZbZXyI" + "body": "6El2lmQr5Ea5Hq9L0fLVfCSATL6qaGJl" } ], "offset": 6, @@ -470,22 +470,22 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.042458ms + duration: 311.417µs - id: 16 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: change: - - "4" + - "5" limit: - "3" offset: - "6" - url: http://127.0.0.1:56373/abhinav/test-repo/comments?change=4&limit=3&offset=6 + url: http://127.0.0.1:53569/abhinav/test-repo/comments?change=5&limit=3&offset=6 method: GET response: proto: HTTP/1.1 @@ -497,15 +497,15 @@ interactions: "items": [ { "id": 8, - "body": "qp1FOZUVrehYPmU311YMEQDrHlJ2BJ40" + "body": "ha2n0xHKCbk6xogserVQ8vOR2o2gbNH8" }, { "id": 9, - "body": "sODVHSvcIOhqxyrkgurNH9HMl2b2LSg6" + "body": "Dr8DblxCuohfDzo0gUkdZdTuRxz8uIfz" }, { "id": 10, - "body": "k7NLLLvg5wMj1tasfkFfqdbhqfa8Ah0E" + "body": "lr6i3eENW1lJd9i6NNxcjZCditYNWUqF" } ], "offset": 9 @@ -517,22 +517,22 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.038417ms + duration: 210.416µs - id: 17 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: change: - - "4" + - "5" limit: - "10" offset: - "0" - url: http://127.0.0.1:56373/abhinav/test-repo/comments?change=4&limit=10&offset=0 + url: http://127.0.0.1:53569/abhinav/test-repo/comments?change=5&limit=10&offset=0 method: GET response: proto: HTTP/1.1 @@ -544,39 +544,39 @@ interactions: "items": [ { "id": 1, - "body": "uXAhcpF8V9XZWbQom2aI0nM6Q9fbiqpd" + "body": "YJgNsP025jRY5hQX6mh2mIfiwifvz6G8" }, { "id": 3, - "body": "DU3TnJBDljtJOjrPNPgfJRo44fzPxWxZ" + "body": "3zyUZ2FZcxkOgp4FBvJfBKTnh9fdpNa3" }, { "id": 4, - "body": "WeEXYW6yjMMf0jmnnze12omw5CmTHrDt" + "body": "im86H34osfgrDc7E4gdx7gE3XXcTXmPX" }, { "id": 5, - "body": "S4IQsdn1dgnUfOJEFb5JEm7kVxLd8oil" + "body": "fgt7AG4rAoQp1gGEw8G2U2lbe0qWrTmp" }, { "id": 6, - "body": "JNpsluBOeFN8cwri2EMfI8BrYjbArxPf" + "body": "KMuajrgxFADVDZhIFdzUBVzpAm9ee7xR" }, { "id": 7, - "body": "FgUJanBcocO1ablzVyYVjDh6fUZbZXyI" + "body": "6El2lmQr5Ea5Hq9L0fLVfCSATL6qaGJl" }, { "id": 8, - "body": "qp1FOZUVrehYPmU311YMEQDrHlJ2BJ40" + "body": "ha2n0xHKCbk6xogserVQ8vOR2o2gbNH8" }, { "id": 9, - "body": "sODVHSvcIOhqxyrkgurNH9HMl2b2LSg6" + "body": "Dr8DblxCuohfDzo0gUkdZdTuRxz8uIfz" }, { "id": 10, - "body": "k7NLLLvg5wMj1tasfkFfqdbhqfa8Ah0E" + "body": "lr6i3eENW1lJd9i6NNxcjZCditYNWUqF" } ], "offset": 9 @@ -588,4 +588,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 177.583µs + duration: 956.5µs diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangesStates.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangesStates.yaml index 32e610d37..63f60dabf 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangesStates.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ChangesStates.yaml @@ -7,37 +7,37 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 80 - host: 127.0.0.1:56373 - body: '{"subject":"Open jMfwXkYK","body":"Open change","base":"main","head":"jMfwXkYK"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Open QohFltlh","body":"Open change","base":"main","head":"QohFltlh"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 82 + content_length: 80 body: | { - "number": 15, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/15" + "number": 9, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/9" } headers: Content-Length: - - "82" + - "80" Content-Type: - application/json status: 200 OK code: 200 - duration: 13.188042ms + duration: 29.667375ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 84 - host: 127.0.0.1:56373 - body: '{"subject":"Merged C5hzQ5Ik","body":"Merged change","base":"main","head":"C5hzQ5Ik"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Merged gvgMWiD8","body":"Merged change","base":"main","head":"gvgMWiD8"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -46,8 +46,8 @@ interactions: content_length: 82 body: | { - "number": 16, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/16" + "number": 11, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/11" } headers: Content-Length: @@ -56,16 +56,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 13.844667ms + duration: 29.907541ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 84 - host: 127.0.0.1:56373 - body: '{"subject":"Closed IEtiDhFs","body":"Closed change","base":"main","head":"IEtiDhFs"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Closed mzMsv5Rh","body":"Closed change","base":"main","head":"mzMsv5Rh"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -74,8 +74,8 @@ interactions: content_length: 82 body: | { - "number": 18, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/18" + "number": 15, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/15" } headers: Content-Length: @@ -84,16 +84,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 14.069959ms + duration: 27.266959ms - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 18 - host: 127.0.0.1:56373 - body: '{"ids":[15,16,18]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/states + content_length: 17 + host: 127.0.0.1:53569 + body: '{"ids":[9,11,15]}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/states method: POST response: proto: HTTP/1.1 @@ -105,15 +105,15 @@ interactions: "statuses": [ { "state": "open", - "headHash": "84a9e994290ae89ccc93f7d2a03d009b39d103ea" + "headHash": "df17da0bd1722f06dbd75d8948417dded7a12d77" }, { "state": "merged", - "headHash": "25b03948515094aec6d2cf82537f16d56a1486dc" + "headHash": "fae1a4a757b2259632acb5eb5926849f125dc594" }, { "state": "closed", - "headHash": "0c32e505344f156f8aa70189cfd851a1a40765cd" + "headHash": "145f79d936fdcaf9ccd165ed6be52de6eaba9895" } ] } @@ -124,4 +124,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 10.51575ms + duration: 27.146958ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/CommentCountsByChange.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/CommentCountsByChange.yaml index a6c9cab53..b98cc5b46 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/CommentCountsByChange.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/CommentCountsByChange.yaml @@ -7,37 +7,37 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 98 - host: 127.0.0.1:56373 - body: '{"subject":"Testing HQWHaq9z","body":"Test PR for comment counts","base":"main","head":"HQWHaq9z"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing wleC7vBI","body":"Test PR for comment counts","base":"main","head":"wleC7vBI"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 82 + content_length: 80 body: | { - "number": 11, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/11" + "number": 1, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/1" } headers: Content-Length: - - "82" + - "80" Content-Type: - application/json status: 200 OK code: 200 - duration: 28.731083ms + duration: 46.254708ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 - content_length: 12 - host: 127.0.0.1:56373 - body: '{"ids":[11]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/comment-counts + content_length: 11 + host: 127.0.0.1:53569 + body: '{"ids":[1]}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/comment-counts method: POST response: proto: HTTP/1.1 @@ -61,4 +61,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.628833ms + duration: 3.071583ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/FindChangesByBranchDoesNotExist.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/FindChangesByBranchDoesNotExist.yaml index 4673e14a0..931a943e7 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/FindChangesByBranchDoesNotExist.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/FindChangesByBranchDoesNotExist.yaml @@ -7,13 +7,13 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/does-not-exist?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/does-not-exist?limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -29,4 +29,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 752.916µs + duration: 570µs diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/NoTemplates.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/NoTemplates.yaml index 4e2249722..970786aee 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/NoTemplates.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/NoTemplates.yaml @@ -7,8 +7,8 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change-template + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change-template method: GET response: proto: HTTP/1.1 @@ -24,4 +24,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 138.274667ms + duration: 270.632708ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/TemplatesPresent.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/TemplatesPresent.yaml index 2736a773b..490f41621 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/TemplatesPresent.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/ListChangeTemplates/TemplatesPresent.yaml @@ -7,8 +7,8 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change-template + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change-template method: GET response: proto: HTTP/1.1 @@ -18,12 +18,12 @@ interactions: body: | [ { - "filename": "1HQqVNZV.md", - "body": "\n" + "filename": "aW2r2kGY.md", + "body": "This is a test template\n" }, { - "filename": "yE3Lj2v5.md", - "body": "This is a test template\n" + "filename": "np6KTBsj.md", + "body": "\n" } ] headers: @@ -33,4 +33,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 149.63575ms + duration: 309.049ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitBaseDoesNotExist.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitBaseDoesNotExist.yaml index 4c536dd39..a9463e3d0 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitBaseDoesNotExist.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitBaseDoesNotExist.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 106 - host: 127.0.0.1:56373 - body: '{"subject":"Testing vY4xsORW","body":"Test PR with non-existent base","base":"9s9oMX6C","head":"vY4xsORW"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing bcKIEKS3","body":"Test PR with non-existent base","base":"ExQcEmr1","head":"bcKIEKS3"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -25,16 +25,16 @@ interactions: - text/plain; charset=utf-8 status: 400 Bad Request code: 400 - duration: 9.87325ms + duration: 18.86725ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 29 - host: 127.0.0.1:56373 - body: '{"ref":"refs/heads/9s9oMX6C"}' - url: http://127.0.0.1:56373/abhinav/test-repo/ref/exists + host: 127.0.0.1:53569 + body: '{"ref":"refs/heads/ExQcEmr1"}' + url: http://127.0.0.1:53569/abhinav/test-repo/ref/exists method: POST response: proto: HTTP/1.1 @@ -52,4 +52,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 10.2145ms + duration: 20.495625ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitChangeFromPushRepository.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitChangeFromPushRepository.yaml index be69f95da..7c6944ecc 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitChangeFromPushRepository.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitChangeFromPushRepository.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 138 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"subject":"Testing fork QDezbUVv","body":"Test fork change request","base":"main","head":"QDezbUVv","head_repo":"abhinav-fork/test-repo"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 12, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/12" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/12" } headers: Content-Length: @@ -35,8 +35,8 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/12 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/12 method: GET response: proto: HTTP/1.1 @@ -46,7 +46,7 @@ interactions: body: | { "number": 12, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/12", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/12", "state": "open", "title": "Testing fork QDezbUVv", "body": "Test fork change request", @@ -81,7 +81,7 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: head_repo: - abhinav-fork/test-repo @@ -89,7 +89,7 @@ interactions: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/QDezbUVv?head_repo=abhinav-fork%2Ftest-repo&limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/QDezbUVv?head_repo=abhinav-fork%2Ftest-repo&limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -100,7 +100,7 @@ interactions: [ { "number": 12, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/12", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/12", "state": "open", "title": "Testing fork QDezbUVv", "body": "Test fork change request", @@ -136,13 +136,13 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/QDezbUVv?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/QDezbUVv?limit=10&state=all method: GET response: proto: HTTP/1.1 diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssignee.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssignee.yaml index aeb06a334..fbafc7661 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssignee.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssignee.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 97 - host: 127.0.0.1:56373 - body: '{"subject":"Testing 66MtCOhk","body":"Test PR without assignees","base":"main","head":"66MtCOhk"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing 4D8sFmyC","body":"Test PR without assignees","base":"main","head":"4D8sFmyC"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 10, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/10" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/10" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 25.159916ms + duration: 29.755416ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/10 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/10 method: GET response: proto: HTTP/1.1 @@ -46,9 +46,9 @@ interactions: body: | { "number": 10, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/10", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/10", "state": "open", - "title": "Testing 66MtCOhk", + "title": "Testing 4D8sFmyC", "body": "Test PR without assignees", "base": { "repository": { @@ -56,15 +56,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "66MtCOhk", - "sha": "66a0c50cac60c34e761b18838932a3aab48deac9" + "ref": "4D8sFmyC", + "sha": "3dce9a05f05fb11263d1482ecd4125949e747389" } } headers: @@ -74,16 +74,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 22.786459ms + duration: 27.52675ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 39 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"assignees":["assignee1","assignee2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/10 + url: http://127.0.0.1:53569/abhinav/test-repo/change/10 method: PATCH response: proto: HTTP/1.1 @@ -99,15 +99,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 696.167µs + duration: 224.75µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/10 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/10 method: GET response: proto: HTTP/1.1 @@ -117,9 +117,9 @@ interactions: body: | { "number": 10, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/10", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/10", "state": "open", - "title": "Testing 66MtCOhk", + "title": "Testing 4D8sFmyC", "body": "Test PR without assignees", "base": { "repository": { @@ -127,15 +127,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "66MtCOhk", - "sha": "66a0c50cac60c34e761b18838932a3aab48deac9" + "ref": "4D8sFmyC", + "sha": "3dce9a05f05fb11263d1482ecd4125949e747389" }, "assignees": [ "assignee1", @@ -149,4 +149,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 17.645625ms + duration: 29.249833ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne.yaml index 0f7dfd8b2..23e87958b 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/AddAssigneesOneByOne.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 97 - host: 127.0.0.1:56373 - body: '{"subject":"Testing Op9WQIRi","body":"Test PR without assignees","base":"main","head":"Op9WQIRi"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing lEG1MUxo","body":"Test PR without assignees","base":"main","head":"lEG1MUxo"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 80 body: | { - "number": 5, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/5" + "number": 4, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/4" } headers: Content-Length: @@ -28,16 +28,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 21.201041ms + duration: 42.64775ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 27 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"assignees":["assignee1"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/5 + url: http://127.0.0.1:53569/abhinav/test-repo/change/4 method: PATCH response: proto: HTTP/1.1 @@ -53,16 +53,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 263.792µs + duration: 631.583µs - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 27 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"assignees":["assignee2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/5 + url: http://127.0.0.1:53569/abhinav/test-repo/change/4 method: PATCH response: proto: HTTP/1.1 @@ -78,15 +78,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 2.75425ms + duration: 129.958µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/5 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/4 method: GET response: proto: HTTP/1.1 @@ -95,10 +95,10 @@ interactions: content_length: 571 body: | { - "number": 5, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/5", + "number": 4, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/4", "state": "open", - "title": "Testing Op9WQIRi", + "title": "Testing lEG1MUxo", "body": "Test PR without assignees", "base": { "repository": { @@ -106,15 +106,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "Op9WQIRi", - "sha": "34d6de08ed0a392a6ec261c288d5516e65dc2614" + "ref": "lEG1MUxo", + "sha": "c95d303c96fd553536f0b20c74bbf7cb19087f37" }, "assignees": [ "assignee1", @@ -128,4 +128,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 21.133625ms + duration: 37.717625ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/SubmitWithAssignee.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/SubmitWithAssignee.yaml index 1bd09cc14..9e3a5f5c2 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/SubmitWithAssignee.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditAssignees/SubmitWithAssignee.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 131 - host: 127.0.0.1:56373 - body: '{"subject":"Testing lE3mefp0","body":"Test PR with assignee","base":"main","head":"lE3mefp0","assignees":["assignee1","assignee2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing UQDEDpcg","body":"Test PR with assignee","base":"main","head":"UQDEDpcg","assignees":["assignee1","assignee2"]}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 80 body: | { - "number": 7, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/7" + "number": 2, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/2" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 21.594125ms + duration: 38.620583ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/7 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/2 method: GET response: proto: HTTP/1.1 @@ -45,10 +45,10 @@ interactions: content_length: 567 body: | { - "number": 7, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/7", + "number": 2, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/2", "state": "open", - "title": "Testing lE3mefp0", + "title": "Testing UQDEDpcg", "body": "Test PR with assignee", "base": { "repository": { @@ -56,15 +56,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "lE3mefp0", - "sha": "1294690f93a2b746dbb81d60a23b512988633d14" + "ref": "UQDEDpcg", + "sha": "0970719d4527d7008b38d05e15c0e92f6acfb25b" }, "assignees": [ "assignee1", @@ -78,20 +78,20 @@ interactions: - application/json status: 200 OK code: 200 - duration: 17.238667ms + duration: 38.749042ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/lE3mefp0?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/UQDEDpcg?limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -101,10 +101,10 @@ interactions: body: | [ { - "number": 7, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/7", + "number": 2, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/2", "state": "open", - "title": "Testing lE3mefp0", + "title": "Testing UQDEDpcg", "body": "Test PR with assignee", "base": { "repository": { @@ -112,15 +112,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "lE3mefp0", - "sha": "1294690f93a2b746dbb81d60a23b512988633d14" + "ref": "UQDEDpcg", + "sha": "0970719d4527d7008b38d05e15c0e92f6acfb25b" }, "assignees": [ "assignee1", @@ -135,4 +135,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.395ms + duration: 33.967333ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditBase.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditBase.yaml index ed56e8e18..33cf7f1c6 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditBase.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditBase.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 100 - host: 127.0.0.1:56373 - body: '{"subject":"Testing 9YbzXae4","body":"Test PR with custom base","base":"t8XzNN0X","head":"9YbzXae4"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing mKnU8EV4","body":"Test PR with custom base","base":"P2inVOao","head":"mKnU8EV4"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 8, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/8" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/8" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 23.763167ms + duration: 41.197167ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/8 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/8 method: GET response: proto: HTTP/1.1 @@ -46,25 +46,25 @@ interactions: body: | { "number": 8, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/8", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/8", "state": "open", - "title": "Testing 9YbzXae4", + "title": "Testing mKnU8EV4", "body": "Test PR with custom base", "base": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "t8XzNN0X", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "ref": "P2inVOao", + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "9YbzXae4", - "sha": "a3fc1801625662da2cc43a337ee7ba729a73d732" + "ref": "mKnU8EV4", + "sha": "cd26b16c292f00fa0b6a5f5f710c461474950344" } } headers: @@ -74,16 +74,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 19.21925ms + duration: 44.502708ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 15 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"base":"main"}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/8 + url: http://127.0.0.1:53569/abhinav/test-repo/change/8 method: PATCH response: proto: HTTP/1.1 @@ -99,15 +99,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 485.75µs + duration: 129.75µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/8 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/8 method: GET response: proto: HTTP/1.1 @@ -117,9 +117,9 @@ interactions: body: | { "number": 8, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/8", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/8", "state": "open", - "title": "Testing 9YbzXae4", + "title": "Testing mKnU8EV4", "body": "Test PR with custom base", "base": { "repository": { @@ -127,15 +127,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "9YbzXae4", - "sha": "a3fc1801625662da2cc43a337ee7ba729a73d732" + "ref": "mKnU8EV4", + "sha": "cd26b16c292f00fa0b6a5f5f710c461474950344" } } headers: @@ -145,4 +145,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 17.355333ms + duration: 32.452916ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditChange.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditChange.yaml index e0876df1f..7faddd99f 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditChange.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditChange.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 79 - host: 127.0.0.1:56373 - body: '{"subject":"Testing RowsHzO8","body":"Test PR","base":"main","head":"RowsHzO8"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing cI6Rfmnn","body":"Test PR","base":"main","head":"cI6Rfmnn"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 80 body: | { - "number": 1, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/1" + "number": 7, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/7" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 22.12625ms + duration: 45.201167ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/1 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/7 method: GET response: proto: HTTP/1.1 @@ -45,10 +45,10 @@ interactions: content_length: 498 body: | { - "number": 1, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/1", + "number": 7, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/7", "state": "open", - "title": "Testing RowsHzO8", + "title": "Testing cI6Rfmnn", "body": "Test PR", "base": { "repository": { @@ -56,15 +56,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "RowsHzO8", - "sha": "1ee3894a24c031b5d053a0b8bb25a9182fd77604" + "ref": "cI6Rfmnn", + "sha": "1e2c32dc4f5cfec9674dac2d9d7d0274a63c15b0" } } headers: @@ -74,20 +74,20 @@ interactions: - application/json status: 200 OK code: 200 - duration: 18.941208ms + duration: 39.789833ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/RowsHzO8?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/cI6Rfmnn?limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -97,10 +97,10 @@ interactions: body: | [ { - "number": 1, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/1", + "number": 7, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/7", "state": "open", - "title": "Testing RowsHzO8", + "title": "Testing cI6Rfmnn", "body": "Test PR", "base": { "repository": { @@ -108,15 +108,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "RowsHzO8", - "sha": "1ee3894a24c031b5d053a0b8bb25a9182fd77604" + "ref": "cI6Rfmnn", + "sha": "1e2c32dc4f5cfec9674dac2d9d7d0274a63c15b0" } } ] @@ -127,4 +127,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 16.2305ms + duration: 40.238416ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditDraft.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditDraft.yaml index 5114cb40a..9371286e1 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditDraft.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditDraft.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 98 - host: 127.0.0.1:56373 - body: '{"subject":"Testing pCyU7Tpe","body":"Test draft PR","base":"main","head":"pCyU7Tpe","draft":true}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing R4gPQ4SL","body":"Test draft PR","base":"main","head":"R4gPQ4SL","draft":true}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 80 body: | { - "number": 2, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/2" + "number": 3, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/3" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 22.713917ms + duration: 37.971ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/2 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3 method: GET response: proto: HTTP/1.1 @@ -45,11 +45,11 @@ interactions: content_length: 521 body: | { - "number": 2, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/2", + "number": 3, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/3", "draft": true, "state": "open", - "title": "Testing pCyU7Tpe", + "title": "Testing R4gPQ4SL", "body": "Test draft PR", "base": { "repository": { @@ -57,15 +57,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "pCyU7Tpe", - "sha": "7bd9a65f890645f1b36eb13dd6a622b6c4b32b13" + "ref": "R4gPQ4SL", + "sha": "965286a9aa45336455161a7b2a4db1e34f9ac98d" } } headers: @@ -75,16 +75,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.71025ms + duration: 36.908792ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 15 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"draft":false}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/2 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3 method: PATCH response: proto: HTTP/1.1 @@ -100,15 +100,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 134.583µs + duration: 213.667µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/2 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3 method: GET response: proto: HTTP/1.1 @@ -117,10 +117,10 @@ interactions: content_length: 504 body: | { - "number": 2, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/2", + "number": 3, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/3", "state": "open", - "title": "Testing pCyU7Tpe", + "title": "Testing R4gPQ4SL", "body": "Test draft PR", "base": { "repository": { @@ -128,15 +128,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "pCyU7Tpe", - "sha": "7bd9a65f890645f1b36eb13dd6a622b6c4b32b13" + "ref": "R4gPQ4SL", + "sha": "965286a9aa45336455161a7b2a4db1e34f9ac98d" } } headers: @@ -146,16 +146,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 17.1405ms + duration: 43.281167ms - id: 4 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 14 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"draft":true}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/2 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3 method: PATCH response: proto: HTTP/1.1 @@ -171,15 +171,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.807125ms + duration: 484.75µs - id: 5 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/2 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/3 method: GET response: proto: HTTP/1.1 @@ -188,11 +188,11 @@ interactions: content_length: 521 body: | { - "number": 2, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/2", + "number": 3, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/3", "draft": true, "state": "open", - "title": "Testing pCyU7Tpe", + "title": "Testing R4gPQ4SL", "body": "Test draft PR", "base": { "repository": { @@ -200,15 +200,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "pCyU7Tpe", - "sha": "7bd9a65f890645f1b36eb13dd6a622b6c4b32b13" + "ref": "R4gPQ4SL", + "sha": "965286a9aa45336455161a7b2a4db1e34f9ac98d" } } headers: @@ -218,4 +218,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.676166ms + duration: 37.233416ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditLabels.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditLabels.yaml index 7b515d983..351de0b08 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditLabels.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditLabels.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 113 - host: 127.0.0.1:56373 - body: '{"subject":"Testing jbBnJuHT","body":"Test PR with labels","base":"main","head":"jbBnJuHT","labels":["QcHb0icn"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing 4HOIehwu","body":"Test PR with labels","base":"main","head":"4HOIehwu","labels":["2VGRqO7j"]}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 6, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/6" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/6" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 21.992708ms + duration: 41.998125ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/6 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/6 method: GET response: proto: HTTP/1.1 @@ -46,9 +46,9 @@ interactions: body: | { "number": 6, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/6", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/6", "state": "open", - "title": "Testing jbBnJuHT", + "title": "Testing 4HOIehwu", "body": "Test PR with labels", "base": { "repository": { @@ -56,18 +56,18 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "jbBnJuHT", - "sha": "c34dbee79699111d2299f1fd5737e5535ae1f643" + "ref": "4HOIehwu", + "sha": "b324fe0ca7ab1155f6db68bb6738b3a35c706c3f" }, "labels": [ - "QcHb0icn" + "2VGRqO7j" ] } headers: @@ -77,16 +77,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 15.358041ms + duration: 36.385333ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 23 - host: 127.0.0.1:56373 - body: '{"labels":["820svnw8"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/6 + host: 127.0.0.1:53569 + body: '{"labels":["dF2bQUKv"]}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/6 method: PATCH response: proto: HTTP/1.1 @@ -102,16 +102,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 1.308292ms + duration: 398.042µs - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 34 - host: 127.0.0.1:56373 - body: '{"labels":["820svnw8","bUnh1ONJ"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/6 + host: 127.0.0.1:53569 + body: '{"labels":["dF2bQUKv","bSgDWsqi"]}' + url: http://127.0.0.1:53569/abhinav/test-repo/change/6 method: PATCH response: proto: HTTP/1.1 @@ -127,20 +127,20 @@ interactions: - application/json status: 200 OK code: 200 - duration: 242.25µs + duration: 421.083µs - id: 4 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/jbBnJuHT?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/4HOIehwu?limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -151,9 +151,9 @@ interactions: [ { "number": 6, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/6", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/6", "state": "open", - "title": "Testing jbBnJuHT", + "title": "Testing 4HOIehwu", "body": "Test PR with labels", "base": { "repository": { @@ -161,20 +161,20 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "jbBnJuHT", - "sha": "c34dbee79699111d2299f1fd5737e5535ae1f643" + "ref": "4HOIehwu", + "sha": "b324fe0ca7ab1155f6db68bb6738b3a35c706c3f" }, "labels": [ - "QcHb0icn", - "820svnw8", - "bUnh1ONJ" + "2VGRqO7j", + "dF2bQUKv", + "bSgDWsqi" ] } ] @@ -185,4 +185,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.795292ms + duration: 41.2895ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewer.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewer.yaml index a9ce1ed31..ca892789d 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewer.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewer.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 97 - host: 127.0.0.1:56373 - body: '{"subject":"Testing qS0oWVxR","body":"Test PR without reviewers","base":"main","head":"qS0oWVxR"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing BXaFutk3","body":"Test PR without reviewers","base":"main","head":"BXaFutk3"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 82 body: | { - "number": 17, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/17" + "number": 13, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/13" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 13.741459ms + duration: 30.130458ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/17 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/13 method: GET response: proto: HTTP/1.1 @@ -45,10 +45,10 @@ interactions: content_length: 518 body: | { - "number": 17, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/17", + "number": 13, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/13", "state": "open", - "title": "Testing qS0oWVxR", + "title": "Testing BXaFutk3", "body": "Test PR without reviewers", "base": { "repository": { @@ -56,15 +56,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "qS0oWVxR", - "sha": "d10f53257e22f018a3d5d54780611749d959d3ed" + "ref": "BXaFutk3", + "sha": "4423dce906b5fddf7ddd3a536c3ffc2a2627a245" } } headers: @@ -74,16 +74,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 13.343458ms + duration: 27.700542ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 39 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"reviewers":["reviewer1","reviewer2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/17 + url: http://127.0.0.1:53569/abhinav/test-repo/change/13 method: PATCH response: proto: HTTP/1.1 @@ -99,15 +99,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 20.163792ms + duration: 35.203875ms - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/17 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/13 method: GET response: proto: HTTP/1.1 @@ -116,10 +116,10 @@ interactions: content_length: 583 body: | { - "number": 17, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/17", + "number": 13, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/13", "state": "open", - "title": "Testing qS0oWVxR", + "title": "Testing BXaFutk3", "body": "Test PR without reviewers", "base": { "repository": { @@ -127,15 +127,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "78e7fffc2e2f2b050db2efb7f3de43be063e31f3" + "sha": "935a1736c1d0abc559194d26342e46a76e102069" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "qS0oWVxR", - "sha": "d10f53257e22f018a3d5d54780611749d959d3ed" + "ref": "BXaFutk3", + "sha": "4423dce906b5fddf7ddd3a536c3ffc2a2627a245" }, "requested_reviewers": [ "reviewer1", @@ -149,4 +149,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 21.720042ms + duration: 55.48225ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne.yaml index e2a72c93e..567791f79 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/AddReviewersOneByOne.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 97 - host: 127.0.0.1:56373 - body: '{"subject":"Testing sN0uOM0J","body":"Test PR without reviewers","base":"main","head":"sN0uOM0J"}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing EwqrlAsh","body":"Test PR without reviewers","base":"main","head":"EwqrlAsh"}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -19,7 +19,7 @@ interactions: body: | { "number": 14, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/14" + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/14" } headers: Content-Length: @@ -28,15 +28,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 14.613166ms + duration: 30.532125ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/14 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/14 method: GET response: proto: HTTP/1.1 @@ -46,9 +46,9 @@ interactions: body: | { "number": 14, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/14", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/14", "state": "open", - "title": "Testing sN0uOM0J", + "title": "Testing EwqrlAsh", "body": "Test PR without reviewers", "base": { "repository": { @@ -56,15 +56,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "sN0uOM0J", - "sha": "fe1856b3db7f006918e699a1c316e92ad3342d93" + "ref": "EwqrlAsh", + "sha": "6ee3f5e3004c4d74580c42ea3b0f85d0fcf5f0f8" } } headers: @@ -74,16 +74,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 11.315875ms + duration: 27.463625ms - id: 2 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 27 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"reviewers":["reviewer1"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/14 + url: http://127.0.0.1:53569/abhinav/test-repo/change/14 method: PATCH response: proto: HTTP/1.1 @@ -99,16 +99,16 @@ interactions: - application/json status: 200 OK code: 200 - duration: 415.5µs + duration: 35.209625ms - id: 3 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 27 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 body: '{"reviewers":["reviewer2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/change/14 + url: http://127.0.0.1:53569/abhinav/test-repo/change/14 method: PATCH response: proto: HTTP/1.1 @@ -124,15 +124,15 @@ interactions: - application/json status: 200 OK code: 200 - duration: 102.334µs + duration: 26.211ms - id: 4 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 - url: http://127.0.0.1:56373/abhinav/test-repo/change/14 + host: 127.0.0.1:53569 + url: http://127.0.0.1:53569/abhinav/test-repo/change/14 method: GET response: proto: HTTP/1.1 @@ -142,9 +142,9 @@ interactions: body: | { "number": 14, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/14", + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/14", "state": "open", - "title": "Testing sN0uOM0J", + "title": "Testing EwqrlAsh", "body": "Test PR without reviewers", "base": { "repository": { @@ -152,15 +152,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "935a1736c1d0abc559194d26342e46a76e102069" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "sN0uOM0J", - "sha": "fe1856b3db7f006918e699a1c316e92ad3342d93" + "ref": "EwqrlAsh", + "sha": "6ee3f5e3004c4d74580c42ea3b0f85d0fcf5f0f8" }, "requested_reviewers": [ "reviewer1", @@ -174,4 +174,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 11.028083ms + duration: 28.57575ms diff --git a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/SubmitWithReviewer.yaml b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/SubmitWithReviewer.yaml index 0c837343b..6d9e90d43 100644 --- a/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/SubmitWithReviewer.yaml +++ b/internal/forge/shamhub/testdata/fixtures/TestIntegration/SubmitEditReviewers/SubmitWithReviewer.yaml @@ -7,9 +7,9 @@ interactions: proto_major: 1 proto_minor: 1 content_length: 131 - host: 127.0.0.1:56373 - body: '{"subject":"Testing 4oj7KI1p","body":"Test PR with reviewer","base":"main","head":"4oj7KI1p","reviewers":["reviewer1","reviewer2"]}' - url: http://127.0.0.1:56373/abhinav/test-repo/changes + host: 127.0.0.1:53569 + body: '{"subject":"Testing df7WoDco","body":"Test PR with reviewer","base":"main","head":"df7WoDco","reviewers":["reviewer1","reviewer2"]}' + url: http://127.0.0.1:53569/abhinav/test-repo/changes method: POST response: proto: HTTP/1.1 @@ -18,8 +18,8 @@ interactions: content_length: 82 body: | { - "number": 13, - "url": "http://127.0.0.1:56374/abhinav/test-repo/change/13" + "number": 12, + "url": "http://127.0.0.1:53570/abhinav/test-repo/change/12" } headers: Content-Length: @@ -28,20 +28,20 @@ interactions: - application/json status: 200 OK code: 200 - duration: 23.097875ms + duration: 30.764459ms - id: 1 request: proto: HTTP/1.1 proto_major: 1 proto_minor: 1 content_length: 0 - host: 127.0.0.1:56373 + host: 127.0.0.1:53569 form: limit: - "10" state: - all - url: http://127.0.0.1:56373/abhinav/test-repo/changes/by-branch/4oj7KI1p?limit=10&state=all + url: http://127.0.0.1:53569/abhinav/test-repo/changes/by-branch/df7WoDco?limit=10&state=all method: GET response: proto: HTTP/1.1 @@ -51,10 +51,10 @@ interactions: body: | [ { - "number": 13, - "html_url": "http://127.0.0.1:56374/abhinav/test-repo/change/13", + "number": 12, + "html_url": "http://127.0.0.1:53570/abhinav/test-repo/change/12", "state": "open", - "title": "Testing 4oj7KI1p", + "title": "Testing df7WoDco", "body": "Test PR with reviewer", "base": { "repository": { @@ -62,15 +62,15 @@ interactions: "name": "test-repo" }, "ref": "main", - "sha": "71012226be854d2a77b193ca779abba5cc84cca0" + "sha": "c17a304a06b281e76d193f8a86a8ab71585b64e3" }, "head": { "repository": { "owner": "abhinav", "name": "test-repo" }, - "ref": "4oj7KI1p", - "sha": "d42a7e9d935c9911190197ad4e04542af2500250" + "ref": "df7WoDco", + "sha": "6cca56449280db9fd8954bd6d01d3b30ca7a474c" }, "requested_reviewers": [ "reviewer1", @@ -85,4 +85,4 @@ interactions: - application/json status: 200 OK code: 200 - duration: 23.390833ms + duration: 28.513791ms diff --git a/internal/gateway/bitbucket/api.go b/internal/gateway/bitbucket/api.go index 40d833002..63e424241 100644 --- a/internal/gateway/bitbucket/api.go +++ b/internal/gateway/bitbucket/api.go @@ -85,6 +85,7 @@ type PullRequestListOptions struct { type Comment struct { ID int64 `json:"id"` Content Content `json:"content"` + User User `json:"user"` Inline *Inline `json:"inline,omitempty"` Resolution *Resolution `json:"resolution,omitempty"` } @@ -92,6 +93,25 @@ type Comment struct { // CommentCreateRequest is the request body for creating or updating a comment. type CommentCreateRequest struct { Content Content `json:"content"` + + // Inline, when set, posts a code-review (inline) comment + // on the given file path and line. + Inline *Inline `json:"inline,omitempty"` + + // Parent, when set, posts a reply on the given parent comment. + // Mutually exclusive with Inline. + Parent *CommentRef `json:"parent,omitempty"` +} + +// CommentRef identifies a comment by ID. +type CommentRef struct { + ID int64 `json:"id"` +} + +// CommentResolveRequest is the request body for resolving +// or unresolving an inline comment. +type CommentResolveRequest struct { + Resolution *Resolution `json:"resolution"` } // CommentList is the paginated response for listing comments. @@ -490,6 +510,32 @@ func (c *Client) CommitStatusCreate( return &response, resp, nil } +// CommentResolve resolves or unresolves a pull-request inline comment. +// +// Pass req.Resolution = nil to unresolve, non-nil to resolve. +func (c *Client) CommentResolve( + ctx context.Context, + workspace string, + repo string, + prID int64, + commentID int64, + req *CommentResolveRequest, +) (*Comment, *Response, error) { + var response Comment + resp, err := c.put( + ctx, + fmt.Sprintf( + "/repositories/%s/%s/pullrequests/%d/comments/%d", + workspace, repo, prID, commentID, + ), + nil, req, &response, + ) + if err != nil { + return nil, resp, err + } + return &response, resp, nil +} + func buildPullRequestListRequest( workspace string, repo string, diff --git a/internal/gateway/gitlab/api.go b/internal/gateway/gitlab/api.go index 6609e1464..eea60aa77 100644 --- a/internal/gateway/gitlab/api.go +++ b/internal/gateway/gitlab/api.go @@ -354,6 +354,116 @@ func (c *Client) MergeRequestDiscussionList( return response, resp, nil } +// MergeRequestDiscussionCreate creates a new discussion (review thread) +// on a merge request. +// +// GitLab API: +// https://docs.gitlab.com/api/discussions/#create-new-merge-request-thread +func (c *Client) MergeRequestDiscussionCreate( + ctx context.Context, + projectID int64, + mergeRequest int64, + opt *CreateMergeRequestDiscussionOptions, +) (*Discussion, *Response, error) { + var response Discussion + resp, err := c.post( + ctx, + fmt.Sprintf( + "projects/%d/merge_requests/%d/discussions", + projectID, mergeRequest, + ), + nil, opt, &response, + ) + if err != nil { + return nil, resp, err + } + return &response, resp, nil +} + +// MergeRequestDiscussionResolve resolves or unresolves a discussion thread. +// +// GitLab API: +// https://docs.gitlab.com/api/discussions/#resolve-a-merge-request-thread +func (c *Client) MergeRequestDiscussionResolve( + ctx context.Context, + projectID int64, + mergeRequest int64, + discussionID string, + opt *ResolveMergeRequestDiscussionOptions, +) (*Discussion, *Response, error) { + var response Discussion + resp, err := c.put( + ctx, + fmt.Sprintf( + "projects/%d/merge_requests/%d/discussions/%s", + projectID, mergeRequest, + url.PathEscape(discussionID), + ), + nil, opt, &response, + ) + if err != nil { + return nil, resp, err + } + return &response, resp, nil +} + +// MergeRequestDiscussionNoteCreate adds a note to an existing discussion +// thread (i.e. reply). +// +// GitLab API: +// https://docs.gitlab.com/api/discussions/#add-note-to-existing-merge-request-thread +func (c *Client) MergeRequestDiscussionNoteCreate( + ctx context.Context, + projectID int64, + mergeRequest int64, + discussionID string, + opt *AddMergeRequestDiscussionNoteOptions, +) (*DiscussionNote, *Response, error) { + var response DiscussionNote + resp, err := c.post( + ctx, + fmt.Sprintf( + "projects/%d/merge_requests/%d/discussions/%s/notes", + projectID, mergeRequest, + url.PathEscape(discussionID), + ), + nil, opt, &response, + ) + if err != nil { + return nil, resp, err + } + return &response, resp, nil +} + +// MergeRequestDiscussionNoteUpdate edits the body of an existing +// discussion note. +// +// GitLab API: +// https://docs.gitlab.com/api/discussions/#modify-an-existing-merge-request-thread-note +func (c *Client) MergeRequestDiscussionNoteUpdate( + ctx context.Context, + projectID int64, + mergeRequest int64, + discussionID string, + noteID int64, + opt *UpdateMergeRequestDiscussionNoteOptions, +) (*DiscussionNote, *Response, error) { + var response DiscussionNote + resp, err := c.put( + ctx, + fmt.Sprintf( + "projects/%d/merge_requests/%d/discussions/%s/notes/%d", + projectID, mergeRequest, + url.PathEscape(discussionID), noteID, + ), + nil, opt, &response, + ) + if err != nil { + return nil, resp, err + } + return &response, resp, nil +} + // ProjectTemplateList lists project templates for a template type. // // GitLab API: @@ -535,7 +645,16 @@ type BasicMergeRequest struct { // https://docs.gitlab.com/api/merge_requests/ type MergeRequest struct { BasicMergeRequest - HeadPipeline *Pipeline `json:"head_pipeline,omitempty"` + HeadPipeline *Pipeline `json:"head_pipeline,omitempty"` + DiffRefs MergeRequestDiffRefs `json:"diff_refs"` +} + +// MergeRequestDiffRefs holds the SHAs needed +// to position inline comments on a merge request diff. +type MergeRequestDiffRefs struct { + BaseSha string `json:"base_sha"` + HeadSha string `json:"head_sha"` + StartSha string `json:"start_sha"` } // Pipeline is a GitLab CI pipeline status summary. @@ -643,8 +762,25 @@ type Discussion struct { // GitLab discussions API: // https://docs.gitlab.com/api/discussions/ type DiscussionNote struct { - Resolvable bool `json:"resolvable"` - Resolved bool `json:"resolved"` + ID int64 `json:"id"` + Body string `json:"body"` + Author DiscussionNoteUser `json:"author"` + Position *DiscussionPosition `json:"position,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Resolvable bool `json:"resolvable"` + Resolved bool `json:"resolved"` +} + +// DiscussionNoteUser matches the author shape on a discussion note. +type DiscussionNoteUser struct { + Username string `json:"username"` +} + +// DiscussionPosition is the inline position of a code-review note. +type DiscussionPosition struct { + NewPath string `json:"new_path"` + OldPath string `json:"old_path"` + NewLine int64 `json:"new_line"` } // ProjectTemplate matches the subset of project template fields @@ -846,6 +982,42 @@ func (o *ListMergeRequestDiscussionsOptions) encodeQuery() url.Values { // ListProjectTemplatesOptions configures template-list requests. type ListProjectTemplatesOptions struct{} +// CreateMergeRequestDiscussionOptions configures the creation +// of a new merge request discussion thread. +type CreateMergeRequestDiscussionOptions struct { + Body *string `json:"body,omitempty"` + Position *PositionOptions `json:"position,omitempty"` +} + +// PositionOptions describes the inline position for +// a discussion-creation request. +type PositionOptions struct { + BaseSHA *string `json:"base_sha,omitempty"` + HeadSHA *string `json:"head_sha,omitempty"` + StartSHA *string `json:"start_sha,omitempty"` + PositionType *string `json:"position_type,omitempty"` + NewPath *string `json:"new_path,omitempty"` + OldPath *string `json:"old_path,omitempty"` + NewLine *int64 `json:"new_line,omitempty"` +} + +// ResolveMergeRequestDiscussionOptions configures resolve/unresolve. +type ResolveMergeRequestDiscussionOptions struct { + Resolved *bool `json:"resolved,omitempty"` +} + +// AddMergeRequestDiscussionNoteOptions configures replies on +// an existing discussion thread. +type AddMergeRequestDiscussionNoteOptions struct { + Body *string `json:"body,omitempty"` +} + +// UpdateMergeRequestDiscussionNoteOptions configures edits to +// an existing discussion note. +type UpdateMergeRequestDiscussionNoteOptions struct { + Body *string `json:"body,omitempty"` +} + func gitlabProjectResource(project any) (string, error) { switch v := project.(type) { case int: diff --git a/internal/spice/state/staged_comment.go b/internal/spice/state/staged_comment.go new file mode 100644 index 000000000..11079e783 --- /dev/null +++ b/internal/spice/state/staged_comment.go @@ -0,0 +1,121 @@ +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 new file mode 100644 index 000000000..0f8da8bc6 --- /dev/null +++ b/internal/spice/state/staged_comment_test.go @@ -0,0 +1,128 @@ +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) + }) +} From 594265be46350a194d4057cb4a310736342fd03b Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 09:33:17 -0400 Subject: [PATCH 3/6] branch comment: add CLI commands for managing review comments Add `gs branch comment` subcommand group with six commands: - list: show inline comments and staged comments, with `--json` for NDJSON output - stage: stage an inline comment for batch submission - add: post an inline comment immediately - submit-staged: submit all staged comments as a review - resolve: resolve or unresolve a review thread - edit: edit a staged or forge comment Also adds DiffBranchBytes to git.Worktree for programmatic diff access needed by comment coordinate mapping. --- .../unreleased/Added-20260314-155204.yaml | 3 + .../unreleased/Added-20260502-102600.yaml | 3 + branch.go | 3 + branch_comment.go | 10 + branch_comment_add.go | 153 ++++++++ branch_comment_edit.go | 220 +++++++++++ branch_comment_list.go | 361 ++++++++++++++++++ branch_comment_resolve.go | 92 +++++ branch_comment_stage.go | 177 +++++++++ branch_comment_submit_staged.go | 169 ++++++++ doc/includes/cli-reference.md | 330 ++++++++-------- doc/includes/cli-shorthands.md | 5 +- internal/git/diff_wt.go | 15 + testdata/help/branch_comment_add.txt | 27 ++ testdata/help/branch_comment_edit.txt | 29 ++ testdata/help/branch_comment_list.txt | 28 ++ testdata/help/branch_comment_resolve.txt | 24 ++ testdata/help/branch_comment_stage.txt | 29 ++ .../help/branch_comment_submit-staged.txt | 25 ++ testdata/help/gs.txt | 41 +- 20 files changed, 1565 insertions(+), 179 deletions(-) create mode 100644 .changes/unreleased/Added-20260314-155204.yaml create mode 100644 .changes/unreleased/Added-20260502-102600.yaml create mode 100644 branch_comment.go create mode 100644 branch_comment_add.go create mode 100644 branch_comment_edit.go create mode 100644 branch_comment_list.go create mode 100644 branch_comment_resolve.go create mode 100644 branch_comment_stage.go create mode 100644 branch_comment_submit_staged.go create mode 100644 testdata/help/branch_comment_add.txt create mode 100644 testdata/help/branch_comment_edit.txt create mode 100644 testdata/help/branch_comment_list.txt create mode 100644 testdata/help/branch_comment_resolve.txt create mode 100644 testdata/help/branch_comment_stage.txt create mode 100644 testdata/help/branch_comment_submit-staged.txt diff --git a/.changes/unreleased/Added-20260314-155204.yaml b/.changes/unreleased/Added-20260314-155204.yaml new file mode 100644 index 000000000..ffd94f42f --- /dev/null +++ b/.changes/unreleased/Added-20260314-155204.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment: Add commands for managing change request review comments from the CLI' +time: 2026-03-14T15:52:04.220487-04:00 diff --git a/.changes/unreleased/Added-20260502-102600.yaml b/.changes/unreleased/Added-20260502-102600.yaml new file mode 100644 index 000000000..af000b4cb --- /dev/null +++ b/.changes/unreleased/Added-20260502-102600.yaml @@ -0,0 +1,3 @@ +kind: Added +body: 'branch comment list: Add --json flag to write comments as a stream of JSON objects' +time: 2026-05-02T10:26:00.668941-04:00 diff --git a/branch.go b/branch.go index 160587832..e359605ba 100644 --- a/branch.go +++ b/branch.go @@ -38,6 +38,9 @@ type branchCmd struct { // Pull request management Merge branchMergeCmd `cmd:"" aliases:"m" experiment:"merge" help:"Merge a branch into trunk"` Submit branchSubmitCmd `cmd:"" aliases:"s" help:"Submit a branch"` + + // Comment management + Comment branchCommentCmd `cmd:"" aliases:"cmt" help:"Manage change request comments"` } // BranchPromptConfig defines configuration for the branch tree prompt diff --git a/branch_comment.go b/branch_comment.go new file mode 100644 index 000000000..a1ec02b5d --- /dev/null +++ b/branch_comment.go @@ -0,0 +1,10 @@ +package main + +type branchCommentCmd struct { + List branchCommentListCmd `cmd:"" aliases:"ls" help:"List comments on a change request"` + Stage branchCommentStageCmd `cmd:"" help:"Stage an inline comment for batch submission"` + Add branchCommentAddCmd `cmd:"" help:"Post an inline comment immediately"` + SubmitStaged branchCommentSubmitStagedCmd `cmd:"" aliases:"ss" help:"Submit all staged comments as a review"` + Resolve branchCommentResolveCmd `cmd:"" help:"Resolve or unresolve a review thread"` + Edit branchCommentEditCmd `cmd:"" help:"Edit a comment"` +} diff --git a/branch_comment_add.go b/branch_comment_add.go new file mode 100644 index 000000000..e53c32bbf --- /dev/null +++ b/branch_comment_add.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.abhg.dev/gs/internal/diffmap" + "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/text" +) + +type branchCommentAddCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to add comment for. Defaults to current branch."` +} + +func (*branchCommentAddCmd) Help() string { + return text.Dedent(` + Posts an inline comment immediately + on the change request for the current branch. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + `) +} + +func (cmd *branchCommentAddCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + 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, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + req := forge.InlineCommentRequest{ + Body: body, + ThreadID: cmd.Respond, + } + + // Map file:line to diff coordinates + // if this is a new comment (not a reply). + if cmd.Respond == "" { + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + path, diffLine, side, err := mapper.Map(file, line) + if err != nil { + return fmt.Errorf( + "map %s:%d to diff: %w", + file, line, err, + ) + } + + req.Path = path + req.Line = diffLine + req.Side = side + } + + posted, err := inline.PostInlineComment( + ctx, b.Change.ChangeID(), req, + ) + if err != nil { + return fmt.Errorf("post inline comment: %w", err) + } + + log.Infof( + "Posted comment %s on %s.", + posted.ID, b.Change.ChangeID(), + ) + return nil +} diff --git a/branch_comment_edit.go b/branch_comment_edit.go new file mode 100644 index 000000000..be7d9fa7d --- /dev/null +++ b/branch_comment_edit.go @@ -0,0 +1,220 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "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/text" +) + +type branchCommentEditCmd struct { + ID string `arg:"" help:"Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID."` + 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 whose comments to edit. Defaults to current branch."` +} + +func (*branchCommentEditCmd) Help() string { + return text.Dedent(` + Edits the body of a comment. + + For staged comments (sc-N prefix), + the comment is updated in the local staging area. + + For forge comments, the comment is updated + on the remote forge. + + If no message is given with -m, an editor is opened + with the current comment body pre-filled. + `) +} + +func (cmd *branchCommentEditCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + repo *git.Repository, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Handle staged comment edits. + if scID, ok := parseStagedCommentID(cmd.ID); ok { + return cmd.editStaged( + ctx, log, store, repo, branch, scID, + ) + } + + // Handle forge comment edits. + return cmd.editForge( + ctx, log, wt, svc, repo, forgeRepo, branch, + ) +} + +func (cmd *branchCommentEditCmd) editStaged( + ctx context.Context, + log *silog.Logger, + store *state.Store, + repo *git.Repository, + branch string, + scID int, +) error { + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + idx := -1 + for i, c := range staged.Comments { + if c.ID == scID { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf( + "staged comment sc-%d not found", scID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, staged.Comments[idx].Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged.Comments[idx].Body = body + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + log.Infof("Updated staged comment sc-%d.", scID) + return nil +} + +func (cmd *branchCommentEditCmd) editForge( + ctx context.Context, + log *silog.Logger, + _ *git.Worktree, + svc *spice.Service, + repo *git.Repository, + forgeRepo forge.Repository, + branch string, +) error { + 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", branch, + ) + } + + editor, ok := forgeRepo.(forge.WithCommentEdit) + if !ok { + return errors.New( + "forge does not support comment editing", + ) + } + + // Find the comment by ID to get its current body. + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return fmt.Errorf("list comments: %w", err) + } + + var target *forge.InlineComment + for _, c := range comments { + if c.ID.String() == cmd.ID { + target = c + break + } + } + if target == nil { + return fmt.Errorf( + "comment %s not found", cmd.ID, + ) + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, target.Body, + ) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + if err := editor.EditComment( + ctx, target.ID, body, + ); err != nil { + return fmt.Errorf("edit comment: %w", err) + } + + log.Infof("Updated comment %s.", cmd.ID) + return nil +} + +// parseStagedCommentID parses "sc-N" into integer N. +// Returns (N, true) on success, (0, false) otherwise. +func parseStagedCommentID(s string) (int, bool) { + after, found := strings.CutPrefix(s, "sc-") + if !found { + return 0, false + } + id, err := strconv.Atoi(after) + if err != nil { + return 0, false + } + return id, true +} diff --git a/branch_comment_list.go b/branch_comment_list.go new file mode 100644 index 000000000..c0ef9f57b --- /dev/null +++ b/branch_comment_list.go @@ -0,0 +1,361 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/alecthomas/kong" + "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/text" +) + +type branchCommentListCmd struct { + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to list comments for. Defaults to current branch."` + Staged bool `help:"Show only staged comments."` + Unresolved bool `help:"Show only unresolved comments."` + JSON bool `name:"json" released:"unreleased" help:"Write to stdout as a stream of JSON objects."` +} + +func (*branchCommentListCmd) Help() string { + return text.Dedent(` + Lists comments on the change request + associated with the current branch. + Use --branch to target a different branch. + + Staged comments that have not yet been submitted + are shown with an 'sc-N' prefix. + + Use --staged to show only staged comments. + Use --unresolved to show only unresolved comments. + + With --json, prints output to stdout + as a stream of JSON objects. + `) +} + +func (cmd *branchCommentListCmd) Run( + ctx context.Context, + kctx *kong.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch, err := cmd.resolveBranch(ctx, wt) + if err != nil { + return err + } + + staged, forgeComments, err := cmd.loadComments( + ctx, log, svc, store, forgeRepo, branch, + ) + if err != nil { + return err + } + + if cmd.JSON { + return cmd.writeJSON( + kctx.Stdout, staged, forgeComments, + ) + } + return cmd.writeText(log, branch, staged, forgeComments) +} + +func (cmd *branchCommentListCmd) 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 *branchCommentListCmd) loadComments( + ctx context.Context, + log *silog.Logger, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, + branch string, +) ([]*state.StagedComment, []*forge.InlineComment, error) { + staged, err := loadStagedComments(ctx, store, branch) + if err != nil { + return nil, nil, err + } + + if cmd.Staged { + 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 staged comments: %w", err, + ) + } + 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, +) ([]*forge.InlineComment, 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 + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + log.Infof( + "Forge does not support inline comments.", + ) + return nil, nil + } + + comments, err := inline.ListInlineComments( + ctx, b.Change.ChangeID(), + ) + if err != nil { + return nil, fmt.Errorf( + "list inline comments: %w", err, + ) + } + return comments, nil +} + +func (cmd *branchCommentListCmd) filterForge( + comments []*forge.InlineComment, +) []*forge.InlineComment { + if !cmd.Unresolved { + return comments + } + + var filtered []*forge.InlineComment + for _, c := range comments { + if !c.Resolved { + filtered = append(filtered, c) + } + } + return filtered +} + +// writeText prints comments in human-readable format. +func (cmd *branchCommentListCmd) writeText( + log *silog.Logger, + branch string, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) error { + if len(staged) > 0 { + log.Infof("Staged comments:") + for _, c := range staged { + writeStagedText(log, c) + } + } + + if cmd.Staged && len(staged) == 0 { + log.Infof("No staged comments for %s.", branch) + return nil + } + + if len(forgeComments) > 0 { + log.Infof("Comments:") + for _, c := range forgeComments { + writeForgeText(log, c) + } + } + + if len(forgeComments) == 0 && len(staged) == 0 { + log.Infof("No comments on %s.", 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 + } + log.Infof(" sc-%-4d %s", c.ID, location) + writeBodyIndented(log, c.Body) +} + +func writeForgeText( + log *silog.Logger, c *forge.InlineComment, +) { + location := fmt.Sprintf("%s:%d", c.Path, c.Line) + threadInfo := "" + if c.ThreadID != "" { + threadInfo = " [" + c.ThreadID + "]" + } + log.Infof( + " %-12s %s %s %s%s", + c.ID, location, c.Author, + commentStatus(c), threadInfo, + ) + writeBodyIndented(log, c.Body) +} + +func writeBodyIndented(log *silog.Logger, body string) { + for line := range strings.SplitSeq(body, "\n") { + log.Infof(" %s", line) + } +} + +func commentStatus(c *forge.InlineComment) string { + if c.Outdated { + return "outdated" + } + if c.Resolved { + return "resolved" + } + return "open" +} + +// writeJSON encodes comments as NDJSON to stdout. +func (cmd *branchCommentListCmd) writeJSON( + w io.Writer, + staged []*state.StagedComment, + forgeComments []*forge.InlineComment, +) (retErr error) { + bufw := bufio.NewWriter(w) + defer func() { + retErr = errors.Join(retErr, bufw.Flush()) + }() + + enc := json.NewEncoder(bufw) + for _, c := range staged { + if err := enc.Encode(stagedToJSON(c)); err != nil { + return fmt.Errorf("encode staged: %w", err) + } + } + for _, c := range forgeComments { + if err := enc.Encode(forgeToJSON(c)); err != nil { + return fmt.Errorf("encode forge: %w", err) + } + } + return nil +} + +func stagedToJSON(c *state.StagedComment) jsonComment { + return jsonComment{ + Kind: "staged", + ID: fmt.Sprintf("sc-%d", c.ID), + Path: c.File, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + } +} + +func forgeToJSON(c *forge.InlineComment) jsonComment { + var createdAt *time.Time + if !c.CreatedAt.IsZero() { + createdAt = &c.CreatedAt + } + return jsonComment{ + Kind: "forge", + ID: c.ID.String(), + Path: c.Path, + Line: c.Line, + Body: c.Body, + ThreadID: c.ThreadID, + Author: c.Author, + Status: commentStatus(c), + CreatedAt: createdAt, + } +} + +// jsonComment is the JSON representation +// of a comment for --json output. +type jsonComment struct { + // Kind is "staged" or "forge". + Kind string `json:"kind"` + + // ID is the comment identifier. + // For staged comments: "sc-N". + // For forge comments: forge-specific ID. + ID string `json:"id"` + + // Path is the file path relative to the repo root. + Path string `json:"path,omitempty"` + + // Line is the line number in the file. + Line int `json:"line,omitempty"` + + // Body is the full markdown body of the comment. + Body string `json:"body"` + + // ThreadID is the thread identifier, if any. + ThreadID string `json:"threadID,omitempty"` + + // Author is the username of the comment author. + // Only set for forge comments. + Author string `json:"author,omitempty"` + + // Status is "open", "resolved", or "outdated". + // Only set for forge comments. + Status string `json:"status,omitempty"` + + // CreatedAt is the time the comment was created. + // Only set for forge comments. + CreatedAt *time.Time `json:"createdAt,omitempty"` +} diff --git a/branch_comment_resolve.go b/branch_comment_resolve.go new file mode 100644 index 000000000..60bf544c3 --- /dev/null +++ b/branch_comment_resolve.go @@ -0,0 +1,92 @@ +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/spice/state" + "go.abhg.dev/gs/internal/text" +) + +type branchCommentResolveCmd struct { + ThreadID string `arg:"" help:"Thread ID to resolve."` + Unresolve bool `help:"Unresolve the thread instead of resolving it."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch whose change request contains the thread. Defaults to current branch."` +} + +func (*branchCommentResolveCmd) Help() string { + return text.Dedent(` + Resolves a review thread on the change request + for the current branch. + + Use --unresolve to mark the thread as unresolved. + + The thread ID is shown in 'gs branch comment list'. + `) +} + +func (cmd *branchCommentResolveCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + // Verify branch has a change request. + 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", branch, + ) + } + + resolver, ok := forgeRepo.(forge.WithThreadResolution) + if !ok { + return errors.New( + "forge does not support thread resolution", + ) + } + + if cmd.Unresolve { + if err := resolver.UnresolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf( + "unresolve thread: %w", err, + ) + } + log.Infof("Unresolved thread %s.", cmd.ThreadID) + } else { + if err := resolver.ResolveThread( + ctx, cmd.ThreadID, + ); err != nil { + return fmt.Errorf("resolve thread: %w", err) + } + log.Infof("Resolved thread %s.", cmd.ThreadID) + } + + return nil +} diff --git a/branch_comment_stage.go b/branch_comment_stage.go new file mode 100644 index 000000000..b5eb87eec --- /dev/null +++ b/branch_comment_stage.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "go.abhg.dev/gs/internal/git" + "go.abhg.dev/gs/internal/silog" + "go.abhg.dev/gs/internal/spice/state" + "go.abhg.dev/gs/internal/text" + "go.abhg.dev/gs/internal/xec" +) + +type branchCommentStageCmd struct { + FileAndLine string `arg:"" optional:"" help:"File and line in the form file.go:42."` + Message string `short:"m" placeholder:"MSG" help:"Comment body. Opens editor if not provided."` + Respond string `placeholder:"THREAD_ID" help:"Thread ID to reply to instead of starting a new thread."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to stage comment for. Defaults to current branch."` +} + +func (*branchCommentStageCmd) Help() string { + return text.Dedent(` + Stages an inline comment for later batch submission. + Provide the file and line number as file.go:42. + + If no message is given with -m, an editor is opened. + + Use --respond to reply to an existing thread + instead of starting a new one. + + Staged comments are submitted together with + 'gs branch comment submit-staged'. + `) +} + +func (cmd *branchCommentStageCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + store *state.Store, + repo *git.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + branch, err = wt.CurrentBranch(ctx) + if err != nil { + return fmt.Errorf("get current branch: %w", err) + } + } + + var file string + var line int + if cmd.Respond == "" { + if cmd.FileAndLine == "" { + return errors.New( + "file:line argument is required " + + "unless --respond is used", + ) + } + var err error + file, line, err = parseFileAndLine(cmd.FileAndLine) + if err != nil { + return err + } + } + + body := cmd.Message + if body == "" { + var err error + body, err = editCommentBody( + ctx, repo, "" /* initial */) + if err != nil { + return err + } + } + if strings.TrimSpace(body) == "" { + return errors.New("empty comment body, aborting") + } + + staged, err := store.LoadStagedComments(ctx, branch) + if err != nil { + return fmt.Errorf("load staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{NextID: 1} + } + + comment := state.StagedComment{ + ID: staged.NextID, + File: file, + Line: line, + Body: body, + ThreadID: cmd.Respond, + } + staged.Comments = append(staged.Comments, comment) + staged.NextID++ + + if err := store.SaveStagedComments( + ctx, branch, staged, + ); err != nil { + return fmt.Errorf("save staged comments: %w", err) + } + + if cmd.Respond != "" { + log.Infof( + "Staged reply sc-%d to thread %s.", + comment.ID, cmd.Respond, + ) + } else { + log.Infof( + "Staged comment sc-%d on %s:%d.", + comment.ID, file, line, + ) + } + return nil +} + +// parseFileAndLine parses a "file.go:42" argument +// into file and line components. +func parseFileAndLine(s string) (string, int, error) { + idx := strings.LastIndex(s, ":") + if idx < 0 { + return "", 0, fmt.Errorf( + "expected file:line format, got %q", s, + ) + } + file := s[:idx] + line, err := strconv.Atoi(s[idx+1:]) + if err != nil { + return "", 0, fmt.Errorf( + "invalid line number in %q: %w", s, err, + ) + } + if line <= 0 { + return "", 0, fmt.Errorf( + "line number must be positive, got %d", line, + ) + } + return file, line, nil +} + +// editCommentBody opens an editor for the user +// to write a comment body. +// initial is pre-filled text (may be empty). +func editCommentBody( + ctx context.Context, + repo *git.Repository, + initial string, +) (string, error) { + tmpFile := filepath.Join( + os.TempDir(), "GS_COMMENT_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) + } + return string(content), nil +} diff --git a/branch_comment_submit_staged.go b/branch_comment_submit_staged.go new file mode 100644 index 000000000..4194c22ce --- /dev/null +++ b/branch_comment_submit_staged.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "errors" + "fmt" + + "go.abhg.dev/gs/internal/diffmap" + "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/text" +) + +type branchCommentSubmitStagedCmd 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."` + Branch string `short:"b" placeholder:"BRANCH" predictor:"trackedBranches" help:"Branch to submit staged comments for. Defaults to current branch."` +} + +func (*branchCommentSubmitStagedCmd) Help() string { + return text.Dedent(` + Submits all staged comments for the current branch + as a single review on the change request. + + Use --approve or --request-changes + to set the review event type. + Defaults to a comment-only review. + + Use --body to add an overall review body. + `) +} + +func (cmd *branchCommentSubmitStagedCmd) Run( + ctx context.Context, + log *silog.Logger, + wt *git.Worktree, + svc *spice.Service, + store *state.Store, + forgeRepo forge.Repository, +) error { + branch := cmd.Branch + if branch == "" { + var err error + 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 staged comments: %w", err) + } + if staged == nil { + staged = &state.StagedComments{} + } + + if len(staged.Comments) == 0 { + log.Infof("No staged comments to submit.") + 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, + ) + } + + inline, ok := forgeRepo.(forge.WithInlineComments) + if !ok { + return errors.New( + "forge does not support inline comments", + ) + } + + // Build diff map for coordinate translation. + diff, err := wt.DiffBranchBytes(ctx, b.Base, branch) + if err != nil { + return fmt.Errorf("get diff: %w", err) + } + + mapper, err := diffmap.New(diff) + if err != nil { + return fmt.Errorf("parse diff: %w", err) + } + + // Map staged comments to forge requests. + var comments []forge.InlineCommentRequest + for _, sc := range staged.Comments { + if sc.ThreadID != "" { + // Thread reply: no coordinate mapping needed. + comments = append(comments, + forge.InlineCommentRequest{ + Body: sc.Body, + ThreadID: sc.ThreadID, + }, + ) + continue + } + + path, diffLine, side, err := mapper.Map( + sc.File, sc.Line, + ) + if err != nil { + return fmt.Errorf( + "sc-%d: map %s:%d to diff: %w", + sc.ID, sc.File, sc.Line, err, + ) + } + + comments = append(comments, + forge.InlineCommentRequest{ + Path: path, + Line: diffLine, + Body: sc.Body, + Side: side, + }, + ) + } + + event := forge.ReviewComment + if cmd.Approve { + event = forge.ReviewApprove + } else if cmd.RequestChanges { + event = forge.ReviewRequestChanges + } + + if err := inline.SubmitReview( + ctx, + b.Change.ChangeID(), + forge.ReviewRequest{ + Body: cmd.Body, + Comments: comments, + Event: event, + }, + ); err != nil { + return fmt.Errorf("submit review: %w", err) + } + + if err := store.ClearStagedComments( + ctx, branch, + ); err != nil { + return fmt.Errorf("clear staged comments: %w", err) + } + + log.Infof( + "Submitted %d comment(s) as review on %s.", + len(comments), + b.Change.ChangeID(), + ) + return nil +} diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 507c9d1c6..ed11008e8 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,17 +156,11 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. -Branches above merged and deleted branches -are retargeted to the trunk branch. -Run with --restack to also restack them and their upstacks. -Run with --restack=aboves to only restack direct upstacks -of deleted branches, leaving higher branches in place. - **Flags** -* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. +* `--restack`: Restack the current stack after syncing -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) ### git-spice repo restack {#gs-repo-restack} @@ -284,51 +278,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice stack merge {#gs-stack-merge} - -``` -gs stack (s) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a stack - -Merges the CRs for the current branch's stack into trunk. -Use --branch to merge a different branch's stack. - -The stack includes the selected branch, -its downstack branches down to trunk, -and every upstack branch. - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -Before merging, the stack is checked for branches -whose base PR was already merged on the forge. -Use --no-branch-check to skip this validation. - -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -before failing if merge readiness is not reached. - -By default, a branch failure skips that branch's upstack descendants, -but independent sibling branches continue. -Use --fail-fast to stop the queue after the first branch failure. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--no-branch-check`: Skip stale base validation before merging. -* `--fail-fast`: Stop the merge queue after the first branch failure. -* `--branch=NAME`: Branch whose stack to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -451,7 +401,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -606,68 +556,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) - -### git-spice downstack merge {#gs-downstack-merge} - -``` -gs downstack (ds) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch and those below it - -Merges the current branch and all branches below it -into trunk via the forge API, bottom-up. -Use --branch to start at a different branch. - -This command acts as a local merge queue: -it merges one Change Request, -waits for that merge to finish, -restacks and updates the next Change Request, -waits for merge readiness on the updated Change Request, -and then repeats the process. - -For a stack like this: - - main <- feature1 <- feature2 <- feature3 - -Running from feature3 merges in this order: - - feature1, feature2, feature3 - -Already-merged branches are skipped automatically. -Branches must have an open Change Request to be merged. - -Before merging, the downstack is checked for branches -whose base PR was already merged on the forge. -Use --no-branch-check to skip this validation. - -Before each merge, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait -(default: 30m, 0 means fail immediately if not ready). - -Between merges, the command waits for each merge -to complete, restacks and updates the next PR, -waits for merge readiness on the updated PR, -and syncs merged branch cleanup. - -Use --no-wait for single branch merging -when you don't want to wait for the merge to propagate. ---no-wait is rejected for multi-branch merges. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. -* `--branch=NAME`: Branch to start merging from - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice downstack edit {#gs-downstack-edit} @@ -875,13 +764,10 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are retargeted onto +Branches above the deleted branches are first rebased onto the next branches available downstack, or onto trunk if there are no branches available below. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - Without any arguments, a prompt will allow selecting the branch to delete. @@ -896,9 +782,8 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch -* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -1068,12 +953,9 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are retargeted onto its original base, +are first rebased onto its original base, and then the current branch is moved onto the new base. -Use --restack to rebase those branches and their upstacks -immediately after retargeting. - A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -1098,9 +980,8 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move -* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1121,35 +1002,6 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff -### git-spice branch merge {#gs-branch-merge} - -``` -gs branch (b) merge (m) [flags] -``` - -:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) - -Merge a branch into trunk - -Merges the CR for the current branch into trunk. -Use --branch to merge a different branch. - -The branch must be based directly on trunk. -To merge a stacked branch, use 'gs downstack merge'. - -Before merging, waits for merge readiness: -the forge must observe the pushed head -and report that the CR is ready to merge. -Use --ready-timeout to configure the maximum wait. - -**Flags** - -* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. -* `--ready-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.readyTimeout" }](/cli/config.md#spicemergereadytimeout)): Max time to wait for merge readiness before each merge. 0 means check once. -* `--branch=NAME`: Branch to merge - -**Configuration**: [spice.merge.method](/cli/config.md#spicemergemethod), [spice.merge.readyTimeout](/cli/config.md#spicemergereadytimeout) - ### git-spice branch submit {#gs-branch-submit} ``` @@ -1203,7 +1055,167 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) + +### git-spice branch comment list {#gs-branch-comment-list} + +``` +gs branch (b) comment (cmt) list (ls) [flags] +``` + +List comments on a change request + +Lists comments on the change request +associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted +are shown with an 'sc-N' prefix. + +Use --staged to show only staged comments. +Use --unresolved to show only unresolved comments. + +With --json, prints output to stdout +as a stream of JSON objects. + +**Flags** + +* `-b`, `--branch=BRANCH`: Branch to list comments for. Defaults to current branch. +* `--staged`: Show only staged comments. +* `--unresolved`: Show only unresolved comments. +* `--json`: Write to stdout as a stream of JSON objects. :material-tag-hidden:{ title="Released in version" }Unreleased + +### git-spice branch comment stage {#gs-branch-comment-stage} + +``` +gs branch (b) comment (cmt) stage [] [flags] +``` + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +Staged comments are submitted together with +'gs branch comment submit-staged'. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to stage comment for. Defaults to current branch. + +### git-spice branch comment add {#gs-branch-comment-add} + +``` +gs branch (b) comment (cmt) add [] [flags] +``` + +Post an inline comment immediately + +Posts an inline comment immediately +on the change request for the current branch. +Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread +instead of starting a new one. + +**Arguments** + +* `file-and-line`: File and line in the form file.go:42. + +**Flags** + +* `-m`, `--message=MSG`: Comment body. Opens editor if not provided. +* `--respond=THREAD_ID`: Thread ID to reply to instead of starting a new thread. +* `-b`, `--branch=BRANCH`: Branch to add comment for. Defaults to current branch. + +### git-spice branch comment submit-staged {#gs-branch-comment-submit-staged} + +``` +gs branch (b) comment (cmt) submit-staged (ss) [flags] +``` + +Submit all staged comments as a review + +Submits all staged comments for the current branch +as a single review on the change request. + +Use --approve or --request-changes +to set the review event type. +Defaults to a comment-only review. + +Use --body to add an overall review body. + +**Flags** + +* `--body=BODY`: Overall review body. +* `--approve`: Mark the review as approved. +* `--request-changes`: Mark the review as requesting changes. +* `-b`, `--branch=BRANCH`: Branch to submit staged comments for. Defaults to current branch. + +### git-spice branch comment resolve {#gs-branch-comment-resolve} + +``` +gs branch (b) comment (cmt) resolve [flags] +``` + +Resolve or unresolve a review thread + +Resolves a review thread on the change request +for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +**Arguments** + +* `thread-id`: Thread ID to resolve. + +**Flags** + +* `--unresolve`: Unresolve the thread instead of resolving it. +* `-b`, `--branch=BRANCH`: Branch whose change request contains the thread. Defaults to current branch. + +### git-spice branch comment edit {#gs-branch-comment-edit} + +``` +gs branch (b) comment (cmt) edit [flags] +``` + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), +the comment is updated in the local staging area. + +For forge comments, the comment is updated +on the remote forge. + +If no message is given with -m, an editor is opened +with the current comment body pre-filled. + +**Arguments** + +* `id`: Comment ID to edit. Use 'sc-N' for staged comments or a forge comment ID. + +**Flags** + +* `-m`, `--message=MSG`: New comment body. Opens editor if not provided. +* `-b`, `--branch=BRANCH`: Branch whose comments to edit. Defaults to current branch. ## Commit diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 064cad722..42098f47a 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -1,12 +1,13 @@ | **Shorthand** | **Long form** | | --- | --- | | gs bc | [gs branch create](/cli/reference.md#gs-branch-create) | +| gs bcmtls | [gs branch comment list](/cli/reference.md#gs-branch-comment-list) | +| gs bcmtss | [gs branch comment submit-staged](/cli/reference.md#gs-branch-comment-submit-staged) | | gs bco | [gs branch checkout](/cli/reference.md#gs-branch-checkout) | | gs bd | [gs branch delete](/cli/reference.md#gs-branch-delete) | | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | -| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -21,7 +22,6 @@ | gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) | | gs csp | [gs commit split](/cli/reference.md#gs-commit-split) | | gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) | -| gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) | | gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | | gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) | @@ -34,7 +34,6 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | -| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/internal/git/diff_wt.go b/internal/git/diff_wt.go index a3a805de9..f1686410b 100644 --- a/internal/git/diff_wt.go +++ b/internal/git/diff_wt.go @@ -19,3 +19,18 @@ func (w *Worktree) DiffBranch(ctx context.Context, base, head string) error { } return nil } + +// DiffBranchBytes returns the unified diff output +// between base and head using triple-dot syntax. +func (w *Worktree) DiffBranchBytes( + ctx context.Context, + base, head string, +) ([]byte, error) { + out, err := w.gitCmd( + ctx, "diff", base+"..."+head, + ).Output() + if err != nil { + return nil, fmt.Errorf("diff: %w", err) + } + return out, nil +} diff --git a/testdata/help/branch_comment_add.txt b/testdata/help/branch_comment_add.txt new file mode 100644 index 000000000..a3a596440 --- /dev/null +++ b/testdata/help/branch_comment_add.txt @@ -0,0 +1,27 @@ +Usage: gs branch (b) comment (cmt) add [] [flags] + +Post an inline comment immediately + +Posts an inline comment immediately on the change request for the current +branch. Provide the file and line number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to add comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_edit.txt b/testdata/help/branch_comment_edit.txt new file mode 100644 index 000000000..26ef35744 --- /dev/null +++ b/testdata/help/branch_comment_edit.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) edit [flags] + +Edit a comment + +Edits the body of a comment. + +For staged comments (sc-N prefix), the comment is updated in the local staging +area. + +For forge comments, the comment is updated on the remote forge. + +If no message is given with -m, an editor is opened with the current comment +body pre-filled. + +Arguments: + Comment ID to edit. Use 'sc-N' for staged comments or a forge comment + ID. + +Flags: + -m, --message=MSG New comment body. Opens editor if not provided. + -b, --branch=BRANCH Branch whose comments to edit. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_list.txt b/testdata/help/branch_comment_list.txt new file mode 100644 index 000000000..2ad4dabf5 --- /dev/null +++ b/testdata/help/branch_comment_list.txt @@ -0,0 +1,28 @@ +Usage: gs branch (b) comment (cmt) list (ls) [flags] + +List comments on a change request + +Lists comments on the change request associated with the current branch. +Use --branch to target a different branch. + +Staged comments that have not yet been submitted are shown with an 'sc-N' +prefix. + +Use --staged to show only staged comments. Use --unresolved to show only +unresolved comments. + +With --json, prints output to stdout as a stream of JSON objects. + +Flags: + -b, --branch=BRANCH Branch to list comments for. Defaults to current + branch. + --staged Show only staged comments. + --unresolved Show only unresolved comments. + --json Write to stdout as a stream of JSON objects. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_resolve.txt b/testdata/help/branch_comment_resolve.txt new file mode 100644 index 000000000..c4ee13472 --- /dev/null +++ b/testdata/help/branch_comment_resolve.txt @@ -0,0 +1,24 @@ +Usage: gs branch (b) comment (cmt) resolve [flags] + +Resolve or unresolve a review thread + +Resolves a review thread on the change request for the current branch. + +Use --unresolve to mark the thread as unresolved. + +The thread ID is shown in 'gs branch comment list'. + +Arguments: + Thread ID to resolve. + +Flags: + --unresolve Unresolve the thread instead of resolving it. + -b, --branch=BRANCH Branch whose change request contains the thread. + Defaults to current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_stage.txt b/testdata/help/branch_comment_stage.txt new file mode 100644 index 000000000..d8d4e7d2b --- /dev/null +++ b/testdata/help/branch_comment_stage.txt @@ -0,0 +1,29 @@ +Usage: gs branch (b) comment (cmt) stage [] [flags] + +Stage an inline comment for batch submission + +Stages an inline comment for later batch submission. Provide the file and line +number as file.go:42. + +If no message is given with -m, an editor is opened. + +Use --respond to reply to an existing thread instead of starting a new one. + +Staged comments are submitted together with 'gs branch comment submit-staged'. + +Arguments: + [] File and line in the form file.go:42. + +Flags: + -m, --message=MSG Comment body. Opens editor if not provided. + --respond=THREAD_ID Thread ID to reply to instead of starting a new + thread. + -b, --branch=BRANCH Branch to stage comment for. Defaults to current + branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/branch_comment_submit-staged.txt b/testdata/help/branch_comment_submit-staged.txt new file mode 100644 index 000000000..6d6b6c339 --- /dev/null +++ b/testdata/help/branch_comment_submit-staged.txt @@ -0,0 +1,25 @@ +Usage: gs branch (b) comment (cmt) submit-staged (ss) [flags] + +Submit all staged comments as a review + +Submits all staged comments for the current branch as a single review on the +change request. + +Use --approve or --request-changes to set the review event type. Defaults to a +comment-only review. + +Use --body to add an overall review body. + +Flags: + --body=BODY Overall review body. + --approve Mark the review as approved. + --request-changes Mark the review as requesting changes. + -b, --branch=BRANCH Branch to submit staged comments for. Defaults to + current branch. + +Global Flags: + -h, --help Show help for the command + --version Print version information and quit + -v, --verbose Enable verbose output ($GIT_SPICE_VERBOSE) + -C, --dir=DIR Change to DIR before doing anything + --[no-]prompt Whether to prompt for missing information diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 43406fde2..166ac7a91 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,7 +31,6 @@ Log Stack stack (s) submit (s) Submit a stack - stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -41,26 +40,34 @@ Stack upstack (us) delete (d) Delete all branches above the current branch downstack (ds) track (tr) Track all untracked branches below a branch downstack (ds) submit (s) Submit a branch and those below it - downstack (ds) merge (m) Merge a branch and those below it downstack (ds) edit (e) Edit the order of branches below a branch downstack (ds) restack (r) Restack a branch and its downstack Branch - branch (b) track (tr) Track a branch - branch (b) untrack (untr) Forget a tracked branch - branch (b) checkout (co) Switch to a branch - branch (b) create (c) Create a new branch - branch (b) delete (d,rm) Delete branches - branch (b) fold (fo) Merge a branch into its base - branch (b) split (sp) Split a branch on commits - branch (b) squash (sq) Squash a branch into one commit - branch (b) edit (e) Edit the commits in a branch - branch (b) rename (rn,mv) Rename a branch - branch (b) restack (r) Restack a branch - branch (b) onto (on) Move a branch onto another branch - branch (b) diff (di) Show diff between a branch and its base - branch (b) merge (m) Merge a branch into trunk - branch (b) submit (s) Submit a branch + branch (b) track (tr) Track a branch + branch (b) untrack (untr) Forget a tracked branch + branch (b) checkout (co) Switch to a branch + branch (b) create (c) Create a new branch + branch (b) delete (d,rm) Delete branches + branch (b) fold (fo) Merge a branch into its base + branch (b) split (sp) Split a branch on commits + branch (b) squash (sq) Squash a branch into one commit + branch (b) edit (e) Edit the commits in a branch + branch (b) rename (rn,mv) Rename a branch + branch (b) restack (r) Restack a branch + branch (b) onto (on) Move a branch onto another branch + branch (b) diff (di) Show diff between a branch and its base + branch (b) submit (s) Submit a branch + branch (b) comment (cmt) list (ls) + List comments on a change request + branch (b) comment (cmt) stage + Stage an inline comment for batch submission + branch (b) comment (cmt) add Post an inline comment immediately + branch (b) comment (cmt) submit-staged (ss) + Submit all staged comments as a review + branch (b) comment (cmt) resolve + Resolve or unresolve a review thread + branch (b) comment (cmt) edit Edit a comment Commit commit (c) create (c) Create a new commit From ef6e3266bdf56c5b1159053a5e821307b86abf79 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:22:28 +0000 Subject: [PATCH 4/6] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 95 ++++++++++++++++++++++++++++++---- doc/includes/cli-shorthands.md | 1 + testdata/help/gs.txt | 1 + 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ed11008e8..ad319b235 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ git-spice is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) +**Configuration**: [spice.forge.bitbucket.apiURL](/cli/config.md#spiceforgebitbucketapiurl), [spice.forge.bitbucket.url](/cli/config.md#spiceforgebitbucketurl), [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl), [spice.forge.kind](/cli/config.md#spiceforgekind), [spice.git.indexLockTimeout](/cli/config.md#spicegitindexlocktimeout), [spice.secret.backend](/cli/config.md#spicesecretbackend) ## Shell @@ -156,11 +156,17 @@ The repository must have a remote associated for syncing. A prompt will ask for one if the repository was not initialized with a remote. +Branches above merged and deleted branches +are retargeted to the trunk branch. +Run with --restack to also restack them and their upstacks. +Run with --restack=aboves to only restack direct upstacks +of deleted branches, leaving higher branches in place. + **Flags** -* `--restack`: Restack the current stack after syncing +* `--restack` ([:material-wrench:{ .middle title="spice.repoSync.restack" }](/cli/config.md#spicereposyncrestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges) +**Configuration**: [spice.repoSync.closedChanges](/cli/config.md#spicereposyncclosedchanges), [spice.repoSync.restack](/cli/config.md#spicereposyncrestack) ### git-spice repo restack {#gs-repo-restack} @@ -278,7 +284,7 @@ only if there are multiple CRs in the stack. * `-a`, `--assign=ASSIGNEE,...`: Assign the change request to these users. Pass multiple times or separate with commas. :material-tag:{ title="Released in version" }[v0.21.0](/changelog.md#v0.21.0) * `--no-web`: Alias for --web=false. -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice stack restack {#gs-stack-restack} @@ -401,7 +407,7 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice upstack restack {#gs-upstack-restack} @@ -556,7 +562,66 @@ only if there are multiple CRs in the stack. * `--no-web`: Alias for --web=false. * `--branch=NAME`: Branch to start at -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) + +### git-spice downstack merge {#gs-downstack-merge} + +``` +gs downstack (ds) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch and those below it + +Merges the current branch and all branches below it +into trunk via the forge API, bottom-up. +Use --branch to start at a different branch. + +This command acts as a local merge queue: +it merges one Change Request, +waits for that merge to finish, +restacks and updates the next Change Request, +waits for its CI checks to pass, +and then repeats the process. + +For a stack like this: + + main <- feature1 <- feature2 <- feature3 + +Running from feature3 merges in this order: + + feature1, feature2, feature3 + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +Before merging, the downstack is checked for branches +whose base PR was already merged on the forge. +Use --no-branch-check to skip this validation. + +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +(default: 30m, 0 means fail immediately if not ready). + +Between merges, the command waits for each merge +to complete, restacks and updates the next PR, +waits for CI checks on the updated PR, +and syncs merged branch cleanup. + +Use --no-wait for single branch merging +when you don't want to wait for the merge to propagate. +--no-wait is rejected for multi-branch merges. + +**Flags** + +* `--branch=NAME`: Branch to start merging from +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) ### git-spice downstack edit {#gs-downstack-edit} @@ -764,10 +829,13 @@ gs branch (b) delete (d,rm) [ ...] [flags] Delete branches The deleted branches and their commits are removed from the stack. -Branches above the deleted branches are first rebased onto +Branches above the deleted branches are retargeted onto the next branches available downstack, or onto trunk if there are no branches available below. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + Without any arguments, a prompt will allow selecting the branch to delete. @@ -782,8 +850,9 @@ Use --force to delete the branch regardless of unmerged changes. **Flags** * `--force`: Force deletion of the branch +* `--restack` ([:material-wrench:{ .middle title="spice.branchDelete.restack" }](/cli/config.md#spicebranchdeleterestack)): How to restack branches above deleted branches. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchDelete.restack](/cli/config.md#spicebranchdeleterestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch fold {#gs-branch-fold} @@ -953,9 +1022,12 @@ Commits of the current branch are transplanted onto another branch while leaving the rest of the stack intact. That is, branches above the current branch -are first rebased onto its original base, +are retargeted onto its original base, and then the current branch is moved onto the new base. +Use --restack to rebase those branches and their upstacks +immediately after retargeting. + A prompt will allow selecting the new base for the branch. Provide an argument to skip the prompt. Use the --branch flag to target a different branch for the move. @@ -980,8 +1052,9 @@ Use 'gs upstack onto' to also move the upstack branches. **Flags** * `--branch=NAME`: Branch to move +* `--restack` ([:material-wrench:{ .middle title="spice.branchOnto.restack" }](/cli/config.md#spicebranchontorestack)): How to restack branches above the moved branch. One of 'none', 'aboves', and 'upstack'. -**Configuration**: [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) +**Configuration**: [spice.branchOnto.restack](/cli/config.md#spicebranchontorestack), [spice.branchPrompt.sort](/cli/config.md#spicebranchpromptsort) ### git-spice branch diff {#gs-branch-diff} @@ -1055,7 +1128,7 @@ only if there are multiple CRs in the stack. * `--body=BODY`: Body of the change request * `--branch=NAME`: Branch to submit -**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.label](/cli/config.md#spicesubmitlabel), [spice.submit.label.addWhen](/cli/config.md#spicesubmitlabeladdwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) +**Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.web](/cli/config.md#spicesubmitweb) ### git-spice branch comment list {#gs-branch-comment-list} diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 42098f47a..5322219d9 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -22,6 +22,7 @@ | gs cp | [gs commit pick](/cli/reference.md#gs-commit-pick) | | gs csp | [gs commit split](/cli/reference.md#gs-commit-split) | | gs dse | [gs downstack edit](/cli/reference.md#gs-downstack-edit) | +| gs dsm | [gs downstack merge](/cli/reference.md#gs-downstack-merge) | | gs dsr | [gs downstack restack](/cli/reference.md#gs-downstack-restack) | | gs dss | [gs downstack submit](/cli/reference.md#gs-downstack-submit) | | gs dstr | [gs downstack track](/cli/reference.md#gs-downstack-track) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 166ac7a91..74ac03e07 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -40,6 +40,7 @@ Stack upstack (us) delete (d) Delete all branches above the current branch downstack (ds) track (tr) Track all untracked branches below a branch downstack (ds) submit (s) Submit a branch and those below it + downstack (ds) merge (m) Merge a branch and those below it downstack (ds) edit (e) Edit the order of branches below a branch downstack (ds) restack (r) Restack a branch and its downstack From 112367adf078f4a7ce05f985020b1ec790eef9c5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:55:43 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- doc/includes/cli-reference.md | 75 ++++++++++++++++++++++++++++++++-- doc/includes/cli-shorthands.md | 2 + testdata/help/gs.txt | 2 + 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index ad319b235..f99e35ecd 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -286,6 +286,48 @@ only if there are multiple CRs in the stack. **Configuration**: [spice.submit.assignees](/cli/config.md#spicesubmitassignees), [spice.submit.draft](/cli/config.md#spicesubmitdraft), [spice.submit.labels](/cli/config.md#spicesubmitlabels), [spice.submit.labels.addWhen](/cli/config.md#spicesubmitlabelsaddwhen), [spice.submit.listTemplatesTimeout](/cli/config.md#spicesubmitlisttemplatestimeout), [spice.submit.navigationComment](/cli/config.md#spicesubmitnavigationcomment), [spice.submit.navigationComment.downstack](/cli/config.md#spicesubmitnavigationcommentdownstack), [spice.submit.navigationCommentStyle.marker](/cli/config.md#spicesubmitnavigationcommentstylemarker), [spice.submit.navigationCommentSync](/cli/config.md#spicesubmitnavigationcommentsync), [spice.submit.publish](/cli/config.md#spicesubmitpublish), [spice.submit.reviewers](/cli/config.md#spicesubmitreviewers), [spice.submit.reviewers.addWhen](/cli/config.md#spicesubmitreviewersaddwhen), [spice.submit.skipRestackCheck](/cli/config.md#spicesubmitskiprestackcheck), [spice.submit.template](/cli/config.md#spicesubmittemplate), [spice.submit.updateOnly](/cli/config.md#spicesubmitupdateonly), [spice.submit.web](/cli/config.md#spicesubmitweb) +### git-spice stack merge {#gs-stack-merge} + +``` +gs stack (s) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a stack + +Merges the CRs for the current branch's stack into trunk. +Use --branch to merge a different branch's stack. + +The stack includes the selected branch, +its downstack branches down to trunk, +and every upstack branch. + +Already-merged branches are skipped automatically. +Branches must have an open Change Request to be merged. + +Before merging, the stack is checked for branches +whose base PR was already merged on the forge. +Use --no-branch-check to skip this validation. + +Before each merge, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait +before failing if checks are not ready. + +By default, a branch failure skips that branch's upstack descendants, +but independent sibling branches continue. +Use --fail-fast to stop the queue after the first branch failure. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-branch-check`: Skip stale base validation before merging. +* `--fail-fast`: Stop the merge queue after the first branch failure. +* `--branch=NAME`: Branch whose stack to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice stack restack {#gs-stack-restack} ``` @@ -615,11 +657,11 @@ when you don't want to wait for the merge to propagate. **Flags** -* `--branch=NAME`: Branch to start merging from -* `--no-wait`: Skip polling for a single branch merge to propagate. -* `--no-branch-check`: Skip stale base validation before merging. * `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. * `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--no-wait`: Skip polling for a single branch merge to propagate. +* `--no-branch-check`: Skip stale base validation before merging. +* `--branch=NAME`: Branch to start merging from **Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) @@ -1075,6 +1117,33 @@ Use --branch to target a different branch. * `--branch=NAME`: Branch to diff +### git-spice branch merge {#gs-branch-merge} + +``` +gs branch (b) merge (m) [flags] +``` + +:material-test-tube:{ title="Experimental" }[merge](/cli/experiments.md#merge) + +Merge a branch into trunk + +Merges the CR for the current branch into trunk. +Use --branch to merge a different branch. + +The branch must be based directly on trunk. +To merge a stacked branch, use 'gs downstack merge'. + +Before merging, waits for CI checks to pass. +Use --build-timeout to configure the maximum wait. + +**Flags** + +* `--method=METHOD` ([:material-wrench:{ .middle title="spice.merge.method" }](/cli/config.md#spicemergemethod)): Preferred merge method. One of 'merge', 'squash', and 'rebase'. +* `--build-timeout=30m` ([:material-wrench:{ .middle title="spice.merge.buildTimeout" }](/cli/config.md#spicemergebuildtimeout)): Max time to wait for CI checks before each merge. 0 means check once. +* `--branch=NAME`: Branch to merge + +**Configuration**: [spice.merge.buildTimeout](/cli/config.md#spicemergebuildtimeout), [spice.merge.method](/cli/config.md#spicemergemethod) + ### git-spice branch submit {#gs-branch-submit} ``` diff --git a/doc/includes/cli-shorthands.md b/doc/includes/cli-shorthands.md index 5322219d9..b3cb86a0e 100644 --- a/doc/includes/cli-shorthands.md +++ b/doc/includes/cli-shorthands.md @@ -8,6 +8,7 @@ | gs bdi | [gs branch diff](/cli/reference.md#gs-branch-diff) | | gs be | [gs branch edit](/cli/reference.md#gs-branch-edit) | | gs bfo | [gs branch fold](/cli/reference.md#gs-branch-fold) | +| gs bm | [gs branch merge](/cli/reference.md#gs-branch-merge) | | gs bon | [gs branch onto](/cli/reference.md#gs-branch-onto) | | gs br | [gs branch restack](/cli/reference.md#gs-branch-restack) | | gs brn | [gs branch rename](/cli/reference.md#gs-branch-rename) | @@ -35,6 +36,7 @@ | gs rs | [gs repo sync](/cli/reference.md#gs-repo-sync) | | gs sd | [gs stack delete](/cli/reference.md#gs-stack-delete) | | gs se | [gs stack edit](/cli/reference.md#gs-stack-edit) | +| gs sm | [gs stack merge](/cli/reference.md#gs-stack-merge) | | gs sr | [gs stack restack](/cli/reference.md#gs-stack-restack) | | gs ss | [gs stack submit](/cli/reference.md#gs-stack-submit) | | gs usd | [gs upstack delete](/cli/reference.md#gs-upstack-delete) | diff --git a/testdata/help/gs.txt b/testdata/help/gs.txt index 74ac03e07..68df72df1 100644 --- a/testdata/help/gs.txt +++ b/testdata/help/gs.txt @@ -31,6 +31,7 @@ Log Stack stack (s) submit (s) Submit a stack + stack (s) merge (m) Merge a stack stack (s) restack (r) Restack a stack stack (s) edit (e) Edit the order of branches in a stack stack (s) delete (d) Delete all branches in a stack @@ -58,6 +59,7 @@ Branch branch (b) restack (r) Restack a branch branch (b) onto (on) Move a branch onto another branch branch (b) diff (di) Show diff between a branch and its base + branch (b) merge (m) Merge a branch into trunk branch (b) submit (s) Submit a branch branch (b) comment (cmt) list (ls) List comments on a change request From 63cfc0c4675af24bf941ba88df936047c0b56399 Mon Sep 17 00:00:00 2001 From: Edmund Kohlwey Date: Thu, 14 May 2026 09:32:45 -0400 Subject: [PATCH 6/6] branch comment: add end-to-end test scripts Add test scripts covering the core comment workflows: - stage and submit staged comments as a review - add an inline comment immediately - list comments (text and `--json` output) - resolve a review thread - edit a staged comment Also fixes a nil pointer dereference when no staged comments exist for a branch, and includes thread IDs in list output for use with resolve/unresolve commands. --- testdata/script/branch_comment_add.txt | 37 +++++++++++ .../script/branch_comment_edit_staged.txt | 43 +++++++++++++ testdata/script/branch_comment_list.txt | 46 ++++++++++++++ testdata/script/branch_comment_list_json.txt | 63 +++++++++++++++++++ testdata/script/branch_comment_resolve.txt | 39 ++++++++++++ .../script/branch_comment_stage_submit.txt | 58 +++++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 testdata/script/branch_comment_add.txt create mode 100644 testdata/script/branch_comment_edit_staged.txt create mode 100644 testdata/script/branch_comment_list.txt create mode 100644 testdata/script/branch_comment_list_json.txt create mode 100644 testdata/script/branch_comment_resolve.txt create mode 100644 testdata/script/branch_comment_stage_submit.txt diff --git a/testdata/script/branch_comment_add.txt b/testdata/script/branch_comment_add.txt new file mode 100644 index 000000000..e07135fb7 --- /dev/null +++ b/testdata/script/branch_comment_add.txt @@ -0,0 +1,37 @@ +# Post an inline comment immediately with 'branch comment add'. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add handler.go +gs bc -m 'Add handler' feature1 +gs branch submit --fill +stderr 'Created #' + +# post an inline comment immediately +gs branch comment add handler.go:5 -m 'This needs a context parameter.' +stderr 'Posted comment' + +-- repo/handler.go -- +package main + +import "fmt" + +func handle() { + fmt.Println("handling") +} diff --git a/testdata/script/branch_comment_edit_staged.txt b/testdata/script/branch_comment_edit_staged.txt new file mode 100644 index 000000000..9b9b85928 --- /dev/null +++ b/testdata/script/branch_comment_edit_staged.txt @@ -0,0 +1,43 @@ +# Edit a staged comment before submission. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add main.go +gs bc -m 'Add main' feature1 +gs branch submit --fill +stderr 'Created #' + +# stage a comment +gs branch comment stage main.go:3 -m 'Original comment.' +stderr 'Staged comment sc-1' + +# edit the staged comment with -m +gs branch comment edit sc-1 -m 'Updated comment.' +stderr 'Updated staged comment sc-1' + +# verify the edit took effect by listing staged +gs branch comment list --staged +stderr 'Updated comment' + +-- repo/main.go -- +package main + +func main() { + println("hello") +} diff --git a/testdata/script/branch_comment_list.txt b/testdata/script/branch_comment_list.txt new file mode 100644 index 000000000..047c6ee81 --- /dev/null +++ b/testdata/script/branch_comment_list.txt @@ -0,0 +1,46 @@ +# List comments on a change request. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add main.go +gs bc -m 'Add main' feature1 +gs branch submit --fill +stderr 'Created #' + +# add an inline comment so there is something to list +gs branch comment add main.go:3 -m 'Consider using a constant.' +stderr 'Posted comment' + +# list should show the comment +gs branch comment list +stderr 'Comments:' +stderr 'main.go:3' +stderr 'Consider using a constant' + +# list on a branch with no CR should say so +gs bc -m 'Another branch' feature2 +gs branch comment list +stderr 'No change request' + +-- repo/main.go -- +package main + +func main() { + println("hello") +} diff --git a/testdata/script/branch_comment_list_json.txt b/testdata/script/branch_comment_list_json.txt new file mode 100644 index 000000000..aa894d0e6 --- /dev/null +++ b/testdata/script/branch_comment_list_json.txt @@ -0,0 +1,63 @@ +# List comments on a change request with --json output. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add main.go +gs bc -m 'Add main' feature1 +gs branch submit --fill +stderr 'Created #' + +# add an inline comment so there is something to list +gs branch comment add main.go:3 -m 'Consider using a constant here because it would make the code much more maintainable and readable.' +stderr 'Posted comment' + +# --json should output NDJSON to stdout with forge comment +gs branch comment list --json +stdout '"kind":"forge"' +stdout '"body":"Consider using a constant here because it would make the code much more maintainable and readable."' +stdout '"path":"main.go"' +stdout '"line":3' +stdout '"status":"open"' + +# text mode shows the full body without truncation +gs branch comment list +stderr 'Consider using a constant here because it would make the code much more maintainable and readable.' + +# stage a comment +gs branch comment stage main.go:5 -m 'Add error handling here.' +stderr 'Staged comment' + +# --staged --json should include only staged comments +gs branch comment list --staged --json +cmpenvJSON stdout $WORK/golden/staged_only.json + +# --json without --staged includes both staged and forge +gs branch comment list --json +stdout '"kind":"staged"' +stdout '"kind":"forge"' + +-- repo/main.go -- +package main + +func main() { + println("hello") +} + +-- golden/staged_only.json -- +{"kind":"staged","id":"sc-1","path":"main.go","line":5,"body":"Add error handling here."} diff --git a/testdata/script/branch_comment_resolve.txt b/testdata/script/branch_comment_resolve.txt new file mode 100644 index 000000000..a0e5dbae4 --- /dev/null +++ b/testdata/script/branch_comment_resolve.txt @@ -0,0 +1,39 @@ +# Resolve and unresolve a review thread. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add util.go +gs bc -m 'Add util' feature1 +gs branch submit --fill +stderr 'Created #' + +# post an inline comment to create a thread +gs branch comment add util.go:3 -m 'Rename this function.' +stderr 'Posted comment' + +# get the thread ID from the list output +gs branch comment list +stderr 'thread-' + +-- repo/util.go -- +package main + +func doStuff() { + // stuff +} diff --git a/testdata/script/branch_comment_stage_submit.txt b/testdata/script/branch_comment_stage_submit.txt new file mode 100644 index 000000000..9b91ac240 --- /dev/null +++ b/testdata/script/branch_comment_stage_submit.txt @@ -0,0 +1,58 @@ +# Stage inline comments and submit them as a review. + +as 'Test ' +at '2024-04-05T16:40:32Z' + +# setup +cd repo +git init +git commit --allow-empty -m 'Initial commit' + +# set up a fake GitHub remote +shamhub init +shamhub new origin alice/example.git +shamhub register alice +git push origin main + +env SHAMHUB_USERNAME=alice +gs auth login + +# create a branch with a file and submit it +git add feature.go +gs bc -m 'Add feature' feature1 +gs branch submit --fill +stderr 'Created #' + +# stage a comment on the file +gs branch comment stage feature.go:3 -m 'Consider renaming this function.' +stderr 'Staged comment sc-1 on feature.go:3' + +# stage another comment +gs branch comment stage feature.go:7 -m 'Add error handling here.' +stderr 'Staged comment sc-2 on feature.go:7' + +# list staged comments +gs branch comment list --staged +stderr 'sc-1' +stderr 'feature.go:3' +stderr 'sc-2' +stderr 'feature.go:7' + +# submit staged comments as a review +gs branch comment submit-staged +stderr 'Submitted 2 comment' + +# staged comments should be cleared after submit +gs branch comment list --staged +stderr 'No staged comments' + +-- repo/feature.go -- +package main + +func doWork() { + // does some work +} + +func handleRequest() { + // handles a request +}