Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { getPRChecks } from './client/check/get-pr-checks'
export { getPRComments } from './client/fetch/get-pr-comments'
export { getPRForBranch } from './client/lookup/get-pr-for-branch'
export { getPRForBranchOutcome } from './client/lookup/pr-for-branch-outcome'
export { getPRPreviewDeploymentUrl } from './client/fetch/pr-preview-deployment-url'
export {
getPullRequestPushTarget,
type PullRequestPushTarget
Expand Down
83 changes: 83 additions & 0 deletions src/main/github/client/fetch/pr-preview-deployment-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
ghExecFileAsync: vi.fn(),
resolveGitHubRepoExecution: vi.fn(),
repositoryRateLimitGuard: vi.fn()
}))

vi.mock('../../gh-utils', () => ({
ghExecFileAsync: mocks.ghExecFileAsync,
acquire: vi.fn(async () => {}),
release: vi.fn()
}))
vi.mock('../../github-api-repository', () => ({
resolveGitHubRepoExecution: mocks.resolveGitHubRepoExecution
}))
vi.mock('../../rate-limit', () => ({
repositoryRateLimitGuard: mocks.repositoryRateLimitGuard,
noteRepositoryRateLimitSpend: vi.fn()
}))

import { getPRPreviewDeploymentUrl } from './pr-preview-deployment-url'

type Status = { state: string; environmentUrl: string | null } | null

function respondWithCommits(commits: Status[][]): void {
mocks.ghExecFileAsync.mockResolvedValue({
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
commits: {
nodes: commits.map((statuses) => ({
commit: {
deployments: { nodes: statuses.map((latestStatus) => ({ latestStatus })) }
}
}))
}
}
}
}
}),
stderr: ''
})
}

describe('getPRPreviewDeploymentUrl', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.resolveGitHubRepoExecution.mockResolvedValue({
ownerRepo: { owner: 'acme', repo: 'web' },
ghOptions: { cwd: '/repo' }
})
mocks.repositoryRateLimitGuard.mockReturnValue({ blocked: false })
})

it('returns the newest successful deployment, skipping a head commit still building', async () => {
respondWithCommits([
[{ state: 'SUCCESS', environmentUrl: 'https://old.pages.dev' }],
[
{ state: 'FAILURE', environmentUrl: null },
{ state: 'SUCCESS', environmentUrl: 'https://newer.pages.dev' }
],
[null]
])

await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBe('https://newer.pages.dev')
const argv = mocks.ghExecFileAsync.mock.calls[0][0] as string[]
expect(argv).toEqual(expect.arrayContaining(['owner=acme', 'repo=web', 'number=42']))
})

it('returns null when no deployment succeeded', async () => {
respondWithCommits([[{ state: 'FAILURE', environmentUrl: null }], []])

await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBeNull()
})

it('returns null when the lookup fails', async () => {
mocks.ghExecFileAsync.mockRejectedValue(new Error('gh failed'))

await expect(getPRPreviewDeploymentUrl('/repo', 42)).resolves.toBeNull()
})
})
92 changes: 92 additions & 0 deletions src/main/github/client/fetch/pr-preview-deployment-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from '../../rate-limit'

const PR_DEPLOYMENTS_QUERY = `query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
commits(last: 30) {
nodes {
commit {
deployments(first: 10, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes { latestStatus { state environmentUrl } }
}
}
}
}
}
}
}`

type DeploymentStatus = { state?: string; environmentUrl?: string | null } | null

type PRDeploymentsResponse = {
data?: {
repository?: {
pullRequest?: {
commits?: {
nodes?: { commit?: { deployments?: { nodes?: { latestStatus?: DeploymentStatus }[] } } }[]
}
}
}
}
}

function newestSuccessfulDeploymentUrl(response: PRDeploymentsResponse): string | null {
const commits = response.data?.repository?.pullRequest?.commits?.nodes ?? []
for (const node of commits.toReversed()) {
for (const deployment of node.commit?.deployments?.nodes ?? []) {
const status = deployment.latestStatus
if (status?.state === 'SUCCESS' && status.environmentUrl) {
return status.environmentUrl
}
}
}
return null
}

