mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* 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>
1381 lines
38 KiB
TypeScript
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()
|
|
})
|
|
})
|