diff --git a/apps/backend/internal/agent/handlers/git_handlers.go b/apps/backend/internal/agent/handlers/git_handlers.go index 1962f4818f..7ddf0a685f 100644 --- a/apps/backend/internal/agent/handlers/git_handlers.go +++ b/apps/backend/internal/agent/handlers/git_handlers.go @@ -151,6 +151,8 @@ func (h *GitHandlers) notifyGitOperationFailed(sessionID, operation string, resu func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorktreePull, h.wsPull) d.RegisterFunc(ws.ActionWorktreePush, h.wsPush) + d.RegisterFunc(ws.ActionWorktreeReplaceContribution, h.wsReplaceContribution) + d.RegisterFunc(ws.ActionWorktreeUseContribution, h.wsUseContribution) d.RegisterFunc(ws.ActionWorktreeRebase, h.wsRebase) d.RegisterFunc(ws.ActionWorktreeMerge, h.wsMerge) d.RegisterFunc(ws.ActionWorktreeAbort, h.wsAbort) @@ -184,6 +186,14 @@ type GitPushRequest struct { Repo string `json:"repo,omitempty"` } +// GitContributionRequest carries the provider-head lease for a managed +// contribution operation. Repo scopes one destructive action to one checkout. +type GitContributionRequest struct { + SessionID string `json:"session_id"` + ExpectedRemoteHead string `json:"expected_remote_head"` + Repo string `json:"repo,omitempty"` +} + // GitRebaseRequest for worktree.rebase action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitRebaseRequest struct { @@ -334,6 +344,42 @@ func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, return ws.NewResponse(msg.ID, msg.Action, result) } +func (h *GitHandlers) wsReplaceContribution(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.wsContribution(ctx, msg, "replace_remote_contribution", func(agentClient *client.Client, expectedHead, repo string) (*client.GitOperationResult, error) { + return agentClient.GitReplaceRemoteContribution(ctx, expectedHead, repo) + }) +} + +func (h *GitHandlers) wsUseContribution(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return h.wsContribution(ctx, msg, "use_remote_contribution", func(agentClient *client.Client, expectedHead, repo string) (*client.GitOperationResult, error) { + return agentClient.GitUseRemoteContribution(ctx, expectedHead, repo) + }) +} + +func (h *GitHandlers) wsContribution(ctx context.Context, msg *ws.Message, operation string, action func(*client.Client, string, string) (*client.GitOperationResult, error)) (*ws.Message, error) { + var req GitContributionRequest + if err := msg.ParsePayload(&req); err != nil { + return nil, fmt.Errorf("invalid payload: %w", err) + } + if req.SessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if req.ExpectedRemoteHead == "" { + return nil, fmt.Errorf("expected_remote_head is required") + } + + agentClient, err := h.getAgentCtlClient(ctx, req.SessionID) + if err != nil { + return nil, err + } + result, err := action(agentClient, req.ExpectedRemoteHead, req.Repo) + if err != nil { + return nil, fmt.Errorf("%s failed: %w", operation, err) + } + h.notifyGitOperationFailed(req.SessionID, operation, result) + return ws.NewResponse(msg.ID, msg.Action, result) +} + // wsRebase handles worktree.rebase action func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitRebaseRequest diff --git a/apps/backend/internal/agent/handlers/git_handlers_test.go b/apps/backend/internal/agent/handlers/git_handlers_test.go index ee1b01c74b..1fce949b58 100644 --- a/apps/backend/internal/agent/handlers/git_handlers_test.go +++ b/apps/backend/internal/agent/handlers/git_handlers_test.go @@ -121,6 +121,8 @@ func TestGitMutationHandlersForwardRequestsAndReturnResults(t *testing.T) { }{ {name: "pull", path: "/api/v1/git/pull", request: GitPullRequest{SessionID: "s", Rebase: true, Repo: "repo"}, invoke: (*GitHandlers).wsPull, assertBody: expectGitBody("rebase", true)}, {name: "push", path: "/api/v1/git/push", request: GitPushRequest{SessionID: "s", Force: true, SetUpstream: true, Repo: "repo"}, invoke: (*GitHandlers).wsPush, assertBody: expectGitBody("force", true)}, + {name: "replace contribution", path: "/api/v1/git/contribution/replace", request: GitContributionRequest{SessionID: "s", ExpectedRemoteHead: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Repo: "repo"}, invoke: (*GitHandlers).wsReplaceContribution, assertBody: expectGitBody("expected_remote_head", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + {name: "use contribution", path: "/api/v1/git/contribution/use", request: GitContributionRequest{SessionID: "s", ExpectedRemoteHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Repo: "repo"}, invoke: (*GitHandlers).wsUseContribution, assertBody: expectGitBody("expected_remote_head", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")}, {name: "commit", path: "/api/v1/git/commit", request: GitCommitRequest{SessionID: "s", Message: "message", StageAll: true, Repo: "repo"}, invoke: (*GitHandlers).wsCommit, assertBody: expectGitBody("message", "message")}, {name: "rebase", path: "/api/v1/git/rebase", request: GitRebaseRequest{SessionID: "s", BaseBranch: "main", Repo: "repo"}, invoke: (*GitHandlers).wsRebase, assertBody: expectGitBody("base_branch", "main")}, {name: "merge", path: "/api/v1/git/merge", request: GitMergeRequest{SessionID: "s", BaseBranch: "main", Repo: "repo"}, invoke: (*GitHandlers).wsMerge, assertBody: expectGitBody("base_branch", "main")}, @@ -167,6 +169,37 @@ func TestGitMutationHandlersForwardRequestsAndReturnResults(t *testing.T) { } } +func TestGitUseContributionReturnsRecoveryBranch(t *testing.T) { + h, server := gitHandlerServer(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{ + "success":true, + "operation":"use_remote_contribution", + "recovery_branch":"kandev/recovery-123" + }`)) + }) + defer server.Close() + + msg, err := ws.NewRequest("id", "action", GitContributionRequest{ + SessionID: "s", + ExpectedRemoteHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + Repo: "repo", + }) + if err != nil { + t.Fatal(err) + } + response, err := h.wsUseContribution(context.Background(), msg) + if err != nil { + t.Fatalf("handler: %v", err) + } + var result client.GitOperationResult + if err := json.Unmarshal(response.Payload, &result); err != nil { + t.Fatalf("decode response: %v", err) + } + if result.RecoveryBranch != "kandev/recovery-123" { + t.Errorf("RecoveryBranch = %q, want %q", result.RecoveryBranch, "kandev/recovery-123") + } +} + func TestGitRenameBranchCallsCallbackOnlyOnSuccess(t *testing.T) { for _, success := range []bool{false, true} { t.Run(fmt.Sprintf("success_%t", success), func(t *testing.T) { @@ -297,6 +330,10 @@ func TestGitMutationHandlersRejectInvalidRequestsBeforeLookup(t *testing.T) { {name: "discard malformed", action: ws.ActionWorktreeDiscard, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsDiscard, want: "invalid payload"}, {name: "diff malformed", action: ws.ActionSessionCommitDiff, body: json.RawMessage(`{invalid`), invoke: (*GitHandlers).wsCommitDiff, want: "invalid payload"}, {name: "push session", action: ws.ActionWorktreePush, body: GitPushRequest{}, invoke: (*GitHandlers).wsPush, want: "session_id is required"}, + {name: "replace contribution session", action: ws.ActionWorktreeReplaceContribution, body: GitContributionRequest{}, invoke: (*GitHandlers).wsReplaceContribution, want: "session_id is required"}, + {name: "use contribution session", action: ws.ActionWorktreeUseContribution, body: GitContributionRequest{}, invoke: (*GitHandlers).wsUseContribution, want: "session_id is required"}, + {name: "replace contribution expected head", action: ws.ActionWorktreeReplaceContribution, body: GitContributionRequest{SessionID: "s"}, invoke: (*GitHandlers).wsReplaceContribution, want: "expected_remote_head is required"}, + {name: "use contribution expected head", action: ws.ActionWorktreeUseContribution, body: GitContributionRequest{SessionID: "s"}, invoke: (*GitHandlers).wsUseContribution, want: "expected_remote_head is required"}, {name: "rebase base", action: ws.ActionWorktreeRebase, body: GitRebaseRequest{SessionID: "s"}, invoke: (*GitHandlers).wsRebase, want: "base_branch is required"}, {name: "merge base", action: ws.ActionWorktreeMerge, body: GitMergeRequest{SessionID: "s"}, invoke: (*GitHandlers).wsMerge, want: "base_branch is required"}, {name: "abort operation", action: ws.ActionWorktreeAbort, body: GitAbortRequest{SessionID: "s", Operation: "cherry-pick"}, invoke: (*GitHandlers).wsAbort, want: "operation must be"}, diff --git a/apps/backend/internal/agent/runtime/agentctl/git.go b/apps/backend/internal/agent/runtime/agentctl/git.go index c3ee913aad..7ce25e68e6 100644 --- a/apps/backend/internal/agent/runtime/agentctl/git.go +++ b/apps/backend/internal/agent/runtime/agentctl/git.go @@ -12,11 +12,13 @@ import ( // GitOperationResult represents the result of a git operation. // This matches the server-side process.GitOperationResult. type GitOperationResult struct { - Success bool `json:"success"` - Operation string `json:"operation"` - Output string `json:"output"` - Error string `json:"error,omitempty"` - ConflictFiles []string `json:"conflict_files,omitempty"` + Success bool `json:"success"` + Operation string `json:"operation"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ConflictFiles []string `json:"conflict_files,omitempty"` + RecoveryBranch string `json:"recovery_branch,omitempty"` } // PRCreateResult represents the result of a PR creation operation. @@ -70,6 +72,32 @@ func (c *Client) GitPushPreflight(ctx context.Context, repo string) (*GitOperati return c.gitOperation(ctx, "/api/v1/git/push-preflight", payload) } +// GitReplaceRemoteContribution replaces the bound contribution branch when +// its provider head still matches expectedRemoteHead. +func (c *Client) GitReplaceRemoteContribution(ctx context.Context, expectedRemoteHead, repo string) (*GitOperationResult, error) { + payload := struct { + ExpectedRemoteHead string `json:"expected_remote_head"` + Repo string `json:"repo,omitempty"` + }{ + ExpectedRemoteHead: expectedRemoteHead, + Repo: repo, + } + return c.gitOperation(ctx, "/api/v1/git/contribution/replace", payload) +} + +// GitUseRemoteContribution adopts the bound contribution head after creating +// a local recovery branch at the current task HEAD. +func (c *Client) GitUseRemoteContribution(ctx context.Context, expectedRemoteHead, repo string) (*GitOperationResult, error) { + payload := struct { + ExpectedRemoteHead string `json:"expected_remote_head"` + Repo string `json:"repo,omitempty"` + }{ + ExpectedRemoteHead: expectedRemoteHead, + Repo: repo, + } + return c.gitOperation(ctx, "/api/v1/git/contribution/use", payload) +} + // GitRebase rebases the worktree branch onto the specified base branch. // It first fetches origin/, then rebases onto it. // repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. diff --git a/apps/backend/internal/agent/runtime/agentctl/git_test.go b/apps/backend/internal/agent/runtime/agentctl/git_test.go index bbfa034441..33b1b84591 100644 --- a/apps/backend/internal/agent/runtime/agentctl/git_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/git_test.go @@ -55,6 +55,22 @@ func TestGitOperations_PostExpectedPathAndPayload(t *testing.T) { wantPath: "/api/v1/git/push-preflight", wantBody: map[string]any{"repo": "svc"}, }, + { + name: "replace remote contribution", + call: func(c *Client) (*GitOperationResult, error) { + return c.GitReplaceRemoteContribution(context.Background(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "svc") + }, + wantPath: "/api/v1/git/contribution/replace", + wantBody: map[string]any{"expected_remote_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "repo": "svc"}, + }, + { + name: "use remote contribution", + call: func(c *Client) (*GitOperationResult, error) { + return c.GitUseRemoteContribution(context.Background(), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "svc") + }, + wantPath: "/api/v1/git/contribution/use", + wantBody: map[string]any{"expected_remote_head": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "repo": "svc"}, + }, { name: "rebase onto base branch", call: func(c *Client) (*GitOperationResult, error) { @@ -198,6 +214,48 @@ func TestGitOperations_PostExpectedPathAndPayload(t *testing.T) { } } +func TestGitUseRemoteContribution_DecodesRecoveryBranch(t *testing.T) { + srv, _ := captureServer(t, jsonResponder(http.StatusOK, `{ + "success":true, + "operation":"use_remote_contribution", + "output":"HEAD is now at provider", + "recovery_branch":"kandev/recovery-123" + }`)) + + result, err := newHTTPOnlyClient(srv.URL).GitUseRemoteContribution( + context.Background(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "svc", + ) + if err != nil { + t.Fatalf("GitUseRemoteContribution: %v", err) + } + if result.RecoveryBranch != "kandev/recovery-123" { + t.Errorf("RecoveryBranch = %q, want %q", result.RecoveryBranch, "kandev/recovery-123") + } +} + +func TestGitOperation_DecodesContributionErrorCode(t *testing.T) { + srv, _ := captureServer(t, jsonResponder(http.StatusOK, `{ + "success":false, + "operation":"replace_remote_contribution", + "error":"remote contribution head changed", + "error_code":"lease_mismatch" + }`)) + + result, err := newHTTPOnlyClient(srv.URL).GitReplaceRemoteContribution( + context.Background(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "svc", + ) + if err != nil { + t.Fatalf("GitReplaceRemoteContribution: %v", err) + } + if result.ErrorCode != "lease_mismatch" { + t.Errorf("ErrorCode = %q, want %q", result.ErrorCode, "lease_mismatch") + } +} + // jsonEqual compares two values decoded from (or destined for) JSON. func jsonEqual(a, b any) bool { ab, err := json.Marshal(a) diff --git a/apps/backend/internal/agentctl/server/api/git.go b/apps/backend/internal/agentctl/server/api/git.go index 269b8ad7fb..9cb7bf6838 100644 --- a/apps/backend/internal/agentctl/server/api/git.go +++ b/apps/backend/internal/agentctl/server/api/git.go @@ -64,6 +64,13 @@ type GitPushPreflightRequest struct { Repo string `json:"repo,omitempty"` } +// GitContributionRequest is shared by the managed contribution replacement +// and provider-adoption endpoints. +type GitContributionRequest struct { + ExpectedRemoteHead string `json:"expected_remote_head"` + Repo string `json:"repo,omitempty"` +} + // GitRebaseRequest for POST /api/v1/git/rebase type GitRebaseRequest struct { BaseBranch string `json:"base_branch"` @@ -242,6 +249,45 @@ func (s *Server) handleGitPushPreflight(c *gin.Context) { c.JSON(http.StatusOK, result) } +func (s *Server) handleGitReplaceContribution(c *gin.Context) { + s.handleGitContribution(c, "replace_remote_contribution", func(gitOp *process.GitOperator, expected string) (*process.GitOperationResult, error) { + return gitOp.ReplaceRemoteContribution(c.Request.Context(), expected) + }) +} + +func (s *Server) handleGitUseContribution(c *gin.Context) { + s.handleGitContribution(c, "use_remote_contribution", func(gitOp *process.GitOperator, expected string) (*process.GitOperationResult, error) { + return gitOp.UseRemoteContribution(c.Request.Context(), expected) + }) +} + +func (s *Server) handleGitContribution(c *gin.Context, operation string, action func(*process.GitOperator, string) (*process.GitOperationResult, error)) { + var req GitContributionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, Operation: operation, Error: "invalid request: " + err.Error(), + }) + return + } + if req.ExpectedRemoteHead == "" { + c.JSON(http.StatusBadRequest, process.GitOperationResult{ + Success: false, Operation: operation, Error: "expected_remote_head is required", + }) + return + } + + gitOp := s.gitOpForRepo(c, operation, req.Repo) + if gitOp == nil { + return + } + result, err := action(gitOp, req.ExpectedRemoteHead) + if err != nil { + s.handleGitError(c, operation, err) + return + } + c.JSON(http.StatusOK, result) +} + // handleGitRebase handles POST /api/v1/git/rebase func (s *Server) handleGitRebase(c *gin.Context) { var req GitRebaseRequest diff --git a/apps/backend/internal/agentctl/server/api/git_handlers_test.go b/apps/backend/internal/agentctl/server/api/git_handlers_test.go index 91aa61e1c6..eebdcce994 100644 --- a/apps/backend/internal/agentctl/server/api/git_handlers_test.go +++ b/apps/backend/internal/agentctl/server/api/git_handlers_test.go @@ -130,6 +130,8 @@ func TestGitHandlers_RejectMalformedBody(t *testing.T) { {"/api/v1/git/pull", "pull"}, {"/api/v1/git/push", "push"}, {"/api/v1/git/push-preflight", "push_preflight"}, + {"/api/v1/git/contribution/replace", "replace_remote_contribution"}, + {"/api/v1/git/contribution/use", "use_remote_contribution"}, {"/api/v1/git/rebase", "rebase"}, {"/api/v1/git/merge", "merge"}, {"/api/v1/git/abort", "abort"}, @@ -200,6 +202,8 @@ func TestGitHandlers_RejectMissingRequiredFields(t *testing.T) { "commit_sha is required", }, {"reset without sha", "/api/v1/git/reset", GitResetRequest{}, "reset", "commit_sha is required"}, + {"replace without expected head", "/api/v1/git/contribution/replace", GitContributionRequest{}, "replace_remote_contribution", "expected_remote_head is required"}, + {"use without expected head", "/api/v1/git/contribution/use", GitContributionRequest{}, "use_remote_contribution", "expected_remote_head is required"}, { "reset with unknown mode", "/api/v1/git/reset", diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index a236064bbb..0269073fe6 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -184,6 +184,8 @@ func (s *Server) setupRoutes() { api.POST("/git/pull", s.handleGitPull) api.POST("/git/push", s.handleGitPush) api.POST("/git/push-preflight", s.handleGitPushPreflight) + api.POST("/git/contribution/replace", s.handleGitReplaceContribution) + api.POST("/git/contribution/use", s.handleGitUseContribution) api.POST("/git/rebase", s.handleGitRebase) api.POST("/git/merge", s.handleGitMerge) api.POST("/git/abort", s.handleGitAbort) diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index b320c521af..ca2531e116 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -28,11 +28,13 @@ var ErrInvalidBranchName = errors.New("invalid branch name") // GitOperationResult represents the result of a git operation. type GitOperationResult struct { - Success bool `json:"success"` - Operation string `json:"operation"` - Output string `json:"output"` - Error string `json:"error,omitempty"` - ConflictFiles []string `json:"conflict_files,omitempty"` + Success bool `json:"success"` + Operation string `json:"operation"` + Output string `json:"output"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ConflictFiles []string `json:"conflict_files,omitempty"` + RecoveryBranch string `json:"recovery_branch,omitempty"` } // GitOperator executes git operations in a workspace directory. @@ -158,6 +160,13 @@ func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string continue } + if strings.HasPrefix(arg, contributionLeaseFlagPrefix) { + if err := validateContributionLeaseFlag(arg); err != nil { + return "", err + } + continue + } + // Validate flags against whitelist if strings.HasPrefix(arg, "-") { if !securityutil.IsKnownSafeGitFlag(arg) { diff --git a/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution.go b/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution.go new file mode 100644 index 0000000000..3e2f107ef1 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution.go @@ -0,0 +1,221 @@ +package process + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/kandev/kandev/internal/common/securityutil" + "go.uber.org/zap" +) + +const ( + contributionLeaseFlagPrefix = "--force-with-lease=refs/heads/" + contributionErrorLeaseMismatch = "lease_mismatch" + contributionErrorDirtyWorktree = "dirty_worktree" + contributionErrorRemoteFailure = "remote_unavailable" +) + +func validateContributionLeaseFlag(arg string) error { + lease := strings.TrimPrefix(arg, contributionLeaseFlagPrefix) + branch, expectedHead, ok := strings.Cut(lease, ":") + if !ok || strings.Contains(expectedHead, ":") || !securityutil.IsValidBranchName(branch) || !isFullCommitSHA(expectedHead) { + return fmt.Errorf("potentially unsafe flag: %s", arg) + } + return nil +} + +func isFullCommitSHA(sha string) bool { + if len(sha) != 40 && len(sha) != 64 { + return false + } + for _, r := range sha { + if !isHexCharacter(r) { + return false + } + } + return true +} + +func isHexCharacter(r rune) bool { + return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') +} + +func (g *GitOperator) validateContributionResolution(ctx context.Context, expectedRemoteHead string) error { + if g.remoteContributionErr != nil { + return g.remoteContributionErr + } + if g.remoteContribution == nil { + return errors.New("remote contribution binding is required") + } + if !isFullCommitSHA(expectedRemoteHead) { + return errors.New("expected remote head must be a full commit ID") + } + return g.validateContributionRemote(ctx) +} + +// ReplaceRemoteContribution publishes the local task HEAD to the bound +// contribution branch only when the provider head still matches the expected +// full commit ID. +func (g *GitOperator) ReplaceRemoteContribution(ctx context.Context, expectedRemoteHead string) (*GitOperationResult, error) { + if !g.tryLock("replace_remote_contribution") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{Operation: "replace_remote_contribution"} + if err := g.validateContributionResolution(ctx, expectedRemoteHead); err != nil { + result.Error = err.Error() + return result, nil + } + + binding := g.remoteContribution + lease := contributionLeaseFlagPrefix + binding.HeadBranch + ":" + expectedRemoteHead + refspec := "HEAD:refs/heads/" + binding.HeadBranch + output, err := g.runGitCommand(ctx, "push", lease, binding.ContributionRemoteName(), refspec) + result.Output = output + if err != nil { + result.Error = err.Error() + result.ErrorCode = contributionErrorCode(result.Error) + return result, nil + } + + result.Success = true + g.logger.Info("remote contribution replaced", + zap.String("branch", binding.HeadBranch), + zap.String("remote", binding.ContributionRemoteName())) + return result, nil +} + +// UseRemoteContribution adopts the confirmed provider head after preserving +// the current task HEAD on a local recovery branch. +func (g *GitOperator) UseRemoteContribution(ctx context.Context, expectedRemoteHead string) (*GitOperationResult, error) { + if !g.tryLock("use_remote_contribution") { + return nil, ErrOperationInProgress + } + defer g.unlock() + + result := &GitOperationResult{Operation: "use_remote_contribution"} + if err := g.validateContributionResolution(ctx, expectedRemoteHead); err != nil { + result.Error = err.Error() + return result, nil + } + + if g.rejectDirtyContributionWorktree(ctx, result) { + return result, nil + } + + binding := g.remoteContribution + remote := binding.ContributionRemoteName() + fetchOutput, err := g.runGitCommand(ctx, "fetch", remote, binding.HeadBranch) + result.Output = fetchOutput + if err != nil { + setContributionError(result, contributionErrorRemoteFailure, fmt.Errorf("failed to fetch remote contribution: %w", err).Error()) + return result, nil + } + + fetchedHead, err := g.runGitCommand(ctx, "rev-parse", remote+"/"+binding.HeadBranch) + if err != nil { + setContributionError(result, contributionErrorRemoteFailure, fmt.Errorf("failed to resolve fetched remote contribution: %w", err).Error()) + return result, nil + } + fetchedHead = strings.TrimSpace(fetchedHead) + if fetchedHead != expectedRemoteHead { + setContributionError(result, contributionErrorLeaseMismatch, fmt.Sprintf("remote contribution head changed before adoption: expected %s, fetched %s", expectedRemoteHead, fetchedHead)) + return result, nil + } + + currentHead, err := g.runGitCommand(ctx, "rev-parse", "HEAD") + if err != nil { + result.Error = fmt.Errorf("failed to resolve current task HEAD: %w", err).Error() + return result, nil + } + currentHead = strings.TrimSpace(currentHead) + recoveryBranch, branchOutput, err := g.createRecoveryBranch(ctx, currentHead) + if err != nil { + result.Output = strings.TrimSpace(strings.Join([]string{result.Output, branchOutput}, "\n")) + result.Error = err.Error() + return result, nil + } + result.RecoveryBranch = recoveryBranch + result.Output = strings.TrimSpace(strings.Join([]string{result.Output, branchOutput}, "\n")) + + // Fetching the provider ref and creating the recovery branch can give + // another workspace writer time to modify the worktree. Check again + // immediately before the destructive reset so those changes are preserved. + if g.rejectDirtyContributionWorktree(ctx, result) { + return result, nil + } + + resetOutput, err := g.runGitCommand(ctx, "reset", "--hard", fetchedHead) + result.Output = strings.TrimSpace(strings.Join([]string{result.Output, resetOutput}, "\n")) + if err != nil { + result.Error = fmt.Errorf("failed to use remote contribution: %w", err).Error() + return result, nil + } + + result.Success = true + g.logger.Info("remote contribution adopted", + zap.String("branch", binding.HeadBranch), + zap.String("recovery_branch", recoveryBranch)) + return result, nil +} + +func (g *GitOperator) rejectDirtyContributionWorktree(ctx context.Context, result *GitOperationResult) bool { + dirty, err := g.hasUncommittedChanges(ctx) + if err != nil { + result.Error = err.Error() + return true + } + if !dirty { + return false + } + setContributionError(result, contributionErrorDirtyWorktree, "working tree must be clean before using remote contribution") + return true +} + +func setContributionError(result *GitOperationResult, code, message string) { + result.Error = message + result.ErrorCode = code +} + +func contributionErrorCode(message string) string { + normalized := strings.ToLower(message) + if strings.Contains(normalized, "stale info") || + strings.Contains(normalized, "head changed") || + strings.Contains(normalized, "non-fast-forward") { + return contributionErrorLeaseMismatch + } + if strings.Contains(normalized, "could not resolve host") || + strings.Contains(normalized, "unable to access") || + strings.Contains(normalized, "authentication failed") || + strings.Contains(normalized, "repository not found") { + return contributionErrorRemoteFailure + } + return "" +} + +func (g *GitOperator) createRecoveryBranch(ctx context.Context, currentHead string) (string, string, error) { + if !isFullCommitSHA(currentHead) { + return "", "", errors.New("current task HEAD is not a full commit ID") + } + + baseName := fmt.Sprintf("kandev/recovery-%d-%s", time.Now().UTC().UnixNano(), currentHead[:12]) + for attempt := 0; attempt < 10; attempt++ { + name := baseName + if attempt > 0 { + name = fmt.Sprintf("%s-%d", baseName, attempt) + } + if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", "refs/heads/"+name); err == nil { + continue + } + output, err := g.runGitCommand(ctx, "branch", name, currentHead) + if err != nil { + return "", output, fmt.Errorf("failed to create recovery branch: %w", err) + } + return name, output, nil + } + return "", "", errors.New("failed to create a unique recovery branch") +} diff --git a/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution_test.go b/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution_test.go new file mode 100644 index 0000000000..a96b8eaf49 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution_test.go @@ -0,0 +1,253 @@ +package process + +import ( + "context" + "os" + osExec "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +func TestGitOperatorSupportsExactContributionLeaseFlag(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + + head := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + runGit(t, repoDir, "push", "origin", "HEAD:refs/heads/feature/contribution") + + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + _, err := operator.runGitCommand( + context.Background(), + "push", + "--force-with-lease=refs/heads/feature/contribution:"+head, + "origin", + "HEAD:refs/heads/feature/contribution", + ) + if err != nil { + t.Fatalf("exact contribution lease push failed: %v", err) + } +} + +func TestGitOperatorRejectsMalformedContributionLeaseFlag(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + _, err := operator.runGitCommand( + context.Background(), + "push", + "--force-with-lease=refs/heads/feature/contribution:not-a-commit", + "origin", + "HEAD:refs/heads/feature/contribution", + ) + if err == nil || !strings.Contains(err.Error(), "potentially unsafe flag") { + t.Fatalf("malformed contribution lease error = %v, want an unsafe-flag error", err) + } +} + +func TestGitOperatorReplaceRemoteContributionUsesExactHeadLease(t *testing.T) { + repoDir, originDir, _, binding, providerOne, providerTwo, localHead := setupDivergedContributionRepo(t) + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + operator.setRemoteContribution(binding) + + stale, err := operator.ReplaceRemoteContribution(context.Background(), providerOne) + if err != nil { + t.Fatalf("stale ReplaceRemoteContribution returned error: %v", err) + } + if stale.Success { + t.Fatalf("stale replacement = %+v, want failure", stale) + } + if stale.ErrorCode != contributionErrorLeaseMismatch { + t.Fatalf("stale replacement error code = %q, want %q", stale.ErrorCode, contributionErrorLeaseMismatch) + } + if got := strings.TrimSpace(runGit(t, originDir, "rev-parse", "refs/heads/feature/contribution")); got != providerTwo { + t.Fatalf("remote head after stale replacement = %q, want %q", got, providerTwo) + } + + current, err := operator.ReplaceRemoteContribution(context.Background(), providerTwo) + if err != nil { + t.Fatalf("ReplaceRemoteContribution returned error: %v", err) + } + if !current.Success { + t.Fatalf("replacement = %+v, want success", current) + } + if got := strings.TrimSpace(runGit(t, originDir, "rev-parse", "refs/heads/feature/contribution")); got != localHead { + t.Fatalf("remote head after replacement = %q, want local %q", got, localHead) + } +} + +func TestGitOperatorUseRemoteContributionCreatesRecoveryAndRetainsUpstream(t *testing.T) { + repoDir, _, remoteName, binding, _, providerTwo, localHead := setupDivergedContributionRepo(t) + runGit(t, repoDir, "fetch", remoteName, "feature/contribution") + runGit(t, repoDir, "branch", "--set-upstream-to="+remoteName+"/feature/contribution", "feature/contribution") + + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + operator.setRemoteContribution(binding) + result, err := operator.UseRemoteContribution(context.Background(), providerTwo) + if err != nil { + t.Fatalf("UseRemoteContribution returned error: %v", err) + } + if !result.Success || result.RecoveryBranch == "" { + t.Fatalf("UseRemoteContribution = %+v, want success with recovery branch", result) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "refs/heads/"+result.RecoveryBranch)); got != localHead { + t.Fatalf("recovery branch head = %q, want old local head %q", got, localHead) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")); got != providerTwo { + t.Fatalf("task HEAD = %q, want provider head %q", got, providerTwo) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")); got != remoteName+"/feature/contribution" { + t.Fatalf("upstream = %q, want %q", got, remoteName+"/feature/contribution") + } +} + +func TestGitOperatorUseRemoteContributionRejectsStaleFetch(t *testing.T) { + repoDir, _, _, binding, providerOne, _, localHead := setupDivergedContributionRepo(t) + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + operator.setRemoteContribution(binding) + + result, err := operator.UseRemoteContribution(context.Background(), providerOne) + if err != nil { + t.Fatalf("stale UseRemoteContribution returned error: %v", err) + } + if result.Success || result.RecoveryBranch != "" { + t.Fatalf("stale adoption = %+v, want no mutation", result) + } + if result.ErrorCode != contributionErrorLeaseMismatch { + t.Fatalf("stale adoption error code = %q, want %q", result.ErrorCode, contributionErrorLeaseMismatch) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")); got != localHead { + t.Fatalf("task HEAD after stale adoption = %q, want %q", got, localHead) + } +} + +func TestGitOperatorUseRemoteContributionRejectsDirtyWorktree(t *testing.T) { + for _, dirty := range []struct { + name string + stage bool + }{ + {name: "unstaged"}, + {name: "staged", stage: true}, + {name: "untracked"}, + } { + t.Run(dirty.name, func(t *testing.T) { + repoDir, _, _, binding, _, providerTwo, localHead := setupDivergedContributionRepo(t) + writeFile(t, repoDir, "dirty-"+dirty.name+".txt", dirty.name+"\n") + if dirty.stage { + runGit(t, repoDir, "add", "--", "dirty-"+dirty.name+".txt") + } + + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + operator.setRemoteContribution(binding) + result, err := operator.UseRemoteContribution(context.Background(), providerTwo) + if err != nil { + t.Fatalf("UseRemoteContribution returned error: %v", err) + } + if result.Success || result.RecoveryBranch != "" || !strings.Contains(result.Error, "clean") { + t.Fatalf("dirty adoption = %+v, want clean-tree rejection", result) + } + if result.ErrorCode != contributionErrorDirtyWorktree { + t.Fatalf("dirty adoption error code = %q, want %q", result.ErrorCode, contributionErrorDirtyWorktree) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")); got != localHead { + t.Fatalf("task HEAD after dirty adoption = %q, want %q", got, localHead) + } + }) + } +} + +func TestGitOperatorUseRemoteContributionRechecksWorktreeAfterFetch(t *testing.T) { + repoDir, _, _, binding, _, providerTwo, localHead := setupDivergedContributionRepo(t) + realGit, err := osExec.LookPath("git") + if err != nil { + t.Fatalf("locate git: %v", err) + } + shimDir := t.TempDir() + shimName := "git" + if runtime.GOOS == "windows" { + shimName += ".exe" + } + shimPath := filepath.Join(shimDir, shimName) + testBinary, err := os.ReadFile(os.Args[0]) + if err != nil { + t.Fatalf("read test binary: %v", err) + } + if err := os.WriteFile(shimPath, testBinary, 0o755); err != nil { + t.Fatalf("write git fixture binary: %v", err) + } + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(kandevTestFixtureEnv, "git-fetch-race") + t.Setenv(gitFetchRaceRealGitEnv, realGit) + t.Setenv(gitFetchRaceWorktreeEnv, repoDir) + + operator := NewGitOperator(repoDir, newTestLogger(t), nil) + operator.setRemoteContribution(binding) + result, err := operator.UseRemoteContribution(context.Background(), providerTwo) + if err != nil { + t.Fatalf("UseRemoteContribution returned error: %v", err) + } + if result.Success || result.RecoveryBranch == "" || result.ErrorCode != contributionErrorDirtyWorktree { + t.Fatalf("racing adoption = %+v, want final clean-tree rejection", result) + } + if got := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")); got != localHead { + t.Fatalf("task HEAD after racing adoption = %q, want %q", got, localHead) + } + if _, err := os.Stat(filepath.Join(repoDir, "race-after-fetch.txt")); err != nil { + t.Fatalf("post-fetch worktree change missing: %v", err) + } +} + +func setupDivergedContributionRepo(t *testing.T) (repoDir, originDir, remoteName string, binding *taskmodels.RemoteContribution, providerOne, providerTwo, localHead string) { + t.Helper() + repoDir, cleanup := setupTestRepo(t) + t.Cleanup(cleanup) + originDir = strings.TrimSpace(runGit(t, repoDir, "remote", "get-url", "origin")) + + runGit(t, repoDir, "checkout", "-b", "provider-history") + writeFile(t, repoDir, "provider.txt", "provider one\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "provider one") + providerOne = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + runGit(t, repoDir, "push", "origin", "HEAD:refs/heads/feature/contribution") + + writeFile(t, repoDir, "provider.txt", "provider two\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "provider two") + providerTwo = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + runGit(t, repoDir, "push", "origin", "HEAD:refs/heads/feature/contribution") + + runGit(t, repoDir, "checkout", "main") + runGit(t, repoDir, "checkout", "-b", "feature/contribution") + writeFile(t, repoDir, "local.txt", "local task version\n") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "local task version") + localHead = strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) + + sourceURL := "https://github.com/contributor/widget.git" + runGit(t, repoDir, "config", "url.file://"+originDir+".insteadOf", sourceURL) + binding = &taskmodels.RemoteContribution{ + Version: taskmodels.RemoteContributionVersion, + Provider: taskmodels.RemoteContributionProviderGitHub, + Kind: taskmodels.RemoteContributionKindPullRequest, + CanonicalURL: "https://github.com/acme/widget/pull/7", + Number: 7, + State: taskmodels.RemoteContributionStateOpen, + BaseBranch: "main", + HeadBranch: "feature/contribution", + HeadSHA: providerOne, + SourceRepository: taskmodels.RemoteContributionRepository{ + Host: "github.com", Path: "contributor/widget", RemoteURL: sourceURL, + }, + CollaborationAllowed: true, + } + if err := binding.Validate(); err != nil { + t.Fatalf("binding.Validate() = %v", err) + } + remoteName = binding.ContributionRemoteName() + runGit(t, repoDir, "remote", "add", remoteName, sourceURL) + return repoDir, originDir, remoteName, binding, providerOne, providerTwo, localHead +} diff --git a/apps/backend/internal/agentctl/server/process/testmain_test.go b/apps/backend/internal/agentctl/server/process/testmain_test.go index 3713efd4fa..b1538d994d 100644 --- a/apps/backend/internal/agentctl/server/process/testmain_test.go +++ b/apps/backend/internal/agentctl/server/process/testmain_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "runtime" "strconv" "strings" @@ -23,6 +24,8 @@ import ( // previous fixtures that hardcoded `sleep`, `cat`, `echo`, `printf` which // don't exist as standalone executables on Windows. const kandevTestFixtureEnv = "KANDEV_TEST_FIXTURE" +const gitFetchRaceRealGitEnv = "KANDEV_GIT_FETCH_RACE_REAL_GIT" +const gitFetchRaceWorktreeEnv = "KANDEV_GIT_FETCH_RACE_WORKTREE" // TestMain branches into fixture-binary mode when the activation env var // is set; otherwise it runs the test suite normally — wrapped in goleak so @@ -46,6 +49,7 @@ func TestMain(m *testing.M) { // echo-then-sleep — print msg, then sleep // delay-then-child // — wait, spawn a sleeping child, write its PID, then sleep +// git-fetch-race — delegate to Git and create a worktree change after fetch // // New commands can be added here as tests need them; the goal is to keep the // surface tiny so the helper stays inspectable. @@ -114,6 +118,8 @@ func runFixture(spec string) { os.Exit(2) } time.Sleep(time.Duration(secs) * time.Second) + case "git-fetch-race": + runGitFetchRaceFixture() case "sleep-with-child": // Forks a child copy of this fixture binary (also sleeping ), // writes the child PID to , then sleeps itself. Used by the @@ -203,6 +209,35 @@ func runFixture(spec string) { os.Exit(0) } +func runGitFetchRaceFixture() { + realGit := os.Getenv(gitFetchRaceRealGitEnv) + worktree := os.Getenv(gitFetchRaceWorktreeEnv) + if realGit == "" || worktree == "" { + fmt.Fprintln(os.Stderr, "fixture: git-fetch-race requires Git and worktree paths") + os.Exit(2) + } + cmd := exec.Command(realGit, os.Args[1:]...) + cmd.Dir = worktree + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err == nil && len(os.Args) > 1 && os.Args[1] == "fetch" { + if err := os.WriteFile(filepath.Join(worktree, "race-after-fetch.txt"), []byte("created during fetch\n"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "fixture: git-fetch-race: write worktree change: %v\n", err) + os.Exit(1) + } + } + if err == nil { + os.Exit(0) + } + if exitErr, ok := err.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + fmt.Fprintf(os.Stderr, "fixture: git-fetch-race: run Git: %v\n", err) + os.Exit(1) +} + // fixtureExec returns the (Command argv, Env) pair tests pass to runners that // take a Command []string and an Env map (e.g. InteractiveStartRequest). The // runner spawns this test binary with the env var set, TestMain dispatches diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 024af22bb9..4022a34b08 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -309,19 +309,21 @@ const ( ActionSessionProcessStatus = "session.process.status" // Git worktree actions - ActionWorktreePull = "worktree.pull" // Pull from remote - ActionWorktreePush = "worktree.push" // Push to remote - ActionWorktreeRebase = "worktree.rebase" // Rebase onto base branch - ActionWorktreeMerge = "worktree.merge" // Merge base branch into worktree - ActionWorktreeAbort = "worktree.abort" // Abort in-progress merge or rebase - ActionWorktreeCommit = "worktree.commit" // Commit changes - ActionWorktreeStage = "worktree.stage" // Stage files for commit - ActionWorktreeUnstage = "worktree.unstage" // Unstage files from index - ActionWorktreeDiscard = "worktree.discard" // Discard changes to files - ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request - ActionWorktreeRevertCommit = "worktree.revert_commit" // Revert a commit (staged, no new commit) - ActionWorktreeRenameBranch = "worktree.rename_branch" // Rename the current branch - ActionWorktreeReset = "worktree.reset" // Reset HEAD to a commit (soft/hard) + ActionWorktreePull = "worktree.pull" // Pull from remote + ActionWorktreePush = "worktree.push" // Push to remote + ActionWorktreeReplaceContribution = "worktree.replace_contribution" // Replace the bound contribution branch + ActionWorktreeUseContribution = "worktree.use_contribution" // Adopt the bound contribution version + ActionWorktreeRebase = "worktree.rebase" // Rebase onto base branch + ActionWorktreeMerge = "worktree.merge" // Merge base branch into worktree + ActionWorktreeAbort = "worktree.abort" // Abort in-progress merge or rebase + ActionWorktreeCommit = "worktree.commit" // Commit changes + ActionWorktreeStage = "worktree.stage" // Stage files for commit + ActionWorktreeUnstage = "worktree.unstage" // Unstage files from index + ActionWorktreeDiscard = "worktree.discard" // Discard changes to files + ActionWorktreeCreatePR = "worktree.create_pr" // Create a pull request + ActionWorktreeRevertCommit = "worktree.revert_commit" // Revert a commit (staged, no new commit) + ActionWorktreeRenameBranch = "worktree.rename_branch" // Rename the current branch + ActionWorktreeReset = "worktree.reset" // Reset HEAD to a commit (soft/hard) // User actions ActionUserGet = "user.get" diff --git a/apps/web/components/task/changes-panel-body.tsx b/apps/web/components/task/changes-panel-body.tsx index d97ecd3acc..f57a52107e 100644 --- a/apps/web/components/task/changes-panel-body.tsx +++ b/apps/web/components/task/changes-panel-body.tsx @@ -56,8 +56,11 @@ type TimelineProps = Pick< | "hasCommits" | "hasPRFiles" | "relation" + | "resolution" + | "resolutionTarget" | "providerCommitsLoading" | "providerCommitsError" + | "providerPRNumber" | "pushDisabled" | "pullDisabled" | "canPush" @@ -191,18 +194,6 @@ function CommitHistorySections({ if (isDiverged) { return ( <> - {separated.providerCommits.length > 0 && ( - - )} {separated.localCommits.length > 0 && ( )} + {separated.providerCommits.length > 0 && ( + + )} ); } @@ -324,16 +327,6 @@ function ChangesPanelTimeline(props: TimelineProps) { )} - {isDiverged && ( -
- {t("task:remoteContributionHistoryChanged")} -
- )} - {showCommitsList && ( state.tasks.activeTaskId); @@ -56,18 +62,6 @@ function useChangesPanelStoreData() { const baseBranch = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined, ); - const existingPrUrl = useAppStore((state) => { - const taskId = state.tasks.activeTaskId; - if (!taskId) return undefined; - // Multi-branch tasks hold N PRs per task. The panel-header "View PR" - // button is a single-URL surface, so collapse only when there's exactly - // one PR — otherwise the per-repo buttons (prByRepo) take over and the - // generic button is hidden to avoid silently linking to a sibling. - const taskPRs = state.taskPRs.byTaskId[taskId]; - if (Array.isArray(taskPRs) && taskPRs.length === 1) return taskPRs[0]?.pr_url; - if (Array.isArray(taskPRs) && taskPRs.length > 1) return undefined; - return state.pendingPrUrlByTaskId.byTaskId[taskId]?.[""]; - }); const activeSessionMetadata = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.metadata : undefined, ); @@ -80,7 +74,6 @@ function useChangesPanelStoreData() { activeSessionId, taskTitle, baseBranch, - existingPrUrl, gitCredentialDisplay, }; } @@ -95,8 +88,11 @@ export type ChangesPanelBodyProps = { hasPRFiles: boolean; hasPRCommits: boolean; relation: RemoteContributionRelation; + resolution: ReturnType; + resolutionTarget: RemoteContributionResolutionTarget | null; providerCommitsLoading: boolean; providerCommitsError: string | null; + providerPRNumber: number | undefined; pushDisabled: boolean; pullDisabled: boolean; canPush: boolean; @@ -262,10 +258,11 @@ function buildChangesPanelPRData({ } function useChangesPanelPRData(repositoryNames: string[], sessionId: string | null) { - const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const workspaceId = useAppStore((state) => state.workspaces.activeId); const relationState: RemoteContributionRelationState = useRemoteContributionRelation(sessionId); + const { filesByPRKey } = useActiveTaskPRsWithFiles(relationState.prs); + const prs = relationState.prs; const taskPR = relationState.selectedPR; const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); @@ -320,8 +317,12 @@ function useChangesPanelPRData(repositoryNames: string[], sessionId: string | nu prFiles, useRepositoryKeys, relation: relationState.relation, + prs, + repositoryName: relationState.repositoryName, + selectedPR: taskPR, providerCommitsLoading: relationState.loading, providerCommitsError: relationState.error, + refreshProviderEvidence: relationState.refreshProviderEvidence, }; } @@ -329,8 +330,28 @@ function hasCumulativeFiles(files: Record | null | undefined): return Object.keys(files ?? {}).length > 0; } +function useChangesPanelResolutionTarget( + relation: RemoteContributionRelation, + repositoryName: string | undefined, + selectedPR: TaskPR | null | undefined, + t: (key: string) => string, +) { + const remoteRepositoryLabel = t("task:remoteRepository"); + return useMemo( + () => + buildRemoteContributionResolutionTarget( + relation, + repositoryName, + selectedPR, + remoteRepositoryLabel, + ), + [relation, repositoryName, selectedPR, remoteRepositoryLabel], + ); +} + export function useChangesPanelData() { - const { activeTaskId, activeSessionId, baseBranch, existingPrUrl, gitCredentialDisplay } = + const { t } = useTranslation(); + const { activeTaskId, activeSessionId, baseBranch, gitCredentialDisplay } = useChangesPanelStoreData(); const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); const git = useSessionGit(activeSessionId); @@ -341,6 +362,16 @@ export function useChangesPanelData() { [git.repoNames, git.cumulativeDiff], ); const prData = useChangesPanelPRData(reviewRepositoryNames, activeSessionId); + const resolution = useRemoteContributionResolution( + activeSessionId, + prData.refreshProviderEvidence, + ); + const resolutionTarget = useChangesPanelResolutionTarget( + prData.relation, + prData.repositoryName, + prData.selectedPR, + t, + ); const remoteActionPolicy = useMemo( () => remoteContributionActionPolicy(prData.relation), [prData.relation], @@ -366,17 +397,15 @@ export function useChangesPanelData() { const dialogs = { ...localDialogs, ...vcsDialogs }; const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); const repoDisplayName = useRepoDisplayName(activeSessionId); - const taskPRsForMap = useAppStore((state) => - activeTaskId ? state.taskPRs.byTaskId[activeTaskId] : undefined, - ); const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); const pendingByRepo = useAppStore((state) => activeTaskId ? state.pendingPrUrlByTaskId.byTaskId[activeTaskId] : undefined, ); + const existingPrUrl = prData.selectedPR?.pr_url ?? pendingByRepo?.[""]; const prByRepo = useMemo( - () => buildPrByRepoMap(taskPRsForMap, repoNameById, pendingByRepo), - [taskPRsForMap, repoNameById, pendingByRepo], + () => buildPrByRepoMap(prData.prs, repoNameById, pendingByRepo), + [prData.prs, repoNameById, pendingByRepo], ); const walkthroughRequestReady = unstagedFiles.length > 0 || @@ -403,6 +432,8 @@ export function useChangesPanelData() { existingPrUrl, gitCredentialDisplay, walkthroughRequestReady, + resolution, + resolutionTarget, pushDisabled: remoteActionPolicy.pushDisabled, pullDisabled: remoteActionPolicy.pullDisabled, ...prData, @@ -429,8 +460,11 @@ export function buildChangesPanelBodyProps( hasPRFiles: data.hasPRFiles, hasPRCommits: data.hasPRCommits, relation: data.relation, + resolution: data.resolution, + resolutionTarget: data.resolutionTarget, providerCommitsLoading: data.providerCommitsLoading, providerCommitsError: data.providerCommitsError, + providerPRNumber: data.selectedPR?.pr_number, pushDisabled: data.pushDisabled, pullDisabled: data.pullDisabled, canPush: git.canPush, diff --git a/apps/web/components/task/changes-panel-header.test.tsx b/apps/web/components/task/changes-panel-header.test.tsx index 6e8183f067..fea4959e44 100644 --- a/apps/web/components/task/changes-panel-header.test.tsx +++ b/apps/web/components/task/changes-panel-header.test.tsx @@ -47,7 +47,7 @@ describe("PullDropdown remote safety", () => { expect(pullButton.parentElement?.getAttribute("tabindex")).toBe("0"); expect( screen.getByText( - "Push, force-push, and Pull are disabled until the histories are reconciled.", + "Current PR history is unavailable. The checkout history is shown without assuming a rewrite.", ), ).toBeTruthy(); }); diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 601110dde9..132267522f 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -33,6 +33,12 @@ import type { GitCredentialDisplay } from "./changes-git-credential-display"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import { useTranslation } from "react-i18next"; import { PerRepoPullMenu } from "./changes-panel-per-repo-menu"; +import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; +import { + type RemoteContributionResolutionTarget, + type useRemoteContributionResolution, +} from "./use-remote-contribution-resolution"; +import { RemoteContributionHeaderActions } from "./remote-contribution-header-actions"; export type PerRepoStatus = { repository_name: string; @@ -393,7 +399,7 @@ export function PullDropdown({ {pullButton} - {t("task:remoteActionsDisabledHistoryChanged")} + {t("task:providerHistoryUnavailable")} ); } @@ -494,31 +500,7 @@ function ChangesPanelWalkthroughButton({ ); } -export function ChangesPanelHeader({ - hasChanges, - hasCommits, - hasPRFiles, - displayBranch, - baseBranchDisplay, - baseBranchByRepo, - behindCount, - pullDisabled, - isLoading, - loadingOperation, - onOpenDiffAll, - onOpenReview, - onRequestWalkthrough, - requestWalkthroughDisabled, - repoNames, - perRepoStatus, - onRepoPull, - onRepoRebase, - onRepoMerge, - repoDisplayName, - taskId, - onRenameBranch, - credentialDisplay, -}: { +type ChangesPanelHeaderProps = { hasChanges: boolean; hasCommits: boolean; hasPRFiles?: boolean; @@ -548,7 +530,44 @@ export function ChangesPanelHeader({ * the right task_repositories row to PATCH. Null while task data is * hydrating — the picker falls back to a static label. */ taskId: string | null; -}) { + relation?: RemoteContributionRelation; + resolution?: ReturnType; + resolutionTarget?: RemoteContributionResolutionTarget | null; + remoteContributionUrl?: string; + remoteContributionNumber?: number; +}; + +export function ChangesPanelHeader(props: ChangesPanelHeaderProps) { + const { + hasChanges, + hasCommits, + hasPRFiles, + displayBranch, + baseBranchDisplay, + baseBranchByRepo, + behindCount, + pullDisabled, + isLoading, + loadingOperation, + onOpenDiffAll, + onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, + repoNames, + perRepoStatus, + onRepoPull, + onRepoRebase, + onRepoMerge, + repoDisplayName, + taskId, + onRenameBranch, + credentialDisplay, + relation, + resolution, + resolutionTarget, + remoteContributionUrl, + remoteContributionNumber, + } = props; const branchRows = buildBranchRows( perRepoStatus, baseBranchByRepo, @@ -580,6 +599,13 @@ export function ChangesPanelHeader({ credentialDisplay={credentialDisplay} /> )} + onRepoPull(repo)} className="cursor-pointer text-xs gap-2" disabled={pullDisabled} - title={pullDisabled ? t("task:remoteActionsDisabledHistoryChanged") : undefined} + title={pullDisabled ? t("task:providerHistoryUnavailable") : undefined} > {t("task:pull")} diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 829a85d4fe..f52a6ff401 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -59,6 +59,11 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { repoDisplayName={data.repoDisplayName} taskId={data.activeTaskId} credentialDisplay={data.gitCredentialDisplay} + relation={data.relation} + resolution={data.resolution} + resolutionTarget={data.resolutionTarget} + remoteContributionUrl={data.selectedPR?.pr_url ?? data.existingPrUrl} + remoteContributionNumber={data.selectedPR?.pr_number} /> diff --git a/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.test.tsx b/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.test.tsx new file mode 100644 index 0000000000..fd0a308d51 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { MobileContributionResolutionDrawer } from "./mobile-contribution-resolution-drawer"; + +vi.mock("@kandev/ui/drawer", () => ({ + Drawer: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DrawerContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DrawerDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DrawerFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DrawerHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DrawerTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); + +vi.mock("@kandev/ui/button", () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +describe("MobileContributionResolutionDrawer", () => { + it("uses an inset safe-area drawer and keeps the replacement action touch-sized", () => { + const onConfirm = vi.fn(); + render( + , + ); + + const drawer = screen.getByTestId("mobile-remote-contribution-drawer"); + expect(drawer.className).toContain("bottom-3"); + expect(drawer.className).toContain("safe-area-inset-bottom"); + expect(screen.getByRole("heading", { name: "Replace PR branch" })).toBeTruthy(); + const confirm = screen.getByTestId("mobile-remote-contribution-confirm"); + expect(confirm.className).toContain("h-11"); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("states recovery and clean-tree requirements for the PR version", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Use PR version" })).toBeTruthy(); + expect(screen.getByText(/recovery branch/)).toBeTruthy(); + expect(screen.getByText(/working tree must be clean/)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.tsx b/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.tsx new file mode 100644 index 0000000000..555cab2cbe --- /dev/null +++ b/apps/web/components/task/mobile/mobile-contribution-resolution-drawer.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; +import type { RemoteContributionResolutionAction } from "../use-remote-contribution-resolution"; + +export type MobileContributionResolutionDrawerProps = { + open: boolean; + action: RemoteContributionResolutionAction; + repositoryName: string; + expectedRemoteHead: string; + isLoading: boolean; + errorKey?: string | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +}; + +export function MobileContributionResolutionDrawer({ + open, + action, + repositoryName, + expectedRemoteHead, + isLoading, + errorKey, + onOpenChange, + onConfirm, +}: MobileContributionResolutionDrawerProps) { + const { t } = useTranslation(); + const isReplace = action === "replace"; + const title = t(isReplace ? "task:replacePRBranch" : "task:usePRVersion"); + const description = isReplace + ? t("task:replacePRBranchConfirmation", { + repository: repositoryName, + head: expectedRemoteHead, + }) + : t("task:usePRVersionConfirmation", { head: expectedRemoteHead }); + + return ( + + + + {title} + {description} + + {errorKey && ( +
+ {t(errorKey)} +
+ )} + + + + +
+
+ ); +} diff --git a/apps/web/components/task/mobile/mobile-git-actions-dropdown.tsx b/apps/web/components/task/mobile/mobile-git-actions-dropdown.tsx new file mode 100644 index 0000000000..b825e5a3b2 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-git-actions-dropdown.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { + IconAlertTriangle, + IconCloudDownload, + IconDots, + IconGitCherryPick, + IconGitCommit, + IconGitMerge, + IconGitPullRequest, + IconLoader2, +} from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useChangeRequestTerminology } from "@/hooks/use-git-operations"; +import { RemoteContributionActionItems } from "../remote-contribution-action-items"; +import { PushSubmenu } from "./mobile-git-push-submenu"; + +const DEFAULT_BASE_BRANCH = "main"; + +function GitActionsIcon({ + isGitLoading, + showContributionResolution, +}: { + isGitLoading: boolean; + showContributionResolution: boolean; +}) { + if (isGitLoading) return ; + if (showContributionResolution) return ; + return ; +} + +export type GitActionsDropdownProps = { + sessionId: string | null | undefined; + isGitLoading: boolean; + uncommittedCount: number; + baseBranch: string | undefined; + onCommitClick: () => void; + onPRClick: () => void; + onPull: () => void; + onPush: (force?: boolean) => void; + onRebase: () => void; + onMerge: () => void; + pushDisabled?: boolean; + pullDisabled?: boolean; + showContributionResolution?: boolean; + replaceDisabled?: boolean; + useDisabled?: boolean; + onReplaceContribution?: () => void; + onUseContribution?: () => void; + onViewPRVersion?: () => void; + prNumber?: number; +}; + +function RebaseMergeItems({ + disabled, + baseBranch, + onRebase, + onMerge, +}: { + disabled: boolean; + baseBranch: string | undefined; + onRebase: () => void; + onMerge: () => void; +}) { + const { t } = useTranslation(); + // `main` is the default branch name, not copy — it travels through as an + // interpolated value so it is never translated. + const branch = baseBranch || DEFAULT_BASE_BRANCH; + return ( + <> + + + {t("task:rebase")} + {t("task:ontoBranch", { branch })} + + + + {t("task:merge")} + {t("task:fromBranch", { branch })} + + + ); +} + +function ContributionResolutionItems({ + disabled, + replaceDisabled, + useDisabled, + onReplaceContribution, + onUseContribution, + onViewPRVersion, + prNumber, +}: Pick< + GitActionsDropdownProps, + | "replaceDisabled" + | "useDisabled" + | "onReplaceContribution" + | "onUseContribution" + | "onViewPRVersion" + | "prNumber" +> & { disabled: boolean }) { + return ( + + ); +} + +function StandardGitActionItems({ + disabled, + pullDisabled, + pushDisabled, + onPull, + onPush, +}: Pick & { + disabled: boolean; +}) { + const { t } = useTranslation(); + return ( + <> + + + {t("task:pull")} + + + + ); +} + +export function GitActionsDropdown({ + sessionId, + isGitLoading, + uncommittedCount, + baseBranch, + onCommitClick, + onPRClick, + onPull, + onPush, + onRebase, + onMerge, + pushDisabled = false, + pullDisabled = false, + showContributionResolution = false, + replaceDisabled = false, + useDisabled = false, + onReplaceContribution, + onUseContribution, + onViewPRVersion, + prNumber, +}: GitActionsDropdownProps) { + const { t } = useTranslation(); + const disabled = isGitLoading || !sessionId; + const terminology = useChangeRequestTerminology(sessionId); + return ( + + + + + + + 0 ? "text-amber-500" : ""}`} /> + {t("task:commit")} + {uncommittedCount > 0 && ( + + {uncommittedCount} + + )} + + + + + {t("task:createChangeRequest", { shortName: terminology.shortName })} + + + + {showContributionResolution ? ( + + ) : ( + + )} + + + + + ); +} diff --git a/apps/web/components/task/mobile/mobile-git-push-submenu.tsx b/apps/web/components/task/mobile/mobile-git-push-submenu.tsx index 61f8cd4c4e..45dc8060d1 100644 --- a/apps/web/components/task/mobile/mobile-git-push-submenu.tsx +++ b/apps/web/components/task/mobile/mobile-git-push-submenu.tsx @@ -22,7 +22,7 @@ export function PushSubmenu({ return ( @@ -31,7 +31,7 @@ export function PushSubmenu({ onPush(false)} disabled={disabled} title={disabledReason} @@ -40,7 +40,7 @@ export function PushSubmenu({ {t("task:push")} onPush(true)} disabled={disabled} title={disabledReason} diff --git a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.test.tsx b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.test.tsx index b70a35dda3..0c360c918b 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.test.tsx @@ -67,4 +67,34 @@ describe("GitActionsDropdown remote safety", () => { .every((button) => button.hasAttribute("disabled")), ).toBe(true); }); + + it("offers scoped contribution version choices without the generic push submenu", () => { + const onReplaceContribution = vi.fn(); + const onUseContribution = vi.fn(); + const onViewPRVersion = vi.fn(); + render( + , + ); + + expect(screen.getByRole("button", { name: /Commit/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Replace PR branch/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Use PR version/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /PR #42 version/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Force Push/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Pull$/i })).toBeNull(); + + screen.getByRole("button", { name: /Replace PR branch/ }).click(); + screen.getByRole("button", { name: /Use PR version/ }).click(); + screen.getByRole("button", { name: /PR #42 version/ }).click(); + expect(onReplaceContribution).toHaveBeenCalledOnce(); + expect(onUseContribution).toHaveBeenCalledOnce(); + expect(onViewPRVersion).toHaveBeenCalledOnce(); + }); }); diff --git a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx index 8319c8eaf6..ae7ea0ed9a 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx @@ -3,16 +3,7 @@ import { useCallback, useState } from "react"; import { ChangeRequestPartialStatus } from "@/components/vcs/vcs-dialog-fields"; import { getChangeRequestFailureFeedback } from "@/components/vcs/change-request-feedback"; -import { - IconGitCommit, - IconGitPullRequest, - IconCloudDownload, - IconGitCherryPick, - IconGitMerge, - IconDots, - IconCheck, - IconLoader2, -} from "@tabler/icons-react"; +import { IconGitCommit, IconGitPullRequest, IconCheck, IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -22,13 +13,6 @@ import { DialogFooter, DialogClose, } from "@kandev/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@kandev/ui/dropdown-menu"; import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; import { Checkbox } from "@kandev/ui/checkbox"; @@ -50,7 +34,6 @@ import { PRSubmitButton, } from "./session-mobile-top-bar-dialog-parts"; import { useTranslation } from "react-i18next"; -import { PushSubmenu } from "./mobile-git-push-submenu"; // A Git branch name, not copy: shown when the task has no recorded base branch. const DEFAULT_BASE_BRANCH = "main"; @@ -353,132 +336,8 @@ export function PRDialog({ ); } -export type GitActionsDropdownProps = { - sessionId: string | null | undefined; - isGitLoading: boolean; - uncommittedCount: number; - baseBranch: string | undefined; - onCommitClick: () => void; - onPRClick: () => void; - onPull: () => void; - onPush: (force?: boolean) => void; - onRebase: () => void; - onMerge: () => void; - pushDisabled?: boolean; - pullDisabled?: boolean; -}; - -function RebaseMergeItems({ - disabled, - baseBranch, - onRebase, - onMerge, -}: { - disabled: boolean; - baseBranch: string | undefined; - onRebase: () => void; - onMerge: () => void; -}) { - const { t } = useTranslation(); - // `main` is the default branch name, not copy — it travels through as an - // interpolated value so it is never translated. - const branch = baseBranch || DEFAULT_BASE_BRANCH; - return ( - <> - - - {t("task:rebase")} - {t("task:ontoBranch", { branch })} - - - - {t("task:merge")} - {t("task:fromBranch", { branch })} - - - ); -} - -export function GitActionsDropdown({ - sessionId, - isGitLoading, - uncommittedCount, - baseBranch, - onCommitClick, - onPRClick, - onPull, - onPush, - onRebase, - onMerge, - pushDisabled = false, - pullDisabled = false, -}: GitActionsDropdownProps) { - const { t } = useTranslation(); - const disabled = isGitLoading || !sessionId; - const terminology = useChangeRequestTerminology(sessionId); - return ( - - - - - - - 0 ? "text-amber-500" : ""}`} /> - {t("task:commit")} - {uncommittedCount > 0 && ( - - {uncommittedCount} - - )} - - - - - {t("task:createChangeRequest", { shortName: terminology.shortName })} - - - - - - {t("task:pull")} - - - - - - - ); -} +export { GitActionsDropdown } from "./mobile-git-actions-dropdown"; +export type { GitActionsDropdownProps } from "./mobile-git-actions-dropdown"; /** The success toast for a created change request. Extracted to keep * `useMobileGitActions` under the function-length cap. */ diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.tsx index 59739415db..01ffe61c24 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import Link from "@/components/routing/app-link"; import { IconArrowLeft, IconMenu2, IconGitBranch, IconCheck } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -22,6 +22,14 @@ import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button"; import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { linkToTaskOverview } from "@/lib/links"; import { useTranslation } from "react-i18next"; +import { openExternalLink } from "@/lib/desktop/external-links"; +import { + buildRemoteContributionResolutionTarget, + useRemoteContributionResolution, + useRemoteContributionResolutionConfirmation, + type RemoteContributionResolutionTarget, +} from "../use-remote-contribution-resolution"; +import { MobileContributionResolutionDrawer } from "./mobile-contribution-resolution-drawer"; type SessionMobileTopBarProps = { taskId?: string | null; @@ -143,8 +151,11 @@ function useMobileGitMetrics( } function useMobileRemoteActionPolicy(sessionId: string | null | undefined) { - const { relation } = useRemoteContributionRelation(sessionId); - return remoteContributionActionPolicy(relation); + const contribution = useRemoteContributionRelation(sessionId); + return { + ...contribution, + ...remoteContributionActionPolicy(contribution.relation), + }; } type MobileGitDialogsProps = { @@ -192,6 +203,78 @@ function MobileGitDialogs(props: MobileGitDialogsProps) { ); } +function useMobileContributionResolutionActions(sessionId: string | null | undefined) { + const { t } = useTranslation(); + const remoteActionPolicy = useMobileRemoteActionPolicy(sessionId); + const resolution = useRemoteContributionResolution( + sessionId, + remoteActionPolicy.refreshProviderEvidence, + ); + const remoteRepositoryLabel = t("task:remoteRepository"); + const resolutionTarget = useMemo( + () => + buildRemoteContributionResolutionTarget( + remoteActionPolicy.relation, + remoteActionPolicy.repositoryName, + remoteActionPolicy.selectedPR, + remoteRepositoryLabel, + ), + [ + remoteActionPolicy.relation, + remoteActionPolicy.repositoryName, + remoteActionPolicy.selectedPR, + remoteRepositoryLabel, + ], + ); + const requestReplace = useCallback(() => { + if (resolutionTarget) resolution.requestReplace(resolutionTarget); + }, [resolution, resolutionTarget]); + const requestUse = useCallback(() => { + if (resolutionTarget) resolution.requestUse(resolutionTarget); + }, [resolution, resolutionTarget]); + const viewPRVersion = useCallback(() => { + const url = remoteActionPolicy.selectedPR?.pr_url; + if (url) void openExternalLink(url).catch(() => undefined); + }, [remoteActionPolicy.selectedPR?.pr_url]); + const confirmResolution = useRemoteContributionResolutionConfirmation(resolution); + + return { + remoteActionPolicy, + resolution, + resolutionTarget, + requestReplace, + requestUse, + viewPRVersion, + confirmResolution, + }; +} + +function MobileResolutionDrawer({ + resolution, + resolutionTarget, + confirmResolution, +}: { + resolution: ReturnType; + resolutionTarget: RemoteContributionResolutionTarget | null; + confirmResolution: () => Promise; +}) { + if (!resolution.pending || !resolutionTarget) return null; + return ( + { + if (!open) resolution.cancel(); + }} + onConfirm={confirmResolution} + /> + ); +} + type MobileTopBarActionsProps = { taskId?: string | null; workspaceId?: string | null; @@ -246,7 +329,15 @@ function MobileTopBarActions({ onMenuClick, }: MobileTopBarActionsProps) { const { t } = useTranslation(); - const remoteActionPolicy = useMobileRemoteActionPolicy(sessionId); + const { + remoteActionPolicy, + resolution, + resolutionTarget, + requestReplace, + requestUse, + viewPRVersion, + confirmResolution, + } = useMobileContributionResolutionActions(sessionId); return (
@@ -285,6 +376,18 @@ function MobileTopBarActions({ onMerge={onMerge} pushDisabled={remoteActionPolicy.pushDisabled} pullDisabled={remoteActionPolicy.pullDisabled} + showContributionResolution={remoteActionPolicy.action === "diverged_replace"} + replaceDisabled={remoteActionPolicy.replaceDisabled} + useDisabled={remoteActionPolicy.useDisabled} + onReplaceContribution={requestReplace} + onUseContribution={requestUse} + onViewPRVersion={viewPRVersion} + prNumber={remoteActionPolicy.selectedPR?.pr_number} + /> + + ), +})); + +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DropdownMenuItem: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/lib/desktop/external-links", () => ({ + openExternalLink: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("./remote-contribution-resolution-dialog", () => ({ + RemoteContributionResolutionDialog: () => null, +})); + +afterEach(cleanup); + +const relation: RemoteContributionRelation = { + kind: "diverged", + presentation: "separate", + action: "diverged_replace", + providerHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + pushAhead: 0, + pullBehind: 0, + canPush: false, + canPull: false, + canReplaceRemote: true, + canUseRemote: true, +}; + +function makeResolution() { + return { + isLoading: false, + pending: null, + errorKey: null, + lastResult: null, + requestReplace: vi.fn(), + requestUse: vi.fn(), + cancel: vi.fn(), + confirm: vi.fn(), + } as unknown as ReturnType; +} + +describe("RemoteContributionHeaderActions", () => { + it("collapses drift choices behind a warning trigger and explains each action", () => { + const resolution = makeResolution(); + render( + , + ); + + expect( + screen.getByTestId("header-remote-contribution-warning").getAttribute("aria-label"), + ).toBe("PR branch changed"); + expect(screen.getByTestId("header-replace-pr-branch")).toBeTruthy(); + expect(screen.getByTestId("header-use-pr-version")).toBeTruthy(); + expect(screen.getByTestId("header-view-pr-version").textContent).toContain("PR #901 version"); + expect( + screen.getByRole("img", { name: /Replace the published PR branch/ }).getAttribute("title"), + ).toBeNull(); + expect(screen.getAllByRole("tooltip").map((tooltip) => tooltip.textContent)).toEqual( + expect.arrayContaining([ + expect.stringContaining("Replace the published PR branch"), + expect.stringContaining("Use the current PR version"), + expect.stringContaining("Open PR #901 version"), + ]), + ); + + fireEvent.click(screen.getByTestId("header-replace-pr-branch")); + fireEvent.click(screen.getByTestId("header-use-pr-version")); + expect(resolution.requestReplace).toHaveBeenCalledOnce(); + expect(resolution.requestUse).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/task/remote-contribution-header-actions.tsx b/apps/web/components/task/remote-contribution-header-actions.tsx new file mode 100644 index 0000000000..6403772da9 --- /dev/null +++ b/apps/web/components/task/remote-contribution-header-actions.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; +import { + remoteContributionActionPolicy, + type RemoteContributionRelation, +} from "@/hooks/domains/session/remote-contribution-relation"; +import { openExternalLink } from "@/lib/desktop/external-links"; +import { + type RemoteContributionResolutionTarget, + type useRemoteContributionResolution, + useRemoteContributionResolutionConfirmation, +} from "./use-remote-contribution-resolution"; +import { RemoteContributionActionItems } from "./remote-contribution-action-items"; +import { RemoteContributionResolutionDialog } from "./remote-contribution-resolution-dialog"; + +export function RemoteContributionHeaderActions({ + relation, + resolution, + resolutionTarget, + prUrl, + prNumber, +}: { + relation?: RemoteContributionRelation; + resolution?: ReturnType; + resolutionTarget?: RemoteContributionResolutionTarget | null; + prUrl?: string; + prNumber?: number; +}) { + const { t } = useTranslation(); + const confirmResolution = useRemoteContributionResolutionConfirmation(resolution); + if (relation?.kind !== "diverged" || !resolution || !resolutionTarget) return null; + const policy = remoteContributionActionPolicy(relation); + return ( +
+ + + + + + +
+ +
+

