Files
orca/src/main/ipc/github.test.ts
T
JinjingandOrca 33bd676644 fix(github): align PR source and review head origin (#10677)
* fix(github): align PR source and review head origin

* fix(github): pin number-based work item open to the repo source preference

Open-by-number and details still ran the upstream-first multi-candidate PR
probe, so a fork and its upstream sharing a PR number opened different PRs
than the list and start-point paths did once #10677 pinned those to origin.

Thread repo.issueSourcePreference through dispatchWorkItem, getWorkItemDetails,
getRepoWorkItem, and getRepoWorkItemDetails. getWorkItemByOwnerRepo is left
alone: explicit owner/repo already pins identity. auto/upstream/undefined keep
the multi-candidate probe.

Co-authored-by: Orca <help@stably.ai>

* test(github): enforce origin preference in review head origin resolution

The explicit origin preference must short-circuit before any identity probe, so no remote queries should occur. Add validation to reject unexpected remotes and tighten the test assertion to verify no remote get-url calls happen at all.

* fix(github): enforce origin preference in issue open-by-number lookup

listWorkItems and getWorkItem must share preference so origin/upstream
toggles cannot disagree. Explicit origin preference now fail-closes when
origin identity is unresolved (no bare-lookup fallback), matching the
PR candidate resolution rule.

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-25 22:48:53 -07:00

1381 lines
38 KiB
TypeScript

/* eslint-disable max-lines -- Why: GitHub IPC tests share one mocked Electron
handler harness; keeping the related route wiring together avoids duplicated setup. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const ORIGINAL_PLATFORM = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
const {
handleMock,
getPRForBranchMock,
getIssueMock,
getWorkItemMock,
getWorkItemByOwnerRepoMock,
getWorkItemDetailsMock,
getPRFileContentsMock,
getPRChecksMock,
getPRCheckDetailsMock,
getPRCommentsMock,
resolveReviewThreadMock,
setPRFileViewedMock,
addPRReviewCommentMock,
addPRReviewCommentReplyMock,
updatePRTitleMock,
listIssuesMock,
listWorkItemsMock,
countWorkItemsMock,
createIssueMock,
updateIssueMock,
addIssueCommentMock,
listLabelsMock,
listAssignableUsersMock,
getAuthenticatedViewerMock,
mergePRMock,
setPRAutoMergeMock,
updatePRStateMock,
rerunPRChecksMock,
requestPRReviewersMock,
removePRReviewersMock,
checkOrcaStarredMock,
starOrcaMock,
trackMock,
getCohortAtEmitMock,
getAllWebContentsMock,
sendToTrustedUIRendererMock,
clearVisiblePRRefreshWindowMock,
enqueuePRRefreshMock,
refreshPRNowMock,
reportVisiblePRRefreshCandidatesMock,
setPRRefreshOutcomeObserverMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getIssueMock: vi.fn(),
getWorkItemMock: vi.fn(),
getWorkItemByOwnerRepoMock: vi.fn(),
getWorkItemDetailsMock: vi.fn(),
getPRFileContentsMock: vi.fn(),
getPRChecksMock: vi.fn(),
getPRCheckDetailsMock: vi.fn(),
getPRCommentsMock: vi.fn(),
resolveReviewThreadMock: vi.fn(),
setPRFileViewedMock: vi.fn(),
addPRReviewCommentMock: vi.fn(),
addPRReviewCommentReplyMock: vi.fn(),
updatePRTitleMock: vi.fn(),
listIssuesMock: vi.fn(),
listWorkItemsMock: vi.fn(),
countWorkItemsMock: vi.fn(),
createIssueMock: vi.fn(),
updateIssueMock: vi.fn(),
addIssueCommentMock: vi.fn(),
listLabelsMock: vi.fn(),
listAssignableUsersMock: vi.fn(),
getAuthenticatedViewerMock: vi.fn(),
mergePRMock: vi.fn(),
setPRAutoMergeMock: vi.fn(),
updatePRStateMock: vi.fn(),
rerunPRChecksMock: vi.fn(),
requestPRReviewersMock: vi.fn(),
removePRReviewersMock: vi.fn(),
checkOrcaStarredMock: vi.fn(),
starOrcaMock: vi.fn(),
trackMock: vi.fn(),
getCohortAtEmitMock: vi.fn(),
getAllWebContentsMock: vi.fn(),
sendToTrustedUIRendererMock: vi.fn(),
clearVisiblePRRefreshWindowMock: vi.fn(),
enqueuePRRefreshMock: vi.fn(),
refreshPRNowMock: vi.fn(),
reportVisiblePRRefreshCandidatesMock: vi.fn(),
setPRRefreshOutcomeObserverMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock
},
webContents: {
getAllWebContents: getAllWebContentsMock
}
}))
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock,
getIssue: getIssueMock,
getWorkItem: getWorkItemMock,
getWorkItemByOwnerRepo: getWorkItemByOwnerRepoMock,
listIssues: listIssuesMock,
listWorkItems: listWorkItemsMock,
countWorkItems: countWorkItemsMock,
createIssue: createIssueMock,
updateIssue: updateIssueMock,
addIssueComment: addIssueCommentMock,
listLabels: listLabelsMock,
listAssignableUsers: listAssignableUsersMock,
getAuthenticatedViewer: getAuthenticatedViewerMock,
getPRChecks: getPRChecksMock,
getPRCheckDetails: getPRCheckDetailsMock,
getPRComments: getPRCommentsMock,
resolveReviewThread: resolveReviewThreadMock,
setPRFileViewed: setPRFileViewedMock,
addPRReviewComment: addPRReviewCommentMock,
addPRReviewCommentReply: addPRReviewCommentReplyMock,
updatePRTitle: updatePRTitleMock,
mergePR: mergePRMock,
setPRAutoMerge: setPRAutoMergeMock,
updatePRState: updatePRStateMock,
rerunPRChecks: rerunPRChecksMock,
requestPRReviewers: requestPRReviewersMock,
removePRReviewers: removePRReviewersMock,
checkOrcaStarred: checkOrcaStarredMock,
starOrca: starOrcaMock
}))
vi.mock('../github/work-item-details', () => ({
getWorkItemDetails: getWorkItemDetailsMock,
getPRFileContents: getPRFileContentsMock
}))
vi.mock('../github/pr-refresh-coordinator', () => ({
clearVisiblePRRefreshWindow: clearVisiblePRRefreshWindowMock,
enqueuePRRefresh: enqueuePRRefreshMock,
refreshPRNow: refreshPRNowMock,
reportVisiblePRRefreshCandidates: reportVisiblePRRefreshCandidatesMock,
setPRRefreshOutcomeObserver: setPRRefreshOutcomeObserverMock
}))
vi.mock('../telemetry/client', () => ({
track: trackMock
}))
vi.mock('../telemetry/cohort-classifier', () => ({
getCohortAtEmit: getCohortAtEmitMock
}))
vi.mock('./ui', () => ({
sendToTrustedUIRenderer: sendToTrustedUIRendererMock
}))
import { registerGitHubHandlers } from './github'
import { clearPRRefreshValidationBackoffForTests } from '../github/pr-refresh-validation-backoff'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('registerGitHubHandlers', () => {
const handlers: HandlerMap = {}
type FixtureRepo = {
id: string
path: string
displayName: string
badgeColor: string
addedAt: number
connectionId?: string | null
executionHostId?: string | null
issueSourcePreference?: 'origin' | 'upstream'
}
let repos: FixtureRepo[] = []
let projects: {
id: string
displayName: string
badgeColor: string
sourceRepoIds: string[]
localWindowsRuntimePreference?: { kind: 'wsl'; distro: string }
createdAt: number
updatedAt: number
}[] = []
const store = {
getRepos: () => repos,
getProjects: () => projects,
getSettings: () => ({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
}
const stats = {
hasCountedPR: () => false,
record: vi.fn()
}
beforeEach(() => {
setPlatform(ORIGINAL_PLATFORM)
handleMock.mockReset()
getPRForBranchMock.mockReset()
getIssueMock.mockReset()
getWorkItemMock.mockReset()
getWorkItemByOwnerRepoMock.mockReset()
getWorkItemDetailsMock.mockReset()
getPRFileContentsMock.mockReset()
getPRChecksMock.mockReset()
getPRCheckDetailsMock.mockReset()
getPRCommentsMock.mockReset()
resolveReviewThreadMock.mockReset()
setPRFileViewedMock.mockReset()
addPRReviewCommentMock.mockReset()
addPRReviewCommentReplyMock.mockReset()
updatePRTitleMock.mockReset()
listIssuesMock.mockReset()
listWorkItemsMock.mockReset()
countWorkItemsMock.mockReset()
createIssueMock.mockReset()
updateIssueMock.mockReset()
addIssueCommentMock.mockReset()
listLabelsMock.mockReset()
listAssignableUsersMock.mockReset()
getAuthenticatedViewerMock.mockReset()
mergePRMock.mockReset()
setPRAutoMergeMock.mockReset()
updatePRStateMock.mockReset()
rerunPRChecksMock.mockReset()
requestPRReviewersMock.mockReset()
removePRReviewersMock.mockReset()
checkOrcaStarredMock.mockReset()
starOrcaMock.mockReset()
trackMock.mockReset()
getCohortAtEmitMock.mockReset()
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: undefined })
getAllWebContentsMock.mockReset()
getAllWebContentsMock.mockReturnValue([])
sendToTrustedUIRendererMock.mockReset()
clearVisiblePRRefreshWindowMock.mockReset()
enqueuePRRefreshMock.mockReset()
refreshPRNowMock.mockReset()
reportVisiblePRRefreshCandidatesMock.mockReset()
setPRRefreshOutcomeObserverMock.mockReset()
clearPRRefreshValidationBackoffForTests()
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
// Reset fixture repos to the default single-repo fixture each test, so
// individual tests can mutate the list without leaking preferences across
// tests (e.g. a preference-threading test could otherwise shadow the
// default-undefined assertions in sibling tests).
repos = [
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
]
projects = []
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
})
it('normalizes registered repo paths before invoking github clients', async () => {
getPRForBranchMock.mockResolvedValue({ number: 42 })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:prForBranch'](null, {
repoPath: '/workspace/repo/../repo',
branch: 'feature/test'
})
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
'feature/test',
null,
null,
null
)
})
it('targets mutation notifications without broadcasting to 100 browser guests', async () => {
const guestSends = Array.from({ length: 100 }, () => vi.fn())
getAllWebContentsMock.mockReturnValue(
guestSends.map((send, index) => ({
id: index + 100,
isDestroyed: () => false,
send
}))
)
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:notifyWorkItemMutated'](
{ sender: { id: 1 } },
{
repoPath: '/home/runtime/repo',
repoId: 'repo-1',
type: 'pr',
number: 42
}
)
expect(result).toBe(true)
expect(sendToTrustedUIRendererMock).toHaveBeenCalledOnce()
expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith(
'gh:workItemMutated',
{
repoPath: '/workspace/repo',
repoId: 'repo-1',
type: 'pr',
number: 42
},
1
)
expect(getAllWebContentsMock).not.toHaveBeenCalled()
expect(guestSends.reduce((total, send) => total + send.mock.calls.length, 0)).toBe(0)
})
it('targets mutation notifications with resolved repo id when called by repo path', async () => {
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:notifyWorkItemMutated'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
type: 'issue',
number: 7
}
)
expect(result).toBe(true)
expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith(
'gh:workItemMutated',
{
repoPath: '/workspace/repo',
repoId: 'repo-1',
type: 'issue',
number: 7
},
1
)
})
it('targets PR file viewed mutations with repo id for cache invalidation', async () => {
setPRFileViewedMock.mockResolvedValue(true)
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:setPRFileViewed'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
pullRequestId: 'PR_kw',
path: 'src/app.ts',
viewed: true
}
)
expect(result).toBe(true)
expect(sendToTrustedUIRendererMock).toHaveBeenCalledWith(
'gh:workItemMutated',
{
repoPath: '/workspace/repo',
repoId: 'repo-1',
type: 'pr',
number: 42
},
1
)
})
it('rejects unknown repository paths', async () => {
registerGitHubHandlers(store as never, stats as never)
expect(() =>
handlers['gh:issue'](null, {
repoPath: '/workspace/other',
number: 7
})
).toThrow('Access denied: unknown repository path')
expect(getIssueMock).not.toHaveBeenCalled()
})
it('returns typed automatic PR refresh validation skips without enqueueing', async () => {
registerGitHubHandlers(store as never, stats as never)
const candidate = {
cacheKey: 'missing::feature/test',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/test',
repoKind: 'git' as const
}
const first = await handlers['gh:enqueuePRRefresh'](null, {
candidate,
reason: 'active',
priority: 80
})
const second = await handlers['gh:enqueuePRRefresh'](null, {
candidate,
reason: 'active',
priority: 80
})
expect(first).toEqual({ kind: 'skipped', skippedReason: 'validation-denied' })
expect(second).toEqual({ kind: 'skipped', skippedReason: 'validation-backoff' })
expect(first).not.toBe(false)
expect(second).not.toBe(false)
expect(enqueuePRRefreshMock).not.toHaveBeenCalled()
})
it('uses registered repo routing fields for automatic PR refresh candidates', async () => {
repos = [
{
id: 'repo-ssh',
path: '/workspace/remote-repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'ssh-real',
executionHostId: 'ssh:ssh-real'
}
]
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:enqueuePRRefresh'](null, {
candidate: {
cacheKey: 'remote::feature/test',
repoPath: '/workspace/remote-repo',
repoId: 'repo-ssh',
branch: 'feature/test',
repoKind: 'git',
connectionId: 'ssh-stale',
executionHostId: 'runtime:stale',
connectionState: 'disconnected',
localGitOptions: { wslDistro: 'Stale' }
},
reason: 'active',
priority: 80
})
const candidate = enqueuePRRefreshMock.mock.calls[0]?.[0]
expect(candidate).toEqual(
expect.objectContaining({
repoPath: '/workspace/remote-repo',
repoId: 'repo-ssh',
connectionId: 'ssh-real',
executionHostId: 'ssh:ssh-real',
connectionState: 'connected'
})
)
expect(candidate).not.toHaveProperty('localGitOptions')
})
it('keeps manual PR refresh validation strict', async () => {
registerGitHubHandlers(store as never, stats as never)
await expect(
handlers['gh:refreshPRNow'](null, {
candidate: {
cacheKey: 'missing::feature/test',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/test',
repoKind: 'git'
}
})
).rejects.toThrow('Access denied: unknown repository path')
expect(refreshPRNowMock).not.toHaveBeenCalled()
})
it('skips stale visible PR refresh candidates without rejecting the batch', async () => {
registerGitHubHandlers(store as never, stats as never)
expect(
handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 7, once: vi.fn() } },
{
generation: 1,
candidates: [
{
cacheKey: '/workspace/repo::feature/live',
repoPath: '/workspace/repo',
branch: 'feature/live',
repoKind: 'git',
repoId: 'repo-1'
},
{
cacheKey: '/workspace/missing::feature/stale',
repoPath: '/workspace/missing',
branch: 'feature/stale',
repoKind: 'git',
repoId: 'repo-missing'
}
]
}
)
).toBe(true)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith(
[
expect.objectContaining({
repoPath: '/workspace/repo',
repoId: 'repo-1',
branch: 'feature/live'
})
],
1,
7
)
})
it('clears a sender visible PR refresh set when all current candidates are invalid', async () => {
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 8, once: vi.fn() } },
{
generation: 2,
candidates: [
{
cacheKey: 'missing::feature/old',
repoPath: '/workspace/missing',
repoId: 'missing-repo',
branch: 'feature/old',
repoKind: 'git'
}
]
}
)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith([], 2, 8)
})
it('rejects GitHub source context from a different host', async () => {
registerGitHubHandlers(store as never, stats as never)
expect(() =>
handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId: 'ssh:openclaw-2',
repoId: 'repo-1'
}
})
).toThrow('Access denied: GitHub source host does not match repository host')
expect(listWorkItemsMock).not.toHaveBeenCalled()
})
it('guards label metadata lookups with source host context', async () => {
listLabelsMock.mockResolvedValue(['bug'])
repos = [
...repos,
{
id: 'repo-ssh',
path: '/workspace/remote-repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
connectionId: 'openclaw-2',
executionHostId: 'ssh:openclaw-2'
}
]
registerGitHubHandlers(store as never, stats as never)
await expect(
handlers['gh:listLabels'](null, {
repoPath: '/workspace/remote-repo',
repoId: 'repo-ssh',
sourceContext: {
kind: 'task-source',
provider: 'github',
projectId: 'project-1',
hostId: 'ssh:openclaw-2',
repoId: 'repo-ssh'
}
})
).resolves.toEqual(['bug'])
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/remote-repo', undefined, 'openclaw-2')
})
it('forwards listIssues for registered repositories and unwraps items', async () => {
listIssuesMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined, null)
expect(result).toEqual([])
})
it('drops the error field from listIssues envelope at the IPC boundary', async () => {
// Why: src/main/ipc/github.ts intentionally unwraps the { items, error? }
// envelope to just `items` to preserve the pre-feature-1
// `Promise<IssueInfo[]>` contract for `gh:listIssues`. Feature 1's UI
// consumes the richer envelope through `gh:listWorkItems` instead. This
// test locks in that intentional drop so a future change that starts
// propagating the error through this channel (or that throws when an
// error is present) is caught.
listIssuesMock.mockResolvedValue({
items: [],
error: {
type: 'permission_denied',
message:
"You don't have permission to read issues for this repository. Check your GitHub token scopes."
}
})
registerGitHubHandlers(store as never, stats as never)
const result = await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, undefined, null)
expect(result).toEqual([])
})
it('threads issueSourcePreference through gh:listIssues', async () => {
// Why: repo.issueSourcePreference must reach listIssues so the upstream
// repo is queried when configured. A regression that drops the arg would
// pass the default-fixture tests (which assert `undefined`) silently, so
// this test pins the non-undefined preference-threading contract.
repos[0].issueSourcePreference = 'upstream'
listIssuesMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listIssues'](null, {
repoPath: '/workspace/repo',
limit: 5
})
expect(listIssuesMock).toHaveBeenCalledWith('/workspace/repo', 5, 'upstream', null)
})
it('threads issueSourcePreference through gh:listWorkItems', async () => {
// Why: gh:listWorkItems must also forward repo.issueSourcePreference
// (5th arg) so the work-items view honors the per-repo source selector.
repos[0].issueSourcePreference = 'origin'
listWorkItemsMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
limit: 10,
query: 'is:open',
page: 2,
noCache: true
})
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
10,
'is:open',
2,
'origin',
null,
true
)
})
it('routes local WSL project GitHub issue and work-item IPC through project git options', async () => {
setPlatform('win32')
projects = [
{
id: 'project-1',
displayName: 'repo',
badgeColor: 'blue',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
createdAt: 0,
updatedAt: 0
}
]
listIssuesMock.mockResolvedValue({ items: [] })
listWorkItemsMock.mockResolvedValue({ items: [] })
countWorkItemsMock.mockResolvedValue(0)
getIssueMock.mockResolvedValue(null)
createIssueMock.mockResolvedValue({ ok: true, number: 1, url: 'https://example.com/1' })
updateIssueMock.mockResolvedValue({ ok: true })
addIssueCommentMock.mockResolvedValue({ ok: true })
listLabelsMock.mockResolvedValue([])
listAssignableUsersMock.mockResolvedValue([])
getPRForBranchMock.mockResolvedValue(null)
registerGitHubHandlers(store as never, stats as never)
const localGitOptions = { wslDistro: 'Ubuntu' }
await handlers['gh:prForBranch'](null, {
repoPath: '/workspace/repo',
branch: 'feature/wsl'
})
await handlers['gh:reportVisiblePRRefreshCandidates'](
{ sender: { id: 7, once: vi.fn() } },
{
generation: 1,
candidates: [
{
cacheKey: '/workspace/repo::feature/wsl',
repoPath: '/workspace/repo',
branch: 'feature/wsl',
repoKind: 'git',
repoId: 'repo-1'
}
]
}
)
await handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
limit: 10,
query: 'is:open',
page: 2,
noCache: true
})
await handlers['gh:countWorkItems'](null, {
repoPath: '/workspace/repo',
query: 'is:issue'
})
await handlers['gh:listIssues'](null, { repoPath: '/workspace/repo', limit: 5 })
await handlers['gh:issue'](null, { repoPath: '/workspace/repo', number: 7 })
await handlers['gh:createIssue'](null, {
repoPath: '/workspace/repo',
title: 'Title',
body: 'Body',
labels: ['bug']
})
await handlers['gh:updateIssue'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
number: 7,
updates: { body: 'Updated' }
}
)
await handlers['gh:addIssueComment'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
number: 7,
body: 'Comment'
}
)
await handlers['gh:listLabels'](null, { repoPath: '/workspace/repo' })
await handlers['gh:listAssignableUsers'](null, { repoPath: '/workspace/repo' })
expect(getPRForBranchMock).toHaveBeenCalledWith(
'/workspace/repo',
'feature/wsl',
null,
null,
null,
{ localGitExecOptions: localGitOptions }
)
expect(reportVisiblePRRefreshCandidatesMock).toHaveBeenCalledWith(
[
expect.objectContaining({
repoPath: '/workspace/repo',
repoId: 'repo-1',
localGitOptions
})
],
1,
7
)
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
10,
'is:open',
2,
undefined,
null,
true,
localGitOptions
)
expect(countWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
'is:issue',
undefined,
null,
localGitOptions
)
expect(listIssuesMock).toHaveBeenCalledWith(
'/workspace/repo',
5,
undefined,
null,
localGitOptions
)
expect(getIssueMock).toHaveBeenCalledWith('/workspace/repo', 7, null, localGitOptions)
expect(createIssueMock).toHaveBeenCalledWith(
'/workspace/repo',
'Title',
'Body',
undefined,
null,
{ labels: ['bug'], assignees: undefined },
localGitOptions
)
expect(updateIssueMock).toHaveBeenCalledWith(
'/workspace/repo',
7,
{ body: 'Updated' },
null,
localGitOptions
)
expect(addIssueCommentMock).toHaveBeenCalledWith(
'/workspace/repo',
7,
'Comment',
null,
null,
localGitOptions
)
expect(listLabelsMock).toHaveBeenCalledWith('/workspace/repo', undefined, null, localGitOptions)
expect(listAssignableUsersMock).toHaveBeenCalledWith(
'/workspace/repo',
undefined,
null,
localGitOptions
)
})
// Why: open-by-number must pin the same source the list uses, else a fork and
// its upstream sharing PR #42 open different PRs from the same click.
it('pins the repo origin source preference on work item and details IPC', async () => {
repos = [
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
issueSourcePreference: 'origin'
}
]
getWorkItemMock.mockResolvedValue(null)
getWorkItemDetailsMock.mockResolvedValue(null)
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:workItem'](null, { repoPath: '/workspace/repo', number: 42, type: 'pr' })
await handlers['gh:workItemDetails'](null, {
repoPath: '/workspace/repo',
number: 42,
type: 'pr'
})
expect(getWorkItemMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'pr',
null,
undefined,
'origin'
)
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'pr',
null,
undefined,
'origin'
)
})
it('routes local WSL project GitHub PR detail and action IPC through project git options', async () => {
setPlatform('win32')
projects = [
{
id: 'project-1',
displayName: 'repo',
badgeColor: 'blue',
sourceRepoIds: ['repo-1'],
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' },
createdAt: 0,
updatedAt: 0
}
]
const prRepo = { owner: 'acme', repo: 'orca', host: 'github.acme-corp.com' }
const localGitOptions = { wslDistro: 'Ubuntu' }
getWorkItemMock.mockResolvedValue(null)
getWorkItemByOwnerRepoMock.mockResolvedValue(null)
getWorkItemDetailsMock.mockResolvedValue(null)
getPRFileContentsMock.mockResolvedValue({ original: '', modified: '' })
getPRChecksMock.mockResolvedValue([])
getPRCheckDetailsMock.mockResolvedValue(null)
getPRCommentsMock.mockResolvedValue([])
resolveReviewThreadMock.mockResolvedValue(true)
setPRFileViewedMock.mockResolvedValue(true)
addPRReviewCommentReplyMock.mockResolvedValue({ ok: true })
addPRReviewCommentMock.mockResolvedValue({ ok: true })
updatePRTitleMock.mockResolvedValue(true)
mergePRMock.mockResolvedValue({ ok: true })
setPRAutoMergeMock.mockResolvedValue({ ok: true })
updatePRStateMock.mockResolvedValue({ ok: true })
rerunPRChecksMock.mockResolvedValue({ ok: true, count: 1 })
requestPRReviewersMock.mockResolvedValue({ ok: true })
removePRReviewersMock.mockResolvedValue({ ok: true })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:workItem'](null, { repoPath: '/workspace/repo', number: 42, type: 'pr' })
await handlers['gh:workItemByOwnerRepo'](null, {
repoPath: '/workspace/repo',
owner: 'acme',
repo: 'orca',
host: prRepo.host,
number: 42,
type: 'pr'
})
await handlers['gh:workItemDetails'](null, {
repoPath: '/workspace/repo',
number: 42,
type: 'pr'
})
await handlers['gh:prFileContents'](null, {
repoPath: '/workspace/repo',
prNumber: 42,
prRepo,
path: 'src/app.ts',
status: 'modified',
headSha: 'head-sha',
baseSha: 'base-sha'
})
await handlers['gh:prChecks'](null, {
repoPath: '/workspace/repo',
prNumber: 42,
headSha: 'head-sha',
prRepo,
noCache: true
})
await handlers['gh:prCheckDetails'](null, {
repoPath: '/workspace/repo',
checkRunId: 9,
workflowRunId: 8,
checkName: 'lint',
url: 'https://example.com/check',
prRepo
})
await handlers['gh:prComments'](null, {
repoPath: '/workspace/repo',
prNumber: 42,
prRepo,
noCache: true
})
await handlers['gh:resolveReviewThread'](null, {
repoPath: '/workspace/repo',
threadId: 'thread-1',
resolve: true,
prRepo
})
await handlers['gh:setPRFileViewed'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
prRepo,
pullRequestId: 'PR_kw',
path: 'src/app.ts',
viewed: true
}
)
await handlers['gh:addPRReviewCommentReply'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
prRepo,
commentId: 11,
body: ' Reply ',
threadId: 'thread-1',
path: 'src/app.ts',
line: 10
}
)
await handlers['gh:addPRReviewComment'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
prRepo,
commitId: ' head-sha ',
path: 'src/app.ts',
line: 10,
body: ' Inline '
}
)
await handlers['gh:updatePRTitle'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
title: 'New title',
prRepo
}
)
await handlers['gh:mergePR'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
method: 'squash',
prRepo
}
)
await handlers['gh:setPRAutoMerge'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
enabled: true,
method: 'squash',
prRepo
}
)
await handlers['gh:updatePRState'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
updates: { state: 'closed' },
prRepo
}
)
await handlers['gh:rerunPRChecks'](null, {
repoPath: '/workspace/repo',
prNumber: 42,
headSha: 'head-sha',
failedOnly: true,
prRepo
})
await handlers['gh:requestPRReviewers'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
reviewers: ['octo'],
prRepo
}
)
await handlers['gh:removePRReviewers'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
reviewers: ['octo'],
prRepo
}
)
expect(getWorkItemMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'pr',
null,
localGitOptions,
undefined
)
expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith(
'/workspace/repo',
prRepo,
42,
'pr',
null,
localGitOptions
)
expect(getWorkItemDetailsMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'pr',
null,
localGitOptions,
undefined
)
expect(getPRFileContentsMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo })
)
expect(getPRChecksMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'head-sha',
prRepo,
{ noCache: true },
null,
localGitOptions
)
expect(getPRCheckDetailsMock).toHaveBeenCalledWith(
'/workspace/repo',
{
checkRunId: 9,
workflowRunId: 8,
checkName: 'lint',
url: 'https://example.com/check',
prRepo
},
null,
localGitOptions
)
expect(getPRCommentsMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
{ noCache: true, prRepo },
null,
localGitOptions
)
expect(resolveReviewThreadMock).toHaveBeenCalledWith(
'/workspace/repo',
'thread-1',
true,
null,
prRepo,
localGitOptions
)
expect(setPRFileViewedMock).toHaveBeenCalledWith(
expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo })
)
expect(addPRReviewCommentReplyMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
11,
'Reply',
'thread-1',
'src/app.ts',
10,
null,
prRepo,
localGitOptions
)
expect(addPRReviewCommentMock).toHaveBeenCalledWith(
expect.objectContaining({
repoPath: '/workspace/repo',
commitId: 'head-sha',
body: 'Inline',
prRepo,
localGitOptions
})
)
expect(updatePRTitleMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'New title',
null,
prRepo,
localGitOptions
)
expect(mergePRMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
'squash',
null,
prRepo,
localGitOptions
)
expect(setPRAutoMergeMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
true,
'squash',
null,
prRepo,
localGitOptions
)
expect(updatePRStateMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
{ state: 'closed' },
null,
prRepo,
localGitOptions
)
expect(rerunPRChecksMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
{ headSha: 'head-sha', failedOnly: true, prRepo },
null,
localGitOptions
)
expect(requestPRReviewersMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
['octo'],
null,
prRepo,
localGitOptions
)
expect(removePRReviewersMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
['octo'],
null,
prRepo,
localGitOptions
)
})
it('threads SSH connectionId through GitHub work-item handlers', async () => {
repos[0].connectionId = 'openclaw-2'
listWorkItemsMock.mockResolvedValue({ items: [] })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:listWorkItems'](null, {
repoPath: '/workspace/repo',
limit: 10,
query: ''
})
expect(listWorkItemsMock).toHaveBeenCalledWith(
'/workspace/repo',
10,
'',
undefined,
undefined,
'openclaw-2',
undefined
)
})
it('threads SSH connectionId through pull request merge', async () => {
repos[0].connectionId = 'openclaw-2'
mergePRMock.mockResolvedValue({ ok: true })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:mergePR'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
method: 'squash',
prRepo: { owner: 'acme', repo: 'orca' }
}
)
expect(mergePRMock).toHaveBeenCalledWith('/workspace/repo', 42, 'squash', 'openclaw-2', {
owner: 'acme',
repo: 'orca'
})
})
it('threads SSH connectionId through pull request auto-merge', async () => {
repos[0].connectionId = 'openclaw-2'
setPRAutoMergeMock.mockResolvedValue({ ok: true })
registerGitHubHandlers(store as never, stats as never)
await handlers['gh:setPRAutoMerge'](
{ sender: { id: 1 } },
{
repoPath: '/workspace/repo',
prNumber: 42,
enabled: true,
method: 'squash',
prRepo: { owner: 'acme', repo: 'orca' }
}
)
expect(setPRAutoMergeMock).toHaveBeenCalledWith(
'/workspace/repo',
42,
true,
'squash',
'openclaw-2',
{
owner: 'acme',
repo: 'orca'
}
)
})
it('forwards the authenticated viewer lookup', async () => {
getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' })
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:viewer'](null, undefined)).resolves.toEqual({
login: 'octocat',
email: 'octocat@example.com'
})
expect(getAuthenticatedViewerMock).toHaveBeenCalled()
})
it('emits app_starred_orca once after a successful star with cohort context', async () => {
starOrcaMock.mockResolvedValue(true)
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 })
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:starOrca'](null, 'settings')).resolves.toBe(true)
expect(starOrcaMock).toHaveBeenCalledTimes(1)
expect(getCohortAtEmitMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledTimes(1)
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
source: 'settings',
nth_repo_added: 3
})
})
it('accepts every app star source for success telemetry', async () => {
starOrcaMock.mockResolvedValue(true)
registerGitHubHandlers(store as never, stats as never)
for (const source of [
'star_nag',
'agent_value_moment',
'onboarding_completed',
'settings',
'landing'
] as const) {
await expect(handlers['gh:starOrca'](null, source)).resolves.toBe(true)
}
expect(trackMock).toHaveBeenCalledTimes(5)
expect(trackMock.mock.calls.map(([, props]) => props)).toEqual([
{ source: 'star_nag', nth_repo_added: undefined },
{ source: 'agent_value_moment', nth_repo_added: undefined },
{ source: 'onboarding_completed', nth_repo_added: undefined },
{ source: 'settings', nth_repo_added: undefined },
{ source: 'landing', nth_repo_added: undefined }
])
})
it('does not emit app_starred_orca when the star action returns false', async () => {
starOrcaMock.mockResolvedValue(false)
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:starOrca'](null, 'landing')).resolves.toBe(false)
expect(starOrcaMock).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
})
it('does not emit app_starred_orca when the star action throws', async () => {
starOrcaMock.mockRejectedValue(new Error('gh failed'))
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:starOrca'](null, 'star_nag')).rejects.toThrow('gh failed')
expect(trackMock).not.toHaveBeenCalled()
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
})
it('preserves star result but skips telemetry for an invalid IPC source', async () => {
starOrcaMock.mockResolvedValue(true)
registerGitHubHandlers(store as never, stats as never)
await expect(handlers['gh:starOrca'](null, 'github_website')).resolves.toBe(true)
expect(starOrcaMock).toHaveBeenCalledTimes(1)
expect(trackMock).not.toHaveBeenCalled()
expect(getCohortAtEmitMock).not.toHaveBeenCalled()
})
})