Files
kandev/apps/backend/internal/github/mock_client.go
T
João Salavisa 0f1081d423 feat(share): add public share links via GitHub Gists (#995)
* feat(share): add public share links via GitHub Gists

Adds a Share button to the chat panel header that publishes the
current session as a secret GitHub Gist on the user's account. The
share link routes through gistpreview.github.io to render share.html
as a styled kandev-themed page; snapshot.json + README.md ride along
for machine-readable + fallback views.

Snapshots run a redaction pass (absolute paths, common credential
shapes, .env contents, shell env vars) and strip <kandev-system>
prompt blocks via the shared sysprompt helper. Message filtering
whitelists user prose + tool_* types so internal noise never leaks
into shares.

Spec at docs/specs/public-share-links/spec.md.

* fix(share): coerce pendingClarification to bool for ClarificationSection

A late refactor extracted ClarificationSection with `pendingClarification: boolean`
on its props, but the call site passes the actual Message|null value. CI's tsc
build catches the mismatch; runtime was unaffected because the section only uses
the value in a truthiness check. Coerce with Boolean() at the call site to keep
the prop's intent narrow.

* fix(share): address PR #995 review feedback

Bugs (flagged by Greptile, Cubic, Claude):
 - abs-path redaction split on `root + "/"` so sibling dirs like
   /home/user/proj2 don't get corrupted when workspaceRoot is
   /home/user/proj; regression test added.
 - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so
   share.IsAlreadyGone matches consistently with the PAT client; this
   restores the "gist already revoked upstream" soft-success path for
   CLI-authenticated users.
 - SnapshotSizeBytes is now measured from the same MarshalIndent payload
   the gist backend uploads, so the size shown in the UI matches the
   actual gist bytes (was ~20-40% underreported).

Polish (flagged by CodeRabbit, Cubic):
 - Tool name + tool output now HTML-escaped; tool call args and output
   render via <pre><code> instead of triple-backtick fences so payloads
   containing ``` can't break out of the code block.
 - share-list.tsx: clear `busy` via finally and break error layout out
   of the justify-between row.
 - use-shares.ts: convert UseSharesResult to interface (repo TS rules);
   reset error+isLoading when ids are missing.
 - use-shares.test.tsx: wait on the actual async outcome (shares
   populated, listShares called) instead of isLoading flipping false.
 - provider.go: replace `repo.Close` cleanup with an explicit noop
   closure so the comment ("Cleanup is a no-op") and the impl match.
 - spec.md: aligned the shareability rule across What/Failure/Scenarios/
   Out-of-scope — any non-prehistory state can be shared; added language
   tags to fenced code blocks for markdownlint.

Skipped intentionally (reply on each thread):
 - HTML-escape every metadata badge: the values are controlled enum
   strings from kandev's own schema, not user input. Defense-in-depth
   noise. Tool name (which can come from agent runtime) IS escaped.
 - useShares request-token guard against stale responses: the dialog
   owns the hook lifecycle and opens against a single (taskId,
   sessionId) — the race the comment describes can't occur here.

* fix(share): redact paths for every worktree, not just the first

A single TaskSession can carry multiple worktrees when the task spans
multiple repos. The redactor previously only saw the first worktree
path, so absolute paths from secondary worktrees leaked into public
shares instead of being rewritten to repo-relative form.

NewRedactor is now variadic and applies the abs-path rule against every
root it receives, sorted longest-first so a root that is a prefix of
another (e.g. /workspace vs /workspace/repo1) gets matched by the more
specific one first. workspaceRootFor in builder.go becomes
workspaceRootsFor and emits every WorktreePath plus the WorkspacePath
fallback.

Flagged by Cubic on PR #995.

* fix(share): address PR #995 review round 3

Bugs (flagged by Cubic + Claude on gh_client.go:714):
 - DeleteGist now calls the existing isNotFoundErr helper instead of an
   inline strings.Contains(err, "HTTP 404"). The helper also covers
   "404 Not Found" and "status: 404", which some gh versions emit, so
   the share service's "gist already revoked upstream" soft-success
   path now fires consistently across CLI auth users.

Bugs (flagged by Claude on builder.go:220):
 - toolCallBlock used to read metadata["tool_name"] and metadata["args"]
   but production never writes those keys; the typed payload lives under
   metadata["normalized"] (a *streams.NormalizedPayload) and the tool
   kind is on Message.Type. Switched to deriving ToolName from
   strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit",
   …) and reading args from metadata["normalized"], with backward-compat
   fallback to the older "tool_name"/"args" keys for the debug-fixture
   replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape
   exercises the real session shape.

Bugs (flagged by CodeRabbit on redaction.go:110):
 - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted
   paths) used to leak because the matcher only handled root + "/".
   Switched to a precompiled regex per root —
   regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone
   `root` get stripped, while sibling dirs like /workspace2 still don't
   match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the
   bare-root variants.

Doc (flagged by Claude on spec.md):
 - Two malformed fence closers (`go and `text on closing fences) corrected
   to plain ``` so the file is markdownlint-clean.

* fix(share): narrow abs-path boundary to avoid sibling-prefix corruption

Round 4 review (Claude + CodeRabbit) caught a regression in the regex
introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch
bare-root cases, but `\b` fires on any word↔non-word transition. So a
sibling path like `/workspace.bak`, `/workspace-old`, or
`/workspace-other` would false-match the `/workspace` root (since
e→. and e→- are word boundaries), stripping it and leaving a
corrupted `.bak` / `-old/file.ts` in the share.

Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or
end-of-string only — never punctuation or quote chars. The boundary
is captured and re-emitted via ReplaceAllStringFunc (or dropped, in
the `/` case) so prose loses only the root, not the surrounding space.

Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering
`.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes.
The quoted-bare-root case (`"/workspace"`) no longer matches and was
dropped from the standalone-root test — contrived enough that a small
leak there is preferable to corrupting realistic sibling paths.

* refactor(share): move share button into chat status bar

Replaces the dedicated PanelHeaderBarSplit above the message list with
a slot in ChatStatusBar (the existing row holding TodoIndicator and
PRStatusChip just above the chat input). The button anchors right via
ml-auto; when the "proceed to next step" affordance is present it
takes the right anchor instead and the share button stays inline.

Also tightens the iconOnly variant in ShareButton to a ghost style
that matches the muted-foreground chips in the status bar, and exports
shareableSessionStateClient from share-button.tsx so the gating logic
lives next to the entry point it controls.

Net -23 lines in task-chat-panel.tsx: the extra header bar and the
local gate helper are gone.

* docs(share): broaden tooltip + add share/ to CLAUDE.md tree

The share tooltip read "Share this completed task with a public link",
but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT
/ FAILED / CANCELLED too. A user hovering mid-run saw misleading copy;
reworded to "Share this task conversation as a public link" which
matches the actual gate.

Also added the missing internal/task/share/ entry to the task/ subtree
in CLAUDE.md so the new package shows up in the repo map — flagged
across multiple rounds.

* docs: add internal/task/share/ to repo map

Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to)
was missing the share/ subpackage under task/. Adding it now so the
repo map matches what's checked in.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:35:41 +01:00

549 lines
15 KiB
Go

package github
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
const mockDefaultUser = "mock-user"
// prKey is a composite key for PR lookups by owner/repo/number.
type prKey struct {
Owner string
Repo string
Number int
}
// branchKey is a composite key for PR lookups by owner/repo/branch.
type branchKey struct {
Owner string
Repo string
Branch string
}
// checkKey is a composite key for check-run lookups by owner/repo/ref.
type checkKey struct {
Owner string
Repo string
Ref string
}
// submittedReview records a SubmitReview call for test assertions.
type submittedReview struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Event string `json:"event"`
Body string `json:"body"`
}
// mergedPR records a MergePR call for test assertions.
type mergedPR struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
MergeMethod string `json:"merge_method"`
}
// repoKey is a composite key for per-repo lookups by owner/repo.
type repoKey struct {
Owner string
Repo string
}
// MockClient implements Client with in-memory configurable data for E2E testing.
// All data is protected by a sync.RWMutex for thread safety.
type MockClient struct {
mu sync.RWMutex
user string
authenticated bool
authError string
prs map[prKey]*PR
prsByBranch map[branchKey]*PR
orgs []GitHubOrg
repos map[string][]GitHubRepo
branches map[repoKey][]RepoBranch
reviews map[prKey][]PRReview
comments map[prKey][]PRComment
checks map[checkKey][]CheckRun
files map[prKey][]PRFile
commits map[prKey][]PRCommitInfo
submittedReviews []submittedReview
mergedPRs []mergedPR
mergeMethods map[repoKey]RepoMergeMethods
gists map[string]mockGist
deletedGists []string
nextGistID int
}
// mockGist captures a gist that was created via the mock client so tests
// can inspect what would have been uploaded.
type mockGist struct {
ID string
Description string
Public bool
Files map[string]GistFile
HTMLURL string
}
// NewMockClient creates a new MockClient with default values.
func NewMockClient() *MockClient {
return &MockClient{
user: mockDefaultUser,
authenticated: true,
prs: make(map[prKey]*PR),
prsByBranch: make(map[branchKey]*PR),
repos: make(map[string][]GitHubRepo),
branches: make(map[repoKey][]RepoBranch),
reviews: make(map[prKey][]PRReview),
comments: make(map[prKey][]PRComment),
checks: make(map[checkKey][]CheckRun),
files: make(map[prKey][]PRFile),
commits: make(map[prKey][]PRCommitInfo),
mergeMethods: make(map[repoKey]RepoMergeMethods),
gists: make(map[string]mockGist),
}
}
// --- Client interface implementation ---
func (m *MockClient) IsAuthenticated(context.Context) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.authenticated, nil
}
func (m *MockClient) GetAuthenticatedUser(context.Context) (string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.user, nil
}
func (m *MockClient) GetPR(_ context.Context, owner, repo string, number int) (*PR, error) {
m.mu.RLock()
defer m.mu.RUnlock()
pr, ok := m.prs[prKey{owner, repo, number}]
if !ok {
return nil, fmt.Errorf("mock: PR %s/%s#%d not found", owner, repo, number)
}
return pr, nil
}
func (m *MockClient) FindPRByBranch(_ context.Context, owner, repo, branch string) (*PR, error) {
m.mu.RLock()
defer m.mu.RUnlock()
pr := m.prsByBranch[branchKey{owner, repo, branch}]
return pr, nil
}
func (m *MockClient) ListAuthoredPRs(_ context.Context, owner, repo string) ([]*PR, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*PR
for k, pr := range m.prs {
if k.Owner == owner && k.Repo == repo && pr.AuthorLogin == m.user {
result = append(result, pr)
}
}
return result, nil
}
func (m *MockClient) ListReviewRequestedPRs(context.Context, string, string, string) ([]*PR, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*PR
for _, pr := range m.prs {
if len(pr.RequestedReviewers) > 0 {
result = append(result, pr)
}
}
return result, nil
}
func (m *MockClient) ListIssues(context.Context, string, string) ([]*Issue, error) {
return nil, nil
}
func (m *MockClient) ListIssuesPaged(context.Context, string, string, int, int) (*IssueSearchPage, error) {
return &IssueSearchPage{Issues: []*Issue{}, TotalCount: 0, Page: 1, PerPage: 50}, nil
}
func (m *MockClient) SearchPRs(context.Context, string, string) ([]*PR, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*PR, 0, len(m.prs))
for _, pr := range m.prs {
result = append(result, pr)
}
return result, nil
}
func (m *MockClient) SearchPRsPaged(ctx context.Context, _, _ string, page, perPage int) (*PRSearchPage, error) {
prs, err := m.SearchPRs(ctx, "", "")
if err != nil {
return nil, err
}
return &PRSearchPage{PRs: prs, TotalCount: len(prs), Page: page, PerPage: perPage}, nil
}
func (m *MockClient) GetIssueState(context.Context, string, string, int) (string, error) {
return defaultPRState, nil
}
func (m *MockClient) ListUserOrgs(context.Context) ([]GitHubOrg, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.orgs == nil {
return []GitHubOrg{}, nil
}
return m.orgs, nil
}
func (m *MockClient) SearchOrgRepos(_ context.Context, org, query string, _ int) ([]GitHubRepo, error) {
m.mu.RLock()
defer m.mu.RUnlock()
repos := m.repos[org]
if query == "" {
return repos, nil
}
var filtered []GitHubRepo
for _, r := range repos {
if strings.Contains(strings.ToLower(r.FullName), strings.ToLower(query)) {
filtered = append(filtered, r)
}
}
return filtered, nil
}
func (m *MockClient) ListPRReviews(_ context.Context, owner, repo string, number int) ([]PRReview, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.reviews[prKey{owner, repo, number}], nil
}
func (m *MockClient) ListPRComments(_ context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) {
m.mu.RLock()
defer m.mu.RUnlock()
all := m.comments[prKey{owner, repo, number}]
if since == nil {
return all, nil
}
var filtered []PRComment
for _, c := range all {
if c.UpdatedAt.After(*since) {
filtered = append(filtered, c)
}
}
return filtered, nil
}
func (m *MockClient) ListCheckRuns(_ context.Context, owner, repo, ref string) ([]CheckRun, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.checks[checkKey{owner, repo, ref}], nil
}
func (m *MockClient) GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) {
return getPRFeedback(ctx, m, owner, repo, number)
}
func (m *MockClient) GetPRStatus(ctx context.Context, owner, repo string, number int) (*PRStatus, error) {
return getPRStatus(ctx, m, owner, repo, number)
}
func (m *MockClient) ListPRFiles(_ context.Context, owner, repo string, number int) ([]PRFile, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.files[prKey{owner, repo, number}], nil
}
func (m *MockClient) ListPRCommits(_ context.Context, owner, repo string, number int) ([]PRCommitInfo, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.commits[prKey{owner, repo, number}], nil
}
func (m *MockClient) ListRepoBranches(_ context.Context, owner, repo string) ([]RepoBranch, error) {
m.mu.RLock()
defer m.mu.RUnlock()
branches, ok := m.branches[repoKey{owner, repo}]
if !ok {
return nil, &GitHubAPIError{
StatusCode: 404,
Endpoint: fmt.Sprintf("/repos/%s/%s/branches", owner, repo),
Body: fmt.Sprintf("repository %s/%s not found", owner, repo),
}
}
out := make([]RepoBranch, len(branches))
copy(out, branches)
return out, nil
}
func (m *MockClient) SubmitReview(_ context.Context, owner, repo string, number int, event, body string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.submittedReviews = append(m.submittedReviews, submittedReview{
Owner: owner, Repo: repo, Number: number, Event: event, Body: body,
})
return nil
}
func (m *MockClient) GetRepoMergeMethods(_ context.Context, owner, repo string) (RepoMergeMethods, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if methods, ok := m.mergeMethods[repoKey{owner, repo}]; ok {
return methods, nil
}
// Default to all three allowed so existing e2e fixtures don't have to
// seed merge settings just to exercise the merge button.
return RepoMergeMethods{Merge: true, Squash: true, Rebase: true}, nil
}
// SetRepoMergeMethods overrides the allowed merge methods for a repo.
// Used by e2e fixtures to exercise the squash-only / rebase-only paths.
func (m *MockClient) SetRepoMergeMethods(owner, repo string, methods RepoMergeMethods) {
m.mu.Lock()
defer m.mu.Unlock()
m.mergeMethods[repoKey{owner, repo}] = methods
}
func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int, mergeMethod string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.mergedPRs = append(m.mergedPRs, mergedPR{
Owner: owner, Repo: repo, Number: number, MergeMethod: mergeMethod,
})
now := time.Now().UTC()
if pr, ok := m.prs[prKey{owner, repo, number}]; ok {
pr.State = "merged"
pr.MergedAt = &now
pr.Mergeable = false
}
return nil
}
func (m *MockClient) CreateGist(_ context.Context, in CreateGistInput) (*GistResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.nextGistID++
id := fmt.Sprintf("mock-gist-%d", m.nextGistID)
htmlURL := "https://gist.github.com/" + m.user + "/" + id
files := make(map[string]GistFile, len(in.Files))
for k, v := range in.Files {
files[k] = v
}
m.gists[id] = mockGist{
ID: id,
Description: in.Description,
Public: in.Public,
Files: files,
HTMLURL: htmlURL,
}
return &GistResponse{
ID: id,
HTMLURL: htmlURL,
CreatedAt: time.Now().UTC(),
}, nil
}
func (m *MockClient) DeleteGist(_ context.Context, gistID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.gists[gistID]; !ok {
return &GitHubAPIError{
StatusCode: 404,
Endpoint: "/gists/" + gistID,
Body: "gist not found",
}
}
delete(m.gists, gistID)
m.deletedGists = append(m.deletedGists, gistID)
return nil
}
// Gists returns all currently-stored gists for test inspection.
func (m *MockClient) Gists() map[string]mockGist {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]mockGist, len(m.gists))
for k, v := range m.gists {
out[k] = v
}
return out
}
// DeletedGists returns the IDs of gists that were deleted, in call order.
func (m *MockClient) DeletedGists() []string {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]string, len(m.deletedGists))
copy(out, m.deletedGists)
return out
}
// --- Setter methods for HTTP control endpoints ---
// SetUser sets the authenticated username.
func (m *MockClient) SetUser(username string) {
m.mu.Lock()
defer m.mu.Unlock()
m.user = username
}
// SetAuthHealth toggles the authenticated state for the e2e auth-lost test.
// When authenticated=false the popover renders the "Reconnect GitHub" branch.
// authError is an opaque message preserved for diagnostics.
func (m *MockClient) SetAuthHealth(authenticated bool, authError string) {
m.mu.Lock()
defer m.mu.Unlock()
m.authenticated = authenticated
m.authError = authError
}
// AddPR adds a PR to the mock data store, indexed by owner/repo/number and branch.
func (m *MockClient) AddPR(pr *PR) {
m.mu.Lock()
defer m.mu.Unlock()
m.prs[prKey{pr.RepoOwner, pr.RepoName, pr.Number}] = pr
if pr.HeadBranch != "" {
m.prsByBranch[branchKey{pr.RepoOwner, pr.RepoName, pr.HeadBranch}] = pr
}
}
// AddOrgs appends organizations to the mock data store.
func (m *MockClient) AddOrgs(orgs []GitHubOrg) {
m.mu.Lock()
defer m.mu.Unlock()
m.orgs = append(m.orgs, orgs...)
}
// AddBranches sets branches for a repository.
func (m *MockClient) AddBranches(owner, repo string, branches []RepoBranch) {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]RepoBranch, len(branches))
copy(cp, branches)
m.branches[repoKey{owner, repo}] = cp
}
// AddRepos appends repositories for an organization.
func (m *MockClient) AddRepos(org string, repos []GitHubRepo) {
m.mu.Lock()
defer m.mu.Unlock()
m.repos[org] = append(m.repos[org], repos...)
}
// AddReviews appends reviews for a PR.
func (m *MockClient) AddReviews(owner, repo string, number int, reviews []PRReview) {
m.mu.Lock()
defer m.mu.Unlock()
k := prKey{owner, repo, number}
m.reviews[k] = append(m.reviews[k], reviews...)
}
// AddComments appends comments for a PR.
func (m *MockClient) AddComments(owner, repo string, number int, comments []PRComment) {
m.mu.Lock()
defer m.mu.Unlock()
k := prKey{owner, repo, number}
m.comments[k] = append(m.comments[k], comments...)
}
// AddCheckRuns appends check runs for a ref.
func (m *MockClient) AddCheckRuns(owner, repo, ref string, checks []CheckRun) {
m.mu.Lock()
defer m.mu.Unlock()
k := checkKey{owner, repo, ref}
m.checks[k] = append(m.checks[k], checks...)
}
// ReplaceCheckRuns overwrites the check runs for a ref. Used by the e2e mock
// controller so a follow-up seed call yields deterministic state.
func (m *MockClient) ReplaceCheckRuns(owner, repo, ref string, checks []CheckRun) {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]CheckRun, len(checks))
copy(cp, checks)
m.checks[checkKey{owner, repo, ref}] = cp
}
// ReplaceReviews overwrites the reviews for a PR.
func (m *MockClient) ReplaceReviews(owner, repo string, number int, reviews []PRReview) {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]PRReview, len(reviews))
copy(cp, reviews)
m.reviews[prKey{owner, repo, number}] = cp
}
// ReplaceComments overwrites the comments for a PR.
func (m *MockClient) ReplaceComments(owner, repo string, number int, comments []PRComment) {
m.mu.Lock()
defer m.mu.Unlock()
cp := make([]PRComment, len(comments))
copy(cp, comments)
m.comments[prKey{owner, repo, number}] = cp
}
// AddPRFiles appends files for a PR.
func (m *MockClient) AddPRFiles(owner, repo string, number int, files []PRFile) {
m.mu.Lock()
defer m.mu.Unlock()
k := prKey{owner, repo, number}
m.files[k] = append(m.files[k], files...)
}
// AddPRCommits appends commits for a PR.
func (m *MockClient) AddPRCommits(owner, repo string, number int, commits []PRCommitInfo) {
m.mu.Lock()
defer m.mu.Unlock()
k := prKey{owner, repo, number}
m.commits[k] = append(m.commits[k], commits...)
}
// Reset clears all mock data and resets the user to the default.
func (m *MockClient) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.user = mockDefaultUser
m.authenticated = true
m.authError = ""
m.prs = make(map[prKey]*PR)
m.prsByBranch = make(map[branchKey]*PR)
m.orgs = nil
m.repos = make(map[string][]GitHubRepo)
m.branches = make(map[repoKey][]RepoBranch)
m.reviews = make(map[prKey][]PRReview)
m.comments = make(map[prKey][]PRComment)
m.checks = make(map[checkKey][]CheckRun)
m.files = make(map[prKey][]PRFile)
m.commits = make(map[prKey][]PRCommitInfo)
m.submittedReviews = nil
m.mergedPRs = nil
m.mergeMethods = make(map[repoKey]RepoMergeMethods)
m.gists = make(map[string]mockGist)
m.deletedGists = nil
m.nextGistID = 0
}
// SubmittedReviews returns all recorded SubmitReview calls.
func (m *MockClient) SubmittedReviews() []submittedReview {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]submittedReview, len(m.submittedReviews))
copy(result, m.submittedReviews)
return result
}
// MergedPRs returns all recorded MergePR calls.
func (m *MockClient) MergedPRs() []mergedPR {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]mergedPR, len(m.mergedPRs))
copy(result, m.mergedPRs)
return result
}