+10
-1
@@ -23,17 +23,21 @@ LABEL org.opencontainers.image.pnpm-version="${PNPM_VERSION}"
|
||||
|
||||
# Install runtime dependencies. gh is included because the GitHub integration
|
||||
# (PR review, webhooks) shells out to it for auth fallback when GITHUB_TOKEN
|
||||
# is not set. apprise is installed via pipx for notification fan-out.
|
||||
# is not set. Azure CLI + azure-devops extension support agentctl Azure Repos
|
||||
# PR creation (az repos pr create). apprise is installed via pipx for
|
||||
# notification fan-out.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
gh \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gosu \
|
||||
tini \
|
||||
python3 \
|
||||
python3-venv \
|
||||
pipx && \
|
||||
curl -sL https://aka.ms/InstallAzureCLIDeb | bash && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install apprise
|
||||
|
||||
@@ -42,6 +46,11 @@ RUN apt-get update && \
|
||||
# copilot, amp, ...) lives on the PV and survives pod restarts and image upgrades.
|
||||
RUN userdel -r node && groupadd -r kandev && useradd -r -g kandev -u 1000 -d /data/home -M kandev
|
||||
|
||||
# Install azure-devops extension under the runtime user's Azure config dir on /data.
|
||||
RUN mkdir -p /data/home/.azure && \
|
||||
AZURE_CONFIG_DIR=/data/home/.azure az extension add --name azure-devops && \
|
||||
chown -R kandev:kandev /data/home/.azure
|
||||
|
||||
# Create app directory structure matching what `kandev start` expects:
|
||||
# /app/apps/backend/bin/kandev
|
||||
# /app/apps/web/.next/standalone/web/server.js
|
||||
|
||||
@@ -20,10 +20,11 @@ agentctl exposes these route groups (see `server/api/`):
|
||||
|
||||
| Remote host | CLI | Notes |
|
||||
|-------------|-----|-------|
|
||||
| `dev.azure.com`, `ssh.dev.azure.com`, `*.visualstudio.com` | `az repos pr create` | Requires `az` on `PATH` and `azure-devops` extension. Auth: `az login` or `AZURE_DEVOPS_EXT_PAT`. |
|
||||
| Everything else (today) | `gh pr create` | GitLab agentctl routing is not implemented yet — use the `/pr` skill `glab` flow. |
|
||||
| `github.com`, `*.github.com` | `gh pr create` | Requires `gh` on `PATH` (included in the Kandev Docker image). |
|
||||
| `dev.azure.com`, `ssh.dev.azure.com`, `*.visualstudio.com` | `az repos pr create` | Requires `az` on `PATH`, `azure-devops` extension (both in the Kandev Docker image). Auth: `az login` or `AZURE_DEVOPS_EXT_PAT`. |
|
||||
| Other hosts (e.g. GitLab) | — | Returns an unsupported-remote error. Use host-specific CLIs outside agentctl (e.g. `/pr` skill `glab` for GitLab). |
|
||||
|
||||
Azure PR URLs are returned to the client but do not trigger backend `onPRCreated` / TaskPR linkage (GitHub-only today).
|
||||
Azure PR URLs are returned to the client but do not trigger backend `onPRCreated` / TaskPR linkage (GitHub-only today). The web UI keeps a session-scoped pending PR URL so the changes panel hides **Create PR** after a successful Azure create.
|
||||
|
||||
## Adapter Model
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -17,14 +18,17 @@ import (
|
||||
type prProvider string
|
||||
|
||||
const (
|
||||
prProviderGitHub prProvider = "github"
|
||||
prProviderAzureRepos prProvider = "azure_repos"
|
||||
prCreateSubcommand = "create"
|
||||
repositoryFlagTitle = "--title"
|
||||
repositoryFlagBody = "--body"
|
||||
repositoryFlagDescription = "--description"
|
||||
repositoryFlagHead = "--head"
|
||||
redactedLogValue = "[REDACTED]"
|
||||
prProviderGitHub prProvider = "github"
|
||||
prProviderAzureRepos prProvider = "azure_repos"
|
||||
prCreateSubcommand = "create"
|
||||
repositoryFlagTitle = "--title"
|
||||
repositoryFlagBody = "--body"
|
||||
repositoryFlagDescription = "--description"
|
||||
repositoryFlagHead = "--head"
|
||||
redactedLogValue = "[REDACTED]"
|
||||
azureDevOpsExtensionName = "azure-devops"
|
||||
errAzureCLIMissing = "azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops"
|
||||
errAzureDevOpsExtensionMissing = "azure DevOps CLI extension is not installed; run: az extension add --name azure-devops"
|
||||
)
|
||||
|
||||
type azureRepoInfo struct {
|
||||
@@ -391,14 +395,27 @@ func (g *GitOperator) createGitHubPR(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ensureAzureDevOpsCLI(ctx context.Context) error {
|
||||
if _, err := exec.LookPath("az"); err != nil {
|
||||
return errors.New(errAzureCLIMissing)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "az", "extension", "show", "--name", azureDevOpsExtensionName)
|
||||
cmd.Env = filterGitEnv(os.Environ())
|
||||
if err := cmd.Run(); err != nil {
|
||||
return errors.New(errAzureDevOpsExtensionMissing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *GitOperator) createAzureReposPR(
|
||||
ctx context.Context,
|
||||
result *PRCreateResult,
|
||||
remoteURL, branch, title, body, baseBranch string,
|
||||
draft bool,
|
||||
) (*PRCreateResult, error) {
|
||||
if _, err := exec.LookPath("az"); err != nil {
|
||||
result.Error = "Azure CLI (az) is not on PATH; install it and run: az extension add --name azure-devops"
|
||||
if err := ensureAzureDevOpsCLI(ctx); err != nil {
|
||||
result.Error = err.Error()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,17 @@ func TestGitOperatorCreatePR_UsesAzureCLIForAzureRepos(t *testing.T) {
|
||||
scriptDir := t.TempDir()
|
||||
azArgsPath := filepath.Join(scriptDir, "az-args.txt")
|
||||
writeExecutable(t, filepath.Join(scriptDir, "git"), fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%s\\n' 'git@ssh.dev.azure.com:v3/acme/platform/widgets'\n exit 0\nfi\nexec %q \"$@\"\n", "%s", realGit))
|
||||
writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf("#!/bin/sh\nprintf '%s\\n' \"$@\" > %q\nprintf '%s\\n' 'WARNING: preview command group' >&2\nprintf 'Creating pull request...\\n'\ncat <<'EOF'\n{\"pullRequestId\":42,\"repository\":{\"remoteUrl\":\"https://dev.azure.com/acme/platform/_git/widgets\"}}\nEOF\n", "%s", azArgsPath, "%s"))
|
||||
writeExecutable(t, filepath.Join(scriptDir, "az"), fmt.Sprintf(`#!/bin/sh
|
||||
if [ "$1" = "extension" ] && [ "$2" = "show" ]; then
|
||||
exit 0
|
||||
fi
|
||||
printf '%%s\n' "$@" > %q
|
||||
printf 'WARNING: preview command group\n' >&2
|
||||
printf 'Creating pull request...\n'
|
||||
cat <<'EOF'
|
||||
{"pullRequestId":42,"repository":{"remoteUrl":"https://dev.azure.com/acme/platform/_git/widgets"}}
|
||||
EOF
|
||||
`, azArgsPath))
|
||||
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
gitOp := NewGitOperator(repoDir, log, nil)
|
||||
@@ -165,6 +175,135 @@ func writeExecutable(t *testing.T, path, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeGitRemoteWrapper(t *testing.T, scriptDir, originURL string) {
|
||||
t.Helper()
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Fatalf("find git: %v", err)
|
||||
}
|
||||
writeExecutable(
|
||||
t,
|
||||
filepath.Join(scriptDir, "git"),
|
||||
fmt.Sprintf("#!/bin/sh\nif [ \"$1\" = \"remote\" ] && [ \"$2\" = \"get-url\" ] && [ \"$3\" = \"origin\" ]; then\n printf '%%s\\n' %q\n exit 0\nfi\nexec %q \"$@\"\n", originURL, realGit),
|
||||
)
|
||||
}
|
||||
|
||||
func TestGitOperatorCreatePR_UnsupportedGitLabRemote(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell wrapper test is Unix-only")
|
||||
}
|
||||
|
||||
repoDir, cleanup := setupTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
log := newTestLogger(t)
|
||||
runGit(t, repoDir, "checkout", "-b", "feature/gitlab-pr")
|
||||
writeFile(t, repoDir, "gitlab.txt", "gitlab\n")
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "add gitlab change")
|
||||
|
||||
scriptDir := t.TempDir()
|
||||
writeGitRemoteWrapper(t, scriptDir, "git@gitlab.com:acme/widgets.git")
|
||||
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
gitOp := NewGitOperator(repoDir, log, nil)
|
||||
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatalf("CreatePR should fail for GitLab remote: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.Error, "unsupported git remote") {
|
||||
t.Fatalf("unexpected error: %q", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitOperatorCreatePR_AzureMissingCLI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell wrapper test is Unix-only")
|
||||
}
|
||||
|
||||
repoDir, cleanup := setupTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
log := newTestLogger(t)
|
||||
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-cli")
|
||||
writeFile(t, repoDir, "azure.txt", "azure\n")
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "add azure change")
|
||||
|
||||
scriptDir := t.TempDir()
|
||||
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
|
||||
// Isolate PATH so CI's preinstalled az is not visible to LookPath.
|
||||
t.Setenv("PATH", scriptDir)
|
||||
|
||||
gitOp := NewGitOperator(repoDir, log, nil)
|
||||
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatalf("CreatePR should fail without az: %+v", result)
|
||||
}
|
||||
if result.Error != errAzureCLIMissing {
|
||||
t.Fatalf("error = %q, want %q", result.Error, errAzureCLIMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitOperatorCreatePR_AzureMissingExtension(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell wrapper test is Unix-only")
|
||||
}
|
||||
|
||||
repoDir, cleanup := setupTestRepo(t)
|
||||
defer cleanup()
|
||||
|
||||
log := newTestLogger(t)
|
||||
runGit(t, repoDir, "checkout", "-b", "feature/azure-missing-ext")
|
||||
writeFile(t, repoDir, "azure.txt", "azure\n")
|
||||
runGit(t, repoDir, "add", ".")
|
||||
runGit(t, repoDir, "commit", "-m", "add azure change")
|
||||
|
||||
scriptDir := t.TempDir()
|
||||
writeGitRemoteWrapper(t, scriptDir, "git@ssh.dev.azure.com:v3/acme/platform/widgets")
|
||||
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 1; fi\nexit 0\n")
|
||||
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
gitOp := NewGitOperator(repoDir, log, nil)
|
||||
result, err := gitOp.CreatePR(context.Background(), "Title", "Body", "main", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePR returned error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatalf("CreatePR should fail without azure-devops extension: %+v", result)
|
||||
}
|
||||
if result.Error != errAzureDevOpsExtensionMissing {
|
||||
t.Fatalf("error = %q, want %q", result.Error, errAzureDevOpsExtensionMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAzureDevOpsCLI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell wrapper test is Unix-only")
|
||||
}
|
||||
|
||||
scriptDir := t.TempDir()
|
||||
writeExecutable(t, filepath.Join(scriptDir, "az"), "#!/bin/sh\nif [ \"$1\" = \"extension\" ] && [ \"$2\" = \"show\" ]; then exit 0; fi\nexit 1\n")
|
||||
t.Setenv("PATH", scriptDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
if err := ensureAzureDevOpsCLI(context.Background()); err != nil {
|
||||
t.Fatalf("ensureAzureDevOpsCLI() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", "")
|
||||
if err := ensureAzureDevOpsCLI(context.Background()); err == nil {
|
||||
t.Fatal("expected error when az is missing")
|
||||
} else if err.Error() != errAzureCLIMissing {
|
||||
t.Fatalf("error = %q, want %q", err.Error(), errAzureCLIMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectPRProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { PanelBody } from "./panel-primitives";
|
||||
import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs";
|
||||
import {
|
||||
FileListSection,
|
||||
CommitsSection,
|
||||
ReviewProgressBar,
|
||||
PRFilesSection,
|
||||
} from "./changes-panel-timeline";
|
||||
import { mergeCommits } from "./changes-panel-helpers";
|
||||
import type { ChangesPanelBodyProps } from "./changes-panel-data";
|
||||
|
||||
function ChangesPanelDialogsSection({
|
||||
dialogs,
|
||||
isLoading,
|
||||
}: Pick<ChangesPanelBodyProps, "dialogs" | "isLoading">) {
|
||||
return (
|
||||
<>
|
||||
<DiscardDialog
|
||||
open={dialogs.showDiscardDialog}
|
||||
onOpenChange={dialogs.setShowDiscardDialog}
|
||||
fileToDiscard={dialogs.fileToDiscard}
|
||||
filesToDiscard={dialogs.filesToDiscard}
|
||||
onConfirm={dialogs.handleDiscardConfirm}
|
||||
/>
|
||||
<AmendDialog
|
||||
open={dialogs.amendDialogOpen}
|
||||
onOpenChange={dialogs.setAmendDialogOpen}
|
||||
amendMessage={dialogs.amendMessage}
|
||||
onAmendMessageChange={dialogs.setAmendMessage}
|
||||
onAmend={dialogs.handleAmend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ResetDialog
|
||||
open={dialogs.resetDialogOpen}
|
||||
onOpenChange={dialogs.setResetDialogOpen}
|
||||
commitSha={dialogs.resetCommitSha}
|
||||
onReset={dialogs.handleReset}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineProps = Pick<
|
||||
ChangesPanelBodyProps,
|
||||
| "hasAnything"
|
||||
| "hasUnstaged"
|
||||
| "hasStaged"
|
||||
| "hasCommits"
|
||||
| "hasPRFiles"
|
||||
| "canPush"
|
||||
| "canCreatePR"
|
||||
| "existingPrUrl"
|
||||
| "unstagedFiles"
|
||||
| "stagedFiles"
|
||||
| "prFiles"
|
||||
| "prCommits"
|
||||
| "commits"
|
||||
| "pendingStageFiles"
|
||||
| "aheadCount"
|
||||
| "isLoading"
|
||||
| "loadingOperation"
|
||||
| "dialogs"
|
||||
| "onOpenDiffFile"
|
||||
| "onEditFile"
|
||||
| "onOpenCommitDetail"
|
||||
| "onRevertCommit"
|
||||
| "onStageAll"
|
||||
| "onUnstageAll"
|
||||
| "onStage"
|
||||
| "onUnstage"
|
||||
| "onBulkStage"
|
||||
| "onBulkUnstage"
|
||||
| "onBulkDiscard"
|
||||
| "onPush"
|
||||
| "onForcePush"
|
||||
| "onRepoStageAll"
|
||||
| "onRepoUnstageAll"
|
||||
| "onRepoCommit"
|
||||
| "onRepoPush"
|
||||
| "onRepoCreatePR"
|
||||
| "repoDisplayName"
|
||||
| "perRepoStatus"
|
||||
| "prByRepo"
|
||||
>;
|
||||
|
||||
type WorkingTreeProps = Pick<
|
||||
TimelineProps,
|
||||
| "hasUnstaged"
|
||||
| "hasStaged"
|
||||
| "unstagedFiles"
|
||||
| "stagedFiles"
|
||||
| "pendingStageFiles"
|
||||
| "loadingOperation"
|
||||
| "dialogs"
|
||||
| "onOpenDiffFile"
|
||||
| "onEditFile"
|
||||
| "onStageAll"
|
||||
| "onUnstageAll"
|
||||
| "onStage"
|
||||
| "onUnstage"
|
||||
| "onBulkStage"
|
||||
| "onBulkUnstage"
|
||||
| "onBulkDiscard"
|
||||
| "onRepoStageAll"
|
||||
| "onRepoUnstageAll"
|
||||
| "onRepoCommit"
|
||||
| "repoDisplayName"
|
||||
> & { isLastUnstaged: boolean; isLastStaged: boolean };
|
||||
|
||||
function WorkingTreeSections(props: WorkingTreeProps) {
|
||||
const isBulkOp = props.pendingStageFiles.size === 0;
|
||||
return (
|
||||
<>
|
||||
{props.hasUnstaged && (
|
||||
<FileListSection
|
||||
variant="unstaged"
|
||||
files={props.unstagedFiles}
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={props.isLastUnstaged}
|
||||
actionLabel="Stage all"
|
||||
isActionLoading={isBulkOp && props.loadingOperation === "stage"}
|
||||
onAction={props.onStageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
onStage={props.onStage}
|
||||
onUnstage={props.onUnstage}
|
||||
onDiscard={props.dialogs.handleDiscardClick}
|
||||
onBulkStage={props.onBulkStage}
|
||||
onBulkDiscard={props.onBulkDiscard}
|
||||
onRepoAction={props.onRepoStageAll}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
)}
|
||||
{props.hasStaged && (
|
||||
<FileListSection
|
||||
variant="staged"
|
||||
files={props.stagedFiles}
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={props.isLastStaged}
|
||||
actionLabel="Commit"
|
||||
isActionLoading={props.loadingOperation === "commit"}
|
||||
onAction={() => props.dialogs.openCommitDialog()}
|
||||
secondaryActionLabel="Unstage all"
|
||||
isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"}
|
||||
onSecondaryAction={props.onUnstageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
onStage={props.onStage}
|
||||
onUnstage={props.onUnstage}
|
||||
onDiscard={props.dialogs.handleDiscardClick}
|
||||
onBulkUnstage={props.onBulkUnstage}
|
||||
onBulkDiscard={props.onBulkDiscard}
|
||||
onRepoAction={props.onRepoCommit}
|
||||
onRepoSecondaryAction={props.onRepoUnstageAll}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesPanelTimeline(props: TimelineProps) {
|
||||
if (!props.hasAnything) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
Your changed files will appear here
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedCommits = mergeCommits(props.commits, props.prCommits);
|
||||
const hasMergedCommits = mergedCommits.length > 0;
|
||||
const hasLocalChanges = props.hasUnstaged || props.hasStaged;
|
||||
const showCommits = props.hasStaged || props.hasCommits;
|
||||
const showCommitsList = props.hasStaged || hasMergedCommits;
|
||||
const hasSomethingAfterStaged = (props.hasPRFiles && hasLocalChanges) || showCommitsList;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{props.hasPRFiles && !hasLocalChanges && (
|
||||
<div data-testid="pr-files-section">
|
||||
<PRFilesSection
|
||||
files={props.prFiles}
|
||||
isLast={!showCommitsList}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkingTreeSections
|
||||
{...props}
|
||||
isLastUnstaged={!props.hasStaged && !hasSomethingAfterStaged}
|
||||
isLastStaged={!hasSomethingAfterStaged}
|
||||
/>
|
||||
|
||||
{props.hasPRFiles && hasLocalChanges && (
|
||||
<div data-testid="pr-files-section">
|
||||
<PRFilesSection
|
||||
files={props.prFiles}
|
||||
isLast={!showCommitsList}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommitsList && (
|
||||
<CommitsSection
|
||||
commits={mergedCommits}
|
||||
isLast={!showCommits}
|
||||
onOpenCommitDetail={props.onOpenCommitDetail}
|
||||
onRevertCommit={props.onRevertCommit}
|
||||
onAmendCommit={props.dialogs.handleOpenAmendDialog}
|
||||
onResetToCommit={props.dialogs.handleOpenResetDialog}
|
||||
onRepoPush={props.onRepoPush}
|
||||
onRepoCreatePR={props.onRepoCreatePR}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
perRepoStatus={props.perRepoStatus}
|
||||
prByRepo={props.prByRepo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangesPanelBody(props: ChangesPanelBodyProps) {
|
||||
return (
|
||||
<PanelBody className="flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<ChangesPanelTimeline {...props} />
|
||||
</div>
|
||||
<ReviewProgressBar
|
||||
reviewedCount={props.reviewedCount}
|
||||
totalFileCount={props.totalFileCount}
|
||||
onOpenReview={props.onOpenReview}
|
||||
/>
|
||||
<ChangesPanelDialogsSection dialogs={props.dialogs} isLoading={props.isLoading} />
|
||||
</PanelBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
|
||||
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
|
||||
import type { PRChangedFile } from "./changes-panel-timeline";
|
||||
import type { PRDiffFile } from "@/lib/types/github";
|
||||
import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks";
|
||||
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
|
||||
import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo";
|
||||
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files";
|
||||
import { usePRCommits } from "@/hooks/domains/github/use-pr-commits";
|
||||
import {
|
||||
type ChangedFile,
|
||||
computeReviewProgress,
|
||||
computeStagedStats,
|
||||
getBaseBranchDisplay,
|
||||
mapPRFilesToChangedFiles,
|
||||
mapToChangedFiles,
|
||||
buildPrByRepoMap,
|
||||
buildRepoNameById,
|
||||
} from "./changes-panel-helpers";
|
||||
import type { OpenDiffOptions } from "./changes-diff-target";
|
||||
|
||||
function useChangesPanelStoreData() {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSessionId = useEnvironmentSessionId();
|
||||
const taskTitle = useAppStore((state) => {
|
||||
if (!state.tasks.activeTaskId) return undefined;
|
||||
return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title;
|
||||
});
|
||||
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;
|
||||
const fromTaskPR = state.taskPRs.byTaskId[taskId]?.[0]?.pr_url;
|
||||
if (fromTaskPR) return fromTaskPR;
|
||||
return state.pendingPrUrlByTaskId.byTaskId[taskId]?.[""];
|
||||
});
|
||||
return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl };
|
||||
}
|
||||
|
||||
type DialogsType = ReturnType<typeof useChangesDialogHandlers> & ReturnType<typeof useVcsDialogs>;
|
||||
|
||||
export type ChangesPanelBodyProps = {
|
||||
hasAnything: boolean;
|
||||
hasUnstaged: boolean;
|
||||
hasStaged: boolean;
|
||||
hasCommits: boolean;
|
||||
hasPRFiles: boolean;
|
||||
hasPRCommits: boolean;
|
||||
canPush: boolean;
|
||||
canCreatePR: boolean;
|
||||
existingPrUrl: string | undefined;
|
||||
unstagedFiles: ChangedFile[];
|
||||
stagedFiles: ChangedFile[];
|
||||
prFiles: PRChangedFile[];
|
||||
prCommits: {
|
||||
sha: string;
|
||||
message: string;
|
||||
author_login: string;
|
||||
author_date: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}[];
|
||||
commits: {
|
||||
commit_sha: string;
|
||||
commit_message: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
pushed?: boolean;
|
||||
}[];
|
||||
pendingStageFiles: Set<string>;
|
||||
reviewedCount: number;
|
||||
totalFileCount: number;
|
||||
aheadCount: number;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
dialogs: DialogsType;
|
||||
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onOpenCommitDetail?: (sha: string, repo?: string) => void;
|
||||
onOpenReview?: () => void;
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
onStage: (path: string, repo?: string) => Promise<void>;
|
||||
onUnstage: (path: string, repo?: string) => Promise<void>;
|
||||
onBulkStage: (paths: string[]) => void;
|
||||
onBulkUnstage: (paths: string[]) => void;
|
||||
onBulkDiscard: (paths: string[]) => void;
|
||||
onPush: () => void;
|
||||
onForcePush: () => void;
|
||||
stagedFileCount: number;
|
||||
stagedAdditions: number;
|
||||
stagedDeletions: number;
|
||||
onRepoStageAll?: (repo: string) => void;
|
||||
onRepoUnstageAll?: (repo: string) => void;
|
||||
onRepoCommit?: (repo: string) => void;
|
||||
onRepoPush?: (repo: string) => void;
|
||||
onRepoCreatePR?: (repo: string) => void;
|
||||
repoDisplayName?: (repositoryName: string) => string | undefined;
|
||||
perRepoStatus?: Array<{ repository_name: string; ahead: number }>;
|
||||
prByRepo?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function usePerRepoCallbacks(
|
||||
git: ReturnType<typeof useSessionGit>,
|
||||
vcsDialogs: ReturnType<typeof useVcsDialogs>,
|
||||
gitHandlers: ReturnType<typeof useChangesGitHandlers>,
|
||||
) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRepoStageAll: (repo: string) => {
|
||||
gitHandlers.handleGitOperation(
|
||||
() => git.stage(undefined, repo),
|
||||
repo ? `Stage all (${repo})` : "Stage all",
|
||||
);
|
||||
},
|
||||
onRepoUnstageAll: (repo: string) => {
|
||||
gitHandlers.handleGitOperation(
|
||||
() => git.unstage(undefined, repo),
|
||||
repo ? `Unstage all (${repo})` : "Unstage all",
|
||||
);
|
||||
},
|
||||
onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo),
|
||||
onRepoPush: (repo: string) => gitHandlers.handlePush(repo),
|
||||
onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo),
|
||||
onRepoPull: (repo: string) => gitHandlers.handlePull(repo),
|
||||
onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo),
|
||||
onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo),
|
||||
}),
|
||||
[git, vcsDialogs, gitHandlers],
|
||||
);
|
||||
}
|
||||
|
||||
function useChangesPanelPRData() {
|
||||
const { prs, filesByPRKey } = useActiveTaskPRsWithFiles();
|
||||
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
|
||||
const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]);
|
||||
const taskHasMultipleRepos = useAppStore((s) => {
|
||||
const taskId = s.tasks.activeTaskId;
|
||||
if (!taskId) return false;
|
||||
const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId);
|
||||
return (task?.repositories?.length ?? 0) > 1;
|
||||
});
|
||||
const taskPR = useActiveTaskPR();
|
||||
const refreshKey = taskPR?.last_synced_at ?? null;
|
||||
const { commits: prCommitsList } = usePRCommits(
|
||||
taskPR?.owner ?? null,
|
||||
taskPR?.repo ?? null,
|
||||
taskPR?.pr_number ?? null,
|
||||
refreshKey,
|
||||
);
|
||||
const { prFiles, prDiffFiles } = useMemo(() => {
|
||||
const merged: PRChangedFile[] = [];
|
||||
const flat: PRDiffFile[] = [];
|
||||
for (const pr of prs) {
|
||||
const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`;
|
||||
const files = filesByPRKey[key] ?? [];
|
||||
const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : "";
|
||||
const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : "";
|
||||
merged.push(...mapPRFilesToChangedFiles(files, stamp));
|
||||
flat.push(...files);
|
||||
}
|
||||
return { prFiles: merged, prDiffFiles: flat };
|
||||
}, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]);
|
||||
const hasPRFiles = prFiles.length > 0;
|
||||
const hasPRCommits = prCommitsList.length > 0;
|
||||
return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles };
|
||||
}
|
||||
|
||||
export function useChangesPanelData() {
|
||||
const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData();
|
||||
const baseBranchByRepo = useBaseBranchByRepo(activeTaskId);
|
||||
const git = useSessionGit(activeSessionId);
|
||||
const { toast } = useToast();
|
||||
const { reviews } = useSessionFileReviews(activeSessionId);
|
||||
const prData = useChangesPanelPRData();
|
||||
const vcsDialogs = useVcsDialogs();
|
||||
const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]);
|
||||
const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]);
|
||||
const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]);
|
||||
const { reviewedCount, totalFileCount } = useMemo(
|
||||
() => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles),
|
||||
[git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles],
|
||||
);
|
||||
const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]);
|
||||
const gitHandlers = useChangesGitHandlers(git, toast, baseBranch);
|
||||
const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation);
|
||||
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 prByRepo = useMemo(
|
||||
() => buildPrByRepoMap(taskPRsForMap, repoNameById, pendingByRepo),
|
||||
[taskPRsForMap, repoNameById, pendingByRepo],
|
||||
);
|
||||
return {
|
||||
git,
|
||||
baseBranchDisplay,
|
||||
baseBranchByRepo,
|
||||
unstagedFiles,
|
||||
stagedFiles,
|
||||
reviewedCount,
|
||||
totalFileCount,
|
||||
staged,
|
||||
gitHandlers,
|
||||
localDialogs,
|
||||
dialogs,
|
||||
repoCallbacks,
|
||||
repoDisplayName,
|
||||
prByRepo,
|
||||
existingPrUrl,
|
||||
...prData,
|
||||
};
|
||||
}
|
||||
|
||||
type ChangesPanelCallbacks = {
|
||||
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onOpenCommitDetail?: (sha: string, repo?: string) => void;
|
||||
onOpenReview?: () => void;
|
||||
};
|
||||
|
||||
export function buildChangesPanelBodyProps(
|
||||
data: ReturnType<typeof useChangesPanelData>,
|
||||
callbacks: ChangesPanelCallbacks,
|
||||
): ChangesPanelBodyProps {
|
||||
const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data;
|
||||
return {
|
||||
hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits,
|
||||
hasUnstaged: git.hasUnstaged,
|
||||
hasStaged: git.hasStaged,
|
||||
hasCommits: git.hasCommits,
|
||||
hasPRFiles: data.hasPRFiles,
|
||||
hasPRCommits: data.hasPRCommits,
|
||||
canPush: git.canPush,
|
||||
canCreatePR: git.canCreatePR,
|
||||
existingPrUrl: data.existingPrUrl,
|
||||
unstagedFiles: data.unstagedFiles,
|
||||
stagedFiles: data.stagedFiles,
|
||||
prFiles: data.prFiles,
|
||||
prCommits: data.prCommitsList,
|
||||
commits: git.commits,
|
||||
pendingStageFiles: git.pendingStageFiles,
|
||||
reviewedCount: data.reviewedCount,
|
||||
totalFileCount: data.totalFileCount,
|
||||
aheadCount: git.ahead,
|
||||
isLoading: git.isLoading,
|
||||
loadingOperation: git.loadingOperation,
|
||||
dialogs: data.dialogs,
|
||||
onOpenDiffFile: callbacks.onOpenDiffFile,
|
||||
onEditFile: callbacks.onEditFile,
|
||||
onOpenCommitDetail: callbacks.onOpenCommitDetail,
|
||||
onRevertCommit: gitHandlers.handleRevertCommit,
|
||||
onOpenReview: callbacks.onOpenReview,
|
||||
onStageAll: git.stageAll,
|
||||
onUnstageAll: git.unstageAll,
|
||||
onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined),
|
||||
onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined),
|
||||
onBulkStage: (paths) => {
|
||||
git.stageFile(paths).catch(() => undefined);
|
||||
},
|
||||
onBulkUnstage: (paths) => {
|
||||
git.unstageFile(paths).catch(() => undefined);
|
||||
},
|
||||
onBulkDiscard: localDialogs.handleBulkDiscardClick,
|
||||
onPush: () => gitHandlers.handlePush(),
|
||||
onForcePush: () => gitHandlers.handleForcePush(),
|
||||
stagedFileCount: staged.stagedFileCount,
|
||||
stagedAdditions: staged.stagedAdditions,
|
||||
stagedDeletions: staged.stagedDeletions,
|
||||
onRepoStageAll: repoCallbacks.onRepoStageAll,
|
||||
onRepoUnstageAll: repoCallbacks.onRepoUnstageAll,
|
||||
onRepoCommit: repoCallbacks.onRepoCommit,
|
||||
onRepoPush: repoCallbacks.onRepoPush,
|
||||
onRepoCreatePR: repoCallbacks.onRepoCreatePR,
|
||||
repoDisplayName: data.repoDisplayName,
|
||||
perRepoStatus: git.perRepoStatus,
|
||||
prByRepo: data.prByRepo,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapPRFilesToChangedFiles } from "./changes-panel-helpers";
|
||||
import { buildPrByRepoMap, mapPRFilesToChangedFiles } from "./changes-panel-helpers";
|
||||
import type { PRDiffFile } from "@/lib/types/github";
|
||||
|
||||
function diffFile(overrides: Partial<PRDiffFile>): PRDiffFile {
|
||||
@@ -68,3 +68,28 @@ describe("mapPRFilesToChangedFiles", () => {
|
||||
expect(mapPRFilesToChangedFiles([], "frontend")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPrByRepoMap", () => {
|
||||
it("does not copy a multi-repo TaskPR URL into the empty-key fallback", () => {
|
||||
const map = buildPrByRepoMap(
|
||||
[
|
||||
{ pr_url: "https://github.com/o/r/pull/1", repository_id: "id-b" },
|
||||
{ pr_url: "https://github.com/o/r/pull/2", repository_id: "id-a" },
|
||||
],
|
||||
{ "id-a": "repo-a", "id-b": "repo-b" },
|
||||
undefined,
|
||||
);
|
||||
expect(map[""]).toBeUndefined();
|
||||
expect(map["repo-a"]).toBe("https://github.com/o/r/pull/2");
|
||||
expect(map["repo-b"]).toBe("https://github.com/o/r/pull/1");
|
||||
});
|
||||
|
||||
it("uses empty-key fallback only for legacy TaskPR rows without repository_id", () => {
|
||||
const map = buildPrByRepoMap(
|
||||
[{ pr_url: "https://dev.azure.com/o/p/_git/r/pullrequest/9" }],
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(map[""]).toBe("https://dev.azure.com/o/p/_git/r/pullrequest/9");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,3 +212,47 @@ export function getBaseBranchDisplay(baseBranch: string | undefined): string {
|
||||
export function isMultiRepoCommits(commits: { repository_name?: string }[]): boolean {
|
||||
return commits.some((c) => !!c.repository_name);
|
||||
}
|
||||
|
||||
type TaskPRUrlInput = {
|
||||
pr_url?: string;
|
||||
repository_id?: string;
|
||||
};
|
||||
|
||||
/** Workspace repository id → display name for multi-repo PR/commit grouping. */
|
||||
export function buildRepoNameById(
|
||||
reposByWorkspace: Record<string, Array<{ id: string; name: string }>>,
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const list of Object.values(reposByWorkspace)) {
|
||||
for (const r of list) {
|
||||
out[r.id] = r.name;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Merge synced TaskPR URLs and client-only pending PR URLs by repo display name. */
|
||||
export function buildPrByRepoMap(
|
||||
taskPRs: TaskPRUrlInput[] | undefined,
|
||||
repoNameById: Record<string, string>,
|
||||
pendingByRepo: Record<string, string> | undefined,
|
||||
): Record<string, string | undefined> {
|
||||
const map: Record<string, string | undefined> = {};
|
||||
if (taskPRs) {
|
||||
for (const pr of taskPRs) {
|
||||
if (!pr.pr_url) continue;
|
||||
const repoKey = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : "";
|
||||
map[repoKey] = map[repoKey] ?? pr.pr_url;
|
||||
}
|
||||
}
|
||||
if (pendingByRepo) {
|
||||
for (const [repoKey, url] of Object.entries(pendingByRepo)) {
|
||||
if (url) map[repoKey] = map[repoKey] ?? url;
|
||||
}
|
||||
}
|
||||
const legacySingleRepo = taskPRs?.find((p) => p.pr_url && !p.repository_id);
|
||||
if (!map[""] && legacySingleRepo?.pr_url) {
|
||||
map[""] = legacySingleRepo.pr_url;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo } from "react";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import { useSessionFileReviews } from "@/hooks/use-session-file-reviews";
|
||||
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { memo } from "react";
|
||||
import { PanelRoot } from "./panel-primitives";
|
||||
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
|
||||
import { DiscardDialog, AmendDialog, ResetDialog } from "./changes-panel-dialogs";
|
||||
import { useVcsDialogs } from "@/components/vcs/vcs-dialogs";
|
||||
import { ChangesPanelHeader } from "./changes-panel-header";
|
||||
import {
|
||||
FileListSection,
|
||||
CommitsSection,
|
||||
ReviewProgressBar,
|
||||
PRFilesSection,
|
||||
} from "./changes-panel-timeline";
|
||||
import type { PRChangedFile } from "./changes-panel-timeline";
|
||||
import type { PRDiffFile } from "@/lib/types/github";
|
||||
import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks";
|
||||
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
|
||||
import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo";
|
||||
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files";
|
||||
import { usePRCommits } from "@/hooks/domains/github/use-pr-commits";
|
||||
import {
|
||||
type ChangedFile,
|
||||
computeReviewProgress,
|
||||
computeStagedStats,
|
||||
filterUnpushedCommits,
|
||||
getBaseBranchDisplay,
|
||||
mapPRFilesToChangedFiles,
|
||||
mapToChangedFiles,
|
||||
mergeCommits,
|
||||
} from "./changes-panel-helpers";
|
||||
import { filterUnpushedCommits, mergeCommits } from "./changes-panel-helpers";
|
||||
import { useChangesPanelData, buildChangesPanelBodyProps } from "./changes-panel-data";
|
||||
import { ChangesPanelBody } from "./changes-panel-body";
|
||||
import type { OpenDiffOptions } from "./changes-diff-target";
|
||||
|
||||
export { filterUnpushedCommits, mergeCommits };
|
||||
@@ -47,546 +19,6 @@ type ChangesPanelProps = {
|
||||
onOpenReview?: () => void;
|
||||
};
|
||||
|
||||
function useChangesPanelStoreData() {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
// Use environment-stable sessionId so git hooks (commits, cumulative diff)
|
||||
// don't re-fetch when switching between sessions in the same environment.
|
||||
const activeSessionId = useEnvironmentSessionId();
|
||||
const taskTitle = useAppStore((state) => {
|
||||
if (!state.tasks.activeTaskId) return undefined;
|
||||
return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title;
|
||||
});
|
||||
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;
|
||||
// Single-PR consumer: surface the primary PR's URL. Multi-repo cases get
|
||||
// per-repo PR URLs via the timeline's prByRepo map elsewhere.
|
||||
return state.taskPRs.byTaskId[taskId]?.[0]?.pr_url ?? undefined;
|
||||
});
|
||||
return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl };
|
||||
}
|
||||
|
||||
type DialogsType = ReturnType<typeof useChangesDialogHandlers> & ReturnType<typeof useVcsDialogs>;
|
||||
|
||||
type ChangesPanelBodyProps = {
|
||||
hasAnything: boolean;
|
||||
hasUnstaged: boolean;
|
||||
hasStaged: boolean;
|
||||
hasCommits: boolean;
|
||||
hasPRFiles: boolean;
|
||||
hasPRCommits: boolean;
|
||||
canPush: boolean;
|
||||
canCreatePR: boolean;
|
||||
existingPrUrl: string | undefined;
|
||||
unstagedFiles: ChangedFile[];
|
||||
stagedFiles: ChangedFile[];
|
||||
prFiles: PRChangedFile[];
|
||||
prCommits: {
|
||||
sha: string;
|
||||
message: string;
|
||||
author_login: string;
|
||||
author_date: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}[];
|
||||
commits: {
|
||||
commit_sha: string;
|
||||
commit_message: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
pushed?: boolean;
|
||||
}[];
|
||||
pendingStageFiles: Set<string>;
|
||||
reviewedCount: number;
|
||||
totalFileCount: number;
|
||||
aheadCount: number;
|
||||
isLoading: boolean;
|
||||
loadingOperation: string | null;
|
||||
dialogs: DialogsType;
|
||||
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
|
||||
onEditFile: (path: string) => void;
|
||||
onOpenCommitDetail?: (sha: string, repo?: string) => void;
|
||||
onOpenReview?: () => void;
|
||||
// Multi-repo: handlers carry the commit's repository_name so revert/amend/
|
||||
// reset target the right git repo. Without it the op runs at the workspace
|
||||
// root and fails ("can only revert latest commit") on multi-repo tasks.
|
||||
onRevertCommit?: (sha: string, repo?: string) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
// Multi-repo: callers pass the file's repository_name so the agentctl op
|
||||
// routes to the right repo. Same-named files across repos can't be
|
||||
// disambiguated by path alone.
|
||||
onStage: (path: string, repo?: string) => Promise<void>;
|
||||
onUnstage: (path: string, repo?: string) => Promise<void>;
|
||||
onBulkStage: (paths: string[]) => void;
|
||||
onBulkUnstage: (paths: string[]) => void;
|
||||
onBulkDiscard: (paths: string[]) => void;
|
||||
onPush: () => void;
|
||||
onForcePush: () => void;
|
||||
stagedFileCount: number;
|
||||
stagedAdditions: number;
|
||||
stagedDeletions: number;
|
||||
// Per-repo handlers — wired only in multi-repo workspaces. Each one targets
|
||||
// a single repo subpath in agentctl. Single-repo sessions don't render the
|
||||
// per-repo group header, so these are unused in that case.
|
||||
onRepoStageAll?: (repo: string) => void;
|
||||
onRepoUnstageAll?: (repo: string) => void;
|
||||
onRepoCommit?: (repo: string) => void;
|
||||
onRepoPush?: (repo: string) => void;
|
||||
onRepoCreatePR?: (repo: string) => void;
|
||||
/** Maps a repository_name to its display label (used as the per-repo group label). */
|
||||
repoDisplayName?: (repositoryName: string) => string | undefined;
|
||||
/** Per-repo branch / ahead / behind summary; powers the "ahead" badge on Push. */
|
||||
perRepoStatus?: Array<{ repository_name: string; ahead: number }>;
|
||||
/** Existing PR URL keyed by repository_name; "" key for single-repo. */
|
||||
prByRepo?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function ChangesPanelDialogsSection({
|
||||
dialogs,
|
||||
isLoading,
|
||||
}: Pick<ChangesPanelBodyProps, "dialogs" | "isLoading">) {
|
||||
return (
|
||||
<>
|
||||
<DiscardDialog
|
||||
open={dialogs.showDiscardDialog}
|
||||
onOpenChange={dialogs.setShowDiscardDialog}
|
||||
fileToDiscard={dialogs.fileToDiscard}
|
||||
filesToDiscard={dialogs.filesToDiscard}
|
||||
onConfirm={dialogs.handleDiscardConfirm}
|
||||
/>
|
||||
<AmendDialog
|
||||
open={dialogs.amendDialogOpen}
|
||||
onOpenChange={dialogs.setAmendDialogOpen}
|
||||
amendMessage={dialogs.amendMessage}
|
||||
onAmendMessageChange={dialogs.setAmendMessage}
|
||||
onAmend={dialogs.handleAmend}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ResetDialog
|
||||
open={dialogs.resetDialogOpen}
|
||||
onOpenChange={dialogs.setResetDialogOpen}
|
||||
commitSha={dialogs.resetCommitSha}
|
||||
onReset={dialogs.handleReset}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineProps = Pick<
|
||||
ChangesPanelBodyProps,
|
||||
| "hasAnything"
|
||||
| "hasUnstaged"
|
||||
| "hasStaged"
|
||||
| "hasCommits"
|
||||
| "hasPRFiles"
|
||||
| "canPush"
|
||||
| "canCreatePR"
|
||||
| "existingPrUrl"
|
||||
| "unstagedFiles"
|
||||
| "stagedFiles"
|
||||
| "prFiles"
|
||||
| "prCommits"
|
||||
| "commits"
|
||||
| "pendingStageFiles"
|
||||
| "aheadCount"
|
||||
| "isLoading"
|
||||
| "loadingOperation"
|
||||
| "dialogs"
|
||||
| "onOpenDiffFile"
|
||||
| "onEditFile"
|
||||
| "onOpenCommitDetail"
|
||||
| "onRevertCommit"
|
||||
| "onStageAll"
|
||||
| "onUnstageAll"
|
||||
| "onStage"
|
||||
| "onUnstage"
|
||||
| "onBulkStage"
|
||||
| "onBulkUnstage"
|
||||
| "onBulkDiscard"
|
||||
| "onPush"
|
||||
| "onForcePush"
|
||||
| "onRepoStageAll"
|
||||
| "onRepoUnstageAll"
|
||||
| "onRepoCommit"
|
||||
| "onRepoPush"
|
||||
| "onRepoCreatePR"
|
||||
| "repoDisplayName"
|
||||
| "perRepoStatus"
|
||||
| "prByRepo"
|
||||
>;
|
||||
|
||||
type WorkingTreeProps = Pick<
|
||||
TimelineProps,
|
||||
| "hasUnstaged"
|
||||
| "hasStaged"
|
||||
| "unstagedFiles"
|
||||
| "stagedFiles"
|
||||
| "pendingStageFiles"
|
||||
| "loadingOperation"
|
||||
| "dialogs"
|
||||
| "onOpenDiffFile"
|
||||
| "onEditFile"
|
||||
| "onStageAll"
|
||||
| "onUnstageAll"
|
||||
| "onStage"
|
||||
| "onUnstage"
|
||||
| "onBulkStage"
|
||||
| "onBulkUnstage"
|
||||
| "onBulkDiscard"
|
||||
| "onRepoStageAll"
|
||||
| "onRepoUnstageAll"
|
||||
| "onRepoCommit"
|
||||
| "repoDisplayName"
|
||||
> & { isLastUnstaged: boolean; isLastStaged: boolean };
|
||||
|
||||
function WorkingTreeSections(props: WorkingTreeProps) {
|
||||
const isBulkOp = props.pendingStageFiles.size === 0;
|
||||
return (
|
||||
<>
|
||||
{props.hasUnstaged && (
|
||||
<FileListSection
|
||||
variant="unstaged"
|
||||
files={props.unstagedFiles}
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={props.isLastUnstaged}
|
||||
actionLabel="Stage all"
|
||||
isActionLoading={isBulkOp && props.loadingOperation === "stage"}
|
||||
onAction={props.onStageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
onStage={props.onStage}
|
||||
onUnstage={props.onUnstage}
|
||||
onDiscard={props.dialogs.handleDiscardClick}
|
||||
onBulkStage={props.onBulkStage}
|
||||
onBulkDiscard={props.onBulkDiscard}
|
||||
onRepoAction={props.onRepoStageAll}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
)}
|
||||
{props.hasStaged && (
|
||||
<FileListSection
|
||||
variant="staged"
|
||||
files={props.stagedFiles}
|
||||
pendingStageFiles={props.pendingStageFiles}
|
||||
isLast={props.isLastStaged}
|
||||
actionLabel="Commit"
|
||||
isActionLoading={props.loadingOperation === "commit"}
|
||||
onAction={() => props.dialogs.openCommitDialog()}
|
||||
secondaryActionLabel="Unstage all"
|
||||
isSecondaryActionLoading={isBulkOp && props.loadingOperation === "unstage"}
|
||||
onSecondaryAction={props.onUnstageAll}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
onEditFile={props.onEditFile}
|
||||
onStage={props.onStage}
|
||||
onUnstage={props.onUnstage}
|
||||
onDiscard={props.dialogs.handleDiscardClick}
|
||||
onBulkUnstage={props.onBulkUnstage}
|
||||
onBulkDiscard={props.onBulkDiscard}
|
||||
onRepoAction={props.onRepoCommit}
|
||||
onRepoSecondaryAction={props.onRepoUnstageAll}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesPanelTimeline(props: TimelineProps) {
|
||||
if (!props.hasAnything) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
Your changed files will appear here
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mergedCommits = mergeCommits(props.commits, props.prCommits);
|
||||
const hasMergedCommits = mergedCommits.length > 0;
|
||||
const hasLocalChanges = props.hasUnstaged || props.hasStaged;
|
||||
const showCommits = props.hasStaged || props.hasCommits;
|
||||
const showCommitsList = props.hasStaged || hasMergedCommits;
|
||||
const hasSomethingAfterStaged = (props.hasPRFiles && hasLocalChanges) || showCommitsList;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* PR files first when no local working-tree changes */}
|
||||
{props.hasPRFiles && !hasLocalChanges && (
|
||||
<div data-testid="pr-files-section">
|
||||
<PRFilesSection
|
||||
files={props.prFiles}
|
||||
isLast={!showCommitsList}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkingTreeSections
|
||||
{...props}
|
||||
isLastUnstaged={!props.hasStaged && !hasSomethingAfterStaged}
|
||||
isLastStaged={!hasSomethingAfterStaged}
|
||||
/>
|
||||
|
||||
{/* PR files after local changes when both exist */}
|
||||
{props.hasPRFiles && hasLocalChanges && (
|
||||
<div data-testid="pr-files-section">
|
||||
<PRFilesSection
|
||||
files={props.prFiles}
|
||||
isLast={!showCommitsList}
|
||||
onOpenDiff={props.onOpenDiffFile}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCommitsList && (
|
||||
<CommitsSection
|
||||
commits={mergedCommits}
|
||||
isLast={!showCommits}
|
||||
onOpenCommitDetail={props.onOpenCommitDetail}
|
||||
onRevertCommit={props.onRevertCommit}
|
||||
onAmendCommit={props.dialogs.handleOpenAmendDialog}
|
||||
onResetToCommit={props.dialogs.handleOpenResetDialog}
|
||||
onRepoPush={props.onRepoPush}
|
||||
onRepoCreatePR={props.onRepoCreatePR}
|
||||
repoDisplayName={props.repoDisplayName}
|
||||
perRepoStatus={props.perRepoStatus}
|
||||
prByRepo={props.prByRepo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangesPanelBody(props: ChangesPanelBodyProps) {
|
||||
return (
|
||||
<PanelBody className="flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
|
||||
<ChangesPanelTimeline {...props} />
|
||||
</div>
|
||||
<ReviewProgressBar
|
||||
reviewedCount={props.reviewedCount}
|
||||
totalFileCount={props.totalFileCount}
|
||||
onOpenReview={props.onOpenReview}
|
||||
/>
|
||||
<ChangesPanelDialogsSection dialogs={props.dialogs} isLoading={props.isLoading} />
|
||||
</PanelBody>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts the existing single-repo handlers to the per-repo invocation sites
|
||||
* (group-header buttons). Each callback simply forwards `repo` to the
|
||||
* underlying op — the routing happens inside `useSessionGit` /
|
||||
* `useGitOperations`. Single-repo passes the empty string, which the backend
|
||||
* treats as the workspace root.
|
||||
*/
|
||||
function usePerRepoCallbacks(
|
||||
git: ReturnType<typeof useSessionGit>,
|
||||
vcsDialogs: ReturnType<typeof useVcsDialogs>,
|
||||
gitHandlers: ReturnType<typeof useChangesGitHandlers>,
|
||||
) {
|
||||
// Bug 9: route per-repo stage/unstage through `handleGitOperation` so toasts
|
||||
// fire for both success and failure. The previous `.catch(() => undefined)`
|
||||
// silenced rejections — the user got no feedback when an op failed.
|
||||
return useMemo(
|
||||
() => ({
|
||||
onRepoStageAll: (repo: string) => {
|
||||
gitHandlers.handleGitOperation(
|
||||
() => git.stage(undefined, repo),
|
||||
repo ? `Stage all (${repo})` : "Stage all",
|
||||
);
|
||||
},
|
||||
onRepoUnstageAll: (repo: string) => {
|
||||
gitHandlers.handleGitOperation(
|
||||
() => git.unstage(undefined, repo),
|
||||
repo ? `Unstage all (${repo})` : "Unstage all",
|
||||
);
|
||||
},
|
||||
onRepoCommit: (repo: string) => vcsDialogs.openCommitDialog(repo),
|
||||
onRepoPush: (repo: string) => gitHandlers.handlePush(repo),
|
||||
onRepoCreatePR: (repo: string) => vcsDialogs.openPRDialog(repo),
|
||||
onRepoPull: (repo: string) => gitHandlers.handlePull(repo),
|
||||
onRepoRebase: (repo: string) => gitHandlers.handleRebase(repo),
|
||||
onRepoMerge: (repo: string) => gitHandlers.handleMerge(repo),
|
||||
}),
|
||||
[git, vcsDialogs, gitHandlers],
|
||||
);
|
||||
}
|
||||
|
||||
function useChangesPanelPRData() {
|
||||
// Multi-repo tasks have one TaskPR per repo; fan out file fetches per PR
|
||||
// and stamp each file with its repository_name so PRFilesSection can
|
||||
// group by repo. Commits stay scoped to the primary PR for now — the
|
||||
// commits section already groups by repo from the workspace git status,
|
||||
// not from the PR itself.
|
||||
const { prs, filesByPRKey } = useActiveTaskPRsWithFiles();
|
||||
// Subscribe to the slice (stable ref between hydrations), then build the
|
||||
// id -> name lookup with useMemo so the dependency in the next useMemo is
|
||||
// stable and we don't re-create the merged file list on every render.
|
||||
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
|
||||
const repoNameById = useMemo<Record<string, string>>(() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const list of Object.values(reposByWorkspace)) {
|
||||
for (const r of list as Array<{ id: string; name: string }>) {
|
||||
out[r.id] = r.name;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [reposByWorkspace]);
|
||||
// Tag PR files with their repo whenever the TASK is multi-repo, not just
|
||||
// when there are 2+ PRs. A multi-repo task with one PR (e.g. one repo
|
||||
// doesn't have a branch yet) still benefits from the per-repo header so
|
||||
// the user can tell which repo's files are being shown — the COMMITS and
|
||||
// STAGED sections already do this.
|
||||
const taskHasMultipleRepos = useAppStore((s) => {
|
||||
const taskId = s.tasks.activeTaskId;
|
||||
if (!taskId) return false;
|
||||
const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId);
|
||||
return (task?.repositories?.length ?? 0) > 1;
|
||||
});
|
||||
const taskPR = useActiveTaskPR();
|
||||
const refreshKey = taskPR?.last_synced_at ?? null;
|
||||
const { commits: prCommitsList } = usePRCommits(
|
||||
taskPR?.owner ?? null,
|
||||
taskPR?.repo ?? null,
|
||||
taskPR?.pr_number ?? null,
|
||||
refreshKey,
|
||||
);
|
||||
const { prFiles, prDiffFiles } = useMemo(() => {
|
||||
const merged: PRChangedFile[] = [];
|
||||
const flat: PRDiffFile[] = [];
|
||||
for (const pr of prs) {
|
||||
const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`;
|
||||
const files = filesByPRKey[key] ?? [];
|
||||
const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : "";
|
||||
// Single-repo tasks render flat (the section header is enough);
|
||||
// multi-repo tasks always tag with the resolved repo name so the
|
||||
// PRFilesGroupedList renders one sub-header per repo, even when only
|
||||
// one repo has a PR yet. Falls back to owner/repo if the workspace
|
||||
// lookup misses, so the row never lands in an untagged phantom group.
|
||||
const stamp = taskHasMultipleRepos ? repoName || `${pr.owner}/${pr.repo}` : "";
|
||||
merged.push(...mapPRFilesToChangedFiles(files, stamp));
|
||||
flat.push(...files);
|
||||
}
|
||||
return { prFiles: merged, prDiffFiles: flat };
|
||||
}, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]);
|
||||
const hasPRFiles = prFiles.length > 0;
|
||||
const hasPRCommits = prCommitsList.length > 0;
|
||||
return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles };
|
||||
}
|
||||
|
||||
function useChangesPanelData() {
|
||||
const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData();
|
||||
const baseBranchByRepo = useBaseBranchByRepo(activeTaskId);
|
||||
const git = useSessionGit(activeSessionId);
|
||||
const { toast } = useToast();
|
||||
const { reviews } = useSessionFileReviews(activeSessionId);
|
||||
const prData = useChangesPanelPRData();
|
||||
const vcsDialogs = useVcsDialogs();
|
||||
const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]);
|
||||
const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]);
|
||||
const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]);
|
||||
const { reviewedCount, totalFileCount } = useMemo(
|
||||
() => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles),
|
||||
[git.allFiles, git.cumulativeDiff, reviews, prData.prDiffFiles],
|
||||
);
|
||||
const staged = useMemo(() => computeStagedStats(git.stagedFiles), [git.stagedFiles]);
|
||||
const gitHandlers = useChangesGitHandlers(git, toast, baseBranch);
|
||||
const localDialogs = useChangesDialogHandlers(git, toast, gitHandlers.handleGitOperation);
|
||||
const dialogs = { ...localDialogs, ...vcsDialogs };
|
||||
const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers);
|
||||
const repoDisplayName = useRepoDisplayName(activeSessionId);
|
||||
// Existing PR URL is currently workspace-scoped — surface it under the
|
||||
// empty (single-repo) key so per-repo Create PR can show "PR exists".
|
||||
const prByRepo = useMemo<Record<string, string | undefined>>(
|
||||
() => ({ "": existingPrUrl }),
|
||||
[existingPrUrl],
|
||||
);
|
||||
return {
|
||||
git,
|
||||
baseBranchDisplay,
|
||||
baseBranchByRepo,
|
||||
unstagedFiles,
|
||||
stagedFiles,
|
||||
reviewedCount,
|
||||
totalFileCount,
|
||||
staged,
|
||||
gitHandlers,
|
||||
localDialogs,
|
||||
dialogs,
|
||||
repoCallbacks,
|
||||
repoDisplayName,
|
||||
prByRepo,
|
||||
existingPrUrl,
|
||||
...prData,
|
||||
};
|
||||
}
|
||||
|
||||
function buildChangesPanelBodyProps(
|
||||
data: ReturnType<typeof useChangesPanelData>,
|
||||
callbacks: ChangesPanelProps,
|
||||
): ChangesPanelBodyProps {
|
||||
const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data;
|
||||
return {
|
||||
hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits,
|
||||
hasUnstaged: git.hasUnstaged,
|
||||
hasStaged: git.hasStaged,
|
||||
hasCommits: git.hasCommits,
|
||||
hasPRFiles: data.hasPRFiles,
|
||||
hasPRCommits: data.hasPRCommits,
|
||||
canPush: git.canPush,
|
||||
canCreatePR: git.canCreatePR,
|
||||
existingPrUrl: data.existingPrUrl,
|
||||
unstagedFiles: data.unstagedFiles,
|
||||
stagedFiles: data.stagedFiles,
|
||||
prFiles: data.prFiles,
|
||||
prCommits: data.prCommitsList,
|
||||
commits: git.commits,
|
||||
pendingStageFiles: git.pendingStageFiles,
|
||||
reviewedCount: data.reviewedCount,
|
||||
totalFileCount: data.totalFileCount,
|
||||
aheadCount: git.ahead,
|
||||
isLoading: git.isLoading,
|
||||
loadingOperation: git.loadingOperation,
|
||||
dialogs: data.dialogs,
|
||||
onOpenDiffFile: callbacks.onOpenDiffFile,
|
||||
onEditFile: callbacks.onEditFile,
|
||||
onOpenCommitDetail: callbacks.onOpenCommitDetail,
|
||||
onRevertCommit: gitHandlers.handleRevertCommit,
|
||||
onOpenReview: callbacks.onOpenReview,
|
||||
onStageAll: git.stageAll,
|
||||
onUnstageAll: git.unstageAll,
|
||||
onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined),
|
||||
onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined),
|
||||
onBulkStage: (paths) => {
|
||||
git.stageFile(paths).catch(() => undefined);
|
||||
},
|
||||
onBulkUnstage: (paths) => {
|
||||
git.unstageFile(paths).catch(() => undefined);
|
||||
},
|
||||
onBulkDiscard: localDialogs.handleBulkDiscardClick,
|
||||
onPush: () => gitHandlers.handlePush(),
|
||||
onForcePush: () => gitHandlers.handleForcePush(),
|
||||
stagedFileCount: staged.stagedFileCount,
|
||||
stagedAdditions: staged.stagedAdditions,
|
||||
stagedDeletions: staged.stagedDeletions,
|
||||
onRepoStageAll: repoCallbacks.onRepoStageAll,
|
||||
onRepoUnstageAll: repoCallbacks.onRepoUnstageAll,
|
||||
onRepoCommit: repoCallbacks.onRepoCommit,
|
||||
onRepoPush: repoCallbacks.onRepoPush,
|
||||
onRepoCreatePR: repoCallbacks.onRepoCreatePR,
|
||||
repoDisplayName: data.repoDisplayName,
|
||||
perRepoStatus: git.perRepoStatus,
|
||||
prByRepo: data.prByRepo,
|
||||
};
|
||||
}
|
||||
|
||||
const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) {
|
||||
const isArchived = useIsTaskArchived();
|
||||
const data = useChangesPanelData();
|
||||
@@ -618,4 +50,4 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) {
|
||||
});
|
||||
|
||||
export { ChangesPanel, ChangesPanelBody, useChangesPanelData, buildChangesPanelBodyProps };
|
||||
export type { ChangesPanelBodyProps };
|
||||
export type { ChangesPanelBodyProps } from "./changes-panel-data";
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import type { FileInfo } from "@/lib/state/slices";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import {
|
||||
CommitSummary,
|
||||
MobilePRBranchSummary,
|
||||
@@ -434,6 +435,8 @@ export function useMobileGitActions(
|
||||
setPrDialogOpen: (v: boolean) => void,
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const setPendingPrUrlForTask = useAppStore((state) => state.setPendingPrUrlForTask);
|
||||
// SessionGit's commit fans out per-repo for multi-repo workspaces; the raw
|
||||
// useGitOperations.commit hits the workspace root and fails on multi-repo
|
||||
// tasks. Pull from useGitOperations only what SessionGit doesn't override.
|
||||
@@ -484,7 +487,12 @@ export function useMobileGitActions(
|
||||
description: result.pr_url || "PR created successfully",
|
||||
variant: "success",
|
||||
});
|
||||
if (result.pr_url) window.open(result.pr_url, "_blank");
|
||||
if (result.pr_url) {
|
||||
if (activeTaskId) {
|
||||
setPendingPrUrlForTask(activeTaskId, "", result.pr_url);
|
||||
}
|
||||
window.open(result.pr_url, "_blank");
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Create PR failed",
|
||||
@@ -500,7 +508,7 @@ export function useMobileGitActions(
|
||||
});
|
||||
}
|
||||
},
|
||||
[createPR, baseBranch, toast, setPrDialogOpen],
|
||||
[createPR, baseBranch, toast, setPrDialogOpen, activeTaskId, setPendingPrUrlForTask],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { useSessionGit } from "@/hooks/domains/session/use-session-git";
|
||||
import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name";
|
||||
import { useGitOperations } from "@/hooks/use-git-operations";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
|
||||
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
|
||||
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
|
||||
@@ -469,6 +470,8 @@ function useCreatePRHandler(
|
||||
createPR: ReturnType<typeof useGitOperations>["createPR"],
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const setPendingPrUrlForTask = useAppStore((state) => state.setPendingPrUrlForTask);
|
||||
return useCallback(async () => {
|
||||
if (!ps.title.trim()) return;
|
||||
ps.setOpen(false);
|
||||
@@ -487,7 +490,12 @@ function useCreatePRHandler(
|
||||
description: result.pr_url || "PR created successfully",
|
||||
variant: "success",
|
||||
});
|
||||
if (result.pr_url) window.open(result.pr_url, "_blank");
|
||||
if (result.pr_url) {
|
||||
if (activeTaskId) {
|
||||
setPendingPrUrlForTask(activeTaskId, ps.repo || "", result.pr_url);
|
||||
}
|
||||
window.open(result.pr_url, "_blank");
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Create PR failed",
|
||||
@@ -504,7 +512,7 @@ function useCreatePRHandler(
|
||||
}
|
||||
ps.setTitle("");
|
||||
ps.setBody("");
|
||||
}, [ps, baseBranch, createPR, toast]);
|
||||
}, [ps, baseBranch, createPR, toast, activeTaskId, setPendingPrUrlForTask]);
|
||||
}
|
||||
|
||||
function useVcsDialogsState(
|
||||
|
||||
@@ -65,6 +65,7 @@ export const defaultState = {
|
||||
sessionPollMode: defaultSessionRuntimeState.sessionPollMode,
|
||||
githubStatus: defaultGitHubState.githubStatus,
|
||||
taskPRs: defaultGitHubState.taskPRs,
|
||||
pendingPrUrlByTaskId: defaultGitHubState.pendingPrUrlByTaskId,
|
||||
prWatches: defaultGitHubState.prWatches,
|
||||
reviewWatches: defaultGitHubState.reviewWatches,
|
||||
issueWatches: defaultGitHubState.issueWatches,
|
||||
|
||||
@@ -180,3 +180,32 @@ describe("setTaskPR", () => {
|
||||
expect(store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPendingPrUrlForTask", () => {
|
||||
it("stores a pending PR URL until TaskPR sync clears it", () => {
|
||||
const store = makeStore();
|
||||
|
||||
store
|
||||
.getState()
|
||||
.setPendingPrUrlForTask("task-1", "", "https://dev.azure.com/o/p/_git/r/pullrequest/1");
|
||||
expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.[""]).toBe(
|
||||
"https://dev.azure.com/o/p/_git/r/pullrequest/1",
|
||||
);
|
||||
|
||||
store.getState().setTaskPR("task-1", makePR());
|
||||
expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears only the synced repo pending URL in multi-repo tasks", () => {
|
||||
const store = makeStore();
|
||||
const urlA = "https://dev.azure.com/o/p/_git/a/pullrequest/1";
|
||||
const urlB = "https://dev.azure.com/o/p/_git/b/pullrequest/2";
|
||||
|
||||
store.getState().setPendingPrUrlForTask("task-1", "repo-a", urlA);
|
||||
store.getState().setPendingPrUrlForTask("task-1", "repo-b", urlB);
|
||||
store.getState().setTaskPR("task-1", makePR({ repository_id: "repo-a", pr_url: urlA }));
|
||||
|
||||
expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-b"]).toBe(urlB);
|
||||
expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-a"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { GitHubSlice, GitHubSliceState } from "./types";
|
||||
export const defaultGitHubState: GitHubSliceState = {
|
||||
githubStatus: { status: null, loaded: false, loading: false },
|
||||
taskPRs: { byTaskId: {} },
|
||||
pendingPrUrlByTaskId: { byTaskId: {} },
|
||||
prWatches: { items: [], loaded: false, loading: false },
|
||||
reviewWatches: { items: [], loaded: false, loading: false },
|
||||
issueWatches: { items: [], loaded: false, loading: false },
|
||||
@@ -33,7 +34,33 @@ function createGitHubStatusActions(
|
||||
};
|
||||
}
|
||||
|
||||
function createTaskPRActions(set: ImmerSet): Pick<GitHubSlice, "setTaskPRs" | "setTaskPR"> {
|
||||
function clearPendingPrUrlForRepo(draft: GitHubSlice, taskId: string, repoKey: string) {
|
||||
const pending = draft.pendingPrUrlByTaskId.byTaskId[taskId];
|
||||
if (!pending) return;
|
||||
delete pending[repoKey];
|
||||
if (Object.keys(pending).length === 0) {
|
||||
delete draft.pendingPrUrlByTaskId.byTaskId[taskId];
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear client-only pending URLs for the repo that just synced (not sibling repos). */
|
||||
function clearPendingForTaskPR(
|
||||
draft: GitHubSlice,
|
||||
taskId: string,
|
||||
pr: { repository_id?: string; pr_url?: string },
|
||||
) {
|
||||
clearPendingPrUrlForRepo(draft, taskId, pr.repository_id ?? "");
|
||||
clearPendingPrUrlForRepo(draft, taskId, "");
|
||||
const pending = draft.pendingPrUrlByTaskId.byTaskId[taskId];
|
||||
if (!pending || !pr.pr_url) return;
|
||||
for (const key of Object.keys(pending)) {
|
||||
if (pending[key] === pr.pr_url) clearPendingPrUrlForRepo(draft, taskId, key);
|
||||
}
|
||||
}
|
||||
|
||||
function createTaskPRActions(
|
||||
set: ImmerSet,
|
||||
): Pick<GitHubSlice, "setTaskPRs" | "setTaskPR" | "setPendingPrUrlForTask"> {
|
||||
return {
|
||||
setTaskPRs: (prs) =>
|
||||
set((draft) => {
|
||||
@@ -51,6 +78,19 @@ function createTaskPRActions(set: ImmerSet): Pick<GitHubSlice, "setTaskPRs" | "s
|
||||
if (idx >= 0) existing[idx] = pr;
|
||||
else existing.push(pr);
|
||||
draft.taskPRs.byTaskId[taskId] = existing;
|
||||
clearPendingForTaskPR(draft, taskId, pr);
|
||||
}),
|
||||
setPendingPrUrlForTask: (taskId, repoKey, prUrl) =>
|
||||
set((draft) => {
|
||||
const trimmed = prUrl.trim();
|
||||
if (!trimmed) {
|
||||
clearPendingPrUrlForRepo(draft, taskId, repoKey);
|
||||
return;
|
||||
}
|
||||
if (!draft.pendingPrUrlByTaskId.byTaskId[taskId]) {
|
||||
draft.pendingPrUrlByTaskId.byTaskId[taskId] = {};
|
||||
}
|
||||
draft.pendingPrUrlByTaskId.byTaskId[taskId][repoKey] = trimmed;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ export type TaskPRsState = {
|
||||
byTaskId: Record<string, TaskPR[]>;
|
||||
};
|
||||
|
||||
export type PendingPrUrlsState = {
|
||||
/**
|
||||
* Client-only PR URLs after Create PR succeeds before TaskPR sync (e.g. Azure Repos).
|
||||
* Keyed by task id, then repo name (or "" for single-repo).
|
||||
*/
|
||||
byTaskId: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
export type PRWatchesState = {
|
||||
items: PRWatch[];
|
||||
loaded: boolean;
|
||||
@@ -56,6 +64,7 @@ export type PRFeedbackCacheState = {
|
||||
export type GitHubSliceState = {
|
||||
githubStatus: GitHubStatusState;
|
||||
taskPRs: TaskPRsState;
|
||||
pendingPrUrlByTaskId: PendingPrUrlsState;
|
||||
prWatches: PRWatchesState;
|
||||
reviewWatches: ReviewWatchesState;
|
||||
issueWatches: IssueWatchesState;
|
||||
@@ -68,6 +77,7 @@ export type GitHubSliceActions = {
|
||||
setGitHubStatusLoading: (loading: boolean) => void;
|
||||
setTaskPRs: (prs: Record<string, TaskPR[]>) => void;
|
||||
setTaskPR: (taskId: string, pr: TaskPR) => void;
|
||||
setPendingPrUrlForTask: (taskId: string, repoKey: string, prUrl: string) => void;
|
||||
setPRWatches: (watches: PRWatch[]) => void;
|
||||
setPRWatchesLoading: (loading: boolean) => void;
|
||||
removePRWatch: (id: string) => void;
|
||||
|
||||
@@ -9,16 +9,6 @@ import type {
|
||||
Turn,
|
||||
TaskSession,
|
||||
} from "@/lib/types/http";
|
||||
import type {
|
||||
GitHubStatus,
|
||||
GitHubRateLimitUpdate,
|
||||
TaskPR,
|
||||
PRWatch,
|
||||
ReviewWatch as GitHubReviewWatch,
|
||||
IssueWatch as GitHubIssueWatch,
|
||||
GitHubActionPresets,
|
||||
PRFeedback,
|
||||
} from "@/lib/types/github";
|
||||
import type { SystemHealthResponse } from "@/lib/types/health";
|
||||
import type { UISliceActions as UIA } from "./slices/ui/types";
|
||||
import type * as UISliceTypes from "./slices/ui/types";
|
||||
@@ -77,6 +67,8 @@ import {
|
||||
type ConnectionState,
|
||||
type SystemSliceActions,
|
||||
type AutomationsSliceActions,
|
||||
type FeaturesSliceActions,
|
||||
type GitHubSliceActions,
|
||||
} from "./slices";
|
||||
import type {
|
||||
AvailableCommand,
|
||||
@@ -90,8 +82,7 @@ import type {
|
||||
UserShellInfo,
|
||||
} from "./slices/session-runtime/types";
|
||||
|
||||
// Re-export all types from slices for backwards compatibility (split out
|
||||
// to keep this file under the max-lines limit).
|
||||
// Re-export all types from slices for backwards compatibility.
|
||||
export type * from "./store-reexports";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { JiraIssueWatch } from "@/lib/types/jira";
|
||||
@@ -186,6 +177,7 @@ export type AppState = {
|
||||
// GitHub slice
|
||||
githubStatus: (typeof defaultGitHubState)["githubStatus"];
|
||||
taskPRs: (typeof defaultGitHubState)["taskPRs"];
|
||||
pendingPrUrlByTaskId: (typeof defaultGitHubState)["pendingPrUrlByTaskId"];
|
||||
prWatches: (typeof defaultGitHubState)["prWatches"];
|
||||
reviewWatches: (typeof defaultGitHubState)["reviewWatches"];
|
||||
issueWatches: (typeof defaultGitHubState)["issueWatches"];
|
||||
@@ -206,7 +198,6 @@ export type AppState = {
|
||||
|
||||
// Feature flags slice
|
||||
features: (typeof defaultFeaturesState)["features"];
|
||||
setFeatures: (features: (typeof defaultFeaturesState)["features"]) => void;
|
||||
|
||||
// Automations slice
|
||||
automations: (typeof defaultAutomationsState)["automations"];
|
||||
@@ -234,30 +225,6 @@ export type AppState = {
|
||||
kanbanPreviewedTaskId: (typeof defaultUIState)["kanbanPreviewedTaskId"];
|
||||
sidebarTaskPrefs: (typeof defaultUIState)["sidebarTaskPrefs"];
|
||||
|
||||
// GitHub actions
|
||||
setGitHubStatus: (status: GitHubStatus | null) => void;
|
||||
setGitHubStatusLoading: (loading: boolean) => void;
|
||||
setTaskPRs: (prs: Record<string, TaskPR[]>) => void;
|
||||
setTaskPR: (taskId: string, pr: TaskPR) => void;
|
||||
setPRWatches: (watches: PRWatch[]) => void;
|
||||
setPRWatchesLoading: (loading: boolean) => void;
|
||||
removePRWatch: (id: string) => void;
|
||||
setReviewWatches: (watches: GitHubReviewWatch[]) => void;
|
||||
setReviewWatchesLoading: (loading: boolean) => void;
|
||||
addReviewWatch: (watch: GitHubReviewWatch) => void;
|
||||
updateReviewWatch: (watch: GitHubReviewWatch) => void;
|
||||
removeReviewWatch: (id: string) => void;
|
||||
setIssueWatches: (watches: GitHubIssueWatch[]) => void;
|
||||
setIssueWatchesLoading: (loading: boolean) => void;
|
||||
addIssueWatch: (watch: GitHubIssueWatch) => void;
|
||||
updateIssueWatch: (watch: GitHubIssueWatch) => void;
|
||||
removeIssueWatch: (id: string) => void;
|
||||
setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void;
|
||||
setActionPresetsLoading: (workspaceId: string, loading: boolean) => void;
|
||||
applyGitHubRateLimitUpdate: (update: GitHubRateLimitUpdate) => void;
|
||||
setPRFeedbackCacheEntry: (key: string, feedback: PRFeedback) => void;
|
||||
removePRFeedbackCacheEntry: (key: string) => void;
|
||||
|
||||
// GitLab actions
|
||||
setTaskMRs: (mrs: Record<string, TaskMR[]>) => void;
|
||||
setTaskMR: (taskId: string, mr: TaskMR) => void;
|
||||
@@ -279,8 +246,6 @@ export type AppState = {
|
||||
removeLinearIssueWatch: (id: string) => void;
|
||||
resetLinearIssueWatches: () => void;
|
||||
|
||||
// Automations actions merged via AutomationsSliceActions intersection below.
|
||||
|
||||
// Actions from all slices
|
||||
hydrate: (state: Partial<AppState>, options?: HydrationOptions) => void;
|
||||
setActiveWorkspace: (workspaceId: string | null) => void;
|
||||
@@ -554,7 +519,9 @@ export type AppState = {
|
||||
setRunAttempts: (runId: string, attempts: RouteAttempt[]) => void;
|
||||
appendRunAttempt: (runId: string, attempt: RouteAttempt) => void;
|
||||
setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void;
|
||||
} & SystemSliceActions &
|
||||
} & GitHubSliceActions &
|
||||
SystemSliceActions &
|
||||
FeaturesSliceActions &
|
||||
AutomationsSliceActions;
|
||||
|
||||
export function createAppStore(initialState?: Partial<AppState>) {
|
||||
@@ -638,6 +605,7 @@ export function createAppStore(initialState?: Partial<AppState>) {
|
||||
sessionPollMode: merged.sessionPollMode,
|
||||
githubStatus: merged.githubStatus,
|
||||
taskPRs: merged.taskPRs,
|
||||
pendingPrUrlByTaskId: merged.pendingPrUrlByTaskId,
|
||||
prWatches: merged.prWatches,
|
||||
reviewWatches: merged.reviewWatches,
|
||||
issueWatches: merged.issueWatches,
|
||||
|
||||
@@ -9,11 +9,12 @@ Setup notes and caveats for developing Kandev in ephemeral cloud VMs (Cursor Clo
|
||||
- **pnpm 9.15.9** — matches `packageManager` in `apps/package.json`. Install with `npm install -g pnpm@9.15.9`.
|
||||
- **golangci-lint v2** — required for `make lint-backend`. Install with `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`.
|
||||
- **gcc** — required for CGO (SQLite FTS5). Pre-installed on most Ubuntu-based cloud VMs.
|
||||
- **Azure Repos PR creation (optional)** — only needed when testing `worktree.create_pr` against Azure remotes:
|
||||
- **Azure Repos PR creation (optional)** — only needed when testing `worktree.create_pr` against Azure remotes outside the published Docker image:
|
||||
- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (`az`) on `PATH`
|
||||
- DevOps extension: `az extension add --name azure-devops`
|
||||
- Auth: `az login` or `export AZURE_DEVOPS_EXT_PAT=<pat>`
|
||||
- GitHub PR creation still uses `gh` (see host table in `apps/backend/internal/agentctl/AGENTS.md`).
|
||||
- The `ghcr.io/kdlbs/kandev` image ships `gh`, `az`, and the `azure-devops` extension preinstalled (see root `Dockerfile`).
|
||||
- Host routing table: `apps/backend/internal/agentctl/AGENTS.md`.
|
||||
|
||||
## Generated files before typecheck
|
||||
|
||||
|
||||
Reference in New Issue
Block a user