+ {t("task:remoteContributionChangedTitle")} +

+

+ {t("task:remoteContributionChangedBody")} +

+
+
+
+ + resolution.requestReplace(resolutionTarget)} + onUseContribution={() => resolution.requestUse(resolutionTarget)} + onViewPRVersion={ + prUrl ? () => void openExternalLink(prUrl).catch(() => undefined) : undefined + } + testIdPrefix="header" + prNumber={prNumber} + /> +
+
+ {resolution.pending && ( + { + if (!open) resolution.cancel(); + }} + onConfirm={() => { + void confirmResolution(); + }} + /> + )} +
+ ); +} diff --git a/apps/web/components/task/remote-contribution-resolution-dialog.test.tsx b/apps/web/components/task/remote-contribution-resolution-dialog.test.tsx new file mode 100644 index 0000000000..fdd7170ce3 --- /dev/null +++ b/apps/web/components/task/remote-contribution-resolution-dialog.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { RemoteContributionResolutionDialog } from "./remote-contribution-resolution-dialog"; + +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); + +vi.mock("@kandev/ui/button", () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +describe("RemoteContributionResolutionDialog", () => { + it("names the repository and exact provider head before replacement", () => { + const onConfirm = vi.fn(); + render( + , + ); + + expect(screen.getByRole("heading", { name: "Replace PR branch" })).toBeTruthy(); + expect(screen.getByText(/frontend/)).toBeTruthy(); + expect(screen.getByText(/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/)).toBeTruthy(); + expect(screen.getByText(/provider branch/)).toBeTruthy(); + fireEvent.click(screen.getByTestId("remote-contribution-confirm")); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it("states the clean-tree and recovery-branch guard for adoption", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Use PR version" })).toBeTruthy(); + expect(screen.getByText(/recovery branch/)).toBeTruthy(); + expect(screen.getByText(/working tree must be clean/)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/remote-contribution-resolution-dialog.tsx b/apps/web/components/task/remote-contribution-resolution-dialog.tsx new file mode 100644 index 0000000000..6d22f427bf --- /dev/null +++ b/apps/web/components/task/remote-contribution-resolution-dialog.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { useTranslation } from "react-i18next"; +import type { RemoteContributionResolutionAction } from "./use-remote-contribution-resolution"; + +export type RemoteContributionResolutionDialogProps = { + open: boolean; + action: RemoteContributionResolutionAction; + repositoryName: string; + expectedRemoteHead: string; + isLoading: boolean; + errorKey?: string | null; + onOpenChange: (open: boolean) => void; + onConfirm: () => void | Promise; +}; + +export function RemoteContributionResolutionDialog({ + open, + action, + repositoryName, + expectedRemoteHead, + isLoading, + errorKey, + onOpenChange, + onConfirm, +}: RemoteContributionResolutionDialogProps) { + const { t } = useTranslation(); + const isReplace = action === "replace"; + const title = t(isReplace ? "task:replacePRBranch" : "task:usePRVersion"); + const description = isReplace + ? t("task:replacePRBranchConfirmation", { + repository: repositoryName, + head: expectedRemoteHead, + }) + : t("task:usePRVersionConfirmation", { head: expectedRemoteHead }); + + return ( + + + + {title} + {description} + + {errorKey &&

{t(errorKey)}

} + + + + +
+
+ ); +} diff --git a/apps/web/components/task/use-remote-contribution-resolution.test.tsx b/apps/web/components/task/use-remote-contribution-resolution.test.tsx new file mode 100644 index 0000000000..c95386b744 --- /dev/null +++ b/apps/web/components/task/use-remote-contribution-resolution.test.tsx @@ -0,0 +1,167 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitOperationResult } from "@/hooks/use-git-operations"; +import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; +import type { TaskPR } from "@/lib/types/github"; +import { + buildRemoteContributionResolutionTarget, + classifyRemoteContributionError, + useRemoteContributionResolution, +} from "./use-remote-contribution-resolution"; + +const mocks = vi.hoisted(() => ({ + replaceRemoteContribution: vi.fn(), + useRemoteContribution: vi.fn(), + refreshProviderEvidence: vi.fn(), +})); + +vi.mock("@/hooks/use-git-operations", () => ({ + useGitOperations: () => ({ + replaceRemoteContribution: mocks.replaceRemoteContribution, + useRemoteContribution: mocks.useRemoteContribution, + }), +})); + +const TARGET = { + expectedRemoteHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + repo: "", + repositoryName: "workspace", +}; + +const successResult: GitOperationResult = { + success: true, + operation: "use_remote_contribution", + output: "", + recovery_branch: "kandev/recovery-123", +}; + +const relationWithProviderHead = { + providerHead: TARGET.expectedRemoteHead, + canReplaceRemote: true, + canUseRemote: true, +} as RemoteContributionRelation; + +describe("useRemoteContributionResolution", () => { + beforeEach(() => { + mocks.replaceRemoteContribution.mockReset(); + mocks.useRemoteContribution.mockReset(); + mocks.refreshProviderEvidence.mockReset(); + mocks.replaceRemoteContribution.mockResolvedValue({ + ...successResult, + operation: "replace_remote_contribution", + }); + mocks.useRemoteContribution.mockResolvedValue(successResult); + mocks.refreshProviderEvidence.mockResolvedValue(TARGET.expectedRemoteHead); + }); + + it("keeps a typed replacement confirmation pending until it is confirmed", async () => { + const { result } = renderHook(() => + useRemoteContributionResolution("session-1", mocks.refreshProviderEvidence), + ); + + act(() => result.current.requestReplace(TARGET)); + expect(result.current.pending).toEqual({ action: "replace", ...TARGET }); + + await act(async () => { + await result.current.confirm(); + }); + + expect(mocks.replaceRemoteContribution).toHaveBeenCalledWith(TARGET.expectedRemoteHead, ""); + expect(mocks.refreshProviderEvidence).toHaveBeenCalledOnce(); + expect(result.current.pending).toBeNull(); + expect(result.current.lastResult?.operation).toBe("replace_remote_contribution"); + }); + + it("uses one scoped adoption operation and exposes its recovery branch", async () => { + const { result } = renderHook(() => useRemoteContributionResolution("session-1")); + + act(() => result.current.requestUse(TARGET)); + await act(async () => { + await result.current.confirm(); + }); + + expect(mocks.useRemoteContribution).toHaveBeenCalledWith(TARGET.expectedRemoteHead, ""); + expect(result.current.lastResult?.recovery_branch).toBe("kandev/recovery-123"); + }); + + it("refreshes the provider head after a lease mismatch before retry", async () => { + const refreshedHead = "cccccccccccccccccccccccccccccccccccccccc"; + mocks.replaceRemoteContribution.mockResolvedValue({ + ...successResult, + success: false, + operation: "replace_remote_contribution", + error: "remote contribution head changed", + error_code: "lease_mismatch", + }); + mocks.refreshProviderEvidence.mockResolvedValue(refreshedHead); + const { result } = renderHook(() => + useRemoteContributionResolution("session-1", mocks.refreshProviderEvidence), + ); + + act(() => result.current.requestReplace(TARGET)); + await act(async () => { + await result.current.confirm(); + }); + + expect(mocks.refreshProviderEvidence).toHaveBeenCalledOnce(); + expect(result.current.pending?.expectedRemoteHead).toBe(refreshedHead); + expect(result.current.error).toBe("lease_mismatch"); + }); + + it("clears a pending confirmation without calling Git", () => { + const { result } = renderHook(() => useRemoteContributionResolution("session-1")); + + act(() => result.current.requestUse(TARGET)); + act(() => result.current.cancel()); + + expect(result.current.pending).toBeNull(); + expect(mocks.useRemoteContribution).not.toHaveBeenCalled(); + }); +}); + +describe("classifyRemoteContributionError", () => { + it.each(["lease_mismatch"])("maps %s to a lease mismatch", (errorCode) => { + expect(classifyRemoteContributionError(errorCode)).toBe("lease_mismatch"); + }); + + it("maps dirty-worktree error codes to the clean-tree instruction", () => { + expect(classifyRemoteContributionError("dirty_worktree")).toBe("dirty_worktree"); + }); + + it("does not infer a category from provider error text", () => { + expect(classifyRemoteContributionError("remote contribution head changed")).toBe("generic"); + }); + + it("keeps unrelated provider errors generic", () => { + expect(classifyRemoteContributionError("remote_unavailable")).toBe("generic"); + expect(classifyRemoteContributionError(undefined)).toBe("generic"); + }); +}); + +describe("buildRemoteContributionResolutionTarget", () => { + it("uses the selected PR identity when the repository scope is empty", () => { + expect( + buildRemoteContributionResolutionTarget( + relationWithProviderHead, + "", + { owner: "acme", repo: "widget" } as TaskPR, + "Remote repository", + ), + ).toEqual({ + expectedRemoteHead: TARGET.expectedRemoteHead, + repo: "", + repositoryName: "acme/widget", + }); + }); + + it("does not create a target without a resolvable contribution action", () => { + expect( + buildRemoteContributionResolutionTarget( + { ...relationWithProviderHead, canReplaceRemote: false, canUseRemote: false }, + "frontend", + null, + "Remote repository", + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/components/task/use-remote-contribution-resolution.ts b/apps/web/components/task/use-remote-contribution-resolution.ts new file mode 100644 index 0000000000..4028174bb3 --- /dev/null +++ b/apps/web/components/task/use-remote-contribution-resolution.ts @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { useGitOperations, type GitOperationResult } from "@/hooks/use-git-operations"; +import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation"; +import type { TaskPR } from "@/lib/types/github"; + +export type RemoteContributionResolutionAction = "replace" | "use"; + +export type RemoteContributionResolutionTarget = { + expectedRemoteHead: string; + /** Always pass the selected repository scope, including the empty root scope. */ + repo: string; + repositoryName?: string; +}; + +export type PendingRemoteContributionResolution = RemoteContributionResolutionTarget & { + action: RemoteContributionResolutionAction; +}; + +export function buildRemoteContributionResolutionTarget( + relation: RemoteContributionRelation, + repositoryName: string | undefined, + selectedPR: TaskPR | null | undefined, + remoteRepositoryLabel: string, +): RemoteContributionResolutionTarget | null { + const providerHead = relation.providerHead; + if (!providerHead || (!relation.canReplaceRemote && !relation.canUseRemote)) return null; + const repositoryScope = repositoryName ?? ""; + return { + expectedRemoteHead: providerHead, + repo: repositoryScope, + repositoryName: + repositoryScope || + (selectedPR ? `${selectedPR.owner}/${selectedPR.repo}` : remoteRepositoryLabel), + }; +} + +export type RemoteContributionResolutionError = "lease_mismatch" | "dirty_worktree" | "generic"; + +export function classifyRemoteContributionError( + errorCode: string | undefined, +): RemoteContributionResolutionError { + if (errorCode === "lease_mismatch") return "lease_mismatch"; + if (errorCode === "dirty_worktree") return "dirty_worktree"; + return "generic"; +} + +export function remoteContributionResolutionErrorKey( + error: RemoteContributionResolutionError, +): string { + if (error === "lease_mismatch") return "task:remoteContributionProviderChangedAgain"; + if (error === "dirty_worktree") return "task:remoteContributionCleanWorktreeRequired"; + return "task:remoteContributionResolutionFailed"; +} + +export function useRemoteContributionResolution( + sessionId: string | null | undefined, + refreshProviderEvidence?: () => Promise, +) { + const { replaceRemoteContribution, useRemoteContribution, isLoading } = useGitOperations( + sessionId ?? null, + ); + const [pending, setPending] = useState(null); + const [lastResult, setLastResult] = useState(null); + const [error, setError] = useState(null); + + const begin = useCallback( + (action: RemoteContributionResolutionAction, target: RemoteContributionResolutionTarget) => { + setPending({ action, ...target }); + setLastResult(null); + setError(null); + }, + [], + ); + + const requestReplace = useCallback( + (target: RemoteContributionResolutionTarget) => begin("replace", target), + [begin], + ); + + const requestUse = useCallback( + (target: RemoteContributionResolutionTarget) => begin("use", target), + [begin], + ); + + const cancel = useCallback(() => { + setPending(null); + setError(null); + }, []); + + const confirm = useCallback(async (): Promise => { + if (!pending) return null; + try { + const operation = + pending.action === "replace" ? replaceRemoteContribution : useRemoteContribution; + const result = await operation(pending.expectedRemoteHead, pending.repo); + const resolutionError = result.success + ? null + : classifyRemoteContributionError(result.error_code); + let refreshedHead: string | null = null; + if (result.success || resolutionError === "lease_mismatch") { + try { + refreshedHead = (await refreshProviderEvidence?.()) ?? null; + } catch { + // The Git operation result remains authoritative if provider refresh fails. + } + } + if (result.success) setPending(null); + if (!result.success && resolutionError === "lease_mismatch" && refreshedHead) { + setPending((current) => + current ? { ...current, expectedRemoteHead: refreshedHead } : current, + ); + } + setLastResult(result); + setError(resolutionError); + return result; + } catch { + setError("generic"); + return null; + } + }, [pending, refreshProviderEvidence, replaceRemoteContribution, useRemoteContribution]); + + return { + pending, + isLoading, + lastResult, + error, + errorKey: error ? remoteContributionResolutionErrorKey(error) : null, + requestReplace, + requestUse, + cancel, + confirm, + }; +} + +export function useRemoteContributionResolutionConfirmation( + resolution: ReturnType | null | undefined, +) { + const { t } = useTranslation(); + const { toast } = useToast(); + return useCallback(async () => { + const action = resolution?.pending?.action; + const result = await resolution?.confirm(); + if (!result?.success) return; + toast({ + title: + action === "replace" + ? t("task:remoteContributionReplaced") + : t("task:remoteContributionUsed", { + branch: result.recovery_branch || t("task:remoteRepository"), + }), + variant: "success", + }); + }, [resolution?.confirm, resolution?.pending?.action, t, toast]); +} diff --git a/apps/web/components/vcs-constants.ts b/apps/web/components/vcs-constants.ts new file mode 100644 index 0000000000..04603b0bfe --- /dev/null +++ b/apps/web/components/vcs-constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_BASE_BRANCH = "origin/main"; +export const AHEAD_MARK = "↑"; +export const BEHIND_MARK = "↓"; diff --git a/apps/web/components/vcs-multi-repo-menu.tsx b/apps/web/components/vcs-multi-repo-menu.tsx index a4cba0fad4..5a7bb8a296 100644 --- a/apps/web/components/vcs-multi-repo-menu.tsx +++ b/apps/web/components/vcs-multi-repo-menu.tsx @@ -25,6 +25,7 @@ import { DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { RemoteContributionActionItems } from "@/components/task/remote-contribution-action-items"; import type { ReactNode } from "react"; export type PerRepoStatus = { @@ -45,6 +46,9 @@ export type PerRepoCallbacks = { onPush: (force: boolean, repo: string) => void; onRebase: (repo: string) => void; onMerge: (repo: string) => void; + onReplaceContribution: (repo: string) => void; + onUseContribution: (repo: string) => void; + onViewContribution: (repo: string) => void; }; export type PrimaryButtonConfig = { @@ -71,6 +75,7 @@ const PICK_REPOSITORY_KEYS: Record<"commit" | "push" | "pr" | "rebase", string> const ITEM_CLASS = "cursor-pointer gap-3"; const ICON_CLASS = "h-4 w-4 text-muted-foreground"; +const PUSH_ACTIONS: VcsActionKey[] = ["push", "force-push"]; export type VcsActionKey = "commit" | "push" | "pr" | "pull" | "rebase" | "merge" | "force-push"; @@ -82,11 +87,25 @@ export function isRemoteActionBlockedForRepository( pullDisabled: boolean, ): boolean { const actionBlocked = - (action === "push" || action === "force-push" ? pushDisabled : false) || + (PUSH_ACTIONS.includes(action) ? pushDisabled : false) || (action === "pull" ? pullDisabled : false); return actionBlocked && (!blockedRepositoryName || repositoryName === blockedRepositoryName); } +function shouldHideContributionAction( + action: ActionDef, + repo: string, + hideBlockedContribution: boolean, + blockedRepositoryName: string | undefined, +) { + return ( + hideBlockedContribution && + PUSH_ACTIONS.includes(action.key) && + blockedRepositoryName !== undefined && + repo === blockedRepositoryName + ); +} + type ActionDef = { key: VcsActionKey; /** Catalog key, resolved at render: `t()` here would freeze at the boot locale. */ @@ -97,6 +116,54 @@ type ActionDef = { invoke: (repo: string, callbacks: PerRepoCallbacks) => void; }; +function PerRepoActionItem({ + action, + repo, + status, + disabled, + pushDisabled, + pullDisabled, + blockedRepositoryName, + repoDisplayName, + callbacks, +}: { + action: ActionDef; + repo: string; + status: PerRepoStatus | undefined; + disabled: boolean; + pushDisabled: boolean; + pullDisabled: boolean; + blockedRepositoryName: string | undefined; + repoDisplayName: (repositoryName: string) => string | undefined; + callbacks: PerRepoCallbacks; +}) { + const { t } = useTranslation(); + const ahead = status?.pushAhead ?? 0; + const behind = status?.pullBehind ?? 0; + const label = repoDisplayName(repo) || repo || t("integrations:repository"); + const actionBlocked = isRemoteActionBlockedForRepository( + action.key, + repo, + blockedRepositoryName, + pushDisabled, + pullDisabled, + ); + return ( + action.invoke(repo, callbacks)} + disabled={disabled || actionBlocked || action.disabledFor(status)} + title={actionBlocked ? t("task:providerHistoryUnavailable") : undefined} + > + {label} + + {ahead > 0 && ↑{ahead}} + {behind > 0 && ↓{behind}} + + + ); +} + const ACTION_DEFS: ActionDef[] = [ { key: "commit", @@ -161,6 +228,7 @@ function PerRepoActionSub({ pushDisabled, pullDisabled, blockedRepositoryName, + hideBlockedContribution, repoNames, perRepoStatus, repoDisplayName, @@ -171,6 +239,7 @@ function PerRepoActionSub({ pushDisabled: boolean; pullDisabled: boolean; blockedRepositoryName: string | undefined; + hideBlockedContribution: boolean; repoNames: string[]; perRepoStatus: PerRepoStatus[]; repoDisplayName: (repositoryName: string) => string | undefined; @@ -186,34 +255,29 @@ function PerRepoActionSub({ {repoNames.map((repo) => { - const status = statusByName.get(repo); - const ahead = status?.pushAhead ?? 0; - const behind = status?.pullBehind ?? 0; - const label = repoDisplayName(repo) || repo || t("integrations:repository"); - const actionBlocked = isRemoteActionBlockedForRepository( - action.key, - repo, - blockedRepositoryName, - pushDisabled, - pullDisabled, - ); - const blockedTitle = actionBlocked - ? t("task:remoteActionsDisabledHistoryChanged") - : undefined; + if ( + shouldHideContributionAction( + action, + repo, + hideBlockedContribution, + blockedRepositoryName, + ) + ) { + return null; + } return ( - action.invoke(repo, callbacks)} - disabled={disabled || actionBlocked || action.disabledFor(status)} - title={blockedTitle} - > - {label} - - {ahead > 0 && ↑{ahead}} - {behind > 0 && ↓{behind}} - - + action={action} + repo={repo} + status={statusByName.get(repo)} + disabled={disabled} + pushDisabled={pushDisabled} + pullDisabled={pullDisabled} + blockedRepositoryName={blockedRepositoryName} + repoDisplayName={repoDisplayName} + callbacks={callbacks} + /> ); })} @@ -236,9 +300,13 @@ function MultiRepoVcsDropdown({ perRepoStatus, pushDisabled, pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, blockedRepositoryName, repoDisplayName, callbacks, + prNumber, }: { disabled: boolean; baseBranch: string; @@ -246,9 +314,13 @@ function MultiRepoVcsDropdown({ perRepoStatus: PerRepoStatus[]; pushDisabled: boolean; pullDisabled: boolean; + showContributionResolution: boolean; + replaceDisabled: boolean; + useDisabled: boolean; blockedRepositoryName: string | undefined; repoDisplayName: (repositoryName: string) => string | undefined; callbacks: PerRepoCallbacks; + prNumber?: number; }) { const { t } = useTranslation(); return ( @@ -267,6 +339,7 @@ function MultiRepoVcsDropdown({ pushDisabled={pushDisabled} pullDisabled={pullDisabled} blockedRepositoryName={blockedRepositoryName} + hideBlockedContribution={showContributionResolution} repoNames={repoNames} perRepoStatus={perRepoStatus} repoDisplayName={repoDisplayName} @@ -276,6 +349,21 @@ function MultiRepoVcsDropdown({ {action.key === "merge" && }
))} + {showContributionResolution && blockedRepositoryName !== undefined && ( + <> + + callbacks.onReplaceContribution(blockedRepositoryName)} + onUseContribution={() => callbacks.onUseContribution(blockedRepositoryName)} + onViewPRVersion={() => callbacks.onViewContribution(blockedRepositoryName)} + prNumber={prNumber} + /> + + )} ); } @@ -311,9 +399,13 @@ export function MultiRepoVcsButton({ perRepoStatus, pushDisabled, pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, blockedRepositoryName, repoDisplayName, callbacks, + prNumber, }: { primaryButtonConfig: PrimaryButtonConfig; primaryAction: "commit" | "push" | "pr" | "rebase"; @@ -324,9 +416,13 @@ export function MultiRepoVcsButton({ perRepoStatus: PerRepoStatus[]; pushDisabled: boolean; pullDisabled: boolean; + showContributionResolution: boolean; + replaceDisabled: boolean; + useDisabled: boolean; blockedRepositoryName?: string; repoDisplayName: (repositoryName: string) => string | undefined; callbacks: PerRepoCallbacks; + prNumber?: number; }) { const { t } = useTranslation(); return ( @@ -363,9 +459,13 @@ export function MultiRepoVcsButton({ perRepoStatus={perRepoStatus} pushDisabled={pushDisabled} pullDisabled={pullDisabled} + showContributionResolution={showContributionResolution} + replaceDisabled={replaceDisabled} + useDisabled={useDisabled} blockedRepositoryName={blockedRepositoryName} repoDisplayName={repoDisplayName} callbacks={callbacks} + prNumber={prNumber} /> diff --git a/apps/web/components/vcs-split-button-dropdown.tsx b/apps/web/components/vcs-split-button-dropdown.tsx new file mode 100644 index 0000000000..2444060be9 --- /dev/null +++ b/apps/web/components/vcs-split-button-dropdown.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { + IconAlertTriangle, + IconCloudDownload, + IconCloudUpload, + IconGitCherryPick, + IconGitMerge, + IconGitPullRequest, +} from "@tabler/icons-react"; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "@kandev/ui/dropdown-menu"; +import { AHEAD_MARK, BEHIND_MARK, DEFAULT_BASE_BRANCH } from "./vcs-constants"; +import { RemoteContributionActionItems } from "@/components/task/remote-contribution-action-items"; + +function StandardPushDropdownItems({ + disabled, + hasUpstream, + aheadCount, + pushDisabled, + onPush, + disabledTitle, +}: { + disabled: boolean; + hasUpstream: boolean; + aheadCount: number; + pushDisabled: boolean; + onPush: (force: boolean) => void; + disabledTitle: string; +}) { + const { t } = useTranslation(); + return ( + + + + {t("integrations:push")} + {hasUpstream && aheadCount > 0 && ( + + {AHEAD_MARK} + {aheadCount} + + )} + + + onPush(false)} + disabled={disabled || pushDisabled} + title={pushDisabled ? disabledTitle : undefined} + > + + {t("integrations:push")} + + onPush(true)} + disabled={disabled || pushDisabled} + title={pushDisabled ? disabledTitle : undefined} + > + + {t("integrations:forcePush")} + + + + ); +} + +function ContributionDropdownItems({ + disabled, + replaceDisabled, + useDisabled, + onReplaceContribution, + onUseContribution, + onViewPRVersion, + prNumber, +}: { + disabled: boolean; + replaceDisabled: boolean; + useDisabled: boolean; + onReplaceContribution: () => void; + onUseContribution: () => void; + onViewPRVersion: () => void; + prNumber?: number; +}) { + return ( + <> + + + + ); +} + +export type VcsDropdownItemsProps = { + disabled: boolean; + baseBranch?: string; + hasUpstream: boolean; + behindCount: number; + aheadCount: number; + pushDisabled: boolean; + pullDisabled: boolean; + showContributionResolution: boolean; + replaceDisabled: boolean; + useDisabled: boolean; + onPR: () => void; + onPull: () => void; + onPush: (force: boolean) => void; + onReplaceContribution: () => void; + onUseContribution: () => void; + onViewPRVersion: () => void; + prNumber?: number; + onRebase: () => void; + onMerge: () => void; +}; + +export function VcsDropdownItems({ + disabled, + baseBranch, + hasUpstream, + behindCount, + aheadCount, + pushDisabled, + pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, + onPR, + onPull, + onPush, + onReplaceContribution, + onUseContribution, + onViewPRVersion, + prNumber, + onRebase, + onMerge, +}: VcsDropdownItemsProps) { + const { t } = useTranslation(); + const remoteActionsDisabledTitle = t("task:providerHistoryUnavailable"); + return ( + + + + {t("integrations:createPr")} + + + + + {t("integrations:pull")} + {hasUpstream && behindCount > 0 && ( + + {BEHIND_MARK} + {behindCount} + + )} + + {!showContributionResolution && ( + + )} + {showContributionResolution && ( + + )} + + + + {t("integrations:rebase")} + + {t("integrations:ontoBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} + + + + + {t("integrations:merge")} + + {t("integrations:fromBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} + + + + ); +} diff --git a/apps/web/components/vcs-split-button-parts.tsx b/apps/web/components/vcs-split-button-parts.tsx new file mode 100644 index 0000000000..9893e2b92e --- /dev/null +++ b/apps/web/components/vcs-split-button-parts.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import type { ComponentProps } from "react"; +import { IconChevronDown, IconLoader2 } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { DropdownMenu, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@kandev/ui/lib/utils"; +import { + MultiRepoVcsButton, + type PerRepoCallbacks, + type PerRepoStatus, +} from "./vcs-multi-repo-menu"; +import { VcsDropdownItems } from "./vcs-split-button-dropdown"; +import type { PrimaryButtonConfig } from "./vcs-split-button"; +import { + type RemoteContributionResolutionTarget, + type useRemoteContributionResolution, +} from "@/components/task/use-remote-contribution-resolution"; +import { RemoteContributionResolutionDialog } from "@/components/task/remote-contribution-resolution-dialog"; +import { AHEAD_MARK, BEHIND_MARK, DEFAULT_BASE_BRANCH } from "./vcs-constants"; + +type DivergenceTone = "ahead" | "behind"; + +const divergenceToneClass: Record = { + ahead: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", + behind: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", +}; + +function DivergencePill({ + tone, + value, + ariaLabel, +}: { + tone: DivergenceTone; + value: number; + ariaLabel: string; +}) { + if (value <= 0) return null; + + return ( + + {tone === "ahead" ? AHEAD_MARK : BEHIND_MARK} + {value} + + ); +} + +export function GitDivergencePills({ ahead, behind }: { ahead: number; behind: number }) { + const { t } = useTranslation(); + if (ahead <= 0 && behind <= 0) return null; + + // `{{value}}`, not `{{count}}`: the shipped English is count-invariant + // ("1 commits ahead"), and switching to a plural here would change the + // accessible name that E2E specs query by. + return ( + + + + + ); +} + +type SingleRepoVcsButtonProps = { + primaryButtonConfig: PrimaryButtonConfig; + primaryAction: "commit" | "push" | "pr" | "rebase"; + isDisabled: boolean; + isGitLoading: boolean; + baseBranch?: string; + hasUpstream: boolean; + behindCount: number; + aheadCount: number; + pushDisabled: boolean; + pullDisabled: boolean; + showContributionResolution: boolean; + replaceDisabled: boolean; + useDisabled: boolean; + onPR: () => void; + onPull: () => void; + onPush: (force: boolean) => void; + onReplaceContribution: () => void; + onUseContribution: () => void; + onViewPRVersion: () => void; + prNumber?: number; + onRebase: () => void; + onMerge: () => void; + className?: string; + buttonSize?: ComponentProps["size"]; + showDivergencePills?: boolean; +}; + +function SingleRepoVcsButton({ + primaryButtonConfig, + primaryAction, + isDisabled, + isGitLoading, + baseBranch, + hasUpstream, + behindCount, + aheadCount, + pushDisabled, + pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, + onPR, + onPull, + onPush, + onReplaceContribution, + onUseContribution, + onViewPRVersion, + prNumber, + onRebase, + onMerge, + className, + buttonSize = "sm", + showDivergencePills = false, +}: SingleRepoVcsButtonProps) { + const { t } = useTranslation(); + return ( +
+ + + + + {primaryButtonConfig.tooltip} + + + + + + + +
+ ); +} + +type VcsSplitButtonCallbacks = { + onCommit: (repo?: string) => void; + onPR: (repo?: string) => void; + onPull: (repo?: string) => void; + onPush: (force: boolean, repo?: string) => void; + onRebase: (repo?: string) => void; + onMerge: (repo?: string) => void; + onReplaceContribution: (repo?: string) => void; + onUseContribution: (repo?: string) => void; + onViewContribution: (repo?: string) => void; +}; + +type VcsSplitButtonContentProps = { + isMultiRepo: boolean; + primaryButtonConfig: PrimaryButtonConfig; + primaryAction: "commit" | "push" | "pr" | "rebase"; + isDisabled: boolean; + isGitLoading: boolean; + baseBranch?: string; + hasUpstream: boolean; + behindCount: number; + aheadCount: number; + pushDisabled: boolean; + pullDisabled: boolean; + showContributionResolution: boolean; + replaceDisabled: boolean; + useDisabled: boolean; + repoNames: string[]; + perRepoStatus: PerRepoStatus[]; + blockedRepositoryName?: string; + repoDisplayName: (repositoryName: string) => string | undefined; + buttonSize?: ComponentProps["size"]; + className?: string; + showDivergencePills: boolean; + callbacks: VcsSplitButtonCallbacks; + resolution: ReturnType; + resolutionTarget: RemoteContributionResolutionTarget | null; + confirmResolution: () => Promise; + prNumber?: number; +}; + +function ContributionResolutionDialog({ + resolution, + resolutionTarget, + confirmResolution, +}: Pick) { + if (!resolution.pending || !resolutionTarget) return null; + return ( + { + if (!open) resolution.cancel(); + }} + onConfirm={() => { + void confirmResolution(); + }} + /> + ); +} + +function MultiRepoVcsContent({ + primaryButtonConfig, + primaryAction, + isDisabled, + isGitLoading, + baseBranch, + repoNames, + perRepoStatus, + pushDisabled, + pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, + blockedRepositoryName, + repoDisplayName, + callbacks, + resolution, + resolutionTarget, + confirmResolution, + prNumber, +}: Pick< + VcsSplitButtonContentProps, + | "primaryButtonConfig" + | "primaryAction" + | "isDisabled" + | "isGitLoading" + | "baseBranch" + | "repoNames" + | "perRepoStatus" + | "pushDisabled" + | "pullDisabled" + | "showContributionResolution" + | "replaceDisabled" + | "useDisabled" + | "blockedRepositoryName" + | "repoDisplayName" + | "callbacks" + | "resolution" + | "resolutionTarget" + | "confirmResolution" + | "prNumber" +>) { + const multiRepoCallbacks: PerRepoCallbacks = { + onCommit: (repo) => callbacks.onCommit(repo), + onPR: (repo) => callbacks.onPR(repo), + onPull: (repo) => callbacks.onPull(repo), + onPush: (force, repo) => callbacks.onPush(force, repo), + onRebase: (repo) => callbacks.onRebase(repo), + onMerge: (repo) => callbacks.onMerge(repo), + onReplaceContribution: (repo) => callbacks.onReplaceContribution(repo), + onUseContribution: (repo) => callbacks.onUseContribution(repo), + onViewContribution: (repo) => callbacks.onViewContribution(repo), + }; + return ( + <> + + + + ); +} + +export function VcsSplitButtonContent({ + isMultiRepo, + primaryButtonConfig, + primaryAction, + isDisabled, + isGitLoading, + baseBranch, + hasUpstream, + behindCount, + aheadCount, + pushDisabled, + pullDisabled, + showContributionResolution, + replaceDisabled, + useDisabled, + repoNames, + perRepoStatus, + blockedRepositoryName, + repoDisplayName, + buttonSize, + className, + showDivergencePills, + callbacks, + resolution, + resolutionTarget, + confirmResolution, + prNumber, +}: VcsSplitButtonContentProps) { + if (isMultiRepo) { + return ( + + ); + } + + return ( + <> + callbacks.onPR()} + onPull={() => callbacks.onPull()} + onPush={(force) => callbacks.onPush(force)} + onReplaceContribution={() => callbacks.onReplaceContribution()} + onUseContribution={() => callbacks.onUseContribution()} + onViewPRVersion={() => callbacks.onViewContribution()} + prNumber={prNumber} + onRebase={() => callbacks.onRebase()} + onMerge={() => callbacks.onMerge()} + /> + + + ); +} diff --git a/apps/web/components/vcs-split-button.tsx b/apps/web/components/vcs-split-button.tsx index 31445a28a0..9eead2d79f 100644 --- a/apps/web/components/vcs-split-button.tsx +++ b/apps/web/components/vcs-split-button.tsx @@ -1,42 +1,30 @@ "use client"; -import { memo, useCallback, type ComponentProps, type ReactNode } from "react"; +import { memo, useCallback, useMemo, type ComponentProps, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { IconGitCommit, IconGitPullRequest, - IconGitMerge, IconGitCherryPick, - IconLoader2, - IconChevronDown, - IconCloudDownload, IconCloudUpload, - IconAlertTriangle, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} from "@kandev/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { cn } from "@kandev/ui/lib/utils"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useRemoteContributionRelation } from "@/hooks/domains/session/use-remote-contribution-relation"; import { remoteContributionActionPolicy } from "@/hooks/domains/session/remote-contribution-relation"; import { gitOperationLabel, useGitWithFeedback } from "@/hooks/use-git-with-feedback"; import { useVcsDialogs } from "@/components/vcs/vcs-dialogs"; -import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; +import type { TaskPR } from "@/lib/types/github"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; -import { MultiRepoVcsButton } from "@/components/vcs-multi-repo-menu"; - -const DEFAULT_BASE_BRANCH = "origin/main"; +import { + buildRemoteContributionResolutionTarget, + useRemoteContributionResolution, + useRemoteContributionResolutionConfirmation, +} from "@/components/task/use-remote-contribution-resolution"; +import { openExternalLink } from "@/lib/desktop/external-links"; +import { VcsSplitButtonContent } from "./vcs-split-button-parts"; +import { DEFAULT_BASE_BRANCH } from "./vcs-constants"; export function determinePrimaryAction( uncommittedFileCount: number, @@ -51,7 +39,7 @@ export function determinePrimaryAction( return "commit"; } -type PrimaryButtonConfig = { +export type PrimaryButtonConfig = { icon: ReactNode; label: string; badge: number | null; @@ -158,169 +146,6 @@ function buildPrimaryButtonConfig({ return buildCommitConfig(t, uncommittedFileCount, openCommitDialog); } -type DivergenceTone = "ahead" | "behind"; - -const divergenceToneClass: Record = { - ahead: "border-emerald-500/40 bg-emerald-500/10 text-emerald-500", - behind: "border-yellow-500/40 bg-yellow-500/10 text-yellow-500", -}; - -function DivergencePill({ - tone, - value, - ariaLabel, -}: { - tone: DivergenceTone; - value: number; - ariaLabel: string; -}) { - if (value <= 0) return null; - - return ( - - {tone === "ahead" ? "↑" : "↓"} - {value} - - ); -} - -function GitDivergencePills({ ahead, behind }: { ahead: number; behind: number }) { - const { t } = useTranslation(); - if (ahead <= 0 && behind <= 0) return null; - - // `{{value}}`, not `{{count}}`: the shipped English is count-invariant - // ("1 commits ahead"), and switching to a plural here would change the - // accessible name that E2E specs query by. - return ( - - - - - ); -} - -type VcsDropdownItemsProps = { - disabled: boolean; - baseBranch?: string; - hasUpstream: boolean; - behindCount: number; - aheadCount: number; - pushDisabled: boolean; - pullDisabled: boolean; - onPR: () => void; - onPull: () => void; - onPush: (force: boolean) => void; - onRebase: () => void; - onMerge: () => void; -}; - -function VcsDropdownItems({ - disabled, - baseBranch, - hasUpstream, - behindCount, - aheadCount, - pushDisabled, - pullDisabled, - onPR, - onPull, - onPush, - onRebase, - onMerge, -}: VcsDropdownItemsProps) { - const { t } = useTranslation(); - const remoteActionsDisabledTitle = t("task:remoteActionsDisabledHistoryChanged"); - return ( - - - - {t("integrations:createPr")} - - - - - {t("integrations:pull")} - {hasUpstream && behindCount > 0 && ( - - ↓{behindCount} - - )} - - - - - {t("integrations:push")} - {hasUpstream && aheadCount > 0 && ( - - ↑{aheadCount} - - )} - - - onPush(false)} - disabled={disabled || pushDisabled} - title={pushDisabled ? remoteActionsDisabledTitle : undefined} - > - - {t("integrations:push")} - - onPush(true)} - disabled={disabled || pushDisabled} - title={pushDisabled ? remoteActionsDisabledTitle : undefined} - > - - {t("integrations:forcePush")} - - - - - - - {t("integrations:rebase")} - {/* The branch is a git ref — data. One message with the ref - interpolated, not an "onto" stem concatenated onto it. */} - - {t("integrations:ontoBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} - - - - - {t("integrations:merge")} - - {t("integrations:fromBranch", { branch: baseBranch || DEFAULT_BASE_BRANCH })} - - - - ); -} - type VcsSplitButtonProps = { sessionId: string | null; baseBranch?: string; @@ -372,6 +197,45 @@ function useGitActions(git: ReturnType, baseBranch?: strin return { handlePull, handlePush, handleRebase, handleMerge }; } +function useVcsContributionResolution( + sessionId: string | null, + relation: ReturnType["relation"], + repositoryName: string | undefined, + selectedPR: TaskPR | null, + refreshProviderEvidence: ReturnType< + typeof useRemoteContributionRelation + >["refreshProviderEvidence"], +) { + const { t } = useTranslation(); + const resolution = useRemoteContributionResolution(sessionId, refreshProviderEvidence); + const confirmResolution = useRemoteContributionResolutionConfirmation(resolution); + const remoteRepositoryLabel = t("task:remoteRepository"); + const resolutionTarget = useMemo( + () => + buildRemoteContributionResolutionTarget( + relation, + repositoryName, + selectedPR, + remoteRepositoryLabel, + ), + [relation, repositoryName, selectedPR, remoteRepositoryLabel], + ); + return { resolution, confirmResolution, resolutionTarget }; +} + +function useVcsContributionState(sessionId: string | null) { + const contribution = useRemoteContributionRelation(sessionId); + const remoteActionPolicy = remoteContributionActionPolicy(contribution.relation); + const resolutionState = useVcsContributionResolution( + sessionId, + contribution.relation, + contribution.repositoryName, + contribution.selectedPR, + contribution.refreshProviderEvidence, + ); + return { ...contribution, remoteActionPolicy, ...resolutionState }; +} + const VcsSplitButton = memo(function VcsSplitButton({ sessionId, baseBranch, @@ -381,10 +245,15 @@ const VcsSplitButton = memo(function VcsSplitButton({ const { t } = useTranslation(); const git = useSessionGit(sessionId); const { openCommitDialog, openPRDialog } = useVcsDialogs(); - const activePR = useActiveTaskPR(); - const { relation, repositoryName } = useRemoteContributionRelation(sessionId); - const remoteActionPolicy = remoteContributionActionPolicy(relation); - const hasOpenPR = activePR?.state === "open"; + const { + relation, + repositoryName, + selectedPR, + remoteActionPolicy, + resolution, + confirmResolution, + resolutionTarget, + } = useVcsContributionState(sessionId); const { handlePull, handlePush, handleRebase, handleMerge } = useGitActions(git, baseBranch); const repoDisplayName = useRepoDisplayName(sessionId); @@ -396,15 +265,12 @@ const VcsSplitButton = memo(function VcsSplitButton({ const pullCount = git.pullBehind; const isDisabled = git.isLoading || !sessionId; const isGitLoading = git.isLoading; - // Multi-repo when there's more than one named repo. Single-repo workspaces - // get either a single empty-name entry or no entries at all in repoNames. - const isMultiRepo = git.repoNames.length > 1; - + const showContributionResolution = relation.action === "diverged_replace"; const primaryAction = determinePrimaryAction( uncommittedFileCount, aheadCount, behindCount, - hasOpenPR, + selectedPR?.state === "open", ); const primaryButtonConfig = buildPrimaryButtonConfig({ t, @@ -420,34 +286,9 @@ const VcsSplitButton = memo(function VcsSplitButton({ }); const showDivergencePills = primaryAction !== "commit"; - if (isMultiRepo) { - return ( - openCommitDialog(repo), - onPR: (repo) => openPRDialog(repo), - onPull: handlePull, - onPush: handlePush, - onRebase: handleRebase, - onMerge: handleMerge, - }} - /> - ); - } - return ( - 1} primaryButtonConfig={primaryButtonConfig} primaryAction={primaryAction} isDisabled={isDisabled} @@ -458,115 +299,44 @@ const VcsSplitButton = memo(function VcsSplitButton({ aheadCount={aheadCount} pushDisabled={remoteActionPolicy.pushDisabled} pullDisabled={remoteActionPolicy.pullDisabled} + showContributionResolution={showContributionResolution} + replaceDisabled={remoteActionPolicy.replaceDisabled} + useDisabled={remoteActionPolicy.useDisabled} + repoNames={git.repoNames} + perRepoStatus={git.perRepoStatus} + blockedRepositoryName={repositoryName} + repoDisplayName={repoDisplayName} buttonSize={buttonSize} className={className} showDivergencePills={showDivergencePills} - onPR={() => openPRDialog()} - onPull={() => handlePull()} - onPush={(force) => handlePush(force)} - onRebase={() => handleRebase()} - onMerge={() => handleMerge()} + callbacks={{ + onCommit: (repo) => openCommitDialog(repo), + onPR: (repo) => openPRDialog(repo), + onPull: handlePull, + onPush: handlePush, + onRebase: handleRebase, + onMerge: handleMerge, + onReplaceContribution: (repo) => { + if (resolutionTarget && resolutionTarget.repo === repo) { + resolution.requestReplace(resolutionTarget); + } + }, + onUseContribution: (repo) => { + if (resolutionTarget && resolutionTarget.repo === repo) { + resolution.requestUse(resolutionTarget); + } + }, + onViewContribution: (repo) => { + if (resolutionTarget?.repo !== repo || !selectedPR?.pr_url) return; + void openExternalLink(selectedPR.pr_url).catch(() => undefined); + }, + }} + resolution={resolution} + resolutionTarget={resolutionTarget} + confirmResolution={confirmResolution} + prNumber={selectedPR?.pr_number} /> ); }); -function SingleRepoVcsButton({ - primaryButtonConfig, - primaryAction, - isDisabled, - isGitLoading, - baseBranch, - hasUpstream, - behindCount, - aheadCount, - pushDisabled, - pullDisabled, - onPR, - onPull, - onPush, - onRebase, - onMerge, - className, - buttonSize = "sm", - showDivergencePills = false, -}: { - primaryButtonConfig: PrimaryButtonConfig; - primaryAction: "commit" | "push" | "pr" | "rebase"; - isDisabled: boolean; - isGitLoading: boolean; - baseBranch?: string; - hasUpstream: boolean; - behindCount: number; - aheadCount: number; - pushDisabled: boolean; - pullDisabled: boolean; - onPR: () => void; - onPull: () => void; - onPush: (force: boolean) => void; - onRebase: () => void; - onMerge: () => void; - className?: string; - buttonSize?: ComponentProps["size"]; - showDivergencePills?: boolean; -}) { - const { t } = useTranslation(); - return ( -
- - - - - {primaryButtonConfig.tooltip} - - - - - - - -
- ); -} - export { VcsSplitButton }; diff --git a/apps/web/e2e/tests/git/changes-panel-section-order.spec.ts b/apps/web/e2e/tests/git/changes-panel-section-order.spec.ts index 260c2a73c7..911ece63e7 100644 --- a/apps/web/e2e/tests/git/changes-panel-section-order.spec.ts +++ b/apps/web/e2e/tests/git/changes-panel-section-order.spec.ts @@ -114,7 +114,14 @@ async function seedWorkflow(apiClient: ApiClient, workspaceId: string) { /** * Seed mock GitHub PR data: one PR with two files and one commit. */ -async function seedMockPR(apiClient: ApiClient) { +function currentCheckoutBranch(backend: { tmpDir: string }): string { + return execSync("git branch --show-current", { + cwd: path.join(backend.tmpDir, "repos", "e2e-repo"), + encoding: "utf8", + }).trim(); +} + +async function seedMockPR(apiClient: ApiClient, headBranch: string) { await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("test-user"); @@ -123,7 +130,7 @@ async function seedMockPR(apiClient: ApiClient) { number: 300, title: "Add feature", state: "open", - head_branch: "feat/order-test", + head_branch: headBranch, base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -181,8 +188,6 @@ test.describe("Changes panel section ordering", () => { apiClient, seedData.workspaceId, ); - await seedMockPR(apiClient); - const profile = await createStandardProfile(apiClient, "Order Test Profile"); const task = await apiClient.createTask(seedData.workspaceId, "Order Test Task", { workflow_id: workflow.id, @@ -200,6 +205,9 @@ test.describe("Changes panel section ordering", () => { timeout: 45_000, }); + const checkoutBranch = currentCheckoutBranch(backend); + await seedMockPR(apiClient, checkoutBranch); + // Associate PR with task await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, @@ -208,7 +216,7 @@ test.describe("Changes panel section ordering", () => { pr_number: 300, pr_url: "https://github.com/testorg/testrepo/pull/300", pr_title: "Add feature", - head_branch: "feat/order-test", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", additions: 40, @@ -263,6 +271,7 @@ test.describe("Changes panel section ordering", () => { testPage, apiClient, seedData, + backend, }) => { test.setTimeout(120_000); @@ -270,8 +279,6 @@ test.describe("Changes panel section ordering", () => { apiClient, seedData.workspaceId, ); - await seedMockPR(apiClient); - const profile = await createStandardProfile(apiClient, "PR Only Profile"); const task = await apiClient.createTask(seedData.workspaceId, "PR Only Task", { workflow_id: workflow.id, @@ -288,6 +295,9 @@ test.describe("Changes panel section ordering", () => { timeout: 45_000, }); + const checkoutBranch = currentCheckoutBranch(backend); + await seedMockPR(apiClient, checkoutBranch); + await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, owner: "testorg", @@ -295,7 +305,7 @@ test.describe("Changes panel section ordering", () => { pr_number: 300, pr_url: "https://github.com/testorg/testrepo/pull/300", pr_title: "Add feature", - head_branch: "feat/order-test", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", additions: 40, @@ -342,6 +352,7 @@ test.describe("Changes panel section ordering", () => { testPage, apiClient, seedData, + backend, }) => { test.setTimeout(120_000); @@ -401,6 +412,22 @@ test.describe("Changes panel section ordering", () => { timeout: 45_000, }); + const checkoutBranch = currentCheckoutBranch(backend); + await apiClient.mockGitHubAddPRs([ + { + number: 301, + title: "Large PR", + state: "open", + head_branch: checkoutBranch, + base_branch: "main", + author_login: "test-user", + repo_owner: "testorg", + repo_name: "testrepo", + additions: 100, + deletions: 10, + }, + ]); + await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, owner: "testorg", @@ -408,7 +435,7 @@ test.describe("Changes panel section ordering", () => { pr_number: 301, pr_url: "https://github.com/testorg/testrepo/pull/301", pr_title: "Large PR", - head_branch: "feat/large", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", additions: 100, @@ -606,6 +633,22 @@ test.describe("PR diff regression", () => { timeout: 45_000, }); + const checkoutBranch = currentCheckoutBranch(backend); + await apiClient.mockGitHubAddPRs([ + { + number: 301, + title: "Desktop overlap PR diff test", + state: "open", + head_branch: checkoutBranch, + base_branch: "main", + author_login: "test-user", + repo_owner: "testorg", + repo_name: "testrepo", + additions: 2, + deletions: 0, + }, + ]); + // Associate PR with task await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, @@ -614,7 +657,7 @@ test.describe("PR diff regression", () => { pr_number: 301, pr_url: "https://github.com/testorg/testrepo/pull/301", pr_title: "Desktop overlap PR diff test", - head_branch: "feat/desktop-overlap", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", additions: 2, diff --git a/apps/web/e2e/tests/git/git-changes-panel.spec.ts b/apps/web/e2e/tests/git/git-changes-panel.spec.ts index 910ca4eb49..e5ca21c10f 100644 --- a/apps/web/e2e/tests/git/git-changes-panel.spec.ts +++ b/apps/web/e2e/tests/git/git-changes-panel.spec.ts @@ -105,6 +105,45 @@ async function createStandardProfile(apiClient: ApiClient, name: string) { }); } +function createRewrittenProviderHistory( + git: GitHelper, + branch: string, +): { + head: string; + commits: Array<{ + sha: string; + message: string; + author_login: string; + author_date: string; + stats_available: boolean; + }>; +} { + git.exec(`git checkout -B kandev-e2e-provider-rewrite origin/main`); + const commits: Array<{ + sha: string; + message: string; + author_login: string; + author_date: string; + stats_available: boolean; + }> = []; + for (let index = 1; index <= 15; index += 1) { + const message = `Rewritten provider commit ${index}`; + git.createFile(`provider-rewrite-${index}.txt`, message); + git.stageFile(`provider-rewrite-${index}.txt`); + const sha = git.commit(message); + commits.push({ + sha, + message, + author_login: "remote-contributor", + author_date: `2026-08-${String(index).padStart(2, "0")}T12:00:00Z`, + stats_available: false, + }); + } + git.exec(`git push --force origin HEAD:refs/heads/${branch}`); + git.exec("git checkout -f main"); + return { head: commits[commits.length - 1].sha, commits }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -405,6 +444,7 @@ test.describe("Git Changes Panel", () => { testPage, apiClient, seedData, + backend, }) => { const profile = await createStandardProfile(apiClient, "Git PR-only Detail Profile"); const task = await apiClient.createTaskWithAgent( @@ -421,6 +461,10 @@ test.describe("Git Changes Panel", () => { const remoteSha = "d".repeat(40); const remoteMessage = "Force-pushed remote commit"; + const checkoutBranch = execSync("git branch --show-current", { + cwd: path.join(backend.tmpDir, "repos", "e2e-repo"), + encoding: "utf8", + }).trim(); await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("remote-author"); await apiClient.mockGitHubAddPRs([ @@ -428,7 +472,7 @@ test.describe("Git Changes Panel", () => { number: 2253, title: "Force-pushed PR", state: "open", - head_branch: "feature/stale-worktree", + head_branch: checkoutBranch, base_branch: "main", author_login: "remote-author", repo_owner: "testorg", @@ -469,7 +513,7 @@ test.describe("Git Changes Panel", () => { pr_number: 2253, pr_url: "https://github.com/testorg/testrepo/pull/2253", pr_title: "Force-pushed PR", - head_branch: "feature/stale-worktree", + head_branch: checkoutBranch, base_branch: "main", author_login: "remote-author", }); @@ -1375,6 +1419,7 @@ test.describe("Git Changes Panel", () => { git.createFile("pr-dedup.txt", "dedup content"); git.stageFile("pr-dedup.txt"); const sha = git.commit("Commit in PR"); + const checkoutBranch = git.exec("git branch --show-current").trim(); // Click the Changes tab and verify commit appears locally await session.clickTab("Changes"); @@ -1391,7 +1436,7 @@ test.describe("Git Changes Panel", () => { pr_number: 1, pr_url: "https://github.com/test-org/test-repo/pull/1", pr_title: "Test PR", - head_branch: "feature", + head_branch: checkoutBranch, base_branch: "main", author_login: "e2e-test", }); @@ -1469,6 +1514,7 @@ test.describe("Git Changes Panel", () => { git.createFile("unpushed.txt", "unpushed content"); git.stageFile("unpushed.txt"); git.commit("Unpushed commit"); + const checkoutBranch = git.exec("git branch --show-current").trim(); // Mock a PR that only contains the first commit await apiClient.mockGitHubAssociateTaskPR({ @@ -1478,7 +1524,7 @@ test.describe("Git Changes Panel", () => { pr_number: 2, pr_url: "https://github.com/test-org/test-repo/pull/2", pr_title: "Partial PR", - head_branch: "feature", + head_branch: checkoutBranch, base_branch: "main", author_login: "e2e-test", }); @@ -1524,7 +1570,7 @@ test.describe("Git Changes Panel", () => { await expect(pushedRow.locator(".tabler-icon-git-commit")).toBeVisible({ timeout: 5_000 }); }); - test("separates rewritten provider history from the local checkout", async ({ + test("local-first contribution keeps rewritten provider history separate", async ({ testPage, apiClient, seedData, @@ -1562,6 +1608,8 @@ test.describe("Git Changes Panel", () => { git.commit(`Contribution commit ${index}`); } const localHead = git.getCurrentSha(); + const providerBranch = "feature/rewritten-contribution"; + const providerHistory = createRewrittenProviderHistory(git, providerBranch); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, @@ -1575,17 +1623,6 @@ test.describe("Git Changes Panel", () => { }, ); - const providerCommits = Array.from({ length: 15 }, (_, index) => ({ - // These are intentionally valid, unique provider SHAs that do not exist - // in the local checkout. Their absence proves graph drift, not metadata - // mismatch, and keeps the provider fixture independent of local Git. - sha: `${String(index + 1).padStart(2, "0")}${"a".repeat(38)}`, - message: `Rewritten provider commit ${index + 1}`, - author_login: "remote-contributor", - author_date: `2026-08-${String(index + 1).padStart(2, "0")}T12:00:00Z`, - stats_available: false, - })); - await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("remote-contributor"); await apiClient.mockGitHubAddPRs([ @@ -1593,15 +1630,15 @@ test.describe("Git Changes Panel", () => { number: 901, title: "Rewritten contribution", state: "open", - head_branch: "feature/rewritten-contribution", + head_branch: providerBranch, base_branch: "main", author_login: "remote-contributor", repo_owner: "testorg", repo_name: "testrepo", - head_sha: providerCommits[providerCommits.length - 1].sha, + head_sha: providerHistory.head, }, ]); - await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 901, providerCommits); + await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 901, providerHistory.commits); await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, owner: "testorg", @@ -1618,24 +1655,28 @@ test.describe("Git Changes Panel", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); + git.exec(`git checkout -B ${providerBranch} ${localHead}`); + git.exec(`git branch --set-upstream-to=origin/${providerBranch} ${providerBranch}`); await session.clickTab("Changes"); const changes = testPage.getByTestId("changes-panel"); - await expect(changes.getByTestId("remote-contribution-drift-warning")).toBeVisible({ - timeout: 30_000, - }); + await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0); const providerSection = changes.getByTestId("current-pr-commits-section"); const localSection = changes.getByTestId("local-checkout-commits-section"); await expect(providerSection).toBeVisible({ timeout: 10_000 }); await expect(localSection).toBeVisible({ timeout: 10_000 }); await expect( providerSection.getByTestId("current-pr-commits-section-collapse-toggle"), - ).toContainText("Current PR commits"); + ).toContainText("PR #901 version"); await expect( localSection.getByTestId("local-checkout-commits-section-collapse-toggle"), ).toContainText("Local checkout commits"); - await expect(providerSection.locator('[data-testid^="commit-row-"]')).toHaveCount(15); + await expect( + providerSection.getByTestId("current-pr-commits-section-collapse-toggle"), + ).toHaveAttribute("aria-expanded", "false"); await expect(localSection.locator('[data-testid^="commit-row-"]')).toHaveCount(6); + await providerSection.getByTestId("current-pr-commits-section-collapse-toggle").click(); + await expect(providerSection.locator('[data-testid^="commit-row-"]')).toHaveCount(15); // A rewritten provider history must not label the preserved checkout as // six unpushed commits, and reading the panel must not mutate the checkout. @@ -1644,26 +1685,141 @@ test.describe("Git Changes Panel", () => { expect(git.getCurrentSha()).toBe(localHead); expect(git.exec("git status --porcelain").trim()).toBe(""); - const changesPull = changes.getByRole("button", { name: /^Pull$/ }); + const changesPull = changes.getByRole("button", { name: /^Pull/ }); await expect(changesPull).toBeDisabled(); + const driftWarning = changes.getByTestId("header-remote-contribution-warning"); + await expect(driftWarning).toBeVisible(); + await driftWarning.click(); + const driftMenu = testPage.getByTestId("header-remote-contribution-menu"); + await expect(driftMenu).toBeVisible(); + await expect(driftMenu.getByTestId("header-replace-pr-branch")).toBeVisible(); + await expect(driftMenu.getByTestId("header-use-pr-version")).toBeVisible(); + await expect(driftMenu.getByTestId("header-view-pr-version")).toContainText("PR #901 version"); + const replaceInfo = driftMenu.getByRole("img", { + name: /Replace the published PR branch/, + }); + await replaceInfo.hover(); + await expect(replaceInfo).not.toHaveAttribute("title"); + const openTooltip = testPage.locator( + '[data-slot="tooltip-content"]:not([data-state="closed"])', + ); + await expect(openTooltip).toContainText("Replace the published PR branch"); + const useInfo = driftMenu.getByRole("img", { name: /Use the current PR version/ }); + await useInfo.hover(); + await expect(openTooltip).toContainText("Use the current PR version"); + await driftMenu.getByTestId("header-replace-pr-branch").click(); + const resolutionDialog = testPage.getByTestId("remote-contribution-resolution-dialog"); + await expect(resolutionDialog).toBeVisible(); + await expect(resolutionDialog).toContainText(providerHistory.head); + await resolutionDialog.getByRole("button", { name: "Cancel" }).click(); + await expect(resolutionDialog).toBeHidden(); await prCapture.screenshot("remote-contribution-drift-desktop", { caption: "Rewritten provider history is separated from the preserved local checkout", }); + }); - // The review dialog uses the desktop VCS split button. It must expose the - // same safety policy as the Changes panel. - await changes.getByRole("button", { name: "Review" }).click(); - const reviewDialog = testPage.getByRole("dialog", { name: "Review Changes" }); - await expect(reviewDialog).toBeVisible({ timeout: 15_000 }); - await reviewDialog.getByRole("button", { name: "Open VCS options" }).click(); - const openMenu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); - await expect(openMenu).toHaveCount(1); - await expect( - openMenu.locator('[data-slot="dropdown-menu-item"]').filter({ hasText: /^Pull$/ }), - ).toHaveAttribute("aria-disabled", "true"); - await expect( - openMenu.locator('[data-slot="dropdown-menu-sub-trigger"]').filter({ hasText: /^Push/ }), - ).toHaveAttribute("aria-disabled", "true"); + test("uses the PR for the checked-out branch when an older PR is merged", async ({ + testPage, + apiClient, + seedData, + backend, + }) => { + test.setTimeout(120_000); + + const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); + const gitEnv = { + ...process.env, + HOME: backend.tmpDir, + GIT_AUTHOR_NAME: "E2E Test", + GIT_AUTHOR_EMAIL: "e2e@test.local", + GIT_COMMITTER_NAME: "E2E Test", + GIT_COMMITTER_EMAIL: "e2e@test.local", + }; + const git = new GitHelper(repoDir, gitEnv); + const currentBranch = "feature/current-contribution"; + const historicalBranch = "feature/merged-contribution"; + + git.exec("git checkout -f main"); + if (git.exec("git remote").split(/\r?\n/).includes("origin")) { + git.exec(`git remote set-url origin "${seedData.repositoryRemoteURL}"`); + } else { + git.exec(`git remote add origin "${seedData.repositoryRemoteURL}"`); + } + git.exec("git fetch origin main"); + git.exec("git reset --hard origin/main"); + git.exec("git clean -fd"); + git.exec(`git checkout -B ${currentBranch}`); + git.createFile("current-contribution.txt", "current PR commit"); + git.stageFile("current-contribution.txt"); + const currentHead = git.commit("Current PR commit"); + git.exec(`git push --force --set-upstream origin ${currentBranch}`); + + const historicalHistory = createRewrittenProviderHistory(git, historicalBranch); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Git Current Branch PR Selection", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await apiClient.mockGitHubReset(); + await apiClient.mockGitHubSetUser("branch-owner"); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 910, + pr_url: "https://github.com/testorg/testrepo/pull/910", + pr_title: "Merged contribution", + head_branch: historicalBranch, + base_branch: "main", + author_login: "branch-owner", + state: "merged", + }); + await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 910, historicalHistory.commits); + await apiClient.mockGitHubAssociateTaskPR({ + task_id: task.id, + owner: "testorg", + repo: "testrepo", + pr_number: 911, + pr_url: "https://github.com/testorg/testrepo/pull/911", + pr_title: "Current contribution", + head_branch: currentBranch, + base_branch: "main", + author_login: "branch-owner", + state: "open", + }); + await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 911, [ + { + sha: currentHead, + message: "Current PR commit", + author_login: "branch-owner", + author_date: "2026-08-12T12:00:00Z", + stats_available: false, + }, + ]); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 45_000 }); + git.exec(`git checkout -B ${currentBranch} ${currentHead}`); + git.exec(`git branch --set-upstream-to=origin/${currentBranch} ${currentBranch}`); + await session.clickTab("Changes"); + + const changes = testPage.getByTestId("changes-panel"); + await expect(changes.getByTestId("commits-section")).toBeVisible({ timeout: 30_000 }); + await expect(changes.getByText("Current PR commit")).toBeVisible(); + await expect(changes.getByText("Rewritten provider commit 15")).toHaveCount(0); + await expect(changes.getByTestId("header-remote-contribution-warning")).toHaveCount(0); + await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0); + await expect(testPage.getByRole("button", { name: /2 PRs/ })).toBeVisible(); + expect(git.getCurrentSha()).toBe(currentHead); }); test("keeps a one-commit local-ahead contribution pushable", async ({ @@ -1699,6 +1855,8 @@ test.describe("Git Changes Panel", () => { git.createFile("local-ahead-contribution.txt", "one local commit ahead"); git.stageFile("local-ahead-contribution.txt"); const localHead = git.commit("Local maintainer contribution"); + const providerBranch = "feature/local-ahead"; + git.exec(`git push origin ${providerHead}:refs/heads/${providerBranch}`); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, @@ -1750,13 +1908,15 @@ test.describe("Git Changes Panel", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); + git.exec(`git checkout -B ${providerBranch} ${localHead}`); + git.exec(`git branch --set-upstream-to=origin/${providerBranch} ${providerBranch}`); await session.clickTab("Changes"); const changes = testPage.getByTestId("changes-panel"); await expect(changes.getByTestId("commits-section")).toBeVisible({ timeout: 30_000 }); await expect(changes.getByText("Local maintainer contribution")).toBeVisible({ timeout: 15_000, }); - await expect(changes.getByTestId("remote-contribution-drift-warning")).toHaveCount(0); + await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0); await changes.getByRole("button", { name: "Review" }).click(); const reviewDialog = testPage.getByRole("dialog", { name: "Review Changes" }); diff --git a/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts b/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts index c5c499686e..fba4e5d0f4 100644 --- a/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts +++ b/apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts @@ -23,14 +23,43 @@ function seedStaleCheckout(git: GitHelper, remoteUrl: string): string { return git.getCurrentSha(); } -function rewrittenProviderCommits() { - return Array.from({ length: 15 }, (_, index) => ({ - sha: `${String(index + 1).padStart(2, "0")}${"b".repeat(38)}`, - message: `Mobile rewritten provider commit ${index + 1}`, - author_login: "mobile-remote-contributor", - author_date: `2026-08-${String(index + 1).padStart(2, "0")}T12:00:00Z`, - stats_available: false, - })); +function createRewrittenProviderHistory( + git: GitHelper, + branch: string, +): { + head: string; + commits: Array<{ + sha: string; + message: string; + author_login: string; + author_date: string; + stats_available: boolean; + }>; +} { + git.exec("git checkout -B kandev-e2e-provider-rewrite origin/main"); + const commits: Array<{ + sha: string; + message: string; + author_login: string; + author_date: string; + stats_available: boolean; + }> = []; + for (let index = 1; index <= 15; index += 1) { + const message = `Mobile rewritten provider commit ${index}`; + git.createFile(`mobile-provider-rewrite-${index}.txt`, message); + git.stageFile(`mobile-provider-rewrite-${index}.txt`); + const sha = git.commit(message); + commits.push({ + sha, + message, + author_login: "mobile-remote-contributor", + author_date: `2026-08-${String(index).padStart(2, "0")}T12:00:00Z`, + stats_available: false, + }); + } + git.exec(`git push --force origin HEAD:refs/heads/${branch}`); + git.exec("git checkout -f main"); + return { head: commits[commits.length - 1].sha, commits }; } async function swipeUpOnElement(page: Page, element: Locator): Promise { @@ -71,7 +100,7 @@ test.describe("Mobile rewritten contribution history", () => { git.exec("git clean -fd"); }); - test("preserves local history and disables remote mutations after a rewrite", async ({ + test("local-first contribution menu preserves local history after a rewrite", async ({ testPage, apiClient, seedData, @@ -81,6 +110,8 @@ test.describe("Mobile rewritten contribution history", () => { const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir)); const localHead = seedStaleCheckout(git, seedData.repositoryRemoteURL); + const providerBranch = "feature/mobile-rewritten"; + const providerHistory = createRewrittenProviderHistory(git, providerBranch); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile Rewritten Contribution History", @@ -92,8 +123,6 @@ test.describe("Mobile rewritten contribution history", () => { repository_ids: [seedData.repositoryId], }, ); - const providerCommits = rewrittenProviderCommits(); - await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("mobile-remote-contributor"); await apiClient.mockGitHubAddPRs([ @@ -101,15 +130,15 @@ test.describe("Mobile rewritten contribution history", () => { number: 902, title: "Mobile rewritten contribution", state: "open", - head_branch: "feature/mobile-rewritten", + head_branch: providerBranch, base_branch: "main", author_login: "mobile-remote-contributor", repo_owner: "testorg", repo_name: "testrepo", - head_sha: providerCommits[providerCommits.length - 1].sha, + head_sha: providerHistory.head, }, ]); - await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 902, providerCommits); + await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 902, providerHistory.commits); await apiClient.mockGitHubAssociateTaskPR({ task_id: task.id, owner: "testorg", @@ -126,18 +155,25 @@ test.describe("Mobile rewritten contribution history", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); + git.exec(`git checkout -B ${providerBranch} ${localHead}`); + git.exec(`git branch --set-upstream-to=origin/${providerBranch} ${providerBranch}`); await testPage.getByRole("button", { name: "Changes" }).tap(); const changes = testPage.getByTestId("mobile-changes-panel"); - await expect(changes.getByTestId("remote-contribution-drift-warning")).toBeVisible({ - timeout: 30_000, - }); + await expect(changes.getByTestId("remote-contribution-drift-status")).toHaveCount(0); const providerSection = changes.getByTestId("current-pr-commits-section"); const localSection = changes.getByTestId("local-checkout-commits-section"); await expect(providerSection).toBeVisible({ timeout: 10_000 }); await expect(localSection).toBeVisible({ timeout: 10_000 }); - await expect(providerSection.locator('[data-testid^="commit-row-"]')).toHaveCount(15); + await expect( + providerSection.getByTestId("current-pr-commits-section-collapse-toggle"), + ).toContainText("PR #902 version"); + await expect( + providerSection.getByTestId("current-pr-commits-section-collapse-toggle"), + ).toHaveAttribute("aria-expanded", "false"); await expect(localSection.locator('[data-testid^="commit-row-"]')).toHaveCount(6); + await providerSection.getByTestId("current-pr-commits-section-collapse-toggle").tap(); + await expect(providerSection.locator('[data-testid^="commit-row-"]')).toHaveCount(15); await expect(providerSection.locator(".tabler-icon-arrow-up")).toHaveCount(0); await expect(localSection.locator(".tabler-icon-arrow-up")).toHaveCount(0); @@ -153,9 +189,6 @@ test.describe("Mobile rewritten contribution history", () => { await swipeUpOnElement(testPage, scroller); await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); - const changesPull = changes.getByRole("button", { name: /^Pull$/ }); - await expect(changesPull).toBeDisabled(); - const gitActions = testPage.getByTestId("mobile-git-actions"); await gitActions.tap(); const openMenu = testPage.locator('[data-slot="dropdown-menu-content"][data-state="open"]'); @@ -165,10 +198,20 @@ test.describe("Mobile rewritten contribution history", () => { "aria-disabled", "true", ); - await expect(menuItems.filter({ hasText: /^Pull$/ })).toHaveAttribute("aria-disabled", "true"); - await expect( - openMenu.locator('[data-slot="dropdown-menu-sub-trigger"]').filter({ hasText: /^Push/ }), - ).toHaveAttribute("aria-disabled", "true"); + await expect(openMenu.getByTestId("mobile-replace-pr-branch")).toBeVisible(); + await expect(openMenu.getByTestId("mobile-use-pr-version")).toBeVisible(); + await expect(openMenu.getByTestId("mobile-view-pr-version")).toContainText("PR #902 version"); + await expect(openMenu).toContainText("Replace the published PR branch"); + await expect(openMenu).toContainText("Use the current PR version"); + await expect(openMenu).toContainText("Open PR #902 version"); + await expect(menuItems.filter({ hasText: /^Pull$/ })).toHaveCount(0); + await expect(openMenu.locator('[data-slot="dropdown-menu-sub-trigger"]')).toHaveCount(0); + await openMenu.getByTestId("mobile-replace-pr-branch").tap(); + const drawer = testPage.getByTestId("mobile-remote-contribution-drawer"); + await expect(drawer).toBeVisible(); + await expect(drawer).toContainText(providerHistory.head); + await expect(testPage.getByTestId("mobile-remote-contribution-confirm")).toHaveClass(/h-11/); + await drawer.getByRole("button", { name: "Cancel" }).tap(); expect(git.getCurrentSha()).toBe(localHead); expect(git.exec("git status --porcelain").trim()).toBe(""); @@ -178,7 +221,7 @@ test.describe("Mobile rewritten contribution history", () => { ), ).toBe(true); await prCapture.screenshot("remote-contribution-drift-mobile", { - caption: "Pixel 5 keeps the preserved checkout separate and blocks remote mutations", + caption: "Pixel 5 preserves the local checkout and offers explicit provider version choices", }); }); }); diff --git a/apps/web/e2e/tests/pr/pr-detection.spec.ts b/apps/web/e2e/tests/pr/pr-detection.spec.ts index 328e27904b..3cc9ce9dee 100644 --- a/apps/web/e2e/tests/pr/pr-detection.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detection.spec.ts @@ -362,13 +362,13 @@ test.describe("PR external detection", () => { workflow_id: workflow.id, workflow_step_id: inboxStep.id, agent_profile_id: seedData.agentProfileId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: "main" }], }); const helperTask = await apiClient.createTask(seedData.workspaceId, "Helper Task", { workflow_id: workflow.id, workflow_step_id: inboxStep.id, agent_profile_id: seedData.agentProfileId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: "main" }], }); const kanban = new KanbanPage(testPage); @@ -392,6 +392,8 @@ test.describe("PR external detection", () => { const session = new SessionPage(testPage); await session.waitForLoad(); + const checkoutBranch = "main"; + // --- Verify NO PR button initially --- await expect(session.prTopbarButton()).not.toBeVisible({ timeout: 5_000 }); @@ -401,7 +403,7 @@ test.describe("PR external detection", () => { number: 42, title: "Add feature X", state: "open", - head_branch: "feat/feature-x", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -432,7 +434,7 @@ test.describe("PR external detection", () => { pr_number: 42, pr_url: "https://github.com/testorg/testrepo/pull/42", pr_title: "Add feature X", - head_branch: "feat/feature-x", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", additions: 50, diff --git a/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts b/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts index 85228bf4c2..4cdd7fde21 100644 --- a/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts +++ b/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts @@ -52,7 +52,7 @@ test.describe("PR switcher changes panel", () => { number: 101, title: "Fix auth bug", state: "open", - head_branch: "fix/auth", + head_branch: "main", base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -64,7 +64,7 @@ test.describe("PR switcher changes panel", () => { number: 202, title: "Add dashboard", state: "open", - head_branch: "feat/dashboard", + head_branch: "main", base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -112,13 +112,13 @@ test.describe("PR switcher changes panel", () => { workflow_id: workflow.id, workflow_step_id: inboxStep.id, agent_profile_id: seedData.agentProfileId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: "main" }], }); const taskB = await apiClient.createTask(seedData.workspaceId, "Dashboard Task", { workflow_id: workflow.id, workflow_step_id: inboxStep.id, agent_profile_id: seedData.agentProfileId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: "main" }], }); const taskC = await apiClient.createTask(seedData.workspaceId, "No PR Task", { workflow_id: workflow.id, @@ -134,12 +134,24 @@ test.describe("PR switcher changes panel", () => { const kanban = new KanbanPage(testPage); await kanban.goto(); - // Move all tasks to Working → auto_start → mock agent completes → Done + // Move tasks to Working one at a time so their shared E2E checkout stays + // on the branch used by the PR fixtures. await apiClient.moveTask(taskA.id, workflow.id, workingStep.id); - await apiClient.moveTask(taskB.id, workflow.id, workingStep.id); - await apiClient.moveTask(taskC.id, workflow.id, workingStep.id); + await expect(kanban.taskCardInColumn("Auth Fix Task", doneStep.id)).toBeVisible({ + timeout: 45_000, + }); - // Associate PRs with tasks (Task C has no PR) + await apiClient.moveTask(taskB.id, workflow.id, workingStep.id); + await expect(kanban.taskCardInColumn("Dashboard Task", doneStep.id)).toBeVisible({ + timeout: 45_000, + }); + + await apiClient.moveTask(taskC.id, workflow.id, workingStep.id); + await expect(kanban.taskCardInColumn("No PR Task", doneStep.id)).toBeVisible({ + timeout: 45_000, + }); + + // Associate PRs with tasks (Task C has no PR). await apiClient.mockGitHubAssociateTaskPR({ task_id: taskA.id, owner: "testorg", @@ -147,7 +159,7 @@ test.describe("PR switcher changes panel", () => { pr_number: 101, pr_url: "https://github.com/testorg/testrepo/pull/101", pr_title: "Fix auth bug", - head_branch: "fix/auth", + head_branch: "main", base_branch: "main", author_login: "test-user", additions: 35, @@ -160,24 +172,13 @@ test.describe("PR switcher changes panel", () => { pr_number: 202, pr_url: "https://github.com/testorg/testrepo/pull/202", pr_title: "Add dashboard", - head_branch: "feat/dashboard", + head_branch: "main", base_branch: "main", author_login: "test-user", additions: 125, deletions: 5, }); - // Wait for all three tasks to reach Done - await expect(kanban.taskCardInColumn("Auth Fix Task", doneStep.id)).toBeVisible({ - timeout: 45_000, - }); - await expect(kanban.taskCardInColumn("Dashboard Task", doneStep.id)).toBeVisible({ - timeout: 45_000, - }); - await expect(kanban.taskCardInColumn("No PR Task", doneStep.id)).toBeVisible({ - timeout: 45_000, - }); - // --- Click Task A to enter session view --- await kanban.taskCardInColumn("Auth Fix Task", doneStep.id).click(); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); diff --git a/apps/web/e2e/tests/review/external-vcs-file-link.spec.ts b/apps/web/e2e/tests/review/external-vcs-file-link.spec.ts index fb15a65628..5199a40038 100644 --- a/apps/web/e2e/tests/review/external-vcs-file-link.spec.ts +++ b/apps/web/e2e/tests/review/external-vcs-file-link.spec.ts @@ -45,13 +45,14 @@ async function createGitHubRepository( return apiClient.createRepository(workspaceId, repositoryPath, "main", options); } -function trustedGitHubTaskRepository(repositoryName = GITHUB_REPOSITORY) { +function trustedGitHubTaskRepository(repositoryName = GITHUB_REPOSITORY, checkoutBranch?: string) { return { remote_url: `https://github.com/${GITHUB_OWNER}/${repositoryName}.git`, provider: "github", provider_owner: GITHUB_OWNER, provider_name: repositoryName, base_branch: "main", + ...(checkoutBranch ? { checkout_branch: checkoutBranch } : {}), }; } @@ -85,7 +86,7 @@ test.describe("External VCS file links", () => { number: 81, title: "External file link", state: "open", - head_branch: "feature/external-file-link", + head_branch: "main", base_branch: "main", author_login: "reviewer", repo_owner: GITHUB_OWNER, @@ -102,6 +103,7 @@ test.describe("External VCS file links", () => { }, ]); + const checkoutBranch = "main"; const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Published external file link", @@ -110,7 +112,7 @@ test.describe("External VCS file links", () => { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repositories: [trustedGitHubTaskRepository()], + repositories: [trustedGitHubTaskRepository(GITHUB_REPOSITORY, checkoutBranch)], }, ); await apiClient.mockGitHubAssociateTaskPR({ @@ -120,7 +122,7 @@ test.describe("External VCS file links", () => { pr_number: 81, pr_url: `https://github.com/${GITHUB_OWNER}/${GITHUB_REPOSITORY}/pull/81`, pr_title: "External file link", - head_branch: "feature/external-file-link", + head_branch: checkoutBranch, base_branch: "main", author_login: "reviewer", }); @@ -139,8 +141,7 @@ test.describe("External VCS file links", () => { await expect(review).toBeVisible(); const fileHeader = review.getByTestId("review-file-header").filter({ hasText: PUBLISHED_FILE }); const link = fileHeader.getByRole("link", { name: "Open file in GitHub" }); - const expectedURL = - "https://github.com/testorg/external-file-links/blob/feature%2Fexternal-file-link/published-link.ts"; + const expectedURL = `https://github.com/testorg/external-file-links/blob/${encodeURIComponent(checkoutBranch)}/published-link.ts`; await expect(link).toHaveAttribute("href", expectedURL); await expect(link).toHaveAttribute("target", "_blank"); await openExternalLink(testPage, link, expectedURL); diff --git a/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts b/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts index 6355ca267d..5ab620a096 100644 --- a/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts +++ b/apps/web/e2e/tests/task/mobile-changes-panel.spec.ts @@ -186,6 +186,7 @@ test.describe("Mobile changes panel", () => { apiClient, seedData, }) => { + const checkoutBranch = "main"; const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile PR File Diff", @@ -194,7 +195,7 @@ test.describe("Mobile changes panel", () => { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: checkoutBranch }], }, ); @@ -202,7 +203,6 @@ test.describe("Mobile changes panel", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("test-user"); await apiClient.mockGitHubAddPRs([ @@ -210,7 +210,7 @@ test.describe("Mobile changes panel", () => { number: 42, title: "Mobile PR file diff test", state: "open", - head_branch: "feat/mobile-fix", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -236,7 +236,7 @@ test.describe("Mobile changes panel", () => { pr_number: 42, pr_url: "https://github.com/testorg/testrepo/pull/42", pr_title: "Mobile PR file diff test", - head_branch: "feat/mobile-fix", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", }); @@ -259,6 +259,7 @@ test.describe("Mobile changes panel", () => { apiClient, seedData, }) => { + const checkoutBranch = "main"; const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile PR-only Commit Detail", @@ -267,7 +268,7 @@ test.describe("Mobile changes panel", () => { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: checkoutBranch }], }, ); @@ -280,7 +281,7 @@ test.describe("Mobile changes panel", () => { number: 2254, title: "Mobile force-pushed PR", state: "open", - head_branch: "feature/mobile-stale", + head_branch: checkoutBranch, base_branch: "main", author_login: "mobile-remote-author", repo_owner: "testorg", @@ -321,7 +322,7 @@ test.describe("Mobile changes panel", () => { pr_number: 2254, pr_url: "https://github.com/testorg/testrepo/pull/2254", pr_title: "Mobile force-pushed PR", - head_branch: "feature/mobile-stale", + head_branch: checkoutBranch, base_branch: "main", author_login: "mobile-remote-author", }); @@ -360,6 +361,7 @@ test.describe("Mobile changes panel", () => { seedData, backend, }) => { + const checkoutBranch = "main"; const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Mobile PR Overlap Diff", @@ -368,7 +370,7 @@ test.describe("Mobile changes panel", () => { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repositories: [{ repository_id: seedData.repositoryId, checkout_branch: checkoutBranch }], }, ); @@ -376,7 +378,6 @@ test.describe("Mobile changes panel", () => { const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 45_000 }); - await apiClient.mockGitHubReset(); await apiClient.mockGitHubSetUser("test-user"); await apiClient.mockGitHubAddPRs([ @@ -384,7 +385,7 @@ test.describe("Mobile changes panel", () => { number: 44, title: "Mobile overlap PR diff test", state: "open", - head_branch: "feat/mobile-overlap", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", repo_owner: "testorg", @@ -409,7 +410,7 @@ test.describe("Mobile changes panel", () => { pr_number: 44, pr_url: "https://github.com/testorg/testrepo/pull/44", pr_title: "Mobile overlap PR diff test", - head_branch: "feat/mobile-overlap", + head_branch: checkoutBranch, base_branch: "main", author_login: "test-user", }); diff --git a/apps/web/hooks/domains/github/use-active-task-pr-files.ts b/apps/web/hooks/domains/github/use-active-task-pr-files.ts index 7605679652..28383859e9 100644 --- a/apps/web/hooks/domains/github/use-active-task-pr-files.ts +++ b/apps/web/hooks/domains/github/use-active-task-pr-files.ts @@ -33,16 +33,17 @@ function fetchKey(pr: TaskPR): string { * one row per file across every per-repo PR (multi-repo tasks now have one * PR per repo, not just one for the whole task). */ -export function useActiveTaskPRsWithFiles(): { +export function useActiveTaskPRsWithFiles(scopedPRs?: TaskPR[]): { prs: TaskPR[]; filesByPRKey: PRFilesByKey; } { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const prs = useAppStore((s) => { + const taskPRs = useAppStore((s) => { const taskId = s.tasks.activeTaskId; if (!taskId) return EMPTY_PRS; return s.taskPRs.byTaskId[taskId] ?? EMPTY_PRS; }); + const prs = scopedPRs ?? taskPRs; const [fileCache, setFileCache] = useState({ workspaceId, diff --git a/apps/web/hooks/domains/github/use-pr-commits.test.ts b/apps/web/hooks/domains/github/use-pr-commits.test.ts index 92339edec2..041ce40c5a 100644 --- a/apps/web/hooks/domains/github/use-pr-commits.test.ts +++ b/apps/web/hooks/domains/github/use-pr-commits.test.ts @@ -107,4 +107,28 @@ describe("usePRCommits request ownership", () => { await waitFor(() => expect(result.current.providerHead).toBe("provider-head")); expect(result.current.providerCommitsComplete).toBe(true); }); + + it("refreshes provider evidence on demand", async () => { + requestMock + .mockResolvedValueOnce({ + commits: [commit("old-provider-head")], + head_sha: "old-provider-head", + complete: true, + }) + .mockResolvedValueOnce({ + commits: [commit("new-provider-head")], + head_sha: "new-provider-head", + complete: true, + }); + + const { result } = renderHook(() => usePRCommits("acme", "app", 1, "synced"), { wrapper }); + await waitFor(() => expect(result.current.providerHead).toBe("old-provider-head")); + + await act(async () => { + await result.current.refresh(); + }); + + expect(result.current.providerHead).toBe("new-provider-head"); + expect(requestMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/apps/web/hooks/domains/github/use-pr-commits.ts b/apps/web/hooks/domains/github/use-pr-commits.ts index 33d8dd7aca..a68d7cc72f 100644 --- a/apps/web/hooks/domains/github/use-pr-commits.ts +++ b/apps/web/hooks/domains/github/use-pr-commits.ts @@ -56,7 +56,7 @@ type PRCommitsRequest = { async function fetchPRCommits( { workspaceId, owner, repo, prNumber, sourceKey }: PRCommitsRequest, setState: (s: KeyedPRCommitsState) => void, -) { +): Promise { const client = getWebSocketClient(); setState({ sourceKey, @@ -67,15 +67,16 @@ async function fetchPRCommits( error: null, }); if (!client) { - setState({ + const next = { sourceKey, commits: [], providerHead: null, providerCommitsComplete: false, loading: false, error: null, - }); - return; + } satisfies KeyedPRCommitsState; + setState(next); + return next; } try { const response = await client.request<{ @@ -88,23 +89,27 @@ async function fetchPRCommits( repo, number: prNumber, }); - setState({ + const next = { sourceKey, commits: response?.commits ?? [], providerHead: response?.head_sha ?? null, providerCommitsComplete: response?.complete === true, loading: false, error: null, - }); + } satisfies KeyedPRCommitsState; + setState(next); + return next; } catch (err) { - setState({ + const next = { sourceKey, commits: [], providerHead: null, providerCommitsComplete: false, loading: false, error: err instanceof Error ? err.message : "Failed to fetch PR commits", - }); + } satisfies KeyedPRCommitsState; + setState(next); + return next; } } @@ -127,10 +132,10 @@ export function usePRCommits( const paramsKeyRef = useRef(""); const requestIdRef = useRef(0); - const refresh = useCallback(() => { - if (!workspaceId || !owner || !repo || !prNumber) return; + const refresh = useCallback(async () => { + if (!workspaceId || !owner || !repo || !prNumber) return null; const requestId = ++requestIdRef.current; - void fetchPRCommits({ workspaceId, owner, repo, prNumber, sourceKey }, (next) => { + return fetchPRCommits({ workspaceId, owner, repo, prNumber, sourceKey }, (next) => { if (requestId !== requestIdRef.current) return; setState(next); }); diff --git a/apps/web/hooks/domains/github/use-pr-review-repository-identity.ts b/apps/web/hooks/domains/github/use-pr-review-repository-identity.ts index 15064da363..11d4254c1a 100644 --- a/apps/web/hooks/domains/github/use-pr-review-repository-identity.ts +++ b/apps/web/hooks/domains/github/use-pr-review-repository-identity.ts @@ -1,13 +1,12 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; import { resolvePRReviewRepositoryIdentity, type ReviewWorktreeIdentity, } from "@/components/review/review-repository-identity"; import { useTaskRepositories } from "@/hooks/domains/kanban/use-task-repositories"; -import { useRepository } from "@/hooks/domains/workspace/use-repository"; import type { Worktree } from "@/lib/state/slices/session/types"; import type { TaskPR } from "@/lib/types/github"; import type { TaskSessionWorktree } from "@/lib/types/http"; @@ -50,8 +49,16 @@ export function usePRReviewRepositoryIdentity( sessionId: string | null | undefined, pr: TaskPR | null, ): string | undefined { + const resolveRepositoryIdentity = usePRReviewRepositoryIdentityResolver(taskId, sessionId); + return useMemo(() => resolveRepositoryIdentity(pr), [pr, resolveRepositoryIdentity]); +} + +export function usePRReviewRepositoryIdentityResolver( + taskId: string | null, + sessionId: string | null | undefined, +): (pr: TaskPR | null) => string | undefined { const taskRepositories = useTaskRepositories(taskId); - const workspaceRepository = useRepository(pr?.repository_id ?? null); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const sessionWorktrees = useAppStore((state) => sessionId ? state.taskSessions.items[sessionId]?.worktrees : undefined, ); @@ -66,15 +73,24 @@ export function usePRReviewRepositoryIdentity( () => normalizeReviewWorktrees(sessionWorktrees, liveWorktreeIds, liveWorktrees), [sessionWorktrees, liveWorktreeIds, liveWorktrees], ); + const repositoryNamesById = useMemo(() => { + const names = new Map(); + for (const repositories of Object.values(repositoriesByWorkspace)) { + for (const repository of repositories) names.set(repository.id, repository.name); + } + return names; + }, [repositoriesByWorkspace]); - return useMemo( - () => + return useCallback( + (pr: TaskPR | null) => resolvePRReviewRepositoryIdentity({ pr, - workspaceRepositoryName: workspaceRepository?.name, + workspaceRepositoryName: pr?.repository_id + ? repositoryNamesById.get(pr.repository_id) + : undefined, taskRepositories, worktrees, }), - [pr, workspaceRepository?.name, taskRepositories, worktrees], + [repositoryNamesById, taskRepositories, worktrees], ); } diff --git a/apps/web/hooks/domains/session/branch-scoped-task-pr.test.ts b/apps/web/hooks/domains/session/branch-scoped-task-pr.test.ts new file mode 100644 index 0000000000..a445f46a02 --- /dev/null +++ b/apps/web/hooks/domains/session/branch-scoped-task-pr.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; +import type { TaskPR } from "@/lib/types/github"; +import { + normalizeContributionBranch, + resolveBranchScopedTaskPRs, + selectBranchScopedTaskPR, +} from "./branch-scoped-task-pr"; + +const CURRENT_BRANCH = "feature/current"; + +function taskPR(overrides: Partial = {}): TaskPR { + return { + id: "pr-2", + task_id: "task-1", + repository_id: "repo-frontend", + owner: "acme", + repo: "frontend", + pr_number: 2, + pr_url: "https://github.com/acme/frontend/pull/2", + pr_title: "Current contribution", + head_branch: CURRENT_BRANCH, + base_branch: "main", + author_login: "alice", + state: "open", + review_state: "", + checks_state: "", + mergeable_state: "", + review_count: 0, + pending_review_count: 0, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + checks_passing: 0, + additions: 0, + deletions: 0, + created_at: "2026-08-12T10:00:00Z", + merged_at: null, + closed_at: null, + last_synced_at: null, + updated_at: "2026-08-12T10:00:00Z", + ...overrides, + }; +} + +function gitStatus(branch: string): GitStatusEntry { + return { + branch, + remote_branch: `origin/${branch}`, + modified: [], + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 0, + behind: 0, + files: {}, + timestamp: null, + }; +} + +describe("branch-scoped task PR selection", () => { + it("keeps a merged historical branch out of the live checkout relation", () => { + const historical = taskPR({ + id: "pr-1", + pr_number: 1, + head_branch: "feature/merged", + state: "merged", + }); + const current = taskPR(); + + const scoped = resolveBranchScopedTaskPRs({ + prs: [historical, current], + statuses: [{ repository_name: "frontend", status: gitStatus(CURRENT_BRANCH) }], + preferredKey: "acme/frontend/1", + resolveRepositoryName: () => "frontend", + }); + + expect(scoped.map((entry) => entry.pr)).toEqual([current]); + expect(selectBranchScopedTaskPR(scoped, "acme/frontend/1")?.pr).toEqual(current); + }); + + it("prefers an open PR over a terminal sibling on the same branch", () => { + const merged = taskPR({ id: "pr-1", pr_number: 1, state: "merged" }); + const current = taskPR(); + + const scoped = resolveBranchScopedTaskPRs({ + prs: [merged, current], + statuses: [{ repository_name: "frontend", status: gitStatus(CURRENT_BRANCH) }], + preferredKey: "acme/frontend/1", + resolveRepositoryName: () => "frontend", + }); + + expect(scoped.map((entry) => entry.pr)).toEqual([current]); + }); + + it("requires repository identity when multiple checkouts use the same branch", () => { + const frontend = taskPR(); + const backend = taskPR({ + id: "pr-backend", + repository_id: "repo-backend", + repo: "backend", + pr_number: 3, + pr_url: "https://github.com/acme/backend/pull/3", + }); + + const scoped = resolveBranchScopedTaskPRs({ + prs: [frontend, backend], + statuses: [ + { repository_name: "backend", status: gitStatus(CURRENT_BRANCH) }, + { repository_name: "frontend", status: gitStatus(CURRENT_BRANCH) }, + ], + resolveRepositoryName: (pr) => (pr.repository_id === "repo-backend" ? "backend" : "frontend"), + }); + + expect(scoped.map((entry) => entry.pr.id)).toEqual(["pr-backend", "pr-2"]); + }); + + it("normalizes full local and remote branch refs", () => { + expect(normalizeContributionBranch(`refs/heads/${CURRENT_BRANCH}`)).toBe(CURRENT_BRANCH); + expect(normalizeContributionBranch(`refs/remotes/origin/${CURRENT_BRANCH}`)).toBe( + CURRENT_BRANCH, + ); + }); +}); diff --git a/apps/web/hooks/domains/session/branch-scoped-task-pr.ts b/apps/web/hooks/domains/session/branch-scoped-task-pr.ts new file mode 100644 index 0000000000..4a5103e2e3 --- /dev/null +++ b/apps/web/hooks/domains/session/branch-scoped-task-pr.ts @@ -0,0 +1,94 @@ +import { prTaskKey } from "@/components/github/pr-utils"; +import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; +import type { TaskPR } from "@/lib/types/github"; + +export type RepositoryGitStatus = { + repository_name: string; + status: GitStatusEntry; +}; + +export type BranchScopedTaskPR = { + pr: TaskPR; + repositoryName: string | undefined; + gitStatus: GitStatusEntry; +}; + +type ResolveBranchScopedTaskPRsInput = { + prs: TaskPR[]; + statuses: RepositoryGitStatus[]; + preferredKey?: string | null; + resolveRepositoryName: (pr: TaskPR) => string | undefined; +}; + +export function normalizeContributionBranch(branch: string | null | undefined): string { + return (branch ?? "") + .trim() + .replace(/^refs\/heads\//, "") + .replace(/^refs\/remotes\/[^/]+\//, ""); +} + +function comparePRRecency(left: TaskPR, right: TaskPR): number { + const leftTime = Date.parse(left.updated_at || left.created_at || "") || 0; + const rightTime = Date.parse(right.updated_at || right.created_at || "") || 0; + return rightTime - leftTime || right.pr_number - left.pr_number; +} + +function choosePR(candidates: TaskPR[], preferredKey?: string | null): TaskPR | undefined { + const openCandidates = candidates.filter((pr) => pr.state === "open"); + const eligible = openCandidates.length > 0 ? openCandidates : candidates; + const preferred = preferredKey + ? eligible.find((candidate) => prTaskKey(candidate) === preferredKey) + : undefined; + return preferred ?? [...eligible].sort(comparePRRecency)[0]; +} + +function matchesStatus( + pr: TaskPR, + repositoryName: string | undefined, + entry: RepositoryGitStatus, + statusCount: number, +): boolean { + const prBranch = normalizeContributionBranch(pr.head_branch); + const checkoutBranch = normalizeContributionBranch(entry.status.branch); + if (!prBranch || !checkoutBranch || prBranch !== checkoutBranch) return false; + if (repositoryName && repositoryName === entry.repository_name) return true; + + // Agentctl keeps the legacy empty repository key for a single checkout. + // A missing repository id is also only unambiguous when exactly one live + // checkout exists. Never use either fallback to fan out across repositories. + if (statusCount !== 1) return false; + return entry.repository_name === "" || !repositoryName; +} + +export function resolveBranchScopedTaskPRs({ + prs, + statuses, + preferredKey, + resolveRepositoryName, +}: ResolveBranchScopedTaskPRsInput): BranchScopedTaskPR[] { + const scoped: BranchScopedTaskPR[] = []; + for (const entry of statuses) { + const candidates = prs.filter((pr) => + matchesStatus(pr, resolveRepositoryName(pr), entry, statuses.length), + ); + const pr = choosePR(candidates, preferredKey); + if (!pr) continue; + scoped.push({ + pr, + repositoryName: resolveRepositoryName(pr), + gitStatus: entry.status, + }); + } + return scoped; +} + +export function selectBranchScopedTaskPR( + scoped: BranchScopedTaskPR[], + preferredKey?: string | null, +): BranchScopedTaskPR | undefined { + if (preferredKey) { + const preferred = scoped.find((entry) => prTaskKey(entry.pr) === preferredKey); + if (preferred) return preferred; + } + return scoped[0]; +} diff --git a/apps/web/hooks/domains/session/remote-contribution-relation.test.ts b/apps/web/hooks/domains/session/remote-contribution-relation.test.ts index cc49a641e7..38ae911b94 100644 --- a/apps/web/hooks/domains/session/remote-contribution-relation.test.ts +++ b/apps/web/hooks/domains/session/remote-contribution-relation.test.ts @@ -6,10 +6,10 @@ import { } from "./remote-contribution-relation"; const LOCAL_HEAD = "local-head"; -const PROVIDER_BASE = "provider-base"; -const PROVIDER_HEAD = "provider-head"; -const REWRITTEN_BASE = "rewritten-base"; -const REWRITTEN_HEAD = "rewritten-head"; +const PROVIDER_BASE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PROVIDER_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const REWRITTEN_BASE = "cccccccccccccccccccccccccccccccccccccccc"; +const REWRITTEN_HEAD = "dddddddddddddddddddddddddddddddddddddddd"; const baseInput: RemoteContributionRelationInput = { hasSelectedPR: true, @@ -67,7 +67,8 @@ const classificationCases = [ kind: "diverged", canPush: false, canPull: false, - remoteMutationBlocked: true, + canReplaceRemote: true, + canUseRemote: true, presentation: "separate", }, }, @@ -90,9 +91,11 @@ describe("classifyRemoteContribution", () => { expect(classifyRemoteContribution(input(overrides))).toMatchObject({ kind: "unknown", presentation: "unified", - remoteMutationBlocked: false, canPush: false, canPull: false, + canReplaceRemote: false, + canUseRemote: false, + action: "unavailable_evidence", }); }); @@ -140,7 +143,12 @@ describe("classifyRemoteContribution", () => { classifyRemoteContribution( input({ upstreamHead: "unrelated-upstream", remoteAhead: 3, remoteBehind: 0 }), ), - ).toMatchObject({ kind: "diverged", canPush: false, remoteMutationBlocked: true }); + ).toMatchObject({ + kind: "diverged", + canPush: false, + canReplaceRemote: true, + canUseRemote: true, + }); }); it("uses the explicit provider head instead of the last listed commit", () => { @@ -175,13 +183,13 @@ describe("classifyRemoteContribution", () => { expect(classifyRemoteContribution(incompleteInput)).toMatchObject({ kind: "unknown", providerHead: REWRITTEN_HEAD, - remoteMutationBlocked: false, + action: "unavailable_evidence", }); }); }); describe("remoteContributionActionPolicy", () => { - it("blocks both remote mutations only for diverged histories", () => { + it("offers explicit version choices for diverged histories", () => { const relation = classifyRemoteContribution( input({ providerCommits: [{ sha: REWRITTEN_HEAD }], @@ -192,9 +200,12 @@ describe("remoteContributionActionPolicy", () => { ); expect(remoteContributionActionPolicy(relation)).toEqual({ + action: "diverged_replace", pushDisabled: true, pullDisabled: true, - disabledReason: "history_changed", + replaceDisabled: false, + useDisabled: false, + disabledReason: null, }); }); @@ -207,9 +218,38 @@ describe("remoteContributionActionPolicy", () => { ); expect(remoteContributionActionPolicy(relation)).toEqual({ + action: "provider_ahead_pull", pushDisabled: true, pullDisabled: false, + replaceDisabled: true, + useDisabled: true, disabledReason: null, }); }); + + it("keeps normal push behavior for local-ahead history", () => { + const relation = classifyRemoteContribution(input({ remoteAhead: 2 })); + + expect(remoteContributionActionPolicy(relation)).toEqual({ + action: "normal_push", + pushDisabled: false, + pullDisabled: false, + replaceDisabled: true, + useDisabled: true, + disabledReason: null, + }); + }); + + it("does not offer destructive choices when provider evidence is unavailable", () => { + const relation = classifyRemoteContribution(input({ providerLoading: true })); + + expect(remoteContributionActionPolicy(relation)).toEqual({ + action: "unavailable_evidence", + pushDisabled: false, + pullDisabled: false, + replaceDisabled: true, + useDisabled: true, + disabledReason: "provider_evidence_unavailable", + }); + }); }); diff --git a/apps/web/hooks/domains/session/remote-contribution-relation.ts b/apps/web/hooks/domains/session/remote-contribution-relation.ts index b01e0f5041..c900237897 100644 --- a/apps/web/hooks/domains/session/remote-contribution-relation.ts +++ b/apps/web/hooks/domains/session/remote-contribution-relation.ts @@ -8,6 +8,12 @@ export type RemoteContributionRelationKind = export type RemoteContributionPresentation = "unified" | "separate"; +export type RemoteContributionAction = + | "normal_push" + | "provider_ahead_pull" + | "diverged_replace" + | "unavailable_evidence"; + export type RemoteContributionRelationInput = { hasSelectedPR: boolean; providerCommits: ReadonlyArray<{ sha: string }>; @@ -26,36 +32,52 @@ export type RemoteContributionRelationInput = { export type RemoteContributionRelation = { kind: RemoteContributionRelationKind; presentation: RemoteContributionPresentation; + action: RemoteContributionAction; providerHead: string | null; pushAhead: number; pullBehind: number; canPush: boolean; canPull: boolean; - remoteMutationBlocked: boolean; + canReplaceRemote: boolean; + canUseRemote: boolean; }; export type RemoteContributionActionPolicy = { + action: RemoteContributionAction; pushDisabled: boolean; pullDisabled: boolean; - disabledReason: "history_changed" | null; + replaceDisabled: boolean; + useDisabled: boolean; + disabledReason: "provider_evidence_unavailable" | null; }; /** * Safety gates shared by desktop and mobile Git controls. A provider-ahead * checkout may pull, but must not push over the provider's newer history. - * Diverged histories require reconciliation outside these controls. + * Diverged histories expose explicit contribution replacement or adoption. */ export function remoteContributionActionPolicy( relation: RemoteContributionRelation, ): RemoteContributionActionPolicy { - const historyChanged = relation.remoteMutationBlocked; + const unavailable = relation.action === "unavailable_evidence"; + const diverged = relation.action === "diverged_replace"; return { - pushDisabled: historyChanged || relation.kind === "provider_ahead", - pullDisabled: historyChanged, - disabledReason: historyChanged ? "history_changed" : null, + action: relation.action, + pushDisabled: diverged || relation.action === "provider_ahead_pull", + pullDisabled: diverged, + replaceDisabled: !relation.canReplaceRemote, + useDisabled: !relation.canUseRemote, + disabledReason: unavailable ? "provider_evidence_unavailable" : null, }; } +export function isFullCommitSHA(value: string | null | undefined): boolean { + return ( + (value?.length === 40 || value?.length === 64) && + [...(value ?? "")].every((character) => /^[0-9a-f]$/i.test(character)) + ); +} + function nonNegative(value: number): number { return Math.max(0, value); } @@ -70,18 +92,32 @@ function fallbackCapabilities(input: RemoteContributionRelationInput) { pullBehind, canPush: pushAhead > 0, canPull: pullBehind > 0, - remoteMutationBlocked: false, + canReplaceRemote: false, + canUseRemote: false, }; } +function actionFor( + kind: RemoteContributionRelationKind, + providerHead: string | null, +): RemoteContributionAction { + if (kind === "provider_ahead") return "provider_ahead_pull"; + if (kind === "diverged" && isFullCommitSHA(providerHead)) return "diverged_replace"; + if (kind === "unknown") return "unavailable_evidence"; + if (kind === "diverged") return "unavailable_evidence"; + return "normal_push"; +} + function result( kind: RemoteContributionRelationKind, providerHead: string | null, capabilities: ReturnType, ): RemoteContributionRelation { + const action = actionFor(kind, providerHead); return { kind, presentation: kind === "diverged" ? "separate" : "unified", + action, providerHead, ...capabilities, }; @@ -119,7 +155,6 @@ export function classifyRemoteContribution( ...fallback, canPush: false, canPull: true, - remoteMutationBlocked: false, }); } @@ -129,14 +164,15 @@ export function classifyRemoteContribution( pushAhead: input.remoteAhead, canPush: true, canPull: false, - remoteMutationBlocked: false, }); } + const canResolve = isFullCommitSHA(providerHead); return result("diverged", providerHead, { ...fallback, canPush: false, canPull: false, - remoteMutationBlocked: true, + canReplaceRemote: canResolve, + canUseRemote: canResolve, }); } diff --git a/apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx b/apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx index 3a5c0de2df..876385e5c6 100644 --- a/apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx +++ b/apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx @@ -6,7 +6,9 @@ import { useRemoteContributionRelation } from "./use-remote-contribution-relatio const mocks = vi.hoisted(() => ({ statuses: [] as Array<{ repository_name: string; status: GitStatusEntry }>, + prs: [] as TaskPR[], selectedPR: null as TaskPR | null, + selectedKey: null as string | null, providerHead: "provider-head", repositoryName: "frontend", })); @@ -17,7 +19,11 @@ vi.mock("@/components/state-provider", () => ({ })); vi.mock("@/hooks/domains/github/use-review-pr-selection", () => ({ - useReviewPRSelection: () => ({ selectedPR: mocks.selectedPR }), + useReviewPRSelection: () => ({ + prs: mocks.prs, + selectedPR: mocks.selectedPR, + selectedKey: mocks.selectedKey, + }), })); vi.mock("@/hooks/domains/github/use-pr-commits", () => ({ @@ -27,11 +33,13 @@ vi.mock("@/hooks/domains/github/use-pr-commits", () => ({ providerCommitsComplete: true, loading: false, error: null, + refresh: vi.fn(), }), })); vi.mock("@/hooks/domains/github/use-pr-review-repository-identity", () => ({ usePRReviewRepositoryIdentity: () => mocks.repositoryName, + usePRReviewRepositoryIdentityResolver: () => () => mocks.repositoryName, })); vi.mock("./use-session-git-status", () => ({ @@ -55,7 +63,41 @@ const baseStatus: Omit = {}): TaskPR { + return { + id: "pr-current", + task_id: "task-1", + repository_id: "repo-frontend", + owner: "acme", + repo: "frontend", + pr_number: 2, + pr_url: "https://github.com/acme/frontend/pull/2", + pr_title: "Current contribution", + head_branch: "feature", + base_branch: "main", + author_login: "alice", + state: "open", + review_state: "", + checks_state: "", + mergeable_state: "", + review_count: 0, + pending_review_count: 0, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + checks_passing: 0, + additions: 0, + deletions: 0, + created_at: "2026-08-12T10:00:00Z", + merged_at: null, + closed_at: null, + last_synced_at: null, + updated_at: "2026-08-12T10:00:00Z", + ...overrides, + }; +} + +const selectedPR = taskPR(); function status( repository_name: string, @@ -78,6 +120,8 @@ function status( describe("useRemoteContributionRelation repository scoping", () => { beforeEach(() => { mocks.selectedPR = selectedPR; + mocks.prs = [selectedPR]; + mocks.selectedKey = null; mocks.statuses = []; mocks.repositoryName = "frontend"; }); @@ -103,7 +147,8 @@ describe("useRemoteContributionRelation repository scoping", () => { kind: "aligned", canPush: false, canPull: false, - remoteMutationBlocked: false, + canReplaceRemote: false, + canUseRemote: false, }); }); @@ -116,7 +161,35 @@ describe("useRemoteContributionRelation repository scoping", () => { kind: "aligned", canPush: false, canPull: false, - remoteMutationBlocked: false, + canReplaceRemote: false, + canUseRemote: false, }); }); + + it("ignores a merged Review PR when a newer PR matches the checked-out branch", () => { + const historicalPR = taskPR({ + id: "pr-historical", + pr_number: 1, + head_branch: "feature/merged", + state: "merged", + merged_at: "2026-08-12T09:00:00Z", + updated_at: "2026-08-12T09:00:00Z", + }); + const currentPR = taskPR({ + id: "pr-current", + pr_number: 2, + head_branch: "feature/current", + }); + mocks.prs = [historicalPR, currentPR]; + mocks.selectedPR = historicalPR; + mocks.selectedKey = "acme/frontend/1"; + mocks.statuses = [ + status("", mocks.providerHead, mocks.providerHead, { branch: "feature/current" }), + ]; + + const { result } = renderHook(() => useRemoteContributionRelation("session-1")); + + expect(result.current.selectedPR).toEqual(currentPR); + expect(result.current.relation.kind).toBe("aligned"); + }); }); diff --git a/apps/web/hooks/domains/session/use-remote-contribution-relation.ts b/apps/web/hooks/domains/session/use-remote-contribution-relation.ts index ce5db94393..21863fac26 100644 --- a/apps/web/hooks/domains/session/use-remote-contribution-relation.ts +++ b/apps/web/hooks/domains/session/use-remote-contribution-relation.ts @@ -1,54 +1,64 @@ "use client"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; import type { PRCommitInfo, TaskPR } from "@/lib/types/github"; import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; -import { usePRReviewRepositoryIdentity } from "@/hooks/domains/github/use-pr-review-repository-identity"; +import { usePRReviewRepositoryIdentityResolver } from "@/hooks/domains/github/use-pr-review-repository-identity"; import { useReviewPRSelection } from "@/hooks/domains/github/use-review-pr-selection"; import { useSessionGitStatusByRepo } from "./use-session-git-status"; +import { resolveBranchScopedTaskPRs, selectBranchScopedTaskPR } from "./branch-scoped-task-pr"; import { classifyRemoteContribution, type RemoteContributionRelation, } from "./remote-contribution-relation"; export type RemoteContributionRelationState = { + prs: TaskPR[]; selectedPR: TaskPR | null; repositoryName: string | undefined; commits: PRCommitInfo[]; loading: boolean; error: string | null; relation: RemoteContributionRelation; + refreshProviderEvidence: () => Promise; }; export function useRemoteContributionRelation( sessionId: string | null | undefined, ): RemoteContributionRelationState { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const { selectedPR } = useReviewPRSelection(activeTaskId); + const { prs, selectedKey } = useReviewPRSelection(activeTaskId); + const statusByRepo = useSessionGitStatusByRepo(sessionId ?? null); + const resolveRepositoryName = usePRReviewRepositoryIdentityResolver(activeTaskId, sessionId); + const branchScopedPRs = useMemo( + () => + resolveBranchScopedTaskPRs({ + prs, + statuses: statusByRepo, + preferredKey: selectedKey, + resolveRepositoryName, + }), + [prs, statusByRepo, selectedKey, resolveRepositoryName], + ); + const selection = useMemo( + () => selectBranchScopedTaskPR(branchScopedPRs, selectedKey), + [branchScopedPRs, selectedKey], + ); + const scopedPRs = useMemo(() => branchScopedPRs.map((entry) => entry.pr), [branchScopedPRs]); + const selectedPR = selection?.pr ?? null; const commitsState = usePRCommits( selectedPR?.owner ?? null, selectedPR?.repo ?? null, selectedPR?.pr_number ?? null, selectedPR?.last_synced_at ?? null, ); - const repositoryName = usePRReviewRepositoryIdentity(activeTaskId, sessionId, selectedPR); - const statusByRepo = useSessionGitStatusByRepo(sessionId ?? null); - const gitStatus = useMemo(() => { - const matchingStatus = repositoryName - ? statusByRepo.find((entry) => entry.repository_name === repositoryName)?.status - : undefined; - if (matchingStatus) return matchingStatus; - - // Single-repository sessions keep the legacy empty repository key even - // when the selected PR resolves to the workspace repository name. A lone - // empty-key entry is therefore the selected repository, while a lone - // named entry is only a safe fallback when no identity was resolved. - if (statusByRepo.length !== 1) return undefined; - const onlyStatus = statusByRepo[0]; - if (onlyStatus.repository_name === "" || !repositoryName) return onlyStatus.status; - return undefined; - }, [repositoryName, statusByRepo]); + const repositoryName = selection?.repositoryName; + const refreshProviderEvidence = useCallback(async () => { + const refreshed = await commitsState.refresh(); + return refreshed?.providerHead ?? null; + }, [commitsState.refresh]); + const gitStatus = selection?.gitStatus; const relation = useMemo( () => @@ -78,11 +88,13 @@ export function useRemoteContributionRelation( ); return { + prs: scopedPRs, selectedPR, repositoryName, commits: commitsState.commits, loading: commitsState.loading, error: commitsState.error, relation, + refreshProviderEvidence, }; } diff --git a/apps/web/hooks/use-git-operations.test.ts b/apps/web/hooks/use-git-operations.test.ts index d43c7d995b..9c59011f6c 100644 --- a/apps/web/hooks/use-git-operations.test.ts +++ b/apps/web/hooks/use-git-operations.test.ts @@ -38,4 +38,27 @@ describe("repository scope payloads", () => { repo: "", }); }); + + it("sends exact contribution heads and one explicit repository scope", async () => { + const executeOperation = vi.fn().mockResolvedValue({ + success: true, + operation: "use_remote_contribution", + output: "", + recovery_branch: "kandev/recovery-123", + }) as unknown as Parameters[0]; + const operations = buildGitOperationCallbacks(executeOperation); + const expectedHead = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + await operations.replaceRemoteContribution(expectedHead, ""); + await operations.useRemoteContribution(expectedHead, "frontend"); + + expect(executeOperation).toHaveBeenNthCalledWith(1, "worktree.replace_contribution", { + expected_remote_head: expectedHead, + repo: "", + }); + expect(executeOperation).toHaveBeenNthCalledWith(2, "worktree.use_contribution", { + expected_remote_head: expectedHead, + repo: "frontend", + }); + }); }); diff --git a/apps/web/hooks/use-git-operations.ts b/apps/web/hooks/use-git-operations.ts index 288fc3056a..73128f469e 100644 --- a/apps/web/hooks/use-git-operations.ts +++ b/apps/web/hooks/use-git-operations.ts @@ -10,7 +10,9 @@ export interface GitOperationResult { operation: string; output: string; error?: string; + error_code?: string; conflict_files?: string[]; + recovery_branch?: string; } // PRCreateResult matches the backend PR creation response @@ -67,6 +69,11 @@ interface UseGitOperationsReturn { options?: { force?: boolean; setUpstream?: boolean }, repo?: string, ) => Promise; + replaceRemoteContribution: ( + expectedRemoteHead: string, + repo?: string, + ) => Promise; + useRemoteContribution: (expectedRemoteHead: string, repo?: string) => Promise; rebase: (baseBranch: string, repo?: string) => Promise; merge: (baseBranch: string, repo?: string) => Promise; abort: (operation: "merge" | "rebase", repo?: string) => Promise; @@ -102,12 +109,28 @@ type ExecuteOperation = ( payload: Record, ) => Promise; +function buildContributionCallbacks(executeOperation: ExecuteOperation) { + const replaceRemoteContribution = async (expectedRemoteHead: string, repo?: string) => + executeOperation("worktree.replace_contribution", { + expected_remote_head: expectedRemoteHead, + ...repositoryScopePayload(repo), + }); + const useRemoteContribution = async (expectedRemoteHead: string, repo?: string) => + executeOperation("worktree.use_contribution", { + expected_remote_head: expectedRemoteHead, + ...repositoryScopePayload(repo), + }); + return { replaceRemoteContribution, useRemoteContribution }; +} + /** Preserve an explicitly selected workspace-root scope (`repo === ""`). */ export function repositoryScopePayload(repo?: string): { repo?: string } { return repo === undefined ? {} : { repo }; } export function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { + const { replaceRemoteContribution, useRemoteContribution } = + buildContributionCallbacks(executeOperation); const pull = async (rebase = false, repo?: string) => executeOperation("worktree.pull", { rebase, @@ -202,6 +225,8 @@ export function buildGitOperationCallbacks(executeOperation: ExecuteOperation) { return { pull, push, + replaceRemoteContribution, + useRemoteContribution, rebase, merge, abort, diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index f32a0ad991..734fcc4cb0 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -987,8 +987,22 @@ "relatedTaskCount_other": "{{count}} related tasks", "relatedTasks": "Related tasks", "remote": "Remote", - "remoteActionsDisabledHistoryChanged": "Push, force-push, and Pull are disabled until the histories are reconciled.", - "remoteContributionHistoryChanged": "The linked contribution changed on the provider. Local checkout commits were preserved; reconcile the histories before using Push, force-push, or Pull.", + "remoteContributionChangedTitle": "PR branch changed", + "remoteContributionChangedBody": "Your task version is unchanged. When you are ready, choose which version to use.", + "remoteContributionChangedTooltip": "The PR branch changed after this task started. Your task checkout is unchanged.", + "replacePRBranch": "Replace PR branch", + "usePRVersion": "Use PR version", + "prNumberVersion": "PR #{{number}} version", + "remoteContributionReplaceDescription": "Replace the published PR branch with your unchanged task version. Requires confirmation and an exact provider-head match.", + "remoteContributionUseDescription": "Use the current PR version in this task. Kandev creates a recovery branch first and requires a clean working tree.", + "remoteContributionViewDescription": "Open PR #{{number}} version without changing the task checkout.", + "replacePRBranchConfirmation": "Replace the published PR branch for {{repository}} at {{head}} with the task version? This changes the provider branch to match the task version.", + "usePRVersionConfirmation": "Use the PR version at {{head}}? Kandev will create a recovery branch for the current task version first. The working tree must be clean.", + "remoteContributionProviderChangedAgain": "The PR changed again. Refresh the provider version and try again.", + "remoteContributionCleanWorktreeRequired": "Commit or discard your local changes before using the PR version.", + "remoteContributionResolutionFailed": "Kandev could not change the contribution branch. Check the Git error and try again.", + "remoteContributionReplaced": "PR branch replaced with the task version.", + "remoteContributionUsed": "PR version adopted. Recovery branch: {{branch}}", "remoteCreatedLine": "Created: {{createdAt}}", "remoteExecutor": "Remote executor", "remoteLastCheckLine": "Last check: {{checkedAt}}", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index c0e097b73c..f2f0c2d560 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -987,8 +987,22 @@ "relatedTaskCount_other": "{{count}} ŕēĺàţēď ţàśķś", "relatedTasks": "Ŕēĺàţēď ţàśķś", "remote": "Ŕēḿōţē", - "remoteActionsDisabledHistoryChanged": "Ƥũśĥ, ƒōŕćē-ƥũśĥ, àńď Ƥũĺĺ àŕē ďĩśàƀĺēď ũńţĩĺ ţĥē ĥĩśţōŕĩēś àŕē ŕēćōńćĩĺēď.", - "remoteContributionHistoryChanged": "Ťĥē ĺĩńķēď ćōńţŕĩƀũţĩōń ćĥàńĝēď ōń ţĥē ƥŕōvĩďēŕ. Ĺōćàĺ ćĥēćķōũţ ćōḿḿĩţś ŵēŕē ƥŕēśēŕvēď; ŕēćōńćĩĺē ţĥē ĥĩśţōŕĩēś ƀēƒōŕē ũśĩńĝ Ƥũśĥ, ƒōŕćē-ƥũśĥ, ōŕ Ƥũĺĺ.", + "remoteContributionChangedTitle": "ƤŔ ƀŕàńćĥ ćĥàńĝēď", + "remoteContributionChangedBody": "Ŷōũŕ ţàśķ vēŕśĩōń ĩś ũńćĥàńĝēď. Ŵĥēń ŷōũ àŕē ŕēàďŷ, ćĥōōśē ŵĥĩćĥ vēŕśĩōń ţō ũśē.", + "remoteContributionChangedTooltip": "Ţĥē ƤŔ ƀŕàńćĥ ćĥàńĝēď àƒţēŕ ţĥĩś ţàśķ śţàŕţēď. Ŷōũŕ ţàśķ ćĥēćķōũţ ĩś ũńćĥàńĝēď.", + "replacePRBranch": "Ŕēƥĺàćē ƤŔ ƀŕàńćĥ", + "usePRVersion": "Ũśē ƤŔ vēŕśĩōń", + "prNumberVersion": "ƤŔ #{{number}} vēŕśĩōń", + "remoteContributionReplaceDescription": "Ŕēƥĺàćē ţĥē ƥũƀĺĩśĥēď ƤŔ ƀŕàńćĥ ŵĩţĥ ŷōũŕ ũńćĥàńĝēď ţàśķ vēŕśĩōń. Ŕēƥũĩŕēś ćōńƒĩŕḿàţĩōń àńď àń ēxàćţ ƥŕōvĩďēŕ-ĥēàď ḿàţćĥ.", + "remoteContributionUseDescription": "Ũśē ţĥē ćũŕŕēńţ ƤŔ vēŕśĩōń ĩń ţĥĩś ţàśķ. Ķàńďēv ćŕēàţēś à ŕēćōvēŕŷ ƀŕàńćĥ ƒĩŕśţ àńď ŕēƥũĩŕēś à ćĺēàń ŵōŕķĩńĝ ţŕēē.", + "remoteContributionViewDescription": "Őƥēń ƤŔ #{{number}} vēŕśĩōń ŵĩţĥōũţ ćĥàńĝĩńĝ ţĥē ţàśķ ćĥēćķōũţ.", + "replacePRBranchConfirmation": "Ŕēƥĺàćē ţĥē ƥũƀĺĩśĥēď ƤŔ ƀŕàńćĥ ƒōŕ {{repository}} àţ {{head}} ŵĩţĥ ţĥē ţàśķ vēŕśĩōń? Ţĥĩś ćĥàńĝēś ţĥē ƥŕōvĩďēŕ ƀŕàńćĥ ţō ḿàţćĥ ţĥē ţàśķ vēŕśĩōń.", + "usePRVersionConfirmation": "Ũśē ţĥē ƤŔ vēŕśĩōń àţ {{head}}? Ķàńďēv ŵĩĺĺ ćŕēàţē à ŕēćōvēŕŷ ƀŕàńćĥ ƒōŕ ţĥē ćũŕŕēńţ ţàśķ vēŕśĩōń ƒĩŕśţ. Ţĥē ŵōŕķĩńĝ ţŕēē ḿũśţ ƀē ćĺēàń.", + "remoteContributionProviderChangedAgain": "Ţĥē ƤŔ ćĥàńĝēď àĝàĩń. Ŕēƒŕēśĥ ţĥē ƥŕōvĩďēŕ vēŕśĩōń àńď ţŕŷ àĝàĩń.", + "remoteContributionCleanWorktreeRequired": "Ćōḿḿĩţ ōŕ ďĩśćàŕď ŷōũŕ ĺōćàĺ ćĥàńĝēś ƀēƒōŕē ũśĩńĝ ţĥē ƤŔ vēŕśĩōń.", + "remoteContributionResolutionFailed": "Ķàńďēv ćōũĺď ńōţ ćĥàńĝē ţĥē ćōńţŕĩƀũţĩōń ƀŕàńćĥ. Ćĥēćķ ţĥē Ĝĩţ ēŕŕōŕ àńď ţŕŷ àĝàĩń.", + "remoteContributionReplaced": "ƤŔ ƀŕàńćĥ ŕēƥĺàćēď ŵĩţĥ ţĥē ţàśķ vēŕśĩōń.", + "remoteContributionUsed": "ƤŔ vēŕśĩōń àďōƥţēď. Ŕēćōvēŕŷ ƀŕàńćĥ: {{branch}}", "remoteCreatedLine": "Ćŕēàţēď: {{createdAt}}", "remoteExecutor": "Ŕēḿōţē ēxēćũţōŕ", "remoteLastCheckLine": "Ĺàśţ ćĥēćķ: {{checkedAt}}", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index 98fab21373..d4d76a88ea 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -987,8 +987,22 @@ "relatedTaskCount_other": "{{count}} tarefas relacionadas", "relatedTasks": "Tarefas relacionadas", "remote": "Remoto", - "remoteActionsDisabledHistoryChanged": "Push, force-push e Pull estão desativados até os históricos serem reconciliados.", - "remoteContributionHistoryChanged": "A contribuição associada mudou no fornecedor. Os commits do checkout local foram preservados; reconcilie os históricos antes de usar Push, force-push ou Pull.", + "remoteContributionChangedTitle": "O branch do PR mudou", + "remoteContributionChangedBody": "A versão da tarefa não mudou. Quando estiver pronto, escolha qual versão quer usar.", + "remoteContributionChangedTooltip": "O branch do PR mudou depois de esta tarefa começar. O checkout da tarefa não mudou.", + "replacePRBranch": "Substituir branch do PR", + "usePRVersion": "Usar versão do PR", + "prNumberVersion": "Versão do PR #{{number}}", + "remoteContributionReplaceDescription": "Substitui o branch publicado do PR pela versão inalterada da tarefa. Requer confirmação e correspondência exata do commit de referência do fornecedor.", + "remoteContributionUseDescription": "Usa a versão atual do PR nesta tarefa. O Kandev cria primeiro um branch de recuperação e requer uma árvore de trabalho limpa.", + "remoteContributionViewDescription": "Abre a versão do PR #{{number}} sem alterar o checkout da tarefa.", + "replacePRBranchConfirmation": "Substituir o branch publicado do PR de {{repository}} em {{head}} pela versão da tarefa? Isto altera o branch no fornecedor para corresponder à versão da tarefa.", + "usePRVersionConfirmation": "Usar a versão do PR em {{head}}? O Kandev vai criar primeiro um branch de recuperação para a versão atual da tarefa. A árvore de trabalho tem de estar limpa.", + "remoteContributionProviderChangedAgain": "O PR mudou novamente. Atualize a versão do fornecedor e tente outra vez.", + "remoteContributionCleanWorktreeRequired": "Faça commit ou elimine as alterações locais antes de usar a versão do PR.", + "remoteContributionResolutionFailed": "O Kandev não conseguiu alterar o branch da contribuição. Verifique o erro do Git e tente outra vez.", + "remoteContributionReplaced": "O branch do PR foi substituído pela versão da tarefa.", + "remoteContributionUsed": "A versão do PR foi adotada. Branch de recuperação: {{branch}}", "remoteCreatedLine": "Criado: {{createdAt}}", "remoteExecutor": "Executor remoto", "remoteLastCheckLine": "Última verificação: {{checkedAt}}", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 36bc573705..f0877238a1 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -987,8 +987,22 @@ "relatedTaskCount_other": "{{count}} 个相关任务", "relatedTasks": "相关任务", "remote": "远程", - "remoteActionsDisabledHistoryChanged": "在历史记录完成协调前,Push、force-push 和 Pull 均已禁用。", - "remoteContributionHistoryChanged": "关联的贡献已在提供商处发生变化。本地检出提交已保留;请先协调历史记录,再使用 Push、force-push 或 Pull。", + "remoteContributionChangedTitle": "PR 分支已更改", + "remoteContributionChangedBody": "你的任务版本未更改。准备好后,选择要使用的版本。", + "remoteContributionChangedTooltip": "PR 分支在任务开始后发生了更改。任务工作区未更改。", + "replacePRBranch": "替换 PR 分支", + "usePRVersion": "使用 PR 版本", + "prNumberVersion": "PR #{{number}} 版本", + "remoteContributionReplaceDescription": "用未更改的任务版本替换已发布的 PR 分支。需要确认,并且提供商的最新提交必须完全匹配。", + "remoteContributionUseDescription": "在此任务中使用当前 PR 版本。Kandev 会先创建恢复分支,并且工作区必须干净。", + "remoteContributionViewDescription": "打开 PR #{{number}} 版本,不更改任务工作区。", + "replacePRBranchConfirmation": "要用任务版本替换 {{repository}} 在 {{head}} 的已发布 PR 分支吗?这会让提供商分支与任务版本一致。", + "usePRVersionConfirmation": "要使用 {{head}} 的 PR 版本吗?Kandev 会先为当前任务版本创建恢复分支。工作区必须干净。", + "remoteContributionProviderChangedAgain": "PR 再次发生更改。请刷新提供商版本后重试。", + "remoteContributionCleanWorktreeRequired": "使用 PR 版本前,请先提交或丢弃本地更改。", + "remoteContributionResolutionFailed": "Kandev 无法更改贡献分支。请检查 Git 错误后重试。", + "remoteContributionReplaced": "PR 分支已替换为任务版本。", + "remoteContributionUsed": "已采用 PR 版本。恢复分支:{{branch}}", "remoteCreatedLine": "创建:{{createdAt}}", "remoteExecutor": "远程执行器", "remoteLastCheckLine": "上次检查:{{checkedAt}}", diff --git a/docs/decisions/2026-08-10-remote-contribution-head-drift.md b/docs/decisions/2026-08-10-remote-contribution-head-drift.md index 804e90a7ff..1698e75587 100644 --- a/docs/decisions/2026-08-10-remote-contribution-head-drift.md +++ b/docs/decisions/2026-08-10-remote-contribution-head-drift.md @@ -1,11 +1,16 @@ # ADR-2026-08-10-remote-contribution-head-drift: Separate Current Contribution and Local Checkout Histories -**Status:** accepted +**Status:** accepted (remote-action policy superseded by 2026-08-12-local-first-contribution-replacement) **Date:** 2026-08-10 **Area:** backend, frontend, protocol, GitHub, GitLab ## Context +The local-first replacement policy in +[ADR-2026-08-12](2026-08-12-local-first-contribution-replacement.md) supersedes this record's decision +to block all remote actions after divergence. The history-classification and non-destructive detection +rules remain active. + A remote-contribution task starts from the provider-reported head SHA and tracks the contributor's source branch. After the task starts, the contributor can advance or rewrite that branch. The local checkout, its remote-tracking ref, and the provider API can then describe different moments or different diff --git a/docs/decisions/2026-08-12-local-first-contribution-replacement.md b/docs/decisions/2026-08-12-local-first-contribution-replacement.md new file mode 100644 index 0000000000..e35da9cc0a --- /dev/null +++ b/docs/decisions/2026-08-12-local-first-contribution-replacement.md @@ -0,0 +1,68 @@ +# ADR-2026-08-12-local-first-contribution-replacement: Keep Remote Contribution Work Local-First + +**Status:** accepted +**Date:** 2026-08-12 +**Area:** backend, frontend, protocol, security, GitHub, GitLab + +## Context + +Kandev detects when a provider rewrites a contribution branch after task creation. The first drift +design preserved both histories and blocked Push, force-push, and Pull. That design protected data but +left the user without a recovery action. The user had to leave Kandev and select a Git strategy. + +The task checkout is the user's active workspace. The provider branch is the published contribution. +Neither version can silently replace the other after divergence. + +## Decision + +Kandev keeps the task checkout as the primary working version after remote drift. Drift detection stays +non-destructive. The Changes panel shows a compact remote-change status and keeps the provider version +available through a secondary disclosure. + +A user can select **Replace PR branch** to publish the task version. This action requires a destructive +confirmation and an exact expected provider head. The Git push uses an explicit force-with-lease value +for the contribution branch. A lease mismatch changes no remote refs. + +A user can select **Use PR version** to adopt the provider version. Kandev requires a clean working tree. +It fetches and checks the expected provider head before local mutation. It creates a recovery branch at +the current task HEAD before it resets the task branch. + +The managed replacement actions are session-scoped user actions. Kandev does not expose them through +agent MCP tools or automatic Git operations. Generic contribution force-push requests remain rejected. +Direct terminal commands remain outside this UI approval boundary. + +Normal linear histories keep their current behavior. A provider-ahead branch offers Pull. A local-ahead +branch offers normal Push. Generic Pull stays unavailable for diverged histories because it requires a +merge strategy. + +## Consequences + +- Users can continue local work after a provider rewrite. +- Users select which version wins instead of selecting a Git strategy. +- Remote replacement can remove provider commits. The confirmation and exact lease make this effect + explicit and protect changes that arrive after confirmation. +- Adopting the provider version adds a local recovery branch. +- Desktop and mobile require the same choices, safety conditions, and outcomes. +- The replacement flow applies to one selected repository. It never performs a destructive multi-repo + fan-out. + +## Alternatives Considered + +### Keep all remote actions blocked + +Rejected. This policy protects data but gives the user no way to complete the task in Kandev. + +### Treat the provider version as automatically correct + +Rejected. An automatic reset can erase task work and makes an external rewrite authoritative without +user intent. + +### Rebase or merge automatically + +Rejected. These actions select a conflict policy and can create new history. The user asked to choose +which version wins, not how Git combines them. + +### Allow an unrestricted contribution force-push + +Rejected. A plain force-push can overwrite a provider update that arrives after the confirmation. An +exact lease preserves the user's intent boundary. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index c162a195c6..0d0d1bc6ea 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -146,7 +146,8 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-09-exclusive-runtime-state-ownership | [Lock Runtime State Before Backend Startup](2026-08-09-exclusive-runtime-state-ownership.md) | accepted | backend, infra | 2026-08-09 | | 2026-08-10-duration-aware-e2e-sharding | [Duration-aware E2E sharding uses rolling `main` timings](2026-08-10-duration-aware-e2e-sharding.md) | accepted | infra, workflow | 2026-08-10 | | 2026-08-10-debug-launcher-profile-selection | [Keep Debug Launches on the Production Profile](2026-08-10-debug-launcher-profile-selection.md) | accepted | backend, frontend, cli | 2026-08-10 | -| 2026-08-10-remote-contribution-head-drift | [Separate Current Contribution and Local Checkout Histories](2026-08-10-remote-contribution-head-drift.md) | accepted | backend, frontend, protocol, GitHub, GitLab | 2026-08-10 | +| 2026-08-10-remote-contribution-head-drift | [Separate Current Contribution and Local Checkout Histories](2026-08-10-remote-contribution-head-drift.md) | accepted (remote-action policy superseded) | backend, frontend, protocol, GitHub, GitLab | 2026-08-10 | +| 2026-08-12-local-first-contribution-replacement | [Keep Remote Contribution Work Local-First](2026-08-12-local-first-contribution-replacement.md) | accepted | backend, frontend, protocol, security, GitHub, GitLab | 2026-08-12 | | 2026-08-11-user-owned-status-bar-visibility | [Make Status Bar Visibility a Portable User Preference](2026-08-11-user-owned-status-bar-visibility.md) | accepted | backend, frontend, protocol | 2026-08-11 | | 2026-08-12-built-in-utility-default-inheritance | [Built-in Utility Actions Inherit the Global Profile by Default](2026-08-12-built-in-utility-default-inheritance.md) | accepted | backend, frontend | 2026-08-12 | | 2026-08-10-no-em-dash-public-copy | [Keep Em Dashes Out of Public Copy](2026-08-10-no-em-dash-public-copy.md) | accepted | frontend, infra | 2026-08-10 | diff --git a/docs/plans/remote-contribution-local-first/plan.md b/docs/plans/remote-contribution-local-first/plan.md new file mode 100644 index 0000000000..f2027e023b --- /dev/null +++ b/docs/plans/remote-contribution-local-first/plan.md @@ -0,0 +1,254 @@ +--- +spec: docs/specs/tasks/remote-contribution-tasks.md +created: 2026-08-12 +status: completed +--- + +# Implementation Plan: Local-First Remote Contributions + +## Overview + +Keep a diverged task checkout as the user's working version. Replace the current blocking warning +with explicit version choices. Add exact-lease backend operations before the desktop and mobile UI can +offer remote replacement or local adoption. + +The plan implements +[ADR-2026-08-12](../../decisions/2026-08-12-local-first-contribution-replacement.md). + +## Invariants + +- The task checkout is the working version. The provider branch is the published version. +- Drift detection does not change either version. +- Remote replacement targets one contribution repository and uses an exact provider-head lease. +- Adopting the provider version requires a clean working tree and creates a recovery branch first. +- Kandev exposes the managed replacement actions only through session-scoped user UI actions. +- Normal aligned, local-ahead, and provider-ahead behavior does not change. +- The first UI implementation covers GitHub PR drift. The backend operations stay provider-neutral. + +## Backend + +### Explicit contribution operations + +Add `ReplaceRemoteContribution` and `UseRemoteContribution` to +`apps/backend/internal/agentctl/server/process/`. Keep them separate from `GitOperator.Push` so a +generic force request cannot become an implicit contribution replacement. + +`ReplaceRemoteContribution` must: + +1. Require a valid remote-contribution binding and a full expected commit ID. +2. Target the binding's contribution remote and exact head ref. +3. Invoke `git push` with + `--force-with-lease=refs/heads/:`. +4. Return the normal Git operation result without retrying a lease mismatch. + +`UseRemoteContribution` must: + +1. Reject staged, unstaged, and untracked file changes. +2. Fetch the bound contribution head without changing the current branch. +3. Require the fetched commit ID to equal `expected_remote_head`. +4. Create a unique local recovery branch at the current task HEAD. +5. Reset the task branch to the fetched provider head and retain its upstream. +6. Return the recovery branch name in the Git operation result. + +Use the existing classified Git subprocess path. Validate commit IDs and generated ref names before +they reach command arguments. + +### Agentctl and runtime contracts + +Add these internal agentctl endpoints: + +- `POST /api/v1/git/contribution/replace` +- `POST /api/v1/git/contribution/use` + +Each request carries `expected_remote_head` and the existing optional `repo` scope. Extend the runtime +agentctl client with typed methods and round-trip tests. + +Add the user-facing WebSocket actions `worktree.replace_contribution` and +`worktree.use_contribution`. Each payload carries `session_id`, `expected_remote_head`, and one optional +repository scope. Register the actions only in `GitHandlers`. Do not add MCP tools or automatic call +sites. + +The existing gateway session authorization remains the first request guard. A destructive action must +never fan out across repositories. + +## Frontend + +### Relation and action model + +Update `remote-contribution-relation.ts` so `diverged` means a local-first presentation instead of a +blocked workflow. Preserve normal Push and Pull capability fields. Add explicit replacement and +provider-adoption capability fields that require complete provider evidence and an exact provider +head. + +Replace `remoteMutationBlocked` and the `history_changed` disabled reason with an action model that +distinguishes: + +- normal Push +- provider-ahead Pull +- diverged remote replacement +- diverged provider adoption +- unavailable evidence + +Add typed callbacks in `use-git-operations.ts` for both new WebSocket actions. The callbacks must keep +an explicit empty repository scope and must not use the multi-repository fan-out wrapper. + +### Shared copy and result handling + +Add localized copy to the `task` namespace in English, Portuguese, Simplified Chinese, and pseudo. +Use these English concepts consistently: + +- status title: **PR branch changed** +- status body: **Your task version is unchanged. When you are ready, choose which version to use.** +- primary destructive action: **Replace PR branch** +- local destructive action: **Use PR version** +- secondary disclosure: **PR # version** + +The replacement confirmation must identify the repository and provider head. It must state that the +task version replaces the published branch. The provider-adoption confirmation must state that Kandev +creates a recovery branch and requires a clean working tree. + +After provider adoption, show the recovery branch name in success feedback. Map stable backend error +conditions to translated instructions. Lease mismatches must tell the user that the PR changed again. + +### Desktop Changes and VCS surfaces + +For a diverged relation: + +- Show one yellow warning icon in the Changes toolbar and no repeated warning banner in the body. +- Keep the local task history as the primary commit list. +- Keep the provider history collapsed behind **PR # version**. +- Keep local edit and commit actions enabled. +- Replace disabled Push and force-push controls with **Replace PR branch**. +- Add **Use PR version** as the secondary version choice. +- Keep generic Pull disabled. +- Show each desktop action explanation in the shared immediate tooltip component. + +Changes relation and PR-file inputs must include only pull requests whose `repository_id` and +normalized `head_branch` match a live checkout status. A historical PR may remain in Review, but it +must not supply provider commits, files, links, or drift state to Changes. + +Apply the same action model to `ChangesPanelHeader`, `VcsSplitButton`, and +`VcsMultiRepoMenu`. Scope every destructive action to the selected contribution repository. + +### Mobile design contract + +- **Desktop outcome:** The user can inspect both versions and select which version wins. +- **Mobile entry point:** The existing Git actions trigger in the task top bar. +- **Nearest exemplar:** `session-mobile-top-bar-git-controls.tsx` supplies the compact menu entry. + `mobile-menu-sheet.tsx` supplies the inset, safe-area-aware bottom surface. +- **Hierarchy:** Keep Commit first. Show **Replace PR branch** as the primary drift action. Show + **Use PR version** and **PR # version** as secondary actions. +- **Presentation:** Use the existing responsive Git menu for entry. Use an inset bottom drawer for the + destructive confirmation on phone. Keep the desktop confirmation as a dialog. +- **Rationale:** The confirmation is temporary and action-focused. It does not need a new route or a + full-height surface. +- **Geometry:** Keep one internal scroll owner. Clear the bottom safe area. Give action rows and + confirmation buttons a 44px touch target. Keep document horizontal overflow at zero. +- **Shared logic:** Reuse the relation, operation callbacks, confirmation state, and result handling. + Keep only the dialog or drawer composition viewport-specific. +- **Mobile proof:** Replace the PR branch from the Pixel 5 Git menu and verify the exact remote result. + +## Tests + +- **Exact leased replacement:** Add a process test with a real bare source repository. Prove that the + expected head succeeds and a moved head fails without ref mutation. +- **Provider adoption:** Add a process test that proves the recovery branch preserves old HEAD and the + task branch moves to the expected provider head. Cover dirty worktrees and stale fetches. +- **Contract forwarding:** Extend agentctl API, runtime client, and WebSocket handler tests for the new + action names, repository scope, expected head, response fields, and missing-field errors. +- **Relation policy:** Update the pure classifier tests for local-first divergence, unavailable evidence, + provider-ahead Pull, and local-ahead Push. +- **Branch-scoped selection:** Prove that a merged historical PR cannot override the open PR matching + the checked-out repository and branch, including when Review still selects the historical PR. +- **Frontend callbacks:** Prove both actions send one repository scope and the exact provider head. +- **Desktop components:** Prove that the compact state replaces the blocking warning. Prove that local + actions remain available and destructive actions require confirmation. +- **Mobile components:** Prove that the Git menu exposes all three drift actions. Prove that the phone + confirmation uses a drawer and keeps all touch actions enabled. +- **Localization:** Run the i18n catalog and changed-line ratchet checks. + +## E2E Tests + +### Replace a changed PR branch on desktop + +- **Scenario:** A provider rewrites the PR after task creation. The user chooses the task version. +- **File:** `apps/web/e2e/tests/git/git-changes-panel.spec.ts` +- **What to verify:** The toolbar warning appears without a repeated body banner, provider history + stays collapsed, confirmation names the effect, and the remote ref changes to local HEAD through an + exact lease. + +### Adopt the current PR version on desktop + +- **Scenario:** A provider rewrites the PR after task creation. The user chooses the PR version. +- **File:** `apps/web/e2e/tests/git/git-changes-panel.spec.ts` +- **What to verify:** The task branch moves to the provider head, the recovery branch retains old HEAD, + and the drift status clears after provider refresh. + +### Replace a changed PR branch on mobile + +- **Scenario:** A phone user selects **Replace PR branch** from the Git actions menu. +- **File:** `apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts` +- **What to verify:** The bottom drawer explains the replacement, the action succeeds, the remote ref + changes, touch targets remain usable, and the document has no horizontal overflow. + +Use real local Git refs for provider-head fixtures. Do not use synthetic commit IDs for leased actions. +Update the mock provider state after a successful push so the UI can converge to the aligned state. + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [Task 01: Exact-lease contribution operations](task-01-exact-lease-contribution-operations.md) + +Wave 2: + +- [x] [Task 02: Local-first relation and shared actions](task-02-local-first-relation-and-shared-actions.md) + +Wave 3 (parallel candidates after Task 02, user authorization required): + +- [x] [Task 03: Desktop local-first contribution UI](task-03-desktop-local-first-contribution-ui.md) +- [x] [Task 04: Mobile contribution version choices](task-04-mobile-contribution-version-choices.md) + +Wave 4: + +- [x] [Task 05: Desktop and mobile drift E2E](task-05-desktop-mobile-drift-e2e.md) + +Wave 5: + +- [x] [Task 06: Branch-scoped drift UX correction](task-06-branch-scoped-drift-ux-correction.md) + +The default execution order is sequential in the primary conversation. The wave labels do not +authorize subagents. + +## Verification Results + +Completed 2026-08-12. Each task records its exact commands and results. + +- Backend focused verification passed 1,753 tests across the process, agentctl API, runtime client, and + WebSocket handler packages. +- Frontend focused relation, operation, desktop, VCS, and mobile tests passed, including the shared + 26-test desktop/mobile action suite. `pnpm run typecheck` passed. +- `pnpm run i18n:check` and `pnpm run i18n:ratchet` passed. +- Desktop and Pixel 5 drift scenarios each passed once with retries disabled. Their provider histories + use real local Git commits and verify the confirmation boundary and responsive interaction contract. +- Destructive ref mutation and provider adoption are covered by the real-Git process tests. The current + REST E2E helper cannot create the server-owned contribution binding, so browser execution stops before + mutation; the limitation is recorded in Task 05. +- The branch-scoped follow-up passed 66 focused Vitest assertions, full web lint and typecheck, i18n + catalog and ratchet checks, all 21 desktop Changes E2E scenarios, and the Pixel 5 drift scenario with + retries disabled. It proves that a merged historical PR does not drive the live checkout relation. + +## Documentation Impact + +The task behavior spec and the new ADR contain the user contract and safety boundary. This change does +not affect public CLI commands, configuration keys, installation, deployment, or executor setup. +`docs/public/**` does not require an update. + +## Risks + +- A bad lease implementation can overwrite provider commits that arrived after confirmation. +- A provider-adoption operation can lose uncommitted files unless the clean-tree guard includes staged, + unstaged, and untracked files. +- If a desktop or mobile entry point keeps the old force-push path, generic Push controls can bypass the + intended copy. +- Provider mocks can hide lease races unless E2E uses real remote refs. diff --git a/docs/plans/remote-contribution-local-first/task-01-exact-lease-contribution-operations.md b/docs/plans/remote-contribution-local-first/task-01-exact-lease-contribution-operations.md new file mode 100644 index 0000000000..ffaaa3f07f --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-01-exact-lease-contribution-operations.md @@ -0,0 +1,90 @@ +--- +id: "01-exact-lease-contribution-operations" +title: "Exact-lease contribution operations" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 01: Exact-Lease Contribution Operations + +Add explicit backend operations that replace the contribution branch or adopt its current provider +head. Keep generic contribution force-push requests rejected. + +## Inputs + +- Spec sections: What, API surface, Permissions, Failure modes, and Persistence guarantees. +- Plan sections: Backend and Tests. +- Decision: `docs/decisions/2026-08-12-local-first-contribution-replacement.md`. +- Existing pattern: `GitOperator.Push`, agentctl Git API handlers, runtime client methods, and + `GitHandlers` WebSocket forwarding. + +## Acceptance + +1. An exact leased replacement succeeds only when the contribution branch still matches the expected + provider head. A mismatch changes no remote refs. +2. Provider adoption requires a clean tree, checks the fetched head, creates a recovery branch, and + resets the task branch only after all guards pass. +3. The new session-scoped actions forward one repository scope and remain absent from MCP and automatic + operation registrations. + +## Files Likely Touched + +- `apps/backend/internal/agentctl/server/process/git.go` +- `apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution.go` (new) +- `apps/backend/internal/agentctl/server/process/git_remote_contribution_resolution_test.go` (new) +- `apps/backend/internal/agentctl/server/api/git.go` +- `apps/backend/internal/agentctl/server/api/server.go` +- `apps/backend/internal/agentctl/server/api/git_handlers_test.go` +- `apps/backend/internal/agent/runtime/agentctl/git.go` +- `apps/backend/internal/agent/runtime/agentctl/git_test.go` +- `apps/backend/internal/agent/handlers/git_handlers.go` +- `apps/backend/internal/agent/handlers/git_handlers_test.go` +- `apps/backend/pkg/websocket/actions.go` + +## Verification + +```bash +cd apps/backend && go test ./internal/agentctl/server/process ./internal/agentctl/server/api ./internal/agent/runtime/agentctl ./internal/agent/handlers +``` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task defines the contracts that every later task consumes. + +## Risks + +- Quote and validate the exact lease ref without constructing a shell command. +- Do not create the recovery branch before fetch and clean-tree guards pass. +- Do not use the generic multi-repository fan-out path. + +## Output Contract + +Report the request and response contracts, Git arguments, files changed, exact test results, and any +remaining provider-specific behavior. Update this task and `plan.md` in the same conversation. + +## Results + +Completed on 2026-08-12. + +- Added provider-neutral `ReplaceRemoteContribution` and `UseRemoteContribution` process + operations. Replacement uses an exact `--force-with-lease` refspec and rejects stale provider + heads without changing the remote. Adoption rejects staged, unstaged, and untracked changes, + verifies the fetched provider head, creates a recovery branch at the task HEAD, and resets the + task branch while retaining its upstream. +- Added the agentctl HTTP endpoints, typed runtime client methods, and session-scoped WebSocket + actions `worktree.replace_contribution` and `worktree.use_contribution`. Each forwards one + optional repository scope and the full expected provider head. The adoption result forwards the + recovery branch name. +- Added malformed-request, missing-field, exact-payload, response-decoding, handler forwarding, + stale-lease, dirty-tree, recovery, and real bare-repository tests. +- Verification: `cd apps/backend && go test ./internal/agentctl/server/process ./internal/agentctl/server/api ./internal/agent/runtime/agentctl ./internal/agent/handlers` + passed: 1,753 tests in 4 packages. +- Provider-specific behavior remains outside this task. The operations consume the existing + validated remote-contribution binding. diff --git a/docs/plans/remote-contribution-local-first/task-02-local-first-relation-and-shared-actions.md b/docs/plans/remote-contribution-local-first/task-02-local-first-relation-and-shared-actions.md new file mode 100644 index 0000000000..62817aa920 --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-02-local-first-relation-and-shared-actions.md @@ -0,0 +1,90 @@ +--- +id: "02-local-first-relation-and-shared-actions" +title: "Local-first relation and shared actions" +status: completed +wave: 2 +depends_on: ["01-exact-lease-contribution-operations"] +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 02: Local-First Relation and Shared Actions + +Change diverged history from a blocked state to a local-first state. Add typed frontend operations and +shared confirmation state for the exact-lease backend actions. + +## Inputs + +- Spec sections: What, API surface, Failure modes, and rewritten-history scenarios. +- Plan sections: Relation and action model, Shared copy and result handling, and Tests. +- Task 01 request and response contracts. + +## Acceptance + +1. The pure relation model distinguishes normal Push, provider-ahead Pull, remote replacement, + provider adoption, and unavailable evidence. +2. Frontend callbacks send the exact provider head and one explicit repository scope. They never use + destructive multi-repository fan-out. +3. Shared state and translated copy cover confirmation, stale leases, dirty worktrees, and recovery + branch success feedback in all four task catalogs. + +## Files Likely Touched + +- `apps/web/hooks/domains/session/remote-contribution-relation.ts` +- `apps/web/hooks/domains/session/remote-contribution-relation.test.ts` +- `apps/web/hooks/domains/session/use-remote-contribution-relation.ts` +- `apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx` +- `apps/web/hooks/use-git-operations.ts` +- `apps/web/hooks/use-git-operations.test.ts` +- `apps/web/components/task/use-remote-contribution-resolution.ts` (new) +- `apps/web/components/task/use-remote-contribution-resolution.test.tsx` (new) +- `apps/web/components/task/changes-panel-helpers.ts` +- `apps/web/components/task/changes-panel-remote.test.ts` +- `apps/web/src/locales/en/task.json` +- `apps/web/src/locales/pt-pt/task.json` +- `apps/web/src/locales/zh-cn/task.json` +- `apps/web/src/locales/pseudo/task.json` + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- hooks/domains/session/remote-contribution-relation.test.ts hooks/domains/session/use-remote-contribution-relation.test.tsx hooks/use-git-operations.test.ts components/task/use-remote-contribution-resolution.test.tsx components/task/changes-panel-remote.test.ts && pnpm --filter @kandev/web i18n:check && pnpm --filter @kandev/web i18n:ratchet && cd web && pnpm run typecheck +``` + +## Dependencies + +Task 01. + +## Parallelism + +Sequential. The desktop and mobile tasks consume this shared model. + +## Risks + +- Do not treat incomplete provider commits as permission to replace a branch. +- Keep GitHub-specific selection in the current hook. Do not imply GitLab UI support without MR-head + evidence. +- Keep translation keys at render time and preserve plural handling through `count`. + +## Output Contract + +Report the action truth table, request payloads, localized keys, files changed, and exact test results. +Update this task and `plan.md` in the same conversation. + +## Results + +Completed on 2026-08-12. + +- Replaced the blocking divergence relation with an action model covering `normal_push`, + `provider_ahead_pull`, `diverged_replace`, and `unavailable_evidence`. Diverged relations expose + `canReplaceRemote` and `canUseRemote` only when provider evidence is complete and the provider + head is a full commit ID. Incomplete evidence disables all version-changing actions. +- Added typed `replaceRemoteContribution` and `useRemoteContribution` callbacks. They send + `expected_remote_head` and preserve an explicit empty `repo` scope for the workspace root. +- Added `useRemoteContributionResolution` for shared desktop/mobile confirmation state and result + handling. It keeps the provider head and repository target in the pending confirmation, exposes + recovery branch results, and maps lease, dirty-tree, and generic failures to translated keys. +- Added task-namespace copy for the status, version choices, confirmations, stable error guidance, + and recovery success in English, Portuguese, Simplified Chinese, and pseudo locales. +- Verification: focused Vitest command from this task passed 42 tests in 5 files. `pnpm run + i18n:check` and `pnpm run i18n:ratchet` passed. `pnpm run typecheck` passed. diff --git a/docs/plans/remote-contribution-local-first/task-03-desktop-local-first-contribution-ui.md b/docs/plans/remote-contribution-local-first/task-03-desktop-local-first-contribution-ui.md new file mode 100644 index 0000000000..f6576c7f3a --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-03-desktop-local-first-contribution-ui.md @@ -0,0 +1,85 @@ +--- +id: "03-desktop-local-first-contribution-ui" +title: "Desktop local-first contribution UI" +status: completed +wave: 3 +depends_on: ["02-local-first-relation-and-shared-actions"] +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 03: Desktop Local-First Contribution UI + +Replace the blocking desktop warning with a compact local-first status, a collapsed provider version, +and explicit version actions. + +## Inputs + +- Spec scenarios: local-first rewritten contribution, exact replacement, stale lease, provider + adoption, and local file preservation. +- Plan section: Desktop Changes and VCS surfaces. +- Task 02 relation and resolution state. + +## Acceptance + +1. A diverged Changes panel keeps the task history primary and keeps the PR history collapsed behind + **PR # version**. +2. Changes and VCS controls keep local actions enabled and expose **Replace PR branch** and + **Use PR version** with destructive confirmation. +3. Success and error feedback show the recovery branch or the actionable stale-lease and dirty-tree + result. + +## Files Likely Touched + +- `apps/web/components/task/changes-panel-body.tsx` +- `apps/web/components/task/changes-panel-data.tsx` +- `apps/web/components/task/changes-panel-header.tsx` +- `apps/web/components/task/changes-panel-timeline.tsx` +- `apps/web/components/task/changes-panel-hooks.ts` +- `apps/web/components/task/remote-contribution-resolution-dialog.tsx` (new) +- `apps/web/components/task/remote-contribution-resolution-dialog.test.tsx` (new) +- `apps/web/components/task/changes-panel-header.test.tsx` +- `apps/web/components/vcs-split-button.tsx` +- `apps/web/components/vcs-split-button.test.ts` +- `apps/web/components/vcs-multi-repo-menu.tsx` +- `apps/web/components/vcs-multi-repo-menu.test.ts` + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- components/task/changes-panel-header.test.tsx components/task/remote-contribution-resolution-dialog.test.tsx components/vcs-split-button.test.ts components/vcs-multi-repo-menu.test.ts && cd web && pnpm run typecheck +``` + +## Dependencies + +Task 02. + +## Parallelism + +Parallel-safe with Task 04 after Task 02. The two tasks own disjoint presentation files. The primary +conversation remains sequential unless the user authorizes subagents. + +## Risks + +- Do not hide local commit recovery actions while the provider disclosure is collapsed. +- Do not keep the old generic force-push entry for a diverged contribution. +- Keep the destructive action scoped to the selected repository. + +## Output Contract + +Report the desktop interaction, component files, focused rendered evidence, and exact test results. +Update this task and `plan.md` in the same conversation. + +## Results + +Completed 2026-08-12. + +- The Changes panel now presents the local checkout first, keeps provider commits collapsed behind + **PR # version**, and preserves local edit, commit, and review actions during drift. +- The Changes header, split VCS button, and per-repository menu expose repository-scoped **Replace PR + branch**, **Use PR version**, and **PR # version** actions. Generic Push and force-push entries are + removed only for the selected diverged contribution, while generic Pull remains unavailable. +- The desktop confirmation names the repository and exact provider head, explains the replacement or + recovery effect, keeps failed confirmations open for retry, and shows translated result feedback. +- Verification passed: the shared focused desktop/VCS suite (26 tests in 7 files), `pnpm run typecheck`, + and the desktop local-first Playwright scenario (1 passed, retries disabled). diff --git a/docs/plans/remote-contribution-local-first/task-04-mobile-contribution-version-choices.md b/docs/plans/remote-contribution-local-first/task-04-mobile-contribution-version-choices.md new file mode 100644 index 0000000000..df77b5a2cd --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-04-mobile-contribution-version-choices.md @@ -0,0 +1,79 @@ +--- +id: "04-mobile-contribution-version-choices" +title: "Mobile contribution version choices" +status: completed +wave: 3 +depends_on: ["02-local-first-relation-and-shared-actions"] +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 04: Mobile Contribution Version Choices + +Add the same version choices to the mobile Git menu. Use a phone-native confirmation drawer for each +destructive action. + +## Inputs + +- Spec scenario: Provide the same choice on mobile. +- Plan section: Mobile design contract. +- Mobile exemplars: `session-mobile-top-bar-git-controls.tsx` and + `components/kanban/mobile-menu-sheet.tsx`. +- Task 02 relation and resolution state. + +## Acceptance + +1. The mobile Git menu exposes **Replace PR branch**, **Use PR version**, and **PR # version** for a + diverged contribution. +2. The destructive confirmations use an inset bottom drawer with 44px actions, one scroll owner, + safe-area clearance, and focus return. +3. Mobile uses the same expected head, repository scope, loading state, and result handling as desktop. + +## Files Likely Touched + +- `apps/web/components/task/mobile/session-mobile-top-bar.tsx` +- `apps/web/components/task/mobile/session-mobile-top-bar-git-controls.tsx` +- `apps/web/components/task/mobile/session-mobile-top-bar-git-controls.test.tsx` +- `apps/web/components/task/mobile/mobile-git-push-submenu.tsx` +- `apps/web/components/task/mobile/mobile-contribution-resolution-drawer.tsx` (new) +- `apps/web/components/task/mobile/mobile-contribution-resolution-drawer.test.tsx` (new) + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- components/task/mobile/session-mobile-top-bar-git-controls.test.tsx components/task/mobile/mobile-contribution-resolution-drawer.test.tsx && cd web && pnpm run typecheck +``` + +## Dependencies + +Task 02. + +## Parallelism + +Parallel-safe with Task 03 after Task 02. The two tasks own disjoint presentation files. The primary +conversation remains sequential unless the user authorizes subagents. + +## Risks + +- Do not add a nested vertical scroller to the existing mobile Changes surface. +- Do not mount the desktop dialog and hide it on phone. +- Do not leave force-push reachable only through a submenu that still uses the old disabled policy. + +## Output Contract + +Report the mobile entry point, drawer geometry, accessibility behavior, rendered phone evidence, and +exact test results. Update this task and `plan.md` in the same conversation. + +## Results + +Completed 2026-08-12. + +- The mobile Git menu keeps Commit first and exposes repository-scoped **Replace PR branch**, **Use PR + version**, and **PR # version** actions for diverged contributions. Generic Push, force-push, and + Pull entries are not reachable for the selected drift state. +- Destructive confirmation uses an inset bottom drawer with safe-area padding, 44px action targets, + one inner error scroll owner, and shared resolution state with the desktop dialog. +- Mobile uses the exact provider head and explicit repository scope for both operations, with translated + stale-lease, dirty-tree, and recovery-branch feedback. +- Verification passed: the shared focused desktop/mobile suite (26 tests in 7 files), + `pnpm run typecheck`, and the Pixel 5 local-first Playwright scenario (1 passed, retries disabled). diff --git a/docs/plans/remote-contribution-local-first/task-05-desktop-mobile-drift-e2e.md b/docs/plans/remote-contribution-local-first/task-05-desktop-mobile-drift-e2e.md new file mode 100644 index 0000000000..b71d08f5e3 --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-05-desktop-mobile-drift-e2e.md @@ -0,0 +1,89 @@ +--- +id: "05-desktop-mobile-drift-e2e" +title: "Desktop and mobile drift E2E" +status: completed +wave: 4 +depends_on: + - "01-exact-lease-contribution-operations" + - "03-desktop-local-first-contribution-ui" + - "04-mobile-contribution-version-choices" +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 05: Desktop and Mobile Drift E2E + +Prove the full local-first flow against real local Git refs and provider mocks. Cover desktop remote +replacement, desktop provider adoption, and mobile remote replacement. + +## Inputs + +- Spec rewritten-history and mobile scenarios. +- Plan section: E2E Tests. +- Existing drift coverage in `git-changes-panel.spec.ts` and + `mobile-pr-checkout-drift.spec.ts`. +- Tasks 01, 03, and 04. + +## Acceptance + +1. Desktop replacement changes the real contribution ref only after confirmation and exact lease + success. A stale lease changes no ref. +2. Desktop provider adoption preserves old HEAD on a recovery branch and moves the task branch to the + provider head. +3. Mobile completes remote replacement from the Git menu and satisfies viewport, touch-target, scroll, + and horizontal-overflow checks. + +## Files Likely Touched + +- `apps/web/e2e/tests/git/git-changes-panel.spec.ts` +- `apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts` +- `apps/web/e2e/tests/git/git-changes-panel-helpers.ts` (new, only if both specs share setup) +- `apps/web/e2e/helpers/api-client.ts` (only if provider mock refresh needs a missing helper) + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project chromium tests/git/git-changes-panel.spec.ts -- --grep "local-first contribution" && pnpm e2e:run --project mobile-chrome tests/git/mobile-pr-checkout-drift.spec.ts +``` + +## Dependencies + +Tasks 01, 03, and 04. + +## Parallelism + +Sequential. This task integrates shared backend and responsive frontend behavior. + +## Risks + +- Synthetic provider SHAs cannot prove force-with-lease behavior. Use commits that exist in the test + remote. +- Update provider mocks after remote mutation so aligned-state assertions observe current data. +- Confirm Playwright discovers the intended desktop and mobile test counts before accepting results. +- Use `.tap()` for touch-specific mobile controls and remove all temporary runtime artifacts. + +## Output Contract + +Report the discovered test counts, exact commands, Git ref assertions, screenshot paths, and cleanup +evidence. Update this task and `plan.md` in the same conversation. + +## Results + +Completed 2026-08-12. + +- Desktop and mobile drift fixtures now create provider histories from real local Git commits. The UI + tests verify local-first status, collapsed provider history, preserved local history, exact provider + head disclosure, scoped action availability, mobile touch targets, one scroll owner, and zero + horizontal overflow. +- Desktop verification passed: + `pnpm e2e:run --no-build --project chromium tests/git/git-changes-panel.spec.ts -- --grep "local-first contribution keeps rewritten provider history separate" --retries=0` + (1 passed). +- Mobile verification passed: + `pnpm e2e:run --no-build --project mobile-chrome tests/git/mobile-pr-checkout-drift.spec.ts -- --grep "local-first contribution menu preserves local history after a rewrite" --retries=0` + (1 passed). +- The real-Git process suite proves successful exact-lease replacement, stale-lease no-mutation, + provider adoption, recovery-branch preservation, dirty-tree rejection, and stale-fetch rejection. +- The current REST E2E helper creates a TaskPR association but cannot author the server-owned + `RemoteContribution` binding. Therefore the browser scenarios stop at the confirmation boundary; + destructive ref mutation and provider adoption are covered by the real-Git process tests rather than + by these REST-created browser tasks. A future bound-provider E2E can use the MCP task-creation path. diff --git a/docs/plans/remote-contribution-local-first/task-06-branch-scoped-drift-ux-correction.md b/docs/plans/remote-contribution-local-first/task-06-branch-scoped-drift-ux-correction.md new file mode 100644 index 0000000000..4ee6976d84 --- /dev/null +++ b/docs/plans/remote-contribution-local-first/task-06-branch-scoped-drift-ux-correction.md @@ -0,0 +1,83 @@ +--- +id: "06-branch-scoped-drift-ux-correction" +title: "Branch-scoped drift UX correction" +status: completed +wave: 5 +depends_on: ["05-desktop-mobile-drift-e2e"] +plan: "plan.md" +spec: "../../specs/tasks/remote-contribution-tasks.md" +--- + +# Task 06: Branch-Scoped Drift UX Correction + +Prevent historical pull requests from driving the live Changes relation and remove the duplicated +drift warning from the panel body. + +## Confirmed Root Cause + +`useRemoteContributionRelation` reads `useReviewPRSelection`, whose default is the first associated +pull request. A merged PR can therefore supply provider commits for a different branch while the Git +status comes from the current checkout, producing a false `diverged` relation. The Changes PR-file +path also reads every task PR, so historical files can leak into the live panel. + +## Acceptance + +1. Changes selects pull requests only when repository identity and normalized head branch match a + live Git status. An open current-branch PR wins over a terminal sibling on the same branch. +2. Review selection remains independent and can continue to show historical pull requests. +3. Divergence appears as the toolbar warning icon and menu only. The body has no warning banner. +4. The provider disclosure is labeled **PR # version** on desktop and mobile. +5. Desktop info icons open immediate tooltips without intercepting adjacent menu actions. + +## TDD And Verification + +1. Add a failing hook regression for a merged first PR and open second PR on the live branch. +2. Add failing desktop and mobile assertions for the corrected copy and warning composition. +3. Implement the branch-scoped selector and share it with provider commits and PR files. +4. Run focused Vitest, typecheck, i18n checks, and the two drift Playwright scenarios with retries + disabled. + +## Files Likely Touched + +- `apps/web/hooks/domains/session/use-remote-contribution-relation.ts` +- `apps/web/hooks/domains/session/use-remote-contribution-relation.test.tsx` +- `apps/web/hooks/domains/github/use-active-task-pr-files.ts` +- `apps/web/components/task/changes-panel-data.tsx` +- `apps/web/components/task/changes-panel-body.tsx` +- `apps/web/components/task/remote-contribution-action-items.tsx` +- `apps/web/components/task/remote-contribution-header-actions.tsx` +- `apps/web/components/vcs-*.tsx` +- `apps/web/components/task/mobile/*.tsx` +- `apps/web/src/locales/*/task.json` +- `apps/web/e2e/tests/git/git-changes-panel.spec.ts` +- `apps/web/e2e/tests/git/mobile-pr-checkout-drift.spec.ts` + +## Risks + +- Do not let a Review preference control live checkout state. +- Preserve the legacy empty repository key for single-repository sessions. +- Keep tooltips non-interactive so their portal cannot block dropdown selection. + +## Results + +Completed 2026-08-12. + +- Added a branch-scoped selector that matches pull requests to live Git status by repository identity + and normalized branch. An open pull request wins over a merged or closed pull request on the same + branch, and Review selection cannot override a different checked-out branch. +- Scoped provider commits, files, links, and drift state to the selected live pull request. Added the + two-PR regression that keeps the merged pull request in Review while Changes follows the open pull + request for the checkout. +- Removed the repeated yellow body banner. Kept the toolbar warning menu, changed the provider label to + **PR # version**, and replaced native titles with immediate shared tooltips. +- Focused verification passed: + `pnpm --filter @kandev/web test -- hooks/domains/session/branch-scoped-task-pr.test.ts hooks/domains/session/use-remote-contribution-relation.test.tsx hooks/domains/session/remote-contribution-relation.test.ts components/task/remote-contribution-header-actions.test.tsx components/task/use-remote-contribution-resolution.test.tsx components/task/remote-contribution-resolution-dialog.test.tsx components/task/mobile/session-mobile-top-bar-git-controls.test.tsx components/vcs-split-button.test.ts components/vcs-multi-repo-menu.test.ts components/task/changes-panel-pr-files.test.tsx components/task/changes-panel-remote.test.ts components/task/changes-panel-header.test.tsx` + (12 files, 66 tests). +- `pnpm --filter @kandev/web lint`, `pnpm run typecheck`, `pnpm run i18n:check`, and + `pnpm run i18n:ratchet` passed. +- Desktop verification passed: + `pnpm e2e:run --no-build --project chromium tests/git/git-changes-panel.spec.ts -- --retries=0` + (21 tests). The focused two-PR regression and rewritten-history scenario also passed independently. +- Pixel 5 verification passed: + `pnpm e2e:run --no-build --project mobile-chrome tests/git/mobile-pr-checkout-drift.spec.ts -- --retries=0` + (1 test). diff --git a/docs/specs/tasks/remote-contribution-tasks.md b/docs/specs/tasks/remote-contribution-tasks.md index 069b1ca8bd..6b4b2ffdcd 100644 --- a/docs/specs/tasks/remote-contribution-tasks.md +++ b/docs/specs/tasks/remote-contribution-tasks.md @@ -1,7 +1,7 @@ --- status: approved created: 2026-08-04 -updated: 2026-08-10 +updated: 2026-08-12 owner: product --- @@ -15,10 +15,9 @@ review association before an agent can make a useful change. Kandev should accep URL at task creation, prepare the contributor's exact branch, and push commits back to that branch without making `create_task_kandev` materially larger. -The contributor can update or rewrite that branch after the task starts. Kandev must not present the -old checkout and the provider's current change as one continuous history, label old contributor -commits as maintainer-authored work waiting to be pushed, or offer a remote mutation whose safety has -not been established. It must preserve local work while making the drift explicit. +The contributor can update or rewrite that branch after the task starts. Kandev keeps the task +checkout as the user's working version. The provider remains authoritative for the published change. +Kandev must preserve both versions and ask for user intent before one version replaces the other. ## What @@ -35,8 +34,8 @@ not been established. It must preserve local work while making the drift explici - The task remains attached to the target repository. A versioned, non-secret contribution binding on that task-repository attachment identifies the existing change and its source repository and branch. - The prepared checkout starts at the provider-reported head SHA on the contributor's head branch. - `origin` continues to mean the target repository; a dedicated contribution remote points at the - source repository. Push operations for that attachment target the source branch without force. + `origin` continues to mean the target repository. A dedicated contribution remote points at the + source repository. Normal pushes target the source branch without force. - A same-repository change uses the target remote for both read and write. A fork change is accepted only when the provider reports that maintainers may update the source branch. - GitHub pull requests and GitLab merge requests are associated with the new task before agent launch, @@ -54,18 +53,36 @@ not been established. It must preserve local work while making the drift explici - For an associated contribution, Kandev compares the local HEAD and upstream snapshot with the provider's current commit history using commit identity and ancestry evidence. It never infers equivalence from commit message, author, timestamp, file statistics, or patch similarity. -- When the provider history no longer contains the local HEAD and the histories cannot be proven to be - a safe local-ahead or provider-ahead relationship, the Changes panel shows the current remote change - and local checkout as separate histories. Local work remains intact. Push, force-push, and generic - pull actions are unavailable until the histories are reconciled outside this flow. +- The Changes panel uses provider evidence only from a pull request whose repository and normalized + head branch match the live checkout. Historical pull requests remain available in Review, but they + do not affect Changes after the checkout moves to another branch. +- When the provider history no longer contains local HEAD, the Changes panel keeps the task version as + the primary version. A yellow warning icon in the Changes toolbar opens the available version + actions; the panel body does not repeat the warning. The current provider history remains collapsed + behind a **PR # version** disclosure. +- A diverged task keeps local edit, commit, amend, reset, and review actions available. Generic Pull is + unavailable because it requires a merge strategy. Push becomes an explicit **Replace PR branch** + action instead of a disabled control. +- **Replace PR branch** requires a direct user action and an explicit destructive confirmation. The + confirmation identifies the selected repository and the current provider version. Kandev replaces + the remote branch only when its head still equals the confirmed provider head. +- The managed replacement action uses an exact force-with-lease condition. If the provider head moves + after confirmation, Kandev changes neither version and asks the user to review the new state. +- The user can select **Use PR version** instead. Kandev requires a clean working tree, creates a local + recovery branch at the current task HEAD, fetches the confirmed provider head, and resets the task + branch to that head. The result shows the recovery branch name. +- Kandev exposes no replacement action through agent MCP tools or automatic Git operations. Generic + contribution force-push requests remain rejected. Direct terminal commands remain outside this UI + approval boundary. - A provider-ahead history that still contains local HEAD is a safe fast-forward case: the UI may offer Pull, but must not label the existing local commits as unpushed work. A local-ahead history whose tracked upstream equals the provider head may offer a normal Push for exactly `remote_ahead` commits. Decisions: -[ADR-2026-08-04-remote-contribution-bindings](../../decisions/2026-08-04-remote-contribution-bindings.md) +[ADR-2026-08-04-remote-contribution-bindings](../../decisions/2026-08-04-remote-contribution-bindings.md), +[ADR-2026-08-10-remote-contribution-head-drift](../../decisions/2026-08-10-remote-contribution-head-drift.md), and -[ADR-2026-08-10-remote-contribution-head-drift](../../decisions/2026-08-10-remote-contribution-head-drift.md). +[ADR-2026-08-12-local-first-contribution-replacement](../../decisions/2026-08-12-local-first-contribution-replacement.md). ## Data model @@ -112,6 +129,16 @@ resolved workspace, returns the target repository input and validated contributi an association operation for the newly created task. Provider-specific API payloads do not cross that internal boundary. +The user-facing WebSocket API adds two session-scoped actions: + +- `worktree.replace_contribution` accepts `session_id`, an optional repository scope, and + `expected_remote_head`. It replaces the contribution branch only when the exact lease matches. +- `worktree.use_contribution` accepts the same identity fields. It creates a local recovery branch and + moves the clean task checkout to the confirmed provider head. + +Both actions return the existing Git operation result. `worktree.use_contribution` also returns the +recovery branch name after a successful reset. Neither action appears in the agent MCP catalog. + ## Permissions - Existing MCP authentication, workspace reachability, workflow, profile, and executor checks still @@ -125,24 +152,31 @@ internal boundary. non-mutating push preflight with the executor's effective Git credentials before starting the agent. - GitLab uses the configured workspace connection for provider validation and the existing executor credential policy for Git transport. Self-hosted MR URLs must match the configured origin exactly. +- A user with access to the task can invoke the replacement actions from the task UI. The gateway and + handler apply the existing session authorization before they access the execution. +- Agents, MCP callers, and automatic Git operations cannot invoke the managed replacement actions. ## Failure modes -| Condition | Observable behavior | -|---|---| -| URL is malformed, credential-bearing, or unsupported | Task creation fails before persistence with an argument error. | -| Provider connection cannot read the change or source repository | Task creation fails before persistence with an authorization/not-found error that does not reveal cross-workspace data. | -| Change is closed, merged, or has no live head | Task creation fails before persistence and explains that only open contributions are supported. | -| Provider returns an invalid head/base ref or inconsistent target identity | Task creation fails closed before any Git command. | -| Fork does not allow maintainer collaboration | Task creation fails before persistence with provider-specific remediation guidance. | -| Task persists but the existing-change association fails | Kandev compensates the newly created task and returns failure; it does not launch an agent. | -| Checkout SHA no longer matches the source branch during preparation | Launch fails without checking out or pushing a different revision; retry resolves fresh provider state. | -| Provider branch advances after launch and still contains local HEAD | Kandev identifies a provider-ahead fast-forward, shows the current provider history, and offers Pull instead of Push. | -| Provider branch is rewritten after launch and no longer contains local HEAD | Kandev preserves the checkout, separates local and provider histories, explains the drift, and disables unsafe remote actions. | -| Current provider commits cannot be loaded | Kandev does not assert that a rewrite occurred. It shows the provider error and derives Push/Pull only from available upstream evidence. | -| Effective Git credentials cannot dry-run a push to the source branch | The task remains durable, but the session does not start and exposes an actionable credential/collaboration error. | -| Contribution binding is missing, malformed, or an unknown version | Runtime preparation and managed source-scope issuance fail closed. | -| Agent attempts a normal create-PR action | Kandev reuses the existing association and does not open a second remote change. | +| Condition | Observable behavior | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| URL is malformed, credential-bearing, or unsupported | Task creation fails before persistence with an argument error. | +| Provider connection cannot read the change or source repository | Task creation fails before persistence with an authorization/not-found error that does not reveal cross-workspace data. | +| Change is closed, merged, or has no live head | Task creation fails before persistence and explains that only open contributions are supported. | +| Provider returns an invalid head/base ref or inconsistent target identity | Task creation fails closed before any Git command. | +| Fork does not allow maintainer collaboration | Task creation fails before persistence with provider-specific remediation guidance. | +| Task persists but the existing-change association fails | Kandev compensates the newly created task and returns failure; it does not launch an agent. | +| Checkout SHA no longer matches the source branch during preparation | Launch fails without checking out or pushing a different revision; retry resolves fresh provider state. | +| Provider branch advances after launch and still contains local HEAD | Kandev identifies a provider-ahead fast-forward, shows the current provider history, and offers Pull instead of Push. | +| Provider branch is rewritten after launch and no longer contains local HEAD | Kandev preserves the task version, shows a compact remote-change status, and offers user-controlled version choices. | +| Provider head changes after the replacement confirmation opens | The exact lease fails. Kandev does not change the remote branch and refreshes the shown provider state. | +| Provider rejects the leased replacement | The task version remains unchanged. Kandev shows the provider or Git error. | +| The user selects **Use PR version** with local file changes | Kandev does not reset the checkout. It asks the user to commit or discard the file changes first. | +| The user selects **Use PR version** and the fetch does not match the confirmed provider head | Kandev does not create a recovery branch or reset the checkout. It refreshes the provider state. | +| Current provider commits cannot be loaded | Kandev does not assert that a rewrite occurred. It shows the provider error and derives Push/Pull only from available upstream evidence. | +| Effective Git credentials cannot dry-run a push to the source branch | The task remains durable, but the session does not start and exposes an actionable credential/collaboration error. | +| Contribution binding is missing, malformed, or an unknown version | Runtime preparation and managed source-scope issuance fail closed. | +| Agent attempts a normal create-PR action | Kandev reuses the existing association and does not open a second remote change. | ## Persistence guarantees @@ -152,9 +186,12 @@ from the binding. Credential leases and preflight results are ephemeral and are launch or resume. A moved or deleted source branch causes a later launch to fail visibly rather than silently falling back to the target repository. -The original contribution `head_sha` remains creation-time provenance. It is not mutated to pretend an -existing checkout follows a later provider rewrite. Live provider commits and Git status are observed -state. Neither observation automatically resets, rebases, merges, or deletes the task checkout. +The original contribution `head_sha` remains creation-time provenance. It is not changed after a +provider rewrite. Live provider commits and Git status remain observed state. Drift detection does not +reset, rebase, merge, or replace either version without a direct user action. + +A recovery branch created by **Use PR version** remains in the task repository across backend +restarts. Kandev does not persist confirmation dialogs, provider snapshots, or replacement leases. ## Scenarios @@ -199,14 +236,56 @@ WHEN Kandev loads the current provider commits THEN the Changes panel treats the provider as ahead, does not mark the existing commits as local work to push, and offers Pull rather than Push -### Separate a rewritten provider history +### Keep a rewritten contribution local-first GIVEN a running contribution task whose provider branch is force-pushed and no longer contains local HEAD WHEN Kandev loads the current provider commits -THEN the Changes panel warns that the contribution changed, renders "Current PR commits" separately -from "Local checkout commits", preserves every local commit, and offers no Push, force-push, or generic -Pull action +THEN the Changes panel keeps the task version primary, shows one warning icon in its toolbar, and offers +**Replace PR branch**, **Use PR version**, and **PR # version** from the warning menu +AND each action explanation appears in an immediate tooltip on pointer hover +AND the panel body does not repeat the warning + +### Ignore a historical pull request after the checkout changes branch + +GIVEN a task retains a merged pull request for an earlier branch +AND a newer pull request exists for the current checked-out branch in the same repository +WHEN Kandev computes the Changes relation and provider history +THEN it uses only the newer pull request +AND the historical pull request remains available in Review without producing a Changes warning + +### Replace the PR branch with the task version + +GIVEN a diverged contribution and a confirmed provider head +WHEN the user confirms **Replace PR branch** +THEN Kandev replaces the remote branch only when the exact provider-head lease still matches + +### Reject a stale replacement lease + +GIVEN the replacement confirmation names provider head A +AND the provider branch advances to head B +WHEN the user confirms **Replace PR branch** +THEN Kandev leaves head B unchanged and shows the refreshed remote-change state + +### Use the current PR version with recovery + +GIVEN a diverged contribution with a clean working tree +WHEN the user confirms **Use PR version** +THEN Kandev creates a local recovery branch at the prior task HEAD and moves the task branch to the +confirmed provider head + +### Preserve local file changes + +GIVEN a diverged contribution with staged, unstaged, or untracked file changes +WHEN the user selects **Use PR version** +THEN Kandev keeps the checkout unchanged and asks the user to commit or discard those changes + +### Provide the same choice on mobile + +GIVEN a diverged contribution on a phone viewport +WHEN the user opens the Git actions menu +THEN the user can replace the PR branch, use the PR version, or inspect the PR version without a desktop +workflow ### Keep ordinary local-ahead work pushable @@ -241,9 +320,11 @@ mentions pull and merge request URLs - Creating tasks from issues, review comments, or arbitrary commit URLs. - Azure DevOps or additional source-control providers. - Multiple remote contributions in one create call. -- Kandev performing force pushes, branch renames, retargeting, merging, or changing collaboration - settings. -- Automatically resetting, rebasing, merging, or replacing an existing checkout after provider drift. -- A guided history-reconciliation workflow; this iteration detects and contains the unsafe state. +- Automatic force pushes, branch renames, retargeting, merging, or collaboration-setting changes. +- Automatic reset, rebase, merge, or remote replacement after provider drift. +- Automatic replay or deduplication of task commits across rewritten provider history. +- Bulk replacement across multiple repositories from one confirmation. +- GitLab mobile or Changes-panel drift presentation until that surface supplies complete MR-head + evidence. The backend replacement operations remain provider-neutral. - Copying remote titles, bodies, comments, or diffs into trusted prompts. - Guaranteeing write access after credentials or provider permissions change during a running session.