fix: route PR-only commits to GitHub details (#2268)

This commit is contained in:
Carlos Florêncio
2026-08-05 20:45:59 +01:00
committed by GitHub
parent 5c51dde562
commit 3a1ca57c0a
52 changed files with 3243 additions and 557 deletions
+3
View File
@@ -73,6 +73,9 @@ type Client interface {
// ListPRCommits lists commits in a pull request.
ListPRCommits(ctx context.Context, owner, repo string, number int) ([]PRCommitInfo, error)
// GetPRCommitDetail retrieves one commit and all of its changed files.
GetPRCommitDetail(ctx context.Context, owner, repo, sha string) (PRCommitDetail, error)
// SubmitReview submits a review on a pull request.
// event is one of "APPROVE", "COMMENT", "REQUEST_CHANGES".
SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error
+90 -4
View File
@@ -3,12 +3,22 @@ package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/kandev/kandev/internal/common/securityutil"
)
func validateGitHubCommitSHA(sha string) error {
if !securityutil.LooksLikeCommitSHA(sha) {
return fmt.Errorf("invalid commit SHA")
}
return nil
}
// buildReviewSearchQuery assembles the full GitHub search query.
// When customQuery is non-empty, it is used verbatim as the entire query.
// Otherwise a query is built from scope + optional filter qualifier.
@@ -642,6 +652,28 @@ type ghPRCommit struct {
} `json:"author"`
}
// ghPRCommitDetail is the JSON shape returned by GitHub's individual commit
// endpoint. With gh api --paginate --slurp, the same shape is wrapped in an
// array, one element per page.
type ghPRCommitDetail struct {
SHA string `json:"sha"`
Commit struct {
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Date string `json:"date"`
} `json:"author"`
} `json:"commit"`
Author *struct {
Login string `json:"login"`
} `json:"author"`
Stats struct {
Additions int `json:"additions"`
Deletions int `json:"deletions"`
} `json:"stats"`
Files []ghPRFile `json:"files"`
}
// parsePRFilesJSON parses the JSON response from the PR files API.
func parsePRFilesJSON(data string) ([]PRFile, error) {
var raw []ghPRFile
@@ -667,6 +699,59 @@ func convertRawPRFiles(raw []ghPRFile) []PRFile {
return files
}
// parsePRCommitDetailJSON parses one or more pages from GitHub's individual
// commit endpoint. Metadata and aggregate stats come from the first page;
// file records are appended in provider order and duplicate filenames are
// ignored when a provider repeats a record across pages.
func parsePRCommitDetailJSON(data string) (PRCommitDetail, error) {
var pages []ghPRCommitDetail
trimmed := strings.TrimSpace(data)
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal([]byte(trimmed), &pages); err != nil {
return PRCommitDetail{}, fmt.Errorf("parse PR commit detail: %w", err)
}
} else {
var page ghPRCommitDetail
if err := json.Unmarshal([]byte(trimmed), &page); err != nil {
return PRCommitDetail{}, fmt.Errorf("parse PR commit detail: %w", err)
}
pages = []ghPRCommitDetail{page}
}
if len(pages) == 0 {
return PRCommitDetail{}, errors.New("parse PR commit detail: empty response")
}
first := pages[0]
if first.SHA == "" {
return PRCommitDetail{}, errors.New("parse PR commit detail: missing commit SHA")
}
detail := PRCommitDetail{
SHA: first.SHA,
Message: first.Commit.Message,
AuthorName: first.Commit.Author.Name,
AuthorDate: first.Commit.Author.Date,
Additions: first.Stats.Additions,
Deletions: first.Stats.Deletions,
Files: make([]PRFile, 0),
}
if first.Author != nil {
detail.AuthorLogin = first.Author.Login
}
seen := make(map[string]struct{})
for _, page := range pages {
for _, file := range page.Files {
key := file.Filename + "\x00" + file.PreviousFilename
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
detail.Files = append(detail.Files, convertRawPRFiles([]ghPRFile{file})[0])
}
}
detail.FilesChanged = len(detail.Files)
return detail, nil
}
// buildFullDiff wraps a GitHub patch fragment into a complete unified diff.
func buildFullDiff(filename, status, patch, previousFilename string) string {
if patch == "" {
@@ -729,10 +814,11 @@ func convertRawPRCommits(raw []ghPRCommit) []PRCommitInfo {
msg = msg[:idx]
}
commits[i] = PRCommitInfo{
SHA: c.SHA,
Message: msg,
AuthorLogin: author,
AuthorDate: c.Commit.Author.Date,
SHA: c.SHA,
Message: msg,
AuthorLogin: author,
AuthorDate: c.Commit.Author.Date,
StatsAvailable: false,
}
}
return commits
@@ -0,0 +1,181 @@
package github
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
)
func TestPRCommitStatsAreMarkedUnavailable(t *testing.T) {
commits, err := parsePRCommitsJSON(`[
{
"sha": "1111111111111111111111111111111111111111",
"commit": {
"message": "Remote commit",
"author": {"date": "2026-08-04T10:00:00Z"}
},
"author": {"login": "octocat"}
}
]`)
if err != nil {
t.Fatalf("parse PR commits: %v", err)
}
encoded, err := json.Marshal(commits)
if err != nil {
t.Fatalf("marshal PR commits: %v", err)
}
if !strings.Contains(string(encoded), `"stats_available":false`) {
t.Fatalf("PR commit stats availability = %s, want explicit false", encoded)
}
}
func TestParsePRCommitDetailJSONMergesPages(t *testing.T) {
detail, err := parsePRCommitDetailJSON(`[
{
"sha": "2222222222222222222222222222222222222222",
"commit": {
"message": "Remote detail\nbody",
"author": {"name": "Octo Cat", "date": "2026-08-04T11:00:00Z"}
},
"author": {"login": "octocat"},
"stats": {"additions": 7, "deletions": 3, "total": 10},
"files": [
{"filename": "one.txt", "status": "modified", "additions": 4, "deletions": 1, "patch": "@@ -1 +1 @@\n-old\n+new"},
{"filename": "duplicate.txt", "status": "added", "additions": 1, "deletions": 0}
]
},
{
"sha": "2222222222222222222222222222222222222222",
"commit": {"message": "ignored page metadata", "author": {"name": "Other", "date": "2026-08-05T11:00:00Z"}},
"author": {"login": "other"},
"stats": {"additions": 99, "deletions": 99, "total": 198},
"files": [
{"filename": "duplicate.txt", "status": "added", "additions": 1, "deletions": 0},
{"filename": "two.txt", "status": "removed", "additions": 0, "deletions": 2, "patch": "@@ -1 +0,0 @@\n-gone"}
]
}
]`)
if err != nil {
t.Fatalf("parse PR commit detail: %v", err)
}
if detail.SHA != "2222222222222222222222222222222222222222" || detail.Message != "Remote detail\nbody" {
t.Fatalf("metadata = %#v", detail)
}
if detail.AuthorLogin != "octocat" || detail.AuthorName != "Octo Cat" || detail.AuthorDate != "2026-08-04T11:00:00Z" {
t.Fatalf("author = %#v", detail)
}
if detail.Additions != 7 || detail.Deletions != 3 || detail.FilesChanged != 3 {
t.Fatalf("stats = %#v", detail)
}
if len(detail.Files) != 3 {
t.Fatalf("files = %#v, want three provider-ordered unique files", detail.Files)
}
if detail.Files[0].Filename != "one.txt" || detail.Files[1].Filename != "duplicate.txt" || detail.Files[2].Filename != "two.txt" {
t.Fatalf("file order = %#v", detail.Files)
}
if !strings.Contains(detail.Files[0].Patch, "diff --git a/one.txt b/one.txt") {
t.Fatalf("file patch = %q, want complete diff", detail.Files[0].Patch)
}
}
func TestParsePRCommitDetailJSONRejectsEmptyPayloads(t *testing.T) {
for _, payload := range []string{`null`, `[null]`, `{}`, `[{}]`} {
t.Run(payload, func(t *testing.T) {
if _, err := parsePRCommitDetailJSON(payload); err == nil {
t.Fatalf("parsePRCommitDetailJSON(%q) succeeded, want malformed payload error", payload)
}
})
}
}
func TestGetPRCommitDetailForWorkspaceUsesAuthorizedClient(t *testing.T) {
const sha = "3333333333333333333333333333333333333333"
client := NewMockClient()
client.AddRepos("test-user", []GitHubRepo{{FullName: "acme/widget", Owner: "acme", Name: "widget"}})
client.AddPRCommitDetail("acme", "widget", sha, PRCommitDetail{
SHA: sha,
Files: []PRFile{{Filename: "remote.txt", Status: "modified", Patch: "remote patch"}},
})
svc := newWorkspaceAuthenticatedTestService(t, client, nil, "workspace-1")
detail, err := svc.GetPRCommitDetailForWorkspace(context.Background(), "workspace-1", "", "acme", "widget", sha)
if err != nil {
t.Fatalf("GetPRCommitDetailForWorkspace: %v", err)
}
if detail.SHA != sha || len(detail.Files) != 1 || detail.Files[0].Filename != "remote.txt" {
t.Fatalf("detail = %#v", detail)
}
}
func TestGetPRCommitDetailForWorkspaceRejectsUnsafeSHA(t *testing.T) {
svc := &Service{workspaceAuthorizer: func(context.Context, string) error {
return errors.New("workspace lookup should not run")
}}
_, err := svc.GetPRCommitDetailForWorkspace(context.Background(), "workspace-1", "", "acme", "widget", "main")
if err == nil || !strings.Contains(err.Error(), "invalid commit SHA") {
t.Fatalf("error = %v, want invalid commit SHA before provider access", err)
}
}
func TestWSGetPRCommitDetailRoutesWorkspaceRequest(t *testing.T) {
const sha = "4444444444444444444444444444444444444444"
client := NewMockClient()
client.AddRepos("test-user", []GitHubRepo{{FullName: "acme/widget", Owner: "acme", Name: "widget"}})
client.AddPRCommitDetail("acme", "widget", sha, PRCommitDetail{SHA: sha, Message: "remote commit"})
svc := newWorkspaceAuthenticatedTestService(t, client, nil, "workspace-1")
message, err := ws.NewRequest("request-1", "github.pr_commit.get", map[string]any{
"workspace_id": "workspace-1", "owner": "acme", "repo": "widget", "sha": sha,
})
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err := wsGetPRCommitDetail(svc, testLogger(t))(context.Background(), message)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if response.Type != ws.MessageTypeResponse {
t.Fatalf("response type = %q, want response", response.Type)
}
var body struct {
Commit PRCommitDetail `json:"commit"`
}
if err := json.Unmarshal(response.Payload, &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Commit.SHA != sha || body.Commit.Message != "remote commit" {
t.Fatalf("response body = %#v", body)
}
}
func TestWSGetPRCommitDetailReturnsWorkspaceAuthorizationError(t *testing.T) {
svc := NewService(NewMockClient(), AuthMethodPAT, nil, nil, nil, testLogger(t))
denied := errors.New("workspace not found")
svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error {
if workspaceID != "workspace-1" {
t.Fatalf("workspaceID = %q", workspaceID)
}
return denied
})
message, err := ws.NewRequest("request-1", "github.pr_commit.get", map[string]any{
"workspace_id": "workspace-1", "owner": "acme", "repo": "widget",
"sha": "5555555555555555555555555555555555555555",
})
if err != nil {
t.Fatalf("create request: %v", err)
}
response, err := wsGetPRCommitDetail(svc, testLogger(t))(context.Background(), message)
if err != nil {
t.Fatalf("handler error: %v", err)
}
if response.Type != ws.MessageTypeError || !strings.Contains(string(response.Payload), denied.Error()) {
t.Fatalf("response = %#v, want workspace authorization error", response)
}
}
@@ -117,6 +117,9 @@ func (s *stubClient) ListPRFiles(context.Context, string, string, int) ([]PRFile
func (s *stubClient) ListPRCommits(context.Context, string, string, int) ([]PRCommitInfo, error) {
return nil, nil
}
func (s *stubClient) GetPRCommitDetail(context.Context, string, string, string) (PRCommitDetail, error) {
return PRCommitDetail{}, nil
}
func (s *stubClient) SubmitReview(context.Context, string, string, int, string, string) error {
return nil
}
+17
View File
@@ -782,6 +782,23 @@ func (c *GHClient) ListPRCommits(ctx context.Context, owner, repo string, number
return parsePRCommitsJSON(out)
}
func (c *GHClient) GetPRCommitDetail(ctx context.Context, owner, repo, sha string) (PRCommitDetail, error) {
if err := validateGitHubCommitSHA(sha); err != nil {
return PRCommitDetail{}, err
}
out, err := c.run(ctx, "api",
fmt.Sprintf("repos/%s/%s/commits/%s?per_page=100", owner, repo, sha),
"--paginate", "--slurp")
if err != nil {
return PRCommitDetail{}, fmt.Errorf("get PR commit detail: %w", err)
}
detail, err := parsePRCommitDetailJSON(out)
if err != nil {
return PRCommitDetail{}, err
}
return detail, nil
}
func (c *GHClient) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error {
args := []string{"api",
fmt.Sprintf("repos/%s/%s/pulls/%d/reviews", owner, repo, number),
@@ -234,6 +234,53 @@ esac
}
}
func TestGHClient_PRCommitDetailUsesExactSHAAndMergesPages(t *testing.T) {
binDir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "gh-args.log")
ghPath := filepath.Join(binDir, "gh")
script := `#!/bin/sh
printf '%s\n' "$*" >> "$GH_ARGS_LOG"
case "$*" in
*commits/*)
printf '%s\n' '[{"sha":"2222222222222222222222222222222222222222","commit":{"message":"remote detail","author":{"name":"Octo Cat","date":"2026-08-04T11:00:00Z"}},"author":{"login":"octocat"},"stats":{"additions":7,"deletions":3},"files":[{"filename":"one.txt","status":"modified","additions":4,"deletions":1,"patch":"@@ -1 +1 @@\\n-old\\n+new"}]},{"sha":"2222222222222222222222222222222222222222","commit":{"message":"ignored","author":{"name":"Other","date":"2026-08-05T11:00:00Z"}},"stats":{"additions":99,"deletions":99},"files":[{"filename":"two.txt","status":"removed","additions":0,"deletions":2,"patch":"@@ -1 +0,0 @@\\n-gone"}]}]'
;;
*) printf '%s\n' '[]' ;;
esac
`
if err := os.WriteFile(ghPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake gh: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("GH_ARGS_LOG", logPath)
detail, err := NewGHClient().GetPRCommitDetail(
context.Background(), "acme", "widget", "2222222222222222222222222222222222222222",
)
if err != nil {
t.Fatalf("GetPRCommitDetail: %v", err)
}
if detail.SHA != "2222222222222222222222222222222222222222" || detail.Additions != 7 || detail.Deletions != 3 {
t.Fatalf("detail = %#v", detail)
}
if len(detail.Files) != 2 {
t.Fatalf("files = %#v, want two merged files", detail.Files)
}
args, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read gh args: %v", err)
}
command := string(args)
for _, want := range []string{
"api repos/acme/widget/commits/2222222222222222222222222222222222222222?per_page=100",
"--paginate",
"--slurp",
} {
if !strings.Contains(command, want) {
t.Fatalf("gh args = %q, want %q", command, want)
}
}
}
func TestGHClient_RequestReviewers_UsesGitHubReviewRequestEndpoint(t *testing.T) {
binDir := t.TempDir()
logPath := filepath.Join(t.TempDir(), "gh-args.log")
+30
View File
@@ -53,6 +53,7 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log
dispatcher.RegisterFunc(ws.ActionGitHubPRWatchDelete, wsDeletePRWatch(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubPRFilesGet, wsGetPRFiles(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubPRCommitsGet, wsGetPRCommits(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubPRCommitGet, wsGetPRCommitDetail(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubTaskPRSync, wsSyncTaskPR(svc, log))
dispatcher.RegisterFunc(ws.ActionGitHubStats, wsGetStats(svc, log))
@@ -464,6 +465,35 @@ func wsGetPRCommits(svc *Service, _ *logger.Logger) func(ctx context.Context, ms
}
}
func wsGetPRCommitDetail(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
payload, parseErr := parseMap(msg)
if parseErr != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgInvalidPayload, nil)
return resp, nil
}
workspaceID, _ := payload["workspace_id"].(string)
owner, _ := payload["owner"].(string)
repo, _ := payload["repo"].(string)
sha, _ := payload["sha"].(string)
if workspaceID == "" || owner == "" || repo == "" || sha == "" {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id, owner, repo, sha required", nil)
return resp, nil
}
if err := validateGitHubCommitSHA(sha); err != nil {
resp, _ := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil)
return resp, nil
}
detail, err := svc.GetPRCommitDetailForWorkspace(
ctx, workspaceID, githubUserID(ctx), owner, repo, sha,
)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"commit": detail})
}
}
// --- Issue watch WS handlers ---
func wsListIssueWatches(svc *Service, _ *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -70,6 +70,12 @@ type repoKey struct {
Repo string
}
type commitDetailKey struct {
Owner string
Repo string
SHA string
}
// repoFileEntry is one seeded file for MockClient.ListRepoDirectory /
// GetRepoFileContent. Ref "" is a wildcard that matches any requested ref;
// a non-empty Ref only matches that exact ref.
@@ -104,6 +110,7 @@ type MockClient struct {
checks map[checkKey][]CheckRun
files map[prKey][]PRFile
commits map[prKey][]PRCommitInfo
commitDetails map[commitDetailKey]PRCommitDetail
submittedReviews []submittedReview
requestedReviews []requestedReviewers
mergedPRs []mergedPR
@@ -151,6 +158,7 @@ func NewMockClient() *MockClient {
checks: make(map[checkKey][]CheckRun),
files: make(map[prKey][]PRFile),
commits: make(map[prKey][]PRCommitInfo),
commitDetails: make(map[commitDetailKey]PRCommitDetail),
mergeMethods: make(map[repoKey]RepoMergeMethods),
gists: make(map[string]mockGist),
repoFiles: make(map[repoKey][]repoFileEntry),
@@ -449,6 +457,17 @@ func (m *MockClient) ListPRCommits(_ context.Context, owner, repo string, number
return m.commits[prKey{owner, repo, number}], nil
}
func (m *MockClient) GetPRCommitDetail(_ context.Context, owner, repo, sha string) (PRCommitDetail, error) {
m.mu.RLock()
defer m.mu.RUnlock()
detail, ok := m.commitDetails[commitDetailKey{Owner: owner, Repo: repo, SHA: sha}]
if !ok {
return PRCommitDetail{}, fmt.Errorf("mock: commit %s/%s@%s not found", owner, repo, sha)
}
detail.Files = append([]PRFile(nil), detail.Files...)
return detail, nil
}
func (m *MockClient) ListRepoBranches(_ context.Context, owner, repo string) ([]RepoBranch, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -855,6 +874,17 @@ func (m *MockClient) AddPRCommits(owner, repo string, number int, commits []PRCo
m.commits[k] = append(m.commits[k], commits...)
}
// AddPRCommitDetail seeds an individual GitHub commit response.
func (m *MockClient) AddPRCommitDetail(owner, repo, sha string, detail PRCommitDetail) {
m.mu.Lock()
defer m.mu.Unlock()
if detail.SHA == "" {
detail.SHA = sha
}
detail.Files = append([]PRFile(nil), detail.Files...)
m.commitDetails[commitDetailKey{Owner: owner, Repo: repo, SHA: sha}] = detail
}
// Reset clears all mock data and resets the user to the default.
func (m *MockClient) Reset() {
m.mu.Lock()
@@ -874,6 +904,7 @@ func (m *MockClient) Reset() {
m.checks = make(map[checkKey][]CheckRun)
m.files = make(map[prKey][]PRFile)
m.commits = make(map[prKey][]PRCommitInfo)
m.commitDetails = make(map[commitDetailKey]PRCommitDetail)
m.submittedReviews = nil
m.requestedReviews = nil
m.mergedPRs = nil
@@ -50,6 +50,7 @@ func (c *MockController) RegisterRoutes(router *gin.Engine) {
api.POST("/checks", c.addCheckRuns)
api.POST("/files", c.addPRFiles)
api.POST("/commits", c.addPRCommits)
api.POST("/commit-details", c.addPRCommitDetail)
api.POST("/branches", c.addBranches)
api.POST("/repo-files", c.addRepoFiles)
api.POST("/task-prs", c.associateTaskPR)
@@ -500,6 +501,25 @@ func (c *MockController) addPRCommits(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"added": len(req.Commits)})
}
func (c *MockController) addPRCommitDetail(ctx *gin.Context) {
var req struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
SHA string `json:"sha"`
Detail PRCommitDetail `json:"detail"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.Owner == "" || req.Repo == "" || req.SHA == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "owner, repo, and sha are required"})
return
}
c.mock.AddPRCommitDetail(req.Owner, req.Repo, req.SHA, req.Detail)
ctx.JSON(http.StatusOK, gin.H{"added": 1})
}
func (c *MockController) addBranches(ctx *gin.Context) {
var req struct {
Owner string `json:"owner"`
+23 -7
View File
@@ -820,13 +820,29 @@ type PRFile struct {
// PRCommitInfo represents a commit in a pull request.
type PRCommitInfo struct {
SHA string `json:"sha"`
Message string `json:"message"`
AuthorLogin string `json:"author_login"`
AuthorDate string `json:"author_date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
SHA string `json:"sha"`
Message string `json:"message"`
AuthorLogin string `json:"author_login"`
AuthorDate string `json:"author_date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
StatsAvailable bool `json:"stats_available"`
}
// PRCommitDetail represents the metadata and changed files for one GitHub
// commit. Unlike PRCommitInfo, its statistics come from GitHub's individual
// commit endpoint and are always measured.
type PRCommitDetail struct {
SHA string `json:"sha"`
Message string `json:"message"`
AuthorLogin string `json:"author_login"`
AuthorName string `json:"author_name"`
AuthorDate string `json:"author_date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
Files []PRFile `json:"files"`
}
// DailyCount holds a date and count for chart data.
@@ -89,6 +89,10 @@ func (c *NoopClient) ListPRCommits(context.Context, string, string, int) ([]PRCo
return nil, ErrNoClient
}
func (c *NoopClient) GetPRCommitDetail(context.Context, string, string, string) (PRCommitDetail, error) {
return PRCommitDetail{}, ErrNoClient
}
func (c *NoopClient) ListRepoBranches(context.Context, string, string) ([]RepoBranch, error) {
return nil, ErrNoClient
}
@@ -578,6 +578,28 @@ func (c *PATClient) ListPRCommits(ctx context.Context, owner, repo string, numbe
return convertRawPRCommits(raw), nil
}
func (c *PATClient) GetPRCommitDetail(ctx context.Context, owner, repo, sha string) (PRCommitDetail, error) {
if err := validateGitHubCommitSHA(sha); err != nil {
return PRCommitDetail{}, err
}
var pages []ghPRCommitDetail
endpoint := fmt.Sprintf("/repos/%s/%s/commits/%s?per_page=100", owner, repo, sha)
for endpoint != "" {
var page ghPRCommitDetail
next, err := c.getPaginated(ctx, endpoint, &page)
if err != nil {
return PRCommitDetail{}, fmt.Errorf("get PR commit detail: %w", err)
}
pages = append(pages, page)
endpoint = next
}
encoded, err := json.Marshal(pages)
if err != nil {
return PRCommitDetail{}, fmt.Errorf("encode PR commit detail pages: %w", err)
}
return parsePRCommitDetailJSON(string(encoded))
}
func (c *PATClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]RepoBranch, error) {
var branches []RepoBranch
endpoint := fmt.Sprintf("/repos/%s/%s/branches?per_page=100", owner, repo)
@@ -196,6 +196,42 @@ func TestPATClient_ListCheckRunsPaginatesCheckRuns(t *testing.T) {
}
}
func TestPATClient_PRCommitDetailUsesExactSHAAndMergesPages(t *testing.T) {
var requested []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requested = append(requested, r.URL.RequestURI())
if r.URL.Path != "/repos/acme/widget/commits/2222222222222222222222222222222222222222" {
t.Errorf("path = %q", r.URL.Path)
}
if r.URL.Query().Get("per_page") != "100" {
t.Errorf("per_page = %q, want 100", r.URL.Query().Get("per_page"))
}
if r.URL.Query().Get("page") == "2" {
_, _ = w.Write([]byte(`{"sha":"2222222222222222222222222222222222222222","commit":{"message":"ignored","author":{"name":"Other","date":"2026-08-05T11:00:00Z"}},"stats":{"additions":99,"deletions":99},"files":[{"filename":"two.txt","status":"removed","additions":0,"deletions":2,"patch":"@@ -1 +0,0 @@\n-gone"}]}`))
return
}
w.Header().Set("Link", `<`+githubAPIBase+`/repos/acme/widget/commits/2222222222222222222222222222222222222222?per_page=100&page=2>; rel="next"`)
_, _ = w.Write([]byte(`{"sha":"2222222222222222222222222222222222222222","commit":{"message":"remote detail","author":{"name":"Octo Cat","date":"2026-08-04T11:00:00Z"}},"author":{"login":"octocat"},"stats":{"additions":7,"deletions":3},"files":[{"filename":"one.txt","status":"modified","additions":4,"deletions":1,"patch":"@@ -1 +1 @@\n-old\n+new"}]}`))
}))
t.Cleanup(srv.Close)
detail, err := newPATClientPointingAt(t, srv.URL).GetPRCommitDetail(
context.Background(), "acme", "widget", "2222222222222222222222222222222222222222",
)
if err != nil {
t.Fatalf("GetPRCommitDetail: %v", err)
}
if detail.AuthorLogin != "octocat" || detail.AuthorName != "Octo Cat" || detail.FilesChanged != 2 {
t.Fatalf("detail = %#v", detail)
}
if detail.Additions != 7 || detail.Deletions != 3 || len(detail.Files) != 2 {
t.Fatalf("detail stats/files = %#v", detail)
}
if len(requested) != 2 {
t.Fatalf("requests = %#v, want two pages", requested)
}
}
func TestConvertPatPR_Mergeable(t *testing.T) {
mergeable := true
raw := &patPR{
@@ -548,6 +548,24 @@ func (s *Service) GetPRCommitsForWorkspace(
return resolved.Client.ListPRCommits(ctx, owner, repo, number)
}
// GetPRCommitDetailForWorkspace fetches one commit through the workspace's
// authorized personal read client. It never consults a task worktree.
func (s *Service) GetPRCommitDetailForWorkspace(
ctx context.Context, workspaceID, userID, owner, repo, sha string,
) (PRCommitDetail, error) {
if err := validateGitHubCommitSHA(sha); err != nil {
return PRCommitDetail{}, err
}
if err := s.ensureRepositoryInWorkspaceScope(ctx, workspaceID, owner, repo); err != nil {
return PRCommitDetail{}, err
}
resolved, err := s.resolvePersonalReadClient(ctx, workspaceID, userID, owner, repo)
if err != nil {
return PRCommitDetail{}, err
}
return resolved.Client.GetPRCommitDetail(ctx, owner, repo, sha)
}
// timeEqual compares two nullable time pointers for equality.
func timeEqual(a, b *time.Time) bool {
if a == nil && b == nil {
+1
View File
@@ -457,6 +457,7 @@ const (
ActionGitHubPRWatchDelete = "github.pr_watches.delete"
ActionGitHubPRFilesGet = "github.pr_files.get"
ActionGitHubPRCommitsGet = "github.pr_commits.get"
ActionGitHubPRCommitGet = "github.pr_commit.get"
ActionGitHubTaskPRUpdated = "github.task_pr.updated" // Notification
ActionGitHubTaskPRDeleted = "github.task_pr.deleted" // Notification
ActionGitHubTaskCIOptionsUpdated = "github.task_ci_options.updated" // Notification
@@ -1,5 +1,24 @@
export type DiffSource = "uncommitted" | "committed" | "pr";
export type LocalCommitDetailTarget = {
source: "local";
sha: string;
/** Multi-repo local repository subpath, omitted for the workspace root. */
repo?: string;
};
export type GitHubCommitDetailTarget = {
source: "github";
sha: string;
workspaceId: string;
owner: string;
repo: string;
/** Local display/group identity for the linked repository. */
repositoryName?: string;
};
export type CommitDetailTarget = LocalCommitDetailTarget | GitHubCommitDetailTarget;
export type OpenDiffOptions = {
source?: DiffSource;
repositoryName?: string;
@@ -15,4 +34,4 @@ export type DiffSheetMode =
repositoryName?: string;
prKey?: string;
}
| { kind: "commit"; sha: string; repo?: string };
| { kind: "commit"; target: CommitDetailTarget };
+19 -14
View File
@@ -36,8 +36,9 @@ import {
buildRepoNameById,
selectPRFilesForReviewProgress,
type ReviewProgressPRSource,
type PRCommitForMerge,
} from "./changes-panel-helpers";
import type { OpenDiffOptions } from "./changes-diff-target";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
import type { PRDiffFile, TaskPR } from "@/lib/types/github";
import { getGitCredentialDisplay } from "./changes-git-credential-display";
@@ -95,14 +96,7 @@ export type ChangesPanelBodyProps = {
unstagedFiles: ChangedFile[];
stagedFiles: ChangedFile[];
prFiles: PRChangedFile[];
prCommits: {
sha: string;
message: string;
author_login: string;
author_date: string;
additions: number;
deletions: number;
}[];
prCommits: PRCommitForMerge[];
commits: {
commit_sha: string;
commit_message: string;
@@ -120,7 +114,7 @@ export type ChangesPanelBodyProps = {
dialogs: DialogsType;
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
onOpenReview?: () => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onStageAll: () => void;
@@ -256,6 +250,7 @@ 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 { selectedPR: taskPR } = useReviewPRSelection(activeTaskId);
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]);
@@ -275,6 +270,16 @@ function useChangesPanelPRData(repositoryNames: string[], sessionId: string | nu
taskPR?.pr_number ?? null,
refreshKey,
);
const prCommits = useMemo<PRCommitForMerge[]>(() => {
if (!workspaceId?.trim() || !taskPR?.owner?.trim() || !taskPR.repo?.trim()) return [];
return prCommitsList.map((commit) => ({
...commit,
workspace_id: workspaceId,
owner: taskPR.owner,
repo: taskPR.repo,
repository_name: useRepositoryKeys ? selectedPRRepositoryName : undefined,
}));
}, [prCommitsList, workspaceId, taskPR?.owner, taskPR?.repo, selectedPRRepositoryName]);
const { prFiles, prDiffFiles } = useMemo(
() =>
buildChangesPanelPRData({
@@ -297,10 +302,10 @@ function useChangesPanelPRData(repositoryNames: string[], sessionId: string | nu
],
);
const hasPRFiles = prFiles.length > 0;
const hasPRCommits = prCommitsList.length > 0;
const hasPRCommits = prCommits.length > 0;
return {
prDiffFiles,
prCommitsList,
prCommits,
hasPRFiles,
hasPRCommits,
prFiles,
@@ -389,7 +394,7 @@ export function useChangesPanelData() {
type ChangesPanelCallbacks = {
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
onOpenReview?: () => void;
};
@@ -411,7 +416,7 @@ export function buildChangesPanelBodyProps(
unstagedFiles: data.unstagedFiles,
stagedFiles: data.stagedFiles,
prFiles: data.prFiles,
prCommits: data.prCommitsList,
prCommits: data.prCommits,
commits: git.commits,
pendingStageFiles: git.pendingStageFiles,
reviewedCount: data.reviewedCount,
@@ -10,6 +10,7 @@ import type { FileInfo } from "@/lib/state/store";
import type { PRDiffFile } from "@/lib/types/github";
import { normalizeFileChangeStatus, type FileChangeStatus } from "@/lib/utils/file-change-status";
import type { PRChangedFile } from "./changes-panel-timeline";
import type { CommitDetailTarget } from "./changes-diff-target";
export type ChangedFile = {
path: string;
@@ -266,17 +267,82 @@ export function firstVisibleSection(flags: {
return null;
}
type MergedCommit = {
export type MergedCommit = {
commit_sha: string;
commit_message: string;
insertions: number;
deletions: number;
pushed: boolean;
statsAvailable: boolean;
detailTarget: CommitDetailTarget;
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
repository_name?: string;
committed_at?: string;
};
export type PRCommitForMerge = {
sha: string;
message: string;
additions: number;
deletions: number;
author_date?: string;
stats_available?: boolean;
workspace_id: string;
owner: string;
repo: string;
repository_name?: string;
};
function localCommitTarget(commit: {
commit_sha: string;
repository_name?: string;
}): CommitDetailTarget {
return {
source: "local",
sha: commit.commit_sha,
...(commit.repository_name ? { repo: commit.repository_name } : {}),
};
}
function githubCommitTarget(commit: PRCommitForMerge): CommitDetailTarget | null {
if (!commit.workspace_id.trim() || !commit.owner.trim() || !commit.repo.trim()) return null;
return {
source: "github",
sha: commit.sha,
workspaceId: commit.workspace_id,
owner: commit.owner,
repo: commit.repo,
...(commit.repository_name ? { repositoryName: commit.repository_name } : {}),
};
}
function prCommitKey(pr: PRCommitForMerge): string {
return `${pr.repository_name ?? ""}\0${pr.sha}`;
}
function appendPROnlyCommits(
prCommits: PRCommitForMerge[],
matchedPRKeys: Set<string>,
pushed: MergedCommit[],
): void {
for (const pr of prCommits) {
if (matchedPRKeys.has(prCommitKey(pr))) continue;
const detailTarget = githubCommitTarget(pr);
if (!detailTarget) continue;
pushed.push({
commit_sha: pr.sha,
commit_message: pr.message,
insertions: pr.additions,
deletions: pr.deletions,
pushed: true,
statsAvailable: pr.stats_available === true,
detailTarget,
...(pr.repository_name ? { repository_name: pr.repository_name } : {}),
committed_at: pr.author_date,
});
}
}
/**
* Merge local session commits and PR commits into a single list. A commit is
* pushed when EITHER source confirms it: the backend's `pushed` field
@@ -299,46 +365,46 @@ export function mergeCommits(
pushed?: boolean;
committed_at?: string;
}[],
prCommits: {
sha: string;
message: string;
additions: number;
deletions: number;
author_date?: string;
}[],
prCommits: PRCommitForMerge[],
): MergedCommit[] {
const shaMatches = (a: string, b: string) => a.startsWith(b) || b.startsWith(a);
const repositoryMatches = (a?: string, b?: string) => (a ?? "") === (b ?? "");
const commitsMatch = (
local: { commit_sha: string; repository_name?: string },
pr: PRCommitForMerge,
) =>
shaMatches(pr.sha, local.commit_sha) &&
repositoryMatches(local.repository_name, pr.repository_name);
const unpushed: MergedCommit[] = [];
const pushed: MergedCommit[] = [];
const matchedPRShas = new Set<string>();
const matchedPRKeys = new Set<string>();
for (const c of localCommits) {
const matchesPR = prCommits.some((pr) => shaMatches(pr.sha, c.commit_sha));
const matchesPR = prCommits.some((pr) => commitsMatch(c, pr));
const isPushed = c.pushed === true || matchesPR;
if (isPushed) {
pushed.push({ ...c, pushed: true });
pushed.push({
...c,
pushed: true,
statsAvailable: true,
detailTarget: localCommitTarget(c),
});
} else {
unpushed.push({ ...c, pushed: false });
unpushed.push({
...c,
pushed: false,
statsAvailable: true,
detailTarget: localCommitTarget(c),
});
}
if (matchesPR) {
for (const pr of prCommits) {
if (shaMatches(pr.sha, c.commit_sha)) {
matchedPRShas.add(pr.sha);
if (commitsMatch(c, pr)) {
matchedPRKeys.add(prCommitKey(pr));
}
}
}
}
for (const pr of prCommits) {
if (!matchedPRShas.has(pr.sha)) {
pushed.push({
commit_sha: pr.sha,
commit_message: pr.message,
insertions: pr.additions,
deletions: pr.deletions,
pushed: true,
committed_at: pr.author_date,
});
}
}
appendPROnlyCommits(prCommits, matchedPRKeys, pushed);
return [...unpushed, ...pushed];
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { mergeCommits } from "./changes-panel";
const PR_DATE = "2026-03-29T00:00:00Z";
const SHARED_SHA = "ccc3333";
const WIDGET_REPO = "widget";
const WORKSPACE_ID = "workspace-1";
function makeLocal(sha: string, message = "msg") {
return { commit_sha: sha, commit_message: message, insertions: 1, deletions: 0 };
}
function makePR(sha: string, message = "msg") {
return {
sha,
message,
author_login: "user",
author_date: PR_DATE,
additions: 5,
deletions: 2,
files_changed: 1,
};
}
describe("mergeCommits source provenance", () => {
it("marks PR-only commits as remote with explicitly unavailable stats", () => {
const pr = {
...makePR(SHARED_SHA, "external fix"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: WIDGET_REPO,
repository_name: WIDGET_REPO,
};
expect(mergeCommits([], [pr])).toMatchObject([
{
statsAvailable: false,
detailTarget: {
source: "github",
sha: SHARED_SHA,
workspaceId: WORKSPACE_ID,
owner: "acme",
repo: WIDGET_REPO,
},
},
]);
});
it("prefers the local source when a SHA exists in both feeds", () => {
const local = [{ ...makeLocal(SHARED_SHA, "local"), repository_name: WIDGET_REPO }];
const pr = {
...makePR(SHARED_SHA, "remote"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: WIDGET_REPO,
repository_name: WIDGET_REPO,
};
expect(mergeCommits(local, [pr])).toMatchObject([
{
commit_message: "local",
statsAvailable: true,
detailTarget: { source: "local", sha: SHARED_SHA, repo: WIDGET_REPO },
},
]);
});
it("does not deduplicate equal SHAs from different repositories", () => {
const local = [{ ...makeLocal(SHARED_SHA, "local"), repository_name: "widget-a" }];
const pr = {
...makePR(SHARED_SHA, "remote"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: "widget-b",
repository_name: "widget-b",
};
const result = mergeCommits(local, [pr]);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
repository_name: "widget-b",
detailTarget: { source: "github", repo: "widget-b" },
});
});
it("keeps a same-SHA PR-only commit when another repository matches locally", () => {
const local = [{ ...makeLocal(SHARED_SHA, "local"), repository_name: "widget-a" }];
const matchedPR = {
...makePR(SHARED_SHA, "local PR"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: "widget-a",
repository_name: "widget-a",
};
const remotePR = {
...makePR(SHARED_SHA, "remote PR"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: "widget-b",
repository_name: "widget-b",
};
const result = mergeCommits(local, [matchedPR, remotePR]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
commit_message: "local",
detailTarget: { source: "local", repo: "widget-a" },
});
expect(result[1]).toMatchObject({
commit_message: "remote PR",
repository_name: "widget-b",
detailTarget: { source: "github", repo: "widget-b" },
});
});
});
describe("mergeCommits repository identity", () => {
it("does not create a PR-only target without complete provenance", () => {
const pr = {
...makePR(SHARED_SHA, "remote"),
stats_available: false,
workspace_id: "",
owner: "acme",
repo: WIDGET_REPO,
repository_name: WIDGET_REPO,
};
expect(mergeCommits([], [pr])).toEqual([]);
});
it("does not match a root local commit to a named repository PR", () => {
const local = [makeLocal(SHARED_SHA, "root local")];
const pr = {
...makePR(SHARED_SHA, "remote"),
stats_available: false,
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: WIDGET_REPO,
repository_name: WIDGET_REPO,
};
const result = mergeCommits(local, [pr]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
commit_message: "root local",
pushed: false,
detailTarget: { source: "local", sha: SHARED_SHA },
});
expect(result[1]).toMatchObject({
commit_message: "remote",
pushed: true,
repository_name: WIDGET_REPO,
detailTarget: { source: "github", repo: WIDGET_REPO },
});
});
});
@@ -10,6 +10,7 @@ import {
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { CommitRow, type CommitItem } from "./commit-row";
import type { CommitDetailTarget } from "./changes-diff-target";
import { groupByRepositoryName } from "@/lib/group-by-repo";
import type { ChangedFile } from "./changes-panel-helpers";
@@ -222,7 +223,7 @@ export function CommitsRepoGroup({
* workspaces use this — the action buttons (Push / PR) move up to the
* section header so we don't render a redundant repo sub-header. */
showHeader?: boolean;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
onAmendCommit?: (currentMessage: string, repo?: string) => void;
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
@@ -71,6 +71,12 @@ function commit(sha: string, message: string, repo?: string): CommitProps["commi
insertions: 1,
deletions: 0,
pushed: false,
statsAvailable: true,
detailTarget: {
source: "local",
sha,
...(repo ? { repo } : {}),
},
repository_name: repo,
};
}
@@ -304,6 +310,8 @@ describe("section auto-expand (defaultCollapsed prop)", () => {
insertions: 1,
deletions: 0,
pushed: false,
statsAvailable: true,
detailTarget: { source: "local", sha: "abc123" },
repository_name: undefined,
},
]}
@@ -20,7 +20,7 @@ import {
RepoGroupItem,
} from "./changes-panel-repo-groups";
import { PRFilesGroupedList } from "./changes-panel-pr-files";
import type { OpenDiffOptions } from "./changes-diff-target";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
// --- Timeline visual components ---
@@ -129,7 +129,7 @@ function TimelineSection({
type CommitsSectionProps = {
commits: CommitItem[];
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
// Handlers receive the commit's repository_name so amend/revert/reset land
// in the right git repo. The empty string routes to the workspace root for
// single-repo workspaces.
+30 -14
View File
@@ -9,6 +9,10 @@ const makeLocal = (sha: string, message = "msg") => ({
});
const PR_DATE = "2026-03-29T00:00:00Z";
const WORKSPACE_ID = "workspace-1";
const PR_OWNER = "acme";
const PR_REPO = "widget";
const REMOTE_SHA = "ccc3333";
const makePR = (sha: string) => ({
sha,
@@ -49,26 +53,27 @@ const makePRFull = (sha: string, message = "msg", author = "user") => ({
additions: 5,
deletions: 2,
files_changed: 1,
workspace_id: WORKSPACE_ID,
owner: PR_OWNER,
repo: PR_REPO,
});
describe("mergeCommits", () => {
it("marks all local commits as unpushed when no PR commits exist", () => {
const local = [makeLocal("aaa1111", "first"), makeLocal("bbb2222", "second")];
const result = mergeCommits(local, []);
expect(result).toEqual([
expect(result).toMatchObject([
{
commit_sha: "aaa1111",
commit_message: "first",
insertions: 1,
deletions: 0,
pushed: false,
statsAvailable: true,
detailTarget: { source: "local", sha: "aaa1111" },
},
{
commit_sha: "bbb2222",
commit_message: "second",
insertions: 1,
deletions: 0,
pushed: false,
statsAvailable: true,
detailTarget: { source: "local", sha: "bbb2222" },
},
]);
});
@@ -106,23 +111,34 @@ describe("mergeCommits", () => {
const local = [makeLocal("aaa1111", "first")];
const pr = [makePRFull("aaa1111bbbccc", "first", "user")];
const result = mergeCommits(local, pr);
expect(result).toEqual([
{ commit_sha: "aaa1111", commit_message: "first", insertions: 1, deletions: 0, pushed: true },
expect(result).toMatchObject([
{
commit_sha: "aaa1111",
pushed: true,
statsAvailable: true,
detailTarget: { source: "local", sha: "aaa1111" },
},
]);
});
it("includes PR-only commits (from other contributors) as pushed", () => {
const local: ReturnType<typeof makeLocal>[] = [];
const pr = [makePRFull("ccc3333", "external fix", "other-dev")];
const pr = [makePRFull(REMOTE_SHA, "external fix", "other-dev")];
const result = mergeCommits(local, pr);
expect(result).toEqual([
expect(result).toMatchObject([
{
commit_sha: "ccc3333",
commit_sha: REMOTE_SHA,
commit_message: "external fix",
insertions: 5,
deletions: 2,
pushed: true,
committed_at: PR_DATE,
statsAvailable: false,
detailTarget: {
source: "github",
sha: REMOTE_SHA,
workspaceId: WORKSPACE_ID,
owner: PR_OWNER,
repo: PR_REPO,
},
},
]);
});
+2 -2
View File
@@ -7,7 +7,7 @@ import { ChangesPanelHeader } from "./changes-panel-header";
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";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough";
export { filterUnpushedCommits, mergeCommits };
@@ -15,7 +15,7 @@ export { filterUnpushedCommits, mergeCommits };
type ChangesPanelProps = {
onOpenDiffFile: (path: string, options?: OpenDiffOptions) => void;
onEditFile: (path: string, repo?: string) => void;
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
onOpenDiffAll?: () => void;
onOpenReview?: () => void;
};
@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
refetch: vi.fn(),
useCommitDetail: vi.fn(() => ({
files: null,
commit: null,
loading: false,
error: "Commit detail unavailable",
refetch: mocks.refetch,
})),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({ tasks: { activeSessionId: null } }),
}));
vi.mock("@/hooks/domains/session/use-session-commits", () => ({
useSessionCommits: () => ({ commits: [] }),
}));
vi.mock("@/hooks/domains/session/use-commit-detail", () => ({
useCommitDetail: mocks.useCommitDetail,
}));
vi.mock("@/hooks/use-panel-actions", () => ({
usePanelActions: () => ({ openFile: vi.fn() }),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
setPanelTitle: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("./panel-primitives", () => ({
PanelRoot: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PanelBody: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
import { CommitDetailPanel, CommitDiffView } from "./commit-detail-panel";
afterEach(() => {
mocks.refetch.mockReset();
mocks.useCommitDetail.mockClear();
});
describe("CommitDiffView error state", () => {
it("does not present a protocol failure as an empty commit and offers retry", () => {
render(
<CommitDiffView
target={{
source: "github",
sha: "remote123",
workspaceId: "workspace-1",
owner: "acme",
repo: "widget",
}}
/>,
);
expect(screen.getByRole("alert").textContent).toContain("Commit detail unavailable");
expect(screen.queryByText("No files in this commit")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "system:featureTogglesRetry" }));
expect(mocks.refetch).toHaveBeenCalledOnce();
});
});
describe("CommitDetailPanel target validation", () => {
it("does not accept a partial GitHub target from serialized params", () => {
render(
<CommitDetailPanel
panelId="commit-detail"
params={{ target: { source: "github", sha: "partial" } }}
/>,
);
expect(mocks.useCommitDetail).toHaveBeenCalledWith({ source: "local", sha: "" });
});
});
+128 -28
View File
@@ -1,17 +1,20 @@
"use client";
import { memo, useEffect, useMemo } from "react";
import { IconLoader2 } from "@tabler/icons-react";
import { IconAlertCircle, IconLoader2, IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useTranslation } from "react-i18next";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { FileDiffViewer } from "@/components/diff";
import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults";
import { useAppStore } from "@/components/state-provider";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useCommitDiff } from "@/hooks/domains/session/use-commit-diff";
import { useCommitDetail } from "@/hooks/domains/session/use-commit-detail";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { formatRelativeTime } from "@/lib/utils";
import type { FileInfo } from "@/lib/state/store";
import type { CommitDetailTarget } from "./changes-diff-target";
type CommitDetailPanelProps = {
panelId: string;
@@ -19,12 +22,44 @@ type CommitDetailPanelProps = {
};
type CommitDiffViewProps = {
sha: string;
repo?: string;
target: CommitDetailTarget;
onOpenFile?: (path: string) => void;
wordWrap?: boolean;
};
function isCommitDetailTarget(value: unknown): value is CommitDetailTarget {
if (!value || typeof value !== "object") return false;
const target = value as {
source?: unknown;
sha?: unknown;
repo?: unknown;
workspaceId?: unknown;
owner?: unknown;
};
if (typeof target.sha !== "string" || target.sha.length === 0) return false;
if (target.source === "local") {
return target.repo === undefined || typeof target.repo === "string";
}
return (
target.source === "github" &&
typeof target.workspaceId === "string" &&
target.workspaceId.length > 0 &&
typeof target.owner === "string" &&
target.owner.length > 0 &&
typeof target.repo === "string" &&
target.repo.length > 0
);
}
function targetFromParams(params: Record<string, unknown>): CommitDetailTarget {
if (isCommitDetailTarget(params.target)) return params.target;
return {
source: "local",
sha: typeof params.commitSha === "string" ? params.commitSha : "",
...(typeof params.repo === "string" && params.repo ? { repo: params.repo } : {}),
};
}
function getInitials(name: string): string {
return name
.split(/\s+/)
@@ -42,21 +77,43 @@ function useSortedFileEntries(files: Record<string, FileInfo> | null): [string,
}, [files]);
}
function useActiveCommit(commitSha: string) {
function useActiveCommit(target: CommitDetailTarget) {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
return useMemo(() => commits.find((c) => c.commit_sha === commitSha), [commits, commitSha]);
return useMemo(() => {
if (target.source !== "local") return undefined;
return commits.find(
(c) =>
c.commit_sha === target.sha &&
(!target.repo || !c.repository_name || c.repository_name === target.repo),
);
}, [commits, target]);
}
function headerCommit(
target: CommitDetailTarget,
localCommit: ReturnType<typeof useActiveCommit>,
remoteCommit: ReturnType<typeof useCommitDetail>["commit"],
) {
if (target.source === "github" && remoteCommit) {
return {
author_name: remoteCommit.author_name || remoteCommit.author_login,
commit_message: remoteCommit.message,
committed_at: remoteCommit.author_date,
};
}
return localCommit;
}
/** Standalone commit diff viewer — no dockview dependencies. */
export const CommitDiffView = memo(function CommitDiffView({
sha: commitSha,
repo,
target,
onOpenFile,
wordWrap = DEFAULT_DIFF_WORD_WRAP,
}: CommitDiffViewProps) {
const commit = useActiveCommit(commitSha);
const { files, loading } = useCommitDiff(commitSha, repo);
const localCommit = useActiveCommit(target);
const { files, commit: remoteCommit, loading, error, refetch } = useCommitDetail(target);
const commit = headerCommit(target, localCommit, remoteCommit);
const fileEntries = useSortedFileEntries(files);
if (loading) {
@@ -67,16 +124,18 @@ export const CommitDiffView = memo(function CommitDiffView({
</div>
);
}
if (error) return <CommitDetailErrorState error={error} onRetry={refetch} />;
return (
<div className="overflow-y-auto">
<div className="p-3">{commit && <CommitHeader commit={commit} commitSha={commitSha} />}</div>
<div className="p-3">{commit && <CommitHeader commit={commit} commitSha={target.sha} />}</div>
<CommitFileList
fileEntries={fileEntries}
loading={loading}
onOpenFile={onOpenFile ?? (() => {})}
baseRef={`${commitSha}^`}
repo={repo}
onOpenFile={target.source === "local" ? onOpenFile : undefined}
baseRef={target.source === "local" ? `${target.sha}^` : undefined}
repo={target.source === "local" ? target.repo : undefined}
remote={target.source === "github"}
wordWrap={wordWrap}
/>
</div>
@@ -87,24 +146,24 @@ const CommitDetailPanel = memo(function CommitDetailPanel({
panelId,
params,
}: CommitDetailPanelProps) {
const commitSha = params.commitSha as string;
const repo = (params.repo as string | undefined) ?? undefined;
const target = targetFromParams(params);
const { openFile } = usePanelActions();
const commit = useActiveCommit(commitSha);
const { files, loading } = useCommitDiff(commitSha, repo);
const localCommit = useActiveCommit(target);
const { files, commit: remoteCommit, loading, error, refetch } = useCommitDetail(target);
const commit = headerCommit(target, localCommit, remoteCommit);
const fileEntries = useSortedFileEntries(files);
// Update tab title via dockview API stored in portal manager
useEffect(() => {
if (commit) {
const shortSha = commitSha.slice(0, 7);
const shortSha = target.sha.slice(0, 7);
const msg =
commit.commit_message.length > 30
? commit.commit_message.slice(0, 30) + "..."
: commit.commit_message;
setPanelTitle(panelId, `${shortSha} ${msg}`);
}
}, [commit, commitSha, panelId]);
}, [commit, panelId, target.sha]);
if (loading) {
return (
@@ -118,19 +177,29 @@ const CommitDetailPanel = memo(function CommitDetailPanel({
</PanelRoot>
);
}
if (error) {
return (
<PanelRoot>
<PanelBody>
<CommitDetailErrorState error={error} onRetry={refetch} />
</PanelBody>
</PanelRoot>
);
}
return (
<PanelRoot>
<PanelBody padding={false} scroll>
<div className="p-3">
{commit && <CommitHeader commit={commit} commitSha={commitSha} />}
{commit && <CommitHeader commit={commit} commitSha={target.sha} />}
</div>
<CommitFileList
fileEntries={fileEntries}
loading={loading}
onOpenFile={openFile}
baseRef={`${commitSha}^`}
repo={repo}
onOpenFile={target.source === "local" ? openFile : undefined}
baseRef={target.source === "local" ? `${target.sha}^` : undefined}
repo={target.source === "local" ? target.repo : undefined}
remote={target.source === "github"}
/>
</PanelBody>
</PanelRoot>
@@ -168,6 +237,35 @@ function CommitHeader({
);
}
function CommitDetailErrorState({
error,
onRetry,
}: {
error: string;
onRetry: () => Promise<void>;
}) {
const { t } = useTranslation();
return (
<div
role="alert"
className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-sm text-muted-foreground"
>
<IconAlertCircle className="h-5 w-5 text-destructive" />
<span>{error}</span>
<Button
type="button"
className="min-h-11 cursor-pointer"
variant="outline"
size="sm"
onClick={() => void onRetry()}
>
<IconRefresh className="h-4 w-4" />
{t("system:featureTogglesRetry")}
</Button>
</div>
);
}
/** List of file diffs in a commit */
function CommitFileList({
fileEntries,
@@ -175,13 +273,15 @@ function CommitFileList({
onOpenFile,
baseRef,
repo,
remote,
wordWrap,
}: {
fileEntries: [string, FileInfo][];
loading: boolean;
onOpenFile: (path: string) => void;
baseRef: string;
onOpenFile?: (path: string) => void;
baseRef?: string;
repo?: string;
remote: boolean;
wordWrap?: boolean;
}) {
if (fileEntries.length === 0 && !loading) {
@@ -200,9 +300,9 @@ function CommitFileList({
diff={file.diff}
status={file.status}
onOpenFile={onOpenFile}
enableExpansion={true}
enableExpansion={!remote}
baseRef={baseRef}
repo={repo}
repo={remote ? undefined : repo}
wordWrap={wordWrap}
/>
) : (
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PRCommitDetail } from "@/lib/types/github";
import type { FileInfo } from "@/lib/state/store";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
requestCommitDiff: vi.fn(),
}));
let websocketClient: { request: typeof mocks.request } | null = { request: mocks.request };
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => websocketClient,
}));
vi.mock("./commit-diff-request", () => ({
requestCommitDiff: mocks.requestCommitDiff,
}));
import { mapGitHubCommitFiles, requestCommitDetail } from "./commit-detail-request";
const WORKSPACE_ID = "workspace-1";
const remoteTarget = {
source: "github" as const,
sha: "abc1234567890",
workspaceId: WORKSPACE_ID,
owner: "acme",
repo: "widget",
};
const detail: PRCommitDetail = {
sha: "abc1234567890",
message: "remote commit message",
author_login: "octocat",
author_name: "Octo Cat",
author_date: "2026-08-04T12:00:00Z",
additions: 3,
deletions: 1,
files_changed: 2,
files: [
{
filename: "src/new.ts",
status: "added",
additions: 3,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+new",
},
{
filename: "assets/logo.bin",
status: "modified",
additions: 0,
deletions: 1,
patch: "",
},
],
};
afterEach(() => {
mocks.request.mockReset();
mocks.requestCommitDiff.mockReset();
websocketClient = { request: mocks.request };
});
describe("requestCommitDetail", () => {
it("uses the workspace GitHub action for a remote target", async () => {
mocks.request.mockResolvedValue({ commit: detail });
const result = await requestCommitDetail({
target: {
source: "github",
sha: detail.sha,
workspaceId: WORKSPACE_ID,
owner: "acme",
repo: "widget",
},
local: {
sessionId: "session-1",
taskId: "task-1",
agentctlReady: true,
},
});
expect(result).toMatchObject({ source: "github", success: true, commit: detail });
expect(mocks.request).toHaveBeenCalledWith(
"github.pr_commit.get",
{
workspace_id: WORKSPACE_ID,
owner: "acme",
repo: "widget",
sha: detail.sha,
},
10000,
);
expect(mocks.requestCommitDiff).not.toHaveBeenCalled();
});
it("uses the local session action only for a local target", async () => {
const files: Record<string, FileInfo> = {
"src/local.ts": {
path: "src/local.ts",
status: "modified",
staged: false,
diff: "@@ -1 +1 @@",
},
};
mocks.requestCommitDiff.mockResolvedValue({ success: true, files });
const result = await requestCommitDetail({
target: { source: "local", sha: "local123", repo: "frontend" },
local: {
sessionId: "session-1",
taskId: "task-1",
agentctlReady: true,
},
});
expect(result).toEqual({ source: "local", success: true, files });
expect(mocks.requestCommitDiff).toHaveBeenCalledWith({
sessionId: "session-1",
taskId: "task-1",
commitSha: "local123",
agentctlReady: true,
repo: "frontend",
});
expect(mocks.request).not.toHaveBeenCalled();
});
it("does not fall back to the local action when GitHub fails", async () => {
mocks.request.mockRejectedValue(new Error("GitHub unavailable"));
await expect(
requestCommitDetail({
target: remoteTarget,
local: {
sessionId: "session-1",
taskId: "task-1",
agentctlReady: true,
},
}),
).rejects.toThrow("GitHub unavailable");
expect(mocks.requestCommitDiff).not.toHaveBeenCalled();
});
it("rejects when the WebSocket client is unavailable", async () => {
websocketClient = null;
await expect(
requestCommitDetail({
target: remoteTarget,
}),
).rejects.toThrow();
});
it("rejects a GitHub response without a commit payload", async () => {
mocks.request.mockResolvedValue({});
await expect(
requestCommitDetail({
target: remoteTarget,
}),
).rejects.toThrow();
});
it("rejects a present but incomplete GitHub commit payload", async () => {
mocks.request.mockResolvedValue({ commit: { files: [] } });
await expect(
requestCommitDetail({
target: remoteTarget,
}),
).rejects.toThrow();
});
});
describe("mapGitHubCommitFiles", () => {
it("maps patch and patchless files without introducing a local base ref", () => {
expect(mapGitHubCommitFiles(detail)).toEqual({
"src/new.ts": {
path: "src/new.ts",
status: "added",
staged: false,
additions: 3,
deletions: 0,
diff: "@@ -0,0 +1 @@\n+new",
},
"assets/logo.bin": {
path: "assets/logo.bin",
status: "modified",
staged: false,
additions: 0,
deletions: 1,
diff: "",
},
});
});
});
@@ -0,0 +1,131 @@
"use client";
import type { FileInfo } from "@/lib/state/store";
import type { PRCommitDetail } from "@/lib/types/github";
import { normalizeFileChangeStatus } from "@/lib/utils/file-change-status";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { CommitDetailTarget } from "./changes-diff-target";
import { requestCommitDiff, type CommitDiffResponse } from "./commit-diff-request";
export type CommitDetailRequestResult =
| ({ source: "local" } & CommitDiffResponse)
| {
source: "github";
success: boolean;
commit?: PRCommitDetail;
files?: Record<string, FileInfo>;
};
export class CommitDetailProtocolError extends Error {
constructor(reason: "client_unavailable" | "invalid_response") {
super(reason);
this.name = "CommitDetailProtocolError";
}
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
function isInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value);
}
function isOptionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
}
function isCompleteGitHubFile(value: unknown): value is PRCommitDetail["files"][number] {
if (!value || typeof value !== "object") return false;
const file = value as Record<string, unknown>;
return (
isNonEmptyString(file.filename) &&
isNonEmptyString(file.status) &&
isInteger(file.additions) &&
isInteger(file.deletions) &&
isOptionalString(file.old_path) &&
isOptionalString(file.patch)
);
}
function isCompleteGitHubCommitDetail(value: unknown): value is PRCommitDetail {
if (!value || typeof value !== "object") return false;
const commit = value as Record<string, unknown>;
return (
isNonEmptyString(commit.sha) &&
typeof commit.message === "string" &&
typeof commit.author_login === "string" &&
isNonEmptyString(commit.author_name) &&
isNonEmptyString(commit.author_date) &&
isInteger(commit.additions) &&
isInteger(commit.deletions) &&
isInteger(commit.files_changed) &&
Array.isArray(commit.files) &&
commit.files.every(isCompleteGitHubFile)
);
}
function mapGitHubFile(file: PRCommitDetail["files"][number]): FileInfo {
return {
path: file.filename,
status: normalizeFileChangeStatus(file.status),
staged: false,
additions: file.additions,
deletions: file.deletions,
old_path: file.old_path,
diff: file.patch ?? "",
};
}
export function mapGitHubCommitFiles(detail: PRCommitDetail): Record<string, FileInfo> {
return Object.fromEntries(detail.files.map((file) => [file.filename, mapGitHubFile(file)]));
}
async function requestGitHubCommitDetail(
target: Extract<CommitDetailTarget, { source: "github" }>,
): Promise<CommitDetailRequestResult> {
const ws = getWebSocketClient();
if (!ws) throw new CommitDetailProtocolError("client_unavailable");
const response = await ws.request<{ commit?: PRCommitDetail }>(
"github.pr_commit.get",
{
workspace_id: target.workspaceId,
owner: target.owner,
repo: target.repo,
sha: target.sha,
},
10000,
);
if (!isCompleteGitHubCommitDetail(response?.commit)) {
throw new CommitDetailProtocolError("invalid_response");
}
return {
source: "github",
success: true,
commit: response.commit,
files: mapGitHubCommitFiles(response.commit),
};
}
export async function requestCommitDetail(params: {
target: CommitDetailTarget;
local?: {
sessionId: string;
taskId: string | null;
agentctlReady: boolean;
};
}): Promise<CommitDetailRequestResult> {
if (params.target.source === "github") {
return requestGitHubCommitDetail(params.target);
}
if (!params.local) throw new CommitDetailProtocolError("client_unavailable");
const response = await requestCommitDiff({
...params.local,
commitSha: params.target.sha,
repo: params.target.repo,
});
if (!response) throw new CommitDetailProtocolError("client_unavailable");
return { source: "local", ...response };
}
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { CommitRow, type CommitItem } from "./commit-row";
vi.mock("@kandev/ui/context-menu", () => ({
ContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ContextMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ContextMenuItem: ({
children,
onSelect,
}: {
children: React.ReactNode;
onSelect: () => void;
}) => (
<button type="button" onClick={onSelect}>
{children}
</button>
),
ContextMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@kandev/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
afterEach(cleanup);
function remoteCommit(): CommitItem {
return {
commit_sha: "remote123456",
commit_message: "Remote contribution",
insertions: 0,
deletions: 0,
pushed: true,
statsAvailable: false,
detailTarget: {
source: "github",
sha: "remote123456",
workspaceId: "workspace-1",
owner: "acme",
repo: "widget",
},
};
}
describe("CommitRow", () => {
it("hides unknown statistics and local mutation actions for remote commits", () => {
render(
<CommitRow
commit={remoteCommit()}
isLatest
onAmendCommit={vi.fn()}
onRevertCommit={vi.fn()}
onResetToCommit={vi.fn()}
/>,
);
expect(screen.getByText("Remote contribution")).toBeTruthy();
expect(screen.queryByText("+0")).toBeNull();
expect(screen.queryByText("-0")).toBeNull();
expect(screen.queryByText("Amend message")).toBeNull();
expect(screen.queryByText("Revert commit")).toBeNull();
expect(screen.queryByText("Reset to this commit")).toBeNull();
});
it("passes the full source target when a remote row is opened", () => {
const onOpenCommitDetail = vi.fn();
const commit = remoteCommit();
render(<CommitRow commit={commit} isLatest onOpenCommitDetail={onOpenCommitDetail} />);
fireEvent.click(screen.getByTestId("commit-row-remote1"));
expect(onOpenCommitDetail).toHaveBeenCalledWith(commit.detailTarget);
});
});
+19 -11
View File
@@ -16,6 +16,7 @@ import {
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
import { timeAgo } from "@/lib/utils/time";
import type { CommitDetailTarget } from "./changes-diff-target";
export type CommitItem = {
commit_sha: string;
@@ -23,6 +24,8 @@ export type CommitItem = {
insertions: number;
deletions: number;
pushed?: boolean;
statsAvailable: boolean;
detailTarget: CommitDetailTarget;
/** Multi-repo: name of the repo this commit was made in. Empty for single-repo. */
repository_name?: string;
committed_at?: string;
@@ -47,7 +50,8 @@ function CommitContextMenu({
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
}) {
const hasActions = onAmendCommit || onRevertCommit || onResetToCommit;
const hasActions =
commit.detailTarget.source === "local" && (onAmendCommit || onRevertCommit || onResetToCommit);
if (!hasActions) {
return <>{children}</>;
@@ -177,7 +181,7 @@ export function CommitRow({
// Multi-repo: opening the diff for a non-primary repo's commit needs the
// repo subpath, otherwise the agentctl looks up the SHA at the workspace
// root and finds nothing (each repo has its own commit graph).
onOpenCommitDetail?: (sha: string, repo?: string) => void;
onOpenCommitDetail?: (target: CommitDetailTarget) => void;
// Multi-repo: handlers receive the commit's repository_name so the
// amend/revert/reset op runs in the right git repo. Without it, ops hit
// the workspace root which fails on multi-repo task workspaces.
@@ -185,7 +189,9 @@ export function CommitRow({
onRevertCommit?: (sha: string, repo?: string) => void;
onResetToCommit?: (sha: string, repo?: string) => void;
}) {
const showActions = onResetToCommit || (isLatest && (onAmendCommit || onRevertCommit));
const isLocalCommit = commit.detailTarget.source === "local";
const showActions =
isLocalCommit && (onResetToCommit || (isLatest && (onAmendCommit || onRevertCommit)));
return (
<CommitContextMenu
@@ -200,11 +206,11 @@ export function CommitRow({
tabIndex={0}
data-testid={`commit-row-${commit.commit_sha.slice(0, 7)}`}
className="group relative flex items-center gap-2 text-xs rounded-md px-1 py-1 -mx-1 hover:bg-muted/60 cursor-pointer"
onClick={() => onOpenCommitDetail?.(commit.commit_sha, commit.repository_name)}
onClick={() => onOpenCommitDetail?.(commit.detailTarget)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenCommitDetail?.(commit.commit_sha, commit.repository_name);
onOpenCommitDetail?.(commit.detailTarget);
}
}}
>
@@ -225,12 +231,14 @@ export function CommitRow({
{commit.commit_sha.slice(0, 7)}
</code>
<span className="flex-1 min-w-0 truncate text-foreground">{commit.commit_message}</span>
<span
className={`shrink-0 text-[11px] flex items-center gap-1 mr-1 ${commit.committed_at ? "group-hover:hidden" : ""}`}
>
<span className="text-emerald-500">+{commit.insertions}</span>{" "}
<span className="text-rose-500">-{commit.deletions}</span>
</span>
{commit.statsAvailable && (
<span
className={`shrink-0 text-[11px] flex items-center gap-1 mr-1 ${commit.committed_at ? "group-hover:hidden" : ""}`}
>
<span className="text-emerald-500">+{commit.insertions}</span>{" "}
<span className="text-rose-500">-{commit.deletions}</span>
</span>
)}
{commit.committed_at && (
<span className="hidden group-hover:flex shrink-0 text-[11px] items-center gap-1 mr-1 text-muted-foreground">
{timeAgo(commit.committed_at)}
@@ -13,7 +13,7 @@ import { t } from "@/lib/i18n";
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { BrowserPanel } from "./browser-panel";
import type { OpenDiffOptions } from "./changes-diff-target";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
import { ChangesPanel } from "./changes-panel";
import { CommitDetailPanel } from "./commit-detail-panel";
import { FileEditorPanel } from "./file-editor-panel";
@@ -154,7 +154,7 @@ function ChangesContent({ panelId }: { panelId: string }) {
[addFileDiffPanel],
);
const handleOpenCommitDetail = useCallback(
(sha: string, repo?: string) => addCommitDetailPanel(sha, { repo }),
(target: CommitDetailTarget) => addCommitDetailPanel(target),
[addCommitDetailPanel],
);
const handleOpenDiffAll = useCallback(() => addDiffViewerPanel(), [addDiffViewerPanel]);
+2 -2
View File
@@ -35,7 +35,7 @@ import { TerminalPanel } from "./terminal-panel";
import { BrowserPanel } from "./browser-panel";
import { VscodePanel } from "./vscode-panel";
import { CommitDetailPanel } from "./commit-detail-panel";
import type { OpenDiffOptions } from "./changes-diff-target";
import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target";
import { ReviewDetailPanelComponent } from "./review-detail-panel";
import { MRDetailPanelComponent } from "@/components/gitlab/mr-detail-panel";
import { PluginTaskPanel } from "./plugin-task-panel";
@@ -332,7 +332,7 @@ function ChangesContent({ panelId }: { panelId: string }) {
[addFileDiffPanel],
);
const handleOpenCommitDetail = useCallback(
(sha: string, repo?: string) => addCommitDetailPanel(sha, { repo }),
(target: CommitDetailTarget) => addCommitDetailPanel(target),
[addCommitDetailPanel],
);
const handleOpenDiffAll = useCallback(() => addDiffViewerPanel(), [addDiffViewerPanel]);
@@ -86,8 +86,8 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({
const bodyProps = buildChangesPanelBodyProps(data, {
onOpenDiffFile: handleOpenDiffFile,
onEditFile: onOpenFile ?? (() => {}),
onOpenCommitDetail: (sha, repo) => {
setDiffSheet({ kind: "commit", sha, repo });
onOpenCommitDetail: (target) => {
setDiffSheet({ kind: "commit", target });
},
onOpenReview: handleOpenReview,
});
@@ -161,7 +161,7 @@ function renderPanel(
): React.ReactNode {
if (!mode) return null;
if (mode.kind === "commit") {
return <CommitDiffView sha={mode.sha} repo={mode.repo} onOpenFile={onOpenFile} wordWrap />;
return <CommitDiffView target={mode.target} onOpenFile={onOpenFile} wordWrap />;
}
const panelMode = mode.kind;
const filePath = mode.kind === "file" ? mode.path : undefined;
+28 -1
View File
@@ -10,7 +10,11 @@ import type {
} from "../../lib/types/http";
import type { Agent, AgentProfile } from "../../lib/types/http-agents";
import { normalizeAgentProfile } from "../../lib/api/domains/agent-profile-normalize";
import type { TaskCIAutomationOptions, TaskCIAutomationPatch } from "../../lib/types/github";
import type {
PRCommitDetail,
TaskCIAutomationOptions,
TaskCIAutomationPatch,
} from "../../lib/types/github";
import type { VoiceModeSettings } from "../../lib/types/http-voice";
import type { TaskStatusSummary } from "../../lib/types/task-status-summary";
import type {
@@ -1319,6 +1323,7 @@ export class ApiClient {
message: string;
author_login: string;
author_date: string;
stats_available?: boolean;
}>,
): Promise<void> {
await this.request("POST", "/api/v1/github/mock/commits", {
@@ -1339,6 +1344,28 @@ export class ApiClient {
});
}
async mockGitHubAddPRCommitDetail(
owner: string,
repo: string,
sha: string,
detail: Omit<PRCommitDetail, "sha"> & { sha?: string },
): Promise<void> {
await this.request("POST", "/api/v1/github/mock/commit-details", {
owner,
repo,
sha,
detail: { ...detail, sha: detail.sha ?? sha },
});
await this.seedMockGitHubRepositoryAccess([{ repo_owner: owner, repo_name: repo }]);
const workspaceId = await this.activeWorkspaceId();
if (!workspaceId) return;
await this.mockGitHubSetWorkspaceConnection(workspaceId, {
source: "legacy_shared",
status: "active",
});
}
async mockGitHubAddBranches(
owner: string,
repo: string,
@@ -401,6 +401,105 @@ test.describe("Git Changes Panel", () => {
await expect(testPage.getByText("line 1")).toBeVisible({ timeout: 5_000 });
});
test("PR-only commit uses GitHub details when local history is stale", async ({
testPage,
apiClient,
seedData,
}) => {
const profile = await createStandardProfile(apiClient, "Git PR-only Detail Profile");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Git PR-only Detail Test",
profile.id,
{
description: "Testing PR-only commit details after a force-push",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const remoteSha = "d".repeat(40);
const remoteMessage = "Force-pushed remote commit";
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("remote-author");
await apiClient.mockGitHubAddPRs([
{
number: 2253,
title: "Force-pushed PR",
state: "open",
head_branch: "feature/stale-worktree",
base_branch: "main",
author_login: "remote-author",
repo_owner: "testorg",
repo_name: "testrepo",
},
]);
await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 2253, [
{
sha: remoteSha,
message: remoteMessage,
author_login: "remote-author",
author_date: "2026-08-04T12:00:00Z",
stats_available: false,
},
]);
await apiClient.mockGitHubAddPRCommitDetail("testorg", "testrepo", remoteSha, {
message: remoteMessage,
author_login: "remote-author",
author_name: "Remote Author",
author_date: "2026-08-04T12:00:00Z",
additions: 1,
deletions: 0,
files_changed: 1,
files: [
{
filename: "pr-only-marker.ts",
status: "added",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+PR_ONLY_REMOTE_MARKER",
},
],
});
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 2253,
pr_url: "https://github.com/testorg/testrepo/pull/2253",
pr_title: "Force-pushed PR",
head_branch: "feature/stale-worktree",
base_branch: "main",
author_login: "remote-author",
});
const session = await openTaskSession(testPage, "Git PR-only Detail Test");
await session.clickTab("Changes");
await expect(session.changes).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByTestId("commits-section")).toBeVisible({ timeout: 20_000 });
await session.expandCommitsSection();
const row = testPage.getByTestId(`commit-row-${remoteSha.slice(0, 7)}`);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.getByText("+0", { exact: true })).toHaveCount(0);
await expect(row.getByText("-0", { exact: true })).toHaveCount(0);
await row.hover();
await expect(row.getByRole("button")).toHaveCount(0);
await row.click();
await expect(testPage.getByText(remoteMessage).last()).toBeVisible({ timeout: 15_000 });
await expect(testPage.getByText("Remote Author")).toBeVisible({ timeout: 10_000 });
await testPage.waitForFunction(
(marker: string) =>
Array.from(document.querySelectorAll("diffs-container")).some((container) =>
container.shadowRoot?.textContent?.includes(marker),
),
"PR_ONLY_REMOTE_MARKER",
{ timeout: 60_000 },
);
});
/**
* Verifies that reverting a commit undoes it (soft reset).
* Note: The "Revert commit" action does `git reset --soft HEAD~1`,
@@ -254,6 +254,106 @@ test.describe("Mobile changes panel", () => {
await testPage.getByTestId("mobile-diff-sheet-close").tap();
});
test("PR-only commit opens the remote commit sheet when local history is stale", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile PR-only Commit Detail",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
const remoteSha = "e".repeat(40);
const remoteMessage = "Mobile force-pushed commit";
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("mobile-remote-author");
await apiClient.mockGitHubAddPRs([
{
number: 2254,
title: "Mobile force-pushed PR",
state: "open",
head_branch: "feature/mobile-stale",
base_branch: "main",
author_login: "mobile-remote-author",
repo_owner: "testorg",
repo_name: "testrepo",
},
]);
await apiClient.mockGitHubAddPRCommits("testorg", "testrepo", 2254, [
{
sha: remoteSha,
message: remoteMessage,
author_login: "mobile-remote-author",
author_date: "2026-08-04T12:00:00Z",
stats_available: false,
},
]);
await apiClient.mockGitHubAddPRCommitDetail("testorg", "testrepo", remoteSha, {
message: remoteMessage,
author_login: "mobile-remote-author",
author_name: "Mobile Remote Author",
author_date: "2026-08-04T12:00:00Z",
additions: 1,
deletions: 0,
files_changed: 1,
files: [
{
filename: "mobile-pr-only.ts",
status: "added",
additions: 1,
deletions: 0,
patch: "@@ -0,0 +1 @@\n+MOBILE_PR_ONLY_REMOTE_MARKER",
},
],
});
await apiClient.mockGitHubAssociateTaskPR({
task_id: task.id,
owner: "testorg",
repo: "testrepo",
pr_number: 2254,
pr_url: "https://github.com/testorg/testrepo/pull/2254",
pr_title: "Mobile force-pushed PR",
head_branch: "feature/mobile-stale",
base_branch: "main",
author_login: "mobile-remote-author",
});
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 45_000 });
await openMobileChangesPanel(testPage);
await expandSection(testPage, "commits-section");
const row = testPage.getByTestId(`commit-row-${remoteSha.slice(0, 7)}`);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect(row.getByText("+0", { exact: true })).toHaveCount(0);
await expect(row.getByText("-0", { exact: true })).toHaveCount(0);
await row.tap();
await expect(testPage.getByText("Commit Changes")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByLabel("Commit Changes").getByText(remoteMessage)).toBeVisible({
timeout: 15_000,
});
await expect(testPage.getByText("Mobile Remote Author")).toBeVisible({ timeout: 10_000 });
await expectDiffText(testPage, "MOBILE_PR_ONLY_REMOTE_MARKER");
await expect(testPage.getByTestId("mobile-diff-sheet-close")).toBeVisible();
await expect(testPage.getByTestId("mobile-diff-sheet-close")).toHaveCount(1);
await expect(testPage.locator("body")).toHaveCSS("overflow-x", "hidden");
await testPage.getByTestId("mobile-diff-sheet-close").tap();
await expect(testPage.getByTestId("mobile-diff-sheet-close")).not.toBeVisible({
timeout: 10_000,
});
});
test("tapping PR file row shows PR diff when same file also has local changes", async ({
testPage,
apiClient,
@@ -0,0 +1,93 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import type { PRCommitInfo } from "@/lib/types/github";
import { resolvePRCommitsView, usePRCommits, type KeyedPRCommitsState } from "./use-pr-commits";
const requestMock = vi.hoisted(() => vi.fn());
let websocketClient: { request: typeof requestMock } | null = { request: requestMock };
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => websocketClient,
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
beforeEach(() => {
requestMock.mockReset();
websocketClient = { request: requestMock };
});
function wrapper({ children }: { children: ReactNode }) {
const initialState = {
workspaces: { activeId: "workspace-1" },
} as unknown as Partial<AppState>;
return createElement(StateProvider, { initialState, children });
}
function commit(sha: string): PRCommitInfo {
return {
sha,
message: sha,
author_login: "octocat",
author_date: "2026-08-04T12:00:00Z",
additions: 1,
deletions: 0,
files_changed: 1,
stats_available: false,
};
}
describe("usePRCommits request ownership", () => {
it("masks state unless the complete workspace and PR source key matches", () => {
const staleState: KeyedPRCommitsState = {
sourceKey: "workspace-1/acme/app/1/old-refresh",
commits: [commit("first-sha")],
loading: false,
error: null,
};
expect(resolvePRCommitsView(staleState, "workspace-1/acme/other-app/2/new-refresh")).toEqual({
commits: [],
loading: true,
error: null,
});
});
it("masks the previous PR while a rapid switch is loading", async () => {
const first = deferred<{ commits: PRCommitInfo[] }>();
const second = deferred<{ commits: PRCommitInfo[] }>();
requestMock.mockImplementation((_action: string, payload: { number: number }) =>
payload.number === 1 ? first.promise : second.promise,
);
const { result, rerender } = renderHook(
({ number }) => usePRCommits("acme", "app", number, "synced"),
{ initialProps: { number: 1 }, wrapper },
);
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
rerender({ number: 2 });
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2));
await act(async () => {
second.resolve({ commits: [commit("second-sha")] });
await second.promise;
});
await waitFor(() => expect(result.current.commits[0]?.sha).toBe("second-sha"));
await act(async () => {
first.resolve({ commits: [commit("late-first-sha")] });
await first.promise;
});
expect(result.current.commits[0]?.sha).toBe("second-sha");
});
});
+43 -29
View File
@@ -10,25 +10,43 @@ type PRCommitsState = {
loading: boolean;
error: string | null;
};
type WorkspacePRCommitsState = { workspaceId: string | null; state: PRCommitsState };
export type KeyedPRCommitsState = PRCommitsState & { sourceKey: string };
const INITIAL_STATE: PRCommitsState = {
const INITIAL_STATE: KeyedPRCommitsState = {
sourceKey: "",
commits: [],
loading: false,
error: null,
};
export function resolvePRCommitsView(
state: KeyedPRCommitsState,
requestedKey: string,
): PRCommitsState {
if (state.sourceKey === requestedKey) {
return { commits: state.commits, loading: state.loading, error: state.error };
}
return { commits: [], loading: requestedKey !== "", error: null };
}
type PRCommitsRequest = {
workspaceId: string;
owner: string;
repo: string;
prNumber: number;
sourceKey: string;
};
async function fetchPRCommits(
workspaceId: string,
owner: string,
repo: string,
prNumber: number,
setState: (s: PRCommitsState) => void,
{ workspaceId, owner, repo, prNumber, sourceKey }: PRCommitsRequest,
setState: (s: KeyedPRCommitsState) => void,
) {
const client = getWebSocketClient();
if (!client) return;
setState({ commits: [], loading: true, error: null });
setState({ sourceKey, commits: [], loading: true, error: null });
if (!client) {
setState({ sourceKey, commits: [], loading: false, error: null });
return;
}
try {
const response = await client.request<{ commits?: PRCommitInfo[] }>("github.pr_commits.get", {
workspace_id: workspaceId,
@@ -36,9 +54,10 @@ async function fetchPRCommits(
repo,
number: prNumber,
});
setState({ commits: response?.commits ?? [], loading: false, error: null });
setState({ sourceKey, commits: response?.commits ?? [], loading: false, error: null });
} catch (err) {
setState({
sourceKey,
commits: [],
loading: false,
error: err instanceof Error ? err.message : "Failed to fetch PR commits",
@@ -57,41 +76,36 @@ export function usePRCommits(
refreshKey?: string | null,
) {
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const [result, setResult] = useState<WorkspacePRCommitsState>({
workspaceId: null,
state: INITIAL_STATE,
});
const hasParams = !!workspaceId && !!owner && !!repo && !!prNumber;
const sourceKey = hasParams
? `${workspaceId}/${owner}/${repo}/${prNumber}/${refreshKey ?? ""}`
: "";
const [state, setState] = useState<KeyedPRCommitsState>(INITIAL_STATE);
const paramsKeyRef = useRef<string>("");
const requestIdRef = useRef(0);
const refresh = useCallback(() => {
if (!workspaceId || !owner || !repo || !prNumber) return;
const requestId = ++requestIdRef.current;
void fetchPRCommits(workspaceId, owner, repo, prNumber, (next) => {
void fetchPRCommits({ workspaceId, owner, repo, prNumber, sourceKey }, (next) => {
if (requestId !== requestIdRef.current) return;
setResult({ workspaceId, state: next });
setState(next);
});
}, [workspaceId, owner, repo, prNumber]);
}, [workspaceId, owner, repo, prNumber, sourceKey]);
useEffect(() => {
const key = hasParams ? `${workspaceId}/${owner}/${repo}/${prNumber}/${refreshKey ?? ""}` : "";
if (key === paramsKeyRef.current) return;
paramsKeyRef.current = key;
if (sourceKey === paramsKeyRef.current) return;
paramsKeyRef.current = sourceKey;
if (!workspaceId || !owner || !repo || !prNumber) {
requestIdRef.current++; // invalidate in-flight responses
return;
}
const requestId = ++requestIdRef.current;
void fetchPRCommits(workspaceId, owner, repo, prNumber, (next) => {
void fetchPRCommits({ workspaceId, owner, repo, prNumber, sourceKey }, (next) => {
if (requestId !== requestIdRef.current) return;
setResult({ workspaceId, state: next });
setState(next);
});
}, [workspaceId, owner, repo, prNumber, hasParams, refreshKey]);
}, [workspaceId, owner, repo, prNumber, sourceKey]);
// Return initial state when params are null to clear stale data
if (!hasParams || result.workspaceId !== workspaceId) {
return { ...INITIAL_STATE, refresh };
}
return { ...result.state, refresh };
return { ...resolvePRCommitsView(state, sourceKey), refresh };
}
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { FileInfo } from "@/lib/state/store";
const mocks = vi.hoisted(() => ({
requestCommitDetail: vi.fn(),
toast: vi.fn(),
state: {
tasks: { activeSessionId: "session-1", activeTaskId: "task-1" },
taskSessions: { items: { "session-1": { task_id: "task-1" } } },
sessionAgentctl: { itemsBySessionId: { "session-1": { status: "ready" } } },
},
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: unknown) => unknown) => selector(mocks.state),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/task/commit-detail-request", () => ({
CommitDetailProtocolError: class CommitDetailProtocolError extends Error {},
requestCommitDetail: mocks.requestCommitDetail,
}));
import { useCommitDetail } from "./use-commit-detail";
const remoteFiles: Record<string, FileInfo> = {
"src/remote.ts": {
path: "src/remote.ts",
status: "modified",
staged: false,
diff: "remote patch",
},
};
const remoteTarget = {
source: "github" as const,
sha: "remote123",
workspaceId: "workspace-1",
owner: "acme",
repo: "widget",
};
const nextRemoteTarget = { ...remoteTarget, sha: "remote456" };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
afterEach(() => {
cleanup();
mocks.requestCommitDetail.mockReset();
mocks.toast.mockReset();
mocks.state.sessionAgentctl.itemsBySessionId["session-1"].status = "ready";
});
describe("useCommitDetail", () => {
it("requests a GitHub target without adding local session routing", async () => {
mocks.requestCommitDetail.mockResolvedValue({
source: "github",
success: true,
files: remoteFiles,
commit: {
sha: "remote123",
message: "remote commit",
author_login: "octocat",
author_name: "Octo Cat",
author_date: "2026-08-04T12:00:00Z",
additions: 1,
deletions: 0,
files_changed: 1,
files: [],
},
});
const { result } = renderHook(() => useCommitDetail(remoteTarget));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mocks.requestCommitDetail).toHaveBeenCalledWith({ target: remoteTarget });
expect(result.current.files).toEqual(remoteFiles);
expect(result.current.commit?.message).toBe("remote commit");
});
it("passes local session readiness only for a local target", async () => {
mocks.requestCommitDetail.mockResolvedValue({
source: "local",
success: true,
files: remoteFiles,
});
const target = { source: "local" as const, sha: "local123", repo: "frontend" };
const { result } = renderHook(() => useCommitDetail(target));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mocks.requestCommitDetail).toHaveBeenCalledWith({
target,
local: {
sessionId: "session-1",
taskId: "task-1",
agentctlReady: true,
},
});
});
it("keeps a GitHub error on the remote path and exposes it to the panel", async () => {
mocks.requestCommitDetail.mockRejectedValue(new Error("GitHub unavailable"));
const { result } = renderHook(() => useCommitDetail(remoteTarget));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.files).toBeNull();
expect(result.current.error).toBe("GitHub unavailable");
expect(mocks.toast).toHaveBeenCalled();
expect(mocks.requestCommitDetail).toHaveBeenCalledTimes(1);
});
it("turns a malformed remote response into a persistent error", async () => {
mocks.requestCommitDetail.mockResolvedValue({ source: "github", success: false });
const { result } = renderHook(() => useCommitDetail(remoteTarget));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe("unexpectedResponseFromTheServer");
expect(result.current.files).toBeNull();
expect(mocks.toast).toHaveBeenCalled();
});
it("does not refetch a GitHub target when local readiness changes", async () => {
mocks.requestCommitDetail.mockResolvedValue({
source: "github",
success: true,
files: remoteFiles,
});
const { result, rerender } = renderHook(() => useCommitDetail(remoteTarget));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mocks.requestCommitDetail).toHaveBeenCalledTimes(1);
mocks.state.sessionAgentctl.itemsBySessionId["session-1"].status = "starting";
rerender();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mocks.requestCommitDetail).toHaveBeenCalledTimes(1);
});
it("refetches the same target on demand", async () => {
mocks.requestCommitDetail.mockResolvedValue({ source: "github", success: false });
const { result } = renderHook(() => useCommitDetail(remoteTarget));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.refetch();
});
expect(mocks.requestCommitDetail).toHaveBeenCalledTimes(2);
});
});
describe("useCommitDetail target switching", () => {
it("hides the previous detail while switching targets", async () => {
const first = deferred<{
source: "github";
success: true;
files: Record<string, FileInfo>;
commit: { sha: string; message: string };
}>();
const second = deferred<{
source: "github";
success: true;
files: Record<string, FileInfo>;
commit: { sha: string; message: string };
}>();
mocks.requestCommitDetail.mockImplementation(({ target }: { target: { sha: string } }) =>
target.sha === remoteTarget.sha ? first.promise : second.promise,
);
const { result, rerender } = renderHook(({ target }) => useCommitDetail(target), {
initialProps: { target: remoteTarget },
});
await waitFor(() => expect(mocks.requestCommitDetail).toHaveBeenCalledTimes(1));
await act(async () => {
first.resolve({
source: "github",
success: true,
files: remoteFiles,
commit: { sha: remoteTarget.sha, message: "first commit" },
});
await first.promise;
});
await waitFor(() => expect(result.current.commit?.message).toBe("first commit"));
rerender({ target: nextRemoteTarget });
expect(result.current.files).toBeNull();
expect(result.current.commit).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.loading).toBe(true);
});
});
@@ -0,0 +1,135 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import type { PRCommitDetail } from "@/lib/types/github";
import type { FileInfo } from "@/lib/state/store";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import {
CommitDetailProtocolError,
requestCommitDetail,
} from "@/components/task/commit-detail-request";
export type UseCommitDetailResult = {
files: Record<string, FileInfo> | null;
commit: PRCommitDetail | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
};
function targetKey(target: CommitDetailTarget): string {
if (target.source === "local") return `local:${target.repo ?? ""}:${target.sha}`;
return `github:${target.workspaceId}:${target.owner}/${target.repo}:${target.sha}`;
}
function buildLocalRequest(
target: CommitDetailTarget,
activeSessionId: string | null,
sessionTaskId: string | undefined,
activeTaskId: string | null,
agentctlReady: boolean,
) {
if (target.source !== "local" || !activeSessionId) return undefined;
return {
sessionId: activeSessionId,
taskId: sessionTaskId ?? activeTaskId ?? null,
agentctlReady,
};
}
function detailErrorMessage(error: unknown, unexpectedError: string): string {
if (error instanceof CommitDetailProtocolError) return unexpectedError;
if (error instanceof Error) return error.message;
return unexpectedError;
}
/**
* Loads commit details from the source encoded in the target. GitHub targets
* never use the local session/worktree request, including after an error.
*/
export function useCommitDetail(target: CommitDetailTarget): UseCommitDetailResult {
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const sessionTaskId = useAppStore((state) =>
activeSessionId ? state.taskSessions.items[activeSessionId]?.task_id : undefined,
);
const agentctlReady = useAppStore((state) =>
activeSessionId
? state.sessionAgentctl.itemsBySessionId[activeSessionId]?.status === "ready"
: false,
);
const { toast } = useToast();
const { t } = useTranslation();
const unexpectedError = t("unexpectedResponseFromTheServer", { ns: "github" });
const requestFailed = t("requestFailed");
const [files, setFiles] = useState<Record<string, FileInfo> | null>(null);
const [commit, setCommit] = useState<PRCommitDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const requestSeqRef = useRef(0);
const key = targetKey(target);
const stableTarget = useMemo(() => target, [key]);
const localRequest = useMemo(
() =>
stableTarget.source === "local"
? buildLocalRequest(
stableTarget,
activeSessionId,
sessionTaskId,
activeTaskId,
agentctlReady,
)
: undefined,
[stableTarget, activeSessionId, sessionTaskId, activeTaskId, agentctlReady],
);
const fetchDetail = useCallback(async () => {
const requestSeq = ++requestSeqRef.current;
setLoading(true);
setError(null);
try {
const response = await requestCommitDetail({
target: stableTarget,
...(localRequest ? { local: localRequest } : {}),
});
if (requestSeq !== requestSeqRef.current) return;
if (stableTarget.source === "github" && !response.success) {
throw new CommitDetailProtocolError("invalid_response");
}
setLoadedKey(key);
setFiles(response.success && response.files ? response.files : null);
setCommit(response.source === "github" ? (response.commit ?? null) : null);
} catch (err) {
if (requestSeq !== requestSeqRef.current) return;
const message = detailErrorMessage(err, unexpectedError);
setLoadedKey(key);
setFiles(null);
setCommit(null);
setError(message);
toast({
title: requestFailed,
description: message,
variant: "error",
});
} finally {
if (requestSeq === requestSeqRef.current) setLoading(false);
}
}, [key, localRequest, requestFailed, stableTarget, toast, unexpectedError]);
useEffect(() => {
void fetchDetail();
}, [fetchDetail]);
const isCurrentTargetLoaded = loadedKey === key;
return {
files: isCurrentTargetLoaded ? files : null,
commit: isCurrentTargetLoaded ? commit : null,
loading: loading || !isCurrentTargetLoaded,
error: isCurrentTargetLoaded ? error : null,
refetch: fetchDetail,
};
}
@@ -0,0 +1,269 @@
import { describe, expect, it } from "vitest";
import type { DockviewApi } from "dockview-react";
import { buildExtraPanelActions } from "./dockview-panel-actions";
import { CENTER_GROUP } from "./layout-manager";
import { makeApi, makeStore } from "./dockview-panel-actions.test-utils";
import type { MockPanel } from "./dockview-panel-actions.test-utils";
describe("addPRPanel — dedup with legacy auto-shown panel", () => {
const PR_KEY = "testorg/testrepo/101";
const OTHER_PR_KEY = "testorg/testrepo/202";
const LEGACY_PR_ID = "pr-detail";
const KEYED_PR_ID = `pr-detail|${PR_KEY}`;
const OTHER_KEYED_PR_ID = `pr-detail|${OTHER_PR_KEY}`;
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
/** Canonical PR Details panels retain the key currently shown for the task. */
function seedLegacyPanel(api: DockviewApi, prKey: string): void {
api.addPanel({
id: LEGACY_PR_ID,
component: "pr-detail",
title: "Pull Request",
params: { prKey },
position: { referenceGroup: CENTER_GROUP },
});
}
it("reuses the legacy panel when the requested PR is what it's already showing", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, PR_KEY);
actions.addPRPanel(PR_KEY);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(1);
expect(api.getPanel(KEYED_PR_ID)).toBeUndefined();
expect((api.getPanel(LEGACY_PR_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("opens a distinct tab for a different PR instead of overwriting the legacy tab", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, PR_KEY);
// Multi-repo "+" menu click for a PR other than the one the legacy tab
// is showing must NOT repurpose that tab — it must open its own tab.
actions.addPRPanel(OTHER_PR_KEY);
const legacy = api.getPanel(LEGACY_PR_ID) as unknown as MockPanel;
expect(legacy.params.prKey).toBe(PR_KEY);
const other = api.getPanel(OTHER_KEYED_PR_ID) as unknown as MockPanel;
expect(other).toBeDefined();
expect(other.isActive).toBe(true);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(2);
});
it("creates a new keyed panel when no legacy panel exists", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
expect(api.getPanel(KEYED_PR_ID)).toBeDefined();
expect(api.getPanel(LEGACY_PR_ID)).toBeUndefined();
});
it("focuses an existing keyed panel exactly once", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
actions.addPRPanel(PR_KEY);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(1);
const keyed = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(keyed.isActive).toBe(true);
});
});
describe("addPRPanel — layout-owned group placement", () => {
const PR_KEY = "testorg/testrepo/202";
const KEYED_PR_ID = `pr-detail|${PR_KEY}`;
const SESSION_ID = "s-1";
const SESSION_PANEL_ID = `session:${SESSION_ID}`;
const SESSION_GROUP = "group-session-host";
const PR_DETAILS_GROUP = "group-configured-pr-details";
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
function seedSessionInGroup(api: DockviewApi, groupId: string): void {
api.addPanel({
id: SESSION_PANEL_ID,
component: "chat",
title: "Session",
params: { sessionId: SESSION_ID },
position: { referenceGroup: groupId },
});
}
function seedCanonicalPRDetails(api: DockviewApi, groupId = PR_DETAILS_GROUP): void {
api.addPanel({
id: "pr-detail",
component: "pr-detail",
title: "PR Details",
position: { referenceGroup: groupId },
});
}
it("places a new PR tab in PR Details' configured group, not beside Agent", () => {
const { api, actions } = buildExtra(makeApi());
seedSessionInGroup(api, SESSION_GROUP);
seedCanonicalPRDetails(api);
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr).toBeDefined();
expect(pr.group.id).toBe(PR_DETAILS_GROUP);
expect(pr.group.id).not.toBe(SESSION_GROUP);
});
it("falls back to centerGroupId when no PR Details panel exists", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr).toBeDefined();
expect(pr.group.id).toBe(CENTER_GROUP);
});
it("focuses an existing keyed PR tab without relocating it", () => {
const { api, actions } = buildExtra(makeApi());
seedCanonicalPRDetails(api);
api.addPanel({
id: KEYED_PR_ID,
component: "pr-detail",
title: "Pull Request",
params: { prKey: PR_KEY },
position: { referenceGroup: SESSION_GROUP },
});
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr.group.id).toBe(SESSION_GROUP);
expect(pr.isActive).toBe(true);
});
});
describe("addMRPanel — layout-owned review tabs", () => {
const MR_KEY = "https://gitlab.example.test|platform/kandev|81";
const OTHER_MR_KEY = "https://gitlab.example.test|platform/kandev|82";
const LEGACY_MR_ID = "mr-detail";
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
function seedLegacyPanel(api: DockviewApi, mrKey: string): void {
api.addPanel({
id: LEGACY_MR_ID,
component: "mr-detail",
title: "Merge Request",
params: { mrKey },
position: { referenceGroup: CENTER_GROUP },
});
}
it("focuses a matching legacy panel in place", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, MR_KEY);
actions.addMRPanel(MR_KEY);
expect(api.panels.filter((panel) => panel.id.startsWith(LEGACY_MR_ID))).toHaveLength(1);
expect(api.getPanel(`${LEGACY_MR_ID}|${MR_KEY}`)).toBeUndefined();
expect((api.getPanel(LEGACY_MR_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("opens a keyed panel in the canonical PR Details group", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, MR_KEY);
api.addPanel({
id: "pr-detail",
component: "pr-detail",
title: "PR Details",
position: { referenceGroup: "group-configured-pr-details" },
});
actions.addMRPanel(OTHER_MR_KEY);
const keyed = api.getPanel(`${LEGACY_MR_ID}|${OTHER_MR_KEY}`) as unknown as MockPanel;
expect(keyed).toBeDefined();
expect(keyed.group.id).toBe("group-configured-pr-details");
expect(api.panels.filter((panel) => panel.id.startsWith(LEGACY_MR_ID))).toHaveLength(2);
});
});
const PLUGIN_ID = "kandev-plugin-notes";
const PLUGIN_PANEL_KEY = "notes";
const PLUGIN_PANEL_TITLE = "Notes";
const PLUGIN_PANEL_ID = "plugin:kandev-plugin-notes:notes";
describe("addPluginPanel / closePluginPanels", () => {
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
it("opens a plugin panel beside chat by default, with the shared plugin-panel component/tab", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
const panel = api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel;
expect(panel).toBeDefined();
expect(panel.title).toBe(PLUGIN_PANEL_TITLE);
expect(panel.params).toEqual({ pluginId: PLUGIN_ID, panelKey: PLUGIN_PANEL_KEY });
});
it("focuses an already-open plugin panel instead of duplicating it", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
(api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel).isActive = false;
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
expect(api.panels.filter((p) => p.id === PLUGIN_PANEL_ID)).toHaveLength(1);
expect((api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("respects inCenter and quiet options", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE, {
inCenter: true,
quiet: true,
});
const panel = api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel;
expect(panel.group.id).toBe(CENTER_GROUP);
expect(panel.isActive).toBe(false);
});
it("closePluginPanels removes only the target plugin's panels", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
actions.addPluginPanel("kandev-plugin-other", "widget", "Widget");
actions.closePluginPanels(PLUGIN_ID);
expect(api.getPanel(PLUGIN_PANEL_ID)).toBeUndefined();
expect(api.getPanel("plugin:kandev-plugin-other:widget")).toBeDefined();
});
});
@@ -0,0 +1,130 @@
import type { AddPanelOptions, DockviewApi } from "dockview-react";
import { buildPanelActions } from "./dockview-panel-actions";
import { CENTER_GROUP } from "./layout-manager";
export type MockPanel = {
id: string;
title: string;
params: Record<string, unknown>;
group: { id: string };
isActive: boolean;
api: {
setActive: () => void;
updateParameters: (p: Record<string, unknown>) => void;
moveTo: (opts: { group: { id: string } }) => void;
};
setTitle: (t: string) => void;
};
export function makeApi(
options: { centerGroupId?: string; extraGroupIds?: string[] } = {},
): DockviewApi {
const centerId = options.centerGroupId ?? CENTER_GROUP;
const panels: MockPanel[] = [];
const groups = [{ id: centerId }, ...(options.extraGroupIds ?? []).map((id) => ({ id }))];
function makePanel(add: AddPanelOptions & { id: string }): MockPanel {
const groupId =
(add.position as { referenceGroup?: string } | undefined)?.referenceGroup ?? centerId;
if (!groups.some((g) => g.id === groupId)) groups.push({ id: groupId });
const panel: MockPanel = {
id: add.id,
title: (add.title as string) ?? "",
params: { ...(add.params ?? {}) },
group: { id: groupId },
isActive: false,
setTitle(t: string) {
this.title = t;
},
api: {
setActive() {
for (const p of panels) p.isActive = false;
panel.isActive = true;
},
updateParameters(p: Record<string, unknown>) {
Object.assign(panel.params, p);
},
moveTo({ group }: { group: { id: string } }) {
panel.group = { id: group.id };
},
},
};
return panel;
}
const api = {
get groups() {
return groups;
},
get panels() {
return panels;
},
getPanel(id: string) {
return panels.find((p) => p.id === id);
},
getGroup(id: string) {
return groups.find((g) => g.id === id);
},
addPanel(opts: AddPanelOptions & { id: string }) {
const p = makePanel(opts);
panels.push(p);
if (!opts.inactive) p.api.setActive();
return p;
},
removePanel(panel: { id: string }) {
const i = panels.findIndex((p) => p.id === panel.id);
if (i >= 0) panels.splice(i, 1);
},
get activePanel() {
return panels.find((p) => p.isActive);
},
} as unknown as DockviewApi;
return api;
}
type StoreShape = {
api: DockviewApi | null;
centerGroupId: string;
rightTopGroupId: string;
rightBottomGroupId: string;
selectedDiff: { path: string; content?: string } | null;
};
export function makeStore(api: DockviewApi) {
const state: StoreShape = {
api,
centerGroupId: CENTER_GROUP,
rightTopGroupId: "group-right-top",
rightBottomGroupId: "group-right-bottom",
selectedDiff: null,
};
return {
get: () => state,
set: (partial: Partial<StoreShape>) => Object.assign(state, partial),
state,
};
}
export function build(api: DockviewApi) {
const store = makeStore(api);
const actions = buildPanelActions(store.set, store.get);
return { api, actions, store };
}
export const PATH_A = "src/a.ts";
export const PATH_B = "src/b.ts";
export const PATH_NESTED_B = "src/nested/b.ts";
export const SHARED_PATH = "README.md";
export const NAME_A = "a.ts";
export const NAME_B = "b.ts";
export const SHARED_NAME = "README.md";
export const PINNED_FILE_A_ID = "file:src/a.ts";
export const PREVIEW_FILE_ID = "preview:file-editor";
export const PREVIEW_DIFF_ID = "preview:file-diff";
export const PREVIEW_COMMIT_ID = "preview:commit-detail";
export const SHA_A = "abcdef1234567890";
export const SHA_B = "fedcba0987654321";
export const DIFF_FILE_PREFIX = "diff:file:";
export const FILE_PREFIX_ID = "file:";
export const COMMIT_PREFIX_ID = "commit:";
export const TYPE_FILE_EDITOR = "file-editor" as const;
+44 -402
View File
@@ -1,144 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import type { DockviewApi, AddPanelOptions } from "dockview-react";
import { buildPanelActions, buildExtraPanelActions } from "./dockview-panel-actions";
import type { DockviewApi } from "dockview-react";
import { buildPanelActions } from "./dockview-panel-actions";
import { CENTER_GROUP } from "./layout-manager";
// ---------------------------------------------------------------------------
// Minimal DockviewApi mock
// ---------------------------------------------------------------------------
type MockPanel = {
id: string;
title: string;
params: Record<string, unknown>;
group: { id: string };
isActive: boolean;
api: {
setActive: () => void;
updateParameters: (p: Record<string, unknown>) => void;
moveTo: (opts: { group: { id: string } }) => void;
};
setTitle: (t: string) => void;
};
function makeApi(options: { centerGroupId?: string; extraGroupIds?: string[] } = {}): DockviewApi {
const centerId = options.centerGroupId ?? CENTER_GROUP;
const panels: MockPanel[] = [];
const groups = [{ id: centerId }, ...(options.extraGroupIds ?? []).map((id) => ({ id }))];
function makePanel(add: AddPanelOptions & { id: string }): MockPanel {
const groupId =
(add.position as { referenceGroup?: string } | undefined)?.referenceGroup ?? centerId;
if (!groups.some((g) => g.id === groupId)) groups.push({ id: groupId });
const panel: MockPanel = {
id: add.id,
title: (add.title as string) ?? "",
params: { ...(add.params ?? {}) },
group: { id: groupId },
isActive: false,
setTitle(t: string) {
this.title = t;
},
api: {
setActive() {
for (const p of panels) p.isActive = false;
panel.isActive = true;
},
updateParameters(p: Record<string, unknown>) {
Object.assign(panel.params, p);
},
moveTo({ group }: { group: { id: string } }) {
panel.group = { id: group.id };
},
},
};
return panel;
}
const api = {
get groups() {
return groups;
},
get panels() {
return panels;
},
getPanel(id: string) {
return panels.find((p) => p.id === id);
},
getGroup(id: string) {
return groups.find((g) => g.id === id);
},
addPanel(opts: AddPanelOptions & { id: string }) {
const p = makePanel(opts);
panels.push(p);
if (!opts.inactive) p.api.setActive();
return p;
},
removePanel(panel: { id: string }) {
const i = panels.findIndex((p) => p.id === panel.id);
if (i >= 0) panels.splice(i, 1);
},
get activePanel() {
return panels.find((p) => p.isActive);
},
} as unknown as DockviewApi;
return api;
}
// ---------------------------------------------------------------------------
// Store adapter: panel actions take (set, get) closures
// ---------------------------------------------------------------------------
type StoreShape = {
api: DockviewApi | null;
centerGroupId: string;
rightTopGroupId: string;
rightBottomGroupId: string;
selectedDiff: { path: string; content?: string } | null;
};
function makeStore(api: DockviewApi) {
const state: StoreShape = {
api,
centerGroupId: CENTER_GROUP,
rightTopGroupId: "group-right-top",
rightBottomGroupId: "group-right-bottom",
selectedDiff: null,
};
return {
get: () => state,
set: (partial: Partial<StoreShape>) => Object.assign(state, partial),
state,
};
}
function build(api: DockviewApi) {
const store = makeStore(api);
const actions = buildPanelActions(store.set, store.get);
return { api, actions, store };
}
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
const PATH_A = "src/a.ts";
const PATH_B = "src/b.ts";
const PATH_NESTED_B = "src/nested/b.ts";
const SHARED_PATH = "README.md";
const NAME_A = "a.ts";
const NAME_B = "b.ts";
const SHARED_NAME = "README.md";
const PINNED_FILE_A_ID = "file:src/a.ts";
const PREVIEW_FILE_ID = "preview:file-editor";
const PREVIEW_DIFF_ID = "preview:file-diff";
const PREVIEW_COMMIT_ID = "preview:commit-detail";
const SHA_A = "abcdef1234567890";
const SHA_B = "fedcba0987654321";
const DIFF_FILE_PREFIX = "diff:file:";
const FILE_PREFIX_ID = "file:";
const COMMIT_PREFIX_ID = "commit:";
const TYPE_FILE_EDITOR = "file-editor" as const;
import { build, makeApi } from "./dockview-panel-actions.test-utils";
import type { MockPanel } from "./dockview-panel-actions.test-utils";
import {
COMMIT_PREFIX_ID,
DIFF_FILE_PREFIX,
FILE_PREFIX_ID,
NAME_A,
NAME_B,
PATH_A,
PATH_B,
PATH_NESTED_B,
PINNED_FILE_A_ID,
PREVIEW_COMMIT_ID,
PREVIEW_DIFF_ID,
PREVIEW_FILE_ID,
SHA_A,
SHA_B,
SHARED_NAME,
SHARED_PATH,
TYPE_FILE_EDITOR,
} from "./dockview-panel-actions.test-utils";
// ---------------------------------------------------------------------------
// Tests
@@ -456,6 +340,27 @@ describe("addCommitDetailPanel — preview behavior", () => {
expect(preview.params.commitSha).toBe(SHA_B);
expect(preview.params.promoted).toBeUndefined();
});
it("serializes remote provenance and keeps repositories with equal SHAs distinct", () => {
const first = {
source: "github" as const,
sha: SHA_A,
workspaceId: "workspace-1",
owner: "acme",
repo: "frontend",
};
const second = { ...first, repo: "backend" };
actions.addCommitDetailPanel(first, { pin: true });
actions.addCommitDetailPanel(second, { pin: true });
const firstPanel = api.getPanel(`commit:github:workspace-1:acme/frontend:${SHA_A}`);
const secondPanel = api.getPanel(`commit:github:workspace-1:acme/backend:${SHA_A}`);
expect(firstPanel).toBeDefined();
expect(secondPanel).toBeDefined();
expect((secondPanel as unknown as MockPanel).params.target).toEqual(second);
expect((secondPanel as unknown as MockPanel).params.commitSha).toBe(SHA_A);
});
});
describe("preview slots are independent across types", () => {
@@ -471,266 +376,3 @@ describe("preview slots are independent across types", () => {
expect(api.getPanel(PREVIEW_COMMIT_ID)).toBeDefined();
});
});
describe("addPRPanel — dedup with legacy auto-shown panel", () => {
const PR_KEY = "testorg/testrepo/101";
const OTHER_PR_KEY = "testorg/testrepo/202";
const LEGACY_PR_ID = "pr-detail";
const KEYED_PR_ID = `pr-detail|${PR_KEY}`;
const OTHER_KEYED_PR_ID = `pr-detail|${OTHER_PR_KEY}`;
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
/** Canonical PR Details panels retain the key currently shown for the task. */
function seedLegacyPanel(api: DockviewApi, prKey: string): void {
api.addPanel({
id: LEGACY_PR_ID,
component: "pr-detail",
title: "Pull Request",
params: { prKey },
position: { referenceGroup: CENTER_GROUP },
});
}
it("reuses the legacy panel when the requested PR is what it's already showing", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, PR_KEY);
actions.addPRPanel(PR_KEY);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(1);
expect(api.getPanel(KEYED_PR_ID)).toBeUndefined();
expect((api.getPanel(LEGACY_PR_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("opens a distinct tab for a different PR instead of overwriting the legacy tab", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, PR_KEY);
// Multi-repo "+" menu click for a PR other than the one the legacy tab
// is showing must NOT repurpose that tab — it must open its own tab.
actions.addPRPanel(OTHER_PR_KEY);
const legacy = api.getPanel(LEGACY_PR_ID) as unknown as MockPanel;
expect(legacy.params.prKey).toBe(PR_KEY);
const other = api.getPanel(OTHER_KEYED_PR_ID) as unknown as MockPanel;
expect(other).toBeDefined();
expect(other.isActive).toBe(true);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(2);
});
it("creates a new keyed panel when no legacy panel exists", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
expect(api.getPanel(KEYED_PR_ID)).toBeDefined();
expect(api.getPanel(LEGACY_PR_ID)).toBeUndefined();
});
it("focuses an existing keyed panel exactly once", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
actions.addPRPanel(PR_KEY);
const prPanels = api.panels.filter(
(p) => p.id === LEGACY_PR_ID || p.id.startsWith(`${LEGACY_PR_ID}|`),
);
expect(prPanels).toHaveLength(1);
const keyed = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(keyed.isActive).toBe(true);
});
});
describe("addPRPanel — layout-owned group placement", () => {
const PR_KEY = "testorg/testrepo/202";
const KEYED_PR_ID = `pr-detail|${PR_KEY}`;
const SESSION_ID = "s-1";
const SESSION_PANEL_ID = `session:${SESSION_ID}`;
const SESSION_GROUP = "group-session-host";
const PR_DETAILS_GROUP = "group-configured-pr-details";
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
function seedSessionInGroup(api: DockviewApi, groupId: string): void {
api.addPanel({
id: SESSION_PANEL_ID,
component: "chat",
title: "Session",
params: { sessionId: SESSION_ID },
position: { referenceGroup: groupId },
});
}
function seedCanonicalPRDetails(api: DockviewApi, groupId = PR_DETAILS_GROUP): void {
api.addPanel({
id: "pr-detail",
component: "pr-detail",
title: "PR Details",
position: { referenceGroup: groupId },
});
}
it("places a new PR tab in PR Details' configured group, not beside Agent", () => {
const { api, actions } = buildExtra(makeApi());
seedSessionInGroup(api, SESSION_GROUP);
seedCanonicalPRDetails(api);
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr).toBeDefined();
expect(pr.group.id).toBe(PR_DETAILS_GROUP);
expect(pr.group.id).not.toBe(SESSION_GROUP);
});
it("falls back to centerGroupId when no PR Details panel exists", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr).toBeDefined();
expect(pr.group.id).toBe(CENTER_GROUP);
});
it("focuses an existing keyed PR tab without relocating it", () => {
const { api, actions } = buildExtra(makeApi());
seedCanonicalPRDetails(api);
api.addPanel({
id: KEYED_PR_ID,
component: "pr-detail",
title: "Pull Request",
params: { prKey: PR_KEY },
position: { referenceGroup: SESSION_GROUP },
});
actions.addPRPanel(PR_KEY);
const pr = api.getPanel(KEYED_PR_ID) as unknown as MockPanel;
expect(pr.group.id).toBe(SESSION_GROUP);
expect(pr.isActive).toBe(true);
});
});
describe("addMRPanel — layout-owned review tabs", () => {
const MR_KEY = "https://gitlab.example.test|platform/kandev|81";
const OTHER_MR_KEY = "https://gitlab.example.test|platform/kandev|82";
const LEGACY_MR_ID = "mr-detail";
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
function seedLegacyPanel(api: DockviewApi, mrKey: string): void {
api.addPanel({
id: LEGACY_MR_ID,
component: "mr-detail",
title: "Merge Request",
params: { mrKey },
position: { referenceGroup: CENTER_GROUP },
});
}
it("focuses a matching legacy panel in place", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, MR_KEY);
actions.addMRPanel(MR_KEY);
expect(api.panels.filter((panel) => panel.id.startsWith(LEGACY_MR_ID))).toHaveLength(1);
expect(api.getPanel(`${LEGACY_MR_ID}|${MR_KEY}`)).toBeUndefined();
expect((api.getPanel(LEGACY_MR_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("opens a keyed panel in the canonical PR Details group", () => {
const { api, actions } = buildExtra(makeApi());
seedLegacyPanel(api, MR_KEY);
api.addPanel({
id: "pr-detail",
component: "pr-detail",
title: "PR Details",
position: { referenceGroup: "group-configured-pr-details" },
});
actions.addMRPanel(OTHER_MR_KEY);
const keyed = api.getPanel(`${LEGACY_MR_ID}|${OTHER_MR_KEY}`) as unknown as MockPanel;
expect(keyed).toBeDefined();
expect(keyed.group.id).toBe("group-configured-pr-details");
expect(api.panels.filter((panel) => panel.id.startsWith(LEGACY_MR_ID))).toHaveLength(2);
});
});
const PLUGIN_ID = "kandev-plugin-notes";
const PLUGIN_PANEL_KEY = "notes";
const PLUGIN_PANEL_TITLE = "Notes";
const PLUGIN_PANEL_ID = "plugin:kandev-plugin-notes:notes";
describe("addPluginPanel / closePluginPanels", () => {
function buildExtra(api: DockviewApi) {
const store = makeStore(api);
return { api, actions: buildExtraPanelActions(store.get) };
}
it("opens a plugin panel beside chat by default, with the shared plugin-panel component/tab", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
const panel = api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel;
expect(panel).toBeDefined();
expect(panel.title).toBe(PLUGIN_PANEL_TITLE);
expect(panel.params).toEqual({ pluginId: PLUGIN_ID, panelKey: PLUGIN_PANEL_KEY });
});
it("focuses an already-open plugin panel instead of duplicating it", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
(api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel).isActive = false;
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
expect(api.panels.filter((p) => p.id === PLUGIN_PANEL_ID)).toHaveLength(1);
expect((api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel).isActive).toBe(true);
});
it("respects inCenter and quiet options", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE, {
inCenter: true,
quiet: true,
});
const panel = api.getPanel(PLUGIN_PANEL_ID) as unknown as MockPanel;
expect(panel.group.id).toBe(CENTER_GROUP);
expect(panel.isActive).toBe(false);
});
it("closePluginPanels removes only the target plugin's panels", () => {
const { api, actions } = buildExtra(makeApi());
actions.addPluginPanel(PLUGIN_ID, PLUGIN_PANEL_KEY, PLUGIN_PANEL_TITLE);
actions.addPluginPanel("kandev-plugin-other", "widget", "Widget");
actions.closePluginPanels(PLUGIN_ID);
expect(api.getPanel(PLUGIN_PANEL_ID)).toBeUndefined();
expect(api.getPanel("plugin:kandev-plugin-other:widget")).toBeDefined();
});
});
+34 -7
View File
@@ -1,4 +1,5 @@
import type { DockviewApi, DockviewGroupPanel } from "dockview-react";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import { focusOrAddPanel } from "./dockview-layout-builders";
import {
parsePluginPanelId,
@@ -360,20 +361,46 @@ function buildFileDiffAction(get: StoreGet) {
};
}
function buildCommitItemId(
requestedTarget: CommitDetailTarget | string,
target: CommitDetailTarget,
legacyRepo?: string,
): string {
if (typeof requestedTarget === "string") {
return legacyRepo ? `${legacyRepo}:${requestedTarget}` : requestedTarget;
}
if (target.source === "local") return `local:${target.repo ?? ""}:${target.sha}`;
return `github:${target.workspaceId}:${target.owner}/${target.repo}:${target.sha}`;
}
function buildCommitDetailAction(get: StoreGet) {
return (sha: string, opts?: OpenPanelOpts & { groupId?: string; repo?: string }) => {
return (
requestedTarget: CommitDetailTarget | string,
opts?: OpenPanelOpts & { groupId?: string; repo?: string },
) => {
const { api, centerGroupId } = get();
if (!api) return;
// Multi-repo: scope the panel id by repo so the same SHA from two repos
// (rare in practice, but cheap to be correct) doesn't collide and so the
// existing-tab dedup doesn't reuse the wrong-repo's panel.
const itemId = opts?.repo ? `${opts.repo}:${sha}` : sha;
const target: CommitDetailTarget =
typeof requestedTarget === "string"
? {
source: "local",
sha: requestedTarget,
...(opts?.repo ? { repo: opts.repo } : {}),
}
: requestedTarget;
// Preserve the legacy string-call identity for saved callers/tests; new
// discriminated targets include provenance and repository identity.
const itemId = buildCommitItemId(requestedTarget, target, opts?.repo);
openOrReplacePreview({
api,
type: "commit-detail",
itemId,
title: sha.slice(0, 7),
params: { commitSha: sha, repo: opts?.repo },
title: target.sha.slice(0, 7),
params: {
target,
commitSha: target.sha,
...(target.source === "local" && target.repo ? { repo: target.repo } : {}),
},
groupId: opts?.groupId ?? centerGroupId,
quiet: opts?.quiet,
pin: opts?.pin,
+2 -1
View File
@@ -33,6 +33,7 @@ import {
materializeReusableChatPanel,
} from "./layout-manager";
import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager";
import type { CommitDetailTarget } from "@/components/task/changes-diff-target";
import { performEnvSwitch, replaceStaleSessionPanels } from "./dockview-env-switch";
import { enforcePinnedTargets } from "./dockview-pinned-enforce";
import {
@@ -149,7 +150,7 @@ type DockviewStore = {
},
) => void;
addCommitDetailPanel: (
sha: string,
target: CommitDetailTarget | string,
opts?: OpenPanelOpts & { groupId?: string; repo?: string },
) => void;
addFileEditorPanel: (path: string, name: string, opts?: OpenPanelOpts) => void;
+15
View File
@@ -525,6 +525,21 @@ export type PRCommitInfo = {
additions: number;
deletions: number;
files_changed: number;
/** False when the PR commit-list endpoint did not include exact stats. */
stats_available?: boolean;
};
/** Exact metadata and files returned by the individual GitHub commit endpoint. */
export type PRCommitDetail = {
sha: string;
message: string;
author_login: string;
author_name: string;
author_date: string;
additions: number;
deletions: number;
files_changed: number;
files: PRDiffFile[];
};
// GitHub Issue (separate from Pull Request)
+219
View File
@@ -0,0 +1,219 @@
---
spec: docs/specs/pr-only-commit-details/spec.md
created: 2026-08-04
status: implemented
---
# Implementation Plan: Repair PR-only commit details
## Overview
Make commit provenance a first-class part of the Changes panel. Preserve the
local session path for SHAs known to the worktree, but route PR-only SHAs to a
new workspace-authorized GitHub individual-commit action. Hide unmeasured row
statistics and local mutation controls for remote commits, then cover the
force-pushed/stale-worktree case on desktop and mobile.
## Confirmed root cause
For task `8531031a-9a98-439f-ab0a-c25b480158dc`, the stored session worktree
was created at local head `5c2873d`, while linked PR #2253 was later
force-pushed to six different commits ending at `df3c05e`.
- `mergeCommits` in
`apps/web/components/task/changes-panel-helpers.ts` appends PR-only SHAs to
the local commit feed without preserving their source.
- GitHub's pull request commit-list response does not include commit stats.
`convertRawPRCommits` leaves Go integer fields at zero, so the frontend
presents unknown values as `+0 -0`.
- Every row click is reduced to a SHA and repository name, then sent through
`session.commit_diff`. The backend runs `git show` in the task worktree even
for PR-only SHAs.
- The shared row also exposes local reset/revert/amend callbacks for PR-only
commits, while `CommitDetailPanel` resolves metadata only from local session
commits.
The repair invariant is: row statistics, detail data, metadata, and available
actions must all derive from the same explicit commit source.
## Backend
### Workspace-scoped GitHub commit detail
- Add a `github.pr_commit.get` WebSocket action in
`apps/backend/pkg/websocket/actions.go` and register it in
`apps/backend/internal/github/handlers.go`.
- Add `StatsAvailable bool` with JSON field `stats_available` to `PRCommitInfo`.
Define `PRCommitDetail` in `apps/backend/internal/github/models.go` with SHA,
message, author login/name/date, exact additions/deletions, files changed,
and the merged `[]PRFile` returned by the individual GitHub commit endpoint.
- Extend the GitHub client interface and the `gh`, PAT, noop, and mock clients
with an exact-SHA commit-detail method. The CLI client should call
`gh api repos/{owner}/{repo}/commits/{sha}`; the PAT client should call the
equivalent REST path. Parse both through one helper so their response
semantics stay aligned.
- Request the maximum supported page size and merge every returned file page
for both clients (`gh api --paginate --slurp` for the CLI representation and
Link-header pagination for PAT). Preserve the first page's metadata/stats,
append file records in provider order without duplication, and test a
multi-page response.
- Add a service method that reuses `ensureRepositoryInWorkspaceScope` and the
existing personal read-client resolution. Validate that the SHA is a safe
exact object ID before interpolating it into either client path.
- Do not fetch, persist, or import the commit into the task worktree.
### Unknown list statistics
- Use `PRCommitInfo.stats_available` to explicitly represent whether additions,
deletions, and files-changed values are available. The normal GitHub list
parser must set it to false rather than relying on zero values.
- Keep exact integers mandatory on `PRCommitDetail`, where the individual
endpoint provides them. This lets a genuinely empty commit remain
distinguishable from an unmeasured row.
## Frontend
### Source-aware commit targets
- Introduce a discriminated commit-detail target: local targets contain the
existing SHA/repository routing data; GitHub targets additionally contain
workspace, owner, and repository identity.
- Make `mergeCommits` return that target plus a statistics-availability flag.
A SHA found in both feeds remains one local row and retains its pushed state.
A PR-only SHA becomes a GitHub target and does not acquire zero statistics.
- Thread the full target through the Changes panel callbacks, desktop dockview
panel params/actions, and mobile diff target. Include source and repository
identity in dockview panel identity so equal SHAs from different repositories
cannot collide.
### Unified detail loading and rendering
- Add a source-aware commit-detail hook/request layer. Local targets continue
to use `session.commit_diff`; GitHub targets call `github.pr_commit.get` and
map `PRFile` records into the current diff-view model.
- Have `CommitDetailPanel` obtain metadata and files from that unified result,
fixing the missing PR-only header as well as the patch source.
- For GitHub targets, disable worktree-dependent file actions and local context
expansion. Preserve the current rendering for available, binary, empty, or
truncated GitHub patches; never retry through the local git path.
- Gate reset/revert/amend row actions on local provenance. Render additions and
deletions only when the row marks them as measured.
## Desktop and mobile behavior
The visual composition does not change. Desktop continues to open the existing
dockview commit panel. Mobile continues to use
`MobileChangesPanel` -> `MobileDiffSheet`, the current full-height drawer and
closest native-mobile exemplar. Both surfaces consume the same discriminated
target and detail hook. The mobile drawer keeps its existing single scroll
owner, close affordance, and touch behavior; only its data source and local
action eligibility change.
## Tests
- Backend parser tests prove list statistics are unavailable and individual
commit responses preserve exact metadata, stats, file status, and patch.
- `gh` and PAT client tests prove the exact repository/SHA path and shared
response parsing.
- Handler/service tests prove workspace/repository authorization, exact-SHA
validation, response routing, and error propagation.
- Frontend merge tests prove PR-only provenance, hidden unknown stats,
multi-repository identity, and local precedence for a SHA found in both
feeds.
- Request/hook tests prove mutually exclusive local and GitHub actions and no
local fallback after a GitHub error.
- Panel/row tests prove remote metadata is visible and reset/revert/amend,
worktree-file actions, and local expansion are absent for GitHub targets.
- Dockview and mobile target tests prove source/repository identity survives
serialization and selection.
All production changes follow Red-Green-Refactor: add each focused regression,
record the expected failure against current behavior, add the smallest source
change, then rerun the focused and package-level checks.
## E2E tests
Extend `apps/web/e2e/tests/git/git-changes-panel.spec.ts` with a linked PR whose
mocked current commits are absent from the stale local worktree. Assert that the
row omits false `+0 -0`, its local mutation menu is absent, and opening it shows
the GitHub-specific commit header and patch marker. The scenario must fail if
the UI invokes `session.commit_diff` for that SHA.
Extend `apps/web/e2e/tests/task/mobile-changes-panel.spec.ts` with the same
PR-only source. Assert that tapping the row opens the existing full-height
commit sheet, renders the remote marker patch, exposes no local action, closes
normally, and introduces no horizontal overflow. Add deterministic mock GitHub
individual-commit seeding to the existing mock controller/API helper rather
than depending on live GitHub.
## Implementation waves and parallel candidates
Wave 1 (sequential):
- [x] [Task 01: GitHub commit detail contract](task-01-github-commit-detail.md)
— add the authenticated individual-commit backend contract and distinguish
unknown list statistics.
Wave 2 (depends on Wave 1):
- [x] [Task 02: Source-aware commit UI](task-02-source-aware-commit-ui.md) —
route local and GitHub targets correctly across desktop and mobile.
Wave 3 (depends on Waves 1-2):
- [x] [Task 03: Commit-source E2E coverage](task-03-commit-source-e2e.md) —
exercise stale local history against deterministic remote commit details.
The tasks are intentionally sequential. Task 02 consumes the backend wire
contract from Task 01, and Task 03 validates the integrated behavior and adds
mock support for that settled contract.
## Verification
Run the focused commands recorded in each task, then finish with:
```bash
cd apps/backend && go test ./internal/github
cd apps/web && pnpm run typecheck
cd apps && pnpm --filter @kandev/web lint
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
cd apps/web && pnpm e2e:run tests/git/git-changes-panel.spec.ts -- --grep "PR-only commit"
cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-changes-panel.spec.ts -- --grep "PR-only commit"
git diff --check
```
## Verification results
- Backend: `go test ./internal/github` passed (1081 tests).
- Frontend: focused source/target suite passed (7 files, 72 tests), followed by
`pnpm run typecheck`, web lint, `i18n:check`, and `i18n:ratchet`.
- E2E: the desktop PR-only scenario passed once; the mobile scenario passed
once after narrowing an assertion to the opened sheet. Both scenarios cover
stale local history, hidden unknown statistics/actions, remote metadata, and
remote patch rendering.
- `git diff --check` passed.
- Review remediation: complete PR identity now gates visible commit-list
results; unavailable or malformed detail responses show a persistent retry
state; and remote detail loading is independent of local agent readiness.
The broader focused frontend suite passed (8 files, 69 tests), plus the
remediation request/hook/panel tests (4 files, 15 tests), typecheck, lint,
i18n checks, and ratchet.
- Review follow-up after rebasing onto `main`: root repository identity is now
matched exactly, invalid/null commit payloads are rejected at both backend
and frontend boundaries, target switches cannot expose prior detail state,
and serialized GitHub targets require complete identity. Backend GitHub tests
passed (1,086); focused frontend tests passed (8 files, 73 tests), with
typecheck, lint, i18n checks, ratchet, and diff checks green.
## Risks and out of scope
- Individual GitHub commit requests add a lazy network round trip and can be
rate-limited. The existing loading/error treatment must remain responsive,
and no eager N+1 list loading is allowed.
- GitHub can omit patches for binary or oversized changes. That state must be
presented honestly and must not trigger a local-data fallback.
- The source target becomes part of desktop panel identity and mobile
selection state. Tests must prevent stale serialized targets or
cross-repository SHA collisions.
- No database migration, public documentation change, worktree synchronization,
or non-GitHub provider is part of this repair.
@@ -0,0 +1,63 @@
---
id: "01-github-commit-detail"
title: "GitHub commit detail contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/pr-only-commit-details/spec.md"
---
# Task 01: GitHub commit detail contract
- **Acceptance:** The backend exposes one workspace-authorized
`github.pr_commit.get` action for an exact owner/repository/SHA; the response
contains GitHub commit metadata, exact aggregate statistics, and changed-file
patches merged across provider pages; `PRCommitInfo.stats_available`
distinguishes unavailable list statistics from known zeroes; unauthorized
repositories, unsafe SHAs, upstream errors, and missing commits return errors
without touching a task worktree.
- **Verification:** Follow strict TDD. First add parser, client, and
handler/service regressions and run
`cd apps/backend && go test -run 'Test.*(PRCommitDetail|CommitDetail|PRCommitStats)' ./internal/github`;
record failures showing the absent action/model and zero-value ambiguity.
After the minimal implementation, rerun that command, then run
`cd apps/backend && go test ./internal/github` and `git diff --check`.
- **Files likely touched:**
`apps/backend/pkg/websocket/actions.go`;
`apps/backend/internal/github/models.go`;
`apps/backend/internal/github/client.go`;
`apps/backend/internal/github/client_helpers.go`;
`apps/backend/internal/github/gh_client.go`;
`apps/backend/internal/github/pat_client.go`;
`apps/backend/internal/github/noop_client.go`;
`apps/backend/internal/github/mock_client.go`;
`apps/backend/internal/github/service_pr.go`;
`apps/backend/internal/github/handlers.go`; and focused tests alongside those
files, including authorization coverage.
- **Dependencies:** None.
- **Parallelism:** sequential — the model, client interface, authorization
service, and WebSocket handler form one wire contract and their RED/GREEN
changes overlap the same package.
- **Inputs:** repair spec API contract; current `PRCommitInfo`, `PRFile`,
`GetPRCommits`, `GetPRFiles`, `ensureRepositoryInWorkspaceScope`, personal
read-client resolution, and GitHub handler authorization test patterns.
- **Output contract:** Report the final request/response shape, exact files
changed, RED/GREEN and full-package results, pagination behavior,
authorization/error behavior, confirmation that no worktree or persistence
path was added, and synchronized task/plan status.
## Results
- RED: parser, client, authorization, and WebSocket regressions exposed the
missing individual-commit contract and the zero-value ambiguity for list
statistics.
- GREEN: added `github.pr_commit.get`, workspace/repository authorization,
exact-SHA validation, shared PAT/CLI parsing, paginated file merging with
provider-order deduplication, and mock/noop support. List commits now set
`stats_available: false`; individual details carry exact metadata,
statistics, and files.
- Verification: `go test ./internal/github` passed with 1081 tests; the
focused parser/client/service/handler tests and `git diff --check` passed.
- Individual commit details remain read-only and are never imported into a
task worktree or persisted.
@@ -0,0 +1,84 @@
---
id: "02-source-aware-commit-ui"
title: "Source-aware commit UI"
status: done
wave: 2
depends_on: ["01-github-commit-detail"]
plan: "plan.md"
spec: "../../specs/pr-only-commit-details/spec.md"
---
# Task 02: Source-aware commit UI
- **Acceptance:** Merged commit rows carry a discriminated local/GitHub target;
matching SHAs prefer the local source; PR-only rows omit unknown stats and
local mutation controls; opening a local target calls only
`session.commit_diff`; opening a GitHub target calls only
`github.pr_commit.get`, renders remote metadata/files on desktop and mobile,
disables worktree-dependent actions/expansion, preserves repository identity,
and never falls back locally on a GitHub error.
- **Verification:** Follow strict TDD. Add focused merge, request/hook, row,
panel, dockview, and mobile-target regressions; run the smallest affected
Vitest files and record failures caused by the current SHA-only target and
local-only loader. Use
`cd apps/web && pnpm test -- components/task/changes-panel.test.ts components/task/commit-detail-request.test.ts components/task/commit-detail-panel.test.tsx lib/state/dockview-panel-actions.test.ts`
for the focused RED/GREEN cycle. After implementation, rerun it, then run
`cd apps/web && pnpm run typecheck`,
`cd apps && pnpm --filter @kandev/web lint`,
`cd apps && pnpm run i18n:check && pnpm run i18n:ratchet`, and
`git diff --check`.
- **Files likely touched:**
`apps/web/lib/types/github.ts`;
a source-aware target/request hook under
`apps/web/components/task` or `apps/web/hooks/domains`;
`apps/web/components/task/changes-panel-helpers.ts`;
`apps/web/components/task/changes-panel-data.tsx`;
`apps/web/components/task/changes-panel-body.tsx`;
`apps/web/components/task/commit-row.tsx`;
`apps/web/components/task/commit-detail-panel.tsx`;
`apps/web/components/task/changes-diff-target.ts`;
`apps/web/components/task/dockview-shared.tsx`;
`apps/web/components/task/dockview-panel-content.tsx`;
`apps/web/lib/state/dockview-store.ts`;
`apps/web/lib/state/dockview-panel-actions.ts`;
`apps/web/components/task/mobile/mobile-changes-panel.tsx`;
`apps/web/components/task/mobile/mobile-diff-sheet.tsx`; and colocated unit
tests for the changed helpers, state, hook, and components.
- **Dependencies:** Task 01's settled WebSocket action and response types.
- **Parallelism:** sequential — source identity crosses merge logic, serialized
desktop panel state, mobile selection, and one shared detail renderer; split
implementations would create an intermediate incompatible UI contract.
- **Inputs:** repair spec desired behavior; Task 01 output contract; current
`mergeCommits`, `CommitRow`, `requestCommitDiff`, `useCommitDiff`,
`CommitDetailPanel`, dockview commit panel actions, `MobileChangesPanel`, and
`MobileDiffSheet` patterns.
- **Output contract:** Report the discriminated target shape, route selection,
row/action behavior, desktop/mobile rendering behavior, exact files changed,
RED/GREEN and lint/type/i18n results, remaining patch limitations, and
synchronized task/plan status.
## Results
- RED: source-routing regressions demonstrated that SHA-only rows always used
`session.commit_diff`, including PR-only commits, and that remote rows could
inherit fabricated zero statistics and local actions.
- GREEN: added the discriminated local/GitHub target, repository-aware merge
precedence, source-aware request/hook loading, remote metadata/file mapping,
dockview serialization identity, and shared desktop/mobile rendering. Remote
rows omit unmeasured statistics and all worktree mutation/file/context
actions; GitHub errors do not fall back locally.
- Verification: focused Vitest coverage passed (7 files, 72 tests), followed
by web typecheck, lint, i18n check, i18n ratchet, and `git diff --check`.
- Review remediation: keyed PR commit results by the complete workspace,
repository, pull request, and refresh identity so rapid PR switches cannot
reuse stale rows. Unavailable clients and malformed commit responses now
remain errors with a retry state, and GitHub detail requests no longer
refetch when local agent readiness changes. The remediation suite passed
(8 files, 69 tests), with additional focused regressions covering the PR
switch, protocol failures, retry rendering, and remote dependency boundary.
- Review follow-up: normalized missing repository names to the workspace-root
identity, rejected malformed backend and frontend commit-detail payloads,
bound loaded detail state to the selected target, hardened serialized GitHub
target validation, and corrected the late-response regression. The rebased
verification passed with 1,086 backend GitHub tests and 73 focused frontend
tests.
@@ -0,0 +1,60 @@
---
id: "03-commit-source-e2e"
title: "Commit-source E2E coverage"
status: done
wave: 3
depends_on: ["01-github-commit-detail", "02-source-aware-commit-ui"]
plan: "plan.md"
spec: "../../specs/pr-only-commit-details/spec.md"
---
# Task 03: Commit-source E2E coverage
- **Acceptance:** Deterministic GitHub mock support can seed an individual
commit detail independently of the local worktree; desktop E2E proves a
PR-only commit omits false zero stats and local mutation actions and renders
the remote header/patch; mobile E2E proves the same remote patch opens in the
existing full-height sheet, has no local action, closes normally, and causes
no horizontal overflow; the scenario cannot pass by serving a local
`session.commit_diff` response for the remote SHA.
- **Verification:** Follow strict TDD. Add mock-controller and Playwright
regressions first; record the backend/mock failure and browser failure against
the pre-task state. After the minimal mock and test wiring, run
`cd apps/backend && go test -run 'Test.*Mock.*Commit' ./internal/github`,
`cd apps/web && pnpm e2e:run tests/git/git-changes-panel.spec.ts -- --grep "PR-only commit"`,
`cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-changes-panel.spec.ts -- --grep "PR-only commit"`,
and `git diff --check`.
- **Files likely touched:**
`apps/backend/internal/github/mock_client.go`;
`apps/backend/internal/github/mock_controller.go`;
their focused tests;
`apps/web/e2e/helpers/api-client.ts`;
`apps/web/e2e/tests/git/git-changes-panel.spec.ts`; and
`apps/web/e2e/tests/task/mobile-changes-panel.spec.ts`.
- **Dependencies:** Tasks 01 and 02 complete with stable backend and frontend
source-routing contracts.
- **Parallelism:** sequential — mock seeding must match the settled response
contract, and the desktop/mobile tests jointly validate one integrated source
route.
- **Inputs:** repair spec regression scenarios; Task 01 individual-commit mock
model; Task 02 source-aware targets; existing `mockGitHubAddPRCommits`,
Git Changes panel fixtures, and mobile Changes panel full-height drawer tests.
- **Output contract:** Report the deterministic stale-worktree fixture, how the
test prevents local fallback, exact files changed, backend and desktop/mobile
RED/GREEN results, cleanup state, any quarantined instability, and
synchronized task/plan status.
## Results
- GREEN: added deterministic mock individual-commit seeding through the
existing mock GitHub controller and API helper. Desktop and mobile fixtures
use PR-only SHAs absent from the stale local worktree, so the rendered
metadata and patch require the GitHub detail response.
- Desktop verification passed with
`pnpm e2e:run tests/git/git-changes-panel.spec.ts -- --grep "PR-only commit"`.
- Mobile verification passed with
`pnpm e2e:run --project mobile-chrome tests/task/mobile-changes-panel.spec.ts -- --grep "PR-only commit"`.
The first mobile attempt only exposed a strict-locator ambiguity in the test
assertion; narrowing it to the sheet passed the same scenario.
- No local mutation action appeared, the remote patch marker rendered, the
mobile sheet closed normally, and the horizontal-overflow check passed.
+111
View File
@@ -0,0 +1,111 @@
---
title: Repair PR-only commit details
status: done
created: 2026-08-04
owner: kandev
---
# Repair PR-only commit details
## Problem
The Changes panel combines commits discovered in a task session's local
worktree with commits returned by the task's linked GitHub pull request. When a
pull request is force-pushed after the task worktree is created, those sources
can describe different histories.
PR-only rows currently inherit zero-valued statistics from GitHub's pull
request commit-list response and are opened through the local
`session.commit_diff` action. The list response does not contain commit stats,
and the remote commit may not exist in the task worktree. As a result, Kandev
can show false `+0 -0` totals, an empty or failed detail view, metadata from the
wrong source, and local reset/revert/amend controls for a commit that is not in
the local branch.
## Desired behavior
- A commit row and its detail view use one explicit source of truth.
- A SHA present in the local session commit feed is a **local commit**, even
when the same SHA is also present in the linked pull request. It keeps the
current local `session.commit_diff` behavior and local commit actions.
- A SHA present only in the selected linked GitHub pull request is a
**GitHub commit**. Its source identity includes the workspace, owner,
repository, and SHA.
- A GitHub commit whose list response has no statistics does not display a
fabricated `+0 -0`. A known empty commit may display zero additions and zero
deletions only when those values came from commit-detail data.
- Opening a GitHub commit lazily loads its metadata, aggregate statistics,
changed files, and patches from GitHub's individual commit endpoint. It does
not depend on the commit object being present in the task worktree.
- Desktop and mobile commit details render the same source-aware data.
- GitHub-only commit details are read-only relative to the task worktree. They
do not expose reset, revert, amend, open-current-worktree-file, or local
context-expansion controls.
- A GitHub request failure produces the existing visible error treatment and
never silently falls back to local `git show` data.
- Viewing a GitHub commit never fetches, checks out, resets, or otherwise
mutates the task worktree.
## API contract
Add a workspace-scoped WebSocket request for an individual GitHub commit. The
request carries `workspace_id`, `owner`, `repo`, and an exact commit SHA. The
response carries commit metadata, exact additions/deletions/files-changed
statistics, and the existing GitHub-style file records needed by the diff
viewer.
The backend must authorize the requested repository through the same
workspace-scoped credential and repository checks used by the existing GitHub
pull request actions. Pull request commit-list data must distinguish
unavailable statistics from known zero statistics; omitted data must not be
serialized or interpreted as measured zeroes.
The individual-commit response must merge GitHub's paginated file records so a
commit is not presented as complete after only the first page. Provider limits
or unavailable patches remain explicit upstream limitations rather than a
reason to consult the local worktree.
## Regression scenarios
- **GIVEN** a task worktree at an obsolete pull request head and a linked pull
request that was force-pushed to new SHAs, **WHEN** the user opens a new
PR-only commit, **THEN** Kandev shows the GitHub commit's metadata, exact
statistics, files, and patches without invoking the local commit-diff path.
- **GIVEN** a PR-only commit returned by the pull request commit-list endpoint,
**WHEN** its row is rendered before detail data is loaded, **THEN** the row
omits statistics instead of displaying `+0 -0`.
- **GIVEN** the same SHA is present in both the local and pull request feeds,
**WHEN** the user opens it, **THEN** it is deduplicated as a local commit and
keeps the existing local detail path and local actions.
- **GIVEN** two linked repositories contain PR-only commits, **WHEN** either
commit is opened, **THEN** the request uses that row's workspace, owner,
repository, and SHA rather than the active worktree's repository.
- **GIVEN** a GitHub-only commit on mobile, **WHEN** the user taps the row,
**THEN** the existing full-height commit sheet renders the remote patch and
closes normally without exposing local commit actions.
- **GIVEN** GitHub rejects or cannot serve the individual commit request,
**WHEN** the user opens the row, **THEN** Kandev shows an error and does not
substitute local worktree content.
## Constraints
- Preserve the current local commit-detail flow and action semantics.
- Fetch individual GitHub commit details lazily; do not issue one request per
row while rendering the commit list.
- Reuse the current desktop panel and mobile full-height sheet rather than
adding a new detail composition.
- Keep GitHub patches in their existing patch representation, including the
current binary, unavailable, or truncated-patch treatment.
- Route any changed user-facing copy through i18n. No new explanatory copy is
required for this repair.
## Out of scope
- Automatically fetching or checking out the pull request head in the task
worktree.
- Resetting or rebasing a stale task branch.
- Adding a general stale-worktree warning or synchronization workflow.
- Eagerly enriching every pull request commit row with individual commit data.
- Parent selection or combined-diff controls for merge commits.
- Persistence or database schema changes.
- Adding equivalent remote-commit providers for non-GitHub integrations.