export async function getPRPreviewDeploymentUrl(
repoPath: string,
prNumber: number,
prRepo?: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null> {
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
repoPath,
prRepo,
connectionId,
localGitOptions
)
if (!ownerRepo) {
return null
}
if (repositoryRateLimitGuard(ownerRepo, 'graphql', ghOptions).blocked) {
return null
}
await acquire()
try {
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${PR_DEPLOYMENTS_QUERY}`,
'-f',
`owner=${ownerRepo.owner}`,
'-f',
`repo=${ownerRepo.repo}`,
'-F',
`number=${prNumber}`
],
ghOptions
)
return newestSuccessfulDeploymentUrl(JSON.parse(stdout) as PRDeploymentsResponse)
} catch (err) {
console.warn('getPRPreviewDeploymentUrl failed:', err)
return null
} finally {
release()
}
}
1 change: 1 addition & 0 deletions src/main/ipc/github-ipc-module-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const CLIENT_EXPORTS = [
'getPRChecks',
'getPRCheckDetails',
'getPRComments',
'getPRPreviewDeploymentUrl',
'setPRCommentReaction',
'resolveReviewThread',
'setPRFileViewed',
Expand Down
23 changes: 23 additions & 0 deletions src/main/ipc/github-pr-read-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getPRCheckDetails,
getPRChecks,
getPRComments,
getPRPreviewDeploymentUrl,
getRepoSlug,
getRepoUpstream,
resolveReviewThread,
Expand Down Expand Up @@ -97,6 +98,28 @@ export function registerGitHubPRReadHandlers(store: Store): void {
}
)

ipcMain.handle(
'gh:prPreviewUrl',
(
_event,
args: {
repoPath: string
repoId?: string | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
}
) => {
const repo = assertRegisteredGitHubRepo(args, store)
return getPRPreviewDeploymentUrl(
repo.path,
args.prNumber,
args.prRepo ?? null,
getGitHubRepoConnectionId(repo),
...getGitHubLocalGitOptionArgs(store, repo)
)
}
)

ipcMain.handle(
'gh:prComments',
(
Expand Down
6 changes: 6 additions & 0 deletions src/preload/api/gh-bridge-pull-requests-and-work-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ export const ghPullRequestsAndWorkItemsApi = {
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}) => ipcRenderer.invoke('gh:prComments', args),
prPreviewUrl: (args: {
repoPath: string
repoId?: string | null
prNumber: number
prRepo?: GitHubOwnerRepo | null
}): Promise<string | null> => ipcRenderer.invoke('gh:prPreviewUrl', args),
setPRCommentReaction: (args: {
repoPath: string
repoId?: string | null
Expand Down
6 changes: 6 additions & 0 deletions src/preload/api/github-pull-request-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ export type GithubPullRequestApi = {
prRepo?: GitHubOwnerRepo | null
noCache?: boolean
}) => Promise<PRComment[]>
prPreviewUrl: (args: {
repoPath: string
repoId?: string
prNumber: number
prRepo?: GitHubOwnerRepo | null
}) => Promise<string | null>
setPRCommentReaction: (args: {
repoPath: string
repoId?: string
Expand Down
32 changes: 32 additions & 0 deletions src/renderer/src/app-shell/source-control-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,38 @@ describe('frontend dev server shortcut', () => {
})
})

const preview = vi.hoisted(() => ({ opened: true, open: vi.fn(async () => preview.opened) }))
vi.mock('@/lib/open-pull-request-preview', () => ({
openActivePullRequestPreview: preview.open
}))

describe('pull request preview shortcut', () => {
beforeEach(() => vi.clearAllMocks())

it("opens the active pull request's preview deployment", async () => {
preview.opened = true
const match = (id: string): boolean => id === 'sourceControl.openPullRequestPreview'

expect(takeSourceControlShortcut(match, true, actions)).toBe(
'sourceControl.openPullRequestPreview'
)
await vi.waitFor(() => expect(preview.open).toHaveBeenCalledOnce())
expect(toast.info).not.toHaveBeenCalled()
})

it('says so when the pull request has no preview deployment', async () => {
preview.opened = false
const match = (id: string): boolean => id === 'sourceControl.openPullRequestPreview'

expect(takeSourceControlShortcut(match, true, actions)).toBe(
'sourceControl.openPullRequestPreview'
)
await vi.waitFor(() =>
expect(toast.info).toHaveBeenCalledWith('No preview deployment for this pull request.')
)
})
})

describe('pull request shortcuts', () => {
const openUrl = vi.fn(async () => {})
const writeClipboardText = vi.fn(async () => {})
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/src/app-shell/source-control-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@/components/pending-review/review-summary-focus'
import { activeWorktreePullRequestUrl } from '@/lib/worktree-pull-request-url'
import { openActiveWorkspaceFrontend } from '@/lib/open-workspace-frontend'
import { openActivePullRequestPreview } from '@/lib/open-pull-request-preview'
import { useAppStore } from '../store'
import type { KeybindingActionId } from '../../../shared/keybindings/types'

Expand All @@ -21,6 +22,7 @@ type SourceControlShortcutId =
| 'sourceControl.focusReviewSummary'
| 'sourceControl.openPullRequest'
| 'sourceControl.copyPullRequestUrl'
| 'sourceControl.openPullRequestPreview'
| 'workspace.openFrontend'

function focusReviewSummaryInOpenPanel(): boolean {
Expand Down Expand Up @@ -129,6 +131,19 @@ export function takeSourceControlShortcut(
})
return 'sourceControl.copyPullRequestUrl'
}
if (matchShortcut('sourceControl.openPullRequestPreview')) {
void openActivePullRequestPreview().then((opened) => {
if (!opened) {
toast.info(
translate(
'sourceControl.noPullRequestPreview',
'No preview deployment for this pull request.'
)
)
}
})
return 'sourceControl.openPullRequestPreview'
}
return null
}

Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -20528,7 +20528,8 @@
"status": "PR #{{number}} unlinked"
},
"pullRequestUrlCopied": "Copied pull request URL",
"noPullRequest": "This workspace has no pull request yet."
"noPullRequest": "This workspace has no pull request yet.",
"noPullRequestPreview": "No preview deployment for this pull request."
},
"agentsSidebarIntro": {
"migrated": {
Expand Down
Loading
Loading