From 6c4ac044d5355e72ca4237b939f0b2b974e304fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 01:30:33 +0200 Subject: [PATCH 01/19] feat(git-token-service): internal user-token endpoint for PR review --- packages/worker-utils/src/index.ts | 1 + .../src/internal-service-token-audiences.ts | 1 + ...thub-user-authorization-entrypoint.test.ts | 289 +++++++++++++++- .../github-user-authorization-service.test.ts | 311 ++++++++++++++++++ .../src/github-user-authorization-service.ts | 180 +++++++++- services/git-token-service/src/index.ts | 83 ++++- 6 files changed, 836 insertions(+), 29 deletions(-) diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 8244d4a7c8..a3ccda89ea 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -55,6 +55,7 @@ export { BITBUCKET_REPOSITORY_LIST_AUDIENCE, GITLAB_CREDENTIAL_AUDIT_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, + GITHUB_USER_ACCESS_TOKEN_AUDIENCE, } from './internal-service-token-audiences.js'; export { BITBUCKET_ACCESS_TOKEN_FAMILY_PREFIX, diff --git a/packages/worker-utils/src/internal-service-token-audiences.ts b/packages/worker-utils/src/internal-service-token-audiences.ts index 6b90139d0c..64843bf2e4 100644 --- a/packages/worker-utils/src/internal-service-token-audiences.ts +++ b/packages/worker-utils/src/internal-service-token-audiences.ts @@ -7,3 +7,4 @@ export const BITBUCKET_CODE_REVIEW_WEBHOOK_DELETE_AUDIENCE = 'git-token-service:bitbucket-code-review:webhook-delete'; export const GITLAB_CREDENTIAL_BROKER_AUDIENCE = 'git-token-service:gitlab-credentials'; export const GITLAB_CREDENTIAL_AUDIT_AUDIENCE = 'git-token-service:gitlab-credential-audit'; +export const GITHUB_USER_ACCESS_TOKEN_AUDIENCE = 'git-token-service:github-user-access-token'; diff --git a/services/git-token-service/src/github-user-authorization-entrypoint.test.ts b/services/git-token-service/src/github-user-authorization-entrypoint.test.ts index 16e446ee43..37fe66e9de 100644 --- a/services/git-token-service/src/github-user-authorization-entrypoint.test.ts +++ b/services/git-token-service/src/github-user-authorization-entrypoint.test.ts @@ -1,4 +1,8 @@ -import { BITBUCKET_REPOSITORY_LIST_AUDIENCE, signKiloToken } from '@kilocode/worker-utils'; +import { + BITBUCKET_REPOSITORY_LIST_AUDIENCE, + GITHUB_USER_ACCESS_TOKEN_AUDIENCE, + signKiloToken, +} from '@kilocode/worker-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const serviceMocks = vi.hoisted(() => ({ @@ -6,6 +10,7 @@ const serviceMocks = vi.hoisted(() => ({ getTokenForRepo: vi.fn(), selectUserAuthorization: vi.fn(), disconnectUserAuthorization: vi.fn(), + getUserAccessToken: vi.fn(), })); vi.mock('cloudflare:workers', () => ({ @@ -34,6 +39,7 @@ vi.mock('./github-user-authorization-service.js', () => ({ GitHubUserAuthorizationService: class GitHubUserAuthorizationService { selectUserAuthorization = serviceMocks.selectUserAuthorization; disconnectUserAuthorization = serviceMocks.disconnectUserAuthorization; + getUserAccessToken = serviceMocks.getUserAccessToken; }, })); @@ -308,3 +314,284 @@ describe('fetch disconnect endpoint', () => { await expect(response.json()).resolves.toEqual({ error: 'disconnect_failed' }); }); }); + +describe('fetch user-access-token endpoint', () => { + const jwtSecret = 'test-secret-that-is-at-least-32-characters'; + const env = { + NEXTAUTH_SECRET: { get: async () => jwtSecret } as SecretsStoreSecret, + } as CloudflareEnv; + const tokenFor = async (userId: string, audience?: string): Promise => { + const { token } = await signKiloToken({ + userId, + pepper: null, + secret: jwtSecret, + expiresInSeconds: 60 * 60, + ...(audience ? { audience } : {}), + }); + return `Bearer ${token}`; + }; + + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.getUserAccessToken.mockResolvedValue({ + connected: false, + reason: 'not_connected', + }); + }); + + it.each([new Headers({ Authorization: 'Bearer invalid' }), new Headers()])( + 'does not run getUserAccessToken before user authentication succeeds (%s)', + async headers => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { method: 'POST', headers } + ), + env + ); + + expect(response.status).toBe(401); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + } + ); + + it('rejects a generic Kilo token without the user-access-token audience', async () => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { Authorization: await tokenFor('user_1') }, + body: JSON.stringify({ op: 'fetch' }), + } + ), + env + ); + + expect(response.status).toBe(401); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects a token issued for a different internal audience', async () => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', BITBUCKET_REPOSITORY_LIST_AUDIENCE), + }, + body: JSON.stringify({ op: 'fetch' }), + } + ), + env + ); + + expect(response.status).toBe(401); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('derives the actor from the verified JWT and ignores a body user id for fetch', async () => { + serviceMocks.getUserAccessToken.mockResolvedValueOnce({ + connected: true, + token: 'plaintext-user-token', + expiresAtEpochMs: 1_700_000_000_000, + githubLogin: 'octocat', + authorizationId: 'authorization_1', + credentialVersion: 2, + }); + + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ op: 'fetch', userId: 'user_2' }), + } + ), + env + ); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ + connected: true, + token: 'plaintext-user-token', + expiresAtEpochMs: 1_700_000_000_000, + githubLogin: 'octocat', + authorizationId: 'authorization_1', + credentialVersion: 2, + }); + expect(serviceMocks.getUserAccessToken).toHaveBeenCalledWith('user_1', { op: 'fetch' }); + }); + + it('returns 400 when the request body is not valid JSON', async () => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: '{not-json', + } + ), + env + ); + + expect(response.status).toBe(400); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects malformed discriminated union bodies', async () => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ op: 'rotate' }), + } + ), + env + ); + + expect(response.status).toBe(400); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('rejects non-POST methods', async () => { + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'GET', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + }, + } + ), + env + ); + + expect(response.status).toBe(405); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('returns 404 for unrelated paths even with the user-access-token audience', async () => { + const response = await handler.fetch( + new Request('https://git-token-service.kilosessions.ai/getTokenForRepo', { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + }, + }), + env + ); + + expect(response.status).toBe(404); + expect(serviceMocks.getUserAccessToken).not.toHaveBeenCalled(); + }); + + it('forwards rotate op and revokes generation via service when matching', async () => { + serviceMocks.getUserAccessToken.mockResolvedValueOnce({ + connected: false, + reason: 'revoked', + }); + + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 1, + }), + } + ), + env + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ connected: false, reason: 'revoked' }); + expect(serviceMocks.getUserAccessToken).toHaveBeenCalledWith('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 1, + }); + }); + + it('forwards reportRejected op and reports the matching credential version as revoked', async () => { + serviceMocks.getUserAccessToken.mockResolvedValueOnce({ + connected: false, + reason: 'revoked', + }); + + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + op: 'reportRejected', + authorizationId: 'authorization_1', + credentialVersion: 3, + }), + } + ), + env + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ connected: false, reason: 'revoked' }); + expect(serviceMocks.getUserAccessToken).toHaveBeenCalledWith('user_1', { + op: 'reportRejected', + authorizationId: 'authorization_1', + credentialVersion: 3, + }); + }); + + it('maps a temporarily-unavailable service failure to a 503 response', async () => { + serviceMocks.getUserAccessToken.mockRejectedValueOnce( + Object.assign(new Error('temporarily_unavailable'), {}) + ); + + const response = await handler.fetch( + new Request( + 'https://git-token-service.kilosessions.ai/internal/github-user-authorizations/token', + { + method: 'POST', + headers: { + Authorization: await tokenFor('user_1', GITHUB_USER_ACCESS_TOKEN_AUDIENCE), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ op: 'fetch' }), + } + ), + env + ); + + expect(response.status).toBe(503); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + await expect(response.json()).resolves.toEqual({ error: 'temporarily_unavailable' }); + }); +}); diff --git a/services/git-token-service/src/github-user-authorization-service.test.ts b/services/git-token-service/src/github-user-authorization-service.test.ts index 49b71d13b0..7542f1ee6c 100644 --- a/services/git-token-service/src/github-user-authorization-service.test.ts +++ b/services/git-token-service/src/github-user-authorization-service.test.ts @@ -10,6 +10,10 @@ const database = vi.hoisted(() => ({ deleteWinsRace: true, rowAfterDelete: undefined as Record | undefined, lockExecutions: 0, + // When set, successive `select().limit()` calls shift from this queue, + // letting a test model a row that another request rotated between the + // outer read and the post-lock re-read. + selectSequence: undefined as Array | undefined> | undefined, })); vi.mock('@kilocode/db/client', () => ({ @@ -22,6 +26,9 @@ vi.mock('@kilocode/db/client', () => ({ from: () => ({ where: () => ({ limit: async () => { + if (database.selectSequence && database.selectSequence.length > 0) { + return [database.selectSequence.shift()].filter(Boolean); + } if (database.deleted && !database.deleteWinsRace) { return [database.rowAfterDelete].filter(Boolean); } @@ -476,3 +483,307 @@ describe('GitHubUserAuthorizationService disconnect', () => { expect(database.deleted).toBe(false); }); }); + +describe('GitHubUserAuthorizationService.getUserAccessToken', () => { + beforeEach(() => { + database.rows = [makeRow()]; + database.updates = []; + database.updatedRow = undefined; + database.deleted = false; + database.deleteWinsRace = true; + database.rowAfterDelete = undefined; + database.lockExecutions = 0; + database.selectSequence = undefined; + vi.restoreAllMocks(); + }); + + it('accepts a concurrent refresh winner instead of failing when the generation moved under the lock', async () => { + // A rotate arrives with the stale pair (v1). Between the outer read and + // acquiring the lock, another request already rotated the grant to v2 + // (healthy). The post-lock version mismatch must resolve to the current + // healthy credential, not a 503. + const rowV1 = makeRow(); + const rowV2 = { ...makeRow(), credential_version: 2 }; + // reads: outer getUserAccessToken read (v1), post-lock re-read (v2), + // final reload (v2). + database.selectSequence = [rowV1, rowV2, rowV2]; + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 1, + }); + + expect(result).toMatchObject({ + connected: true, + token: 'access-token', + authorizationId: 'authorization_1', + credentialVersion: 2, + }); + // No OAuth refresh happened (the winner already did it) and nothing was revoked. + expect(fetch).not.toHaveBeenCalled(); + expect(database.updates).toHaveLength(0); + }); + + it('returns not_connected when no authorization row exists', async () => { + database.rows = []; + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { op: 'fetch' }); + + expect(result).toEqual({ connected: false, reason: 'not_connected' }); + expect(fetch).not.toHaveBeenCalled(); + expect(database.updates).toHaveLength(0); + }); + + it('returns revoked when the existing row is already revoked', async () => { + database.rows = [{ ...makeRow(), revoked_at: new Date().toISOString() }]; + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { op: 'fetch' }); + + expect(result).toEqual({ connected: false, reason: 'revoked' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('returns the current credential for fetch without refreshing when outside the buffer', async () => { + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { op: 'fetch' }); + + expect(result).toMatchObject({ + connected: true, + token: 'access-token', + githubLogin: 'octocat', + authorizationId: 'authorization_1', + credentialVersion: 1, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(database.lockExecutions).toBe(0); + }); + + it('refreshes the credential when fetch finds the access token inside the expiry buffer', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); + database.rows = [row]; + const refreshedRow = { + ...makeRow(activePublicKey, 'active', { + access: 'refreshed-access', + refresh: 'refreshed-refresh', + }), + credential_version: 2, + access_token_expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 7200 * 1000).toISOString(), + }; + database.updatedRow = refreshedRow; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access', + expires_in: 3600, + refresh_token: 'refreshed-refresh', + refresh_token_expires_in: 7200, + }) + ) + ); + + const result = await makeService().getUserAccessToken('user_1', { op: 'fetch' }); + + expect(result.connected).toBe(true); + expect(fetch).toHaveBeenCalledWith( + 'https://github.com/login/oauth/access_token', + expect.objectContaining({ method: 'POST' }) + ); + expect(database.lockExecutions).toBe(1); + }); + + it('honors the GITHUB_OAUTH_BASE_URL env seam for the refresh request', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); + database.rows = [row]; + const refreshedRow = { + ...makeRow(activePublicKey, 'active', { + access: 'refreshed-access', + refresh: 'refreshed-refresh', + }), + credential_version: 2, + access_token_expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 7200 * 1000).toISOString(), + }; + database.updatedRow = refreshedRow; + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ + access_token: 'refreshed-access', + expires_in: 3600, + refresh_token: 'refreshed-refresh', + refresh_token_expires_in: 7200, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await makeService({ GITHUB_OAUTH_BASE_URL: 'https://github.test/' }).getUserAccessToken( + 'user_1', + { op: 'fetch' } + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://github.test/login/oauth/access_token', + expect.anything() + ); + }); + + it('refreshes with force when rotate matches the current authorization and version', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + database.rows = [row]; + const refreshedRow = { + ...makeRow(activePublicKey, 'active', { + access: 'refreshed-access', + refresh: 'refreshed-refresh', + }), + credential_version: 2, + access_token_expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 7200 * 1000).toISOString(), + }; + database.updatedRow = refreshedRow; + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access', + expires_in: 3600, + refresh_token: 'refreshed-refresh', + refresh_token_expires_in: 7200, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 1, + }); + + expect(result.connected).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(database.lockExecutions).toBe(1); + }); + + it('returns the current credential without refreshing when rotate carries a stale id', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'old_authorization', + staleCredentialVersion: 1, + }); + + expect(result.connected).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + expect(database.lockExecutions).toBe(0); + }); + + it('returns the current credential without refreshing when rotate carries a stale credential version', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 99, + }); + + expect(result.connected).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not touch a fresh credential after a disconnect-then-reconnect rotates the id back to version 1', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const newRow = { + ...makeRow(), + id: 'authorization_2', + credential_version: 1, + }; + database.rows = [newRow]; + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 7, + }); + + expect(result).toMatchObject({ + connected: true, + authorizationId: 'authorization_2', + credentialVersion: 1, + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(database.lockExecutions).toBe(0); + }); + + it('revokes the matching generation when reportRejected receives the current id and version', async () => { + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'reportRejected', + authorizationId: 'authorization_1', + credentialVersion: 1, + }); + + expect(result).toEqual({ connected: false, reason: 'revoked' }); + expect(database.updates).toHaveLength(1); + expect(database.updates[0]).toMatchObject({ + revocation_reason: 'github_token_rejected', + }); + expect(typeof database.updates[0].revoked_at).toBe('string'); + }); + + it('returns the current credential when reportRejected carries a stale id or version', async () => { + vi.stubGlobal('fetch', vi.fn()); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'reportRejected', + authorizationId: 'old_authorization', + credentialVersion: 1, + }); + + expect(result.connected).toBe(true); + expect(database.updates).toHaveLength(0); + }); + + it('revokes on terminal_rejection when the refresh grant is rejected by GitHub', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + database.rows = [row]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ error: 'bad_refresh_token' })) + ); + + const result = await makeService().getUserAccessToken('user_1', { + op: 'rotate', + staleAuthorizationId: 'authorization_1', + staleCredentialVersion: 1, + }); + + expect(result).toEqual({ connected: false, reason: 'revoked' }); + expect(database.updates).toHaveLength(1); + expect(database.updates[0]).toMatchObject({ revocation_reason: 'github_token_rejected' }); + }); + + it('does not revoke when the refresh attempt fails transiently', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); + database.rows = [row]; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 500 }))); + + await expect( + makeService().getUserAccessToken('user_1', { op: 'fetch' }) + ).rejects.toThrow('temporarily_unavailable'); + expect(database.updates).toHaveLength(0); + }); +}); diff --git a/services/git-token-service/src/github-user-authorization-service.ts b/services/git-token-service/src/github-user-authorization-service.ts index 7fc93ba12d..795d340b0c 100644 --- a/services/git-token-service/src/github-user-authorization-service.ts +++ b/services/git-token-service/src/github-user-authorization-service.ts @@ -26,6 +26,24 @@ export type UserAuthorizationSelection = | { selected: true; token: string; gitAuthor: GitAuthorConfig } | { selected: false; reason: ManagedGitHubFallbackReason }; +export type GitHubUserAccessTokenOp = + | { op: 'fetch' } + | { op: 'rotate'; staleAuthorizationId: string; staleCredentialVersion: number } + | { op: 'reportRejected'; authorizationId: string; credentialVersion: number }; + +export type GitHubUserAccessTokenResult = + | { + connected: true; + token: string; + expiresAtEpochMs: number; + githubLogin: string; + authorizationId: string; + credentialVersion: number; + } + | { connected: false; reason: 'not_connected' | 'revoked' }; + +export type GitHubUserAccessTokenTransientReason = 'temporarily_unavailable'; + const TOKEN_SCHEME = 'github-user-token-rsa-aes-256-gcm'; const EXPIRY_BUFFER_MS = 5 * 60 * 1000; const RefreshResponseSchema = z.object({ @@ -48,10 +66,13 @@ type GitHubUserAuthorizationEnv = CloudflareEnv & { USER_GITHUB_APP_TOKEN_ACTIVE_PRIVATE_KEY?: string; GITHUB_APP_CLIENT_ID?: string; GITHUB_APP_CLIENT_SECRET?: string; + GITHUB_OAUTH_BASE_URL?: string; }; type CredentialKind = 'access' | 'refresh'; type RevokeResult = 'revoked' | 'token_invalid'; type DisconnectRefreshResult = AuthorizationRow | 'terminal_refresh_token' | null; +type RefreshResult = 'refreshed' | 'transient_failure' | 'terminal_rejection'; +const DEFAULT_GITHUB_OAUTH_BASE_URL = 'https://github.com'; class CredentialSelectionError extends Error { constructor(readonly reason: 'credential_unreadable' | 'credential_configuration_error') { @@ -89,9 +110,22 @@ export class GitHubUserAuthorizationService { new Date(authorization.access_token_expires_at).getTime() - Date.now() < EXPIRY_BUFFER_MS ) { - const refreshed = await this.refreshAuthorizationWithLock(db, authorization); - if (!refreshed) return { selected: false, reason: 'refresh_failed' }; - authorization = refreshed; + const refreshResult = await this.refreshAuthorizationWithLock(db, authorization); + if (refreshResult === 'terminal_rejection') { + return { selected: false, reason: 'revoked' }; + } + if (refreshResult === 'transient_failure') { + return { selected: false, reason: 'refresh_failed' }; + } + const [reloaded] = await db + .select() + .from(user_github_app_tokens) + .where(eq(user_github_app_tokens.id, authorization.id)) + .limit(1); + if (!reloaded || reloaded.revoked_at) { + return { selected: false, reason: 'no_user_authorization' }; + } + authorization = reloaded; } const token = this.decryptCredential(authorization, 'access'); @@ -140,6 +174,87 @@ export class GitHubUserAuthorizationService { } } + async getUserAccessToken( + kiloUserId: string, + op: GitHubUserAccessTokenOp + ): Promise { + const db = this.getDatabase(); + const [authorization] = await db + .select() + .from(user_github_app_tokens) + .where( + and( + eq(user_github_app_tokens.kilo_user_id, kiloUserId), + eq(user_github_app_tokens.github_app_type, 'standard') + ) + ) + .limit(1); + if (!authorization) return { connected: false, reason: 'not_connected' }; + if (authorization.revoked_at) return { connected: false, reason: 'revoked' }; + + if (op.op === 'reportRejected') { + if ( + authorization.id !== op.authorizationId || + authorization.credential_version !== op.credentialVersion + ) { + return this.toConnectedResult(authorization); + } + await this.revokeCurrentGeneration(db, authorization, 'github_token_rejected'); + return { connected: false, reason: 'revoked' }; + } + + let forceRefresh = false; + if (op.op === 'rotate') { + if ( + authorization.id !== op.staleAuthorizationId || + authorization.credential_version !== op.staleCredentialVersion + ) { + return this.toConnectedResult(authorization); + } + forceRefresh = true; + } + + if ( + forceRefresh || + new Date(authorization.access_token_expires_at).getTime() - Date.now() < + EXPIRY_BUFFER_MS + ) { + const refreshResult = await this.refreshAuthorizationWithLock(db, authorization, { + force: forceRefresh, + }); + if (refreshResult === 'terminal_rejection') { + await this.revokeCurrentGeneration(db, authorization, 'github_token_rejected'); + return { connected: false, reason: 'revoked' }; + } + if (refreshResult === 'transient_failure') { + throw new Error('temporarily_unavailable'); + } + const [reloaded] = await db + .select() + .from(user_github_app_tokens) + .where(eq(user_github_app_tokens.id, authorization.id)) + .limit(1); + if (!reloaded || reloaded.revoked_at) { + return { connected: false, reason: 'not_connected' }; + } + return this.toConnectedResult(reloaded); + } + + return this.toConnectedResult(authorization); + } + + private toConnectedResult(authorization: AuthorizationRow): GitHubUserAccessTokenResult { + const token = this.decryptCredential(authorization, 'access'); + return { + connected: true, + token, + expiresAtEpochMs: new Date(authorization.access_token_expires_at).getTime(), + githubLogin: authorization.github_login, + authorizationId: authorization.id, + credentialVersion: authorization.credential_version, + }; + } + async disconnectUserAuthorization(kiloUserId: string): Promise { const db = this.getDatabase(); const [candidate] = await db @@ -241,6 +356,15 @@ export class GitHubUserAuthorizationService { return getWorkerDb(this.env.HYPERDRIVE.connectionString, { statement_timeout: 10_000 }); } + private getGitHubOAuthBaseUrl(): string { + const baseUrl = this.env.GITHUB_OAUTH_BASE_URL ?? DEFAULT_GITHUB_OAUTH_BASE_URL; + return baseUrl.replace(/\/+$/, ''); + } + + private getGitHubOAuthAccessTokenUrl(): string { + return `${this.getGitHubOAuthBaseUrl()}/login/oauth/access_token`; + } + private decryptCredential(authorization: AuthorizationRow, kind: CredentialKind): string { let keys: Parameters[2]; try { @@ -331,8 +455,9 @@ export class GitHubUserAuthorizationService { private async refreshAuthorizationWithLock( db: WorkerDb, - candidate: AuthorizationRow - ): Promise { + candidate: AuthorizationRow, + options: { force?: boolean } = {} + ): Promise { return db.transaction(async tx => { await this.lockAuthorizationGrant(tx, candidate.github_user_id); const [authorization] = await tx @@ -340,12 +465,21 @@ export class GitHubUserAuthorizationService { .from(user_github_app_tokens) .where(eq(user_github_app_tokens.id, candidate.id)) .limit(1); - if (!authorization || authorization.revoked_at) return null; + if (!authorization) return 'transient_failure'; + if (authorization.revoked_at) return 'terminal_rejection'; + if (authorization.credential_version !== candidate.credential_version) { + // Another request already refreshed/rotated this grant while we + // waited for the lock. The current row is healthy (present and not + // revoked), so accept the concurrent winner's credential rather + // than failing: the caller re-reads the current row and returns it. + return 'refreshed'; + } if ( - authorization.credential_version !== candidate.credential_version || - new Date(authorization.access_token_expires_at).getTime() - Date.now() >= EXPIRY_BUFFER_MS + !options.force && + new Date(authorization.access_token_expires_at).getTime() - Date.now() >= + EXPIRY_BUFFER_MS ) { - return authorization; + return 'refreshed'; } return this.refreshAuthorization(tx, authorization); }); @@ -354,15 +488,15 @@ export class GitHubUserAuthorizationService { private async refreshAuthorization( db: WorkerTransaction, authorization: AuthorizationRow - ): Promise { + ): Promise { const clientId = this.env.GITHUB_APP_CLIENT_ID; const clientSecret = this.env.GITHUB_APP_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + if (!clientId || !clientSecret) return 'transient_failure'; const refreshToken = this.decryptCredential(authorization, 'refresh'); let response: Response; try { - response = await fetch('https://github.com/login/oauth/access_token', { + response = await fetch(this.getGitHubOAuthAccessTokenUrl(), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -373,12 +507,21 @@ export class GitHubUserAuthorizationService { }), }); } catch { - return null; + return 'transient_failure'; } - if (!response.ok) return null; - const parsed = RefreshResponseSchema.safeParse(await response.json()); - if (!parsed.success) return null; + let responseBody: unknown; + try { + responseBody = await response.json(); + } catch { + return 'transient_failure'; + } + if (RefreshErrorResponseSchema.safeParse(responseBody).success) { + return 'terminal_rejection'; + } + if (!response.ok) return 'transient_failure'; + const parsed = RefreshResponseSchema.safeParse(responseBody); + if (!parsed.success) return 'transient_failure'; const now = Date.now(); const [updated] = await db @@ -409,14 +552,15 @@ export class GitHubUserAuthorizationService { ) ) .returning(); - if (updated) return updated; + if (updated) return 'refreshed'; const [winner] = await db .select() .from(user_github_app_tokens) .where(eq(user_github_app_tokens.id, authorization.id)) .limit(1); - return winner && !winner.revoked_at ? winner : null; + if (winner && !winner.revoked_at) return 'refreshed'; + return 'transient_failure'; } private async revokeAuthorizationOnGitHub(accessToken: string): Promise { diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index 681779ada1..cdd103ba0f 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -10,8 +10,10 @@ import { BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE, GITLAB_CREDENTIAL_AUDIT_AUDIENCE, GITLAB_CREDENTIAL_BROKER_AUDIENCE, + GITHUB_USER_ACCESS_TOKEN_AUDIENCE, } from '@kilocode/worker-utils/internal-service-token-audiences'; import { WorkerEntrypoint } from 'cloudflare:workers'; +import { z } from 'zod'; import { GitHubTokenService, type GitHubAppType } from './github-token-service.js'; import { GitLabLookupService, type GitLabLookupSuccess } from './gitlab-lookup-service.js'; import { @@ -250,6 +252,7 @@ export type RedeemKiloSessionCapabilityResult = | { success: false; reason: RedeemKiloSessionCapabilityFailureReason }; const DISCONNECT_PATH = '/internal/github-user-authorizations/disconnect'; +const USER_ACCESS_TOKEN_PATH = '/internal/github-user-authorizations/token'; const BITBUCKET_REPOSITORIES_PATH = '/internal/bitbucket/repositories'; const BITBUCKET_CODE_REVIEW_PULL_REQUEST_PATH = '/internal/bitbucket/code-review/pull-request'; const BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_PATH = '/internal/bitbucket/code-review/webhooks/ensure'; @@ -268,6 +271,23 @@ const BitbucketDeleteWebhookHttpRequestSchema = BitbucketDeleteWebhookRequestSch owner: true, }); +const UserAccessTokenFetchRequestSchema = z.object({ op: z.literal('fetch') }); +const UserAccessTokenRotateRequestSchema = z.object({ + op: z.literal('rotate'), + staleAuthorizationId: z.string().min(1), + staleCredentialVersion: z.number().int().nonnegative(), +}); +const UserAccessTokenReportRejectedRequestSchema = z.object({ + op: z.literal('reportRejected'), + authorizationId: z.string().min(1), + credentialVersion: z.number().int().nonnegative(), +}); +const UserAccessTokenRequestSchema = z.union([ + UserAccessTokenFetchRequestSchema, + UserAccessTokenRotateRequestSchema, + UserAccessTokenReportRejectedRequestSchema, +]); + const bitbucketCodeReviewAudiences = new Map([ [BITBUCKET_CODE_REVIEW_PULL_REQUEST_PATH, BITBUCKET_CODE_REVIEW_PULL_REQUEST_AUDIENCE], [BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_PATH, BITBUCKET_CODE_REVIEW_WEBHOOK_ENSURE_AUDIENCE], @@ -1169,13 +1189,19 @@ export default { const url = new URL(request.url); const isGitLabCredentialAudit = url.pathname === GITLAB_CREDENTIAL_AUDIT_PATH; const isGitLabCredentialBroker = url.pathname === GITLAB_CREDENTIAL_BROKER_PATH; - const privateGitLabHeaders = - isGitLabCredentialAudit || isGitLabCredentialBroker + // Credential-bearing endpoints must never be cached, including on their + // shared early-return error paths (405/401/503). The GitHub user-access + // token endpoint joins the GitLab private endpoints here. + const privateNoStoreHeaders = + isGitLabCredentialAudit || + isGitLabCredentialBroker || + url.pathname === USER_ACCESS_TOKEN_PATH ? { 'Cache-Control': 'no-store' } : undefined; const codeReviewAudience = bitbucketCodeReviewAudiences.get(url.pathname); if ( url.pathname !== DISCONNECT_PATH && + url.pathname !== USER_ACCESS_TOKEN_PATH && url.pathname !== BITBUCKET_REPOSITORIES_PATH && url.pathname !== GITLAB_CREDENTIAL_BROKER_PATH && !isGitLabCredentialAudit && @@ -1184,14 +1210,14 @@ export default { return new Response(null, { status: 404 }); } if (request.method !== 'POST') { - return new Response(null, { status: 405, headers: privateGitLabHeaders }); + return new Response(null, { status: 405, headers: privateNoStoreHeaders }); } const token = extractBearerToken(request.headers.get('Authorization')); if (!token) { return Response.json( { error: 'unauthorized' }, - { status: 401, headers: privateGitLabHeaders } + { status: 401, headers: privateNoStoreHeaders } ); } @@ -1201,13 +1227,13 @@ export default { } catch { return Response.json( { error: 'authentication_unavailable' }, - { status: 503, headers: privateGitLabHeaders } + { status: 503, headers: privateNoStoreHeaders } ); } if (!secret) { return Response.json( { error: 'authentication_unavailable' }, - { status: 503, headers: privateGitLabHeaders } + { status: 503, headers: privateNoStoreHeaders } ); } @@ -1218,14 +1244,16 @@ export default { ? BITBUCKET_REPOSITORY_LIST_AUDIENCE : url.pathname === GITLAB_CREDENTIAL_BROKER_PATH ? GITLAB_CREDENTIAL_BROKER_AUDIENCE - : isGitLabCredentialAudit - ? GITLAB_CREDENTIAL_AUDIT_AUDIENCE - : codeReviewAudience; + : url.pathname === USER_ACCESS_TOKEN_PATH + ? GITHUB_USER_ACCESS_TOKEN_AUDIENCE + : isGitLabCredentialAudit + ? GITLAB_CREDENTIAL_AUDIT_AUDIENCE + : codeReviewAudience; authorization = await verifyKiloToken(token, secret, audience ? { audience } : undefined); } catch { return Response.json( { error: 'unauthorized' }, - { status: 401, headers: privateGitLabHeaders } + { status: 401, headers: privateNoStoreHeaders } ); } @@ -1360,6 +1388,41 @@ export default { } } + if (url.pathname === USER_ACCESS_TOKEN_PATH) { + let body: unknown; + try { + body = await readBoundedInternalJsonRequest(request); + } catch { + return Response.json( + { error: 'invalid_request' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ); + } + const parsed = UserAccessTokenRequestSchema.safeParse(body); + if (!parsed.success) { + return Response.json( + { error: 'invalid_request' }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ); + } + try { + const service = new GitHubUserAuthorizationService(env); + const result = await service.getUserAccessToken(authorization.kiloUserId, parsed.data); + return Response.json(result, { headers: { 'Cache-Control': 'no-store' } }); + } catch (error) { + if (error instanceof Error && error.message === 'temporarily_unavailable') { + return Response.json( + { error: 'temporarily_unavailable' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } } + ); + } + return Response.json( + { error: 'temporarily_unavailable' }, + { status: 503, headers: { 'Cache-Control': 'no-store' } } + ); + } + } + try { const service = new GitHubUserAuthorizationService(env); await service.disconnectUserAuthorization(authorization.kiloUserId); From d378ef66add2db0142169ceca50ac2540d69f975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 01:30:39 +0200 Subject: [PATCH 02/19] feat(mobile): pr link review entry points --- .../agents/chat-link-actions.test.ts | 38 ++++++++ .../components/agents/chat-link-actions.ts | 27 +++++- .../components/agents/chat-markdown-text.tsx | 74 ++++++++++++++- .../src/components/agents/markdown-text.tsx | 33 +++++-- apps/mobile/src/lib/github-pr-url.test.ts | 95 +++++++++++++++++++ apps/mobile/src/lib/github-pr-url.ts | 36 +++++++ 6 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/src/lib/github-pr-url.test.ts create mode 100644 apps/mobile/src/lib/github-pr-url.ts diff --git a/apps/mobile/src/components/agents/chat-link-actions.test.ts b/apps/mobile/src/components/agents/chat-link-actions.test.ts index e9da2fe15d..8b43bdad5f 100644 --- a/apps/mobile/src/components/agents/chat-link-actions.test.ts +++ b/apps/mobile/src/components/agents/chat-link-actions.test.ts @@ -7,6 +7,7 @@ import { openExternalUrl } from '@/lib/external-link'; import { buildChatLinkActionSheet, + buildPrLinkTapActionSheet, getSelectedChatLinkAction, performChatLinkAction, } from './chat-link-actions'; @@ -41,6 +42,43 @@ describe('chat link actions', () => { expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull(); }); + it('prepends Review PR as the first option for PR links and keeps the existing order', () => { + const sheet = buildChatLinkActionSheet({ isPrLink: true }); + + expect(sheet.options).toEqual(['Review PR', 'Open link', 'Copy link', 'Share link', 'Cancel']); + expect(sheet.cancelButtonIndex).toBe(4); + expect(getSelectedChatLinkAction(sheet, 0)).toBe('review-pr'); + expect(getSelectedChatLinkAction(sheet, 1)).toBe('open'); + expect(getSelectedChatLinkAction(sheet, 2)).toBe('copy'); + expect(getSelectedChatLinkAction(sheet, 3)).toBe('share'); + expect(getSelectedChatLinkAction(sheet, 4)).toBeNull(); + }); + + it('builds the tap sheet with exactly Review PR / Open in browser / Cancel', () => { + const sheet = buildPrLinkTapActionSheet(); + + expect(sheet.options).toEqual(['Review PR', 'Open in browser', 'Cancel']); + expect(sheet.cancelButtonIndex).toBe(2); + expect(getSelectedChatLinkAction(sheet, 0)).toBe('review-pr'); + expect(getSelectedChatLinkAction(sheet, 1)).toBe('open'); + expect(getSelectedChatLinkAction(sheet, 2)).toBeNull(); + expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull(); + }); + + it('leaves non-PR link sheets byte-identical to the pre-PR behavior', () => { + const sheet = buildChatLinkActionSheet({ isPrLink: false }); + + expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']); + expect(sheet.cancelButtonIndex).toBe(3); + }); + + it('omits Review PR when the flag is not supplied', () => { + const sheet = buildChatLinkActionSheet(); + + expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']); + expect(getSelectedChatLinkAction(sheet, 0)).toBe('open'); + }); + it('opens the exact URL through the existing browser helper with retry enabled', async () => { await performChatLinkAction('open', 'https://kilo.ai/docs?source=chat#links'); diff --git a/apps/mobile/src/components/agents/chat-link-actions.ts b/apps/mobile/src/components/agents/chat-link-actions.ts index 8ead408ead..45c9ec985a 100644 --- a/apps/mobile/src/components/agents/chat-link-actions.ts +++ b/apps/mobile/src/components/agents/chat-link-actions.ts @@ -4,14 +4,15 @@ import { toast } from 'sonner-native'; import { openExternalUrl } from '@/lib/external-link'; -type ChatLinkAction = 'open' | 'copy' | 'share'; +type ChatLinkAction = 'open' | 'copy' | 'share' | 'review-pr'; type ChatLinkActionOption = | { kind: ChatLinkAction; label: string } | { kind: 'cancel'; label: 'Cancel' }; -export function buildChatLinkActionSheet() { +export function buildChatLinkActionSheet({ isPrLink = false }: { isPrLink?: boolean } = {}) { const actions: ChatLinkActionOption[] = [ + ...(isPrLink ? ([{ kind: 'review-pr', label: 'Review PR' }] as const) : []), { kind: 'open', label: 'Open link' }, { kind: 'copy', label: 'Copy link' }, { kind: 'share', label: 'Share link' }, @@ -25,8 +26,28 @@ export function buildChatLinkActionSheet() { }; } +/** + * The tap (not long-press) action sheet for a GitHub PR link. The accepted + * contract is exactly three options: Review PR, Open in browser, Cancel. + * This is intentionally distinct from the long-press sheet, which also + * offers Copy/Share. + */ +export function buildPrLinkTapActionSheet() { + const actions: ChatLinkActionOption[] = [ + { kind: 'review-pr', label: 'Review PR' }, + { kind: 'open', label: 'Open in browser' }, + { kind: 'cancel', label: 'Cancel' }, + ]; + + return { + actions, + options: actions.map(action => action.label), + cancelButtonIndex: actions.length - 1, + }; +} + export function getSelectedChatLinkAction( - sheet: ReturnType, + sheet: ReturnType | ReturnType, index: number | undefined ): ChatLinkAction | null { if (index === undefined) { diff --git a/apps/mobile/src/components/agents/chat-markdown-text.tsx b/apps/mobile/src/components/agents/chat-markdown-text.tsx index 1f2afbd6bc..40d6fdd39e 100644 --- a/apps/mobile/src/components/agents/chat-markdown-text.tsx +++ b/apps/mobile/src/components/agents/chat-markdown-text.tsx @@ -1,25 +1,82 @@ import { useActionSheet } from '@expo/react-native-action-sheet'; +import { type Href, useRouter } from 'expo-router'; import { useCallback } from 'react'; import { type GestureResponderEvent } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { openExternalUrl } from '@/lib/external-link'; +import { parseGitHubPrUrl } from '@/lib/github-pr-url'; + import { buildChatLinkActionSheet, + buildPrLinkTapActionSheet, getSelectedChatLinkAction, performChatLinkAction, } from './chat-link-actions'; import { MarkdownText, type MarkdownTextProps } from './markdown-text'; -type ChatMarkdownTextProps = Omit; +type ChatMarkdownTextProps = Omit; + +function buildPrReviewHref(href: string): Href | null { + const parsed = parseGitHubPrUrl(href); + if (!parsed) { + return null; + } + return { + pathname: '/(app)/pr-review/[owner]/[repo]/[number]', + params: { + owner: parsed.owner, + repo: parsed.repo, + number: String(parsed.number), + }, + }; +} export function ChatMarkdownText(props: Readonly) { const { showActionSheetWithOptions } = useActionSheet(); const { bottom } = useSafeAreaInsets(); + const router = useRouter(); + + const handlePressLink = useCallback( + (href: string) => { + if (!parseGitHubPrUrl(href)) { + return false; + } + // Tap on a PR link shows exactly three options: Review PR / Open in + // browser / Cancel. The richer Copy/Share sheet is long-press only. + const sheet = buildPrLinkTapActionSheet(); + showActionSheetWithOptions( + { + options: sheet.options, + cancelButtonIndex: sheet.cancelButtonIndex, + title: 'PR link actions', + message: href, + containerStyle: { paddingBottom: bottom }, + }, + index => { + const action = getSelectedChatLinkAction(sheet, index); + if (action === 'review-pr') { + const reviewHref = buildPrReviewHref(href); + if (reviewHref) { + router.push(reviewHref); + } + return; + } + if (action === 'open') { + void openExternalUrl(href, { label: 'link' }); + } + } + ); + return true; + }, + [bottom, router, showActionSheetWithOptions] + ); const handleLongPressLink = useCallback( (href: string, event?: GestureResponderEvent) => { event?.stopPropagation(); - const sheet = buildChatLinkActionSheet(); + const isPrLink = parseGitHubPrUrl(href) !== null; + const sheet = buildChatLinkActionSheet({ isPrLink }); showActionSheetWithOptions( { options: sheet.options, @@ -30,14 +87,23 @@ export function ChatMarkdownText(props: Readonly) { }, index => { const action = getSelectedChatLinkAction(sheet, index); + if (action === 'review-pr') { + const reviewHref = buildPrReviewHref(href); + if (reviewHref) { + router.push(reviewHref); + } + return; + } if (action) { void performChatLinkAction(action, href); } } ); }, - [bottom, showActionSheetWithOptions] + [bottom, router, showActionSheetWithOptions] ); - return ; + return ( + + ); } diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx index aecafa114c..9c91176020 100644 --- a/apps/mobile/src/components/agents/markdown-text.tsx +++ b/apps/mobile/src/components/agents/markdown-text.tsx @@ -29,11 +29,20 @@ import { MarkdownTable } from './markdown-table'; export type MarkdownLinkLongPressHandler = (href: string, event?: GestureResponderEvent) => void; +export type MarkdownLinkPressHandler = (href: string) => boolean; + export type MarkdownTextProps = { value: string; variant?: MarkdownVariant; selectable?: boolean; onLongPressLink?: MarkdownLinkLongPressHandler; + /** + * Optional tap handler invoked when a rendered link is pressed. When this + * callback is omitted, or when it returns a falsy value, the link is opened + * with `openExternalUrl`. Returning `true` signals that the caller has + * fully handled the press and the default browser open should be skipped. + */ + onPressLink?: MarkdownLinkPressHandler; }; // The library's default `Renderer` renders code blocks with the `em` text @@ -50,20 +59,23 @@ export type MarkdownTextProps = { // loses to the chat bubble's swipe-to-reply pan. We render code as a plain // wrapping Text and tables behind a chip instead — no horizontal ScrollView // ever renders inside a bubble. +type MarkdownRendererHandlers = { + onLongPressLink?: MarkdownLinkLongPressHandler; + onPressLink?: MarkdownLinkPressHandler; +}; + class MarkdownRenderer extends Renderer { private readonly palette: MarkdownPalette; private readonly selectable: boolean; private readonly onLongPressLink?: MarkdownLinkLongPressHandler; + private readonly onPressLink?: MarkdownLinkPressHandler; - constructor( - palette: MarkdownPalette, - selectable = true, - onLongPressLink?: MarkdownLinkLongPressHandler - ) { + constructor(palette: MarkdownPalette, selectable: boolean, handlers: MarkdownRendererHandlers) { super(); this.palette = palette; this.selectable = selectable; - this.onLongPressLink = onLongPressLink; + this.onLongPressLink = handlers.onLongPressLink; + this.onPressLink = handlers.onPressLink; } private textNode(children: string | ReactNode[], styles?: TextStyle): ReactNode { @@ -128,6 +140,10 @@ class MarkdownRenderer extends Renderer { }} onLongPress={getLinkLongPressHandler(this.onLongPressLink, href)} onPress={() => { + const handled = this.onPressLink?.(href); + if (handled) { + return; + } void openExternalUrl(href, { label: accessibilityLabel }); }} style={styles} @@ -182,6 +198,7 @@ export function MarkdownText({ variant = 'assistant', selectable = true, onLongPressLink, + onPressLink, }: Readonly) { const colorScheme = useColorScheme(); const colors = useThemeColors(); @@ -190,7 +207,7 @@ export function MarkdownText({ const palette = getPalette(variant, colors); return { styles: getMarkdownStyles(palette), - renderer: new MarkdownRenderer(palette, selectable, onLongPressLink), + renderer: new MarkdownRenderer(palette, selectable, { onLongPressLink, onPressLink }), theme: { colors: { text: palette.textColor, @@ -200,7 +217,7 @@ export function MarkdownText({ }, }, }; - }, [variant, colors, selectable, onLongPressLink]); + }, [variant, colors, selectable, onLongPressLink, onPressLink]); const elements = useMarkdown(value, { colorScheme, diff --git a/apps/mobile/src/lib/github-pr-url.test.ts b/apps/mobile/src/lib/github-pr-url.test.ts new file mode 100644 index 0000000000..bcff1ff95c --- /dev/null +++ b/apps/mobile/src/lib/github-pr-url.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { parseGitHubPrUrl } from './github-pr-url'; + +describe('parseGitHubPrUrl', () => { + it('parses a canonical PR URL', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('parses a PR URL with the /files subpath', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42/files')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('parses a PR URL with a query string', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42?diff=split')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('parses a PR URL with a trailing slash', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42/')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('parses http URLs', () => { + expect(parseGitHubPrUrl('http://github.com/octocat/hello-world/pull/1')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 1, + }); + }); + + it('parses www.github.com host', () => { + expect(parseGitHubPrUrl('https://www.github.com/octocat/hello-world/pull/7')).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 7, + }); + }); + + it('parses a PR URL with a hash fragment', () => { + expect( + parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42#discussion_r1') + ).toEqual({ + owner: 'octocat', + repo: 'hello-world', + number: 42, + }); + }); + + it('returns null for an issue URL', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/issues/42')).toBeNull(); + }); + + it('returns null for a tree URL', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/tree/main')).toBeNull(); + }); + + it('returns null for a plain repo URL', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world')).toBeNull(); + }); + + it('returns null for a non-GitHub host', () => { + expect(parseGitHubPrUrl('https://gitlab.com/octocat/hello-world/pull/42')).toBeNull(); + }); + + it('returns null for a malformed URL', () => { + expect(parseGitHubPrUrl('not a url at all')).toBeNull(); + }); + + it('returns null for a PR URL with a non-numeric number', () => { + expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/abc')).toBeNull(); + }); + + it('returns null for an empty string', () => { + expect(parseGitHubPrUrl('')).toBeNull(); + }); + + it('returns null for a URL missing the owner', () => { + expect(parseGitHubPrUrl('https://github.com/hello-world/pull/42')).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/github-pr-url.ts b/apps/mobile/src/lib/github-pr-url.ts new file mode 100644 index 0000000000..6a5db28d3e --- /dev/null +++ b/apps/mobile/src/lib/github-pr-url.ts @@ -0,0 +1,36 @@ +type GitHubPrUrl = { + owner: string; + repo: string; + number: number; +}; + +const GITHUB_PR_PATTERN = + /^https?:\/\/(www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#][^\s]*)?$/i; + +/** + * Parse a GitHub pull request URL into its owner, repo and PR number. + * + * Matches `http(s)://(www.)github.com///pull/` and + * tolerates any trailing subpath (e.g. `/files`), query string and trailing + * slash. Returns `null` for non-PR URLs (issues, tree, plain repo), uppercase + * host, non-GitHub hosts, or malformed input. + */ +export function parseGitHubPrUrl(href: string): GitHubPrUrl | null { + if (typeof href !== 'string' || href.length === 0) { + return null; + } + const match = GITHUB_PR_PATTERN.exec(href); + if (!match) { + return null; + } + // The pattern guarantees the digit group is present when exec returns. + const numberString = match[4]; + if (numberString === undefined) { + return null; + } + const number = Number.parseInt(numberString, 10); + if (!Number.isInteger(number) || number <= 0) { + return null; + } + return { owner: match[2] ?? '', repo: match[3] ?? '', number }; +} From 6b59bd90dcfc5964ac4e8e95d7dbb64f9cf68189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 01:30:40 +0200 Subject: [PATCH 03/19] feat(mobile): pr review screens and scaffolding --- apps/mobile/src/app/(app)/_layout.tsx | 6 + .../[owner]/[repo]/[number]/_layout.tsx | 59 +++++ .../[repo]/[number]/comment-composer.tsx | 5 + .../[repo]/[number]/file-navigator.tsx | 5 + .../[owner]/[repo]/[number]/index.tsx | 21 ++ .../[owner]/[repo]/[number]/merge.tsx | 5 + .../[owner]/[repo]/[number]/review-submit.tsx | 5 + apps/mobile/src/app/(app)/pr-review/index.tsx | 10 + .../pr-review-comment-composer-screen.tsx | 55 +++++ .../pr-review/pr-review-connect-gate.tsx | 169 +++++++++++++++ .../pr-review/pr-review-entry-screen.tsx | 204 ++++++++++++++++++ .../pr-review-file-navigator-screen.tsx | 40 ++++ .../pr-review/pr-review-merge-screen.tsx | 40 ++++ .../pr-review-review-submit-screen.tsx | 40 ++++ .../components/pr-review/pr-review-screen.tsx | 30 +++ apps/mobile/src/components/profile-screen.tsx | 24 ++- apps/mobile/src/lib/auth/auth-context.tsx | 8 + apps/mobile/src/lib/hooks/is-tablet.ts | 12 ++ .../src/lib/hooks/use-is-tablet.test.ts | 32 +++ apps/mobile/src/lib/hooks/use-is-tablet.ts | 26 +++ .../pr-review/connect-gate-platform.test.ts | 71 ++++++ .../lib/pr-review/connect-gate-platform.ts | 48 +++++ .../lib/pr-review/diff-selection-bridge.ts | 53 +++++ .../lib/pr-review/file-navigator-bridge.ts | 43 ++++ .../lib/pr-review/pending-review-provider.tsx | 53 +++++ .../src/lib/pr-review/pr-bridges.test.ts | 66 ++++++ .../src/lib/pr-review/recent-prs.test.ts | 123 +++++++++++ apps/mobile/src/lib/pr-review/recent-prs.ts | 101 +++++++++ .../src/lib/pr-review/viewed-files.test.ts | 143 ++++++++++++ apps/mobile/src/lib/pr-review/viewed-files.ts | 158 ++++++++++++++ .../src/lib/profile-agent-navigation.ts | 8 + apps/mobile/src/lib/storage-keys.ts | 2 + apps/mobile/vitest.config.ts | 1 + 33 files changed, 1665 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx create mode 100644 apps/mobile/src/app/(app)/pr-review/index.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-screen.tsx create mode 100644 apps/mobile/src/lib/hooks/is-tablet.ts create mode 100644 apps/mobile/src/lib/hooks/use-is-tablet.test.ts create mode 100644 apps/mobile/src/lib/hooks/use-is-tablet.ts create mode 100644 apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts create mode 100644 apps/mobile/src/lib/pr-review/connect-gate-platform.ts create mode 100644 apps/mobile/src/lib/pr-review/diff-selection-bridge.ts create mode 100644 apps/mobile/src/lib/pr-review/file-navigator-bridge.ts create mode 100644 apps/mobile/src/lib/pr-review/pending-review-provider.tsx create mode 100644 apps/mobile/src/lib/pr-review/pr-bridges.test.ts create mode 100644 apps/mobile/src/lib/pr-review/recent-prs.test.ts create mode 100644 apps/mobile/src/lib/pr-review/recent-prs.ts create mode 100644 apps/mobile/src/lib/pr-review/viewed-files.test.ts create mode 100644 apps/mobile/src/lib/pr-review/viewed-files.ts diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx index 20ec428c07..4d6a5cf1c1 100644 --- a/apps/mobile/src/app/(app)/_layout.tsx +++ b/apps/mobile/src/app/(app)/_layout.tsx @@ -25,6 +25,12 @@ export default function AppLayout() { }} > + (); + const owner = parseParam(params.owner); + const repo = parseParam(params.repo); + const rawNumber = parseParam(params.number); + const number = rawNumber ? Number.parseInt(rawNumber, 10) : Number.NaN; + const { fullSheetDetent } = useFormSheetDetents(); + + if (!owner || !repo || !Number.isInteger(number) || number <= 0) { + return ; + } + + const sheetOptions = { + presentation: 'formSheet' as const, + sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number], + sheetGrabberVisible: true, + headerShown: false, + }; + + // The connect gate wraps every PR-review surface, including this nested + // route reached directly by deep link / chat tap, so a disconnected or + // revoked user can never reach the authenticated queries and mutations. + return ( + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx new file mode 100644 index 0000000000..2872ee62b1 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx @@ -0,0 +1,5 @@ +import { PrReviewCommentComposerScreen } from '@/components/pr-review/pr-review-comment-composer-screen'; + +export default function PrReviewCommentComposerRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx new file mode 100644 index 0000000000..5b34be2218 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx @@ -0,0 +1,5 @@ +import { PrReviewFileNavigatorScreen } from '@/components/pr-review/pr-review-file-navigator-screen'; + +export default function PrReviewFileNavigatorRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx new file mode 100644 index 0000000000..b38a5c9be7 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx @@ -0,0 +1,21 @@ +import { Stack, useLocalSearchParams } from 'expo-router'; + +import { PrReviewScreen } from '@/components/pr-review/pr-review-screen'; + +type Params = { + owner: string; + repo: string; + number: string; +}; + +export default function PrReviewNumberIndexRoute() { + const { owner, repo, number } = useLocalSearchParams(); + const numberValue = Number.parseInt(number, 10); + + return ( + <> + + + + ); +} diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx new file mode 100644 index 0000000000..bef38a58f7 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx @@ -0,0 +1,5 @@ +import { PrReviewMergeScreen } from '@/components/pr-review/pr-review-merge-screen'; + +export default function PrReviewMergeRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx new file mode 100644 index 0000000000..a28b1f7a14 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx @@ -0,0 +1,5 @@ +import { PrReviewReviewSubmitScreen } from '@/components/pr-review/pr-review-review-submit-screen'; + +export default function PrReviewReviewSubmitRoute() { + return ; +} diff --git a/apps/mobile/src/app/(app)/pr-review/index.tsx b/apps/mobile/src/app/(app)/pr-review/index.tsx new file mode 100644 index 0000000000..2514d0c877 --- /dev/null +++ b/apps/mobile/src/app/(app)/pr-review/index.tsx @@ -0,0 +1,10 @@ +import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate'; +import { PrReviewEntryScreen } from '@/components/pr-review/pr-review-entry-screen'; + +export default function PrReviewIndexRoute() { + return ( + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx new file mode 100644 index 0000000000..70322f9861 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx @@ -0,0 +1,55 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ScrollView, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +// Thin stub for the comment composer sheet. S7a fills in the body — the +// line/side context, the text input, the diff snippet preview, the +// submit/cancel actions. S4b only needs the route to render something +// inside a ScrollView (iOS formSheet gotcha) and pin the param contract. +type Params = { + owner: string; + repo: string; + number: string; + path: string; + side: 'LEFT' | 'RIGHT'; + line: string; + startLine?: string; +}; + +export function PrReviewCommentComposerScreen() { + const colors = useThemeColors(); + const router = useRouter(); + const params = useLocalSearchParams(); + const startLine = params.startLine ? Number.parseInt(params.startLine, 10) : undefined; + const lineNumber = Number.parseInt(params.line, 10); + + return ( + + { + router.back(); + }} + /> + + + {params.path} {params.side} L{Number.isFinite(lineNumber) ? lineNumber : '?'} + {startLine && Number.isFinite(startLine) ? `–L${startLine}` : ''} + + + Comment composer lands in S7a. + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx new file mode 100644 index 0000000000..e512e40cb8 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx @@ -0,0 +1,169 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { PlugZap, RefreshCcw, ShieldAlert } from 'lucide-react-native'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, AppState, type AppStateStatus, Platform, View } from 'react-native'; +import { toast } from 'sonner-native'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform'; +import { useTRPC } from '@/lib/trpc'; + +type PrReviewConnectGateProps = { + readonly children: ReactNode; +}; + +/** + * Wraps every PR-review surface. The user's GitHub identity (separate from + * a per-org GitHub App installation) is required to post review comments + * via the mobile app — without it, every mutation would 401 in the same + * way. The gate is the single place that handles: + * + * - happy: connected → render children + * - retryable: getUserAuthorization fails → QueryError + Retry + * - empty: not connected / revoked → EmptyState CTA + * - non-retryable: structurally n/a (this is a configuration gate, not a + * transient server failure). + * + * The CTA calls `githubApps.connectUserAuthorization` and opens the + * returned URL with the platform-appropriate browser launcher (iOS native + * auth session that resolves on sheet close; Android custom tab that + * resolves on app-foreground via AppState). Cancellation on either + * platform simply leaves the gate showing — there's nothing to roll + * back because the auth flow is server-driven. + */ +export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const colors = useThemeColors(); + const authorization = useQuery(trpc.githubApps.getUserAuthorization.queryOptions()); + const connect = useMutation( + trpc.githubApps.connectUserAuthorization.mutationOptions({ + onError: error => { + toast.error(error.message); + }, + }) + ); + + // Track the in-flight launch so a stale AppState 'active' transition + // (from the user backgrounding the app before tapping Connect) doesn't + // trigger a refetch on its own. + const launchedAt = useRef(null); + const [connecting, setConnecting] = useState(false); + + // iOS: openAuthSessionAsync already resolves on sheet close, so we await + // it and refetch right there. Android: openBrowserAsync is fire-and- + // forget, so we listen for AppState returning to 'active' and refetch + // then. Same split as use-device-auth.ts. + useEffect(() => { + if (Platform.OS !== 'android') { + return undefined; + } + const handleChange = (nextState: AppStateStatus) => { + if (nextState !== 'active') { + return; + } + if (launchedAt.current === null) { + return; + } + launchedAt.current = null; + void authorization.refetch(); + }; + const subscription = AppState.addEventListener('change', handleChange); + return () => { + subscription.remove(); + }; + }, [authorization]); + + const handleConnect = async () => { + setConnecting(true); + try { + const result = await connect.mutateAsync(); + launchedAt.current = Date.now(); + const trigger = await openAuthorizationAndWaitForReturn(Platform.OS, result.authorizationUrl); + if (trigger === 'sheet-close') { + // iOS: refetch immediately. Clear the launch sentinel so the + // AppState handler (if it ever fires) doesn't double-refetch. + launchedAt.current = null; + await authorization.refetch(); + await queryClient.invalidateQueries({ + queryKey: trpc.githubApps.getUserAuthorization.queryKey(), + }); + } + // Android: refetch is handled by the AppState listener when the app + // returns to foreground. `openBrowserAsync` resolves as soon as the + // browser is launched, so we must NOT clear the sentinel here — the + // foreground handler clears it once it has consumed it. + } catch { + // mutateAsync already toasted; the openAuthorizationAndWaitForReturn + // rejection means the browser failed to open — clear the sentinel so + // a later unrelated foreground doesn't trigger a stray refetch, and + // keep the gate showing. + launchedAt.current = null; + } finally { + setConnecting(false); + } + }; + + if (authorization.isError) { + return ( + + { + void authorization.refetch(); + }} + isRetrying={authorization.isFetching} + /> + + ); + } + + if (authorization.isLoading) { + return ( + + + + ); + } + + if (!authorization.data?.connected) { + const revoked = authorization.data?.revoked === true; + return ( + + { + void handleConnect(); + }} + > + {connecting ? ( + + ) : ( + + )} + {revoked ? 'Reconnect GitHub' : 'Connect GitHub'} + + } + /> + + ); + } + + return <>{children}; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx new file mode 100644 index 0000000000..21c54415b0 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx @@ -0,0 +1,204 @@ +import { useFocusEffect, useRouter } from 'expo-router'; +import { ChevronRight, Clock, Link2, SearchX } from 'lucide-react-native'; +import { type ReactNode, useCallback, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { ScreenHeader } from '@/components/screen-header'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { parseGitHubPrUrl } from '@/lib/github-pr-url'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { getPrReviewPath } from '@/lib/profile-agent-navigation'; +import { getRecentPrs, type RecentPr, upsertRecentPr } from '@/lib/pr-review/recent-prs'; + +const URL_PLACEHOLDER = 'https://github.com/owner/repo/pull/123'; + +export function PrReviewEntryScreen() { + const router = useRouter(); + const colors = useThemeColors(); + // Uncontrolled iOS input — keep the raw text in a ref so the submit + // handler reads the latest value without re-rendering on every + // keystroke. State is only for derived UI (whether there's any text, + // whether the user already tried an invalid value). The TextInput + // component ref is for focus() (e.g. the empty-state CTA). + const inputRef = useRef(null); + const inputValueRef = useRef(''); + const [hasInput, setHasInput] = useState(false); + const [invalid, setInvalid] = useState(false); + const [recent, setRecent] = useState(null); + + useFocusEffect( + useCallback(() => { + const state = { cancelled: false }; + void (async () => { + const list = await getRecentPrs(); + if (!state.cancelled) { + setRecent(list); + } + })(); + return () => { + state.cancelled = true; + }; + }, []) + ); + + const handleSubmit = async () => { + const raw = inputValueRef.current; + const parsed = parseGitHubPrUrl(raw.trim()); + if (!parsed) { + setInvalid(true); + return; + } + setInvalid(false); + // Title is backfilled on first successful load (S5). + await upsertRecentPr({ + owner: parsed.owner, + repo: parsed.repo, + number: parsed.number, + title: '', + lastOpenedAt: Date.now(), + }); + router.push(getPrReviewPath(parsed.owner, parsed.repo, parsed.number)); + }; + + const focusInput = () => { + inputRef.current?.focus(); + }; + + const handleRecentPress = async (entry: RecentPr) => { + await upsertRecentPr({ + ...entry, + lastOpenedAt: Date.now(), + }); + router.push(getPrReviewPath(entry.owner, entry.repo, entry.number)); + }; + + let helper: ReactNode = null; + if (invalid) { + helper = Not a GitHub pull request link; + } else if (!hasInput) { + helper = ( + + Paste a link like {URL_PLACEHOLDER} + + ); + } + + let recentsBody: ReactNode = null; + if (recent === null) { + recentsBody = ; + } else if (recent.length === 0) { + recentsBody = ( + + Paste a PR link + + } + /> + ); + } else { + recentsBody = ( + + {recent.map((entry, index) => { + const isLast = index === recent.length - 1; + return ( + { + void handleRecentPress(entry); + }} + className={`flex-row items-center gap-3 px-3 py-3 active:opacity-70 ${ + isLast ? '' : 'border-b-[0.5px] border-hair-soft' + }`} + > + + + {entry.title || `${entry.owner}/${entry.repo}#${entry.number}`} + + + {entry.owner}/{entry.repo}#{entry.number} + + + + + ); + })} + + ); + } + + return ( + + + + + + + + Paste a PR link + + + { + // Don't setState on every keystroke; track only whether the + // input has any text and whether the user has tried to + // submit an invalid value. The raw value lives in the ref + // so handleSubmit reads the latest text without re-rendering. + inputValueRef.current = value; + setHasInput(value.length > 0); + if (invalid) { + setInvalid(false); + } + }} + // explicit line-height so the placeholder + typed text render + // at the same vertical position on every iOS version. + className="rounded-md border border-border bg-card px-3 py-3 text-base text-foreground leading-[22px]" + accessibilityLabel="GitHub pull request URL" + returnKeyType="go" + onSubmitEditing={() => { + void handleSubmit(); + }} + /> + {helper} + + + + + + + + Recent + + + {recentsBody} + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx new file mode 100644 index 0000000000..b758515ba9 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx @@ -0,0 +1,40 @@ +import { useLocalSearchParams } from 'expo-router'; +import { ScrollView, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +// Thin stub for the file-navigator sheet. S6c implements the list and the +// scroll-to-file bridge producer. S4b only needs the route to mount and +// pin the param contract. +type Params = { + owner: string; + repo: string; + number: string; +}; + +export function PrReviewFileNavigatorScreen() { + const colors = useThemeColors(); + const params = useLocalSearchParams(); + + return ( + + + + + File navigator lands in S6c. + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx new file mode 100644 index 0000000000..51035c6221 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -0,0 +1,40 @@ +import { useLocalSearchParams } from 'expo-router'; +import { ScrollView, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +// Thin stub for the merge sheet. S8 implements the merge-method picker +// and the merge mutation. S4b only needs the route to mount and pin the +// param contract. +type Params = { + owner: string; + repo: string; + number: string; +}; + +export function PrReviewMergeScreen() { + const colors = useThemeColors(); + const params = useLocalSearchParams(); + + return ( + + + + + Merge picker lands in S8. + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx new file mode 100644 index 0000000000..cf54f39f53 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx @@ -0,0 +1,40 @@ +import { useLocalSearchParams } from 'expo-router'; +import { ScrollView, View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +// Thin stub for the review-submit sheet (approve / request-changes / +// comment-only summary). S8 implements the actual radio group + submit +// mutation. S4b only needs the route to mount and pin the param contract. +type Params = { + owner: string; + repo: string; + number: string; +}; + +export function PrReviewReviewSubmitScreen() { + const colors = useThemeColors(); + const params = useLocalSearchParams(); + + return ( + + + + + Review submit lands in S8. + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx new file mode 100644 index 0000000000..ee7cbb03fe --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -0,0 +1,30 @@ +import { GitPullRequest } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type PrReviewScreenProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; +}; + +// Placeholder for the PR review screen. S5 fleshes this out into the +// Overview / Diff / Discussion / Merge tab surface; S4b only needs the +// route to render *something* so the navigation tree typechecks and the +// gate's happy path can be exercised end-to-end. +export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { + const colors = useThemeColors(); + + return ( + + + + + PR review is loading… + + + ); +} diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index de87cd3876..ad90b39c7d 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -3,6 +3,7 @@ import * as Application from 'expo-application'; import { type Href, useRouter } from 'expo-router'; import { Building2, + GitMerge, GitPullRequest, KeyRound, Lock, @@ -30,7 +31,11 @@ import { showFeedbackPrompt } from '@/lib/feedback'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; -import { getCodeReviewerProfilePath, getProfileAgentScope } from '@/lib/profile-agent-navigation'; +import { + getCodeReviewerProfilePath, + getProfileAgentScope, + getPrReviewEntryPath, +} from '@/lib/profile-agent-navigation'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; @@ -161,6 +166,23 @@ export function ProfileScreen() { /> + {/* PR Review */} + + + Reviews + + { + router.push(getPrReviewEntryPath()); + }} + /> + + {/* Organization */} {organizationId != null && ( diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx index 1867cef55b..6be8061962 100644 --- a/apps/mobile/src/lib/auth/auth-context.tsx +++ b/apps/mobile/src/lib/auth/auth-context.tsx @@ -17,6 +17,8 @@ import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference'; import { clearLastActiveInstance } from '@/lib/last-active-instance'; import { resetPurchaseErrorToastDedup } from '@/lib/kilo-pass/use-store-kilo-pass-purchase'; +import { clearRecentPrs } from '@/lib/pr-review/recent-prs'; +import { clearViewedFiles } from '@/lib/pr-review/viewed-files'; import { AUTH_TOKEN_KEY, NOTIFICATION_PROMPT_SEEN_KEY, @@ -66,6 +68,12 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) { await SecureStore.deleteItemAsync(SESSION_FILTERS_KEY); await SecureStore.deleteItemAsync(NOTIFICATION_PROMPT_SEEN_KEY); await clearLastActiveInstance(); + // PR-review storage is keyed by (owner, repo, number) so it's not + // obviously user-scoped, but the user-facing recents list absolutely + // is — clear it so the next signed-in account doesn't inherit the + // previous user's recently-opened PRs. + await clearRecentPrs(); + await clearViewedFiles(); clearAgentModelPreference(); clearReasoningPreference(); resetAnalyticsUser(); diff --git a/apps/mobile/src/lib/hooks/is-tablet.ts b/apps/mobile/src/lib/hooks/is-tablet.ts new file mode 100644 index 0000000000..633a43a41c --- /dev/null +++ b/apps/mobile/src/lib/hooks/is-tablet.ts @@ -0,0 +1,12 @@ +// Pure threshold check — kept separate from the hook so it can be unit-tested +// without pulling in the React Native runtime (which doesn't parse under the +// vitest/Vite environment). + +const TABLET_MIN_SHORT_EDGE = 600; + +export function isTabletFromDimensions(width: number, height: number, isPad: boolean): boolean { + if (isPad) { + return true; + } + return Math.min(width, height) >= TABLET_MIN_SHORT_EDGE; +} diff --git a/apps/mobile/src/lib/hooks/use-is-tablet.test.ts b/apps/mobile/src/lib/hooks/use-is-tablet.test.ts new file mode 100644 index 0000000000..8fa909ffe5 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-is-tablet.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { isTabletFromDimensions } from './is-tablet'; + +describe('isTabletFromDimensions', () => { + it('reports iPad hardware as tablet regardless of dimensions', () => { + expect(isTabletFromDimensions(320, 480, true)).toBe(true); + expect(isTabletFromDimensions(1024, 768, true)).toBe(true); + }); + + it('reports phone-portrait dimensions (short edge < 600) as phone', () => { + expect(isTabletFromDimensions(390, 844, false)).toBe(false); + expect(isTabletFromDimensions(320, 568, false)).toBe(false); + }); + + it('reports phone-landscape (short edge < 600) as phone', () => { + // A landscape phone is wider than tall but still has a short edge < 600. + expect(isTabletFromDimensions(844, 390, false)).toBe(false); + }); + + it('reports a window with a 400-short-edge as phone', () => { + expect(isTabletFromDimensions(600, 400, false)).toBe(false); + expect(isTabletFromDimensions(400, 600, false)).toBe(false); + }); + + it('reports windows with a short edge >= 600 as tablet', () => { + expect(isTabletFromDimensions(600, 1200, false)).toBe(true); + expect(isTabletFromDimensions(1200, 600, false)).toBe(true); + expect(isTabletFromDimensions(1024, 768, false)).toBe(true); + expect(isTabletFromDimensions(768, 1024, false)).toBe(true); + }); +}); diff --git a/apps/mobile/src/lib/hooks/use-is-tablet.ts b/apps/mobile/src/lib/hooks/use-is-tablet.ts new file mode 100644 index 0000000000..5c36b15b7a --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-is-tablet.ts @@ -0,0 +1,26 @@ +import { Platform, useWindowDimensions } from 'react-native'; + +import { isTabletFromDimensions } from '@/lib/hooks/is-tablet'; + +// Re-export so the existing import path keeps working for callers. +export { isTabletFromDimensions } from '@/lib/hooks/is-tablet'; + +// `Platform.isPad` is a runtime constant on iOS hardware (not in the public +// TS types for cross-platform code), so we narrow at the call site. +function readIsPad(): boolean { + if (Platform.OS !== 'ios') { + return false; + } + return (Platform as { isPad?: boolean }).isPad === true; +} + +/** + * Live-recomputed tablet/phone split. Use for layouts that branch on form + * factor (single-pane vs split-view, sheet detents, etc.). The threshold + * itself is a pure decision in `isTabletFromDimensions` so it stays + * unit-testable without RN's window/Platform globals. + */ +export function useIsTablet(): boolean { + const { width, height } = useWindowDimensions(); + return isTabletFromDimensions(width, height, readIsPad()); +} diff --git a/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts b/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts new file mode 100644 index 0000000000..0a007aae0a --- /dev/null +++ b/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getConnectGatePlatformPlan, + openAuthorizationAndWaitForReturn, +} from './connect-gate-platform'; + +const webBrowserMocks = vi.hoisted(() => ({ + openAuthSessionAsync: vi.fn<(url: string) => Promise>(), + openBrowserAsync: vi.fn<(url: string) => Promise>(), +})); + +vi.mock('expo-web-browser', () => ({ + openAuthSessionAsync: webBrowserMocks.openAuthSessionAsync, + openBrowserAsync: webBrowserMocks.openBrowserAsync, +})); + +beforeEach(() => { + webBrowserMocks.openAuthSessionAsync.mockReset(); + webBrowserMocks.openBrowserAsync.mockReset(); +}); + +describe('getConnectGatePlatformPlan', () => { + it('uses the native auth session and refetches on sheet close for iOS', () => { + expect(getConnectGatePlatformPlan('ios')).toEqual({ + launcher: 'openAuthSession', + refetchTrigger: 'sheet-close', + }); + }); + + it('uses a plain custom-tab browser and refetches on app-foreground for Android', () => { + expect(getConnectGatePlatformPlan('android')).toEqual({ + launcher: 'openBrowser', + refetchTrigger: 'app-foreground', + }); + }); + + it('falls back to the Android plan for unknown platforms (web, etc.)', () => { + expect(getConnectGatePlatformPlan('web')).toEqual({ + launcher: 'openBrowser', + refetchTrigger: 'app-foreground', + }); + expect(getConnectGatePlatformPlan('')).toEqual({ + launcher: 'openBrowser', + refetchTrigger: 'app-foreground', + }); + }); +}); + +describe('openAuthorizationAndWaitForReturn', () => { + it('uses openAuthSessionAsync on iOS and reports sheet-close', async () => { + webBrowserMocks.openAuthSessionAsync.mockResolvedValue('done'); + const trigger = await openAuthorizationAndWaitForReturn('ios', 'https://example.com/connect'); + expect(webBrowserMocks.openAuthSessionAsync).toHaveBeenCalledWith( + 'https://example.com/connect' + ); + expect(webBrowserMocks.openBrowserAsync).not.toHaveBeenCalled(); + expect(trigger).toBe('sheet-close'); + }); + + it('uses openBrowserAsync on Android and reports app-foreground', async () => { + webBrowserMocks.openBrowserAsync.mockResolvedValue(undefined); + const trigger = await openAuthorizationAndWaitForReturn( + 'android', + 'https://example.com/connect' + ); + expect(webBrowserMocks.openBrowserAsync).toHaveBeenCalledWith('https://example.com/connect'); + expect(webBrowserMocks.openAuthSessionAsync).not.toHaveBeenCalled(); + expect(trigger).toBe('app-foreground'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/connect-gate-platform.ts b/apps/mobile/src/lib/pr-review/connect-gate-platform.ts new file mode 100644 index 0000000000..f4177c7c46 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/connect-gate-platform.ts @@ -0,0 +1,48 @@ +// Pure/hook selection for the connect-gate's platform branch. Extracted so +// the platform choice (which browser launcher + which refetch trigger) can +// be unit-tested without pulling in the full React component tree. + +import * as WebBrowser from 'expo-web-browser'; + +type AuthLauncher = 'openAuthSession' | 'openBrowser'; + +type GateRefetchTrigger = 'sheet-close' | 'app-foreground'; + +type ConnectGatePlatformPlan = { + launcher: AuthLauncher; + refetchTrigger: GateRefetchTrigger; +}; + +/** + * Maps a React Native platform to the browser launcher and refetch trigger + * the connect gate should use after the auth session ends. + * + * - iOS: `openAuthSessionAsync` returns when the sheet closes, so we + * refetch on `sheet-close`. No foreground listener needed. + * - Android: `openBrowserAsync` is fire-and-forget (no callback when the + * user finishes), so we wait for the app to return to foreground and + * refetch then. Same pattern as `use-device-auth.ts` ~:34-42. + */ +export function getConnectGatePlatformPlan(platform: string): ConnectGatePlatformPlan { + if (platform === 'ios') { + return { launcher: 'openAuthSession', refetchTrigger: 'sheet-close' }; + } + return { launcher: 'openBrowser', refetchTrigger: 'app-foreground' }; +} + +/** + * Opens the authorization URL with the platform-appropriate launcher and + * resolves with the trigger the caller should use to refetch the + * authorization query. Kept as a single helper so the gate component + * doesn't have to know which platform maps to which API. + */ +export async function openAuthorizationAndWaitForReturn( + platform: string, + authorizationUrl: string +): Promise { + const plan = getConnectGatePlatformPlan(platform); + await (plan.launcher === 'openAuthSession' + ? WebBrowser.openAuthSessionAsync(authorizationUrl) + : WebBrowser.openBrowserAsync(authorizationUrl)); + return plan.refetchTrigger; +} diff --git a/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts new file mode 100644 index 0000000000..dda58382dd --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts @@ -0,0 +1,53 @@ +// Module-level bridge for the current diff selection (path + side + line +// range + the actual selected line text). The diff side calls +// `setDiffSelection` when the user taps a line range; the comment composer +// route reads it via `getDiffSelection` on focus and clears it on blur so +// a stale selection never leaks into the next visit. S7a implements the +// producer side (the diff component) and the consumer side (the composer +// sheet) in the same slice that adds the final pending-comment fields. +// +// The selection carries its owning PR identity so a selection made in one +// PR can never be consumed by another PR's composer if both entries remain +// mounted in the navigation stack: `getDiffSelection` returns null unless +// the requested PR matches the stored selection. + +export type DiffSelectionSide = 'LEFT' | 'RIGHT'; + +export type PrIdentity = { + owner: string; + repo: string; + number: number; +}; + +export type DiffSelection = PrIdentity & { + path: string; + side: DiffSelectionSide; + line: number; + startLine?: number; + selectedText: string; +}; + +let selection: DiffSelection | null = null; + +function samePr(a: PrIdentity, b: PrIdentity): boolean { + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() && + a.number === b.number + ); +} + +export function setDiffSelection(next: DiffSelection) { + selection = next; +} + +export function getDiffSelection(pr: PrIdentity): DiffSelection | null { + if (!selection || !samePr(selection, pr)) { + return null; + } + return selection; +} + +export function clearDiffSelection() { + selection = null; +} diff --git a/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts new file mode 100644 index 0000000000..a4df62aa68 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts @@ -0,0 +1,43 @@ +// Module-level bridge for the file-navigator's "scroll to file" request. +// The file navigator subscribes via `subscribeFileNavigatorRequest` and +// navigates the diff list when the user picks a file from the navigator +// sheet. `requestScrollToFile` is the producer side, called by the +// navigator sheet on selection. S6c implements both ends. +// +// Every request carries its owning PR identity, and subscribers register +// for a specific PR, so a navigation request emitted for one PR is never +// delivered to another PR's diff list if both remain mounted. + +import { type PrIdentity } from './diff-selection-bridge'; + +export type FileNavigatorRequest = PrIdentity & { + path: string; +}; + +type Listener = (request: FileNavigatorRequest) => void; + +const listeners = new Set<{ pr: PrIdentity; listener: Listener }>(); + +function samePr(a: PrIdentity, b: PrIdentity): boolean { + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() && + a.number === b.number + ); +} + +export function requestScrollToFile(request: FileNavigatorRequest) { + for (const entry of listeners) { + if (samePr(entry.pr, request)) { + entry.listener(request); + } + } +} + +export function subscribeFileNavigatorRequest(pr: PrIdentity, listener: Listener): () => void { + const entry = { pr, listener }; + listeners.add(entry); + return () => { + listeners.delete(entry); + }; +} diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx new file mode 100644 index 0000000000..9cb5f5da50 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx @@ -0,0 +1,53 @@ +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +// Minimal item shape — S7a owns the final pending-comment fields. Keeping +// the type intentionally loose lets the provider land in S4b without +// baking in fields that the comment composer hasn't decided on yet. +export type PendingReviewItem = { + id: string; + path: string; + side: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; + body: string; +}; + +type PendingReviewContextValue = { + items: PendingReviewItem[]; + addComment: (item: PendingReviewItem) => void; + removeComment: (id: string) => void; + clear: () => void; +}; + +const PendingReviewContext = createContext(undefined); + +export function PendingReviewProvider({ children }: { readonly children: ReactNode }) { + const [items, setItems] = useState([]); + + const addComment = useCallback((item: PendingReviewItem) => { + setItems(previous => [...previous, item]); + }, []); + + const removeComment = useCallback((id: string) => { + setItems(previous => previous.filter(item => item.id !== id)); + }, []); + + const clear = useCallback(() => { + setItems([]); + }, []); + + const value = useMemo( + () => ({ items, addComment, removeComment, clear }), + [items, addComment, removeComment, clear] + ); + + return {children}; +} + +export function usePendingReview(): PendingReviewContextValue { + const context = useContext(PendingReviewContext); + if (!context) { + throw new Error('usePendingReview must be used within a PendingReviewProvider'); + } + return context; +} diff --git a/apps/mobile/src/lib/pr-review/pr-bridges.test.ts b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts new file mode 100644 index 0000000000..63170dc012 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearDiffSelection, getDiffSelection, setDiffSelection } from './diff-selection-bridge'; +import { + type FileNavigatorRequest, + requestScrollToFile, + subscribeFileNavigatorRequest, +} from './file-navigator-bridge'; + +type NavListener = (request: FileNavigatorRequest) => void; + +const PR_A = { owner: 'octocat', repo: 'hello', number: 1 }; +const PR_B = { owner: 'octocat', repo: 'hello', number: 2 }; + +describe('diff-selection-bridge', () => { + beforeEach(() => { + clearDiffSelection(); + }); + + it('returns the selection only to the PR that produced it', () => { + setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' }); + + expect(getDiffSelection(PR_A)?.path).toBe('a.ts'); + expect(getDiffSelection(PR_B)).toBeNull(); + }); + + it('matches PR identity case-insensitively on owner/repo', () => { + setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' }); + + expect(getDiffSelection({ owner: 'OCTOCAT', repo: 'Hello', number: 1 })).not.toBeNull(); + }); + + it('clears the selection so it never leaks into the next visit', () => { + setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' }); + clearDiffSelection(); + + expect(getDiffSelection(PR_A)).toBeNull(); + }); +}); + +describe('file-navigator-bridge', () => { + it('delivers a scroll request only to listeners of the same PR', () => { + const listenerA = vi.fn(); + const listenerB = vi.fn(); + const unsubA = subscribeFileNavigatorRequest(PR_A, listenerA); + const unsubB = subscribeFileNavigatorRequest(PR_B, listenerB); + + requestScrollToFile({ ...PR_A, path: 'a.ts' }); + + expect(listenerA).toHaveBeenCalledWith({ ...PR_A, path: 'a.ts' }); + expect(listenerB).not.toHaveBeenCalled(); + + unsubA(); + unsubB(); + }); + + it('stops delivering after unsubscribe', () => { + const listener = vi.fn(); + const unsub = subscribeFileNavigatorRequest(PR_A, listener); + unsub(); + + requestScrollToFile({ ...PR_A, path: 'a.ts' }); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/recent-prs.test.ts b/apps/mobile/src/lib/pr-review/recent-prs.test.ts new file mode 100644 index 0000000000..bd02e1f256 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/recent-prs.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearRecentPrs, getRecentPrs, type RecentPr, upsertRecentPr } from './recent-prs'; + +const store = new Map(); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + return store.get(key) ?? null; + }), + setItemAsync: vi.fn(async (key: string, value: string) => { + await Promise.resolve(); + store.set(key, value); + }), + deleteItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + store.delete(key); + }), +})); + +vi.mock('@/lib/storage-keys', () => ({ + PR_REVIEW_RECENTS_KEY: 'pr-review-recents', +})); + +beforeEach(() => { + // Clear the disk mock between tests. The module's write-queue is a + // module-level variable that survives across tests; since every test + // awaits its own upsert/clear chain before returning, the queue is + // always drained before the next test starts. + store.clear(); +}); + +function makeRecent(overrides: Partial = {}): RecentPr { + return { + owner: 'octocat', + repo: 'hello-world', + number: 42, + title: 'Hello PR', + lastOpenedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe('recent-prs', () => { + it('returns an empty list when nothing has been stored', async () => { + await expect(getRecentPrs()).resolves.toEqual([]); + }); + + it('upsert puts a new entry at the front of the list', async () => { + await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 1, title: 'One' })); + await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 2, title: 'Two' })); + + await expect(getRecentPrs()).resolves.toMatchObject([ + { number: 2, title: 'Two' }, + { number: 1, title: 'One' }, + ]); + }); + + it('upsert moves an existing entry to the front and updates its title', async () => { + await upsertRecentPr(makeRecent({ number: 1, title: 'Old title' })); + await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 2, title: 'Two' })); + await upsertRecentPr(makeRecent({ number: 1, title: 'New title' })); + + await expect(getRecentPrs()).resolves.toMatchObject([ + { number: 1, title: 'New title' }, + { number: 2, title: 'Two' }, + ]); + }); + + it('caps the list at 10 entries, keeping the most recent first', async () => { + // Sequentially upsert so each write sees the previous write's result + // — the write-queue relies on this ordering. Promise.all would let + // the in-flight reads race. + const writes: Promise[] = []; + for (let index = 0; index < 10; index += 1) { + writes.push(upsertRecentPr(makeRecent({ number: index + 1, title: `T${index + 1}` }))); + } + await Promise.all(writes); + await upsertRecentPr(makeRecent({ number: 99, title: 'Newest' })); + + const recents = await getRecentPrs(); + expect(recents).toHaveLength(10); + expect(recents[0]).toMatchObject({ number: 99, title: 'Newest' }); + // Most-recent-first: after the cap, the oldest original (T1) drops off + // the tail, T10 sits at 1, and T2..T10 are all still in the list. + expect(recents[1]).toMatchObject({ number: 10, title: 'T10' }); + expect(recents.at(-1)).toMatchObject({ number: 2, title: 'T2' }); + expect(recents).not.toContainEqual(expect.objectContaining({ number: 1, title: 'T1' })); + }); + + it('treats corrupt stored data as an empty list', async () => { + store.set('pr-review-recents', '{not json'); + await expect(getRecentPrs()).resolves.toEqual([]); + }); + + it('treats malformed entries (missing fields) as not present', async () => { + // Mixed bag: one valid entry + an entry missing required fields + a + // string + null. Only the valid one should survive the parse filter. + store.set( + 'pr-review-recents', + JSON.stringify([ + { + owner: 'octocat', + repo: 'hello', + number: 1, + title: 'Good', + lastOpenedAt: 1_700_000_000_000, + }, + { owner: 'octocat', repo: 'hello' }, + 'not-an-object', + null, + ]) + ); + await expect(getRecentPrs()).resolves.toMatchObject([{ number: 1, title: 'Good' }]); + }); + + it('clearRecentPrs deletes the storage key', async () => { + store.set('pr-review-recents', 'placeholder'); + await clearRecentPrs(); + expect(store.has('pr-review-recents')).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/recent-prs.ts b/apps/mobile/src/lib/pr-review/recent-prs.ts new file mode 100644 index 0000000000..ff3c0b0484 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/recent-prs.ts @@ -0,0 +1,101 @@ +import * as SecureStore from 'expo-secure-store'; + +import { PR_REVIEW_RECENTS_KEY } from '@/lib/storage-keys'; + +export type RecentPr = { + owner: string; + repo: string; + number: number; + title: string; + lastOpenedAt: number; +}; + +const RECENT_PR_LIMIT = 10; + +// Same serialized write-queue pattern as last-active-instance: a sign-out +// clear must never be overtaken by an in-flight upsert, which would leak the +// previous account's recents into the next cold start. +let writeQueue: Promise | null = null; + +async function enqueueWrite(op: () => Promise): Promise { + const previous = writeQueue; + const next = (async () => { + if (previous) { + try { + await previous; + } catch { + // An earlier failed write must not block the queue. + } + } + await op(); + })(); + writeQueue = next; + await next; +} + +function recentPrKey(item: RecentPr): string { + return `${item.owner.toLowerCase()}/${item.repo.toLowerCase()}#${item.number}`; +} + +function parseRecents(raw: string | null): RecentPr[] { + if (raw == null || raw.length === 0) { + return []; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.flatMap((entry): RecentPr[] => { + if ( + entry && + typeof entry === 'object' && + typeof (entry as Record).owner === 'string' && + typeof (entry as Record).repo === 'string' && + typeof (entry as Record).number === 'number' && + typeof (entry as Record).title === 'string' && + typeof (entry as Record).lastOpenedAt === 'number' + ) { + return [entry as RecentPr]; + } + return []; + }); + } catch { + return []; + } +} + +function toJsonString(recents: RecentPr[]): string { + // Stable shape — ensure order survives a round trip without any implicit + // normalization that could drop the newest entry on a partial write. + return JSON.stringify(recents); +} + +export async function getRecentPrs(): Promise { + const raw = await SecureStore.getItemAsync(PR_REVIEW_RECENTS_KEY); + return parseRecents(raw); +} + +/** + * Inserts/updates a recent PR entry, moves it to the front, and trims the + * list to the most recent RECENT_PR_LIMIT. The title is taken from the + * caller (which may be the user-typed URL before the PR loads, or a + * later load-time fetch that backfills the title) — the function never + * reads or overwrites the title from disk on its own. + */ +export async function upsertRecentPr(entry: RecentPr): Promise { + await enqueueWrite(async () => { + const existingRaw = await SecureStore.getItemAsync(PR_REVIEW_RECENTS_KEY); + const existing = parseRecents(existingRaw); + const incomingKey = recentPrKey(entry); + const filtered = existing.filter(item => recentPrKey(item) !== incomingKey); + const next = [entry, ...filtered].slice(0, RECENT_PR_LIMIT); + await SecureStore.setItemAsync(PR_REVIEW_RECENTS_KEY, toJsonString(next)); + }); +} + +export async function clearRecentPrs(): Promise { + await enqueueWrite(async () => { + await SecureStore.deleteItemAsync(PR_REVIEW_RECENTS_KEY); + }); +} diff --git a/apps/mobile/src/lib/pr-review/viewed-files.test.ts b/apps/mobile/src/lib/pr-review/viewed-files.test.ts new file mode 100644 index 0000000000..0c526d38ef --- /dev/null +++ b/apps/mobile/src/lib/pr-review/viewed-files.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clearViewedFiles, getViewedFiles, toggleViewedFile } from './viewed-files'; + +const store = new Map(); + +vi.mock('expo-secure-store', () => ({ + getItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + return store.get(key) ?? null; + }), + setItemAsync: vi.fn(async (key: string, value: string) => { + await Promise.resolve(); + store.set(key, value); + }), + deleteItemAsync: vi.fn(async (key: string) => { + await Promise.resolve(); + store.delete(key); + }), +})); + +vi.mock('@/lib/storage-keys', () => ({ + PR_REVIEW_VIEWED_KEY: 'pr-review-viewed', +})); + +beforeEach(() => { + store.clear(); +}); + +const OWNER = 'octocat'; +const REPO = 'hello-world'; +const NUMBER = 42; +const SHA1 = 'aaaaaaaaaaaaaaaa'; +const SHA2 = 'bbbbbbbbbbbbbbbb'; +const REF = { owner: OWNER, repo: REPO, number: NUMBER }; + +function setStored(map: object) { + store.set('pr-review-viewed', JSON.stringify(map)); +} + +describe('viewed-files', () => { + it('returns an empty list when nothing has been stored', async () => { + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]); + }); + + it('returns the stored viewed paths when headSha matches', async () => { + setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts', 'b.ts'] } }); + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['a.ts', 'b.ts']); + }); + + it('returns an empty list when headSha has changed (no leakage from previous SHA)', async () => { + setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts'] } }); + await expect(getViewedFiles(REF, SHA2)).resolves.toEqual([]); + }); + + it('toggle adds a path when none was set for the current headSha', async () => { + setStored({}); + await toggleViewedFile({ ...REF, headSha: SHA1, path: 'src/index.ts' }); + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['src/index.ts']); + }); + + it('toggle removes a path when it is already viewed', async () => { + setStored({ + 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['src/index.ts', 'src/util.ts'] }, + }); + await toggleViewedFile({ ...REF, headSha: SHA1, path: 'src/index.ts' }); + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['src/util.ts']); + }); + + it('toggle resets viewedPaths when headSha changes', async () => { + setStored({ + 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['old.ts', 'stale.ts'] }, + }); + await toggleViewedFile({ ...REF, headSha: SHA2, path: 'new.ts' }); + await expect(getViewedFiles(REF, SHA2)).resolves.toEqual(['new.ts']); + // And the old SHA no longer leaks its viewed set. + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]); + }); + + it('caps the map at 20 PRs, evicting the least-recently-touched', async () => { + const seed: Record = {}; + for (let index = 0; index < 20; index += 1) { + seed[`octocat/repo-${index}#1`] = { headSha: SHA1, viewedPaths: [] }; + } + setStored(seed); + await toggleViewedFile({ + owner: 'octocat', + repo: 'brand-new-repo', + number: 1, + headSha: SHA1, + path: 'a.ts', + }); + + // The newest entry exists with its single viewed file. + await expect( + getViewedFiles({ owner: 'octocat', repo: 'brand-new-repo', number: 1 }, SHA1) + ).resolves.toEqual(['a.ts']); + // The oldest (repo-0) was evicted; reading it yields an empty list. + await expect( + getViewedFiles({ owner: 'octocat', repo: 'repo-0', number: 1 }, SHA1) + ).resolves.toEqual([]); + // The most-recently-touched original (repo-19) is still present. + await expect( + getViewedFiles({ owner: 'octocat', repo: 'repo-19', number: 1 }, SHA1) + ).resolves.toEqual([]); + }); + + it('treats corrupt stored data as an empty map', async () => { + store.set('pr-review-viewed', '{not json'); + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]); + }); + + it('drops structurally invalid entries (non-string headSha / non-array viewedPaths)', async () => { + setStored({ + 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['keep.ts'] }, + 'octocat/bad-sha#1': { headSha: 123, viewedPaths: ['x'] }, + 'octocat/bad-paths#2': { headSha: SHA1, viewedPaths: null }, + 'octocat/bad-nested#3': { headSha: SHA1, viewedPaths: ['ok', 5] }, + }); + // The valid entry is preserved. + await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['keep.ts']); + // The malformed entries are discarded, not returned as non-arrays. + await expect( + getViewedFiles({ owner: 'octocat', repo: 'bad-paths', number: 2 }, SHA1) + ).resolves.toEqual([]); + // And toggling on top of malformed data does not throw. + await expect( + toggleViewedFile({ + owner: 'octocat', + repo: 'bad-nested', + number: 3, + headSha: SHA1, + path: 'z.ts', + }) + ).resolves.toBeUndefined(); + }); + + it('clearViewedFiles deletes the storage key', async () => { + store.set('pr-review-viewed', 'placeholder'); + await clearViewedFiles(); + expect(store.has('pr-review-viewed')).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/viewed-files.ts b/apps/mobile/src/lib/pr-review/viewed-files.ts new file mode 100644 index 0000000000..7b93ff7364 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/viewed-files.ts @@ -0,0 +1,158 @@ +import * as SecureStore from 'expo-secure-store'; + +import { PR_REVIEW_VIEWED_KEY } from '@/lib/storage-keys'; + +type ViewedFileEntry = { + headSha: string; + viewedPaths: string[]; +}; + +type ViewedFileMap = Record; + +const VIEWED_FILES_PR_LIMIT = 20; + +export type ViewedFilePrRef = { + owner: string; + repo: string; + number: number; +}; + +// Same serialized write-queue pattern as last-active-instance and recent-prs. +let writeQueue: Promise | null = null; + +async function enqueueWrite(op: () => Promise): Promise { + const previous = writeQueue; + const next = (async () => { + if (previous) { + try { + await previous; + } catch { + // An earlier failed write must not block the queue. + } + } + await op(); + })(); + writeQueue = next; + await next; +} + +export function viewedFilesKey(ref: ViewedFilePrRef): string { + return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`; +} + +function isValidEntry(value: unknown): value is ViewedFileEntry { + if (!value || typeof value !== 'object') { + return false; + } + const entry = value as Record; + return ( + typeof entry.headSha === 'string' && + Array.isArray(entry.viewedPaths) && + entry.viewedPaths.every(path => typeof path === 'string') + ); +} + +function parseMap(raw: string | null): ViewedFileMap { + if (raw == null || raw.length === 0) { + return {}; + } + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + // Drop any structurally invalid entry rather than trusting the cast, so + // one corrupt record can't make getViewedFiles return a non-array or + // make toggleViewedFile throw on `.includes`. + const result: ViewedFileMap = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (isValidEntry(value)) { + result[key] = { headSha: value.headSha, viewedPaths: value.viewedPaths }; + } + } + return result; + } catch { + return {}; + } +} + +function toJsonString(map: ViewedFileMap): string { + return JSON.stringify(map); +} + +function computeNextViewedPaths( + existing: ViewedFileEntry | undefined, + headSha: string, + path: string +): string[] { + if (!existing || existing.headSha !== headSha) { + // Fresh PR or SHA changed: start a clean set with this one path. + return [path]; + } + if (existing.viewedPaths.includes(path)) { + return existing.viewedPaths.filter(p => p !== path); + } + return [...existing.viewedPaths, path]; +} + +async function readMap(): Promise { + const raw = await SecureStore.getItemAsync(PR_REVIEW_VIEWED_KEY); + return parseMap(raw); +} + +export async function getViewedFiles(ref: ViewedFilePrRef, headSha: string): Promise { + const map = await readMap(); + const entry = map[viewedFilesKey(ref)]; + if (!entry || entry.headSha !== headSha) { + return []; + } + return entry.viewedPaths; +} + +/** + * Toggle a single file path in the viewed set for a PR. The record is keyed + * by `owner/repo#number`; when the incoming `headSha` differs from the + * stored one the viewedPaths are reset (file paths from a previous SHA are + * almost certainly stale and shouldn't be re-marked). The map itself is + * LRU-trimmed to VIEWED_FILES_PR_LIMIT PRs by most-recently-touched. + */ +export type ToggleViewedFileInput = ViewedFilePrRef & { + headSha: string; + path: string; +}; + +export async function toggleViewedFile(input: ToggleViewedFileInput): Promise { + const { headSha, path } = input; + await enqueueWrite(async () => { + const map = await readMap(); + const key = viewedFilesKey(input); + const existing = map[key]; + + const nextViewedPaths = computeNextViewedPaths(existing, headSha, path); + + const nextEntry: ViewedFileEntry = { headSha, viewedPaths: nextViewedPaths }; + + // Re-insert the touched PR at the end of insertion order so we can + // trim oldest-first by Object.keys order. + const reordered: ViewedFileMap = {}; + for (const [existingKey, value] of Object.entries(map)) { + if (existingKey !== key) { + reordered[existingKey] = value; + } + } + reordered[key] = nextEntry; + + const trimmedEntries = Object.entries(reordered).slice(-VIEWED_FILES_PR_LIMIT); + const trimmed: ViewedFileMap = {}; + for (const [trimmedKey, value] of trimmedEntries) { + trimmed[trimmedKey] = value; + } + await SecureStore.setItemAsync(PR_REVIEW_VIEWED_KEY, toJsonString(trimmed)); + }); +} + +export async function clearViewedFiles(): Promise { + await enqueueWrite(async () => { + await SecureStore.deleteItemAsync(PR_REVIEW_VIEWED_KEY); + }); +} diff --git a/apps/mobile/src/lib/profile-agent-navigation.ts b/apps/mobile/src/lib/profile-agent-navigation.ts index 4508124320..115a75857e 100644 --- a/apps/mobile/src/lib/profile-agent-navigation.ts +++ b/apps/mobile/src/lib/profile-agent-navigation.ts @@ -22,3 +22,11 @@ export function getProfileAgentScope( export function getCodeReviewerProfilePath(scope: string): Href { return `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}` as Href; } + +export function getPrReviewEntryPath(): Href { + return '/(app)/pr-review' as Href; +} + +export function getPrReviewPath(owner: string, repo: string, number: number): Href { + return `/(app)/pr-review/${owner}/${repo}/${number}` as Href; +} diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts index c33ee361fa..8e354563e8 100644 --- a/apps/mobile/src/lib/storage-keys.ts +++ b/apps/mobile/src/lib/storage-keys.ts @@ -15,3 +15,5 @@ export const CONSENT_USER_KEY_PREFIX = 'consent-accepted-'; export const AGENT_MODEL_PREFERENCE_KEY = 'agent-model-preference'; export const REASONING_DEFAULT_EXPANDED_KEY = 'agent-reasoning-default-expanded'; export const REVIEW_REQUESTED_AT_KEY = 'store-review-requested-at'; +export const PR_REVIEW_RECENTS_KEY = 'pr-review-recents'; +export const PR_REVIEW_VIEWED_KEY = 'pr-review-viewed'; diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index eebcd19a60..b30a44500b 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ 'src/lib/kilo-pass/**/*.test.ts', 'src/lib/kilo-pass/**/*.test.tsx', 'src/lib/onboarding/**/*.test.ts', + 'src/lib/pr-review/**/*.test.ts', 'src/lib/voice-input/**/*.test.ts', 'src/components/**/*.test.ts', ], From 0a85227d33f7357164c59712babfc96ae58d46ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 01:56:24 +0200 Subject: [PATCH 04/19] feat(web): github pr review read procedures --- apps/web/src/lib/github-pr-review/client.ts | 20 + apps/web/src/lib/github-pr-review/dtos.ts | 164 ++++++ .../src/lib/github-pr-review/errors.test.ts | 192 ++++++++ apps/web/src/lib/github-pr-review/errors.ts | 168 +++++++ .../src/lib/github-pr-review/mappers.test.ts | 363 ++++++++++++++ apps/web/src/lib/github-pr-review/mappers.ts | 338 +++++++++++++ .../src/lib/github-pr-review/retry.test.ts | 119 +++++ apps/web/src/lib/github-pr-review/retry.ts | 128 +++++ .../review-thread-comments.test.ts | 81 +++ .../github/user-token-client.test.ts | 453 +++++++++++++++++ .../platforms/github/user-token-client.ts | 249 ++++++++++ .../src/routers/github-pr-review-router.ts | 465 ++++++++++++++++++ apps/web/src/routers/root-router.ts | 2 + 13 files changed, 2742 insertions(+) create mode 100644 apps/web/src/lib/github-pr-review/client.ts create mode 100644 apps/web/src/lib/github-pr-review/dtos.ts create mode 100644 apps/web/src/lib/github-pr-review/errors.test.ts create mode 100644 apps/web/src/lib/github-pr-review/errors.ts create mode 100644 apps/web/src/lib/github-pr-review/mappers.test.ts create mode 100644 apps/web/src/lib/github-pr-review/mappers.ts create mode 100644 apps/web/src/lib/github-pr-review/retry.test.ts create mode 100644 apps/web/src/lib/github-pr-review/retry.ts create mode 100644 apps/web/src/lib/github-pr-review/review-thread-comments.test.ts create mode 100644 apps/web/src/lib/integrations/platforms/github/user-token-client.test.ts create mode 100644 apps/web/src/lib/integrations/platforms/github/user-token-client.ts create mode 100644 apps/web/src/routers/github-pr-review-router.ts diff --git a/apps/web/src/lib/github-pr-review/client.ts b/apps/web/src/lib/github-pr-review/client.ts new file mode 100644 index 0000000000..aa530fe20c --- /dev/null +++ b/apps/web/src/lib/github-pr-review/client.ts @@ -0,0 +1,20 @@ +import 'server-only'; + +import { Octokit } from '@octokit/rest'; + +/** + * Env seam used to point the GitHub API client at a deterministic test + * fixture (e.g. a local mock server in E2E). When unset, calls hit the real + * `api.github.com`. GraphQL requests are issued with `octokit.request('POST + * /graphql', …)`, which uses Octokit's `baseUrl` to derive the GraphQL URL — + * so the same env value covers REST and GraphQL. + */ +export const GITHUB_API_BASE_URL = process.env.GITHUB_API_BASE_URL ?? 'https://api.github.com'; + +export function createGitHubPrReviewOctokit(token: string): Octokit { + return new Octokit({ + auth: token, + baseUrl: GITHUB_API_BASE_URL, + userAgent: 'kilo-mobile-github-pr-review', + }); +} diff --git a/apps/web/src/lib/github-pr-review/dtos.ts b/apps/web/src/lib/github-pr-review/dtos.ts new file mode 100644 index 0000000000..40d13b1618 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/dtos.ts @@ -0,0 +1,164 @@ +import 'server-only'; + +import { z } from 'zod'; + +export const GitHubPrReviewAuthorSchema = z + .object({ + login: z.string().min(1), + avatarUrl: z.string().url().nullable(), + }) + .strict(); + +export const GitHubPrReviewOverviewSchema = z + .object({ + number: z.number().int().positive(), + title: z.string(), + bodyMarkdown: z.string().nullable(), + author: GitHubPrReviewAuthorSchema.nullable(), + state: z.enum(['open', 'closed', 'merged']), + draft: z.boolean(), + baseRef: z.string(), + headRef: z.string(), + isCrossRepo: z.boolean(), + headRepoFullName: z.string().nullable(), + headSha: z.string().min(1), + prNodeId: z.string().min(1), + counts: z + .object({ + commits: z.number().int().nonnegative(), + changedFiles: z.number().int().nonnegative(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + }) + .strict(), + mergeable: z.boolean().nullable(), + mergeableState: z.string().nullable(), + autoMerge: z + .object({ + method: z.string(), + }) + .nullable(), + reviewDecision: z.enum(['REVIEW_REQUIRED', 'APPROVED', 'CHANGES_REQUESTED']).nullable(), + repo: z + .object({ + allowMergeCommit: z.boolean(), + allowSquashMerge: z.boolean(), + allowRebaseMerge: z.boolean(), + allowAutoMerge: z.boolean(), + deleteBranchOnMerge: z.boolean(), + allowUpdateBranch: z.boolean(), + viewerCanPush: z.boolean(), + viewerCanAdmin: z.boolean(), + viewerLogin: z.string().nullable(), + }) + .strict(), + }) + .strict(); +export type GitHubPrReviewOverview = z.infer; + +export const GitHubPrReviewCheckRunSchema = z + .object({ + name: z.string().min(1), + status: z.string(), + conclusion: z.string().nullable(), + detailsUrl: z.string().url().nullable(), + appName: z.string().nullable(), + }) + .strict(); + +export const GitHubPrReviewChecksResultSchema = z + .object({ + checkRuns: z.array(GitHubPrReviewCheckRunSchema), + rollup: z + .object({ + total: z.number().int().nonnegative(), + success: z.number().int().nonnegative(), + failure: z.number().int().nonnegative(), + pending: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + }) + .strict(), + }) + .strict(); +export type GitHubPrReviewChecksResult = z.infer; + +export const GitHubPrReviewFileSchema = z + .object({ + path: z.string().min(1), + previousPath: z.string().nullable(), + status: z.string(), + additions: z.number().int().nonnegative(), + deletions: z.number().int().nonnegative(), + patch: z.string().nullable(), + patchMissing: z.boolean(), + }) + .strict(); +export type GitHubPrReviewFile = z.infer; + +export const GitHubPrReviewFilesResultSchema = z + .object({ + files: z.array(GitHubPrReviewFileSchema), + nextCursor: z.number().int().nullable(), + }) + .strict(); +export type GitHubPrReviewFilesResult = z.infer; + +export const GitHubPrReviewFileLinesResultSchema = z + .object({ + lines: z.array(z.string()), + totalLines: z.number().int().nonnegative(), + }) + .strict(); +export type GitHubPrReviewFileLinesResult = z.infer; + +export const GitHubPrReviewReactionSchema = z + .object({ + content: z.string(), + count: z.number().int().nonnegative(), + viewerHasReacted: z.boolean(), + }) + .strict(); + +export const GitHubPrReviewReviewCommentSchema = z + .object({ + commentId: z.number().int().positive(), + nodeId: z.string().min(1), + author: GitHubPrReviewAuthorSchema.nullable(), + bodyMarkdown: z.string(), + createdAt: z.string(), + reactions: z.array(GitHubPrReviewReactionSchema), + }) + .strict(); + +export const GitHubPrReviewReviewThreadSchema = z + .object({ + threadId: z.string().min(1), + isResolved: z.boolean(), + isOutdated: z.boolean(), + subjectType: z.enum(['LINE', 'FILE']), + path: z.string().nullable(), + line: z.number().int().nullable(), + startLine: z.number().int().nullable(), + originalLine: z.number().int().nullable(), + originalStartLine: z.number().int().nullable(), + diffSide: z.enum(['LEFT', 'RIGHT']).nullable(), + comments: z.array(GitHubPrReviewReviewCommentSchema), + }) + .strict(); +export type GitHubPrReviewReviewThread = z.infer; + +export const GitHubPrReviewReviewThreadsResultSchema = z + .object({ + threads: z.array(GitHubPrReviewReviewThreadSchema), + nextCursor: z.string().nullable(), + }) + .strict(); +export type GitHubPrReviewReviewThreadsResult = z.infer< + typeof GitHubPrReviewReviewThreadsResultSchema +>; + +export const FILES_PAGE_SIZE = 50; +export const FILES_MAX_PAGES = 60; +export const FILE_LINES_MAX = 500; +export const REVIEW_THREADS_PAGE_SIZE = 50; +export const REVIEW_THREAD_COMMENTS_PAGE_SIZE = 50; diff --git a/apps/web/src/lib/github-pr-review/errors.test.ts b/apps/web/src/lib/github-pr-review/errors.test.ts new file mode 100644 index 0000000000..42b935287d --- /dev/null +++ b/apps/web/src/lib/github-pr-review/errors.test.ts @@ -0,0 +1,192 @@ +import { classifyGitHubHttpError, classifyGitHubGraphQlErrors } from './errors'; + +function httpError( + status: number, + message: string, + headers?: Record +): Error & { + status: number; + response?: { headers?: Record }; +} { + const err = new Error(message) as Error & { + status: number; + response?: { headers?: Record }; + }; + err.status = status; + if (headers) { + err.response = { headers }; + } + return err; +} + +describe('classifyGitHubHttpError', () => { + it('maps 404 to NOT_FOUND with the documented message', () => { + const result = classifyGitHubHttpError(httpError(404, 'Not Found')); + expect(result.code).toBe('NOT_FOUND'); + expect(result.message).toBe( + "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository" + ); + }); + + it('maps 401 to PRECONDITION_FAILED with the reconnect message', () => { + const result = classifyGitHubHttpError(httpError(401, 'Bad credentials')); + expect(result.code).toBe('PRECONDITION_FAILED'); + expect(result.message).toBe('GitHub connection is no longer valid — reconnect'); + }); + + const NOW = 1_700_000_000_000; + + it('maps 403 with x-ratelimit-remaining:0 to TOO_MANY_REQUESTS with an absolute reset epoch', () => { + const resetSeconds = Math.floor(NOW / 1000) + 600; + const result = classifyGitHubHttpError( + httpError(403, 'API rate limit exceeded', { + 'x-ratelimit-remaining': '0', + 'x-ratelimit-reset': String(resetSeconds), + }), + NOW + ); + expect(result.code).toBe('TOO_MANY_REQUESTS'); + // Absolute epoch (ms), not a relative delay. + expect(result.retryAfterEpochMs).toBe(resetSeconds * 1000); + expect(result.message).toMatch(/Try again in about 10 minutes/); + }); + + it('maps 403 retry-after (delta seconds) to an absolute epoch relative to now', () => { + const result = classifyGitHubHttpError( + httpError(403, 'Secondary rate limit', { 'retry-after': '120' }), + NOW + ); + expect(result.code).toBe('TOO_MANY_REQUESTS'); + expect(result.retryAfterEpochMs).toBe(NOW + 120_000); + }); + + it('maps 429 to TOO_MANY_REQUESTS regardless of headers', () => { + const result = classifyGitHubHttpError(httpError(429, 'Too Many Requests')); + expect(result.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('maps non-rate-limit 403 to FORBIDDEN preserving GitHub message', () => { + const result = classifyGitHubHttpError( + httpError(403, 'Resource not accessible by integration') + ); + expect(result.code).toBe('FORBIDDEN'); + expect(result.message).toBe('Resource not accessible by integration'); + }); + + it('maps 422 stale line comment shape to BAD_REQUEST', () => { + const result = classifyGitHubHttpError( + httpError(422, 'Validation Failed: "path" wasn\'t supplied') + ); + expect(result.code).toBe('BAD_REQUEST'); + expect(result.message).toContain("wasn't supplied"); + }); + + it('maps 422 approve-own-PR shape to BAD_REQUEST', () => { + const result = classifyGitHubHttpError( + httpError(422, 'Validation Failed: You cannot approve your own pull request') + ); + expect(result.code).toBe('BAD_REQUEST'); + expect(result.message).toContain('approve your own'); + }); + + it('maps 422 review-submit shape to BAD_REQUEST', () => { + const result = classifyGitHubHttpError( + httpError(422, 'Validation Failed: Pull request review thread lock failed') + ); + expect(result.code).toBe('BAD_REQUEST'); + }); + + it('maps 422 update-branch expected_head_sha mismatch to BAD_REQUEST', () => { + const result = classifyGitHubHttpError( + httpError(422, "expected head sha didn't match current head ref") + ); + expect(result.code).toBe('BAD_REQUEST'); + expect(result.message).toContain('expected head sha'); + }); + + it('maps 422 merge validation to BAD_REQUEST', () => { + const result = classifyGitHubHttpError( + httpError(422, 'Merge commits are not allowed on this repository') + ); + expect(result.code).toBe('BAD_REQUEST'); + expect(result.message).toContain('Merge commits'); + }); + + it('maps 405 to CONFLICT with GitHub message', () => { + const result = classifyGitHubHttpError( + httpError(405, '405 Method Not Allowed: Merge method not allowed') + ); + expect(result.code).toBe('CONFLICT'); + expect(result.message).toContain('Merge method not allowed'); + }); + + it('maps 409 to CONFLICT with GitHub message', () => { + const result = classifyGitHubHttpError( + httpError(409, 'Merge conflict: HEAD is not a fast-forward') + ); + expect(result.code).toBe('CONFLICT'); + expect(result.message).toContain('Merge conflict'); + }); + + it('maps 5xx to BAD_GATEWAY with a human message', () => { + const result = classifyGitHubHttpError(httpError(502, 'Bad Gateway')); + expect(result.code).toBe('BAD_GATEWAY'); + }); + + it('falls back to BAD_GATEWAY for non-Error inputs', () => { + expect(classifyGitHubHttpError('oops').code).toBe('BAD_GATEWAY'); + expect(classifyGitHubHttpError(null).code).toBe('BAD_GATEWAY'); + expect(classifyGitHubHttpError(undefined).code).toBe('BAD_GATEWAY'); + }); +}); + +describe('classifyGitHubGraphQlErrors', () => { + it('returns null when there are no errors', () => { + expect(classifyGitHubGraphQlErrors(undefined)).toBeNull(); + expect(classifyGitHubGraphQlErrors([])).toBeNull(); + }); + + it('maps GraphQL NOT_FOUND to NOT_FOUND', () => { + const result = classifyGitHubGraphQlErrors([ + { type: 'NOT_FOUND', message: 'Could not resolve' }, + ]); + expect(result?.code).toBe('NOT_FOUND'); + expect(result?.message).toBe( + "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository" + ); + }); + + it('maps GraphQL FORBIDDEN to FORBIDDEN preserving message', () => { + const result = classifyGitHubGraphQlErrors([ + { type: 'FORBIDDEN', message: 'Resource not accessible by integration' }, + ]); + expect(result?.code).toBe('FORBIDDEN'); + expect(result?.message).toBe('Resource not accessible by integration'); + }); + + it('maps GraphQL RATE_LIMITED to TOO_MANY_REQUESTS', () => { + const result = classifyGitHubGraphQlErrors([{ type: 'RATE_LIMITED', message: 'rate limit' }]); + expect(result?.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('maps GraphQL SECONDARY_RATE_LIMIT to TOO_MANY_REQUESTS', () => { + const result = classifyGitHubGraphQlErrors([ + { type: 'SECONDARY_RATE_LIMIT', message: 'abuse' }, + ]); + expect(result?.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('falls back to BAD_GATEWAY for unknown GraphQL error types', () => { + const result = classifyGitHubGraphQlErrors([{ type: 'INTERNAL', message: 'boom' }]); + expect(result?.code).toBe('BAD_GATEWAY'); + expect(result?.message).toBe('boom'); + }); + + it('only considers the first error in the array', () => { + const result = classifyGitHubGraphQlErrors([ + { type: 'NOT_FOUND', message: 'first' }, + { type: 'FORBIDDEN', message: 'second' }, + ]); + expect(result?.code).toBe('NOT_FOUND'); + }); +}); diff --git a/apps/web/src/lib/github-pr-review/errors.ts b/apps/web/src/lib/github-pr-review/errors.ts new file mode 100644 index 0000000000..5fd49f8cf6 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/errors.ts @@ -0,0 +1,168 @@ +import 'server-only'; + +export type GitHubPrReviewErrorCode = + | 'NOT_FOUND' + | 'PRECONDITION_FAILED' + | 'TOO_MANY_REQUESTS' + | 'FORBIDDEN' + | 'BAD_REQUEST' + | 'CONFLICT' + | 'BAD_GATEWAY'; + +export type ClassifiedGitHubError = { + code: GitHubPrReviewErrorCode; + message: string; + retryAfterEpochMs?: number; +}; + +type HeaderRecord = Record | undefined; + +function getHeader(headers: HeaderRecord, name: string): string | undefined { + if (!headers) return undefined; + const direct = headers[name]; + if (typeof direct === 'string') return direct; + if (Array.isArray(direct)) return direct[0]; + const lower = headers[name.toLowerCase()]; + if (typeof lower === 'string') return lower; + if (Array.isArray(lower)) return lower[0]; + return undefined; +} + +function getErrorMessage(error: unknown): string { + if (typeof error !== 'object' || error === null) return ''; + const message = (error as { message?: unknown }).message; + return typeof message === 'string' ? message : ''; +} + +type OctokitHttpError = { + status: number; + message: string; + response?: { headers?: HeaderRecord; data?: unknown }; +}; + +function isOctokitHttpError(error: unknown): error is OctokitHttpError { + if (typeof error !== 'object' || error === null) return false; + const status = (error as { status?: unknown }).status; + return typeof status === 'number'; +} + +function isRateLimitHeaders(headers: HeaderRecord): boolean { + const remaining = getHeader(headers, 'x-ratelimit-remaining'); + if (remaining === '0') return true; + return Boolean(getHeader(headers, 'retry-after')); +} + +// Absolute epoch (ms) at which the caller may retry, derived purely from the +// headers plus the supplied `now` (so the classifier stays deterministic and +// testable). `x-ratelimit-reset` is an absolute epoch in seconds; `retry-after` +// is either a delta in seconds (relative to `now`) or an HTTP date. +function retryAfterEpochMs(headers: HeaderRecord, now: number): number | undefined { + const reset = getHeader(headers, 'x-ratelimit-reset'); + if (reset) { + const seconds = Number(reset); + if (Number.isFinite(seconds) && seconds > 0) { + return seconds * 1000; + } + } + const retryAfter = getHeader(headers, 'retry-after'); + if (retryAfter) { + const seconds = Number(retryAfter); + if (Number.isFinite(seconds) && seconds >= 0) { + return now + seconds * 1000; + } + const date = Date.parse(retryAfter); + if (Number.isFinite(date)) { + return date; + } + } + return undefined; +} + +function rateLimitMessage(headers: HeaderRecord, now: number): string { + const resetAt = retryAfterEpochMs(headers, now); + if (resetAt === undefined) { + return 'GitHub rate limit reached. Please try again later.'; + } + const minutes = Math.max(1, Math.round((resetAt - now) / 60_000)); + return `GitHub rate limit reached. Try again in about ${minutes} minute${minutes === 1 ? '' : 's'}.`; +} + +const NOT_FOUND_MESSAGE = + "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository"; +const PRECONDITION_FAILED_MESSAGE = 'GitHub connection is no longer valid — reconnect'; +const FALLBACK_FORBIDDEN = 'You do not have permission to perform this action on this PR'; +const FALLBACK_BAD_REQUEST = 'GitHub rejected this request'; +const FALLBACK_CONFLICT = 'GitHub reported a conflict for this PR'; +const FALLBACK_BAD_GATEWAY = 'GitHub returned an unexpected error'; + +export function classifyGitHubHttpError( + error: unknown, + now: number = Date.now() +): ClassifiedGitHubError { + if (!isOctokitHttpError(error)) { + return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY }; + } + const status = error.status; + const message = getErrorMessage(error) || FALLBACK_BAD_GATEWAY; + const headers = error.response?.headers; + + if (status === 404) { + return { code: 'NOT_FOUND', message: NOT_FOUND_MESSAGE }; + } + if (status === 401) { + return { code: 'PRECONDITION_FAILED', message: PRECONDITION_FAILED_MESSAGE }; + } + if (status === 422) { + return { code: 'BAD_REQUEST', message: message || FALLBACK_BAD_REQUEST }; + } + if (status === 405 || status === 409) { + return { code: 'CONFLICT', message: message || FALLBACK_CONFLICT }; + } + if (status === 403 || status === 429) { + if (status === 429 || (headers && isRateLimitHeaders(headers))) { + return { + code: 'TOO_MANY_REQUESTS', + message: rateLimitMessage(headers, now), + retryAfterEpochMs: retryAfterEpochMs(headers, now), + }; + } + return { code: 'FORBIDDEN', message: message || FALLBACK_FORBIDDEN }; + } + if (status >= 500) { + return { code: 'BAD_GATEWAY', message: message || FALLBACK_BAD_GATEWAY }; + } + if (status >= 400) { + return { code: 'BAD_REQUEST', message: message || FALLBACK_BAD_REQUEST }; + } + return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY }; +} + +export type GraphQlErrorEntry = { type?: string; message?: string }; + +function classifyGraphQlEntry(entry: GraphQlErrorEntry, now: number): ClassifiedGitHubError { + const type = (entry.type ?? '').toUpperCase(); + const message = entry.message?.trim(); + if (type === 'NOT_FOUND') { + return { code: 'NOT_FOUND', message: NOT_FOUND_MESSAGE }; + } + if (type === 'FORBIDDEN') { + return { code: 'FORBIDDEN', message: message || FALLBACK_FORBIDDEN }; + } + if (type === 'RATE_LIMITED' || type === 'SECONDARY_RATE_LIMIT' || type === 'ABUSE_DETECTION') { + return { code: 'TOO_MANY_REQUESTS', message: rateLimitMessage(undefined, now) }; + } + if (message) { + return { code: 'BAD_GATEWAY', message }; + } + return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY }; +} + +export function classifyGitHubGraphQlErrors( + errors: ReadonlyArray | undefined, + now: number = Date.now() +): ClassifiedGitHubError | null { + if (!errors || errors.length === 0) return null; + const first = errors[0]; + if (!first) return null; + return classifyGraphQlEntry(first, now); +} diff --git a/apps/web/src/lib/github-pr-review/mappers.test.ts b/apps/web/src/lib/github-pr-review/mappers.test.ts new file mode 100644 index 0000000000..b32b41235a --- /dev/null +++ b/apps/web/src/lib/github-pr-review/mappers.test.ts @@ -0,0 +1,363 @@ +import { + buildChecksResult, + buildFilesPage, + buildOverviewDto, + buildReviewThreadsResult, + sliceFileLines, +} from './mappers'; +import { FILES_MAX_PAGES } from './dtos'; + +describe('buildOverviewDto', () => { + const basePr = { + number: 12, + title: 'Fix the flux capacitor', + body: 'It was broken', + user: { login: 'octocat', avatar_url: 'https://avatars.example/octocat' }, + state: 'open' as const, + draft: false, + base: { ref: 'main', repo: { full_name: 'kilo/flux' } }, + head: { ref: 'feature/fix', sha: 'abc123', repo: { full_name: 'kilo/flux' } }, + node_id: 'PR_node', + commits: 3, + changed_files: 5, + additions: 100, + deletions: 20, + mergeable: true, + mergeable_state: 'clean', + auto_merge: { merge_method: 'squash' }, + }; + + it('returns overview DTO with all required fields populated', () => { + const dto = buildOverviewDto({ + pr: basePr, + repo: { + allow_merge_commit: true, + allow_squash_merge: true, + allow_rebase_merge: true, + allow_auto_merge: true, + delete_branch_on_merge: true, + allow_update_branch: true, + permissions: { push: true, admin: false }, + }, + graphQl: { + repository: { pullRequest: { reviewDecision: 'APPROVED' } }, + viewer: { login: 'octocat' }, + }, + viewer: { login: 'octocat' }, + }); + expect(dto.title).toBe('Fix the flux capacitor'); + expect(dto.state).toBe('open'); + expect(dto.draft).toBe(false); + expect(dto.reviewDecision).toBe('APPROVED'); + expect(dto.autoMerge).toEqual({ method: 'squash' }); + expect(dto.isCrossRepo).toBe(false); + expect(dto.headRepoFullName).toBe('kilo/flux'); + expect(dto.repo.viewerCanPush).toBe(true); + expect(dto.repo.viewerCanAdmin).toBe(false); + expect(dto.repo.viewerLogin).toBe('octocat'); + }); + + it('maps merged PR to "merged" state regardless of GitHub state', () => { + const dto = buildOverviewDto({ + pr: { ...basePr, merged: true, state: 'closed' }, + repo: {}, + graphQl: null, + viewer: null, + }); + expect(dto.state).toBe('merged'); + }); + + it('flags cross-repo PRs when head and base differ', () => { + const dto = buildOverviewDto({ + pr: { + ...basePr, + head: { ref: 'feature/fix', sha: 'abc123', repo: { full_name: 'octocat/flux' } }, + }, + repo: {}, + graphQl: null, + viewer: null, + }); + expect(dto.isCrossRepo).toBe(true); + expect(dto.headRepoFullName).toBe('octocat/flux'); + }); + + it('handles missing author and reviewDecision', () => { + const dto = buildOverviewDto({ + pr: { ...basePr, user: null }, + repo: {}, + graphQl: { repository: { pullRequest: null }, viewer: null }, + viewer: null, + }); + expect(dto.author).toBeNull(); + expect(dto.reviewDecision).toBeNull(); + expect(dto.repo.viewerLogin).toBeNull(); + }); +}); + +describe('buildChecksResult', () => { + it('merges check runs and dedupes commit statuses to the latest per context', () => { + const result = buildChecksResult({ + checkRuns: [ + { + name: 'ci', + status: 'completed', + conclusion: 'success', + details_url: 'https://example.com/a', + app: { name: 'GitHub Actions' }, + }, + ], + commitStatuses: [ + { + context: 'codecov', + state: 'success', + target_url: 'https://codecov.example/r1', + updated_at: '2026-01-01T00:00:00Z', + }, + { + context: 'codecov', + state: 'failure', + target_url: 'https://codecov.example/r2', + updated_at: '2026-01-02T00:00:00Z', + }, + { context: 'lint', state: 'success', target_url: null, updated_at: null }, + ], + }); + expect(result.checkRuns).toHaveLength(3); + const codecov = result.checkRuns.find(c => c.name === 'codecov'); + expect(codecov?.conclusion).toBe('failure'); + expect(codecov?.detailsUrl).toBe('https://codecov.example/r2'); + expect(result.rollup.total).toBe(3); + expect(result.rollup.success).toBe(2); + expect(result.rollup.failure).toBe(1); + }); + + it('counts pending commit statuses and in-progress/null check runs in the pending bucket', () => { + const result = buildChecksResult({ + checkRuns: [ + { name: 'build', status: 'in_progress', conclusion: null }, + { name: 'lint', status: 'completed', conclusion: null }, + { name: 'test', status: 'completed', conclusion: 'success' }, + ], + commitStatuses: [ + { context: 'deploy', state: 'pending', target_url: null, updated_at: null }, + { context: 'coverage', state: 'success', target_url: null, updated_at: null }, + ], + }); + // 3 check runs + 2 statuses = 5, and every one lands in exactly one bucket. + expect(result.rollup.total).toBe(5); + expect(result.rollup.success).toBe(2); // test + coverage + expect(result.rollup.failure).toBe(0); + expect(result.rollup.skipped).toBe(0); + // build(in_progress) + lint(completed/null) + deploy(pending) = 3 + expect(result.rollup.pending).toBe(3); + expect( + result.rollup.success + result.rollup.failure + result.rollup.pending + result.rollup.skipped + ).toBe(result.rollup.total); + }); +}); + +describe('buildFilesPage', () => { + const makeFile = (i: number) => ({ + filename: `src/file${i}.ts`, + status: 'modified', + additions: 1, + deletions: 0, + }); + + it('returns nextCursor null on a short page even when below the cap', () => { + const result = buildFilesPage({ page: 1, perPage: 50, rawFiles: [makeFile(0), makeFile(1)] }); + expect(result.files).toHaveLength(2); + expect(result.nextCursor).toBeNull(); + }); + + it('returns nextCursor on a full page below the cap', () => { + const raw = Array.from({ length: 50 }, (_, i) => makeFile(i)); + const result = buildFilesPage({ page: 1, perPage: 50, rawFiles: raw }); + expect(result.nextCursor).toBe(2); + }); + + it('clamps to FILES_MAX_PAGES and returns null nextCursor at the cap', () => { + const raw = Array.from({ length: 50 }, (_, i) => makeFile(i)); + const result = buildFilesPage({ page: FILES_MAX_PAGES, perPage: 50, rawFiles: raw }); + expect(result.nextCursor).toBeNull(); + }); + + it('returns nextCursor at page 59 when full', () => { + const raw = Array.from({ length: 50 }, (_, i) => makeFile(i)); + const result = buildFilesPage({ page: 59, perPage: 50, rawFiles: raw }); + expect(result.nextCursor).toBe(60); + }); + + it('returns nextCursor at page 60 when full (cap reached → null)', () => { + const raw = Array.from({ length: 50 }, (_, i) => makeFile(i)); + const result = buildFilesPage({ page: 60, perPage: 50, rawFiles: raw }); + expect(result.nextCursor).toBeNull(); + }); + + it('flags patchMissing when GitHub omits the patch', () => { + const result = buildFilesPage({ + page: 1, + perPage: 50, + rawFiles: [{ filename: 'big.bin', status: 'modified', additions: 0, deletions: 0 }], + }); + expect(result.files[0]?.patchMissing).toBe(true); + expect(result.files[0]?.patch).toBeNull(); + }); + + it('preserves previousPath on renames', () => { + const result = buildFilesPage({ + page: 1, + perPage: 50, + rawFiles: [ + { + filename: 'new.ts', + previous_filename: 'old.ts', + status: 'renamed', + additions: 1, + deletions: 1, + }, + ], + }); + expect(result.files[0]?.previousPath).toBe('old.ts'); + }); +}); + +describe('sliceFileLines', () => { + const content = 'a\nb\nc\nd\ne'; + + it('returns the requested slice and totalLines', () => { + const result = sliceFileLines({ rawContent: content, startLine: 2, endLine: 4 }); + expect(result.lines).toEqual(['b', 'c', 'd']); + expect(result.totalLines).toBe(5); + }); + + it('caps the returned slice at 500 lines', () => { + const huge = Array.from({ length: 1000 }, (_, i) => `line${i}`).join('\n'); + const result = sliceFileLines({ rawContent: huge, startLine: 1, endLine: 10_000 }); + expect(result.lines).toHaveLength(500); + }); +}); + +describe('buildReviewThreadsResult', () => { + it('maps a thread with file-level subject and null line', () => { + const result = buildReviewThreadsResult({ + page: 1, + hasNextPage: false, + endCursor: null, + threads: [ + { + id: 'thread-1', + isResolved: false, + isOutdated: false, + subjectType: 'FILE', + path: 'src/file.ts', + diffSide: 'RIGHT', + comments: [ + { + databaseId: 42, + id: 'comment-node-1', + author: { login: 'octocat', avatarUrl: 'https://avatars.example/octocat' }, + body: 'LGTM', + createdAt: '2026-01-01T00:00:00Z', + reactions: [{ content: '+1', count: 2, viewerHasReacted: false }], + }, + ], + }, + ], + }); + expect(result.threads[0]?.subjectType).toBe('FILE'); + expect(result.threads[0]?.line).toBeNull(); + expect(result.threads[0]?.path).toBe('src/file.ts'); + expect(result.threads[0]?.comments[0]?.reactions[0]?.count).toBe(2); + expect(result.nextCursor).toBeNull(); + }); + + it('maps an outdated thread anchored by originalLine/originalStartLine', () => { + const result = buildReviewThreadsResult({ + page: 1, + hasNextPage: false, + endCursor: null, + threads: [ + { + id: 'thread-2', + isResolved: true, + isOutdated: true, + subjectType: 'LINE', + path: 'src/old.ts', + line: 10, + startLine: 9, + originalLine: 20, + originalStartLine: 19, + diffSide: 'LEFT', + comments: [], + }, + ], + }); + expect(result.threads[0]?.isOutdated).toBe(true); + expect(result.threads[0]?.originalLine).toBe(20); + expect(result.threads[0]?.originalStartLine).toBe(19); + expect(result.threads[0]?.diffSide).toBe('LEFT'); + }); + + it('tolerates deleted-author comments (author = null)', () => { + const result = buildReviewThreadsResult({ + page: 1, + hasNextPage: false, + endCursor: null, + threads: [ + { + id: 'thread-3', + isResolved: false, + isOutdated: false, + comments: [ + { + databaseId: 7, + id: 'comment-node-7', + author: null, + body: 'comment from deleted user', + createdAt: '2026-01-01T00:00:00Z', + reactions: [], + }, + ], + }, + ], + }); + expect(result.threads[0]?.comments[0]?.author).toBeNull(); + }); + + it('exposes nextCursor when GitHub reports hasNextPage and an endCursor', () => { + const result = buildReviewThreadsResult({ + page: 1, + hasNextPage: true, + endCursor: 'Y3Vyc29yOnYyOpHOAAAAAA==', + threads: [], + }); + expect(result.nextCursor).toBe('Y3Vyc29yOnYyOpHOAAAAAA=='); + }); + + it('folds a multi-page comment list into a single complete thread', () => { + // Mapper is invoked per-thread with the already-aggregated comment list; + // the procedure is responsible for the per-thread paginated fetch. + const result = buildReviewThreadsResult({ + page: 1, + hasNextPage: false, + endCursor: null, + threads: [ + { + id: 'thread-4', + isResolved: false, + isOutdated: false, + comments: Array.from({ length: 120 }, (_, i) => ({ + databaseId: 1000 + i, + id: `c-${i}`, + author: null, + body: `comment ${i}`, + createdAt: '2026-01-01T00:00:00Z', + reactions: [], + })), + }, + ], + }); + expect(result.threads[0]?.comments).toHaveLength(120); + }); +}); diff --git a/apps/web/src/lib/github-pr-review/mappers.ts b/apps/web/src/lib/github-pr-review/mappers.ts new file mode 100644 index 0000000000..843da39184 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/mappers.ts @@ -0,0 +1,338 @@ +import 'server-only'; + +import { z } from 'zod'; + +import { + FILES_MAX_PAGES, + REVIEW_THREADS_PAGE_SIZE, + type GitHubPrReviewChecksResult, + type GitHubPrReviewFile, + type GitHubPrReviewFilesResult, + type GitHubPrReviewReviewThread, + type GitHubPrReviewReviewThreadsResult, + GitHubPrReviewFilesResultSchema, + GitHubPrReviewReviewThreadsResultSchema, +} from './dtos'; +import { + GitHubPrReviewAuthorSchema, + GitHubPrReviewOverviewSchema, + type GitHubPrReviewOverview, +} from './dtos'; + +export type PullRequestRestData = { + number: number; + title: string; + body: string | null; + user: { login: string; avatar_url: string } | null; + state: 'open' | 'closed'; + draft?: boolean; + merged?: boolean; + base: { ref: string; repo?: { full_name: string } | null }; + head: { ref: string; sha: string; repo?: { full_name: string } | null }; + node_id: string; + commits: number; + changed_files: number; + additions: number; + deletions: number; + mergeable: boolean | null; + mergeable_state?: string | null; + auto_merge?: { merge_method?: string | null } | null; +}; + +export type RepoRestData = { + allow_merge_commit?: boolean | null; + allow_squash_merge?: boolean | null; + allow_rebase_merge?: boolean | null; + allow_auto_merge?: boolean | null; + delete_branch_on_merge?: boolean | null; + allow_update_branch?: boolean | null; + permissions?: { push?: boolean; admin?: boolean; maintain?: boolean } | null; +}; + +export type ViewerInfo = { login: string } | null; + +export type OverviewGraphQlData = { + repository: { + pullRequest: { + reviewDecision: string | null; + } | null; + } | null; + viewer: { login: string } | null; +} | null; + +function normalizeReviewDecision(value: string | null): GitHubPrReviewOverview['reviewDecision'] { + if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') { + return value; + } + return null; +} + +const GitHubRestUserSchema = z + .object({ + login: z.string().min(1), + avatar_url: z.string().url(), + }) + .strict(); + +export function buildOverviewDto(args: { + pr: PullRequestRestData; + repo: RepoRestData; + graphQl: OverviewGraphQlData; + viewer: ViewerInfo; +}): GitHubPrReviewOverview { + const { pr, repo, graphQl, viewer } = args; + const state: GitHubPrReviewOverview['state'] = pr.merged + ? 'merged' + : pr.state === 'open' + ? 'open' + : 'closed'; + const authorParsed = pr.user + ? GitHubPrReviewAuthorSchema.safeParse({ + login: pr.user.login, + avatarUrl: pr.user.avatar_url, + }) + : null; + const author = authorParsed?.success ? authorParsed.data : null; + const headRepoFullName = pr.head.repo?.full_name ?? null; + const isCrossRepo = Boolean( + pr.base.repo?.full_name && headRepoFullName && pr.base.repo.full_name !== headRepoFullName + ); + const autoMerge = + pr.auto_merge && pr.auto_merge.merge_method ? { method: pr.auto_merge.merge_method } : null; + const overview: GitHubPrReviewOverview = { + number: pr.number, + title: pr.title, + bodyMarkdown: pr.body ?? null, + author, + state, + draft: Boolean(pr.draft), + baseRef: pr.base.ref, + headRef: pr.head.ref, + isCrossRepo, + headRepoFullName, + headSha: pr.head.sha, + prNodeId: pr.node_id, + counts: { + commits: pr.commits, + changedFiles: pr.changed_files, + additions: pr.additions, + deletions: pr.deletions, + }, + mergeable: pr.mergeable, + mergeableState: pr.mergeable_state ?? null, + autoMerge, + reviewDecision: normalizeReviewDecision( + graphQl?.repository?.pullRequest?.reviewDecision ?? null + ), + repo: { + allowMergeCommit: Boolean(repo.allow_merge_commit), + allowSquashMerge: Boolean(repo.allow_squash_merge), + allowRebaseMerge: Boolean(repo.allow_rebase_merge), + allowAutoMerge: Boolean(repo.allow_auto_merge), + deleteBranchOnMerge: Boolean(repo.delete_branch_on_merge), + allowUpdateBranch: Boolean(repo.allow_update_branch), + viewerCanPush: Boolean(repo.permissions?.push), + viewerCanAdmin: Boolean(repo.permissions?.admin), + viewerLogin: viewer?.login ?? null, + }, + }; + return GitHubPrReviewOverviewSchema.parse(overview); +} + +const GitHubCheckRunRestSchema = z + .object({ + name: z.string().min(1), + status: z.string(), + conclusion: z.string().nullable().optional(), + details_url: z.string().url().nullable().optional(), + html_url: z.string().url().nullable().optional(), + app: z + .object({ + name: z.string().nullable().optional(), + }) + .nullable() + .optional(), + }) + .passthrough(); + +type CheckRunInput = z.infer; + +export function buildChecksResult(args: { + checkRuns: CheckRunInput[]; + commitStatuses: Array<{ + context: string; + state: string; + target_url: string | null; + updated_at: string | null; + }>; +}): GitHubPrReviewChecksResult { + const checkRunDtos = args.checkRuns.map(c => ({ + name: c.name, + status: c.status, + conclusion: c.conclusion ?? null, + detailsUrl: c.details_url ?? c.html_url ?? null, + appName: c.app?.name ?? null, + })); + // Dedupe commit statuses per context, keeping the latest by updated_at. + const byContext = new Map(); + for (const status of args.commitStatuses) { + const existing = byContext.get(status.context); + if (!existing) { + byContext.set(status.context, status); + continue; + } + const existingTime = existing.updated_at ? Date.parse(existing.updated_at) : 0; + const nextTime = status.updated_at ? Date.parse(status.updated_at) : 0; + if (nextTime >= existingTime) { + byContext.set(status.context, status); + } + } + const statusDtos = Array.from(byContext.values()).map(s => ({ + name: s.context, + // A commit status is only terminal for success/failure/error states; a + // `pending` state must remain non-completed so the rollup counts it. + status: s.state === 'pending' ? 'pending' : 'completed', + conclusion: s.state, + detailsUrl: s.target_url, + appName: null, + })); + const merged = [...checkRunDtos, ...statusDtos]; + const rollup = { + total: merged.length, + success: merged.filter(c => rollupState(c.status, c.conclusion) === 'success').length, + failure: merged.filter(c => rollupState(c.status, c.conclusion) === 'failure').length, + pending: merged.filter(c => rollupState(c.status, c.conclusion) === 'pending').length, + skipped: merged.filter(c => rollupState(c.status, c.conclusion) === 'skipped').length, + }; + return { checkRuns: merged, rollup }; +} + +// Classify a merged check/status into exactly one rollup bucket. Anything that +// has not reached a terminal conclusion (non-completed status, or completed +// with a null/unknown conclusion) counts as pending so no check is dropped. +function rollupState( + status: string, + conclusion: string | null +): 'success' | 'failure' | 'pending' | 'skipped' { + if (status !== 'completed') return 'pending'; + const value = conclusion ?? ''; + if (/^success$/i.test(value)) return 'success'; + if (/failure|error|cancelled|timed_out|action_required|stale/i.test(value)) return 'failure'; + if (/skipped|neutral/i.test(value)) return 'skipped'; + return 'pending'; +} + +type PullRequestFileInput = { + filename: string; + previous_filename?: string; + status: string; + additions: number; + deletions: number; + patch?: string; +}; + +export function buildFilesPage(args: { + page: number; + perPage: number; + rawFiles: PullRequestFileInput[]; +}): GitHubPrReviewFilesResult { + const { page, perPage, rawFiles } = args; + const clampedPage = Math.max(1, Math.min(FILES_MAX_PAGES, page)); + const files: GitHubPrReviewFile[] = rawFiles.map(f => ({ + path: f.filename, + previousPath: f.previous_filename ?? null, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch ?? null, + patchMissing: !f.patch, + })); + const reachedCap = clampedPage >= FILES_MAX_PAGES; + const shortPage = files.length < perPage; + const nextCursor = shortPage || reachedCap ? null : clampedPage + 1; + return GitHubPrReviewFilesResultSchema.parse({ files, nextCursor }); +} + +export function sliceFileLines(args: { rawContent: string; startLine: number; endLine: number }): { + lines: string[]; + totalLines: number; +} { + const { rawContent, startLine, endLine } = args; + const all = rawContent.split(/\r?\n/); + const totalLines = all.length; + const start = Math.max(1, startLine); + const requestedEnd = Math.max(start, endLine); + const cappedEnd = Math.min(requestedEnd, start + 500 - 1); + const slice = all.slice(start - 1, cappedEnd); + return { lines: slice, totalLines }; +} + +export type GraphQlReviewThreadInput = { + id: string; + isResolved: boolean; + isOutdated: boolean; + subjectType?: string | null; + path?: string | null; + line?: number | null; + startLine?: number | null; + originalLine?: number | null; + originalStartLine?: number | null; + diffSide?: 'LEFT' | 'RIGHT' | null; + comments: GraphQlReviewCommentInput[]; +}; + +export type GraphQlReviewCommentInput = { + databaseId: number; + id: string; + author: { login: string; avatarUrl: string } | null; + body: string; + createdAt: string; + reactions: Array<{ content: string; count: number; viewerHasReacted: boolean }>; +}; + +export function buildReviewThreadsResult(args: { + threads: GraphQlReviewThreadInput[]; + page: number; + hasNextPage: boolean; + endCursor: string | null; +}): GitHubPrReviewReviewThreadsResult { + const { threads, page, hasNextPage, endCursor } = args; + const dtos: GitHubPrReviewReviewThread[] = threads.map(t => { + const subjectType: 'LINE' | 'FILE' = t.subjectType === 'FILE' ? 'FILE' : 'LINE'; + const diffSide = t.diffSide === 'LEFT' || t.diffSide === 'RIGHT' ? t.diffSide : null; + return { + threadId: t.id, + isResolved: t.isResolved, + isOutdated: t.isOutdated, + subjectType, + path: t.path ?? null, + line: t.line ?? null, + startLine: t.startLine ?? null, + originalLine: t.originalLine ?? null, + originalStartLine: t.originalStartLine ?? null, + diffSide, + comments: t.comments.map(c => ({ + commentId: c.databaseId, + nodeId: c.id, + author: c.author ? { login: c.author.login, avatarUrl: c.author.avatarUrl } : null, + bodyMarkdown: c.body, + createdAt: c.createdAt, + reactions: c.reactions.map(r => ({ + content: r.content, + count: r.count, + viewerHasReacted: r.viewerHasReacted, + })), + })), + }; + }); + const nextCursor = + hasNextPage && endCursor && page < Number.MAX_SAFE_INTEGER / REVIEW_THREADS_PAGE_SIZE + ? endCursor + : null; + return GitHubPrReviewReviewThreadsResultSchema.parse({ + threads: dtos, + nextCursor, + }); +} + +export { GitHubRestUserSchema }; diff --git a/apps/web/src/lib/github-pr-review/retry.test.ts b/apps/web/src/lib/github-pr-review/retry.test.ts new file mode 100644 index 0000000000..d5a687c4b4 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/retry.test.ts @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ +import { TRPCError } from '@trpc/server'; + +const getGitHubUserAccessToken = jest.fn(); + +jest.mock('@/lib/integrations/platforms/github/user-token-client', () => ({ + getGitHubUserAccessToken: (...args: unknown[]) => getGitHubUserAccessToken(...args), +})); + +jest.mock('./client', () => ({ + createGitHubPrReviewOctokit: (token: string) => ({ __token: token }), +})); + +import { withGitHubUserTokenRetry } from './retry'; + +function connected(token: string, authorizationId: string, credentialVersion: number) { + return { + status: 'connected' as const, + credential: { + token, + expiresAtEpochMs: Date.now() + 3_600_000, + githubLogin: 'octocat', + authorizationId, + credentialVersion, + }, + }; +} + +function http401() { + return { status: 401, message: 'Bad credentials' }; +} + +beforeEach(() => { + getGitHubUserAccessToken.mockReset(); +}); + +describe('withGitHubUserTokenRetry', () => { + it('returns the result when the first call succeeds (no rotate)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const call = jest.fn().mockResolvedValue('ok'); + + const result = await withGitHubUserTokenRetry({ kiloUserId: 'u1', call }); + + expect(result).toBe('ok'); + expect(getGitHubUserAccessToken).toHaveBeenCalledTimes(1); + expect(getGitHubUserAccessToken).toHaveBeenCalledWith('u1', { op: 'fetch' }); + }); + + it('rotates and retries once on a raw 401, then succeeds', async () => { + getGitHubUserAccessToken + .mockResolvedValueOnce(connected('t1', 'auth_1', 1)) // fetch + .mockResolvedValueOnce(connected('t2', 'auth_1', 2)); // rotate + const call = jest.fn().mockRejectedValueOnce(http401()).mockResolvedValueOnce('recovered'); + + const result = await withGitHubUserTokenRetry({ kiloUserId: 'u1', call }); + + expect(result).toBe('recovered'); + expect(getGitHubUserAccessToken).toHaveBeenNthCalledWith(2, 'u1', { + op: 'rotate', + staleAuthorizationId: 'auth_1', + staleCredentialVersion: 1, + }); + // second call used the rotated token + expect(call).toHaveBeenNthCalledWith(2, { __token: 't2' }); + }); + + it('reports the rotated credential and throws PRECONDITION_FAILED on a second 401', async () => { + getGitHubUserAccessToken + .mockResolvedValueOnce(connected('t1', 'auth_1', 1)) // fetch + .mockResolvedValueOnce(connected('t2', 'auth_1', 2)) // rotate + .mockResolvedValueOnce({ status: 'disconnected', reason: 'revoked' }); // reportRejected + const call = jest.fn().mockRejectedValue(http401()); + + await expect(withGitHubUserTokenRetry({ kiloUserId: 'u1', call })).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + expect(getGitHubUserAccessToken).toHaveBeenNthCalledWith(3, 'u1', { + op: 'reportRejected', + authorizationId: 'auth_1', + credentialVersion: 2, + }); + }); + + it('classifies a raw non-401 error without rotating', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const call = jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }); + + await expect(withGitHubUserTokenRetry({ kiloUserId: 'u1', call })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + // only the initial fetch — no rotate + expect(getGitHubUserAccessToken).toHaveBeenCalledTimes(1); + }); + + it('surfaces an already-classified TRPCError unchanged', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const call = jest.fn().mockRejectedValue(new TRPCError({ code: 'FORBIDDEN', message: 'nope' })); + + await expect(withGitHubUserTokenRetry({ kiloUserId: 'u1', call })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(getGitHubUserAccessToken).toHaveBeenCalledTimes(1); + }); + + it('throws PRECONDITION_FAILED when the user is disconnected', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce({ + status: 'disconnected', + reason: 'not_connected', + }); + const call = jest.fn(); + + await expect(withGitHubUserTokenRetry({ kiloUserId: 'u1', call })).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/github-pr-review/retry.ts b/apps/web/src/lib/github-pr-review/retry.ts new file mode 100644 index 0000000000..f98874d950 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/retry.ts @@ -0,0 +1,128 @@ +import 'server-only'; + +import { TRPCError } from '@trpc/server'; +import type { Octokit } from '@octokit/rest'; + +import { createGitHubPrReviewOctokit } from './client'; +import { + type ClassifiedGitHubError, + classifyGitHubHttpError, + classifyGitHubGraphQlErrors, +} from './errors'; +import { + getGitHubUserAccessToken, + type GitHubUserAccessTokenConnected, +} from '@/lib/integrations/platforms/github/user-token-client'; + +export type CurrentCredential = Pick< + GitHubUserAccessTokenConnected, + 'authorizationId' | 'credentialVersion' +>; + +function throwTrpcFromClassification(classified: ClassifiedGitHubError): never { + throw new TRPCError({ + code: classified.code, + message: classified.message, + }); +} + +function isHttp401(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const status = (error as { status?: unknown }).status; + return status === 401; +} + +async function reportAndThrowPrecondition( + kiloUserId: string, + credential: CurrentCredential +): Promise { + await getGitHubUserAccessToken(kiloUserId, { + op: 'reportRejected', + authorizationId: credential.authorizationId, + credentialVersion: credential.credentialVersion, + }); + throwTrpcFromClassification({ + code: 'PRECONDITION_FAILED', + message: 'GitHub connection is no longer valid — reconnect', + }); +} + +/** + * Wraps a single GitHub call (REST or GraphQL) with the standard + * 1. fetch credential + * 2. invoke + * 3. on 401 → rotate credential + retry once + * 4. on second 401 → reportRejected + PRECONDITION_FAILED + * orchestration. The wrapped call receives a fresh Octokit each time (a + * rotate produces a new token). + */ +export async function withGitHubUserTokenRetry(args: { + kiloUserId: string; + call: (octokit: Octokit) => Promise; +}): Promise { + const first = await getGitHubUserAccessToken(args.kiloUserId, { op: 'fetch' }); + if (first.status !== 'connected') { + throwTrpcFromClassification({ + code: 'PRECONDITION_FAILED', + message: 'GitHub connection is no longer valid — reconnect', + }); + } + const firstOctokit = createGitHubPrReviewOctokit(first.credential.token); + try { + return await args.call(firstOctokit); + } catch (error) { + // A TRPCError is an already-classified failure (e.g. a GraphQL errors[] + // entry, or a deliberate BAD_REQUEST) — surface it unchanged. + if (error instanceof TRPCError) throw error; + // Non-401 raw GitHub errors are classified here (this wrapper is the + // single raw→tRPC boundary), so procedures let raw errors propagate. + if (!isHttp401(error)) throwTrpcFromGitHubError(error); + // First 401 — rotate and retry once. + const rotate = await getGitHubUserAccessToken(args.kiloUserId, { + op: 'rotate', + staleAuthorizationId: first.credential.authorizationId, + staleCredentialVersion: first.credential.credentialVersion, + }); + if (rotate.status !== 'connected') { + throwTrpcFromClassification({ + code: 'PRECONDITION_FAILED', + message: 'GitHub connection is no longer valid — reconnect', + }); + } + const secondOctokit = createGitHubPrReviewOctokit(rotate.credential.token); + try { + return await args.call(secondOctokit); + } catch (secondError) { + if (secondError instanceof TRPCError) throw secondError; + if (isHttp401(secondError)) { + await reportAndThrowPrecondition(args.kiloUserId, { + authorizationId: rotate.credential.authorizationId, + credentialVersion: rotate.credential.credentialVersion, + }); + } + throwTrpcFromGitHubError(secondError); + } + } +} + +/** + * Classify an arbitrary error and throw the corresponding tRPC error. Used by + * the read procedures to surface REST and GraphQL failures uniformly. + */ +export function throwTrpcFromGitHubError(error: unknown): never { + if (error instanceof TRPCError) throw error; + const classified = classifyGitHubHttpError(error); + throwTrpcFromClassification(classified); +} + +/** + * For GraphQL responses, inspect the top-level `errors[]` array and throw a + * tRPC error if non-empty. Octokit returns the body even when `errors[]` is + * populated, so callers must check this after every GraphQL call. + */ +export function throwTrpcFromGraphQlErrors( + errors: ReadonlyArray<{ type?: string; message?: string }> | undefined +): void { + const classified = classifyGitHubGraphQlErrors(errors); + if (classified) throwTrpcFromClassification(classified); +} diff --git a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts new file mode 100644 index 0000000000..9fdd5b853b --- /dev/null +++ b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ +import { + fetchAllThreadComments, + REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST, +} from '@/routers/github-pr-review-router'; + +function commentNode(id: number) { + return { + databaseId: id, + id: `node_${id}`, + body: `comment ${id}`, + createdAt: '2024-01-01T00:00:00Z', + author: { login: 'octocat', avatarUrl: 'https://x/y.png' }, + reactions: { nodes: [] }, + }; +} + +describe('fetchAllThreadComments', () => { + it('follows the comment cursor until hasNextPage is false and uses only valid variables', async () => { + const request = jest + .fn() + // page 2 + .mockResolvedValueOnce({ + data: { + data: { + node: { + comments: { + pageInfo: { hasNextPage: true, endCursor: 'c2' }, + nodes: [commentNode(2)], + }, + }, + }, + }, + }) + // page 3 (final) + .mockResolvedValueOnce({ + data: { + data: { + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [commentNode(3)], + }, + }, + }, + }, + }); + + const octokit = { request } as never; + + const comments = await fetchAllThreadComments({ + octokit, + threadId: 'thread_1', + initialConnection: { + pageInfo: { hasNextPage: true, endCursor: 'c1' }, + nodes: [commentNode(1)], + }, + }); + + // All three pages aggregated to completion — no silent truncation. + expect(comments.map(c => c.databaseId)).toEqual([1, 2, 3]); + expect(request).toHaveBeenCalledTimes(2); + + // The follow-up query must reference only $threadId/$first/$after (no + // unused $owner/$name/$number that GraphQL would reject). + const [, firstVars] = request.mock.calls[0] as [string, Record]; + expect(firstVars.query).toBe(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST); + expect(firstVars).toEqual({ + query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST, + threadId: 'thread_1', + first: 50, + after: 'c1', + }); + expect(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST).not.toMatch(/\$owner|\$name|\$number/); + + const [, secondVars] = request.mock.calls[1] as [string, Record]; + expect(secondVars.after).toBe('c2'); + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/github/user-token-client.test.ts b/apps/web/src/lib/integrations/platforms/github/user-token-client.test.ts new file mode 100644 index 0000000000..aa453482f8 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/github/user-token-client.test.ts @@ -0,0 +1,453 @@ +import { + getGitHubUserAccessToken, + __resetGitHubUserAccessTokenClientForTests, +} from './user-token-client'; + +const mockConfig = { + apiUrl: 'https://git-token-service.example.com', +}; + +jest.mock('@/lib/config.server', () => ({ + get GIT_TOKEN_SERVICE_API_URL() { + return mockConfig.apiUrl; + }, +})); + +jest.mock('@/lib/tokens', () => { + const actual = jest.requireActual('@/lib/tokens'); + return { + ...actual, + TOKEN_EXPIRY: { fiveMinutes: 5 * 60 }, + generateInternalServiceToken: jest.fn( + (userId: string, options?: { audience?: string; expiresIn?: number }) => + `internal-jwt(user=${userId},aud=${options?.audience ?? 'none'})` + ), + }; +}); + +type FetchHandler = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +let fetchMock: jest.Mock, Parameters>; + +function makeConnectedResponse(overrides: { + token?: string; + expiresAtEpochMs?: number; + githubLogin?: string; + authorizationId?: string; + credentialVersion?: number; +}): Response { + return new Response( + JSON.stringify({ + connected: true, + token: overrides.token ?? 'ghs_default', + expiresAtEpochMs: overrides.expiresAtEpochMs ?? Date.now() + 60 * 60 * 1000, + githubLogin: overrides.githubLogin ?? 'octocat', + authorizationId: overrides.authorizationId ?? 'auth-1', + credentialVersion: overrides.credentialVersion ?? 1, + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ); +} + +function makeDisconnectedResponse(reason: 'not_connected' | 'revoked'): Response { + return new Response(JSON.stringify({ connected: false, reason }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeEach(() => { + __resetGitHubUserAccessTokenClientForTests(); + mockConfig.apiUrl = 'https://git-token-service.example.com'; + fetchMock = jest.fn(); + jest.spyOn(global, 'fetch').mockImplementation(fetchMock as unknown as typeof fetch); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('getGitHubUserAccessToken', () => { + it('mints an internal JWT with the dedicated audience and POSTs to the token endpoint', async () => { + fetchMock.mockResolvedValueOnce(makeConnectedResponse({ token: 'ghs_xyz' })); + + const result = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + expect(result.status).toBe('connected'); + if (result.status !== 'connected') throw new Error('expected connected'); + + const generateMock = ( + jest.requireMock('@/lib/tokens') as { + generateInternalServiceToken: jest.Mock; + } + ).generateInternalServiceToken; + expect(generateMock).toHaveBeenCalledWith('kilo-user-1', { + expiresIn: 5 * 60, + audience: 'git-token-service:github-user-access-token', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe( + 'https://git-token-service.example.com/internal/github-user-authorizations/token' + ); + expect(init?.method).toBe('POST'); + const headers = (init?.headers ?? {}) as Record; + expect(headers.Authorization).toBe( + 'Bearer internal-jwt(user=kilo-user-1,aud=git-token-service:github-user-access-token)' + ); + expect(init?.body).toBe(JSON.stringify({ op: 'fetch' })); + }); + + it('caches a successful fetch and reuses it on subsequent fetches without hitting the service', async () => { + fetchMock.mockResolvedValueOnce(makeConnectedResponse({ token: 'ghs_first' })); + + const first = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + const second = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + expect(first).toEqual({ + status: 'connected', + credential: expect.objectContaining({ token: 'ghs_first' }), + }); + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('treats an entry as expired 5 minutes before the actual expiry', async () => { + const expiresAt = Date.now() + 4 * 60 * 1000; // 4 minutes from now (< 5 min headroom) + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ token: 'ghs_soon', expiresAtEpochMs: expiresAt }) + ); + + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + fetchMock.mockResolvedValueOnce(makeConnectedResponse({ token: 'ghs_fresh' })); + const second = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + if (second.status !== 'connected') throw new Error('expected connected'); + expect(second.credential.token).toBe('ghs_fresh'); + }); + + it('rotate does not consult the cache and always hits the endpoint', async () => { + fetchMock.mockResolvedValueOnce(makeConnectedResponse({ token: 'ghs_first' })); + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ + token: 'ghs_rotated', + authorizationId: 'auth-2', + credentialVersion: 2, + }) + ); + const rotated = await getGitHubUserAccessToken('kilo-user-1', { + op: 'rotate', + staleAuthorizationId: 'auth-1', + staleCredentialVersion: 1, + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, init] = fetchMock.mock.calls[1]!; + expect(init?.body).toBe( + JSON.stringify({ + op: 'rotate', + staleAuthorizationId: 'auth-1', + staleCredentialVersion: 1, + }) + ); + if (rotated.status !== 'connected') throw new Error('expected connected'); + expect(rotated.credential.token).toBe('ghs_rotated'); + }); + + it('reportRejected never reads or writes the cache and always hits the endpoint', async () => { + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ + token: 'ghs_cached', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); + const reported = await getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + + expect(reported).toEqual({ status: 'disconnected', reason: 'revoked' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, init] = fetchMock.mock.calls[1]!; + expect(init?.body).toBe( + JSON.stringify({ + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + }); + + it('reportRejected evicts a matching cached generation and a future fetch refreshes', async () => { + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ + token: 'ghs_cached', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); + await getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ token: 'ghs_fresh', authorizationId: 'auth-2', credentialVersion: 2 }) + ); + const after = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + if (after.status !== 'connected') throw new Error('expected connected'); + expect(after.credential.token).toBe('ghs_fresh'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('maps a 503 response to temporarily_unavailable', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { status: 503 }) + ); + + const result = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + expect(result).toEqual({ status: 'temporarily_unavailable' }); + }); + + it('returns disconnected when the service is unconfigured', async () => { + mockConfig.apiUrl = ''; + // The function returns `temporarily_unavailable` when unconfigured; this + // documents the chosen behavior. + const result = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + expect(result).toEqual({ status: 'temporarily_unavailable' }); + }); + + it('serializes overlapping rotate/reportRejected so a delayed rotate cannot re-insert an evicted generation', async () => { + // First populate the cache with auth-1 v1. + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ + token: 'ghs_cached', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + // Wire two responses in order: rotate is slow (queued first), then a fast + // reportRejected. The rotate must complete first because it was enqueued + // first, then the reportRejected runs and evicts the rotate's write. + let resolveRotate: ((value: Response) => void) | null = null; + fetchMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveRotate = resolve; + }) + ); + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); + + const rotatePromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'rotate', + staleAuthorizationId: 'auth-1', + staleCredentialVersion: 1, + }); + const reportPromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + + // Resolve the rotate; the report will run after it because the queue is + // FIFO and rotate was enqueued first. + // Flush microtasks so the rotate's in-flight fetch mock runs and + // resolveRotate is assigned. + await Promise.resolve(); + await Promise.resolve(); + resolveRotate!( + makeConnectedResponse({ + token: 'ghs_rotated', + authorizationId: 'auth-2', + credentialVersion: 2, + }) + ); + await rotatePromise; + const report = await reportPromise; + + expect(report).toEqual({ status: 'disconnected', reason: 'revoked' }); + + // After the report, the cache must hold neither auth-1 v1 nor auth-2 v2 + // (reportRejected on auth-1 v1 evicts only if cached <= op.credentialVersion + // for the same authorizationId; auth-2 v2 is a different authorizationId + // so it is left alone by the eviction rule). However, the rotate's + // successful result was overwritten by the report's later write into the + // cache because we serialize all writes — but report does not write the + // cache, so rotate's write stands. The invariant we care about is that + // no entry with credentialVersion <= 1 for authorizationId='auth-1' + // remains in the cache. + const cachedAfter = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + if (cachedAfter.status === 'connected') { + expect( + cachedAfter.credential.authorizationId === 'auth-1' && + cachedAfter.credential.credentialVersion <= 1 + ).toBe(false); + } + }); + + it('serializes overlapping fetch/reportRejected so a delayed fetch cannot repopulate a revoked generation', async () => { + // Slow fetch + fast reportRejected. + let resolveFetch: ((value: Response) => void) | null = null; + fetchMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); + + const fetchPromise = getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + const reportPromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + + // The report goes through the queue first (fetch is the one waiting + // because fetch was started first; queue is FIFO, so report is behind + // the in-flight fetch). We resolve the fetch now. + resolveFetch!( + makeConnectedResponse({ + token: 'ghs_cached', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + await fetchPromise; + const report = await reportPromise; + + expect(report).toEqual({ status: 'disconnected', reason: 'revoked' }); + + // Cache holds auth-1 v1 after the fetch settled. The reportRejected that + // ran afterward must have evicted it (it evicts when authorizationId + // matches and credentialVersion <= op.credentialVersion). A subsequent + // fetch must hit the service. + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ token: 'ghs_fresh', authorizationId: 'auth-2', credentialVersion: 2 }) + ); + const after = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + if (after.status !== 'connected') throw new Error('expected connected'); + expect(after.credential.authorizationId).toBe('auth-2'); + }); + + it('serializes a following cache-miss fetch AFTER reportRejected (reverse ordering)', async () => { + // Empty cache. Enqueue reportRejected FIRST, then a cache-miss fetch. + // FIFO: the report round-trips first (nothing to evict yet), then the + // fetch runs and reflects post-report service state. + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); // report + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ token: 'ghs_new', authorizationId: 'auth-2', credentialVersion: 2 }) + ); // fetch + + const reportPromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + const fetchPromise = getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + const report = await reportPromise; + const fetched = await fetchPromise; + + expect(report).toEqual({ status: 'disconnected', reason: 'revoked' }); + if (fetched.status !== 'connected') throw new Error('expected connected'); + expect(fetched.credential.authorizationId).toBe('auth-2'); + expect(fetched.credential.credentialVersion).toBe(2); + + // Invariant: no cache entry carries the rejected auth-1 generation. + const cached = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + if (cached.status === 'connected') { + expect( + cached.credential.authorizationId === 'auth-1' && cached.credential.credentialVersion <= 1 + ).toBe(false); + } + }); + + it('serializes a following rotate AFTER reportRejected (reverse ordering)', async () => { + fetchMock.mockResolvedValueOnce(makeDisconnectedResponse('revoked')); // report + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ token: 'ghs_rot', authorizationId: 'auth-2', credentialVersion: 2 }) + ); // rotate + + const reportPromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + const rotatePromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'rotate', + staleAuthorizationId: 'auth-1', + staleCredentialVersion: 1, + }); + + const report = await reportPromise; + const rotate = await rotatePromise; + + expect(report).toEqual({ status: 'disconnected', reason: 'revoked' }); + if (rotate.status !== 'connected') throw new Error('expected connected'); + expect(rotate.credential.authorizationId).toBe('auth-2'); + // The rejected generation must not remain readable from cache. + const cached = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + if (cached.status === 'connected') { + expect( + cached.credential.authorizationId === 'auth-1' && cached.credential.credentialVersion <= 1 + ).toBe(false); + } + }); + + it('treats a cache hit as a pure memory read that does not enter the queue', async () => { + fetchMock.mockResolvedValueOnce( + makeConnectedResponse({ + token: 'ghs_cached', + authorizationId: 'auth-1', + credentialVersion: 1, + }) + ); + await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + + // Queue a reportRejected (must be serialized). While it is in flight, + // a cache hit for fetch must return immediately without going through + // the queue. + let resolveReport: ((value: Response) => void) | null = null; + fetchMock.mockImplementationOnce( + () => + new Promise(resolve => { + resolveReport = resolve; + }) + ); + const reportPromise = getGitHubUserAccessToken('kilo-user-1', { + op: 'reportRejected', + authorizationId: 'auth-1', + credentialVersion: 1, + }); + + // Cache hit — must resolve immediately and not wait for the report. + const start = Date.now(); + const hit = await getGitHubUserAccessToken('kilo-user-1', { op: 'fetch' }); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(50); + if (hit.status !== 'connected') throw new Error('expected connected'); + expect(hit.credential.token).toBe('ghs_cached'); + + resolveReport!(makeDisconnectedResponse('revoked')); + await reportPromise; + }); +}); diff --git a/apps/web/src/lib/integrations/platforms/github/user-token-client.ts b/apps/web/src/lib/integrations/platforms/github/user-token-client.ts new file mode 100644 index 0000000000..0b8151dc75 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/github/user-token-client.ts @@ -0,0 +1,249 @@ +import 'server-only'; + +import { z } from 'zod'; +import { GIT_TOKEN_SERVICE_API_URL } from '@/lib/config.server'; +import { generateInternalServiceToken, TOKEN_EXPIRY } from '@/lib/tokens'; +import { GITHUB_USER_ACCESS_TOKEN_AUDIENCE } from '@kilocode/worker-utils/internal-service-token-audiences'; + +export const GitHubUserAccessTokenOpSchema = z.discriminatedUnion('op', [ + z.object({ op: z.literal('fetch') }).strict(), + z + .object({ + op: z.literal('rotate'), + staleAuthorizationId: z.string().min(1), + staleCredentialVersion: z.number().int().nonnegative(), + }) + .strict(), + z + .object({ + op: z.literal('reportRejected'), + authorizationId: z.string().min(1), + credentialVersion: z.number().int().nonnegative(), + }) + .strict(), +]); +export type GitHubUserAccessTokenOp = z.infer; + +const GitHubUserAccessTokenRequestBodySchema = z.union([ + z.object({ op: z.literal('fetch') }).strict(), + z + .object({ + op: z.literal('rotate'), + staleAuthorizationId: z.string(), + staleCredentialVersion: z.number(), + }) + .strict(), + z + .object({ + op: z.literal('reportRejected'), + authorizationId: z.string(), + credentialVersion: z.number(), + }) + .strict(), +]); + +const GitHubUserAccessTokenConnectedSchema = z + .object({ + connected: z.literal(true), + token: z.string().min(1), + expiresAtEpochMs: z.number().int().positive(), + githubLogin: z.string().min(1), + authorizationId: z.string().min(1), + credentialVersion: z.number().int().nonnegative(), + }) + .strict(); + +const GitHubUserAccessTokenDisconnectedSchema = z + .object({ + connected: z.literal(false), + reason: z.enum(['not_connected', 'revoked']), + }) + .strict(); + +export const GitHubUserAccessTokenResponseSchema = z.union([ + GitHubUserAccessTokenConnectedSchema, + GitHubUserAccessTokenDisconnectedSchema, +]); +export type GitHubUserAccessTokenResponse = z.infer; + +export type GitHubUserAccessTokenConnected = z.infer; + +export type GitHubUserAccessTokenResult = + | { status: 'connected'; credential: GitHubUserAccessTokenConnected } + | { status: 'disconnected'; reason: 'not_connected' | 'revoked' } + | { status: 'temporarily_unavailable' }; + +const CACHE_REFRESH_HEADROOM_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30_000; + +type CachedCredential = GitHubUserAccessTokenConnected; + +const cache = new Map(); +const queue = new Map>(); + +async function enqueueForUser(userId: string, op: () => Promise): Promise { + const previous = queue.get(userId); + const next = (async () => { + if (previous) { + try { + await previous; + } catch { + // A failed earlier op must not poison the queue. + } + } + return op(); + })() as Promise; + const queueEntry = next.catch(() => undefined); + queue.set(userId, queueEntry); + try { + return await next; + } finally { + if (queue.get(userId) === queueEntry) { + queue.delete(userId); + } + } +} + +function readCache(userId: string): CachedCredential | undefined { + const entry = cache.get(userId); + if (!entry) return undefined; + if (entry.expiresAtEpochMs - Date.now() <= CACHE_REFRESH_HEADROOM_MS) { + cache.delete(userId); + return undefined; + } + return entry; +} + +function cacheForUser(userId: string, credential: CachedCredential | null): void { + if (credential === null) { + cache.delete(userId); + return; + } + cache.set(userId, credential); +} + +async function callTokenService( + userId: string, + body: GitHubUserAccessTokenOp +): Promise { + if (!GIT_TOKEN_SERVICE_API_URL) { + return { status: 'temporarily_unavailable' }; + } + const parsedBody = GitHubUserAccessTokenRequestBodySchema.safeParse(body); + if (!parsedBody.success) { + return { status: 'temporarily_unavailable' }; + } + const serviceToken = generateInternalServiceToken(userId, { + expiresIn: TOKEN_EXPIRY.fiveMinutes, + audience: GITHUB_USER_ACCESS_TOKEN_AUDIENCE, + }); + let response: Response; + try { + response = await fetch( + `${GIT_TOKEN_SERVICE_API_URL}/internal/github-user-authorizations/token`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${serviceToken}`, + }, + body: JSON.stringify(parsedBody.data), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + } + ); + } catch { + return { status: 'temporarily_unavailable' }; + } + if (response.status === 503) { + return { status: 'temporarily_unavailable' }; + } + if (response.status === 400 || response.status === 401) { + // Surface upstream contract errors as a non-retryable disconnected result. + // 400 invalid_request and 401 unauthorized both indicate the request is + // structurally wrong / not authenticated; the caller should treat the + // user as needing to reconnect. + return { status: 'disconnected', reason: 'not_connected' }; + } + if (!response.ok) { + return { status: 'temporarily_unavailable' }; + } + let json: unknown; + try { + json = await response.json(); + } catch { + return { status: 'temporarily_unavailable' }; + } + const parsed = GitHubUserAccessTokenResponseSchema.safeParse(json); + if (!parsed.success) { + return { status: 'temporarily_unavailable' }; + } + if (parsed.data.connected) { + return { status: 'connected', credential: parsed.data }; + } + return { status: 'disconnected', reason: parsed.data.reason }; +} + +export async function getGitHubUserAccessToken( + kiloUserId: string, + op: GitHubUserAccessTokenOp +): Promise { + if (op.op === 'fetch') { + const cached = readCache(kiloUserId); + if (cached) { + return { status: 'connected', credential: cached }; + } + return enqueueForUser(kiloUserId, async () => { + // Re-check cache after acquiring the queue slot in case a previous + // operation populated it while we were waiting. + const cachedAfterWait = readCache(kiloUserId); + if (cachedAfterWait) { + return { status: 'connected', credential: cachedAfterWait } as const; + } + const result = await callTokenService(kiloUserId, op); + if (result.status === 'connected') { + cacheForUser(kiloUserId, result.credential); + } + return result; + }); + } + + if (op.op === 'rotate') { + return enqueueForUser(kiloUserId, async () => { + const result = await callTokenService(kiloUserId, op); + if (result.status === 'connected') { + cacheForUser(kiloUserId, result.credential); + } else { + // Rotate failed or returned disconnected/revoked: evict any prior + // cached entry — the stale credential is no longer trustworthy. + cacheForUser(kiloUserId, null); + } + return result; + }); + } + + // reportRejected + return enqueueForUser(kiloUserId, async () => { + const result = await callTokenService(kiloUserId, op); + // Always evict the matching generation so future fetches refresh. + const cached = readCache(kiloUserId); + if ( + cached && + cached.authorizationId === op.authorizationId && + cached.credentialVersion <= op.credentialVersion + ) { + cacheForUser(kiloUserId, null); + } + return result; + }); +} + +/** + * Test-only helper: reset the in-process cache and per-user queue. NOT exported + * via the module's public API surface — only consumed by jest tests in the + * same package. + */ +export function __resetGitHubUserAccessTokenClientForTests(): void { + cache.clear(); + queue.clear(); +} diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts new file mode 100644 index 0000000000..82f676eb96 --- /dev/null +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -0,0 +1,465 @@ +import 'server-only'; + +import * as z from 'zod'; +import { TRPCError } from '@trpc/server'; + +import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import type { createGitHubPrReviewOctokit } from '@/lib/github-pr-review/client'; +import { + buildChecksResult, + buildFilesPage, + buildOverviewDto, + buildReviewThreadsResult, + sliceFileLines, +} from '@/lib/github-pr-review/mappers'; +import { + FILE_LINES_MAX, + FILES_MAX_PAGES, + FILES_PAGE_SIZE, + REVIEW_THREADS_PAGE_SIZE, +} from '@/lib/github-pr-review/dtos'; +import { throwTrpcFromGraphQlErrors, withGitHubUserTokenRetry } from '@/lib/github-pr-review/retry'; +import { getGitHubUserAccessToken } from '@/lib/integrations/platforms/github/user-token-client'; + +const ownerRepoRegex = /^[A-Za-z0-9_.-]+$/; + +const ownerRepoSchema = z + .object({ + owner: z.string().regex(ownerRepoRegex), + repo: z.string().regex(ownerRepoRegex), + }) + .strict(); + +const prNumberSchema = z.number().int().positive(); + +const GetPullRequestInput = ownerRepoSchema.extend({ number: prNumberSchema }).strict(); + +const ListChecksInput = ownerRepoSchema.extend({ ref: z.string().min(1).max(255) }).strict(); + +const ListFilesInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + cursor: z.number().int().min(1).max(FILES_MAX_PAGES).optional(), + }) + .strict(); + +const GetFileLinesInput = ownerRepoSchema + .extend({ + ref: z.string().min(1).max(255), + path: z.string().min(1).max(1024), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + }) + .strict() + .refine(v => v.endLine >= v.startLine, { + message: 'endLine must be >= startLine', + }); + +const ListReviewThreadsInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + cursor: z.string().min(1).optional(), + }) + .strict(); + +const PULL_REQUEST_FRAGMENT_QUERY = /* GraphQL */ ` + query PrReviewDecision($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewDecision + } + } + viewer { + login + } + } +`; + +const REVIEW_THREADS_QUERY = /* GraphQL */ ` + query PrReviewThreads( + $owner: String! + $name: String! + $number: Int! + $first: Int! + $after: String + ) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + isResolved + isOutdated + subjectType + path + line + startLine + originalLine + originalStartLine + diffSide + comments(first: 50) { + pageInfo { + hasNextPage + endCursor + } + nodes { + databaseId + id + body + createdAt + author { + login + avatarUrl + } + reactions(first: 20) { + nodes { + content + count: reactors(first: 0) { + totalCount + } + viewerHasReacted + } + } + } + } + } + } + } + } + } +`; + +const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY = /* GraphQL */ ` + query PrReviewThreadComments($threadId: ID!, $first: Int!, $after: String) { + node(id: $threadId) { + ... on PullRequestReviewThread { + comments(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + databaseId + id + body + createdAt + author { + login + avatarUrl + } + reactions(first: 20) { + nodes { + content + count: reactors(first: 0) { + totalCount + } + viewerHasReacted + } + } + } + } + } + } + } +`; + +type GraphQlReactionNode = { + content: string; + count?: { totalCount: number } | null; + viewerHasReacted: boolean; +}; + +type GraphQlCommentNode = { + databaseId: number; + id: string; + body: string; + createdAt: string; + author: { login: string; avatarUrl: string } | null; + reactions: { nodes: GraphQlReactionNode[] }; +}; + +type GraphQlCommentConnection = { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: GraphQlCommentNode[]; +}; + +type GraphQlReviewThreadNode = { + id: string; + isResolved: boolean; + isOutdated: boolean; + subjectType: 'LINE' | 'FILE' | null; + path: string | null; + line: number | null; + startLine: number | null; + originalLine: number | null; + originalStartLine: number | null; + diffSide: 'LEFT' | 'RIGHT' | null; + comments: GraphQlCommentConnection; +}; + +function normalizeReactions(nodes: GraphQlReactionNode[]) { + return nodes.map(n => ({ + content: n.content, + count: n.count?.totalCount ?? 0, + viewerHasReacted: Boolean(n.viewerHasReacted), + })); +} + +function normalizeComment(node: GraphQlCommentNode) { + return { + databaseId: node.databaseId, + id: node.id, + body: node.body, + createdAt: node.createdAt, + author: node.author, + reactions: normalizeReactions(node.reactions?.nodes ?? []), + }; +} + +// Exported for unit testing the follow-up pagination loop. +export const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST = REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY; + +export async function fetchAllThreadComments(args: { + octokit: ReturnType; + threadId: string; + initialConnection: GraphQlCommentConnection; +}): Promise[]> { + const { octokit, threadId, initialConnection } = args; + const collected: ReturnType[] = + initialConnection.nodes.map(normalizeComment); + let cursor: string | null = initialConnection.pageInfo.endCursor; + let hasNext = initialConnection.pageInfo.hasNextPage; + // Follow the comment cursor until GitHub reports no next page, so DTO + // threads always carry the complete comment list (no silent truncation). + while (hasNext && cursor) { + const response = (await octokit.request('POST /graphql', { + query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY, + threadId, + first: 50, + after: cursor, + })) as { + data: { + data: { node: { comments: GraphQlCommentConnection } | null } | null; + errors?: unknown; + }; + }; + throwTrpcFromGraphQlErrors(response.data.errors as never); + const node = response.data.data?.node; + if (!node) break; + collected.push(...node.comments.nodes.map(normalizeComment)); + hasNext = node.comments.pageInfo.hasNextPage; + cursor = node.comments.pageInfo.endCursor; + } + return collected; +} + +async function fetchReviewThreadsPage(args: { + octokit: ReturnType; + owner: string; + repo: string; + number: number; + cursor: string | null; +}) { + const { octokit, owner, repo, number, cursor } = args; + const response = (await octokit.request('POST /graphql', { + query: REVIEW_THREADS_QUERY, + owner, + name: repo, + number, + first: REVIEW_THREADS_PAGE_SIZE, + after: cursor ?? null, + })) as { + data: { + data: { + repository: { + pullRequest: { + reviewThreads: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: GraphQlReviewThreadNode[]; + }; + } | null; + } | null; + } | null; + errors?: unknown; + }; + }; + throwTrpcFromGraphQlErrors(response.data.errors as never); + return response.data.data?.repository?.pullRequest?.reviewThreads ?? null; +} + +export const githubPrReviewRouter = createTRPCRouter({ + getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { + const overview = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + // Raw GitHub errors propagate to withGitHubUserTokenRetry, which + // handles 401 rotation and classifies everything else. + const pullsResp = await octokit.pulls.get({ + owner: input.owner, + repo: input.repo, + pull_number: input.number, + }); + const pr = pullsResp.data; + const repoResp = await octokit.repos.get({ + owner: input.owner, + repo: input.repo, + }); + const repo = repoResp.data; + // GraphQL for reviewDecision + viewer.login + type OverviewGraphQl = { + repository: { pullRequest: { reviewDecision: string | null } | null } | null; + viewer: { login: string } | null; + }; + let graphQl: OverviewGraphQl | null = null; + try { + const gqlResp = (await octokit.request('POST /graphql', { + query: PULL_REQUEST_FRAGMENT_QUERY, + owner: input.owner, + name: input.repo, + number: input.number, + })) as { data: { data: OverviewGraphQl | null; errors?: unknown } }; + throwTrpcFromGraphQlErrors(gqlResp.data.errors as never); + graphQl = gqlResp.data.data ?? null; + } catch (error) { + if (error instanceof TRPCError) throw error; + // GraphQL failure should not block the rest of the overview. + graphQl = null; + } + return buildOverviewDto({ + pr: pr as never, + repo: repo as never, + graphQl, + viewer: graphQl?.viewer ?? null, + }); + }, + }); + return overview; + }), + + listChecks: baseProcedure.input(ListChecksInput).query(async ({ ctx, input }) => { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const checkRuns = await octokit.paginate(octokit.checks.listForRef, { + owner: input.owner, + repo: input.repo, + ref: input.ref, + per_page: 100, + }); + const statuses = await octokit.paginate(octokit.repos.listCommitStatusesForRef, { + owner: input.owner, + repo: input.repo, + ref: input.ref, + per_page: 100, + }); + return buildChecksResult({ + checkRuns: checkRuns as never, + commitStatuses: statuses as never, + }); + }, + }); + }), + + listFiles: baseProcedure.input(ListFilesInput).query(async ({ ctx, input }) => { + const page = input.cursor ?? 1; + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const response = await octokit.pulls.listFiles({ + owner: input.owner, + repo: input.repo, + pull_number: input.number, + page, + per_page: FILES_PAGE_SIZE, + }); + return buildFilesPage({ + page, + perPage: FILES_PAGE_SIZE, + rawFiles: response.data as never, + }); + }, + }); + }), + + getFileLines: baseProcedure.input(GetFileLinesInput).query(async ({ ctx, input }) => { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const response = await octokit.repos.getContent({ + owner: input.owner, + repo: input.repo, + path: input.path, + ref: input.ref, + mediaType: { format: 'raw' }, + }); + const data = response.data as unknown; + if (typeof data !== 'string') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Requested path is not a file', + }); + } + const cappedEnd = Math.min(input.endLine, input.startLine + FILE_LINES_MAX - 1); + return sliceFileLines({ + rawContent: data, + startLine: input.startLine, + endLine: cappedEnd, + }); + }, + }); + }), + + listReviewThreads: baseProcedure.input(ListReviewThreadsInput).query(async ({ ctx, input }) => { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const connection = await fetchReviewThreadsPage({ + octokit, + owner: input.owner, + repo: input.repo, + number: input.number, + cursor: input.cursor ?? null, + }); + if (!connection) { + return { threads: [], nextCursor: null }; + } + const threads = await Promise.all( + connection.nodes.map(async node => { + const comments = await fetchAllThreadComments({ + octokit, + threadId: node.id, + initialConnection: node.comments, + }); + return { + id: node.id, + isResolved: node.isResolved, + isOutdated: node.isOutdated, + subjectType: node.subjectType, + path: node.path, + line: node.line, + startLine: node.startLine, + originalLine: node.originalLine, + originalStartLine: node.originalStartLine, + diffSide: node.diffSide, + comments, + }; + }) + ); + return buildReviewThreadsResult({ + threads: threads as never, + page: 1, + hasNextPage: connection.pageInfo.hasNextPage, + endCursor: connection.pageInfo.endCursor, + }); + }, + }); + }), +}); + +// Re-export the disconnected helper used by callers that want to surface a +// friendly reconnect message without invoking a full procedure. +export { getGitHubUserAccessToken }; diff --git a/apps/web/src/routers/root-router.ts b/apps/web/src/routers/root-router.ts index 6c8f11a530..81080f0d71 100644 --- a/apps/web/src/routers/root-router.ts +++ b/apps/web/src/routers/root-router.ts @@ -46,6 +46,7 @@ import { mcpGatewayRouter } from '@/routers/mcp-gateway-router'; import { costInsightsRouter } from '@/routers/cost-insights-router'; import { mcpGatewayAuthorizationsRouter } from '@/routers/mcp-gateway-authorizations-router'; import { modelPreferencesRouter } from '@/routers/model-preferences-router'; +import { githubPrReviewRouter } from '@/routers/github-pr-review-router'; export const rootRouter = createTRPCRouter({ test: testRouter, organizations: organizationsRouter, @@ -93,6 +94,7 @@ export const rootRouter = createTRPCRouter({ mcpGatewayAuthorizations: mcpGatewayAuthorizationsRouter, costInsights: costInsightsRouter, modelPreferences: modelPreferencesRouter, + githubPrReview: githubPrReviewRouter, }); // export type definition of API export type RootRouter = typeof rootRouter; From ef9ed48c6f6b357867bdc2ce56f50d685dfdb078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 02:25:06 +0200 Subject: [PATCH 05/19] feat(web): github pr review mutations --- .../src/lib/github-pr-review/dev-seed.test.ts | 131 +++++ apps/web/src/lib/github-pr-review/dev-seed.ts | 84 +++ .../lib/github-pr-review/mutations.test.ts | 373 ++++++++++++++ .../web/src/lib/github-pr-review/mutations.ts | 270 ++++++++++ .../platforms/github/user-authorization.ts | 50 +- .../platforms/github/user-token-envelope.ts | 45 ++ .../src/routers/github-apps-router.test.ts | 77 +++ apps/web/src/routers/github-apps-router.ts | 30 ++ .../routers/github-pr-review-router.test.ts | 312 ++++++++++++ .../src/routers/github-pr-review-router.ts | 479 ++++++++++++++++++ 10 files changed, 1812 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/lib/github-pr-review/dev-seed.test.ts create mode 100644 apps/web/src/lib/github-pr-review/dev-seed.ts create mode 100644 apps/web/src/lib/github-pr-review/mutations.test.ts create mode 100644 apps/web/src/lib/github-pr-review/mutations.ts create mode 100644 apps/web/src/lib/integrations/platforms/github/user-token-envelope.ts create mode 100644 apps/web/src/routers/github-pr-review-router.test.ts diff --git a/apps/web/src/lib/github-pr-review/dev-seed.test.ts b/apps/web/src/lib/github-pr-review/dev-seed.test.ts new file mode 100644 index 0000000000..da91672a07 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/dev-seed.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment node + */ + +const mockEncryptKeyedEnvelope = jest.fn( + (value: string, _scheme: string, _key: unknown, aad: string) => `envelope:${value}:${aad}` +); + +const insertValuesMock = jest.fn().mockReturnThis(); +const onConflictMock = jest.fn().mockReturnThis(); +const returningMock = jest.fn(); +const insertChain = { + values: insertValuesMock, + onConflictDoUpdate: onConflictMock, + returning: returningMock, +}; +const insertMock = jest.fn(() => insertChain); +const dbMock = { insert: insertMock }; + +jest.mock('@/lib/drizzle', () => ({ + get db() { + return dbMock; + }, +})); + +jest.mock('@/lib/config.server', () => ({ + get USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID() { + return 'github-token-key-v1'; + }, + get USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY() { + return Buffer.from('test-public-key').toString('base64'); + }, +})); + +jest.mock('@/lib/encryption', () => ({ + encryptKeyedEnvelope: (...args: [string, string, unknown, string]) => + mockEncryptKeyedEnvelope(...args), +})); + +import { seedUserGithubToken } from './dev-seed'; + +beforeEach(() => { + jest.clearAllMocks(); + insertValuesMock.mockReturnThis(); + onConflictMock.mockReturnThis(); + returningMock.mockResolvedValue([{ id: 'row-1' }]); +}); + +describe('seedUserGithubToken', () => { + it('encrypts the token twice with the matching AADs and upserts a standard row', async () => { + const result = await seedUserGithubToken({ + kiloUserId: 'user-1', + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + + expect(result).toEqual({ upserted: true, githubLogin: 'octocat' }); + + // Two encryption calls — one for access, one for refresh — each with the + // AAD the real authorization path produces. + expect(mockEncryptKeyedEnvelope).toHaveBeenCalledTimes(2); + expect(mockEncryptKeyedEnvelope).toHaveBeenNthCalledWith( + 1, + 'fake-token', + 'github-user-token-rsa-aes-256-gcm', + expect.objectContaining({ keyId: 'github-token-key-v1' }), + 'github-user-authorization:v1:user-1:standard:42:access' + ); + expect(mockEncryptKeyedEnvelope).toHaveBeenNthCalledWith( + 2, + 'fake-token', + 'github-user-token-rsa-aes-256-gcm', + expect.objectContaining({ keyId: 'github-token-key-v1' }), + 'github-user-authorization:v1:user-1:standard:42:refresh' + ); + }); + + it('persists a standard row with far-future expiries and revoked-at null', async () => { + await seedUserGithubToken({ + kiloUserId: 'user-1', + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + + const passedValues = insertValuesMock.mock.calls[0]?.[0]; + expect(passedValues).toEqual( + expect.objectContaining({ + kilo_user_id: 'user-1', + github_app_type: 'standard', + github_user_id: '42', + github_login: 'octocat', + access_token_expires_at: '9999-12-31T23:59:59.000Z', + refresh_token_expires_at: '9999-12-31T23:59:59.000Z', + revoked_at: null, + revocation_reason: null, + }) + ); + expect(passedValues.access_token_encrypted).toContain('access'); + expect(passedValues.refresh_token_encrypted).toContain('refresh'); + }); + + it('upserts on the (kilo_user_id, github_app_type) unique index', async () => { + await seedUserGithubToken({ + kiloUserId: 'user-1', + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + + // The target is a composite (kilo_user_id, github_app_type); we don't + // reach into the column definitions, just assert the conflict clause was + // set so the row gets upserted instead of failing on duplicate-key. + const onConflictArg = onConflictMock.mock.calls[0]?.[0]; + expect(onConflictArg).toBeDefined(); + expect(onConflictArg.set).toBeDefined(); + expect(onConflictArg.setWhere).toBeDefined(); + }); + + it('returns upserted=false when no row is returned', async () => { + returningMock.mockResolvedValueOnce([]); + const result = await seedUserGithubToken({ + kiloUserId: 'user-1', + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + expect(result.upserted).toBe(false); + }); +}); diff --git a/apps/web/src/lib/github-pr-review/dev-seed.ts b/apps/web/src/lib/github-pr-review/dev-seed.ts new file mode 100644 index 0000000000..e6810b3a62 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/dev-seed.ts @@ -0,0 +1,84 @@ +import 'server-only'; + +import { eq, sql } from 'drizzle-orm'; +import { user_github_app_tokens } from '@kilocode/db/schema'; + +import { db } from '@/lib/drizzle'; +// Single source of truth for the envelope scheme, active key, and AAD. Reusing +// the production helper guarantees the seeded row stays decryptable by +// git-token-service even if the scheme/AAD ever changes. +import { encryptUserGithubTokenEnvelope } from '@/lib/integrations/platforms/github/user-token-envelope'; + +// The dev seed writes a far-future expiry so E2E never hits a "token expired" +// branch while the suite is running. YEAR 9999 is the same convention the +// schema fixtures use for non-expiring credentials. +const FAR_FUTURE_ISO = '9999-12-31T23:59:59.000Z'; + +export type SeedUserGithubTokenResult = { + upserted: boolean; + githubLogin: string; +}; + +/** + * Dev-only helper. Encrypts the supplied FAKE token with the same public-key + * envelope the real OAuth callback uses, then upserts a `standard` + * `user_github_app_tokens` row keyed on `kiloUserId`. The same `token` is + * used for both access and refresh — a fake token only needs to authenticate + * against the local mock GitHub server. + * + * The single-source-of-truth: this is the path the dev E2E suite relies on to + * skip the real OAuth round-trip. + */ +export async function seedUserGithubToken(input: { + kiloUserId: string; + token: string; + githubLogin: string; + githubUserId: string; +}): Promise { + const values = { + kilo_user_id: input.kiloUserId, + github_app_type: 'standard' as const, + github_user_id: input.githubUserId, + github_login: input.githubLogin, + access_token_encrypted: encryptUserGithubTokenEnvelope(input.token, { + kiloUserId: input.kiloUserId, + githubUserId: input.githubUserId, + tokenType: 'access', + }), + access_token_expires_at: FAR_FUTURE_ISO, + refresh_token_encrypted: encryptUserGithubTokenEnvelope(input.token, { + kiloUserId: input.kiloUserId, + githubUserId: input.githubUserId, + tokenType: 'refresh', + }), + refresh_token_expires_at: FAR_FUTURE_ISO, + revoked_at: null, + revocation_reason: null, + }; + + const [stored] = await db + .insert(user_github_app_tokens) + .values(values) + .onConflictDoUpdate({ + target: [user_github_app_tokens.kilo_user_id, user_github_app_tokens.github_app_type], + set: { + github_user_id: values.github_user_id, + github_login: values.github_login, + access_token_encrypted: values.access_token_encrypted, + access_token_expires_at: values.access_token_expires_at, + refresh_token_encrypted: values.refresh_token_encrypted, + refresh_token_expires_at: values.refresh_token_expires_at, + revoked_at: null, + revocation_reason: null, + credential_version: sql`${user_github_app_tokens.credential_version} + 1`, + updated_at: new Date().toISOString(), + }, + setWhere: eq(user_github_app_tokens.github_user_id, input.githubUserId), + }) + .returning({ id: user_github_app_tokens.id }); + + return { + upserted: Boolean(stored?.id), + githubLogin: input.githubLogin, + }; +} diff --git a/apps/web/src/lib/github-pr-review/mutations.test.ts b/apps/web/src/lib/github-pr-review/mutations.test.ts new file mode 100644 index 0000000000..a3fb88ce68 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/mutations.test.ts @@ -0,0 +1,373 @@ +/** + * @jest-environment node + */ +import { z } from 'zod'; + +import { + AUTO_MERGE_METHODS, + AutoMergeMethodSchema, + CommentPositionSchema, + MERGE_METHODS, + MergeMethodSchema, + REACTION_CONTENTS, + ReactionContentSchema, + REVIEW_EVENTS, + ReviewEventSchema, + ReviewSideSchema, + buildAddReactionVariables, + buildCreateReviewCommentParams, + buildDeleteRefParams, + buildDisableAutoMergeVariables, + buildEnableAutoMergeVariables, + buildMergePullRequestParams, + buildRemoveReactionVariables, + buildReplyToCommentParams, + buildResolveThreadVariables, + buildSubmitReviewParams, + buildUnresolveThreadVariables, + buildUpdateBranchParams, +} from './mutations'; + +describe('GitHub PR review mutation enums', () => { + it('matches the GitHub reaction content enum', () => { + expect([...REACTION_CONTENTS].sort()).toEqual( + ['CONFUSED', 'EYES', 'HEART', 'HOORAY', 'LAUGH', 'ROCKET', 'THUMBS_DOWN', 'THUMBS_UP'].sort() + ); + }); + + it('matches the three review events', () => { + expect([...REVIEW_EVENTS].sort()).toEqual(['APPROVE', 'COMMENT', 'REQUEST_CHANGES']); + }); + + it('matches the three merge methods (lowercase REST casing)', () => { + expect([...MERGE_METHODS].sort()).toEqual(['merge', 'rebase', 'squash']); + }); + + it('matches the three auto-merge methods (uppercase GraphQL casing)', () => { + expect([...AUTO_MERGE_METHODS].sort()).toEqual(['MERGE', 'REBASE', 'SQUASH']); + }); + + it('rejects reaction content outside the enum', () => { + expect(ReactionContentSchema.safeParse('STAR').success).toBe(false); + expect(ReactionContentSchema.safeParse('THUMBS_UP').success).toBe(true); + }); + + it('rejects unsupported review events', () => { + expect(ReviewEventSchema.safeParse('APPROVE').success).toBe(true); + expect(ReviewEventSchema.safeParse('PENDING').success).toBe(false); + }); + + it('rejects unsupported merge methods', () => { + expect(MergeMethodSchema.safeParse('squash').success).toBe(true); + expect(MergeMethodSchema.safeParse('SQUASH').success).toBe(false); + }); + + it('rejects unsupported auto-merge methods', () => { + expect(AutoMergeMethodSchema.safeParse('MERGE').success).toBe(true); + expect(AutoMergeMethodSchema.safeParse('merge').success).toBe(false); + }); + + it('accepts only LEFT/RIGHT for review side', () => { + expect(ReviewSideSchema.safeParse('LEFT').success).toBe(true); + expect(ReviewSideSchema.safeParse('right').success).toBe(false); + }); +}); + +describe('CommentPositionSchema', () => { + it('accepts a single-line position', () => { + const result = CommentPositionSchema.safeParse({ + path: 'src/foo.ts', + line: 10, + side: 'RIGHT', + }); + expect(result.success).toBe(true); + }); + + it('rejects startLine > line for a multi-line position', () => { + const result = CommentPositionSchema.safeParse({ + path: 'src/foo.ts', + line: 5, + side: 'RIGHT', + startLine: 10, + }); + expect(result.success).toBe(false); + }); + + it('accepts a multi-line position with startLine <= line', () => { + const result = CommentPositionSchema.safeParse({ + path: 'src/foo.ts', + line: 20, + side: 'RIGHT', + startLine: 15, + startSide: 'RIGHT', + }); + expect(result.success).toBe(true); + }); + + it('rejects startLine without startSide (partial multi-line range)', () => { + const result = CommentPositionSchema.safeParse({ + path: 'src/foo.ts', + line: 20, + side: 'RIGHT', + startLine: 15, + }); + expect(result.success).toBe(false); + }); + + it('rejects startSide without startLine (partial multi-line range)', () => { + const result = CommentPositionSchema.safeParse({ + path: 'src/foo.ts', + line: 20, + side: 'RIGHT', + startSide: 'RIGHT', + }); + expect(result.success).toBe(false); + }); + + it('enforces the pairing rule through the submitReview batch (.extend) shape', () => { + const batchComment = CommentPositionSchema.extend({ + body: z.string().min(1), + }).strict(); + const partial = batchComment.safeParse({ + path: 'src/foo.ts', + line: 20, + side: 'RIGHT', + startLine: 15, + body: 'x', + }); + expect(partial.success).toBe(false); + const paired = batchComment.safeParse({ + path: 'src/foo.ts', + line: 20, + side: 'RIGHT', + startLine: 15, + startSide: 'RIGHT', + body: 'x', + }); + expect(paired.success).toBe(true); + }); +}); + +describe('buildCreateReviewCommentParams', () => { + it('maps to the REST field names', () => { + const params = buildCreateReviewCommentParams({ + owner: 'octocat', + repo: 'hello', + number: 7, + body: 'nit', + commitSha: '0'.repeat(40), + path: 'src/foo.ts', + line: 12, + side: 'RIGHT', + }); + expect(params).toEqual({ + owner: 'octocat', + repo: 'hello', + pull_number: 7, + body: 'nit', + commit_id: '0'.repeat(40), + path: 'src/foo.ts', + line: 12, + side: 'RIGHT', + }); + }); + + it('forwards multi-line start fields when present', () => { + const params = buildCreateReviewCommentParams({ + owner: 'o', + repo: 'r', + number: 1, + body: 'b', + commitSha: '0'.repeat(40), + path: 'p', + line: 5, + side: 'LEFT', + startLine: 3, + startSide: 'LEFT', + }); + expect(params).toMatchObject({ start_line: 3, start_side: 'LEFT' }); + }); + + it('omits multi-line start fields when not provided', () => { + const params = buildCreateReviewCommentParams({ + owner: 'o', + repo: 'r', + number: 1, + body: 'b', + commitSha: '0'.repeat(40), + path: 'p', + line: 5, + side: 'RIGHT', + }); + expect(params).not.toHaveProperty('start_line'); + expect(params).not.toHaveProperty('start_side'); + }); +}); + +describe('buildReplyToCommentParams', () => { + it('maps to the REST field names', () => { + expect( + buildReplyToCommentParams({ + owner: 'octocat', + repo: 'hello', + number: 7, + commentId: 99, + body: 'thanks', + }) + ).toEqual({ + owner: 'octocat', + repo: 'hello', + pull_number: 7, + comment_id: 99, + body: 'thanks', + }); + }); +}); + +describe('buildSubmitReviewParams', () => { + it('omits body and comments when neither is provided', () => { + const params = buildSubmitReviewParams({ + owner: 'o', + repo: 'r', + number: 1, + event: 'APPROVE', + commitSha: '0'.repeat(40), + }); + expect(params).toEqual({ + owner: 'o', + repo: 'r', + pull_number: 1, + event: 'APPROVE', + commit_id: '0'.repeat(40), + }); + expect(params).not.toHaveProperty('body'); + expect(params).not.toHaveProperty('comments'); + }); + + it('passes a batch of inline comments with multi-line fields when set', () => { + const params = buildSubmitReviewParams({ + owner: 'o', + repo: 'r', + number: 1, + event: 'REQUEST_CHANGES', + body: 'see below', + commitSha: '0'.repeat(40), + comments: [ + { path: 'a.ts', line: 5, side: 'RIGHT', body: 'one' }, + { path: 'b.ts', line: 10, side: 'RIGHT', startLine: 8, startSide: 'RIGHT', body: 'two' }, + ], + }); + expect(params.comments).toHaveLength(2); + expect(params.comments?.[1]).toMatchObject({ start_line: 8, start_side: 'RIGHT' }); + }); +}); + +describe('buildMergePullRequestParams', () => { + it('maps to the REST merge field names', () => { + const params = buildMergePullRequestParams({ + owner: 'o', + repo: 'r', + number: 1, + method: 'squash', + expectedHeadSha: 'a'.repeat(40), + }); + expect(params).toEqual({ + owner: 'o', + repo: 'r', + pull_number: 1, + merge_method: 'squash', + sha: 'a'.repeat(40), + }); + }); + + it('forwards commit title/message when provided', () => { + const params = buildMergePullRequestParams({ + owner: 'o', + repo: 'r', + number: 1, + method: 'merge', + expectedHeadSha: 'a'.repeat(40), + commitTitle: 'Title (#1)', + commitMessage: 'Body', + }); + expect(params).toMatchObject({ commit_title: 'Title (#1)', commit_message: 'Body' }); + }); +}); + +describe('buildDeleteRefParams', () => { + it('prefixes heads/ on the ref', () => { + expect(buildDeleteRefParams({ owner: 'o', repo: 'r', headRef: 'feature/x' })).toEqual({ + owner: 'o', + repo: 'r', + ref: 'heads/feature/x', + }); + }); +}); + +describe('buildUpdateBranchParams', () => { + it('maps to expected_head_sha', () => { + expect( + buildUpdateBranchParams({ + owner: 'o', + repo: 'r', + number: 1, + expectedHeadSha: 'a'.repeat(40), + }) + ).toEqual({ + owner: 'o', + repo: 'r', + pull_number: 1, + expected_head_sha: 'a'.repeat(40), + }); + }); +}); + +describe('GraphQL variable builders', () => { + it('builds enableAutoMerge variables with optional headline/body', () => { + expect( + buildEnableAutoMergeVariables({ + prNodeId: 'PR_1', + method: 'SQUASH', + commitTitle: 'Title', + commitMessage: 'Body', + }) + ).toEqual({ + input: { + pullRequestId: 'PR_1', + mergeMethod: 'SQUASH', + commitHeadline: 'Title', + commitBody: 'Body', + }, + }); + }); + + it('builds enableAutoMerge variables without optional fields', () => { + expect(buildEnableAutoMergeVariables({ prNodeId: 'PR_1', method: 'MERGE' })).toEqual({ + input: { pullRequestId: 'PR_1', mergeMethod: 'MERGE' }, + }); + }); + + it('builds disableAutoMerge variables', () => { + expect(buildDisableAutoMergeVariables({ prNodeId: 'PR_1' })).toEqual({ + input: { pullRequestId: 'PR_1' }, + }); + }); + + it('builds resolve/unresolve thread variables', () => { + expect(buildResolveThreadVariables({ threadId: 'T_1' })).toEqual({ + input: { threadId: 'T_1' }, + }); + expect(buildUnresolveThreadVariables({ threadId: 'T_1' })).toEqual({ + input: { threadId: 'T_1' }, + }); + }); + + it('builds add/remove reaction variables with the comment node id and content', () => { + expect(buildAddReactionVariables({ commentNodeId: 'C_1', content: 'THUMBS_UP' })).toEqual({ + input: { subjectId: 'C_1', content: 'THUMBS_UP' }, + }); + expect(buildRemoveReactionVariables({ commentNodeId: 'C_1', content: 'HEART' })).toEqual({ + input: { subjectId: 'C_1', content: 'HEART' }, + }); + }); +}); diff --git a/apps/web/src/lib/github-pr-review/mutations.ts b/apps/web/src/lib/github-pr-review/mutations.ts new file mode 100644 index 0000000000..ce3fa3ae36 --- /dev/null +++ b/apps/web/src/lib/github-pr-review/mutations.ts @@ -0,0 +1,270 @@ +import 'server-only'; + +import * as z from 'zod'; + +// Single source of truth for the GitHub reaction enum so Zod validation and +// GraphQL variable builders stay aligned with what the GitHub GraphQL API +// actually accepts. GitHub rejects unknown content strings with a 400. +export const REACTION_CONTENTS = [ + 'THUMBS_UP', + 'THUMBS_DOWN', + 'LAUGH', + 'HOORAY', + 'CONFUSED', + 'HEART', + 'ROCKET', + 'EYES', +] as const; +export const ReactionContentSchema = z.enum(REACTION_CONTENTS); +export type ReactionContent = z.infer; + +// `pulls.createReview.event` — the only three legal values for a batch submit. +export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'] as const; +export const ReviewEventSchema = z.enum(REVIEW_EVENTS); +export type ReviewEvent = z.infer; + +// `pulls.merge.merge_method` — the three merge strategies surfaced to the UI. +export const MERGE_METHODS = ['merge', 'squash', 'rebase'] as const; +export const MergeMethodSchema = z.enum(MERGE_METHODS); +export type MergeMethod = z.infer; + +// GitHub's `PullRequestAutoMergeMethod` (GraphQL) — independent of the REST +// `merge_method` casing above, so we keep a separate constant. +export const AUTO_MERGE_METHODS = ['MERGE', 'REBASE', 'SQUASH'] as const; +export const AutoMergeMethodSchema = z.enum(AUTO_MERGE_METHODS); +export type AutoMergeMethod = z.infer; + +// Diff side used by both single-line comments and multi-line review comments. +export const ReviewSideSchema = z.enum(['LEFT', 'RIGHT']); +export type ReviewSide = z.infer; + +// A single position on a diff — used for both an immediate single comment and +// one entry inside the `submitReview` batch. `startLine`/`startSide` are only +// set for multi-line (range) comments. +export const CommentPositionSchema = z + .object({ + path: z.string().min(1).max(1024), + line: z.number().int().positive(), + side: ReviewSideSchema, + startLine: z.number().int().positive().optional(), + startSide: ReviewSideSchema.optional(), + }) + .strict() + .refine(value => value.startLine === undefined || value.startLine <= value.line, { + message: 'startLine must be <= line', + path: ['startLine'], + }) + // A multi-line range requires BOTH startLine and startSide (or neither); + // GitHub rejects a partially specified range. + .refine(value => (value.startLine === undefined) === (value.startSide === undefined), { + message: 'startLine and startSide must be provided together', + path: ['startSide'], + }); +export type CommentPosition = z.infer; + +// Pure input builders — return the typed values the procedures will hand to +// Octokit. They exist so Zod parsing and GraphQL variable construction can be +// unit-tested without spinning up an Octokit instance. + +// `pulls.createReviewComment` body. `commitSha` pins the comment to a specific +// commit, matching what the mobile overview already has on hand. +export type CreateReviewCommentInput = { + owner: string; + repo: string; + number: number; + body: string; + commitSha: string; + path: string; + line: number; + side: ReviewSide; + startLine?: number; + startSide?: ReviewSide; +}; + +export function buildCreateReviewCommentParams(input: CreateReviewCommentInput) { + return { + owner: input.owner, + repo: input.repo, + pull_number: input.number, + body: input.body, + commit_id: input.commitSha, + path: input.path, + line: input.line, + side: input.side, + ...(input.startLine !== undefined ? { start_line: input.startLine } : {}), + ...(input.startSide !== undefined ? { start_side: input.startSide } : {}), + }; +} + +// `pulls.createReplyForReviewComment` body. +export type ReplyToCommentInput = { + owner: string; + repo: string; + number: number; + commentId: number; + body: string; +}; + +export function buildReplyToCommentParams(input: ReplyToCommentInput) { + return { + owner: input.owner, + repo: input.repo, + pull_number: input.number, + comment_id: input.commentId, + body: input.body, + }; +} + +// `pulls.createReview` body. The mobile client queues a small batch of +// comments and submits them in one call together with an event. +export type SubmitReviewInput = { + owner: string; + repo: string; + number: number; + event: ReviewEvent; + body?: string; + commitSha: string; + comments?: ReadonlyArray<{ + path: string; + line: number; + side: ReviewSide; + startLine?: number; + startSide?: ReviewSide; + body: string; + }>; +}; + +export function buildSubmitReviewParams(input: SubmitReviewInput) { + return { + owner: input.owner, + repo: input.repo, + pull_number: input.number, + event: input.event, + commit_id: input.commitSha, + ...(input.body !== undefined ? { body: input.body } : {}), + ...(input.comments && input.comments.length > 0 + ? { + comments: input.comments.map(c => ({ + path: c.path, + line: c.line, + side: c.side, + ...(c.startLine !== undefined ? { start_line: c.startLine } : {}), + ...(c.startSide !== undefined ? { start_side: c.startSide } : {}), + body: c.body, + })), + } + : {}), + }; +} + +// `pulls.merge` body. The client supplies `headRef` + `isCrossRepo` so we +// don't need an extra round-trip to discover whether the head lives in the +// same repo (and therefore whether `git.deleteRef` is permitted). +export type MergePullRequestInput = { + owner: string; + repo: string; + number: number; + method: MergeMethod; + commitTitle?: string; + commitMessage?: string; + deleteBranch: boolean; + expectedHeadSha: string; + headRef: string; + isCrossRepo: boolean; +}; + +// Narrowed view used by the GitHub-API param builder — the merge call only +// needs the fields GitHub accepts; the extra context (`deleteBranch`/`headRef` +// /`isCrossRepo`) is consumed by the procedure after `pulls.merge` succeeds. +export type BuildMergePullRequestParamsInput = { + owner: string; + repo: string; + number: number; + method: MergeMethod; + commitTitle?: string; + commitMessage?: string; + expectedHeadSha: string; +}; + +export function buildMergePullRequestParams(input: BuildMergePullRequestParamsInput) { + return { + owner: input.owner, + repo: input.repo, + pull_number: input.number, + merge_method: input.method, + sha: input.expectedHeadSha, + ...(input.commitTitle !== undefined ? { commit_title: input.commitTitle } : {}), + ...(input.commitMessage !== undefined ? { commit_message: input.commitMessage } : {}), + }; +} + +// `pulls.updateBranch` body. +export type UpdateBranchInput = { + owner: string; + repo: string; + number: number; + expectedHeadSha: string; +}; + +export function buildUpdateBranchParams(input: UpdateBranchInput) { + return { + owner: input.owner, + repo: input.repo, + pull_number: input.number, + expected_head_sha: input.expectedHeadSha, + }; +} + +// `git.deleteRef` body for best-effort branch delete after a successful merge. +export function buildDeleteRefParams(input: { owner: string; repo: string; headRef: string }) { + return { + owner: input.owner, + repo: input.repo, + ref: `heads/${input.headRef}`, + }; +} + +// GraphQL variable builders — return the plain variable object so callers can +// hand it directly to `octokit.request('POST /graphql', { query, ...vars })`. + +export function buildEnableAutoMergeVariables(input: { + prNodeId: string; + method: AutoMergeMethod; + commitTitle?: string; + commitMessage?: string; +}) { + return { + input: { + pullRequestId: input.prNodeId, + mergeMethod: input.method, + ...(input.commitTitle !== undefined ? { commitHeadline: input.commitTitle } : {}), + ...(input.commitMessage !== undefined ? { commitBody: input.commitMessage } : {}), + }, + }; +} + +export function buildDisableAutoMergeVariables(input: { prNodeId: string }) { + return { input: { pullRequestId: input.prNodeId } }; +} + +export function buildResolveThreadVariables(input: { threadId: string }) { + return { input: { threadId: input.threadId } }; +} + +export function buildUnresolveThreadVariables(input: { threadId: string }) { + return { input: { threadId: input.threadId } }; +} + +export function buildAddReactionVariables(input: { + commentNodeId: string; + content: ReactionContent; +}) { + return { input: { subjectId: input.commentNodeId, content: input.content } }; +} + +export function buildRemoveReactionVariables(input: { + commentNodeId: string; + content: ReactionContent; +}) { + return { input: { subjectId: input.commentNodeId, content: input.content } }; +} diff --git a/apps/web/src/lib/integrations/platforms/github/user-authorization.ts b/apps/web/src/lib/integrations/platforms/github/user-authorization.ts index 2584bd6b56..657071c08b 100644 --- a/apps/web/src/lib/integrations/platforms/github/user-authorization.ts +++ b/apps/web/src/lib/integrations/platforms/github/user-authorization.ts @@ -4,17 +4,11 @@ import { Octokit } from '@octokit/rest'; import { captureException } from '@sentry/nextjs'; import { and, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; -import { - USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID, - USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY, -} from '@/lib/config.server'; import { db } from '@/lib/drizzle'; -import { encryptKeyedEnvelope } from '@/lib/encryption'; import { user_github_app_tokens } from '@kilocode/db/schema'; import { getGitHubAppCredentials, type GitHubAppType } from './app-selector'; import { disconnectStoredGitHubUserAuthorization } from './user-authorization-client'; - -const GITHUB_USER_TOKEN_ENVELOPE_SCHEME = 'github-user-token-rsa-aes-256-gcm'; +import { encryptUserGithubTokenEnvelope } from './user-token-envelope'; const GitHubUserTokenResponseSchema = z.object({ access_token: z.string().min(1), @@ -54,25 +48,6 @@ async function withDevelopmentFailureStage( } } -function requireTokenEnvelopePublicKey(): { keyId: string; publicKeyPem: Buffer } { - if (!USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID || !USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY) { - logDevelopmentAuthorizationFailure('missing_token_envelope_public_key'); - throw new Error('GitHub user token envelope encryption is not configured'); - } - return { - keyId: USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID, - publicKeyPem: Buffer.from(USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY, 'base64'), - }; -} - -function tokenEnvelopeAad( - kiloUserId: string, - githubUserId: string, - tokenType: 'access' | 'refresh' -): string { - return `github-user-authorization:v1:${kiloUserId}:standard:${githubUserId}:${tokenType}`; -} - function authorizationGrantLockKey(githubUserId: string): string { return `github-user-authorization:standard:${githubUserId}`; } @@ -190,7 +165,6 @@ export async function exchangeAndStoreGitHubUserAuthorization(input: { if (currentUser.id.toString() !== githubUserId) { throw new Error('GitHub user authorization identity changed during connection'); } - const encryptionKey = requireTokenEnvelopePublicKey(); const now = Date.now(); let values: typeof user_github_app_tokens.$inferInsert; try { @@ -199,19 +173,17 @@ export async function exchangeAndStoreGitHubUserAuthorization(input: { github_app_type: 'standard', github_user_id: githubUserId, github_login: currentUser.login, - access_token_encrypted: encryptKeyedEnvelope( - tokens.access_token, - GITHUB_USER_TOKEN_ENVELOPE_SCHEME, - encryptionKey, - tokenEnvelopeAad(input.kiloUserId, githubUserId, 'access') - ), + access_token_encrypted: encryptUserGithubTokenEnvelope(tokens.access_token, { + kiloUserId: input.kiloUserId, + githubUserId, + tokenType: 'access', + }), access_token_expires_at: new Date(now + tokens.expires_in * 1000).toISOString(), - refresh_token_encrypted: encryptKeyedEnvelope( - tokens.refresh_token, - GITHUB_USER_TOKEN_ENVELOPE_SCHEME, - encryptionKey, - tokenEnvelopeAad(input.kiloUserId, githubUserId, 'refresh') - ), + refresh_token_encrypted: encryptUserGithubTokenEnvelope(tokens.refresh_token, { + kiloUserId: input.kiloUserId, + githubUserId, + tokenType: 'refresh', + }), refresh_token_expires_at: new Date( now + tokens.refresh_token_expires_in * 1000 ).toISOString(), diff --git a/apps/web/src/lib/integrations/platforms/github/user-token-envelope.ts b/apps/web/src/lib/integrations/platforms/github/user-token-envelope.ts new file mode 100644 index 0000000000..caf812d7e8 --- /dev/null +++ b/apps/web/src/lib/integrations/platforms/github/user-token-envelope.ts @@ -0,0 +1,45 @@ +import 'server-only'; + +import { encryptKeyedEnvelope } from '@/lib/encryption'; +import { + USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID, + USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY, +} from '@/lib/config.server'; + +// Single source of truth for the `standard` GitHub user-token envelope: scheme, +// active public key, and AAD. Every caller that stores a `user_github_app_tokens` +// credential (the production OAuth callback and the dev-only E2E seed) MUST use +// this helper so the envelope stays decryptable by git-token-service and the two +// paths cannot drift. Kept in a small dependency-light module so unit tests can +// import it without pulling the full authorization stack. +const GITHUB_USER_TOKEN_ENVELOPE_SCHEME = 'github-user-token-rsa-aes-256-gcm'; + +export function requireTokenEnvelopePublicKey(): { keyId: string; publicKeyPem: Buffer } { + if (!USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID || !USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY) { + throw new Error('GitHub user token envelope encryption is not configured'); + } + return { + keyId: USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID, + publicKeyPem: Buffer.from(USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY, 'base64'), + }; +} + +export function tokenEnvelopeAad( + kiloUserId: string, + githubUserId: string, + tokenType: 'access' | 'refresh' +): string { + return `github-user-authorization:v1:${kiloUserId}:standard:${githubUserId}:${tokenType}`; +} + +export function encryptUserGithubTokenEnvelope( + token: string, + args: { kiloUserId: string; githubUserId: string; tokenType: 'access' | 'refresh' } +): string { + return encryptKeyedEnvelope( + token, + GITHUB_USER_TOKEN_ENVELOPE_SCHEME, + requireTokenEnvelopePublicKey(), + tokenEnvelopeAad(args.kiloUserId, args.githubUserId, args.tokenType) + ); +} diff --git a/apps/web/src/routers/github-apps-router.test.ts b/apps/web/src/routers/github-apps-router.test.ts index b4ba478dd4..87cea3abb6 100644 --- a/apps/web/src/routers/github-apps-router.test.ts +++ b/apps/web/src/routers/github-apps-router.test.ts @@ -29,6 +29,12 @@ const mockFetchGitHubInstallationDetails = jest.fn<(installationId: string, appType: GitHubAppType) => Promise>(); const mockFetchGitHubRepositories = jest.fn<(installationId: string, appType: GitHubAppType) => Promise>(); +const mockSeedUserGithubToken = + jest.fn< + ( + input: Record + ) => Promise<{ upserted: boolean; githubLogin: string }> + >(); jest.mock('@/lib/integrations/github-apps-service', () => ({})); @@ -48,8 +54,17 @@ jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ mockFetchGitHubRepositories(installationId, appType), })); +jest.mock('@/lib/github-pr-review/dev-seed', () => ({ + seedUserGithubToken: (...args: [Record]) => mockSeedUserGithubToken(...args), +})); + let createCaller: (ctx: { user: User }) => { refreshInstallation: (input?: { organizationId?: string }) => Promise<{ success: boolean }>; + devSeedUserGithubToken: (input: { + token: string; + githubLogin: string; + githubUserId: string; + }) => Promise<{ success: boolean; githubLogin: string }>; }; beforeAll(async () => { @@ -108,3 +123,65 @@ describe('githubAppsRouter.refreshInstallation', () => { expect(mockUpdateRepositoriesForIntegration).not.toHaveBeenCalled(); }); }); + +describe('githubAppsRouter.devSeedUserGithubToken', () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + Object.assign(process.env, { NODE_ENV: originalNodeEnv }); + }); + + it('throws FORBIDDEN when NODE_ENV is not development', async () => { + Object.assign(process.env, { NODE_ENV: 'production' }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + await expect( + caller.devSeedUserGithubToken({ + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + expect(mockSeedUserGithubToken).not.toHaveBeenCalled(); + }); + + it('in development, encrypts + upserts the row for ctx.user', async () => { + Object.assign(process.env, { NODE_ENV: 'development' }); + mockSeedUserGithubToken.mockResolvedValueOnce({ upserted: true, githubLogin: 'octocat' }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const result = await caller.devSeedUserGithubToken({ + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + + expect(result).toEqual({ success: true, githubLogin: 'octocat' }); + expect(mockSeedUserGithubToken).toHaveBeenCalledWith({ + kiloUserId: 'user-1', + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + }); + + it('returns success=false when the helper reports no row was upserted', async () => { + Object.assign(process.env, { NODE_ENV: 'development' }); + mockSeedUserGithubToken.mockResolvedValueOnce({ upserted: false, githubLogin: 'octocat' }); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const result = await caller.devSeedUserGithubToken({ + token: 'fake-token', + githubLogin: 'octocat', + githubUserId: '42', + }); + + expect(result.success).toBe(false); + expect(result.githubLogin).toBe('octocat'); + }); +}); diff --git a/apps/web/src/routers/github-apps-router.ts b/apps/web/src/routers/github-apps-router.ts index e3dd922cff..1a40f1d02d 100644 --- a/apps/web/src/routers/github-apps-router.ts +++ b/apps/web/src/routers/github-apps-router.ts @@ -31,6 +31,7 @@ import { disconnectGitHubUserAuthorization, getGitHubUserAuthorizationStatus, } from '@/lib/integrations/platforms/github/user-authorization'; +import { seedUserGithubToken } from '@/lib/github-pr-review/dev-seed'; export const githubAppsRouter = createTRPCRouter({ // List all integrations @@ -368,4 +369,33 @@ export const githubAppsRouter = createTRPCRouter({ return { success: true }; }), + + // Dev-only: seed the user's `user_github_app_tokens` row with a FAKE token + // so the E2E suite can hit the local mock GitHub without a real OAuth + // round-trip. Encryption uses the same public-key envelope the real + // `exchangeAndStoreGitHubUserAuthorization` path uses, so the seeded row is + // decryptable by the production token client. + devSeedUserGithubToken: baseProcedure + .input( + z.object({ + token: z.string().min(1), + githubLogin: z.string().min(1), + githubUserId: z.string().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + if (process.env.NODE_ENV !== 'development') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'This endpoint is only available in development mode', + }); + } + const result = await seedUserGithubToken({ + kiloUserId: ctx.user.id, + token: input.token, + githubLogin: input.githubLogin, + githubUserId: input.githubUserId, + }); + return { success: result.upserted, githubLogin: result.githubLogin }; + }), }); diff --git a/apps/web/src/routers/github-pr-review-router.test.ts b/apps/web/src/routers/github-pr-review-router.test.ts new file mode 100644 index 0000000000..4a147cc585 --- /dev/null +++ b/apps/web/src/routers/github-pr-review-router.test.ts @@ -0,0 +1,312 @@ +/** + * @jest-environment node + */ +import { TRPCError } from '@trpc/server'; +import { createCallerFactory } from '@/lib/trpc/init'; +import type { User } from '@kilocode/db/schema'; + +const getGitHubUserAccessToken = jest.fn(); + +jest.mock('@/lib/integrations/platforms/github/user-token-client', () => ({ + getGitHubUserAccessToken: (...args: unknown[]) => getGitHubUserAccessToken(...args), +})); + +// The retry wrapper invokes `createGitHubPrReviewOctokit(token)` to build the +// Octokit handed to the `call` callback. We mock the factory to capture the +// token and install per-token call/reject behavior, so we can assert that a +// 401 produced a rotate + retry without spinning up a real Octokit. +type OctokitMock = { + __token: string; + pulls: { + merge: jest.Mock; + createReview: jest.Mock; + createReviewComment: jest.Mock; + createReplyForReviewComment: jest.Mock; + updateBranch: jest.Mock; + }; + git: { deleteRef: jest.Mock }; + request: jest.Mock; +}; + +const tokenOctokits = new Map(); + +function buildOctokit(token: string): OctokitMock { + const existing = tokenOctokits.get(token); + if (existing) return existing; + const octokit: OctokitMock = { + __token: token, + pulls: { + merge: jest.fn(), + createReview: jest.fn(), + createReviewComment: jest.fn(), + createReplyForReviewComment: jest.fn(), + updateBranch: jest.fn(), + }, + git: { deleteRef: jest.fn() }, + request: jest.fn(), + }; + tokenOctokits.set(token, octokit); + return octokit; +} + +jest.mock('@/lib/github-pr-review/client', () => ({ + createGitHubPrReviewOctokit: (token: string) => buildOctokit(token), + GITHUB_API_BASE_URL: 'https://api.github.com', +})); + +let createCaller: any; + +beforeAll(async () => { + const mod = await import('./github-pr-review-router'); + createCaller = createCallerFactory(mod.githubPrReviewRouter); +}); + +function connected(token: string, authorizationId: string, credentialVersion: number) { + return { + status: 'connected' as const, + credential: { + token, + expiresAtEpochMs: Date.now() + 3_600_000, + githubLogin: 'octocat', + authorizationId, + credentialVersion, + }, + }; +} + +const baseMergeInput = { + owner: 'octocat', + repo: 'hello', + number: 1, + method: 'squash' as const, + deleteBranch: true, + expectedHeadSha: 'a'.repeat(40), + headRef: 'feature/x', + isCrossRepo: false, +}; + +beforeEach(() => { + jest.clearAllMocks(); + tokenOctokits.clear(); + getGitHubUserAccessToken.mockReset(); +}); + +describe('githubPrReviewRouter.mergePullRequest', () => { + it('skips the branch delete for a cross-repo head even when deleteBranch=true', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const firstOctokit = buildOctokit('t1'); + firstOctokit.pulls.merge.mockResolvedValueOnce({ + data: { merged: true, sha: 'mergedsha', message: 'PR merged' }, + }); + + const result = await caller.mergePullRequest({ ...baseMergeInput, isCrossRepo: true }); + + expect(result).toEqual({ merged: true, sha: 'mergedsha', branchDeleted: false }); + expect(firstOctokit.pulls.merge).toHaveBeenCalledTimes(1); + expect(firstOctokit.git.deleteRef).not.toHaveBeenCalled(); + }); + + it('skips the branch delete when deleteBranch=false', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const firstOctokit = buildOctokit('t1'); + firstOctokit.pulls.merge.mockResolvedValueOnce({ + data: { merged: true, sha: 'mergedsha', message: 'PR merged' }, + }); + + const result = await caller.mergePullRequest({ ...baseMergeInput, deleteBranch: false }); + + expect(result).toEqual({ merged: true, sha: 'mergedsha', branchDeleted: false }); + expect(firstOctokit.git.deleteRef).not.toHaveBeenCalled(); + }); + + it('reports branchDeleted=true on a successful same-repo delete', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const firstOctokit = buildOctokit('t1'); + firstOctokit.pulls.merge.mockResolvedValueOnce({ + data: { merged: true, sha: 'mergedsha', message: 'PR merged' }, + }); + firstOctokit.git.deleteRef.mockResolvedValueOnce({ data: {} }); + + const result = await caller.mergePullRequest({ ...baseMergeInput, isCrossRepo: false }); + + expect(result).toEqual({ merged: true, sha: 'mergedsha', branchDeleted: true }); + expect(firstOctokit.git.deleteRef).toHaveBeenCalledWith({ + owner: 'octocat', + repo: 'hello', + ref: 'heads/feature/x', + }); + }); + + it('reports branchDeleteError but does not throw when deleteRef fails', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const firstOctokit = buildOctokit('t1'); + firstOctokit.pulls.merge.mockResolvedValueOnce({ + data: { merged: true, sha: 'mergedsha', message: 'PR merged' }, + }); + firstOctokit.git.deleteRef.mockRejectedValueOnce( + Object.assign(new Error('Reference does not exist'), { status: 422 }) + ); + + const result = await caller.mergePullRequest({ ...baseMergeInput, isCrossRepo: false }); + + expect(result.merged).toBe(true); + expect(result.branchDeleted).toBe(false); + expect(typeof result.branchDeleteError).toBe('string'); + }); +}); + +describe('githubPrReviewRouter mutations go through withGitHubUserTokenRetry', () => { + it('rotates the credential and retries on a raw 401', async () => { + getGitHubUserAccessToken + .mockResolvedValueOnce(connected('t1', 'auth_1', 1)) + .mockResolvedValueOnce(connected('t2', 'auth_1', 2)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + // Pre-create the two octokits the wrapper will hand to the call callback. + const t1Octokit = buildOctokit('t1'); + const t2Octokit = buildOctokit('t2'); + t1Octokit.pulls.createReviewComment.mockRejectedValueOnce({ status: 401, message: 'gone' }); + t2Octokit.pulls.createReviewComment.mockResolvedValueOnce({ + data: { id: 77, node_id: 'N_77' }, + }); + + const result = await caller.createReviewComment({ + owner: 'octocat', + repo: 'hello', + number: 1, + body: 'hi', + path: 'src/foo.ts', + line: 4, + side: 'RIGHT', + commitSha: '0'.repeat(40), + }); + + expect(result).toEqual({ commentId: 77, nodeId: 'N_77' }); + expect(t1Octokit.pulls.createReviewComment).toHaveBeenCalledTimes(1); + expect(t2Octokit.pulls.createReviewComment).toHaveBeenCalledTimes(1); + // The second call uses the rotated token's octokit, confirming the rotate. + expect(t2Octokit.__token).toBe('t2'); + }); + + it('classifies a non-401 raw error as CONFLICT (e.g. 409 stale head)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.merge.mockRejectedValueOnce({ + status: 409, + message: 'Head branch was modified', + }); + + await expect( + caller.mergePullRequest({ ...baseMergeInput, isCrossRepo: true }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + // Only the initial fetch — no rotate, since the error was not 401. + expect(getGitHubUserAccessToken).toHaveBeenCalledTimes(1); + }); +}); + +// `submitReview` is a representative batch mutation — a quick smoke test that +// the comments[] payload is forwarded verbatim, complementing the builder +// unit tests. +describe('githubPrReviewRouter.submitReview', () => { + it('forwards the comments[] payload to pulls.createReview', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.pulls.createReview.mockResolvedValueOnce({ + data: { id: 99, node_id: 'N_99', state: 'PENDING' }, + }); + + const result = await caller.submitReview({ + owner: 'octocat', + repo: 'hello', + number: 1, + event: 'REQUEST_CHANGES', + body: 'see comments', + commitSha: '0'.repeat(40), + comments: [{ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }], + }); + + expect(result).toEqual({ reviewId: 99, nodeId: 'N_99', state: 'PENDING' }); + expect(t1Octokit.pulls.createReview).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'REQUEST_CHANGES', + commit_id: '0'.repeat(40), + comments: [ + expect.objectContaining({ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }), + ], + }) + ); + }); +}); + +describe('githubPrReviewRouter GraphQL mutations', () => { + it('resolveThread unwraps the { data: { data } } envelope and returns the operation result', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.request.mockResolvedValueOnce({ + data: { data: { resolveReviewThread: { thread: { id: 'THREAD_1', isResolved: true } } } }, + }); + + const result = await caller.resolveThread({ threadId: 'THREAD_1' }); + + expect(result).toEqual({ threadId: 'THREAD_1', isResolved: true }); + }); + + it('throws when GitHub returns a null operation payload (no synthesized success)', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.request.mockResolvedValueOnce({ + data: { data: { resolveReviewThread: null } }, + }); + + await expect(caller.resolveThread({ threadId: 'THREAD_1' })).rejects.toMatchObject({ + code: 'BAD_GATEWAY', + }); + }); + + it('classifies a GraphQL errors[] entry (FORBIDDEN) instead of reporting success', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.request.mockResolvedValueOnce({ + data: { data: null, errors: [{ type: 'FORBIDDEN', message: 'no push access' }] }, + }); + + await expect(caller.resolveThread({ threadId: 'THREAD_1' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + + it('addReaction returns the confirmed content from the GraphQL payload', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + + const t1Octokit = buildOctokit('t1'); + t1Octokit.request.mockResolvedValueOnce({ + data: { data: { addReaction: { reaction: { content: 'HEART' } } } }, + }); + + const result = await caller.addReaction({ commentNodeId: 'C_1', content: 'HEART' }); + expect(result).toEqual({ content: 'HEART' }); + }); +}); + +// Touch the TRPCError import so the linter doesn't strip it (the retry +// wrapper surfaces already-classified TRPCError unchanged). +void TRPCError; diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index 82f676eb96..16b6478d1b 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -20,6 +20,26 @@ import { } from '@/lib/github-pr-review/dtos'; import { throwTrpcFromGraphQlErrors, withGitHubUserTokenRetry } from '@/lib/github-pr-review/retry'; import { getGitHubUserAccessToken } from '@/lib/integrations/platforms/github/user-token-client'; +import { + AutoMergeMethodSchema, + CommentPositionSchema, + MergeMethodSchema, + ReactionContentSchema, + ReviewEventSchema, + ReviewSideSchema, + buildAddReactionVariables, + buildCreateReviewCommentParams, + buildDeleteRefParams, + buildDisableAutoMergeVariables, + buildEnableAutoMergeVariables, + buildMergePullRequestParams, + buildRemoveReactionVariables, + buildReplyToCommentParams, + buildResolveThreadVariables, + buildSubmitReviewParams, + buildUnresolveThreadVariables, + buildUpdateBranchParams, +} from '@/lib/github-pr-review/mutations'; const ownerRepoRegex = /^[A-Za-z0-9_.-]+$/; @@ -62,6 +82,89 @@ const ListReviewThreadsInput = ownerRepoSchema }) .strict(); +const CreateReviewCommentInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + body: z.string().min(1).max(65_535), + path: z.string().min(1).max(1024), + line: z.number().int().positive(), + side: ReviewSideSchema, + startLine: z.number().int().positive().optional(), + startSide: ReviewSideSchema.optional(), + commitSha: z.string().min(40).max(64), + }) + .strict() + .refine(v => v.startLine === undefined || v.startLine <= v.line, { + message: 'startLine must be <= line', + path: ['startLine'], + }) + .refine(v => (v.startLine === undefined) === (v.startSide === undefined), { + message: 'startLine and startSide must be provided together', + path: ['startSide'], + }); + +const ReplyToCommentInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + commentId: z.number().int().positive(), + body: z.string().min(1).max(65_535), + }) + .strict(); + +const SubmitReviewInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + event: ReviewEventSchema, + body: z.string().min(1).max(65_535).optional(), + commitSha: z.string().min(40).max(64), + comments: z + .array(CommentPositionSchema.extend({ body: z.string().min(1).max(65_535) }).strict()) + .max(100) + .optional(), + }) + .strict(); + +const ThreadIdInput = z.object({ threadId: z.string().min(1).max(256) }).strict(); + +const ReactionInput = z + .object({ + commentNodeId: z.string().min(1).max(256), + content: ReactionContentSchema, + }) + .strict(); + +const MergePullRequestInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + method: MergeMethodSchema, + commitTitle: z.string().min(1).max(255).optional(), + commitMessage: z.string().min(1).max(65_535).optional(), + deleteBranch: z.boolean(), + expectedHeadSha: z.string().min(40).max(64), + headRef: z.string().min(1).max(255), + isCrossRepo: z.boolean(), + }) + .strict(); + +const UpdateBranchInput = ownerRepoSchema + .extend({ + number: prNumberSchema, + expectedHeadSha: z.string().min(40).max(64), + }) + .strict(); + +const AutoMergeInput = z + .object({ + owner: z.string().regex(ownerRepoRegex), + repo: z.string().regex(ownerRepoRegex), + number: prNumberSchema, + prNodeId: z.string().min(1).max(256), + method: AutoMergeMethodSchema.optional(), + commitTitle: z.string().min(1).max(255).optional(), + commitMessage: z.string().min(1).max(65_535).optional(), + }) + .strict(); + const PULL_REQUEST_FRAGMENT_QUERY = /* GraphQL */ ` query PrReviewDecision($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { @@ -167,6 +270,68 @@ const REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY = /* GraphQL */ ` } `; +const ENABLE_AUTO_MERGE_MUTATION = /* GraphQL */ ` + mutation EnableAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } + } +`; + +const DISABLE_AUTO_MERGE_MUTATION = /* GraphQL */ ` + mutation DisableAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { + id + } + } + } +`; + +const RESOLVE_THREAD_MUTATION = /* GraphQL */ ` + mutation ResolveThread($input: ResolveReviewThreadInput!) { + resolveReviewThread(input: $input) { + thread { + id + isResolved + } + } + } +`; + +const UNRESOLVE_THREAD_MUTATION = /* GraphQL */ ` + mutation UnresolveThread($input: UnresolveReviewThreadInput!) { + unresolveReviewThread(input: $input) { + thread { + id + isResolved + } + } + } +`; + +const ADD_REACTION_MUTATION = /* GraphQL */ ` + mutation AddReaction($input: AddReactionInput!) { + addReaction(input: $input) { + reaction { + content + } + } + } +`; + +const REMOVE_REACTION_MUTATION = /* GraphQL */ ` + mutation RemoveReaction($input: RemoveReactionInput!) { + removeReaction(input: $input) { + reaction { + content + } + } + } +`; + type GraphQlReactionNode = { content: string; count?: { totalCount: number } | null; @@ -291,6 +456,47 @@ async function fetchReviewThreadsPage(args: { return response.data.data?.repository?.pullRequest?.reviewThreads ?? null; } +// Octokit's `request('POST /graphql', …)` resolves to `{ data: { data, errors } }` +// (the same envelope the read helpers unwrap). Reading `response.data.errors` +// and `response.data.data` — NOT an extra `.data` level. +type GraphQlMutationResponse = { + data: { data: T | null; errors?: unknown }; +}; + +async function runGraphQlMutation(args: { + octokit: ReturnType; + query: string; + variables: Record; +}): Promise { + const { octokit, query, variables } = args; + const response = (await octokit.request('POST /graphql', { + query, + ...variables, + })) as GraphQlMutationResponse; + throwTrpcFromGraphQlErrors(response.data.errors as never); + const payload = response.data.data; + if (payload === null || payload === undefined) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: 'GitHub returned an empty GraphQL response', + }); + } + return payload; +} + +// A GraphQL mutation whose top-level operation field is null (with no errors[]) +// means GitHub did not perform the action — surface a deliberate failure rather +// than reporting a synthesized success. +function requireGraphQlOperation(value: T | null | undefined, operation: string): T { + if (value === null || value === undefined) { + throw new TRPCError({ + code: 'BAD_GATEWAY', + message: `GitHub did not confirm the ${operation} operation`, + }); + } + return value; +} + export const githubPrReviewRouter = createTRPCRouter({ getPullRequest: baseProcedure.input(GetPullRequestInput).query(async ({ ctx, input }) => { const overview = await withGitHubUserTokenRetry({ @@ -458,6 +664,279 @@ export const githubPrReviewRouter = createTRPCRouter({ }, }); }), + + // Post a single immediate review comment (no pending review required). + createReviewComment: baseProcedure + .input(CreateReviewCommentInput) + .mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildCreateReviewCommentParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + body: input.body, + commitSha: input.commitSha, + path: input.path, + line: input.line, + side: input.side, + startLine: input.startLine, + startSide: input.startSide, + }); + const response = await octokit.pulls.createReviewComment(params); + return { + commentId: response.data.id, + nodeId: response.data.node_id, + }; + }, + }); + return result; + }), + + // Reply to an existing review comment (creates a child comment in the + // same thread). + replyToComment: baseProcedure.input(ReplyToCommentInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildReplyToCommentParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + commentId: input.commentId, + body: input.body, + }); + const response = await octokit.pulls.createReplyForReviewComment(params); + return { + commentId: response.data.id, + nodeId: response.data.node_id, + }; + }, + }); + return result; + }), + + // Submit a pending review with an optional batch of inline comments and + // an overall event (APPROVE / REQUEST_CHANGES / COMMENT). + submitReview: baseProcedure.input(SubmitReviewInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildSubmitReviewParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + event: input.event, + body: input.body, + commitSha: input.commitSha, + comments: input.comments, + }); + const response = await octokit.pulls.createReview(params); + return { + reviewId: response.data.id, + nodeId: response.data.node_id, + state: response.data.state, + }; + }, + }); + return result; + }), + + // Resolve a review thread (GraphQL — there is no REST endpoint for this). + resolveThread: baseProcedure.input(ThreadIdInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildResolveThreadVariables({ threadId: input.threadId }); + const payload = await runGraphQlMutation<{ + resolveReviewThread: { thread: { id: string; isResolved: boolean } } | null; + }>({ octokit, query: RESOLVE_THREAD_MUTATION, variables }); + const thread = requireGraphQlOperation( + payload.resolveReviewThread?.thread, + 'resolveReviewThread' + ); + return { threadId: thread.id, isResolved: thread.isResolved }; + }, + }); + return result; + }), + + unresolveThread: baseProcedure.input(ThreadIdInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildUnresolveThreadVariables({ threadId: input.threadId }); + const payload = await runGraphQlMutation<{ + unresolveReviewThread: { thread: { id: string; isResolved: boolean } } | null; + }>({ octokit, query: UNRESOLVE_THREAD_MUTATION, variables }); + const thread = requireGraphQlOperation( + payload.unresolveReviewThread?.thread, + 'unresolveReviewThread' + ); + return { threadId: thread.id, isResolved: thread.isResolved }; + }, + }); + return result; + }), + + addReaction: baseProcedure.input(ReactionInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildAddReactionVariables({ + commentNodeId: input.commentNodeId, + content: input.content, + }); + const payload = await runGraphQlMutation<{ + addReaction: { reaction: { content: string } } | null; + }>({ octokit, query: ADD_REACTION_MUTATION, variables }); + const reaction = requireGraphQlOperation(payload.addReaction?.reaction, 'addReaction'); + return { content: reaction.content }; + }, + }); + return result; + }), + + removeReaction: baseProcedure.input(ReactionInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildRemoveReactionVariables({ + commentNodeId: input.commentNodeId, + content: input.content, + }); + const payload = await runGraphQlMutation<{ + removeReaction: { reaction: { content: string } } | null; + }>({ octokit, query: REMOVE_REACTION_MUTATION, variables }); + const reaction = requireGraphQlOperation( + payload.removeReaction?.reaction, + 'removeReaction' + ); + return { content: reaction.content }; + }, + }); + return result; + }), + + // Merge a pull request. `expectedHeadSha` enforces the optimistic-concurrency + // fence — if the head moved since the mobile overview was rendered, GitHub + // returns 409 and the caller should re-fetch. The branch delete after a + // successful merge is BEST-EFFORT: failures are reported in the result + // (never thrown) so the mobile client can surface a banner. + mergePullRequest: baseProcedure.input(MergePullRequestInput).mutation(async ({ ctx, input }) => { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildMergePullRequestParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + method: input.method, + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + expectedHeadSha: input.expectedHeadSha, + }); + const response = await octokit.pulls.merge(params); + const merged = Boolean(response.data.merged); + if (!merged || !input.deleteBranch || input.isCrossRepo) { + return { + merged, + sha: response.data.sha, + branchDeleted: false as const, + }; + } + // Best-effort: only call deleteRef when the head is same-repo. + // Catch every error and surface it in the result instead of + // failing the whole mutation. + try { + await octokit.git.deleteRef( + buildDeleteRefParams({ + owner: input.owner, + repo: input.repo, + headRef: input.headRef, + }) + ); + return { + merged: true as const, + sha: response.data.sha, + branchDeleted: true as const, + }; + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Branch delete failed'; + return { + merged: true as const, + sha: response.data.sha, + branchDeleted: false as const, + branchDeleteError: message, + }; + } + }, + }); + }), + + // Update a PR's head branch from its base (the "Update branch" button). + // `expectedHeadSha` is the same stale-screen fence as merge; a mismatch + // 422s and the classifier surfaces it as BAD_REQUEST / CONFLICT. + updateBranch: baseProcedure.input(UpdateBranchInput).mutation(async ({ ctx, input }) => { + return withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const params = buildUpdateBranchParams({ + owner: input.owner, + repo: input.repo, + number: input.number, + expectedHeadSha: input.expectedHeadSha, + }); + const response = await octokit.pulls.updateBranch(params); + return { + message: response.data.message, + }; + }, + }); + }), + + enableAutoMerge: baseProcedure.input(AutoMergeInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildEnableAutoMergeVariables({ + prNodeId: input.prNodeId, + method: input.method ?? 'MERGE', + commitTitle: input.commitTitle, + commitMessage: input.commitMessage, + }); + const payload = await runGraphQlMutation<{ + enablePullRequestAutoMerge: { pullRequest: { id: string } } | null; + }>({ octokit, query: ENABLE_AUTO_MERGE_MUTATION, variables }); + const pullRequest = requireGraphQlOperation( + payload.enablePullRequestAutoMerge?.pullRequest, + 'enablePullRequestAutoMerge' + ); + return { enabled: true as const, prNodeId: pullRequest.id }; + }, + }); + return result; + }), + + disableAutoMerge: baseProcedure.input(AutoMergeInput).mutation(async ({ ctx, input }) => { + const result = await withGitHubUserTokenRetry({ + kiloUserId: ctx.user.id, + call: async octokit => { + const variables = buildDisableAutoMergeVariables({ prNodeId: input.prNodeId }); + const payload = await runGraphQlMutation<{ + disablePullRequestAutoMerge: { pullRequest: { id: string } } | null; + }>({ octokit, query: DISABLE_AUTO_MERGE_MUTATION, variables }); + const pullRequest = requireGraphQlOperation( + payload.disablePullRequestAutoMerge?.pullRequest, + 'disablePullRequestAutoMerge' + ); + return { enabled: false as const, prNodeId: pullRequest.id }; + }, + }); + return result; + }), }); // Re-export the disconnected helper used by callers that want to surface a From d127979ac89df27c6c0e681cda8a5b7bb0dbeb26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 02:25:07 +0200 Subject: [PATCH 06/19] feat(mobile): pr overview and checks --- .../pr-review/pr-review-checks-section.tsx | 307 ++++++++++++++++++ .../pr-review/pr-review-discussion-tab.tsx | 32 ++ .../pr-review/pr-review-files-tab.tsx | 47 +++ .../pr-review/pr-review-overview-parts.tsx | 178 ++++++++++ .../pr-review/pr-review-overview.tsx | 208 ++++++++++++ .../components/pr-review/pr-review-screen.tsx | 124 ++++++- .../pr-review/pr-review-tab-selector.tsx | 66 ++++ .../classify-pr-review-query-state.test.ts | 75 +++++ .../classify-pr-review-query-state.ts | 97 ++++++ 9 files changed, 1120 insertions(+), 14 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/pr-review-checks-section.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-files-tab.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-overview.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx create mode 100644 apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts create mode 100644 apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx new file mode 100644 index 0000000000..a27ef690a2 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -0,0 +1,307 @@ +import { useQuery } from '@tanstack/react-query'; +import { + AlertTriangle, + CheckCircle2, + Circle, + ExternalLink, + Loader2, + MinusCircle, + XCircle, +} from 'lucide-react-native'; +import { useMemo } from 'react'; +import { Pressable, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useTRPC } from '@/lib/trpc'; +import { cn } from '@/lib/utils'; +import { openExternalUrl } from '@/lib/external-link'; + +export type PrReviewChecksSectionProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + /** Head SHA to fetch check runs for. */ + readonly headSha: string; +}; + +type CheckRun = { + name: string; + status: string; + conclusion: string | null; + detailsUrl: string | null; + appName: string | null; +}; + +type CheckTone = 'success' | 'failure' | 'pending' | 'skipped' | 'neutral' | 'warning'; + +function classifyCheckTone(status: string, conclusion: string | null): CheckTone { + // GitHub's CheckRun.status: queued | in_progress | completed | pending | waiting | requested. + // conclusion is null unless status === 'completed'. + if (status !== 'completed') { + return 'pending'; + } + switch (conclusion) { + case 'success': { + return 'success'; + } + case 'failure': + case 'startup_failure': { + return 'failure'; + } + case 'skipped': + case 'cancelled': + case 'stale': { + return 'skipped'; + } + case 'timed_out': + case 'action_required': { + return 'warning'; + } + case 'neutral': + case null: { + return 'neutral'; + } + default: { + return 'neutral'; + } + } +} + +const TONE_COLOR: Record> = { + success: 'good', + failure: 'destructive', + pending: 'mutedForeground', + skipped: 'mutedForeground', + neutral: 'mutedForeground', + warning: 'warn', +}; + +const TONE_ICON: Record = { + success: CheckCircle2, + failure: XCircle, + pending: Loader2, + skipped: MinusCircle, + neutral: Circle, + warning: AlertTriangle, +}; + +function CheckRow({ run }: Readonly<{ run: CheckRun }>) { + const colors = useThemeColors(); + const tone = classifyCheckTone(run.status, run.conclusion); + const Icon = TONE_ICON[tone]; + const iconColor = colors[TONE_COLOR[tone]]; + + const subtitle = run.appName ?? ''; + + const body = ( + + + + + {run.name} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {run.detailsUrl ? : null} + + ); + + if (!run.detailsUrl) { + return body; + } + return ( + { + if (run.detailsUrl) { + void openExternalUrl(run.detailsUrl, { label: 'check details' }); + } + }} + accessibilityRole="link" + accessibilityLabel={`Open ${run.name} details`} + > + {body} + + ); +} + +function buildRollupLine(rollup: { + total: number; + success: number; + failure: number; + pending: number; + skipped: number; +}): string { + if (rollup.total === 0) { + return 'No checks reported'; + } + const parts: string[] = []; + if (rollup.success > 0) { + parts.push(`${rollup.success} passed`); + } + if (rollup.failure > 0) { + parts.push(`${rollup.failure} failed`); + } + if (rollup.pending > 0) { + parts.push(`${rollup.pending} pending`); + } + if (rollup.skipped > 0) { + parts.push(`${rollup.skipped} skipped`); + } + return parts.length > 0 ? parts.join(' · ') : `${rollup.total} checks`; +} + +export function PrReviewChecksSection({ + owner, + repo, + number, + headSha, +}: PrReviewChecksSectionProps) { + const trpc = useTRPC(); + const colors = useThemeColors(); + const prUrl = useMemo( + () => `https://github.com/${owner}/${repo}/pull/${number}`, + [owner, repo, number] + ); + + const checks = useQuery( + trpc.githubPrReview.listChecks.queryOptions({ owner, repo, ref: headSha }) + ); + + // Loading (first time, no cached data): show three skeleton rows in a + // card so the section matches the final dimensions once the data lands. + if (checks.isLoading) { + return ( + + + Checks + + + + + + + + ); + } + + if (checks.isError) { + const state = classifyPrReviewQueryState(checks.error); + if (state.kind === 'not-found') { + // Section-level terminal: NOT_FOUND here means the ref has no + // checks endpoint access (rare; usually means the app isn't + // installed on the head repo). No retry — just the message. + return ( + + + Checks + + + + Checks aren't available yet. The head commit may not have been processed, or the + Kilo GitHub App isn't installed on the head repository. + + + + ); + } + if (state.kind === 'permission') { + return ( + + + Checks + + + + You don't have access to checks for this repository. + + + + ); + } + // Retryable (server/offline) AND reconnect: same UI — section-level + // retry button. Reconnect users will need to back out to the gate to + // reconnect, but the retry still has utility (a stale cached check + // may already be present, or the gate may resolve on its own). + return ( + + + Checks + + + Couldn't load checks. + + + + ); + } + + const data = checks.data; + const runList = data?.checkRuns ?? []; + const rollup = data?.rollup ?? { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 }; + const rollupLine = buildRollupLine(rollup); + + if (runList.length === 0) { + return ( + + + Checks + + + {rollupLine} + + + + ); + } + + return ( + + + Checks + + + + + {rollupLine} + + + {runList.map((run, index) => ( + + + {index < runList.length - 1 ? ( + + ) : null} + + ))} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx new file mode 100644 index 0000000000..7f0738e456 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -0,0 +1,32 @@ +import { MessageSquare } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +export type PrReviewDiscussionTabProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; +}; + +/** + * Placeholder body for the Discussion tab. S5 renders a centered + * "Discussion coming soon" so the tab shell can be exercised end-to-end; + * S7b replaces the body with the review-thread list and pending-comment + * summary. + */ +export function PrReviewDiscussionTab({ owner, repo, number }: PrReviewDiscussionTabProps) { + const colors = useThemeColors(); + return ( + + + + Discussion for {owner}/{repo}#{number} coming soon. + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx new file mode 100644 index 0000000000..cdd3c4b16e --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx @@ -0,0 +1,47 @@ +import { FileText } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +export type PrReviewFilesTabProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + /** Head SHA at the time the Overview was loaded — S6b's diff uses this to keep the file list stable. */ + readonly headSha: string; + /** File count from the Overview DTO so S6b can pre-size the list header. */ + readonly changedFiles: number; +}; + +/** + * Placeholder body for the Files tab. S5 only renders a centered + * "Files view coming soon" so the tab shell can be exercised end-to-end; + * S6b replaces the body with the diff list + file navigator mount. + * + * The prop contract is deliberately fixed here so S6b only has to + * implement the body without re-touching `pr-review-screen.tsx`. + */ +export function PrReviewFilesTab({ + owner, + repo, + number, + headSha, + changedFiles, +}: PrReviewFilesTabProps) { + const colors = useThemeColors(); + return ( + + + + Files view for {owner}/{repo}#{number} ({changedFiles} files) coming soon. + + + head {headSha.slice(0, 7)} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx new file mode 100644 index 0000000000..85d80f31e4 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx @@ -0,0 +1,178 @@ +import { + GitBranch, + GitCommit, + GitMerge, + GitPullRequest, + type LucideIcon, + Plus, +} from 'lucide-react-native'; +import { View } from 'react-native'; + +import { Image } from '@/components/ui/image'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; + +export type PrStateChipTone = 'good' | 'warn' | 'muted' | 'destructive'; + +export type PrStateChipDescriptor = { + label: string; + tone: PrStateChipTone; + icon: LucideIcon; +}; + +export function describePrState(args: { + state: 'open' | 'closed' | 'merged'; + draft: boolean; + reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null; +}): PrStateChipDescriptor { + if (args.state === 'merged') { + return { label: 'Merged', tone: 'muted', icon: GitMerge }; + } + if (args.state === 'closed') { + return { label: 'Closed', tone: 'muted', icon: GitPullRequest }; + } + if (args.draft) { + return { label: 'Draft', tone: 'muted', icon: GitPullRequest }; + } + // state === 'open' + if (args.reviewDecision === 'APPROVED') { + return { label: 'Open · Approved', tone: 'good', icon: GitPullRequest }; + } + if (args.reviewDecision === 'CHANGES_REQUESTED') { + return { label: 'Open · Changes requested', tone: 'destructive', icon: GitPullRequest }; + } + if (args.reviewDecision === 'REVIEW_REQUIRED') { + return { label: 'Open · Review required', tone: 'warn', icon: GitPullRequest }; + } + return { label: 'Open', tone: 'muted', icon: GitPullRequest }; +} + +// Theme colors are CSS variables — Tailwind opacity modifiers like +// `bg-good/10` don't work on them. The chip uses a flat muted background +// and lets the foreground color carry the tone so it stays legible in +// both themes without needing per-tone backgrounds. +const TONE_FG_CLASS: Record = { + good: 'text-good', + warn: 'text-warn', + destructive: 'text-destructive', + muted: 'text-muted-foreground', +}; + +export function PrStateChip({ descriptor }: Readonly<{ descriptor: PrStateChipDescriptor }>) { + const Icon = descriptor.icon; + return ( + + + + {descriptor.label} + + + ); +} + +export function PrAuthorRow({ + author, +}: Readonly<{ author: { login: string; avatarUrl: string | null } | null }>) { + if (!author) { + return ( + + + + Unknown author + + + ); + } + return ( + + {author.avatarUrl ? ( + + ) : ( + + )} + + {author.login} + + + ); +} + +export function PrRefsRow({ + baseRef, + headRef, + headRepoFullName, + isCrossRepo, +}: Readonly<{ + baseRef: string; + headRef: string; + headRepoFullName: string | null; + isCrossRepo: boolean; +}>) { + const colors = useThemeColors(); + return ( + + + + {headRepoFullName && isCrossRepo ? `${headRepoFullName}:` : ''} + {headRef} + + + ← + + + {baseRef} + + + ); +} + +export function PrCountsLine({ + commits, + changedFiles, + additions, + deletions, +}: Readonly<{ + commits: number; + changedFiles: number; + additions: number; + deletions: number; +}>) { + const colors = useThemeColors(); + return ( + + + + + {commits.toLocaleString()} {commits === 1 ? 'commit' : 'commits'} + + + + + + {changedFiles.toLocaleString()} {changedFiles === 1 ? 'file' : 'files'} + + + + + {additions.toLocaleString()} + + / −{deletions.toLocaleString()} + + + + ); +} + +function localizeNumber(n: number): string { + return n.toLocaleString(); +} + +export function formatPrCounts(additions: number, deletions: number): string { + return `+${localizeNumber(additions)} / −${localizeNumber(deletions)}`; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx new file mode 100644 index 0000000000..1e9eca4dc3 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -0,0 +1,208 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { GitPullRequest } from 'lucide-react-native'; +import { useCallback } from 'react'; +import { View } from 'react-native'; +import * as WebBrowser from 'expo-web-browser'; + +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { MarkdownText } from '@/components/agents/markdown-text'; +import { PrReviewChecksSection } from '@/components/pr-review/pr-review-checks-section'; +import { + describePrState, + formatPrCounts, + PrAuthorRow, + PrCountsLine, + PrRefsRow, + PrStateChip, +} from '@/components/pr-review/pr-review-overview-parts'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration'; +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { WEB_BASE_URL } from '@/lib/config'; +import { useTRPC } from '@/lib/trpc'; + +export type PrReviewOverviewProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + /** + * True when this tab is the live, visible body in the tab container. + * Reserved for a future focus-driven refetch — the Overview is + * otherwise a self-contained consumer of `getPullRequest` + the + * inner `listChecks` consumer in `PrReviewChecksSection`. + */ + readonly isActive: boolean; +}; + +function OverviewSkeleton() { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function PrReviewOverview({ + owner, + repo, + number, + isActive: _isActive, +}: PrReviewOverviewProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + + const handleReconnect = useCallback(() => { + // A PRECONDITION_FAILED here means the gate's authorization is no + // longer valid even though the gate already passed. Forcing a + // refetch of the gate's query will either (a) flip it to + // disconnected/revoked, in which case the gate renders its own + // empty state with the Connect CTA, or (b) return connected, in + // which case the user can tap Retry to reload the PR. + void queryClient.invalidateQueries({ + queryKey: trpc.githubApps.getUserAuthorization.queryKey(), + }); + }, [queryClient, trpc.githubApps.getUserAuthorization]); + + const handleInstallApp = useCallback(() => { + void WebBrowser.openBrowserAsync(getGitHubIntegrationUrl(WEB_BASE_URL)); + }, []); + + if (pr.isLoading) { + return ; + } + + if (pr.isError) { + const state = classifyPrReviewQueryState(pr.error); + + if (state.kind === 'not-found') { + return ( + + Install the Kilo GitHub App + + } + /> + ); + } + if (state.kind === 'permission') { + // Terminal — no CTA. The user has no recourse from this screen. + return ( + + ); + } + if (state.kind === 'reconnect') { + return ( + + Check connection + + } + /> + ); + } + // retryable + return ( + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + ); + } + + const data = pr.data; + if (!data) { + // Belt-and-suspenders guard for TS — the isLoading + isError branches + // above already cover the runtime cases. If we got here, tanstack is + // reporting neither loading nor error but also has no data (e.g. + // enabled=false with no cached value). Render the skeleton rather + // than dereferencing an undefined DTO. + return ; + } + const chip = describePrState({ + state: data.state, + draft: data.draft, + reviewDecision: data.reviewDecision, + }); + + return ( + + + + + {data.title} + + + + + + + + + Description + + {data.bodyMarkdown && data.bodyMarkdown.trim().length > 0 ? ( + + + + ) : ( + + No description provided. + + )} + + + + + {/* S8 merge section mounts here */} + + + {formatPrCounts(data.counts.additions, data.counts.deletions)} · head{' '} + {data.headSha.slice(0, 7)} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index ee7cbb03fe..33f082337e 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -1,9 +1,17 @@ -import { GitPullRequest } from 'lucide-react-native'; -import { View } from 'react-native'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; +import { RefreshControl, ScrollView, View } from 'react-native'; +import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab'; +import { PrReviewFilesTab } from '@/components/pr-review/pr-review-files-tab'; +import { PrReviewOverview } from '@/components/pr-review/pr-review-overview'; +import { + type PrReviewTabId, + PrReviewTabSelector, +} from '@/components/pr-review/pr-review-tab-selector'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { upsertRecentPr } from '@/lib/pr-review/recent-prs'; +import { useTRPC } from '@/lib/trpc'; type PrReviewScreenProps = { readonly owner: string; @@ -11,20 +19,108 @@ type PrReviewScreenProps = { readonly number: number; }; -// Placeholder for the PR review screen. S5 fleshes this out into the -// Overview / Diff / Discussion / Merge tab surface; S4b only needs the -// route to render *something* so the navigation tree typechecks and the -// gate's happy path can be exercised end-to-end. +/** + * Tab shell for the PR review surface. S5 owns: + * - the tab container API (PrReviewTabSelector + per-tab body slots) + * - the local tab state + * - pull-to-refresh across the Overview + Checks queries + * - the recents title backfill (upsertRecentPr with the real title + * on the first successful `getPullRequest`). + * + * The screen intentionally fetches the PR DTO once and passes the + * `headSha` and `changedFiles` down to the Files tab so the placeholder + * can show useful info and S6b can drop in without a new fetch layer. + * S6b and S7b own the file/diff and discussion bodies respectively; + * S8 owns the merge section that mounts in the slot inside + * `PrReviewOverview`. + */ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { - const colors = useThemeColors(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [tab, setTab] = useState('overview'); + const [refreshing, setRefreshing] = useState(false); + + // The screen owns the PR query so it can drive the recents backfill + // and pass `headSha` / `changedFiles` to the Files tab. The Overview + // re-uses the same query — tanstack-query dedupes by key, so this is + // a single network round-trip even though both components subscribe. + const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number })); + + // Recents title backfill. S4b left the title empty so the recents row + // can be written before the PR loads. Once we have the real title, + // upsert it so the recents list shows it next time. + useEffect(() => { + const data = pr.data; + if (!data?.title) { + return; + } + void upsertRecentPr({ + owner, + repo, + number, + title: data.title, + lastOpenedAt: Date.now(), + }); + }, [pr.data, owner, repo, number]); + + const handleRefresh = useCallback(() => { + void (async () => { + setRefreshing(true); + try { + const headSha = pr.data?.headSha; + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.githubPrReview.getPullRequest.queryKey({ + owner, + repo, + number, + }), + }), + // Only invalidate checks when we know the head SHA; invalidating with + // an empty ref would target a key that never matches the live query. + ...(headSha + ? [ + queryClient.invalidateQueries({ + queryKey: trpc.githubPrReview.listChecks.queryKey({ owner, repo, ref: headSha }), + }), + ] + : []), + ]); + } finally { + setRefreshing(false); + } + })(); + }, [queryClient, trpc, owner, repo, number, pr.data?.headSha]); + + let body: React.ReactNode = null; + if (tab === 'overview') { + body = ; + } else if (tab === 'files') { + body = ( + + ); + } else { + body = ; + } return ( - - - - PR review is loading… - + + } + > + + {body} + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx b/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx new file mode 100644 index 0000000000..f2e9b538d5 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx @@ -0,0 +1,66 @@ +import * as Haptics from 'expo-haptics'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +export type PrReviewTabId = 'overview' | 'files' | 'discussion'; + +type PrReviewTab = { + id: PrReviewTabId; + label: string; +}; + +const TABS: readonly PrReviewTab[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'files', label: 'Files' }, + { id: 'discussion', label: 'Discussion' }, +]; + +type PrReviewTabSelectorProps = { + activeTab: PrReviewTabId; + onChange: (tab: PrReviewTabId) => void; +}; + +/** + * Horizontal pill row at the top of the PR review surface that picks + * between the Overview, Files, and Discussion tabs. S5 owns the API; + * S6b (Files body) and S7b (Discussion body) only ever render their + * respective tab bodies — the parent screen owns the tab state. + */ +export function PrReviewTabSelector({ activeTab, onChange }: PrReviewTabSelectorProps) { + return ( + + {TABS.map(tab => { + const active = tab.id === activeTab; + return ( + { + if (active) { + return; + } + void Haptics.selectionAsync(); + onChange(tab.id); + }} + className={cn( + 'flex-1 items-center justify-center rounded-md py-2 active:opacity-70', + active && 'bg-card shadow-sm shadow-black/5' + )} + > + + {tab.label} + + + ); + })} + + ); +} diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts new file mode 100644 index 0000000000..586637af4e --- /dev/null +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyPrReviewQueryState } from './classify-pr-review-query-state'; + +function makeTrpcError(code: string): unknown { + // Mirrors the shape tRPC v11 surfaces on a thrown TRPCClientError from + // a query: `data.code` is the canonical TRPC_ERROR_CODE_KEY. + return { data: { code } }; +} + +function makeNestedTrpcError(code: string): unknown { + // Some helpers wrap the shape under `shape.data.code`. The classifier + // must accept both forms so a future tRPC upgrade can't silently + // route every error to the retryable branch. + return { shape: { data: { code } } }; +} + +function makeTopLevelTrpcError(code: string): unknown { + return { code }; +} + +describe('classifyPrReviewQueryState', () => { + it('classifies PRECONDITION_FAILED as a reconnect state (no retry, reconnect CTA)', () => { + expect(classifyPrReviewQueryState(makeTrpcError('PRECONDITION_FAILED'))).toEqual({ + kind: 'reconnect', + }); + }); + + it('classifies NOT_FOUND as a not-found empty state (no retry, install-CTA only)', () => { + expect(classifyPrReviewQueryState(makeTrpcError('NOT_FOUND'))).toEqual({ + kind: 'not-found', + }); + }); + + it('classifies FORBIDDEN as a permanent permission error (no CTA, no retry)', () => { + expect(classifyPrReviewQueryState(makeTrpcError('FORBIDDEN'))).toEqual({ + kind: 'permission', + }); + }); + + it('classifies UNAUTHORIZED as a permanent permission error (no CTA, no retry)', () => { + expect(classifyPrReviewQueryState(makeTrpcError('UNAUTHORIZED'))).toEqual({ + kind: 'permission', + }); + }); + + it('reads the code from the nested shape.data.code form too', () => { + expect(classifyPrReviewQueryState(makeNestedTrpcError('NOT_FOUND'))).toEqual({ + kind: 'not-found', + }); + expect(classifyPrReviewQueryState(makeNestedTrpcError('PRECONDITION_FAILED'))).toEqual({ + kind: 'reconnect', + }); + }); + + it('reads the code from a top-level `code` field', () => { + expect(classifyPrReviewQueryState(makeTopLevelTrpcError('FORBIDDEN'))).toEqual({ + kind: 'permission', + }); + }); + + it('falls back to retryable for unknown / non-tRPC errors', () => { + expect(classifyPrReviewQueryState(new Error('network down'))).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewQueryState('string error')).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewQueryState(null)).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewQueryState(undefined)).toEqual({ kind: 'retryable' }); + }); + + it('falls back to retryable for tRPC 5xx-class codes', () => { + expect(classifyPrReviewQueryState(makeTrpcError('INTERNAL_SERVER_ERROR'))).toEqual({ + kind: 'retryable', + }); + expect(classifyPrReviewQueryState(makeTrpcError('TIMEOUT'))).toEqual({ kind: 'retryable' }); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts new file mode 100644 index 0000000000..de1c34d292 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts @@ -0,0 +1,97 @@ +// Pure classification of a tRPC query error for the PR Review Overview / +// Checks surfaces. The PR review surface has a slightly different error +// matrix than the rest of the app: PRECONDITION_FAILED is treated as a +// configuration/connect-state problem (the user must reopen the connect +// flow rather than retry the request), and the App Store rules forbid +// any retryable UI on permanent permission errors. +// +// Lives in `lib/pr-review/` so screens (and their unit tests) can import +// the same decision tree. No React, no react-query, no expo modules — +// that keeps it testable in plain Node. + +export type PrReviewQueryState = + | { + /** + * The fetch failed with a transient error (network, 5xx, etc.). The + * caller should render `QueryError` with a Retry CTA. + */ + kind: 'retryable'; + } + | { + /** + * The fetch failed with a tRPC code the user can't recover from by + * retrying (e.g. they don't have access to this PR). The caller + * should render a terminal permission message with NO CTA. + */ + kind: 'permission'; + } + | { + /** + * The PR / checks were not found. The caller should render the + * "PR unavailable" empty state with a single CTA pointing at the + * Kilo GitHub App install flow, and NO retry. + */ + kind: 'not-found'; + } + | { + /** + * The connect gate's own precondition check failed. The reviewer + * is most likely disconnected or revoked, so the caller should + * surface a reconnect CTA rather than a generic retry — retrying + * the same query without fixing the underlying connection will + * keep failing in the same way. + */ + kind: 'reconnect'; + }; + +/** + * Extracts the tRPC error code from an unknown thrown value. tRPC v11 + * client errors expose `data.code`; server-shaped errors expose + * `shape.data.code`. Anything else is treated as an unknown transient + * error (retryable). + */ +function readTrpcErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + const record = error as Record; + // Direct TRPCClientError — `data` is the shape's data. + const data = record.data; + if (data && typeof data === 'object') { + const code = (data as Record).code; + if (typeof code === 'string') { + return code; + } + } + // Nested `shape` form (some tRPC versions wrap the shape). + const shape = record.shape; + if (shape && typeof shape === 'object') { + const shapeData = (shape as Record).data; + if (shapeData && typeof shapeData === 'object') { + const code = (shapeData as Record).code; + if (typeof code === 'string') { + return code; + } + } + } + // Some helpers expose the code at the top level. + const top = record.code; + if (typeof top === 'string') { + return top; + } + return undefined; +} + +export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState { + const code = readTrpcErrorCode(error); + if (code === 'PRECONDITION_FAILED') { + return { kind: 'reconnect' }; + } + if (code === 'NOT_FOUND') { + return { kind: 'not-found' }; + } + if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') { + return { kind: 'permission' }; + } + return { kind: 'retryable' }; +} From ac221f2bff1f2c00f1d014951a8c9e85a8100b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 02:27:28 +0200 Subject: [PATCH 07/19] chore(mobile): add lowlight --- apps/mobile/package.json | 1 + pnpm-lock.yaml | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a2c59d4f82..9d533c25a6 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -74,6 +74,7 @@ "expo-tracking-transparency": "55.0.15", "expo-web-browser": "55.0.17", "jotai": "2.18.1", + "lowlight": "^3.3.0", "lucide-react-native": "1.7.0", "nativewind": "5.0.0-preview.3", "posthog-react-native": "^4.54.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40db27a332..6b07a84b32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,6 +422,9 @@ importers: jotai: specifier: 2.18.1 version: 2.18.1(@babel/core@7.29.0)(@babel/template@7.29.7)(@types/react@19.2.14)(react@19.2.0) + lowlight: + specifier: ^3.3.0 + version: 3.3.0 lucide-react-native: specifier: 1.7.0 version: 1.7.0(react-native-svg@15.15.3(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -14086,6 +14089,9 @@ packages: lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -26160,7 +26166,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/expect@3.2.4': dependencies: @@ -26238,7 +26244,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/utils@3.2.4': dependencies: @@ -31344,6 +31350,12 @@ snapshots: dependencies: tslib: 2.8.1 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} lru-cache@11.2.7: {} From 375f7a11fd34ae4879db3023abd64343de8e1d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 03:29:57 +0200 Subject: [PATCH 08/19] feat(mobile): pr diff viewer core --- .../components/pr-review/diff/diff-line.tsx | 159 +++++++++ .../pr-review/diff/pr-diff-file-list.tsx | 284 ++++++++++++++++ .../pr-review/diff/pr-diff-file-rows.tsx | 142 ++++++++ .../pr-review/diff/pr-diff-file-status.ts | 43 +++ .../pr-review/diff/pr-diff-hunk-rows.tsx | 252 +++++++++++++++ .../pr-review/diff/pr-diff-rows.tsx | 18 ++ .../pr-review/pr-review-files-tab.tsx | 43 +-- .../components/pr-review/pr-review-screen.tsx | 32 +- .../src/lib/pr-review/diff/highlight.test.ts | 124 +++++++ .../src/lib/pr-review/diff/highlight.ts | 304 ++++++++++++++++++ .../lib/pr-review/diff/parse-patch.test.ts | 280 ++++++++++++++++ .../src/lib/pr-review/diff/parse-patch.ts | 248 ++++++++++++++ .../lib/pr-review/diff/pr-diff-gap-builder.ts | 92 ++++++ .../diff/pr-diff-list-builder.test.ts | 240 ++++++++++++++ .../pr-review/diff/pr-diff-list-builder.ts | 252 +++++++++++++++ .../pr-review/diff/pr-diff-list-items.test.ts | 101 ++++++ .../lib/pr-review/diff/pr-diff-list-items.ts | 273 ++++++++++++++++ .../diff/pr-review-file-list-state.ts | 196 +++++++++++ .../pr-review/diff/pr-review-file-types.ts | 21 ++ .../diff/pr-review-truncation.test.ts | 32 ++ .../pr-review/diff/pr-review-truncation.ts | 18 ++ .../diff/use-pr-diff-context-loader.ts | 107 ++++++ 22 files changed, 3224 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/diff/diff-line.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx create mode 100644 apps/mobile/src/lib/pr-review/diff/highlight.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/highlight.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/parse-patch.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx new file mode 100644 index 0000000000..06bd2fe842 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx @@ -0,0 +1,159 @@ +// A single line of a diff, rendered in JetBrains Mono with syntax +// highlighting, a gutter for old/new line numbers, and a tinted +// background that signals add / del / context. +// +// We render fixed-height rows (height = lineHeight + vertical padding) +// so FlashList can virtualize without measuring each row. The diff +// surface renders thousands of lines and remeasuring on every scroll +// frame would destroy scroll perf on mid-tier Android devices. + +import { memo, useMemo } from 'react'; +import { Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; + +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight'; +import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; +import { cn } from '@/lib/utils'; + +const LINE_HEIGHT = 18; +const VERTICAL_PADDING = 2; +const GUTTER_WIDTH = 56; +const NO_NEWLINE_INDICATOR = '\u26A0\uFE0F no newline at end of file'; + +const ROW_MIN_HEIGHT = LINE_HEIGHT + VERTICAL_PADDING * 2; +const ROW_STYLE: ViewStyle = { minHeight: ROW_MIN_HEIGHT }; +const GUTTER_STYLE: ViewStyle = { + width: GUTTER_WIDTH, + height: ROW_MIN_HEIGHT, +}; +const CODE_CONTAINER_STYLE: ViewStyle = { paddingVertical: VERTICAL_PADDING }; +const CODE_BASE_STYLE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 12, + lineHeight: LINE_HEIGHT, +}; +const GUTTER_TEXT_BASE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 11, + lineHeight: LINE_HEIGHT, +}; +const NO_NEWLINE_BASE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 11, + lineHeight: LINE_HEIGHT, +}; + +const TOKEN_DARK_LIGHT: Record = { + keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, + builtin: { light: '#1F6FEB', dark: '#79B8FF' }, + literal: { light: '#7B2CBF', dark: '#D8B4FE' }, + number: { light: '#B27214', dark: '#F2B05F' }, + string: { light: '#278150', dark: '#5FCB8E' }, + comment: { light: '#6F6A61', dark: '#8A8680' }, + type: { light: '#1F6FEB', dark: '#79B8FF' }, + function: { light: '#1F6FEB', dark: '#79B8FF' }, + variable: { light: '#14130F', dark: '#F2F0EB' }, + property: { light: '#1F6FEB', dark: '#79B8FF' }, + tag: { light: '#BE4E3F', dark: '#F28B7A' }, + selector: { light: '#7B2CBF', dark: '#D8B4FE' }, + attribute: { light: '#1F6FEB', dark: '#79B8FF' }, + operator: { light: '#6F6A61', dark: '#8A8680' }, + meta: { light: '#6F6A61', dark: '#8A8680' }, + add: { light: '#278150', dark: '#5FCB8E' }, + del: { light: '#BE4E3F', dark: '#F28B7A' }, +}; + +const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; +const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; + +export type DiffLineProps = { + line: ParsedDiffLine; + language: string | null; + keyId: string; +}; + +function gutterTextFor(line: ParsedDiffLine): string { + if (line.type === 'add') { + return `${line.newLine ?? ''}`; + } + if (line.type === 'del') { + return `${line.oldLine ?? ''}`; + } + return `${line.oldLine ?? line.newLine ?? ''}`; +} + +function rowBackgroundFor(type: ParsedDiffLine['type']): string { + if (type === 'add') { + return 'bg-good-tile-bg'; + } + if (type === 'del') { + return 'bg-danger-tile-bg'; + } + return 'bg-transparent'; +} + +function tokenColorFor(className: string | null, isDark: boolean): string { + if (!className) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + const palette = TOKEN_DARK_LIGHT[className]; + if (!palette) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + return isDark ? palette.dark : palette.light; +} + +function DiffLineImpl({ line, language }: Readonly) { + const colors = useThemeColors(); + const isDark = colors.background === '#0E0E10'; + + const tokens = useMemo( + () => highlightLine(line.text, language), + [language, line.text] + ); + + const gutterText = gutterTextFor(line); + const rowBackground = rowBackgroundFor(line.type); + const gutterColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light; + const noNewlineColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light; + const noNewlineLabel = ` ${NO_NEWLINE_INDICATOR}`; + + return ( + + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for gutter */} + + {gutterText} + + + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for code */} + + {tokens.map((token, index) => { + const tokenColor = tokenColorFor(token.className, isDark); + return ( + // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- per-token syntax color + + {token.text} + + ); + })} + {line.noNewlineAtEndOfFile ? ( + // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic muted color for no-newline marker + {noNewlineLabel} + ) : null} + + + + ); +} + +export const DiffLine = memo( + DiffLineImpl, + (prev, next) => + prev.keyId === next.keyId && prev.language === next.language && prev.line === next.line +); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx new file mode 100644 index 0000000000..5197f3660f --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -0,0 +1,284 @@ +// PR diff viewer core: the file list that the orchestrator drops into +// `pr-review-files-tab.tsx` (replacing the S5 placeholder body). +// +// Architecture: +// * A single FlashList with mixed item kinds (see `pr-diff-list-items`) +// * `usePrReviewFileListQuery` drives a tRPC infinite query for `listFiles` +// * `usePrReviewViewedFiles` reads + toggles the per-PR viewed set +// * `useFetchToCompletion` lets S6c's navigator drive the query to its end +// * `subscribeFileNavigatorRequest` is consumed here so a "scroll to file" +// request (emitted by S6c) snaps the list to the right section + +import { FlashList, type FlashListRef } from '@shopify/flash-list'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { View } from 'react-native'; + +import { QueryError } from '@/components/query-error'; +import { DiffLine } from '@/components/pr-review/diff/diff-line'; +import { + EmptyFilesView, + ExpandSeparatorRow, + FileHeaderRow, + HunkHeaderRow, + LIST_CONTENT_STYLE, + PaginationRow, + PatchMissingRow, + TabStateMessage, + TruncationBannerRow, +} from '@/components/pr-review/diff/pr-diff-rows'; +import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; +import { fileHeaderKey, itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader'; +import { + type PrReviewFile, + useFetchToCompletion, + usePrReviewFileListQuery, + usePrReviewViewedFiles, +} from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { + type FileNavigatorRequest, + subscribeFileNavigatorRequest, +} from '@/lib/pr-review/file-navigator-bridge'; + +export type PrReviewFileListProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly headSha: string; + readonly changedFiles: number; + /** Optional callback for the 0-changed-files empty state. */ + readonly onRequestOverview?: () => void; +}; + +export function PrReviewFileList({ + owner, + repo, + number, + headSha, + changedFiles, + onRequestOverview, +}: PrReviewFileListProps) { + const listRef = useRef>(null); + + const { query, firstPageErrorState } = usePrReviewFileListQuery({ + owner, + repo, + number, + enabled: true, + }); + const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha); + const fetchToCompletion = useFetchToCompletion(query, changedFiles); + + const [expanded, setExpanded] = useState>({}); + const { expandedContext, handleLoadContext } = usePrDiffContextLoader({ + owner, + repo, + headSha, + }); + + const files = useMemo(() => { + const all: PrReviewFile[] = []; + for (const page of query.data?.pages ?? []) { + for (const f of page.files) { + all.push(f); + } + } + return all; + }, [query.data]); + + const items = useMemo( + () => + buildItems({ + files, + expanded, + expandedContext, + viewed: viewed.isViewed, + headSha, + owner, + repo, + number, + changedFiles, + isLoading: query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage, + laterPageError: query.isError && files.length > 0, + fetchToCompletionRunning: fetchToCompletion.isRunning, + fetchToCompletionLoaded: fetchToCompletion.loadedFiles, + totalFiles: changedFiles, + }), + [ + files, + expanded, + expandedContext, + viewed, + headSha, + owner, + repo, + number, + changedFiles, + query.isLoading, + query.isFetchingNextPage, + query.hasNextPage, + query.isError, + fetchToCompletion.isRunning, + fetchToCompletion.loadedFiles, + ] + ); + + const indexByKey = useMemo(() => { + const map = new Map(); + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + if (item) { + map.set(item.key, index); + } + } + return map; + }, [items]); + const indexByKeyRef = useRef(indexByKey); + indexByKeyRef.current = indexByKey; + + useEffect(() => { + const unsubscribe = subscribeFileNavigatorRequest( + { owner, repo, number }, + (request: FileNavigatorRequest) => { + const targetKey = fileHeaderKey(request.path); + const index = indexByKeyRef.current.get(targetKey); + if (typeof index === 'number' && index !== -1) { + setExpanded(prev => (prev[request.path] ? prev : { ...prev, [request.path]: true })); + void listRef.current?.scrollToIndex({ index, animated: true, viewPosition: 0 }); + } + } + ); + return unsubscribe; + }, [owner, repo, number]); + + const renderItem = useCallback( + ({ item }: { item: ListItem }) => { + switch (item.kind) { + case 'truncation-banner': { + return ; + } + case 'file-header': { + return ( + { + setExpanded(prev => ({ ...prev, [item.file.path]: !prev[item.file.path] })); + }} + onToggleViewed={() => { + void viewed.toggle(item.file.path); + }} + /> + ); + } + case 'file-patch-missing': { + return ( + { + void viewed.toggle(item.file.path); + }} + /> + ); + } + case 'hunk-header': { + return ; + } + case 'diff-line': { + return ; + } + case 'expand-separator': { + return ( + { + handleLoadContext(item, windowSize); + }} + /> + ); + } + case 'pagination-row': { + return ( + { + void query.fetchNextPage(); + }} + onFetchAll={() => { + void fetchToCompletion.run(); + }} + /> + ); + } + default: { + return null; + } + } + }, + [viewed, query, fetchToCompletion, handleLoadContext] + ); + + if (files.length === 0) { + if (firstPageErrorState?.kind === 'not-found') { + return ( + + ); + } + if (firstPageErrorState?.kind === 'permission') { + return ( + + ); + } + if (firstPageErrorState?.kind === 'retryable' || firstPageErrorState?.kind === 'reconnect') { + return ( + + { + void query.refetch(); + }} + isRetrying={query.isFetching} + /> + + ); + } + } + + if (!query.isLoading && files.length === 0) { + return ; + } + + return ( + + item.key} + getItemType={item => itemTypeFor(item)} + onEndReached={() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + void query.fetchNextPage(); + } + }} + onEndReachedThreshold={0.5} + contentContainerStyle={LIST_CONTENT_STYLE} + ItemSeparatorComponent={null} + /> + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx new file mode 100644 index 0000000000..df1158c68a --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx @@ -0,0 +1,142 @@ +// File-level row components for the PR diff FlashList. + +import { ChevronDown, ChevronRight, File, Link2 } from 'lucide-react-native'; +import { Pressable, View } from 'react-native'; + +import { fileStatusIcon, fileStatusLabel } from '@/components/pr-review/diff/pr-diff-file-status'; +import { ChoiceRow } from '@/components/ui/choice-row'; +import { Text } from '@/components/ui/text'; +import { openExternalUrl } from '@/lib/external-link'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-list-state'; + +function ExpandChevron({ hasDiff, expanded }: { hasDiff: boolean; expanded: boolean }) { + const colors = useThemeColors(); + if (!hasDiff) { + return ; + } + if (expanded) { + return ; + } + return ; +} + +export function TruncationBannerRow({ text }: { text: string }) { + return ( + + {text} + + ); +} + +export function FileHeaderRow({ + file, + expanded, + hasDiff, + viewed, + onToggleExpand, + onToggleViewed, +}: { + file: PrReviewFile; + expanded: boolean; + hasDiff: boolean; + viewed: boolean; + onToggleExpand: () => void; + onToggleViewed: () => void; +}) { + const colors = useThemeColors(); + const StatusIcon = fileStatusIcon(file.status); + const isRename = Boolean(file.previousPath) && file.previousPath !== file.path; + const pathLine = isRename ? `${file.previousPath} → ${file.path}` : file.path; + + return ( + + + + + + + + + {pathLine} + + + + {fileStatusLabel(file.status)} + + + +{file.additions} + + + -{file.deletions} + + + + + + + {viewed ? 'Viewed' : 'Mark viewed'} + + + + + + ); +} + +export function PatchMissingRow({ + file, + viewed, + githubUrl, + onToggleViewed, +}: { + file: PrReviewFile; + viewed: boolean; + githubUrl: string; + onToggleViewed: () => void; +}) { + const colors = useThemeColors(); + return ( + + + + + Diff too large to display + + {file.path} + + + + + + {viewed ? 'Viewed' : 'Mark viewed'} + + + + + { + if (githubUrl) { + void openExternalUrl(githubUrl, { label: 'GitHub diff' }); + } + }} + accessibilityRole="link" + accessibilityLabel="Open this file's diff on GitHub" + className="mt-2 flex-row items-center gap-1.5 self-start" + > + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */} + + Open on GitHub + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts b/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts new file mode 100644 index 0000000000..d1c7c433fd --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts @@ -0,0 +1,43 @@ +// Shared status helpers for PR file rows. + +import { File, FileMinus, FilePlus } from 'lucide-react-native'; + +export function fileStatusLabel(status: string): string { + switch (status) { + case 'added': { + return 'Added'; + } + case 'removed': { + return 'Deleted'; + } + case 'modified': { + return 'Modified'; + } + case 'renamed': { + return 'Renamed'; + } + case 'copied': { + return 'Copied'; + } + case 'changed': { + return 'Changed'; + } + default: { + return status; + } + } +} + +export function fileStatusIcon(status: string) { + switch (status) { + case 'added': { + return FilePlus; + } + case 'removed': { + return FileMinus; + } + default: { + return File; + } + } +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx new file mode 100644 index 0000000000..57bd761501 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx @@ -0,0 +1,252 @@ +// Hunk / expand / pagination / empty-state rows for the PR diff FlashList. + +import { Check, ChevronDown, File, GitCommit, X } from 'lucide-react-native'; +import { Pressable, View, type ViewStyle } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type ExpandSeparatorItem } from '@/lib/pr-review/diff/pr-diff-list-items'; + +export const DEFAULT_EXPAND_WINDOW = 20; +export const EXPAND_ALL_MAX = 100; + +// Module-level style constants so FlashList content containers avoid +// recreating object literals (and so no-inline-styles is satisfied). +export const LIST_CONTENT_STYLE: ViewStyle = { paddingBottom: 24 }; + +export function HunkHeaderRow({ header }: { header: string }) { + const colors = useThemeColors(); + return ( + + + {header} + + + ); +} + +export function ExpandSeparatorRow({ + item, + onLoad, +}: { + item: ExpandSeparatorItem; + onLoad: (windowSize: number) => void; +}) { + const colors = useThemeColors(); + const { startLine, endLine } = item.context; + const isUnknownEnd = !Number.isFinite(endLine); + const gapSize = isUnknownEnd ? DEFAULT_EXPAND_WINDOW : endLine - startLine + 1; + const canExpandAll = !isUnknownEnd && gapSize <= EXPAND_ALL_MAX; + const isPartial = item.state === 'partial'; + + if (item.state === 'unavailable') { + return ( + + + + Context unavailable at this ref + + + ); + } + + if (item.state === 'error') { + return ( + { + onLoad(DEFAULT_EXPAND_WINDOW); + }} + className="flex-row items-center justify-center gap-2 border-y border-hair-soft bg-secondary px-4 py-2 active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Retry loading context" + > + + Failed to load context — tap to retry + + + ); + } + + if (item.state === 'loading') { + return ( + + + + {isUnknownEnd + ? 'Loading context…' + : `Loading ${Math.min(gapSize, DEFAULT_EXPAND_WINDOW)} of ${gapSize} lines…`} + + + ); + } + + const windowEnd = isUnknownEnd + ? startLine + DEFAULT_EXPAND_WINDOW - 1 + : Math.min(startLine + DEFAULT_EXPAND_WINDOW - 1, endLine); + const expandLabel = isPartial ? 'Expand more' : 'Expand'; + + return ( + + { + onLoad(DEFAULT_EXPAND_WINDOW); + }} + className="flex-row items-center gap-1 active:opacity-70" + accessibilityRole="button" + accessibilityLabel={ + isUnknownEnd ? 'Expand context' : `Expand ${DEFAULT_EXPAND_WINDOW} lines of context` + } + > + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */} + + {isUnknownEnd + ? `${expandLabel} context` + : `${expandLabel} ${Math.min(gapSize, DEFAULT_EXPAND_WINDOW)} lines (${startLine}–${windowEnd})`} + + + {canExpandAll ? ( + { + onLoad(gapSize); + }} + className="ml-3 flex-row items-center gap-1 active:opacity-70" + accessibilityRole="button" + accessibilityLabel={`Expand all ${gapSize} lines`} + > + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */} + + Expand all + + + ) : null} + + ); +} + +export function PaginationRow({ + state, + loadedFiles, + totalFiles, + onRetry, + onFetchAll, +}: { + state: 'loading' | 'error' | 'fetch-to-completion' | 'all-loaded' | 'no-pages'; + loadedFiles: number; + totalFiles: number | null; + onRetry: () => void; + onFetchAll: () => void; +}) { + const colors = useThemeColors(); + if (state === 'loading') { + return ( + + + Loading more files… + + + ); + } + if (state === 'error') { + return ( + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic destructive color */} + + Failed to load more files — tap to retry + + + ); + } + if (state === 'fetch-to-completion') { + return ( + + + Loading all files — {loadedFiles.toLocaleString()} + {totalFiles ? ` of ${totalFiles.toLocaleString()}` : ''}… + + + ); + } + if (state === 'no-pages') { + return ( + + + {loadedFiles.toLocaleString()} of {totalFiles?.toLocaleString() ?? '?'} files loaded + + + Load all + + + ); + } + return ( + + + + {loadedFiles.toLocaleString()} file{loadedFiles === 1 ? '' : 's'} loaded + {totalFiles ? ` of ${totalFiles.toLocaleString()}` : ''} + + + ); +} + +export function TabStateMessage({ title, message }: { title: string; message: string }) { + return ( + + {title} + + {message} + + + ); +} + +export function EmptyFilesView({ + changedFiles, + onRequestOverview, +}: { + changedFiles: number; + onRequestOverview?: () => void; +}) { + const colors = useThemeColors(); + return ( + + + No files changed + + {changedFiles === 0 + ? 'This pull request has no file changes.' + : 'Files are still loading. Pull to refresh.'} + + {onRequestOverview ? ( + + Go to Overview + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx new file mode 100644 index 0000000000..b296b697e1 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx @@ -0,0 +1,18 @@ +// Re-export row components for the PR diff FlashList. Split across +// sibling modules so each stays under the max-lines limit. + +export { + FileHeaderRow, + PatchMissingRow, + TruncationBannerRow, +} from '@/components/pr-review/diff/pr-diff-file-rows'; +export { + DEFAULT_EXPAND_WINDOW, + EmptyFilesView, + EXPAND_ALL_MAX, + ExpandSeparatorRow, + HunkHeaderRow, + LIST_CONTENT_STYLE, + PaginationRow, + TabStateMessage, +} from '@/components/pr-review/diff/pr-diff-hunk-rows'; diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx index cdd3c4b16e..dcaca12053 100644 --- a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx @@ -1,26 +1,21 @@ -import { FileText } from 'lucide-react-native'; -import { View } from 'react-native'; - -import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { PrReviewFileList } from '@/components/pr-review/diff/pr-diff-file-list'; export type PrReviewFilesTabProps = { readonly owner: string; readonly repo: string; readonly number: number; - /** Head SHA at the time the Overview was loaded — S6b's diff uses this to keep the file list stable. */ + /** Head SHA at the time the Overview was loaded — keeps the file list stable. */ readonly headSha: string; - /** File count from the Overview DTO so S6b can pre-size the list header. */ + /** File count from the Overview DTO for the >3,000-file truncation banner. */ readonly changedFiles: number; + /** Invoked by the empty state ("0 changed files") to jump back to Overview. */ + readonly onRequestOverview?: () => void; }; /** - * Placeholder body for the Files tab. S5 only renders a centered - * "Files view coming soon" so the tab shell can be exercised end-to-end; - * S6b replaces the body with the diff list + file navigator mount. - * - * The prop contract is deliberately fixed here so S6b only has to - * implement the body without re-touching `pr-review-screen.tsx`. + * Files tab: hosts the S6b diff file list (a virtualized FlashList, so the + * screen renders this outside its Overview ScrollView). S6c layers the file + * navigator sheet and the tablet unified/side-by-side toggle on top of this. */ export function PrReviewFilesTab({ owner, @@ -28,20 +23,16 @@ export function PrReviewFilesTab({ number, headSha, changedFiles, + onRequestOverview, }: PrReviewFilesTabProps) { - const colors = useThemeColors(); return ( - - - - Files view for {owner}/{repo}#{number} ({changedFiles} files) coming soon. - - - head {headSha.slice(0, 7)} - - + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 33f082337e..0d0b85dfed 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useCallback, useEffect, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useState } from 'react'; import { RefreshControl, ScrollView, View } from 'react-native'; import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab'; @@ -92,9 +92,21 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { })(); }, [queryClient, trpc, owner, repo, number, pr.data?.headSha]); - let body: React.ReactNode = null; + // Each tab owns its own scroll: Overview is a ScrollView with + // pull-to-refresh; the Files tab hosts a virtualized FlashList and must + // NOT be nested inside a ScrollView. + let body: ReactNode = null; if (tab === 'overview') { - body = ; + body = ( + } + > + + + ); } else if (tab === 'files') { body = ( { + setTab('overview'); + }} /> ); } else { @@ -112,15 +127,10 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { return ( - } - > + - {body} - + + {body} ); } diff --git a/apps/mobile/src/lib/pr-review/diff/highlight.test.ts b/apps/mobile/src/lib/pr-review/diff/highlight.test.ts new file mode 100644 index 0000000000..4794d6a572 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/highlight.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + clearHighlightCacheForTests, + highlightLine, + type HighlightToken, + languageForPath, +} from './highlight'; + +function classes(tokens: HighlightToken[]): (string | null)[] { + return tokens.map(t => t.className); +} + +function texts(tokens: HighlightToken[]): string[] { + return tokens.map(t => t.text); +} + +describe('languageForPath', () => { + it('maps common file extensions to languages', () => { + expect(languageForPath('src/index.ts')).toBe('typescript'); + expect(languageForPath('src/component.tsx')).toBe('typescript'); + expect(languageForPath('script.js')).toBe('javascript'); + expect(languageForPath('main.py')).toBe('python'); + expect(languageForPath('main.go')).toBe('go'); + expect(languageForPath('lib.rs')).toBe('rust'); + expect(languageForPath('app.rb')).toBe('ruby'); + expect(languageForPath('config.json')).toBe('json'); + expect(languageForPath('README.md')).toBe('markdown'); + expect(languageForPath('run.sh')).toBe('bash'); + expect(languageForPath('style.css')).toBe('css'); + expect(languageForPath('index.html')).toBe('xml'); + expect(languageForPath('ci.yml')).toBe('yaml'); + expect(languageForPath('Cargo.toml')).toBe('ini'); + }); + + it('uses the last extension for compound names like foo.test.ts', () => { + expect(languageForPath('src/foo.test.ts')).toBe('typescript'); + expect(languageForPath('src/component.test.tsx')).toBe('typescript'); + }); + + it('is case-insensitive on the extension', () => { + expect(languageForPath('Script.PY')).toBe('python'); + expect(languageForPath('App.TS')).toBe('typescript'); + }); + + it('returns null for unknown extensions and dotfiles', () => { + expect(languageForPath('README')).toBeNull(); + expect(languageForPath('.gitignore')).toBeNull(); + expect(languageForPath('foo.unknown')).toBeNull(); + }); + + it('returns null for null / undefined input', () => { + expect(languageForPath(null)).toBeNull(); + expect(languageForPath(undefined)).toBeNull(); + }); + + it('handles a path with a dot in a directory segment', () => { + expect(languageForPath('packages/foo.app/index.ts')).toBe('typescript'); + }); +}); + +describe('highlightLine', () => { + beforeEach(() => { + clearHighlightCacheForTests(); + }); + + it('returns a single plain-text token for an unknown language', () => { + const tokens = highlightLine('hello world', null); + expect(tokens).toEqual([{ text: 'hello world', className: null }]); + }); + + it('returns plain text for an empty line', () => { + const tokens = highlightLine('', 'typescript'); + expect(texts(tokens).join('')).toBe(''); + }); + + it('tags a TypeScript const declaration with the keyword + identifier classes', () => { + const tokens = highlightLine('const x = 1;', 'typescript'); + // We don't pin the exact class split (highlight.js may emit + // different sub-tokens across versions), but the keyword class + // must be present. + expect(classes(tokens)).toContain('keyword'); + // And the entire line text must be preserved (no token dropped). + expect(texts(tokens).join('')).toBe('const x = 1;'); + }); + + it('tags a string literal as string in TypeScript', () => { + const tokens = highlightLine('const greeting = "hello";', 'typescript'); + expect(classes(tokens)).toContain('string'); + expect(texts(tokens).join('')).toBe('const greeting = "hello";'); + }); + + it('tags a single-line comment as comment in TypeScript', () => { + const tokens = highlightLine('// hello', 'typescript'); + expect(classes(tokens)).toContain('comment'); + expect(texts(tokens).join('')).toBe('// hello'); + }); + + it('re-runs the same line through the cache and returns the same tokens', () => { + const a = highlightLine('const x = 1;', 'typescript'); + const b = highlightLine('const x = 1;', 'typescript'); + // Reference equality is not required, but the tokenized output + // should match exactly so the UI doesn't see visual diffs. + expect(b).toEqual(a); + }); + + it('falls back to plain text when the highlighter throws', () => { + // unknown language — lowlight throws and we recover. + const tokens = highlightLine('hello world', 'not-a-real-language'); + expect(tokens).toEqual([{ text: 'hello world', className: null }]); + }); + + it('preserves the full line text across tokens (no characters dropped)', () => { + const line = 'function add(a: number, b: number): number { return a + b; }'; + const tokens = highlightLine(line, 'typescript'); + expect(texts(tokens).join('')).toBe(line); + }); + + it('handles JSON keys + string values', () => { + const tokens = highlightLine('"name": "kilo"', 'json'); + expect(classes(tokens)).toContain('string'); + expect(texts(tokens).join('')).toBe('"name": "kilo"'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/highlight.ts b/apps/mobile/src/lib/pr-review/diff/highlight.ts new file mode 100644 index 0000000000..21beb47fd4 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/highlight.ts @@ -0,0 +1,304 @@ +// Per-line syntax highlighting for diff lines, using `lowlight` +// (highlight.js wrapped to return a hast tree instead of mutating the +// DOM). +// +// The accepted v1 ceiling is per-line highlighting: each line of a diff +// is highlighted independently. Multi-line tokens (a block comment +// that opens on line 5 and closes on line 12) may mis-color on lines +// 6–11 because the highlighter doesn't know the comment is still open. +// This is the same trade-off GitHub's mobile web view made and the +// PR-review surface is not the place to ship a multi-line tokenizer — +// callers should still get a clean, readable result, and the cost of +// wiring a per-hunk state machine is out of scope for S6b. +// +// The hast tree is then converted to a flat list of +// `{text, className}` spans so a React Native `` can render it +// with nested `` runs. The class names we produce are a small +// fixed token palette (`tok-keyword`, `tok-string`, ...) derived from +// the app's theme — the rendering side turns those into concrete +// colors with dark variants. We don't use raw `hljs-*` classes because +// the diff surface shouldn't depend on the full highlight.js CSS +// theme being loaded. +// +// A small LRU cache sits on top of the per-line path because a large +// diff can have thousands of identical lines (empty context rows) and +// re-running the tokenizer for each one is wasteful. + +import { common, createLowlight } from 'lowlight'; + +// Lazy singleton — the common grammar set is ~1 MB, instantiating it +// once per module load (and not at all in tests that don't import +// anything that calls it) keeps the bundle and cold-start cost +// contained. +type LowlightInstance = ReturnType; +let lowlightInstance: LowlightInstance | null = null; + +function getLowlight(): LowlightInstance { + lowlightInstance ??= createLowlight(common); + return lowlightInstance; +} + +// Extension → language name. Anything not in this map is highlighted +// as plain text (the underlying `lowlight.highlight` will still return +// a single text node, so callers get a valid result back without +// throwing). Filenames are lower-cased and the last extension is +// matched so `foo.test.ts` resolves to `typescript`. +const EXTENSION_LANGUAGE_MAP: Record = { + ts: 'typescript', + tsx: 'typescript', + mts: 'typescript', + cts: 'typescript', + js: 'javascript', + jsx: 'javascript', + mjs: 'javascript', + cjs: 'javascript', + py: 'python', + go: 'go', + rs: 'rust', + java: 'java', + rb: 'ruby', + json: 'json', + jsonc: 'json', + md: 'markdown', + mdx: 'markdown', + sh: 'bash', + bash: 'bash', + zsh: 'bash', + css: 'css', + scss: 'css', + html: 'xml', + htm: 'xml', + xml: 'xml', + vue: 'xml', + svelte: 'xml', + yml: 'yaml', + yaml: 'yaml', + toml: 'ini', + ini: 'ini', + c: 'c', + h: 'c', + cpp: 'cpp', + cxx: 'cpp', + cc: 'cpp', + hpp: 'cpp', + cs: 'csharp', + php: 'php', + swift: 'swift', + kt: 'kotlin', + scala: 'scala', + sql: 'sql', + graphql: 'graphql', +}; + +export function languageForPath(path: string | null | undefined): string | null { + if (!path) { + return null; + } + const slash = path.lastIndexOf('/'); + const basename = slash !== -1 ? path.slice(slash + 1) : path; + const dot = basename.lastIndexOf('.'); + if (dot <= 0) { + return null; + } + const ext = basename.slice(dot + 1).toLowerCase(); + return EXTENSION_LANGUAGE_MAP[ext] ?? null; +} + +export type HighlightToken = { + text: string; + /** + * The token-color palette key (`keyword`, `string`, `comment`, ...). + * `null` for plain text runs the highlighter didn't tag. + */ + className: string | null; +}; + +// Map a highlight.js class name to our smaller palette. We don't ship +// every hljs sub-language — only the ones that show up in the +// reviewer surface often enough to be worth coloring. +const HLJS_CLASS_PALETTE: Record = { + // Keywords / control flow + keyword: 'keyword', + built_in: 'builtin', + 'builtin-name': 'builtin', + literal: 'literal', + symbol: 'literal', + boolean: 'literal', + number: 'number', + 'function-variable': 'function', + 'class-name': 'type', + type: 'type', + 'title.function': 'function', + 'title.class': 'type', + function: 'function', + attr: 'attribute', + attribute: 'attribute', + variable: 'variable', + template_variable: 'variable', + params: 'variable', + property: 'property', + tag: 'tag', + selector: 'selector', + selector_tag: 'selector', + selector_class: 'selector', + selector_id: 'selector', + selector_pseudo: 'selector', + // Literals + string: 'string', + regexp: 'string', + meta_string: 'string', + subst: 'string', + char: 'string', + // Comments / doc + comment: 'comment', + doctag: 'comment', + quote: 'string', + // Operators / punctuation + operator: 'operator', + punctuation: 'operator', + // Misc + meta: 'meta', + addition: 'add', + deletion: 'del', +}; + +// Cap the per-line cache at 5,000 entries — large diffs can have many +// repeated short lines (empty context rows, import lines) but a hard +// ceiling keeps memory bounded for an attacker-controlled patch. +const HIGHLIGHT_CACHE_LIMIT = 5000; +const highlightCache = new Map(); + +function tokenFromHljsClassNames(classNames: readonly string[]): string | null { + for (const name of classNames) { + const palette = HLJS_CLASS_PALETTE[name]; + if (palette) { + return palette; + } + // highlight.js also prefixes with `hljs-` for some grammars; strip + // the prefix and try again. + if (name.startsWith('hljs-')) { + const stripped = name.slice('hljs-'.length); + const palette2 = HLJS_CLASS_PALETTE[stripped]; + if (palette2) { + return palette2; + } + } + } + return null; +} + +type HastNode = { + type: string; + // hast element nodes carry `properties: { className?: string[] }`. + properties?: { className?: string[] }; + // hast text nodes carry `value: string`. + value?: string; + children?: HastNode[]; +}; + +function flattenHast(node: HastNode, out: HighlightToken[]): void { + if (node.type === 'text' || node.type === 'root') { + // The root node carries the per-line wrapper; we still want its + // text children to come out as plain runs. + if (node.value) { + out.push({ text: node.value, className: null }); + } + if (node.children) { + for (const child of node.children) { + flattenHast(child, out); + } + } + return; + } + if (node.type === 'element') { + const classNames = node.properties?.className ?? []; + const token = tokenFromHljsClassNames(classNames); + // For an element, gather all descendant text into a single token + // run so React Native's nests cleanly. This is what GitHub + // does in its tree-sitter-backed view as well. + const text = collectText(node); + if (text.length > 0) { + out.push({ text, className: token }); + } + return; + } + if (node.value) { + out.push({ text: node.value, className: null }); + } +} + +function collectText(node: HastNode): string { + if (node.type === 'text') { + return node.value ?? ''; + } + if (!node.children) { + return ''; + } + let result = ''; + for (const child of node.children) { + result += collectText(child); + } + return result; +} + +/** + * Highlight a single line of source code into a flat list of + * `{text, className}` tokens. The highlighter is run per-line so + * multi-line tokens (block comments, multi-line strings) may be + * mis-colored on continuation lines — this is the accepted v1 + * ceiling and matches GitHub's mobile web view. + * + * Returns a single plain-text run if the language is unknown or the + * highlighter throws on malformed input. + */ +export function highlightLine(text: string, language: string | null): HighlightToken[] { + if (!language) { + return [{ text, className: null }]; + } + const cacheKey = `${language}\u0000${text}`; + const cached = highlightCache.get(cacheKey); + if (cached) { + // LRU touch — re-insert to move to the end of insertion order. + highlightCache.delete(cacheKey); + highlightCache.set(cacheKey, cached); + return cached; + } + const tokens = runHighlight(text, language); + if (highlightCache.size >= HIGHLIGHT_CACHE_LIMIT) { + // Drop the oldest entry. Map iteration is insertion-ordered, so + // the first key is the least-recently-used. + const oldest = highlightCache.keys().next().value; + if (oldest !== undefined) { + highlightCache.delete(oldest); + } + } + highlightCache.set(cacheKey, tokens); + return tokens; +} + +function runHighlight(text: string, language: string): HighlightToken[] { + try { + const tree = getLowlight().highlight(language, text); + const tokens: HighlightToken[] = []; + // The root node has a single span child whose children carry the + // real classes. We flatten through `flattenHast` to get one + // token per contiguous text/class run. + flattenHast(tree as unknown as HastNode, tokens); + if (tokens.length === 0 && text.length > 0) { + return [{ text, className: null }]; + } + return tokens; + } catch { + // The grammar threw (e.g. unknown language) — fall back to plain. + return [{ text, className: null }]; + } +} + +/** + * For tests: clear the per-line cache so a test that swaps the + * singleton (e.g. to register a custom grammar) gets a fresh state. + */ +export function clearHighlightCacheForTests(): void { + highlightCache.clear(); + lowlightInstance = null; +} diff --git a/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts b/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts new file mode 100644 index 0000000000..459c240fc2 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest'; + +import { type ParsedHunk, parsePatch } from './parse-patch'; + +function firstHunk(hunks: ParsedHunk[]): ParsedHunk { + const hunk = hunks[0]; + if (!hunk) { + throw new Error('expected at least one hunk'); + } + return hunk; +} + +function nthHunk(hunks: ParsedHunk[], index: number): ParsedHunk { + const hunk = hunks[index]; + if (!hunk) { + throw new Error(`expected hunk at index ${index}`); + } + return hunk; +} + +describe('parsePatch', () => { + it('returns an empty result for a null patch', () => { + expect(parsePatch(null)).toEqual({ isRename: false, hunks: [] }); + }); + + it('returns an empty result for an empty string', () => { + expect(parsePatch('')).toEqual({ isRename: false, hunks: [] }); + }); + + it('parses a simple added line', () => { + const patch = [ + 'diff --git a/hello.ts b/hello.ts', + 'index 0000001..1111111 100644', + '--- a/hello.ts', + '+++ b/hello.ts', + '@@ -0,0 +1,1 @@', + '+export const hello = "world";', + ].join('\n'); + + const result = parsePatch(patch); + expect(result.isRename).toBe(false); + expect(result.hunks).toHaveLength(1); + const hunk = firstHunk(result.hunks); + expect(hunk.oldStart).toBe(0); + expect(hunk.oldLines).toBe(0); + expect(hunk.newStart).toBe(1); + expect(hunk.newLines).toBe(1); + expect(hunk.lines).toEqual([ + { + type: 'add', + newLine: 1, + text: 'export const hello = "world";', + noNewlineAtEndOfFile: false, + }, + ]); + }); + + it('parses a pure deletion with context', () => { + const patch = [ + 'diff --git a/hello.ts b/hello.ts', + '@@ -1,3 +1,1 @@', + ' import { foo } from "./foo";', + '-import { bar } from "./bar";', + '-import { baz } from "./baz";', + ' export const x = foo();', + ].join('\n'); + + const result = parsePatch(patch); + expect(result.hunks).toHaveLength(1); + const hunk = firstHunk(result.hunks); + expect(hunk.oldStart).toBe(1); + expect(hunk.oldLines).toBe(3); + expect(hunk.newStart).toBe(1); + expect(hunk.newLines).toBe(1); + expect(hunk.lines).toEqual([ + { + type: 'context', + oldLine: 1, + newLine: 1, + text: 'import { foo } from "./foo";', + noNewlineAtEndOfFile: false, + }, + { + type: 'del', + oldLine: 2, + text: 'import { bar } from "./bar";', + noNewlineAtEndOfFile: false, + }, + { + type: 'del', + oldLine: 3, + text: 'import { baz } from "./baz";', + noNewlineAtEndOfFile: false, + }, + { + type: 'context', + oldLine: 4, + newLine: 2, + text: 'export const x = foo();', + noNewlineAtEndOfFile: false, + }, + ]); + }); + + it('parses a multi-hunk patch with correct line counters per hunk', () => { + const patch = [ + 'diff --git a/file.ts b/file.ts', + '@@ -1,2 +1,2 @@', + ' line one', + '-old line two', + '+new line two', + ' line three', + '@@ -10,3 +10,4 @@', + ' line ten', + '+inserted after ten', + ' line eleven', + ' line twelve', + ].join('\n'); + + const result = parsePatch(patch); + expect(result.hunks).toHaveLength(2); + + const first = firstHunk(result.hunks); + expect(first.oldStart).toBe(1); + expect(first.newStart).toBe(1); + expect(first.lines.map(l => l.type)).toEqual(['context', 'del', 'add', 'context']); + expect(first.lines.map(l => l.oldLine)).toEqual([1, 2, undefined, 3]); + expect(first.lines.map(l => l.newLine)).toEqual([1, undefined, 2, 3]); + + const second = nthHunk(result.hunks, 1); + expect(second.oldStart).toBe(10); + expect(second.newStart).toBe(10); + expect(second.lines.map(l => l.type)).toEqual(['context', 'add', 'context', 'context']); + expect(second.lines[1]?.newLine).toBe(11); + }); + + it('attaches the no-newline marker to the immediately preceding add/del line', () => { + // GitHub emits the `\ No newline at end of file` marker as a + // separate line directly after the line it qualifies — it never + // appears between two add/del lines. So the marker is attached to + // the most recent add/del, not to both halves of a -/+ pair. + const patch = [ + 'diff --git a/single.txt b/single.txt', + '@@ -1,1 +1,1 @@', + '-old content', + '+new content', + String.raw`\ No newline at end of file`, + ].join('\n'); + + const result = parsePatch(patch); + const lines = firstHunk(result.hunks).lines; + expect(lines).toHaveLength(2); + expect(lines[0]).toMatchObject({ type: 'del', noNewlineAtEndOfFile: false }); + expect(lines[1]).toMatchObject({ type: 'add', noNewlineAtEndOfFile: true }); + }); + + it('attaches the no-newline marker to a del when the del is the last non-context line', () => { + const patch = [ + 'diff --git a/single.txt b/single.txt', + '@@ -1,2 +0,0 @@', + '-removed line', + String.raw`\ No newline at end of file`, + ].join('\n'); + + const result = parsePatch(patch); + const lines = firstHunk(result.hunks).lines; + expect(lines).toHaveLength(1); + expect(lines[0]).toMatchObject({ type: 'del', noNewlineAtEndOfFile: true }); + }); + + it('does not emit the no-newline marker as its own line', () => { + const patch = [ + 'diff --git a/single.txt b/single.txt', + '@@ -1,1 +1,1 @@', + '-old', + '+new', + String.raw`\ No newline at end of file`, + ' context', + ].join('\n'); + + const result = parsePatch(patch); + const lines = firstHunk(result.hunks).lines; + // 2 non-context lines + 1 context line = 3 total. The marker is + // attached to the preceding add, not counted as its own row. + expect(lines).toHaveLength(3); + expect(lines[0]?.type).toBe('del'); + expect(lines[1]?.type).toBe('add'); + expect(lines[1]?.noNewlineAtEndOfFile).toBe(true); + expect(lines[2]?.type).toBe('context'); + }); + + it('parses a renamed file and exposes previousPath', () => { + const patch = [ + 'diff --git a/old-name.ts b/new-name.ts', + 'similarity index 95%', + 'rename from old-name.ts', + 'rename to new-name.ts', + '@@ -1,1 +1,1 @@', + '-export const a = 1;', + '+export const a = 2;', + ].join('\n'); + + const result = parsePatch(patch); + expect(result.isRename).toBe(true); + expect(result.previousPath).toBe('old-name.ts'); + expect(result.hunks).toHaveLength(1); + expect(firstHunk(result.hunks).lines).toHaveLength(2); + }); + + it('handles headers without explicit line counts (defaults to 1)', () => { + const patch = ['diff --git a/short.txt b/short.txt', '@@ -1 +1 @@', '-old', '+new'].join('\n'); + + const result = parsePatch(patch); + const hunk = firstHunk(result.hunks); + expect(hunk.oldLines).toBe(1); + expect(hunk.newLines).toBe(1); + }); + + it('preserves a hunk header section heading (e.g. function name) in the header text', () => { + const patch = [ + 'diff --git a/file.ts b/file.ts', + '@@ -1,1 +1,1 @@ def greet():', + '-print("hi")', + '+print("hello")', + ].join('\n'); + + const result = parsePatch(patch); + expect(firstHunk(result.hunks).header).toBe('@@ -1,1 +1,1 @@ def greet():'); + }); + + it('attaches the no-newline marker to the immediately-preceding context line', () => { + const patch = [ + 'diff --git a/x.ts b/x.ts', + '@@ -1,2 +1,2 @@', + '-old last line', + '+new last line', + ' trailing context', + String.raw`\ No newline at end of file`, + ].join('\n'); + + const lines = firstHunk(parsePatch(patch).hunks).lines; + const contextLine = lines.find(l => l.text === 'trailing context'); + const addLine = lines.find(l => l.text === 'new last line'); + // The marker qualifies the context line it directly follows, NOT the + // earlier add/del line. + expect(contextLine?.noNewlineAtEndOfFile).toBe(true); + expect(addLine?.noNewlineAtEndOfFile).toBe(false); + }); + + it('normalizes CRLF line endings and still matches the no-newline marker', () => { + const patch = [ + 'diff --git a/x.ts b/x.ts', + '@@ -1,1 +1,1 @@', + '-old', + '+new', + String.raw`\ No newline at end of file`, + ].join('\r\n'); + + const lines = firstHunk(parsePatch(patch).hunks).lines; + const addLine = lines.find(l => l.type === 'add'); + // No stray \r leaked into the rendered content, and the marker matched. + expect(addLine?.text).toBe('new'); + expect(addLine?.noNewlineAtEndOfFile).toBe(true); + }); + + it('returns an empty hunks array for a malformed hunk header', () => { + const patch = [ + 'diff --git a/x.ts b/x.ts', + '@@ this is not a real header @@', + '-whatever', + '+whatever', + ].join('\n'); + + const result = parsePatch(patch); + // The parser bails out of the rest of the patch on a malformed + // header; the caller still has the file metadata from the DTO so + // it can render "Open on GitHub" as a fallback. + expect(result.hunks).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/parse-patch.ts b/apps/mobile/src/lib/pr-review/diff/parse-patch.ts new file mode 100644 index 0000000000..d9e6721960 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/parse-patch.ts @@ -0,0 +1,248 @@ +// Pure unified-diff parser for GitHub's `file.patch` strings. +// +// GitHub emits a single `diff --git a/ b/` block per file +// followed by one or more `@@ -oldStart,oldLines +newStart,newLines @@` +// hunks. Each hunk body is a sequence of lines starting with ' ' +// (context), '+' (add), or '-' (del). A trailing `\ No newline at end of +// file` marker belongs to the previous add/del line and must not become +// its own diff line — that marker means "the previous line had no +// terminating newline" and is purely metadata, not diff content. +// +// Renamed files appear with a `rename from ` / `rename to ` +// header in addition to the `diff --git` line. We expose those via +// `isRename` + `previousPath` on the parsed file result so the UI can +// render an "old → new" header. The actual diff body for a rename is +// still a normal unified diff against the new path, so `hunks` does not +// need any special treatment. +// +// Outputs are plain data — no React, no React Native, no lowlight — +// so this module is testable in plain Node and reusable by any future +// non-mobile surface (web, CLI, etc.). + +export type DiffLineType = 'context' | 'add' | 'del'; + +export type ParsedDiffLine = { + type: DiffLineType; + /** 1-indexed line number in the old file. Undefined for `add` lines. */ + oldLine?: number; + /** 1-indexed line number in the new file. Undefined for `del` lines. */ + newLine?: number; + /** The line content, without the leading +/-/space marker. */ + text: string; + /** + * The previous line had no terminating newline. Carried as a flag + * rather than its own line so the caller can render it once and the + * total line count matches the row count the user sees. + */ + noNewlineAtEndOfFile: boolean; +}; + +export type ParsedHunk = { + /** The raw `@@ -a,b +c,d @@` header line, minus the trailing section heading. */ + header: string; + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: ParsedDiffLine[]; +}; + +export type ParsedPatch = { + isRename: boolean; + previousPath?: string; + hunks: ParsedHunk[]; +}; + +const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/; +const NO_NEWLINE_MARKER = String.raw`\ No newline at end of file`; +// `git apply` accepts " " / "+" / "-" / "\" as line-type markers. +const LINE_MARKERS = new Set([' ', '+', '-', '\\']); + +type ParseState = { + isRename: boolean; + previousPath: string | undefined; + hunks: ParsedHunk[]; + currentHunk: ParsedHunk | null; + oldLineNo: number; + newLineNo: number; + /** Index of the immediately-preceding parsed body line (any type). */ + lastLineIndex: number; + abort: boolean; +}; + +function processDiffGitLine(state: ParseState): void { + // Starting a new file — flush any open hunk defensively even + // though GitHub's output is well-formed and never has two diff + // blocks in a single patch string. + if (state.currentHunk) { + state.hunks.push(state.currentHunk); + state.currentHunk = null; + } +} + +function processRenameFrom(state: ParseState, line: string): void { + state.isRename = true; + state.previousPath = line.slice('rename from '.length); +} + +function processHunkHeader(state: ParseState, line: string): void { + if (state.currentHunk) { + state.hunks.push(state.currentHunk); + } + const match = HUNK_HEADER_RE.exec(line); + if (!match) { + // Unparseable hunk header — bail out of the rest of this + // patch. The file DTO still has its path / status / counts + // so the user can read the file via "Open on GitHub". + state.currentHunk = null; + state.abort = true; + return; + } + const oldStart = Number(match[1]); + const oldLines = match[2] === undefined ? 1 : Number(match[2]); + const newStart = Number(match[3]); + const newLines = match[4] === undefined ? 1 : Number(match[4]); + state.currentHunk = { + header: `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@${match[5] ?? ''}`, + oldStart, + oldLines, + newStart, + newLines, + lines: [], + }; + state.oldLineNo = oldStart; + state.newLineNo = newStart; + state.lastLineIndex = -1; +} + +function attachNoNewlineMarker(state: ParseState): void { + if (!state.currentHunk || state.lastLineIndex < 0) { + return; + } + const previous = state.currentHunk.lines[state.lastLineIndex]; + if (!previous) { + return; + } + state.currentHunk.lines[state.lastLineIndex] = { + type: previous.type, + ...(previous.oldLine !== undefined ? { oldLine: previous.oldLine } : {}), + ...(previous.newLine !== undefined ? { newLine: previous.newLine } : {}), + text: previous.text, + noNewlineAtEndOfFile: true, + }; +} + +function processBodyLine(state: ParseState, line: string): void { + if (!state.currentHunk) { + // Diff metadata lines (index, ---, +++, similarity, etc.) are + // skipped — we only emit actual hunk bodies. + return; + } + if (line === NO_NEWLINE_MARKER) { + attachNoNewlineMarker(state); + return; + } + const marker = line[0]; + if (!marker || !LINE_MARKERS.has(marker) || marker === '\\') { + // Empty / unrecognized line inside a hunk body — skip rather + // than treat as a malformed context line. The leading '\' case + // for non-marker no-newline lines is already handled above. + return; + } + const text = line.slice(1); + if (marker === ' ') { + state.currentHunk.lines.push({ + type: 'context', + oldLine: state.oldLineNo, + newLine: state.newLineNo, + text, + noNewlineAtEndOfFile: false, + }); + state.oldLineNo += 1; + state.newLineNo += 1; + state.lastLineIndex = state.currentHunk.lines.length - 1; + return; + } + if (marker === '+') { + state.currentHunk.lines.push({ + type: 'add', + newLine: state.newLineNo, + text, + noNewlineAtEndOfFile: false, + }); + state.newLineNo += 1; + state.lastLineIndex = state.currentHunk.lines.length - 1; + return; + } + // marker === '-' + state.currentHunk.lines.push({ + type: 'del', + oldLine: state.oldLineNo, + text, + noNewlineAtEndOfFile: false, + }); + state.oldLineNo += 1; + state.lastLineIndex = state.currentHunk.lines.length - 1; +} + +function processLine(state: ParseState, line: string): void { + if (line.startsWith('diff --git ')) { + processDiffGitLine(state); + return; + } + if (line.startsWith('rename from ')) { + processRenameFrom(state, line); + return; + } + if (line.startsWith('rename to ')) { + // The `rename to` path is the same as the new file path (already + // available in the file DTO), so we don't re-parse it here. + return; + } + if (line.startsWith('@@')) { + processHunkHeader(state, line); + return; + } + processBodyLine(state, line); +} + +export function parsePatch(patch: string | null | undefined): ParsedPatch { + if (!patch) { + return { isRename: false, hunks: [] }; + } + + // Normalize line endings — GitHub's API returns \n, but splitting on + // \r?\n keeps the parser robust against a proxy that returns CRLF and + // ensures the `\ No newline at end of file` marker matches exactly + // (a stray \r would otherwise leak into rendered content and break the + // marker match). + const rawLines = patch.split(/\r?\n/); + + const state: ParseState = { + isRename: false, + previousPath: undefined, + hunks: [], + currentHunk: null, + oldLineNo: 0, + newLineNo: 0, + lastLineIndex: -1, + abort: false, + }; + + for (const line of rawLines) { + if (state.abort) { + break; + } + processLine(state, line); + } + + if (state.currentHunk) { + state.hunks.push(state.currentHunk); + } + + return { + isRename: state.isRename, + ...(state.previousPath ? { previousPath: state.previousPath } : {}), + hunks: state.hunks, + }; +} diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts new file mode 100644 index 0000000000..0930945a38 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts @@ -0,0 +1,92 @@ +// Gap builder helpers for the PR diff FlashList. Kept separate so the +// main list builder stays under the max-lines limit. + +import { type ParsedPatch } from '@/lib/pr-review/diff/parse-patch'; +import { + type BuildItemsArgs, + type ExpandSeparatorItem, + type ExpandSeparatorState, + getCumulativeLines, + getTotalLines, + type ListItem, +} from '@/lib/pr-review/diff/pr-diff-list-items'; + +export function deriveSeparatorState( + state: ExpandSeparatorState, + loadedCount: number +): ExpandSeparatorItem['state'] { + if (state.status === 'loading') { + return 'loading'; + } + if (state.status === 'error') { + return 'error'; + } + if (state.status === 'unavailable') { + return 'unavailable'; + } + return loadedCount > 0 ? 'partial' : 'idle'; +} + +export function pushGapItems(args: { + items: ListItem[]; + file: BuildItemsArgs['files'][number]; + startLine: number; + endLine: number; + gapIndex: number; + hunkIndex: number; + fileContext: Record; + parsed: ParsedPatch; + language: string | null; + headSha: string; +}): void { + const state = args.fileContext[args.gapIndex] ?? { status: 'idle' as const }; + const cumulativeLines = getCumulativeLines(state); + const loadedCount = cumulativeLines.length; + const effectiveEndLine = getTotalLines(state) ?? args.endLine; + const gapSize = effectiveEndLine - args.startLine + 1; + const isComplete = loadedCount >= gapSize; + + for (let lineIdx = 0; lineIdx < loadedCount; lineIdx += 1) { + const lineText = cumulativeLines[lineIdx] ?? ''; + const newLineNo = args.startLine + lineIdx; + args.items.push({ + kind: 'diff-line', + key: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + lineKey: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + hunkIndex: args.hunkIndex, + lineIndex: lineIdx, + parsed: args.parsed, + line: { + type: 'context', + newLine: newLineNo, + text: lineText, + noNewlineAtEndOfFile: false, + }, + language: args.language, + lineKeyId: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + }); + } + + if (isComplete || state.status === 'unavailable') { + return; + } + + const remainingStartLine = args.startLine + loadedCount; + args.items.push({ + kind: 'expand-separator', + key: `gap:${args.file.path}:${args.gapIndex}`, + filePath: args.file.path, + ref: { + owner: '', + repo: '', + number: 0, + ref: args.headSha, + }, + context: { + gapIndex: args.gapIndex, + startLine: remainingStartLine, + endLine: effectiveEndLine, + }, + state: deriveSeparatorState(state, loadedCount), + }); +} diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts new file mode 100644 index 0000000000..cb9a96deb3 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; + +import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; +import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; + +type SeparatorItem = Extract; +type DiffLineListItem = Extract; +type PaginationItem = Extract; + +function separators(items: ListItem[]): SeparatorItem[] { + return items.filter((i): i is SeparatorItem => i.kind === 'expand-separator'); +} +function diffLines(items: ListItem[]): DiffLineListItem[] { + return items.filter((i): i is DiffLineListItem => i.kind === 'diff-line'); +} +function paginationRow(items: ListItem[]): PaginationItem | undefined { + return items.find((i): i is PaginationItem => i.kind === 'pagination-row'); +} + +function makeFile(patch: string, path = 'a.ts'): PrReviewFile { + return { + path, + previousPath: null, + status: 'modified', + additions: 1, + deletions: 1, + patch, + patchMissing: false, + }; +} + +function baseArgs(overrides: Partial = {}): BuildItemsArgs { + return { + files: [], + expanded: {}, + expandedContext: {}, + viewed: () => false, + headSha: 'abc', + owner: 'owner', + repo: 'repo', + number: 1, + changedFiles: 0, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + laterPageError: false, + fetchToCompletionRunning: false, + fetchToCompletionLoaded: 0, + totalFiles: null, + ...overrides, + }; +} + +const singleHunkPatch = [ + 'diff --git a/a.ts b/a.ts', + '@@ -5,3 +5,3 @@', + ' context line 5', + '-old line 6', + '+new line 6', + ' context line 7', +].join('\n'); + +const twoHunkPatch = [ + 'diff --git a/a.ts b/a.ts', + '@@ -5,3 +5,3 @@', + ' context line 5', + '-old line 6', + '+new line 6', + ' context line 7', + '@@ -15,3 +15,3 @@', + ' context line 15', + '-old line 16', + '+new line 16', + ' context line 17', +].join('\n'); + +const largeGapPatch = [ + 'diff --git a/a.ts b/a.ts', + '@@ -5,3 +5,3 @@', + ' context line 5', + '-old line 6', + '+new line 6', + ' context line 7', + '@@ -45,3 +45,3 @@', + ' context line 45', + '-old line 46', + '+new line 46', + ' context line 47', +].join('\n'); + +describe('buildItems later-page error', () => { + it('emits an error pagination row when laterPageError + hasNextPage', () => { + const items = buildItems(baseArgs({ hasNextPage: true, laterPageError: true })); + expect(paginationRow(items)).toMatchObject({ kind: 'pagination-row', state: 'error' }); + }); + + it('does not emit an error pagination row when laterPageError is false', () => { + const items = buildItems(baseArgs({ hasNextPage: true, laterPageError: false })); + expect(paginationRow(items)?.state).not.toBe('error'); + }); + + it('does not emit an error pagination row when there is no next page', () => { + const items = buildItems(baseArgs({ hasNextPage: false, laterPageError: true })); + expect(paginationRow(items)?.state).not.toBe('error'); + }); +}); + +function gapLinesFor(items: ListItem[], path: string, gapIndex: number): DiffLineListItem[] { + const prefix = `gap-line:${path}:${gapIndex}:`; + return diffLines(items).filter(l => l.lineKey.startsWith(prefix)); +} + +function separatorFor(items: ListItem[], gapIndex: number): SeparatorItem | undefined { + return separators(items).find(s => s.context.gapIndex === gapIndex); +} + +describe('buildItems gap separators', () => { + it('renders a leading separator when the first hunk starts after line 1', () => { + const items = buildItems( + baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } }) + ); + expect(separatorFor(items, -1)).toMatchObject({ + context: { gapIndex: -1, startLine: 1, endLine: 4 }, + }); + }); + + it('renders a trailing separator after the last hunk', () => { + const items = buildItems( + baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } }) + ); + const trailing = separatorFor(items, 1); + expect(trailing).toMatchObject({ context: { gapIndex: 1, startLine: 8 }, state: 'idle' }); + expect(Number.isFinite(trailing?.context.endLine ?? 0)).toBe(false); + }); + + it('renders between-hunk separators for gaps larger than 3 lines', () => { + const items = buildItems( + baseArgs({ files: [makeFile(twoHunkPatch)], expanded: { 'a.ts': true } }) + ); + expect(separatorFor(items, 0)).toMatchObject({ + context: { gapIndex: 0, startLine: 8, endLine: 14 }, + }); + }); +}); + +describe('buildItems progressive context windowing', () => { + it('first tap loads window 1 and keeps a partial separator for the remainder', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(largeGapPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { + 0: { status: 'partial', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) }, + }, + }, + }) + ); + const gapLines = gapLinesFor(items, 'a.ts', 0); + expect(gapLines).toHaveLength(20); + expect(gapLines[0]?.line.newLine).toBe(8); + expect(gapLines[19]?.line.newLine).toBe(27); + expect(separatorFor(items, 0)).toMatchObject({ + state: 'partial', + context: { gapIndex: 0, startLine: 28, endLine: 44 }, + }); + }); + + it('second tap advances to window 2 without duplicating earlier lines', () => { + const lines = Array.from({ length: 37 }, (_, i) => `gap-line-${i}`); + const items = buildItems( + baseArgs({ + files: [makeFile(largeGapPatch)], + expanded: { 'a.ts': true }, + expandedContext: { 'a.ts': { 0: { status: 'partial', lines } } }, + }) + ); + const gapLines = gapLinesFor(items, 'a.ts', 0); + expect(gapLines).toHaveLength(37); + expect(gapLines.map(l => l.line.text)).toEqual(lines); + expect(gapLines[0]?.line.newLine).toBe(8); + expect(gapLines[36]?.line.newLine).toBe(44); + expect(separatorFor(items, 0)).toBeUndefined(); + }); + + it('expand all for a small gap removes the separator', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(twoHunkPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { + 0: { status: 'partial', lines: Array.from({ length: 7 }, (_, i) => `gap-line-${i}`) }, + }, + }, + }) + ); + expect(gapLinesFor(items, 'a.ts', 0)).toHaveLength(7); + expect(separatorFor(items, 0)).toBeUndefined(); + }); + + it('a failed later window keeps earlier lines and surfaces an error separator', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(largeGapPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { + 0: { status: 'error', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) }, + }, + }, + }) + ); + expect(gapLinesFor(items, 'a.ts', 0)).toHaveLength(20); + expect(separatorFor(items, 0)).toMatchObject({ + state: 'error', + context: { startLine: 28, endLine: 44 }, + }); + }); +}); + +describe('buildItems trailing gap totalLines', () => { + it('uses totalLines to bound the trailing gap once known', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(singleHunkPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { 1: { status: 'partial', lines: ['trailing-1'], totalLines: 9 } }, + }, + }) + ); + expect(gapLinesFor(items, 'a.ts', 1)).toHaveLength(1); + expect(separatorFor(items, 1)).toMatchObject({ + state: 'partial', + context: { startLine: 9, endLine: 9 }, + }); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts new file mode 100644 index 0000000000..f6c2d3eaa5 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts @@ -0,0 +1,252 @@ +// List builder for the PR diff FlashList. Split from +// `pr-diff-list-items.ts` so each file stays under the max-lines limit. + +import { languageForPath } from '@/lib/pr-review/diff/highlight'; +import { parsePatch } from '@/lib/pr-review/diff/parse-patch'; +import { pushGapItems } from '@/lib/pr-review/diff/pr-diff-gap-builder'; +import { + buildGithubFileUrl, + type BuildItemsArgs, + type ListItem, + PR_REVIEW_MAX_LISTED_FILES, + shouldShowTruncationBanner, + truncationBannerCopy, +} from '@/lib/pr-review/diff/pr-diff-list-items'; + +function pushPatchMissingItems(args: { + items: ListItem[]; + file: BuildItemsArgs['files'][number]; + viewed: boolean; + githubUrl: string; +}): void { + args.items.push({ + kind: 'file-patch-missing', + key: `file-pm:${args.file.path}`, + file: args.file, + viewed: args.viewed, + githubUrl: args.githubUrl, + }); +} + +function pushExpandedFileItems( + items: ListItem[], + file: BuildItemsArgs['files'][number], + args: BuildItemsArgs +): void { + const githubUrl = buildGithubFileUrl({ + owner: args.owner, + repo: args.repo, + number: args.number, + path: file.path, + }); + if (file.patchMissing || !file.patch) { + pushPatchMissingItems({ + items, + file, + viewed: args.viewed(file.path), + githubUrl, + }); + return; + } + + const parsed = parsePatch(file.patch); + const language = languageForPath(file.path); + const hunks = parsed.hunks; + const fileContext = args.expandedContext[file.path] ?? {}; + + for (let hunkIndex = 0; hunkIndex < hunks.length; hunkIndex += 1) { + const hunk = hunks[hunkIndex]; + if (!hunk) { + break; + } + + if (hunkIndex === 0 && hunk.newStart > 1) { + pushGapItems({ + items, + file, + startLine: 1, + endLine: hunk.newStart - 1, + gapIndex: -1, + hunkIndex: 0, + fileContext, + parsed, + language, + headSha: args.headSha, + }); + } + + if (hunkIndex > 0) { + const prevHunk = hunks[hunkIndex - 1]; + if (prevHunk) { + const prevNewEnd = prevHunk.newStart + prevHunk.newLines - 1; + const gap = hunk.newStart - prevNewEnd - 1; + if (gap > 3) { + pushGapItems({ + items, + file, + startLine: prevNewEnd + 1, + endLine: hunk.newStart - 1, + gapIndex: hunkIndex - 1, + hunkIndex, + fileContext, + parsed, + language, + headSha: args.headSha, + }); + } + } + } + + items.push({ + kind: 'hunk-header', + key: `hunk:${file.path}:${hunkIndex}`, + header: hunk.header, + }); + for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex += 1) { + const line = hunk.lines[lineIndex]; + if (!line) { + break; + } + items.push({ + kind: 'diff-line', + key: `line:${file.path}:${hunkIndex}:${lineIndex}`, + lineKey: `line:${file.path}:${hunkIndex}:${lineIndex}`, + hunkIndex, + lineIndex, + parsed, + line, + language, + lineKeyId: `line:${file.path}:${hunkIndex}:${lineIndex}`, + }); + } + } + + const lastHunk = hunks.at(-1); + if (lastHunk) { + pushGapItems({ + items, + file, + startLine: lastHunk.newStart + lastHunk.newLines, + endLine: Infinity, + gapIndex: hunks.length, + hunkIndex: hunks.length - 1, + fileContext, + parsed, + language, + headSha: args.headSha, + }); + } +} + +function pushPaginationItem(items: ListItem[], args: BuildItemsArgs): void { + if (args.isLoading) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'loading', + loadedFiles: 0, + totalFiles: args.totalFiles, + }); + return; + } + + if (args.hasNextPage) { + if (args.laterPageError && !args.isFetchingNextPage && !args.fetchToCompletionRunning) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'error', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + if (args.fetchToCompletionLoaded >= PR_REVIEW_MAX_LISTED_FILES) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'all-loaded', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + if (args.fetchToCompletionRunning) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'fetch-to-completion', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + if (args.isFetchingNextPage) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'loading', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'no-pages', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + + if (args.isFetchingNextPage) { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'loading', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); + return; + } + + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state: 'all-loaded', + loadedFiles: args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); +} + +export function buildItems(args: BuildItemsArgs): ListItem[] { + const items: ListItem[] = []; + + if (shouldShowTruncationBanner(args.changedFiles)) { + items.push({ + kind: 'truncation-banner', + key: 'truncation-banner', + text: truncationBannerCopy(args.changedFiles), + }); + } + + for (const file of args.files) { + const isExpanded = args.expanded[file.path] ?? false; + items.push({ + kind: 'file-header', + key: `file-header:${file.path}`, + file, + expanded: isExpanded, + hasDiff: !file.patchMissing, + viewed: args.viewed(file.path), + }); + if (isExpanded) { + pushExpandedFileItems(items, file, args); + } + } + + pushPaginationItem(items, args); + return items; +} diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts new file mode 100644 index 0000000000..48794e3ed8 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + addContextLoadState, + getCumulativeLines, + setContextLines, +} from '@/lib/pr-review/diff/pr-diff-list-items'; + +describe('addContextLoadState', () => { + it('transitions idle to loading with empty lines', () => { + const next = addContextLoadState({ + state: {}, + filePath: 'a.ts', + gapIndex: 0, + status: 'loading', + }); + expect(next['a.ts']?.[0]).toEqual({ status: 'loading', lines: [] }); + }); + + it('preserves existing lines when transitioning partial to loading', () => { + const state = { + 'a.ts': { + 0: { status: 'partial' as const, lines: ['line1'] }, + }, + }; + const next = addContextLoadState({ state, filePath: 'a.ts', gapIndex: 0, status: 'loading' }); + expect(next['a.ts']?.[0]).toEqual({ status: 'loading', lines: ['line1'] }); + }); + + it('preserves existing lines when transitioning partial to error', () => { + const state = { + 'a.ts': { + 0: { status: 'partial' as const, lines: ['line1'] }, + }, + }; + const next = addContextLoadState({ state, filePath: 'a.ts', gapIndex: 0, status: 'error' }); + expect(next['a.ts']?.[0]).toEqual({ status: 'error', lines: ['line1'] }); + }); + + it('clears state when marking unavailable', () => { + const state = { + 'a.ts': { + 0: { status: 'partial' as const, lines: ['line1'] }, + }, + }; + const next = addContextLoadState({ + state, + filePath: 'a.ts', + gapIndex: 0, + status: 'unavailable', + }); + expect(next['a.ts']?.[0]).toEqual({ status: 'unavailable' }); + }); +}); + +describe('setContextLines', () => { + it('sets partial state with lines from idle', () => { + const next = setContextLines({ + state: {}, + filePath: 'a.ts', + gapIndex: 0, + lines: ['line1', 'line2'], + }); + expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1', 'line2'] }); + }); + + it('appends new lines to existing partial state', () => { + const state = { + 'a.ts': { + 0: { status: 'partial' as const, lines: ['line1'] }, + }, + }; + const next = setContextLines({ state, filePath: 'a.ts', gapIndex: 0, lines: ['line2'] }); + expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1', 'line2'] }); + }); + + it('stores totalLines when provided', () => { + const next = setContextLines({ + state: {}, + filePath: 'a.ts', + gapIndex: 0, + lines: ['line1'], + totalLines: 100, + }); + expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1'], totalLines: 100 }); + }); +}); + +describe('getCumulativeLines', () => { + it('returns lines for loading, partial, and error states', () => { + expect(getCumulativeLines({ status: 'loading', lines: ['a'] })).toEqual(['a']); + expect(getCumulativeLines({ status: 'partial', lines: ['b'] })).toEqual(['b']); + expect(getCumulativeLines({ status: 'error', lines: ['c'] })).toEqual(['c']); + }); + + it('returns empty array for idle and unavailable states', () => { + expect(getCumulativeLines({ status: 'idle' })).toEqual([]); + expect(getCumulativeLines({ status: 'unavailable' })).toEqual([]); + expect(getCumulativeLines(undefined)).toEqual([]); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts new file mode 100644 index 0000000000..159d222e0e --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts @@ -0,0 +1,273 @@ +// Item types + small pure helpers for the PR diff FlashList. +// The list builder lives in `pr-diff-list-builder.ts` so each file +// stays under the max-lines limit. + +import { + PR_REVIEW_MAX_LISTED_FILES, + type PrReviewFile, +} from '@/lib/pr-review/diff/pr-review-file-types'; +import { + shouldShowTruncationBanner, + truncationBannerCopy, +} from '@/lib/pr-review/diff/pr-review-truncation'; +import { type ParsedDiffLine, type ParsedPatch } from '@/lib/pr-review/diff/parse-patch'; + +export type TruncationBannerItem = { + kind: 'truncation-banner'; + key: string; + text: string; +}; + +export type FileHeaderItem = { + kind: 'file-header'; + key: string; + file: PrReviewFile; + expanded: boolean; + hasDiff: boolean; + viewed: boolean; +}; + +export type FilePatchMissingItem = { + kind: 'file-patch-missing'; + key: string; + file: PrReviewFile; + viewed: boolean; + githubUrl: string; +}; + +export type HunkHeaderItem = { + kind: 'hunk-header'; + key: string; + header: string; +}; + +export type DiffLineItem = { + kind: 'diff-line'; + key: string; + lineKey: string; + hunkIndex: number; + lineIndex: number; + parsed: ParsedPatch; + line: ParsedDiffLine; + language: string | null; + lineKeyId: string; +}; + +export type ExpandSeparatorItem = { + kind: 'expand-separator'; + key: string; + filePath: string; + ref: { owner: string; repo: string; number: number; ref: string }; + context: { + gapIndex: number; + startLine: number; + endLine: number; + }; + state: 'idle' | 'loading' | 'error' | 'unavailable' | 'partial'; +}; + +export type PaginationRowItem = { + kind: 'pagination-row'; + key: string; + state: 'loading' | 'error' | 'fetch-to-completion' | 'all-loaded' | 'no-pages'; + loadedFiles: number; + totalFiles: number | null; +}; + +export type ListItem = + | TruncationBannerItem + | FileHeaderItem + | FilePatchMissingItem + | HunkHeaderItem + | DiffLineItem + | ExpandSeparatorItem + | PaginationRowItem; + +export const ITEM_TYPE = { + Truncation: 'truncation', + FileHeader: 'file-header', + FilePatchMissing: 'file-patch-missing', + HunkHeader: 'hunk-header', + DiffLine: 'diff-line', + ExpandSeparator: 'expand-separator', + Pagination: 'pagination', +} as const; + +export type ExpandSeparatorState = + | { status: 'idle' } + | { status: 'loading'; lines: string[]; totalLines?: number } + | { status: 'partial'; lines: string[]; totalLines?: number } + | { status: 'error'; lines: string[]; totalLines?: number } + | { status: 'unavailable' }; + +export function fileHeaderKey(path: string): string { + return `file-header:${path}`; +} + +export function itemTypeFor(item: ListItem): string { + switch (item.kind) { + case 'truncation-banner': { + return ITEM_TYPE.Truncation; + } + case 'file-header': { + return ITEM_TYPE.FileHeader; + } + case 'file-patch-missing': { + return ITEM_TYPE.FilePatchMissing; + } + case 'hunk-header': { + return ITEM_TYPE.HunkHeader; + } + case 'diff-line': { + return ITEM_TYPE.DiffLine; + } + case 'expand-separator': { + return ITEM_TYPE.ExpandSeparator; + } + case 'pagination-row': { + return ITEM_TYPE.Pagination; + } + default: { + return ITEM_TYPE.FileHeader; + } + } +} + +export function buildGithubFileUrl(args: { + owner: string; + repo: string; + number: number; + path: string; +}): string { + return `https://github.com/${args.owner}/${args.repo}/pull/${args.number}/files#diff-${encodeURIComponent(args.path)}`; +} + +export function addContextLoadState(args: { + state: Record>; + filePath: string; + gapIndex: number; + status: 'loading' | 'error' | 'unavailable'; +}): Record> { + const previous = args.state[args.filePath]; + const previousState = previous?.[args.gapIndex]; + if (args.status === 'unavailable') { + return { + ...args.state, + [args.filePath]: { + ...previous, + [args.gapIndex]: { status: 'unavailable' }, + }, + }; + } + const existingLines = + previousState?.status === 'loading' || + previousState?.status === 'partial' || + previousState?.status === 'error' + ? previousState.lines + : []; + const nextStatus: ExpandSeparatorState = + args.status === 'loading' + ? { status: 'loading', lines: existingLines } + : { status: 'error', lines: existingLines }; + return { + ...args.state, + [args.filePath]: { + ...previous, + [args.gapIndex]: nextStatus, + }, + }; +} + +export function getCumulativeLines(state: ExpandSeparatorState | undefined): string[] { + if (state?.status === 'loading' || state?.status === 'partial' || state?.status === 'error') { + return state.lines; + } + return []; +} + +export function getTotalLines(state: ExpandSeparatorState | undefined): number | undefined { + if (state?.status === 'loading' || state?.status === 'partial' || state?.status === 'error') { + return state.totalLines; + } + return undefined; +} + +export function setContextLines(args: { + state: Record>; + filePath: string; + gapIndex: number; + lines: string[]; + totalLines?: number; +}): Record> { + const previous = args.state[args.filePath]; + const previousState = previous?.[args.gapIndex]; + const existingLines = + previousState?.status === 'loading' || + previousState?.status === 'partial' || + previousState?.status === 'error' + ? previousState.lines + : []; + const nextLines = [...existingLines, ...args.lines]; + const nextStatus: ExpandSeparatorState = { + status: 'partial', + lines: nextLines, + totalLines: args.totalLines, + }; + return { + ...args.state, + [args.filePath]: { + ...previous, + [args.gapIndex]: nextStatus, + }, + }; +} + +export function readTrpcErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') { + return undefined; + } + const record = error as Record; + const data = record.data; + if (data && typeof data === 'object') { + const code = (data as Record).code; + if (typeof code === 'string') { + return code; + } + } + const shape = record.shape; + if (shape && typeof shape === 'object') { + const shapeData = (shape as Record).data; + if (shapeData && typeof shapeData === 'object') { + const code = (shapeData as Record).code; + if (typeof code === 'string') { + return code; + } + } + } + const top = record.code; + if (typeof top === 'string') { + return top; + } + return undefined; +} + +export type BuildItemsArgs = { + files: PrReviewFile[]; + expanded: Record; + expandedContext: Record>; + viewed: (path: string) => boolean; + headSha: string; + owner: string; + repo: string; + number: number; + changedFiles: number; + isLoading: boolean; + isFetchingNextPage: boolean; + hasNextPage: boolean; + laterPageError: boolean; + fetchToCompletionRunning: boolean; + fetchToCompletionLoaded: number; + totalFiles: number | null; +}; + +export { PR_REVIEW_MAX_LISTED_FILES, shouldShowTruncationBanner, truncationBannerCopy }; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts new file mode 100644 index 0000000000..01eba0e818 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts @@ -0,0 +1,196 @@ +// File-list query + viewed-files sync + fetch-to-completion control for +// the PR review Files tab. Centralizes the tRPC `listFiles` infinite +// query and the viewed-files store so the list component can stay +// focused on rendering. +// +// The list tab has two distinct loading dimensions: +// 1. Page-level: a single `listFiles` page can fail mid-stream. The +// caller renders a retry row (handled inside the FlashList) and +// the query's `refetch` re-fetches just the failed page. +// 2. Tab-level terminal: a `NOT_FOUND` / `FORBIDDEN` / +// `PRECONDITION_FAILED` on the first page means the whole tab +// is dead — there's no point rendering a list skeleton with a +// retry. The Files tab treats this as a terminal state (no CTA +// for `permission`, an install CTA for `not-found`). +// +// `usePrReviewFileListQuery` is the only hook the list component +// needs; it returns a `useInfiniteQuery` result plus a `status` field +// that already classifies the error so the list can short-circuit +// to the right terminal state. + +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; + +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { + PR_REVIEW_FILES_PAGE_SIZE, + PR_REVIEW_MAX_LISTED_FILES, + PR_REVIEW_MAX_PAGES, + type PrReviewFile, +} from '@/lib/pr-review/diff/pr-review-file-types'; +import { getViewedFiles, toggleViewedFile } from '@/lib/pr-review/viewed-files'; +import { useTRPC } from '@/lib/trpc'; + +// Re-export the pure file types/constants so existing consumers that import +// them from this module keep working (single source of truth lives in +// `pr-review-file-types`, which has no React/react-query import). +export { + PR_REVIEW_FILES_PAGE_SIZE, + PR_REVIEW_MAX_LISTED_FILES, + PR_REVIEW_MAX_PAGES, + type PrReviewFile, +}; + +export function usePrReviewFileListQuery(args: { + owner: string; + repo: string; + number: number; + enabled: boolean; +}) { + const { owner, repo, number, enabled } = args; + const trpc = useTRPC(); + const query = useInfiniteQuery( + trpc.githubPrReview.listFiles.infiniteQueryOptions( + { owner, repo, number }, + { + staleTime: 30_000, + enabled, + getNextPageParam: lastPage => lastPage.nextCursor ?? undefined, + // Cap at the server's page ceiling so we never request page 61. + // 60 pages × 100/page = 6,000 files, which is well above the + // 3,000 truncation banner so fetch-to-completion still has + // headroom to actually finish. + maxPages: PR_REVIEW_MAX_PAGES, + } + ) + ); + + const errorState = query.error ? classifyPrReviewQueryState(query.error) : null; + const firstPageErrorState = errorState; + + return { + query, + errorState, + firstPageErrorState, + }; +} + +/** + * Subscribes the viewed-files store for a specific PR (keyed by + * `owner/repo#number` + `headSha`). Returns the current viewed path + * set plus a `toggle` callback that flips a single path. The + * underlying store is a single SecureStore key shared across all + * PRs, so the hook re-reads on toggle rather than maintaining a + * long-lived in-memory cache. + */ +export function usePrReviewViewedFiles( + ref: { + owner: string; + repo: string; + number: number; + }, + headSha: string +) { + const { owner, repo, number } = ref; + const [paths, setPaths] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + + async function load() { + try { + const next = await getViewedFiles({ owner, repo, number }, headSha); + if (!cancelled) { + setPaths(next); + setIsLoading(false); + } + } catch { + if (!cancelled) { + setPaths([]); + setIsLoading(false); + } + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [owner, repo, number, headSha]); + + const toggle = useCallback( + async (path: string) => { + // Optimistic toggle: flip the local set first so the UI updates + // instantly. The store write is durable (SecureStore), and a + // read on the next mount will reconcile any divergence. + setPaths(previous => { + if (previous.includes(path)) { + return previous.filter(p => p !== path); + } + return [...previous, path]; + }); + await toggleViewedFile({ owner, repo, number, headSha, path }); + }, + [owner, repo, number, headSha] + ); + + const set = new Set(paths); + return { isViewed: (path: string) => set.has(path), toggle, isLoading }; +} + +/** + * Drives an infinite list query to completion. Used by the file + * navigator (S6c) which needs the full listed set to offer a working + * search/scrubber. Returns an imperative `run()` so the consumer can + * start it from a button or an effect and observe progress via + * `isRunning` / `loadedFiles` / `error`. + */ +export type FetchToCompletionResult = { + run: () => Promise; + isRunning: boolean; + loadedFiles: number; + totalFiles: number | null; + error: unknown; +}; + +export function useFetchToCompletion( + query: ReturnType['query'], + totalFiles: number | null +): FetchToCompletionResult { + const [isRunning, setIsRunning] = useState(false); + const [error, setError] = useState(null); + + const loadedFiles = (query.data?.pages ?? []).reduce((sum, page) => sum + page.files.length, 0); + + const run = useCallback(async () => { + if (query.isFetching || !query.hasNextPage) { + return; + } + setError(null); + setIsRunning(true); + try { + // Loop instead of recursing to keep the call stack flat. Pages must be + // fetched sequentially because each request needs the previous page's + // cursor, so `await` inside the loop is intentional here. + // The `hasNextPage` value is re-checked on each iteration via the + // result of `fetchNextPage` (not a stale closure), so the loop + // condition is intentionally the live query flag. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hasNextPage is re-evaluated after each page + while (query.hasNextPage) { + // eslint-disable-next-line no-await-in-loop -- sequential cursor pagination + const result = await query.fetchNextPage(); + if (!result.data || !result.hasNextPage) { + break; + } + } + } catch (caughtError) { + setError(caughtError); + } finally { + setIsRunning(false); + } + }, [query]); + + return { run, isRunning, loadedFiles, totalFiles, error }; +} diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts new file mode 100644 index 0000000000..475d90e40b --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts @@ -0,0 +1,21 @@ +// Pure file-level types and constants for the PR review diff viewer. Lives +// in a module with no React / react-query imports so it can be tested in +// plain Node. + +export type PrReviewFile = { + path: string; + previousPath: string | null; + status: string; + additions: number; + deletions: number; + patch: string | null; + patchMissing: boolean; +}; + +export const PR_REVIEW_FILES_PAGE_SIZE = 100; +export const PR_REVIEW_MAX_LISTED_FILES = 3000; + +// Server caps cursor at 60 (per the S2 read DTO contract). Pages after +// 60 are silently dropped; this is the same boundary the server uses +// for "no more pages" detection, so the client never asks for page 61. +export const PR_REVIEW_MAX_PAGES = 60; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts new file mode 100644 index 0000000000..810418dc60 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { + PR_REVIEW_TRUNCATION_BANNER_THRESHOLD, + shouldShowTruncationBanner, + truncationBannerCopy, +} from './pr-review-truncation'; + +describe('pr-review-truncation', () => { + it('hides the banner when changedFiles is at the threshold', () => { + expect(shouldShowTruncationBanner(PR_REVIEW_TRUNCATION_BANNER_THRESHOLD)).toBe(false); + }); + + it('hides the banner when changedFiles is below the threshold', () => { + expect(shouldShowTruncationBanner(0)).toBe(false); + expect(shouldShowTruncationBanner(2999)).toBe(false); + }); + + it('shows the banner when changedFiles is above the threshold', () => { + expect(shouldShowTruncationBanner(3001)).toBe(true); + expect(shouldShowTruncationBanner(10_000)).toBe(true); + }); + + it('renders the banner copy with the threshold + the actual file count', () => { + expect(truncationBannerCopy(3001)).toBe( + 'Showing the first 3,000 of 3,001 changed files — GitHub API limit' + ); + expect(truncationBannerCopy(12_345)).toBe( + 'Showing the first 3,000 of 12,345 changed files — GitHub API limit' + ); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts new file mode 100644 index 0000000000..52de6dd6da --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts @@ -0,0 +1,18 @@ +// Pure selectors for the Files tab. Kept here so the file-list +// component and the E2E verifier can share the same logic without +// importing the React component tree. +// +// These are intentionally simple — a 3,000-file PR is the boundary +// at which GitHub truncates its listFiles response, and we mirror +// that exactly so the user never sees a "Showing 2,500 of 3,000" +// banner when GitHub would have returned 2,500 anyway. + +export const PR_REVIEW_TRUNCATION_BANNER_THRESHOLD = 3000; + +export function shouldShowTruncationBanner(changedFiles: number): boolean { + return changedFiles > PR_REVIEW_TRUNCATION_BANNER_THRESHOLD; +} + +export function truncationBannerCopy(changedFiles: number): string { + return `Showing the first ${PR_REVIEW_TRUNCATION_BANNER_THRESHOLD.toLocaleString()} of ${changedFiles.toLocaleString()} changed files — GitHub API limit`; +} diff --git a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts new file mode 100644 index 0000000000..db1acee408 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts @@ -0,0 +1,107 @@ +// Context-expansion loader for the PR diff viewer. Encapsulates the +// expandedContext state and the progressive window fetch so the list +// component stays under the max-lines limit. + +import { useCallback, useRef, useState } from 'react'; + +import { + addContextLoadState, + type ExpandSeparatorState, + type ListItem, + readTrpcErrorCode, + setContextLines, +} from '@/lib/pr-review/diff/pr-diff-list-items'; +import { trpcClient } from '@/lib/trpc'; + +export type UsePrDiffContextLoaderResult = { + expandedContext: Record>; + setExpandedContext: React.Dispatch< + React.SetStateAction>> + >; + handleLoadContext: ( + item: Extract, + windowSize: number + ) => void; +}; + +export function usePrDiffContextLoader(args: { + owner: string; + repo: string; + headSha: string; +}): UsePrDiffContextLoaderResult { + const { owner, repo, headSha } = args; + const [expandedContext, setExpandedContext] = useState< + Record> + >({}); + const expandedContextRef = useRef(expandedContext); + expandedContextRef.current = expandedContext; + + const handleLoadContext = useCallback( + (item: Extract, windowSize: number) => { + const existingState = expandedContextRef.current[item.filePath]?.[item.context.gapIndex]; + const alreadyLoaded = + existingState?.status === 'loading' || + existingState?.status === 'partial' || + existingState?.status === 'error' + ? existingState.lines.length + : 0; + const startLine = item.context.startLine + alreadyLoaded; + const endLine = Math.min(item.context.endLine, startLine + windowSize - 1); + + setExpandedContext(prev => + addContextLoadState({ + state: prev, + filePath: item.filePath, + gapIndex: item.context.gapIndex, + status: 'loading', + }) + ); + void (async () => { + try { + const result = await trpcClient.githubPrReview.getFileLines.query({ + owner: item.ref.owner || owner, + repo: item.ref.repo || repo, + ref: item.ref.ref || headSha, + path: item.filePath, + startLine, + endLine, + }); + if (result.lines.length === 0) { + setExpandedContext(prev => + addContextLoadState({ + state: prev, + filePath: item.filePath, + gapIndex: item.context.gapIndex, + status: 'unavailable', + }) + ); + return; + } + setExpandedContext(prev => + setContextLines({ + state: prev, + filePath: item.filePath, + gapIndex: item.context.gapIndex, + lines: result.lines, + totalLines: result.totalLines, + }) + ); + } catch (error: unknown) { + const code = readTrpcErrorCode(error); + const status = code === 'NOT_FOUND' ? 'unavailable' : 'error'; + setExpandedContext(prev => + addContextLoadState({ + state: prev, + filePath: item.filePath, + gapIndex: item.context.gapIndex, + status, + }) + ); + } + })(); + }, + [owner, repo, headSha] + ); + + return { expandedContext, setExpandedContext, handleLoadContext }; +} From e47e3a15d97ccf2400fb5d0f3dd0e01427e55131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 03:29:58 +0200 Subject: [PATCH 09/19] feat(mobile): pr merge and auto-merge --- .../pr-review/merge/pr-merge-icons.tsx | 48 +++ .../merge/pr-merge-section-parts.tsx | 164 +++++++++ .../pr-review/merge/pr-merge-section.tsx | 289 +++++++++++++++ .../pr-review/merge/pr-merge-sheet-parts.tsx | 143 ++++++++ .../pr-review/merge/pr-merge-sheet.tsx | 336 ++++++++++++++++++ .../pr-review/pr-review-merge-screen.tsx | 105 +++++- .../pr-review/pr-review-overview.tsx | 11 +- .../merge/merge-blocked-reasons.test.ts | 278 +++++++++++++++ .../pr-review/merge/merge-blocked-reasons.ts | 287 +++++++++++++++ .../pr-review/merge/use-pr-merge-mutations.ts | 108 ++++++ 10 files changed, 1750 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx create mode 100644 apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx new file mode 100644 index 0000000000..a98144547d --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx @@ -0,0 +1,48 @@ +// Icon-map for the S8 merge surfaces. Lives here (not in the pure +// selector) so the selector's tests can load in plain Node without +// pulling in lucide-react-native. + +import { + AlertTriangle, + GitBranch, + GitPullRequest, + type LucideIcon, + ShieldAlert, + XCircle, +} from 'lucide-react-native'; + +import { + type AllowedMergeMethod, + defaultMergeMethodFor, + getAllowedMergeMethods, + type MergeBlockedReasonId, + PR_MERGE_LABELS, + type PrOverviewRepoSettings, +} from '@/lib/pr-review/merge/merge-blocked-reasons'; + +const BLOCKED_REASON_ICON: Record = { + conflicts: XCircle, + 'required-reviews': ShieldAlert, + 'failing-checks': AlertTriangle, + behind: GitBranch, + 'unstable-checks': AlertTriangle, + draft: GitPullRequest, + 'unknown-state': AlertTriangle, +}; + +export function mergeBlockedReasonIcon(kind: MergeBlockedReasonId): LucideIcon { + return BLOCKED_REASON_ICON[kind]; +} + +export type MergeMethodOption = { + value: AllowedMergeMethod; + label: string; +}; + +export function mergeMethodOptionsFor(repo: PrOverviewRepoSettings): MergeMethodOption[] { + return getAllowedMergeMethods(repo).map(value => ({ value, label: PR_MERGE_LABELS[value] })); +} + +export function defaultMergeMethodOptionFor(repo: PrOverviewRepoSettings): AllowedMergeMethod { + return defaultMergeMethodFor(repo); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx new file mode 100644 index 0000000000..863d48eb5e --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx @@ -0,0 +1,164 @@ +// Sub-components for the S8 merge section. Extracted out of +// `pr-merge-section.tsx` so the section file stays under the +// repo's 300-line limit. + +import { GitMerge, type LucideIcon, RefreshCw } from 'lucide-react-native'; +import { ActivityIndicator, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { mergeBlockedReasonIcon } from '@/components/pr-review/merge/pr-merge-icons'; +import { + type MergeBlockedReason, + type PrOverviewDto, +} from '@/lib/pr-review/merge/merge-blocked-reasons'; + +export function TerminalChip({ state }: Readonly<{ state: PrOverviewDto['state'] }>) { + const label = state === 'merged' ? 'Already merged' : 'This pull request is closed'; + return ( + + + Merge + + + {label} + + + ); +} + +export function MergeabilityCheckingRow() { + return ( + + + Checking mergeability… + + ); +} + +export function MergeabilityTimedOutRow({ + onRefresh, + isRefreshing, +}: Readonly<{ onRefresh: () => void; isRefreshing: boolean }>) { + return ( + + Couldn't determine mergeability. + + + ); +} + +function BlockedReasonRow({ reason }: Readonly<{ reason: MergeBlockedReason }>) { + const colors = useThemeColors(); + const Icon: LucideIcon = mergeBlockedReasonIcon(reason.iconKind); + const tone = (() => { + if (reason.severity === 'destructive') { + return colors.destructive; + } + if (reason.severity === 'warn') { + return colors.warn; + } + return colors.mutedForeground; + })(); + return ( + + + + {reason.title} + + {reason.detail} + + + + ); +} + +export function BlockedPanel({ + reasons, + allowUpdateBranch, + isUpdatePending, + onUpdateBranch, +}: Readonly<{ + reasons: MergeBlockedReason[]; + allowUpdateBranch: boolean; + isUpdatePending: boolean; + onUpdateBranch: () => void; +}>) { + const hasBehindReason = reasons.some(r => r.id === 'behind'); + return ( + + + Why this can't be merged yet + + + {reasons.map(reason => ( + + ))} + + {hasBehindReason && allowUpdateBranch ? ( + + ) : null} + + ); +} + +export function AutoMergeEnabledBanner({ + method, + onDisable, + isDisabling, +}: Readonly<{ method: string; onDisable: () => void; isDisabling: boolean }>) { + return ( + + + Auto-merge is on + + GitHub will merge this pull request automatically when all required checks pass (method:{' '} + {method.toLowerCase()}). + + + + + ); +} + +export function MergeNowButton({ + onPress, + accessibilityLabel, +}: Readonly<{ onPress: () => void; accessibilityLabel: string }>) { + return ( + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx new file mode 100644 index 0000000000..e0a3d0e36e --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx @@ -0,0 +1,289 @@ +// S8 merge section. The orchestrator mounts this at the +// `{/* S8 merge section mounts here */}` slot inside `PrReviewOverview`. +// It owns: +// - the bounded mergeability-polling effect (~3s × 10 → ~30s) +// - the terminal / blocked / mergeable / auto-merge branching +// - the open-sheet affordance (the orchestrator wires the route) +// +// The sheet content lives in `pr-merge-sheet.tsx`; the rendering +// sub-components live in `pr-merge-section-parts.tsx` to keep this file +// under the repo's 300-line limit. + +import { type Href, useRouter } from 'expo-router'; +import { GitMerge } from 'lucide-react-native'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { + type AllowedMergeMethod, + defaultMergeMethodFor, + getMergeabilityStatus, + getMergeBlockedReasons, + type PrMergeMethod, + type PrOverviewDto, +} from '@/lib/pr-review/merge/merge-blocked-reasons'; +import { + useDisableAutoMergeMutation, + useUpdateBranchMutation, +} from '@/lib/pr-review/merge/use-pr-merge-mutations'; +import { + AutoMergeEnabledBanner, + BlockedPanel, + MergeabilityCheckingRow, + MergeabilityTimedOutRow, + TerminalChip, +} from '@/components/pr-review/merge/pr-merge-section-parts'; + +export type PrMergeSectionProps = Readonly<{ + owner: string; + repo: string; + overview: PrOverviewDto; + /** Overview query refetch; used for both bounded polling and post-mutation refresh. */ + onRefetch: () => Promise; + isRefetching: boolean; +}>; + +const MERGEABILITY_POLL_INTERVAL_MS = 3000; +const MERGEABILITY_POLL_MAX_TICKS = 10; + +// Consume a rejecting promise from a timer/event handler. The mutation hooks +// surface failures via their `onError` toast and the query surfaces its own +// error state, so we only need to prevent an unhandled promise rejection here. +function ignoreRejection(promise: Promise): void { + void (async () => { + try { + await promise; + } catch { + /* handled by the hook's onError toast / query error state */ + } + })(); +} + +function mergeSheetHref(args: { + owner: string; + repo: string; + number: number; + mode: 'merge' | 'enable-auto-merge'; + method: PrMergeMethod; +}): Href { + const href: Href = { + pathname: '/(app)/pr-review/[owner]/[repo]/[number]/merge', + params: { + owner: args.owner, + repo: args.repo, + number: String(args.number), + mode: args.mode, + method: args.method, + }, + }; + return href; +} + +export function PrMergeSection({ + owner, + repo, + overview, + onRefetch, + isRefetching, +}: PrMergeSectionProps) { + const router = useRouter(); + const status = getMergeabilityStatus(overview); + const reasons = useMemo( + () => + getMergeBlockedReasons({ + state: overview.state, + draft: overview.draft, + mergeable: overview.mergeable, + mergeableState: overview.mergeableState, + reviewDecision: overview.reviewDecision, + allowUpdateBranch: overview.repo.allowUpdateBranch, + }), + [overview] + ); + + // Bounded polling for the brief window after GitHub queues a + // mergeability re-check. Poll on a fixed interval up to N ticks, then + // surface a retryable row. The timer is cleared on unmount AND on + // every status transition away from 'unknown'. + const tickRef = useRef(0); + const intervalRef = useRef | null>(null); + const [hasTimedOut, setHasTimedOut] = useState(false); + + useEffect(() => { + if (status !== 'unknown') { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + tickRef.current = 0; + setHasTimedOut(false); + return; + } + if (intervalRef.current) { + return; + } + tickRef.current = 0; + setHasTimedOut(false); + intervalRef.current = setInterval(() => { + tickRef.current += 1; + if (tickRef.current >= MERGEABILITY_POLL_MAX_TICKS) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setHasTimedOut(true); + return; + } + ignoreRejection(onRefetch()); + }, MERGEABILITY_POLL_INTERVAL_MS); + }, [status, onRefetch]); + + useEffect( + () => () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, + [] + ); + + const prRef = useMemo( + () => ({ owner, repo, number: overview.number }), + [owner, repo, overview.number] + ); + const updateBranch = useUpdateBranchMutation(prRef); + const disableAutoMerge = useDisableAutoMergeMutation(prRef); + + // Terminal state. + if (status === 'terminal') { + return ; + } + + // Unknown mergeability — poll until it resolves or the timer expires. + if (status === 'unknown') { + if (hasTimedOut) { + return ( + { + ignoreRejection(onRefetch()); + }} + isRefreshing={isRefetching} + /> + ); + } + return ; + } + + // Auto-merge active. + if (overview.autoMerge) { + return ( + + { + ignoreRejection( + disableAutoMerge.mutateAsync({ + owner, + repo, + number: overview.number, + prNodeId: overview.prNodeId, + }) + ); + }} + isDisabling={disableAutoMerge.isPending} + /> + {status === 'mergeable' ? ( + + ) : null} + + ); + } + + // Blocked. + if (status === 'blocked') { + return ( + + { + ignoreRejection( + updateBranch.mutateAsync({ + owner, + repo, + number: overview.number, + expectedHeadSha: overview.headSha, + }) + ); + }} + /> + {overview.repo.allowAutoMerge ? ( + + ) : null} + + ); + } + + // Mergeable — single "Merge" CTA. + return ( + + + Merge + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx new file mode 100644 index 0000000000..bea76a923c --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx @@ -0,0 +1,143 @@ +// Form sub-components for the S8 merge sheet. Extracted out of +// `pr-merge-sheet.tsx` to keep that file under the repo's 300-line limit. + +import { type RefObject } from 'react'; +import { Switch, TextInput, View } from 'react-native'; + +import { PillGroup } from '@/components/security-agent/settings-pill-group'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn } from '@/lib/utils'; +import { + type AllowedMergeMethod, + PR_MERGE_DESCRIPTIONS, +} from '@/lib/pr-review/merge/merge-blocked-reasons'; +import { type MergeMethodOption } from '@/components/pr-review/merge/pr-merge-icons'; + +export function MethodPicker({ + methodOptions, + method, + isDisabled, + onChange, +}: Readonly<{ + methodOptions: MergeMethodOption[]; + method: AllowedMergeMethod; + isDisabled: boolean; + onChange: (next: AllowedMergeMethod) => void; +}>) { + return ( + + + Method + + ({ value: o.value, label: o.label }))} + value={method} + disabled={isDisabled} + onChange={value => { + onChange(value); + }} + /> + + {PR_MERGE_DESCRIPTIONS[method]} + + + ); +} + +export function CommitTitleField({ + titleRef, + inputRef, + placeholder, + isDisabled, +}: Readonly<{ + titleRef: RefObject; + inputRef: RefObject; + placeholder: string; + isDisabled: boolean; +}>) { + const colors = useThemeColors(); + return ( + + Commit title + { + titleRef.current = value; + }} + className={cn( + 'rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground', + 'focus:border-ring' + )} + multiline + /> + + ); +} + +export function CommitMessageField({ + messageRef, + inputRef, + isDisabled, +}: Readonly<{ + messageRef: RefObject; + inputRef: RefObject; + isDisabled: boolean; +}>) { + const colors = useThemeColors(); + return ( + + Commit message + { + messageRef.current = value; + }} + className={cn( + 'min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground', + 'focus:border-ring' + )} + multiline + textAlignVertical="top" + /> + + ); +} + +export function DeleteBranchToggle({ + value, + onChange, + isDisabled, +}: Readonly<{ + value: boolean; + onChange: (next: boolean) => void; + isDisabled: boolean; +}>) { + return ( + + + Delete branch + + Delete the head branch after the merge succeeds. + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx new file mode 100644 index 0000000000..ecd273a0ab --- /dev/null +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -0,0 +1,336 @@ +// S8 merge sheet. The orchestrator mounts this inside the +// `[owner]/[repo]/[number]/merge.tsx` route; the orchestrator-wired +// `PrReviewMergeScreen` fetches the overview DTO, derives the form's +// initial state, and forwards everything as props. +// +// Two modes share the same form: +// - 'merge' — submits `mergePullRequest` +// - 'enable-auto-merge' — submits `enableAutoMerge` +// +// Toasts paint behind formSheets on iOS, so this sheet ALSO renders +// inline errors while the underlying mutation hook still calls +// `toast.error` in `onError`. The form stays open until the user +// dismisses (cancel) or the mutation succeeds (auto-dismiss). + +import * as Haptics from 'expo-haptics'; +import { Alert, ScrollView, type TextInput, View } from 'react-native'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { + type AllowedMergeMethod, + type PrMergeMethod, + type PrOverviewRepoSettings, +} from '@/lib/pr-review/merge/merge-blocked-reasons'; +import { + useEnableAutoMergeMutation, + useMergePullRequestMutation, +} from '@/lib/pr-review/merge/use-pr-merge-mutations'; +import { + defaultMergeMethodOptionFor, + mergeMethodOptionsFor, +} from '@/components/pr-review/merge/pr-merge-icons'; +import { + CommitMessageField, + CommitTitleField, + DeleteBranchToggle, + MethodPicker, +} from '@/components/pr-review/merge/pr-merge-sheet-parts'; + +export type PrMergeSheetMode = 'merge' | 'enable-auto-merge'; + +export type PrMergeSheetProps = Readonly<{ + owner: string; + /** The GitHub repository name (the `repo` path segment, not the settings object). */ + repoName: string; + number: number; + headSha: string; + headRef: string; + isCrossRepo: boolean; + prNodeId: string; + title: string; + bodyMarkdown: string | null; + baseRef: string; + repo: PrOverviewRepoSettings; + initialMethod: PrMergeMethod; + mode: PrMergeSheetMode; + /** Called after a successful merge / auto-merge enable so the orchestrator can refetch. */ + onRefetch: () => Promise; + /** Called when the user cancels or after a successful submit. */ + onDismiss: () => void; +}>; + +type MergePullRequestInput = { + owner: string; + repo: string; + number: number; + method: 'merge' | 'squash' | 'rebase'; + commitTitle?: string; + commitMessage?: string; + deleteBranch: boolean; + expectedHeadSha: string; + headRef: string; + isCrossRepo: boolean; +}; + +type AutoMergeInput = { + owner: string; + repo: string; + number: number; + prNodeId: string; + method?: 'MERGE' | 'SQUASH' | 'REBASE'; + commitTitle?: string; + commitMessage?: string; +}; + +function defaultCommitTitle(title: string, number: number): string { + return `${title} (#${number})`; +} + +function defaultCommitMessage(method: PrMergeMethod, body: string | null): string { + if (method === 'squash') { + return body && body.trim().length > 0 ? body : ''; + } + if (body && body.trim().length > 0) { + return body; + } + return ''; +} + +export function PrMergeSheet(props: PrMergeSheetProps) { + const { + owner, + repoName, + number, + headSha, + headRef, + isCrossRepo, + prNodeId, + title, + bodyMarkdown, + repo: repoSettings, + initialMethod, + mode, + onRefetch, + onDismiss, + } = props; + + const methodOptions = useMemo(() => mergeMethodOptionsFor(repoSettings), [repoSettings]); + const safeInitial: AllowedMergeMethod = useMemo( + () => + methodOptions.find(o => o.value === initialMethod)?.value ?? + defaultMergeMethodOptionFor(repoSettings), + [initialMethod, methodOptions, repoSettings] + ); + const [method, setMethod] = useState(safeInitial); + + const showDeleteBranchToggle = !isCrossRepo; + const [deleteBranch, setDeleteBranch] = useState(repoSettings.deleteBranchOnMerge); + + // iOS uncontrolled-input pattern: store text in a ref via onChangeText, + // use state only for derived UI (the inline error from a failed submit), + // read the ref on submit. `defaultValue` is for the first commit only. + const titleInputRef = useRef(null); + const messageInputRef = useRef(null); + const titleRef = useRef(defaultCommitTitle(title, number)); + const messageRef = useRef(defaultCommitMessage(safeInitial, bodyMarkdown)); + + const [inlineError, setInlineError] = useState(null); + + const ref: { owner: string; repo: string; number: number } = useMemo( + () => ({ owner, repo: repoName, number }), + [owner, repoName, number] + ); + + const mergeMutation = useMergePullRequestMutation(ref); + const enableAutoMergeMutation = useEnableAutoMergeMutation(ref); + + const isMutating = + (mode === 'merge' && mergeMutation.isPending) || + (mode === 'enable-auto-merge' && enableAutoMergeMutation.isPending); + const lastError = mode === 'merge' ? mergeMutation.error : enableAutoMergeMutation.error; + + useEffect(() => { + if (lastError) { + const message = + lastError instanceof Error ? lastError.message : 'Could not merge pull request.'; + setInlineError(message); + } + }, [lastError]); + + function resetForNewMethod(next: AllowedMergeMethod) { + setMethod(next); + messageRef.current = defaultCommitMessage(next, bodyMarkdown); + } + + function buildMergeInput(): MergePullRequestInput { + return { + owner, + repo: repoName, + number, + method, + commitTitle: titleRef.current.trim().length > 0 ? titleRef.current.trim() : undefined, + commitMessage: messageRef.current.trim().length > 0 ? messageRef.current.trim() : undefined, + deleteBranch: showDeleteBranchToggle ? deleteBranch : false, + expectedHeadSha: headSha, + headRef, + isCrossRepo, + }; + } + + function buildAutoMergeInput(): AutoMergeInput { + const autoMethod: 'MERGE' | 'SQUASH' | 'REBASE' = (() => { + if (method === 'merge') { + return 'MERGE'; + } + if (method === 'squash') { + return 'SQUASH'; + } + return 'REBASE'; + })(); + return { + owner, + repo: repoName, + number, + prNodeId, + method: autoMethod, + commitTitle: titleRef.current.trim().length > 0 ? titleRef.current.trim() : undefined, + commitMessage: messageRef.current.trim().length > 0 ? messageRef.current.trim() : undefined, + }; + } + + async function performSubmit() { + setInlineError(null); + try { + // eslint-disable-next-line typescript-eslint/prefer-ternary -- awaits inside branches can't be a ternary expression + if (mode === 'merge') { + await mergeMutation.mutateAsync(buildMergeInput()); + } else { + await enableAutoMergeMutation.mutateAsync(buildAutoMergeInput()); + } + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + await onRefetch(); + // Dismiss exactly this merge route; `onDismiss` (router.back) leaves the + // refreshed PR review screen visible. Do NOT also call router.back() + // here or it would pop the review screen too. + onDismiss(); + } catch (error) { + if (error instanceof Error && error.message) { + setInlineError(error.message); + } else { + setInlineError('Could not merge pull request.'); + } + } + } + + function handleConfirmPress() { + if (isMutating || noMethodsAllowed) { + return; + } + setInlineError(null); + + const submit = () => { + void performSubmit(); + }; + + if (mode === 'merge') { + Alert.alert('Merge pull request?', 'This will merge your changes into the base branch.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Merge', style: 'destructive', onPress: submit }, + ]); + return; + } + Alert.alert( + 'Enable auto-merge?', + 'GitHub will merge this pull request automatically when all required checks pass.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Enable auto-merge', style: 'destructive', onPress: submit }, + ] + ); + } + + const submitLabel = mode === 'merge' ? 'Merge' : 'Enable auto-merge'; + // A repository can (rarely) have every merge method disabled. GitHub would + // reject any submission, so surface it explicitly and block the action + // rather than sending a method the repo does not allow. + const noMethodsAllowed = methodOptions.length === 0; + + return ( + + + {noMethodsAllowed ? ( + + + This repository has no enabled merge methods. Ask a repository admin to enable merge, + squash, or rebase merging. + + + ) : ( + + )} + + {/* Remount on method change so the visible commit message matches the + method-specific default that will actually be submitted. */} + + {showDeleteBranchToggle ? ( + + ) : null} + {inlineError ? ( + + {inlineError} + + ) : null} + + + + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx index 51035c6221..64f59878c1 100644 --- a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx @@ -1,40 +1,109 @@ -import { useLocalSearchParams } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { type ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; +import { PrMergeSheet } from '@/components/pr-review/merge/pr-merge-sheet'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; +import { parseParam } from '@/lib/route-params'; +import { useTRPC } from '@/lib/trpc'; -// Thin stub for the merge sheet. S8 implements the merge-method picker -// and the merge mutation. S4b only needs the route to mount and pin the -// param contract. type Params = { owner: string; repo: string; number: string; + mode?: string; + method?: string; }; +const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']); + +/** + * Merge formSheet route. Reads the PR + mode/method from params, fetches the + * overview so the sheet has the repo settings + head SHA fence, and mounts the + * S8 merge sheet. Rendered inside the `[number]` layout's formSheet stack. + */ export function PrReviewMergeScreen() { + const router = useRouter(); const colors = useThemeColors(); const params = useLocalSearchParams(); + const owner = parseParam(params.owner) ?? ''; + const repo = parseParam(params.repo) ?? ''; + const rawNumber = parseParam(params.number) ?? ''; + const number = Number.parseInt(rawNumber, 10); + const mode = params.mode === 'enable-auto-merge' ? 'enable-auto-merge' : 'merge'; + const method: PrMergeMethod = MERGE_METHODS.has(params.method as PrMergeMethod) + ? (params.method as PrMergeMethod) + : 'merge'; + + const trpc = useTRPC(); + const pr = useQuery( + trpc.githubPrReview.getPullRequest.queryOptions( + { owner, repo, number }, + { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } + ) + ); + + let content: ReactNode = null; + if (pr.isLoading) { + content = ( + + + + ); + } else if (pr.isError || !pr.data) { + content = ( + + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + + ); + } else { + content = ( + { + await pr.refetch(); + }} + onDismiss={() => { + router.back(); + }} + /> + ); + } return ( { + router.back(); + }} /> - - - Merge picker lands in S8. - - + {content} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 1e9eca4dc3..6df6627eeb 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -8,6 +8,7 @@ import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { MarkdownText } from '@/components/agents/markdown-text'; import { PrReviewChecksSection } from '@/components/pr-review/pr-review-checks-section'; +import { PrMergeSection } from '@/components/pr-review/merge/pr-merge-section'; import { describePrState, formatPrCounts, @@ -197,7 +198,15 @@ export function PrReviewOverview({ - {/* S8 merge section mounts here */} + { + await pr.refetch(); + }} + isRefetching={pr.isFetching} + /> {formatPrCounts(data.counts.additions, data.counts.deletions)} · head{' '} diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts new file mode 100644 index 0000000000..71380f5e34 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; + +import { + defaultMergeMethodFor, + getAllowedMergeMethods, + getMergeabilityStatus, + getMergeBlockedReasons, + type MergeBlockedReasonsArgs, +} from './merge-blocked-reasons'; + +function repo(overrides: Partial = {}): MergeBlockedReasonsArgs { + return { + state: 'open', + draft: false, + mergeable: false, + mergeableState: 'blocked', + reviewDecision: 'REVIEW_REQUIRED', + allowUpdateBranch: true, + ...overrides, + }; +} + +describe('getMergeabilityStatus', () => { + it('returns terminal for merged PRs', () => { + expect( + getMergeabilityStatus({ state: 'merged', mergeable: true, mergeableState: 'clean' }) + ).toBe('terminal'); + }); + + it('returns terminal for closed PRs', () => { + expect( + getMergeabilityStatus({ state: 'closed', mergeable: true, mergeableState: 'clean' }) + ).toBe('terminal'); + }); + + it('returns unknown when mergeable is null', () => { + expect(getMergeabilityStatus({ state: 'open', mergeable: null, mergeableState: 'clean' })).toBe( + 'unknown' + ); + }); + + it('returns unknown when mergeableState is null', () => { + expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: null })).toBe( + 'unknown' + ); + }); + + it('returns unknown when mergeableState is "unknown"', () => { + expect( + getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'unknown' }) + ).toBe('unknown'); + }); + + it('returns mergeable only when mergeable=true AND mergeableState=clean', () => { + expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'clean' })).toBe( + 'mergeable' + ); + }); + + it('returns blocked when mergeable=true but state is not clean', () => { + expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'dirty' })).toBe( + 'blocked' + ); + }); + + it('returns blocked when mergeable=false even with a clean state (race)', () => { + expect( + getMergeabilityStatus({ state: 'open', mergeable: false, mergeableState: 'clean' }) + ).toBe('blocked'); + }); +}); + +describe('getMergeBlockedReasons', () => { + it('returns an empty list for merged/closed PRs', () => { + expect( + getMergeBlockedReasons(repo({ state: 'merged', mergeable: false, mergeableState: 'clean' })) + ).toEqual([]); + expect( + getMergeBlockedReasons(repo({ state: 'closed', mergeable: false, mergeableState: 'clean' })) + ).toEqual([]); + }); + + it('reports dirty as a destructive conflicts reason', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'dirty', reviewDecision: 'APPROVED' }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.id).toBe('conflicts'); + expect(reasons[0]?.iconKind).toBe('conflicts'); + expect(reasons[0]?.severity).toBe('destructive'); + }); + + it('reports blocked with required-reviews + failing-checks', () => { + const reasons = getMergeBlockedReasons(repo({ mergeable: false, mergeableState: 'blocked' })); + expect(reasons.map(r => r.id)).toEqual(['required-reviews', 'failing-checks']); + expect(reasons[0]?.iconKind).toBe('required-reviews'); + expect(reasons[0]?.severity).toBe('warn'); + expect(reasons[1]?.iconKind).toBe('failing-checks'); + expect(reasons[1]?.severity).toBe('destructive'); + }); + + it('reports behind as a branch-out-of-date reason with a rebase/update CTA', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'behind', reviewDecision: 'APPROVED' }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.id).toBe('behind'); + expect(reasons[0]?.iconKind).toBe('behind'); + expect(reasons[0]?.detail).toContain('Update the branch from the base'); + }); + + it('shows a rebase-only detail when the repo disallows Update branch', () => { + const reasons = getMergeBlockedReasons( + repo({ + mergeable: false, + mergeableState: 'behind', + reviewDecision: 'APPROVED', + allowUpdateBranch: false, + }) + ); + expect(reasons[0]?.detail).toContain('Rebase or update the branch'); + }); + + it('reports unstable as a non-required-checks-failing info reason', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: true, mergeableState: 'unstable', reviewDecision: 'APPROVED' }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.id).toBe('unstable-checks'); + expect(reasons[0]?.iconKind).toBe('unstable-checks'); + expect(reasons[0]?.severity).toBe('info'); + }); + + it('reports draft as a draft info reason', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'draft', reviewDecision: null }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.id).toBe('draft'); + expect(reasons[0]?.iconKind).toBe('draft'); + expect(reasons[0]?.severity).toBe('info'); + }); + + it('adds a required-reviews reason when reviewDecision is REVIEW_REQUIRED and mergeableState did not include it', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'dirty', reviewDecision: 'REVIEW_REQUIRED' }) + ); + expect(reasons.map(r => r.id)).toEqual(['conflicts', 'required-reviews']); + }); + + it('does not double-list required-reviews when mergeableState=blocked already produced it', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'blocked', reviewDecision: 'REVIEW_REQUIRED' }) + ); + expect(reasons.filter(r => r.id === 'required-reviews')).toHaveLength(1); + }); + + it('adds a draft reason when the PR is marked draft and mergeableState did not already report it', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'dirty', draft: true, reviewDecision: 'APPROVED' }) + ); + expect(reasons.map(r => r.id)).toEqual(['conflicts', 'draft']); + }); + + it('does not double-list draft when mergeableState=draft already produced it', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'draft', draft: true, reviewDecision: 'APPROVED' }) + ); + expect(reasons.filter(r => r.id === 'draft')).toHaveLength(1); + }); + + it('returns an unknown-state reason when mergeableState is unknown', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: true, mergeableState: 'unknown', reviewDecision: 'APPROVED' }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.id).toBe('unknown-state'); + expect(reasons[0]?.iconKind).toBe('unknown-state'); + }); + + it('returns an unknown-state reason when mergeableState is null', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: true, mergeableState: null, reviewDecision: 'APPROVED' }) + ); + expect(reasons[0]?.id).toBe('unknown-state'); + }); + + it('returns an empty list for a fully mergeable PR', () => { + expect( + getMergeBlockedReasons( + repo({ mergeable: true, mergeableState: 'clean', reviewDecision: 'APPROVED' }) + ) + ).toEqual([]); + }); + + it('surfaces unknown future mergeableState values rather than hiding the block', () => { + const reasons = getMergeBlockedReasons( + repo({ mergeable: false, mergeableState: 'some_future_value', reviewDecision: 'APPROVED' }) + ); + expect(reasons).toHaveLength(1); + expect(reasons[0]?.detail).toContain('some_future_value'); + }); +}); + +describe('getAllowedMergeMethods', () => { + it('returns methods in a stable order, filtering out disabled ones', () => { + expect( + getAllowedMergeMethods({ + allowMergeCommit: true, + allowSquashMerge: true, + allowRebaseMerge: true, + allowAutoMerge: true, + deleteBranchOnMerge: true, + allowUpdateBranch: true, + viewerCanPush: true, + viewerCanAdmin: true, + }) + ).toEqual(['merge', 'squash', 'rebase']); + + expect( + getAllowedMergeMethods({ + allowMergeCommit: false, + allowSquashMerge: true, + allowRebaseMerge: false, + allowAutoMerge: true, + deleteBranchOnMerge: true, + allowUpdateBranch: true, + viewerCanPush: true, + viewerCanAdmin: true, + }) + ).toEqual(['squash']); + + expect( + getAllowedMergeMethods({ + allowMergeCommit: false, + allowSquashMerge: false, + allowRebaseMerge: false, + allowAutoMerge: true, + deleteBranchOnMerge: true, + allowUpdateBranch: true, + viewerCanPush: true, + viewerCanAdmin: true, + }) + ).toEqual([]); + }); +}); + +describe('defaultMergeMethodFor', () => { + it('prefers squash when the repo only allows squash', () => { + expect( + defaultMergeMethodFor({ + allowMergeCommit: false, + allowSquashMerge: true, + allowRebaseMerge: false, + allowAutoMerge: true, + deleteBranchOnMerge: true, + allowUpdateBranch: true, + viewerCanPush: true, + viewerCanAdmin: true, + }) + ).toBe('squash'); + }); + + it('falls back to merge when the repo has no allowed methods', () => { + expect( + defaultMergeMethodFor({ + allowMergeCommit: false, + allowSquashMerge: false, + allowRebaseMerge: false, + allowAutoMerge: true, + deleteBranchOnMerge: true, + allowUpdateBranch: true, + viewerCanPush: true, + viewerCanAdmin: true, + }) + ).toBe('merge'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts new file mode 100644 index 0000000000..19e3596191 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts @@ -0,0 +1,287 @@ +// Pure selector: derives the merge-state decision tree that drives the +// S8 merge section (and its unit tests). No React, no react-query, no +// expo modules, and NO icon library — the section/sheet components +// translate the `iconKind` strings into Lucide icons. Keeping the icon +// mapping out of this module lets the tests load in plain Node without +// pulling in lucide-react-native (whose ESM build uses `import.meta` in +// ways the repo's vitest setup doesn't transform). + +export type PrMergeMethod = 'merge' | 'squash' | 'rebase'; +export type PrReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null; + +export type PrOverviewRepoSettings = { + allowMergeCommit: boolean; + allowSquashMerge: boolean; + allowRebaseMerge: boolean; + allowAutoMerge: boolean; + deleteBranchOnMerge: boolean; + allowUpdateBranch: boolean; + viewerCanPush: boolean; + viewerCanAdmin: boolean; +}; + +export type PrOverviewDto = { + state: 'open' | 'closed' | 'merged'; + draft: boolean; + baseRef: string; + headRef: string; + isCrossRepo: boolean; + headSha: string; + prNodeId: string; + title: string; + bodyMarkdown: string | null; + number: number; + mergeable: boolean | null; + mergeableState: string | null; + autoMerge: { method: string } | null; + reviewDecision: PrReviewDecision; + repo: PrOverviewRepoSettings; +}; + +export type MergeabilityStatus = 'unknown' | 'blocked' | 'mergeable' | 'terminal'; + +export type MergeBlockedReasonId = + | 'conflicts' + | 'required-reviews' + | 'failing-checks' + | 'behind' + | 'unstable-checks' + | 'draft' + | 'unknown-state'; + +export type MergeBlockedReasonSeverity = 'info' | 'warn' | 'destructive'; + +export type MergeBlockedReason = { + id: MergeBlockedReasonId; + /** Stable identifier the section uses to look up an icon (see pr-merge-icons.ts). */ + iconKind: MergeBlockedReasonId; + severity: MergeBlockedReasonSeverity; + title: string; + detail: string; +}; + +export type MergeBlockedReasonsArgs = { + state: PrOverviewDto['state']; + draft: PrOverviewDto['draft']; + mergeable: PrOverviewDto['mergeable']; + mergeableState: PrOverviewDto['mergeableState']; + reviewDecision: PrReviewDecision; + allowUpdateBranch: boolean; +}; + +/** + * High-level mergeability status the section uses to pick which UI to + * render. `unknown` covers the brief window after GitHub queues a + * mergeability re-check; the section polls the overview in that case. + * `terminal` means the PR is closed/merged and no further action exists. + */ +export function getMergeabilityStatus(args: { + state: PrOverviewDto['state']; + mergeable: PrOverviewDto['mergeable']; + mergeableState: PrOverviewDto['mergeableState']; +}): MergeabilityStatus { + if (args.state !== 'open') { + return 'terminal'; + } + if ( + args.mergeable === null || + args.mergeableState === null || + args.mergeableState === 'unknown' + ) { + return 'unknown'; + } + if (args.mergeable && args.mergeableState === 'clean') { + return 'mergeable'; + } + return 'blocked'; +} + +const UNKNOWN_REASON: MergeBlockedReason = { + id: 'unknown-state', + iconKind: 'unknown-state', + severity: 'warn', + title: "GitHub hasn't reported a mergeable state", + detail: 'Try refreshing the overview in a moment.', +}; + +const CONFLICTS_REASON: MergeBlockedReason = { + id: 'conflicts', + iconKind: 'conflicts', + severity: 'destructive', + title: 'Merge conflicts', + detail: 'Resolve the merge conflicts on this branch before merging.', +}; + +const REQUIRED_REVIEWS_REASON: MergeBlockedReason = { + id: 'required-reviews', + iconKind: 'required-reviews', + severity: 'warn', + title: 'Required reviews missing', + detail: 'Approvals from the required reviewers are missing or pending.', +}; + +const FAILING_CHECKS_REASON: MergeBlockedReason = { + id: 'failing-checks', + iconKind: 'failing-checks', + severity: 'destructive', + title: 'Failing required checks', + detail: 'Required status checks are failing.', +}; + +const UNSTABLE_REASON: MergeBlockedReason = { + id: 'unstable-checks', + iconKind: 'unstable-checks', + severity: 'info', + title: 'Some checks are failing', + detail: 'Non-required checks are failing. They will not block the merge.', +}; + +const DRAFT_REASON: MergeBlockedReason = { + id: 'draft', + iconKind: 'draft', + severity: 'info', + title: 'Draft pull request', + detail: 'Mark the pull request as ready for review before merging.', +}; + +function behindReason(allowUpdateBranch: boolean): MergeBlockedReason { + return { + id: 'behind', + iconKind: 'behind', + severity: 'warn', + title: 'Branch is out of date', + detail: allowUpdateBranch + ? 'Update the branch from the base, or rebase, before merging.' + : 'The head branch is behind the base. Rebase or update the branch before merging.', + }; +} + +/** + * Ordered, deduplicated list of why-this-PR-can't-be-merged-yet reasons. + * Empty when the PR is mergeable. Order: most specific / most actionable + * first — GitHub's `mergeable_state` wins as the top reason when it + * fires, then reviews, then draft. + */ +export function getMergeBlockedReasons(args: MergeBlockedReasonsArgs): MergeBlockedReason[] { + if (args.state !== 'open') { + return []; + } + const reasons: MergeBlockedReason[] = []; + const seen = new Set(); + + const push = (reason: MergeBlockedReason) => { + if (seen.has(reason.id)) { + return; + } + seen.add(reason.id); + reasons.push(reason); + }; + + switch (args.mergeableState) { + case 'dirty': { + push(CONFLICTS_REASON); + break; + } + case 'blocked': { + push(REQUIRED_REVIEWS_REASON); + push(FAILING_CHECKS_REASON); + break; + } + case 'behind': { + push(behindReason(args.allowUpdateBranch)); + break; + } + case 'unstable': { + push(UNSTABLE_REASON); + break; + } + case 'draft': { + push(DRAFT_REASON); + break; + } + case 'clean': { + if (args.mergeable === false) { + push(UNKNOWN_REASON); + } + break; + } + case 'unknown': + case null: { + push(UNKNOWN_REASON); + break; + } + default: { + // GitHub may add new mergeable_state values over time — surface the + // raw value as a generic blocked reason rather than silently + // showing nothing. + push({ + id: 'unknown-state', + iconKind: 'unknown-state', + severity: 'warn', + title: 'This pull request is not mergeable yet', + detail: `GitHub reported a "${args.mergeableState}" mergeable state.`, + }); + } + } + + if (args.reviewDecision === 'REVIEW_REQUIRED' && args.mergeableState !== 'blocked') { + push(REQUIRED_REVIEWS_REASON); + } + + if (args.draft && args.mergeableState !== 'draft') { + push(DRAFT_REASON); + } + + return reasons; +} + +export type AllowedMergeMethod = PrMergeMethod; + +/** + * Repo-allowed merge methods, in the order the picker should show them. + * Squashes and merges are the two defaults; rebase is rare but still + * honored when enabled. + */ +export function getAllowedMergeMethods(repo: PrOverviewRepoSettings): AllowedMergeMethod[] { + const methods: AllowedMergeMethod[] = []; + if (repo.allowMergeCommit) { + methods.push('merge'); + } + if (repo.allowSquashMerge) { + methods.push('squash'); + } + if (repo.allowRebaseMerge) { + methods.push('rebase'); + } + return methods; +} + +export const PR_MERGE_LABELS: Record = { + merge: 'Create a merge commit', + squash: 'Squash and merge', + rebase: 'Rebase and merge', +}; + +export const PR_MERGE_AUTO_METHODS: Record = { + merge: 'MERGE', + squash: 'SQUASH', + rebase: 'REBASE', +}; + +export const PR_MERGE_DESCRIPTIONS: Record = { + merge: 'Combine all commits from this branch into the base branch with a merge commit.', + squash: 'Combine all commits from this branch into a single commit on the base branch.', + rebase: 'Replay all commits from this branch onto the base branch without a merge commit.', +}; + +/** The default method the picker selects on first open. */ +export function defaultMergeMethodFor(repo: PrOverviewRepoSettings): AllowedMergeMethod { + const allowed = getAllowedMergeMethods(repo); + // The server should never return a PR with no allowed methods, but if + // it does we still need a stable default for the form state. + if (allowed.length === 0) { + return 'merge'; + } + const first = allowed[0]; + return first ?? 'merge'; +} diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts new file mode 100644 index 0000000000..3f29bcaa24 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts @@ -0,0 +1,108 @@ +// S8 merge-side mutation hooks. Pattern mirrors the repo's existing +// mutation hooks (useSessionMutations, useSecurityAgentMutations): +// - `onError` toasts the message +// - `onSettled` invalidates the overview + the per-PR listChecks / +// listFiles caches so the new head SHA refetches +// - keeps the mutation hook thin and lets the sheet / section handle +// inline errors (toasts paint behind formSheets) +// +// listChecks is keyed by `(owner, repo, ref)`. The head ref will change +// after a successful merge / update-branch, so we invalidate the +// procedure PATH (not a single key) — every cached check list for this +// PR is dropped and any mounted consumer re-fetches against the new +// head. `listFiles` is per-page; we invalidate the full procedure too. + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner-native'; + +import { useTRPC } from '@/lib/trpc'; + +type PrRef = { owner: string; repo: string; number: number }; + +function usePrRefKeys(ref: PrRef) { + const trpc = useTRPC(); + return { + getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref), + listChecksPath: trpc.githubPrReview.listChecks.pathFilter(), + listFilesPath: trpc.githubPrReview.listFiles.pathFilter(), + }; +} + +async function invalidatePrCaches( + queryClient: ReturnType, + keys: ReturnType +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: keys.getPullRequest }), + queryClient.invalidateQueries(keys.listChecksPath), + queryClient.invalidateQueries(keys.listFilesPath), + ]); +} + +export function useMergePullRequestMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.mergePullRequest.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }) + ); +} + +export function useUpdateBranchMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.updateBranch.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }) + ); +} + +export function useEnableAutoMergeMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.enableAutoMerge.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }) + ); +} + +export function useDisableAutoMergeMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.disableAutoMerge.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidatePrCaches(queryClient, keys); + }, + }) + ); +} From dfa5d5f42d35f9a2d61aefd5ccc8c2b7ed6d3c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 03:56:59 +0200 Subject: [PATCH 10/19] feat(mobile): pr file navigator and tablet layout --- .../diff/pr-diff-file-list-header.tsx | 172 ++++++++++ .../diff/pr-diff-file-list-render.tsx | 134 ++++++++ .../pr-review/diff/pr-diff-file-list.tsx | 124 +++---- .../pr-review/diff/pr-diff-file-navigator.tsx | 313 ++++++++++++++++++ .../diff/pr-diff-navigator-file-row.tsx | 97 ++++++ .../diff/pr-diff-side-by-side-row.tsx | 279 ++++++++++++++++ .../pr-review-file-navigator-screen.tsx | 83 ++++- .../pr-review/diff/pr-diff-list-builder.ts | 99 ++++-- .../lib/pr-review/diff/pr-diff-list-items.ts | 40 ++- .../diff/pr-review-file-list-state.ts | 34 +- .../lib/pr-review/diff/side-by-side.test.ts | 138 ++++++++ .../src/lib/pr-review/diff/side-by-side.ts | 104 ++++++ 12 files changed, 1495 insertions(+), 122 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx create mode 100644 apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/side-by-side.ts diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx new file mode 100644 index 0000000000..a820fbf406 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -0,0 +1,172 @@ +// Compact header row for the PR Files tab. Shown above the FlashList +// with two responsibilities: +// 1. Navigator entry: "Files · n of m viewed" pressable that opens +// the file-navigator sheet route. Always visible. +// 2. Tablet layout toggle: unified vs side-by-side. Only on tablets +// (the `useIsTablet` hook gates it). The selection is local +// component state — not persisted across mounts. +// +// Phones see the navigator entry only. Tablets see both. + +import { type Href, useRouter } from 'expo-router'; +import { Columns2, Rows3 } from 'lucide-react-native'; +import { useCallback, useMemo, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useIsTablet } from '@/lib/hooks/use-is-tablet'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { cn } from '@/lib/utils'; + +export type PrDiffFileListHeaderProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly viewedCount: number; + readonly totalListed: number; + readonly isTruncated: boolean; + readonly viewMode: DiffViewMode; + readonly onViewModeChange: (mode: DiffViewMode) => void; +}; + +const FILE_NAVIGATOR_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/file-navigator' as const; + +export function PrDiffFileListHeader({ + owner, + repo, + number, + viewedCount, + totalListed, + isTruncated, + viewMode, + onViewModeChange, +}: PrDiffFileListHeaderProps) { + const router = useRouter(); + const isTablet = useIsTablet(); + const colors = useThemeColors(); + + const navigatorHref = useMemo>( + () => ({ pathname: FILE_NAVIGATOR_PATH, params: { owner, repo, number } }), + [owner, repo, number] + ); + + const handleOpenNavigator = useCallback(() => { + router.push(navigatorHref); + }, [router, navigatorHref]); + + return ( + + + + + Files · {viewedCount.toLocaleString()} of {totalListed.toLocaleString()} viewed + + {isTruncated ? ( + + (listed) + + ) : null} + + {isTablet ? : null} + + ); +} + +function ViewModeToggle({ + viewMode, + onChange, +}: { + viewMode: DiffViewMode; + onChange: (mode: DiffViewMode) => void; +}) { + const colors = useThemeColors(); + return ( + + { + onChange('unified'); + }} + testId="diff-mode-unified" + colors={colors} + /> + { + onChange('side-by-side'); + }} + testId="diff-mode-side-by-side" + colors={colors} + /> + + ); +} + +function ViewModeButton({ + active, + label, + onPress, + testId, + colors, +}: { + active: boolean; + label: string; + onPress: () => void; + testId: string; + colors: ReturnType; +}) { + return ( + + + + {label} + + + ); +} + +/** + * Convenience hook for the diff list screen: returns the current view + * mode plus a setter. Default is `unified`. The toggle is local state + * (not persisted) per the S6c spec. + */ +export function useDiffViewMode(): { + viewMode: DiffViewMode; + setViewMode: (mode: DiffViewMode) => void; +} { + const [viewMode, setViewMode] = useState('unified'); + return { viewMode, setViewMode }; +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx new file mode 100644 index 0000000000..907f2500d3 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx @@ -0,0 +1,134 @@ +// `renderItem` for the PR diff FlashList. Extracted out of +// `pr-diff-file-list.tsx` so that file stays under the max-lines +// limit. Receives the full set of state needed to switch on item kind +// and dispatch to the right row component. + +import { useCallback } from 'react'; + +import { DiffLine } from '@/components/pr-review/diff/diff-line'; +import { + EmptyFilesView, + ExpandSeparatorRow, + FileHeaderRow, + HunkHeaderRow, + PaginationRow, + PatchMissingRow, + TabStateMessage, + TruncationBannerRow, +} from '@/components/pr-review/diff/pr-diff-rows'; +import { + HunkSideBySideHeader, + SideBySideRow, +} from '@/components/pr-review/diff/pr-diff-side-by-side-row'; +import { type ExpandSeparatorItem, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { + type FetchToCompletionResult, + type PrReviewFile, + type UsePrReviewFileListQueryResult, +} from '@/lib/pr-review/diff/pr-review-file-list-state'; + +export type UseDiffRenderItemArgs = { + viewed: { + isViewed: (path: string) => boolean; + toggle: (path: string) => Promise; + }; + query: UsePrReviewFileListQueryResult['query']; + fetchToCompletion: FetchToCompletionResult; + handleLoadContext: (item: ExpandSeparatorItem, windowSize: number) => void; + setExpanded: React.Dispatch>>; +}; + +// Re-exported here so the file-list module can import the terminal +// state views from one place. +export { EmptyFilesView, TabStateMessage }; + +// Re-export so the file-list module can keep its PrReviewFile import +// without touching the file-list-types module. +export type { PrReviewFile }; + +export function useDiffRenderItem({ + viewed, + query, + fetchToCompletion, + handleLoadContext, + setExpanded, +}: UseDiffRenderItemArgs) { + return useCallback( + ({ item }: { item: ListItem }) => { + switch (item.kind) { + case 'truncation-banner': { + return ; + } + case 'file-header': { + return ( + { + setExpanded(prev => ({ ...prev, [item.file.path]: !prev[item.file.path] })); + }} + onToggleViewed={() => { + void viewed.toggle(item.file.path); + }} + /> + ); + } + case 'file-patch-missing': { + return ( + { + void viewed.toggle(item.file.path); + }} + /> + ); + } + case 'hunk-header': { + return ; + } + case 'hunk-side-by-side': { + return ; + } + case 'side-by-side-row': { + return ; + } + case 'diff-line': { + return ; + } + case 'expand-separator': { + return ( + { + handleLoadContext(item, windowSize); + }} + /> + ); + } + case 'pagination-row': { + return ( + { + void query.fetchNextPage(); + }} + onFetchAll={() => { + void fetchToCompletion.run(); + }} + /> + ); + } + default: { + return null; + } + } + }, + [viewed, query, fetchToCompletion, handleLoadContext, setExpanded] + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index 5197f3660f..f5961e4a3b 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -8,24 +8,24 @@ // * `useFetchToCompletion` lets S6c's navigator drive the query to its end // * `subscribeFileNavigatorRequest` is consumed here so a "scroll to file" // request (emitted by S6c) snaps the list to the right section +// * A compact header above the FlashList hosts the navigator entry +// button and the tablet-only unified/side-by-side toggle (S6c) import { FlashList, type FlashListRef } from '@shopify/flash-list'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import { QueryError } from '@/components/query-error'; -import { DiffLine } from '@/components/pr-review/diff/diff-line'; +import { + PrDiffFileListHeader, + useDiffViewMode, +} from '@/components/pr-review/diff/pr-diff-file-list-header'; import { EmptyFilesView, - ExpandSeparatorRow, - FileHeaderRow, - HunkHeaderRow, LIST_CONTENT_STYLE, - PaginationRow, - PatchMissingRow, TabStateMessage, - TruncationBannerRow, } from '@/components/pr-review/diff/pr-diff-rows'; +import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render'; import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; import { fileHeaderKey, itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader'; @@ -39,6 +39,7 @@ import { type FileNavigatorRequest, subscribeFileNavigatorRequest, } from '@/lib/pr-review/file-navigator-bridge'; +import { useIsTablet } from '@/lib/hooks/use-is-tablet'; export type PrReviewFileListProps = { readonly owner: string; @@ -75,6 +76,8 @@ export function PrReviewFileList({ repo, headSha, }); + const { viewMode, setViewMode } = useDiffViewMode(); + const isTablet = useIsTablet(); const files = useMemo(() => { const all: PrReviewFile[] = []; @@ -86,6 +89,16 @@ export function PrReviewFileList({ return all; }, [query.data]); + const viewedCount = useMemo(() => { + let count = 0; + for (const file of files) { + if (viewed.isViewed(file.path)) { + count += 1; + } + } + return count; + }, [files, viewed]); + const items = useMemo( () => buildItems({ @@ -105,6 +118,7 @@ export function PrReviewFileList({ fetchToCompletionRunning: fetchToCompletion.isRunning, fetchToCompletionLoaded: fetchToCompletion.loadedFiles, totalFiles: changedFiles, + viewMode: isTablet ? viewMode : 'unified', }), [ files, @@ -122,6 +136,8 @@ export function PrReviewFileList({ query.isError, fetchToCompletion.isRunning, fetchToCompletion.loadedFiles, + viewMode, + isTablet, ] ); @@ -153,78 +169,13 @@ export function PrReviewFileList({ return unsubscribe; }, [owner, repo, number]); - const renderItem = useCallback( - ({ item }: { item: ListItem }) => { - switch (item.kind) { - case 'truncation-banner': { - return ; - } - case 'file-header': { - return ( - { - setExpanded(prev => ({ ...prev, [item.file.path]: !prev[item.file.path] })); - }} - onToggleViewed={() => { - void viewed.toggle(item.file.path); - }} - /> - ); - } - case 'file-patch-missing': { - return ( - { - void viewed.toggle(item.file.path); - }} - /> - ); - } - case 'hunk-header': { - return ; - } - case 'diff-line': { - return ; - } - case 'expand-separator': { - return ( - { - handleLoadContext(item, windowSize); - }} - /> - ); - } - case 'pagination-row': { - return ( - { - void query.fetchNextPage(); - }} - onFetchAll={() => { - void fetchToCompletion.run(); - }} - /> - ); - } - default: { - return null; - } - } - }, - [viewed, query, fetchToCompletion, handleLoadContext] - ); + const renderItem = useDiffRenderItem({ + viewed, + query, + fetchToCompletion, + handleLoadContext, + setExpanded, + }); if (files.length === 0) { if (firstPageErrorState?.kind === 'not-found') { @@ -262,8 +213,21 @@ export function PrReviewFileList({ return ; } + const isTruncated = query.hasNextPage || Boolean(fetchToCompletion.error); + const effectiveViewMode = isTablet ? viewMode : 'unified'; + return ( + void; +}; + +function filterFiles(files: PrReviewFile[], query: string): PrReviewFile[] { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return files; + } + return files.filter(file => file.path.toLowerCase().includes(needle)); +} + +function countViewed(files: PrReviewFile[], isViewed: (path: string) => boolean): number { + let count = 0; + for (const file of files) { + if (isViewed(file.path)) { + count += 1; + } + } + return count; +} + +export function PrDiffFileNavigator({ + owner, + repo, + number, + headSha, + changedFiles, + onDismiss, +}: PrDiffFileNavigatorProps) { + const router = useRouter(); + const colors = useThemeColors(); + const searchRef = useRef(''); + // Re-render trigger when the uncontrolled search field changes — refs + // alone don't cause re-renders, but we don't want to re-mount the + // TextInput on every keystroke (per iOS rule), so the value lives in + // a ref and a version counter drives the filtered list. + const [searchVersion, setSearchVersion] = useState(0); + const inputRef = useRef(null); + + const { query, firstPageErrorState } = usePrReviewFileListQuery({ + owner, + repo, + number, + enabled: true, + }); + const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha); + const fetchAll = useFetchToCompletion(query, changedFiles); + + // Drive the query to completion so search/navigation cover the full listed + // set. `run()` no-ops while the first page is in flight, so re-run it + // reactively once the query becomes eligible (first page settled, more pages + // remain), and stop once complete or after a surfaced error (the user can + // then tap the "Failed to load all files" retry to resume). + const runRef = useRef(fetchAll.run); + runRef.current = fetchAll.run; + useEffect(() => { + if (!query.isFetching && query.hasNextPage && !fetchAll.isRunning && !fetchAll.error) { + void runRef.current(); + } + }, [query.isFetching, query.hasNextPage, fetchAll.isRunning, fetchAll.error]); + + const files = useMemo(() => { + const all: PrReviewFile[] = []; + for (const page of query.data?.pages ?? []) { + for (const f of page.files) { + all.push(f); + } + } + return all; + }, [query.data]); + + const filtered = useMemo( + () => filterFiles(files, searchRef.current), + // `searchVersion` is the only thing that signals "the ref changed", + // so it has to be in the dep list even though `files` is the only + // real data input. + // eslint-disable-next-line react-hooks/exhaustive-deps + [files, searchVersion] + ); + + const viewedCount = useMemo(() => countViewed(files, viewed.isViewed), [files, viewed]); + + const handleSelectFile = (path: string) => { + requestScrollToFile({ owner, repo, number, path }); + if (onDismiss) { + onDismiss(); + return; + } + if (router.canGoBack()) { + router.back(); + } + }; + + if (firstPageErrorState?.kind === 'not-found') { + return ( + + + Pull request unavailable + + This PR can't be opened. It may have been deleted, the repository is private, or the + Kilo GitHub App isn't installed on it. + + + + ); + } + if (firstPageErrorState?.kind === 'permission') { + return ( + + + Access denied + + You don't have permission to view this pull request. + + + + ); + } + if (firstPageErrorState?.kind === 'retryable' || firstPageErrorState?.kind === 'reconnect') { + return ( + + + Couldn't load files + + Check your connection and try again. + + { + void query.refetch(); + }} + className="mt-1 rounded-md border border-border bg-card px-4 py-2 active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Retry loading files" + > + Retry + + + + ); + } + + if (query.isLoading && files.length === 0) { + return ( + + + + + + + {[0, 1, 2, 3, 4].map(index => ( + + + + + + + + ))} + + + ); + } + + if (!query.isLoading && files.length === 0) { + return ( + + + + ); + } + + const showLoadAllRetry = Boolean(fetchAll.error) && !fetchAll.isRunning && query.hasNextPage; + // Truncated when pagination hasn't finished, errored, or GitHub's 3,000-file + // listing cap left fewer listed files than the overview's changed-file count. + const isTruncated = query.hasNextPage || Boolean(fetchAll.error) || changedFiles > files.length; + + return ( + + + + { + searchRef.current = value; + setSearchVersion(version => version + 1); + }} + className="flex-1 text-sm leading-5 text-foreground" + returnKeyType="search" + autoCorrect={false} + autoCapitalize="none" + clearButtonMode="while-editing" + /> + + + + + {viewedCount.toLocaleString()} of {files.length.toLocaleString()} viewed + {isTruncated ? ' of listed files' : ''} + + {fetchAll.isRunning ? ( + + + + Loading {fetchAll.loadedFiles.toLocaleString()} of {changedFiles.toLocaleString()}… + + + ) : null} + + + {showLoadAllRetry ? ( + + Failed to load all files + { + void fetchAll.run(); + }} + className="rounded-md border border-border bg-card px-3 py-1 active:opacity-70" + accessibilityRole="button" + accessibilityLabel="Retry loading all files" + > + Retry + + + ) : null} + + + {filtered.length === 0 ? ( + + + No files match "{searchRef.current}" + + + ) : null} + {filtered.map(file => ( + { + handleSelectFile(file.path); + }} + onToggleViewed={() => { + void viewed.toggle(file.path); + }} + /> + ))} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx new file mode 100644 index 0000000000..2b325fe920 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx @@ -0,0 +1,97 @@ +// A single file row in the PR file navigator sheet. Tap to open the +// file in the diff list (sends a `requestScrollToFile` request and +// dismisses the sheet). A separate "Mark viewed" pressable toggles +// the per-PR viewed set without dismissing. +// +// Owns no haptics — the row's tap is a navigation action, the viewed +// toggle is a checkbox, and both flows already play the +// system/keyboard sound the navigator sheet needs (the row tap goes +// through the navigator which dismisses; the toggle is a deliberate +// state change with a visible "Viewed" / "Mark viewed" label). + +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +function splitPath(path: string): { dir: string; basename: string } { + const slash = path.lastIndexOf('/'); + if (slash === -1) { + return { dir: '', basename: path }; + } + return { dir: path.slice(0, slash + 1), basename: path.slice(slash + 1) }; +} + +export function NavigatorFileRow({ + file, + viewed, + onSelect, + onToggleViewed, +}: { + file: PrReviewFile; + viewed: boolean; + onSelect: () => void; + onToggleViewed: () => void; +}) { + const colors = useThemeColors(); + const { dir, basename } = splitPath(file.path); + return ( + + + + + {dir.length > 0 ? ( + + {dir} + + ) : null} + + {basename} + + + + + +{file.additions} + + + -{file.deletions} + + {file.patchMissing ? ( + + diff too large + + ) : null} + + + + + + + {viewed ? 'Viewed' : 'Mark viewed'} + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx new file mode 100644 index 0000000000..c7c451c3c5 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx @@ -0,0 +1,279 @@ +// Side-by-side row component for the tablet PR diff view. Renders a +// single `SideBySideRow` as two equal columns: the left column shows +// the old/deleted/context line with its old line number; the right +// column shows the new/added/context line with its new line number. +// Either column may be empty (left blank with a placeholder) when the +// pair is a pure add or pure del. +// +// Renders fixed-height rows so FlashList can virtualize without +// remeasuring. The row height matches the unified `DiffLine` row so +// mixed view-mode content (if the toggle changes mid-scroll) would +// still fit a stable grid. + +import { memo, useMemo } from 'react'; +import { Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight'; +import { type ParsedDiffLine, type ParsedHunk } from '@/lib/pr-review/diff/parse-patch'; +import { type SideBySideRow as SideBySideRowData } from '@/lib/pr-review/diff/side-by-side'; +import { cn } from '@/lib/utils'; + +const LINE_HEIGHT = 18; +const VERTICAL_PADDING = 2; +const COLUMN_GUTTER_WIDTH = 56; +const COLUMN_INNER_PADDING = 2; +const NO_NEWLINE_INDICATOR = '\u26A0\uFE0F no newline at end of file'; + +const ROW_MIN_HEIGHT = LINE_HEIGHT + VERTICAL_PADDING * 2; +const ROW_STYLE: ViewStyle = { minHeight: ROW_MIN_HEIGHT }; +const GUTTER_STYLE: ViewStyle = { + width: COLUMN_GUTTER_WIDTH, + height: ROW_MIN_HEIGHT, +}; +const CODE_CONTAINER_STYLE: ViewStyle = { paddingVertical: VERTICAL_PADDING }; +const CODE_BASE_STYLE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 12, + lineHeight: LINE_HEIGHT, +}; +const GUTTER_TEXT_BASE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 11, + lineHeight: LINE_HEIGHT, +}; +const NO_NEWLINE_BASE: TextStyle = { + fontFamily: 'JetBrainsMono_500Medium', + fontSize: 11, + lineHeight: LINE_HEIGHT, +}; + +const TOKEN_DARK_LIGHT: Record = { + keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, + builtin: { light: '#1F6FEB', dark: '#79B8FF' }, + literal: { light: '#7B2CBF', dark: '#D8B4FE' }, + number: { light: '#B27214', dark: '#F2B05F' }, + string: { light: '#278150', dark: '#5FCB8E' }, + comment: { light: '#6F6A61', dark: '#8A8680' }, + type: { light: '#1F6FEB', dark: '#79B8FF' }, + function: { light: '#1F6FEB', dark: '#79B8FF' }, + variable: { light: '#14130F', dark: '#F2F0EB' }, + property: { light: '#1F6FEB', dark: '#79B8FF' }, + tag: { light: '#BE4E3F', dark: '#F28B7A' }, + selector: { light: '#7B2CBF', dark: '#D8B4FE' }, + attribute: { light: '#1F6FEB', dark: '#79B8FF' }, + operator: { light: '#6F6A61', dark: '#8A8680' }, + meta: { light: '#6F6A61', dark: '#8A8680' }, + add: { light: '#278150', dark: '#5FCB8E' }, + del: { light: '#BE4E3F', dark: '#F28B7A' }, +}; + +const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; +const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; + +export type SideBySideRowProps = { + row: SideBySideRowData; + language: string | null; + rowKeyId: string; +}; + +function sideGutterText(line: ParsedDiffLine, side: 'left' | 'right'): string { + if (side === 'left') { + if (line.type === 'add') { + return ''; + } + return `${line.oldLine ?? line.newLine ?? ''}`; + } + if (line.type === 'del') { + return ''; + } + return `${line.newLine ?? line.oldLine ?? ''}`; +} + +function tokenColorFor(className: string | null, isDark: boolean): string { + if (!className) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + const palette = TOKEN_DARK_LIGHT[className]; + if (!palette) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + return isDark ? palette.dark : palette.light; +} + +function rowBackgroundFor(type: ParsedDiffLine['type']): string { + if (type === 'add') { + return 'bg-good-tile-bg'; + } + if (type === 'del') { + return 'bg-danger-tile-bg'; + } + return 'bg-transparent'; +} + +type SideColumnProps = { + line: ParsedDiffLine; + side: 'left' | 'right'; + language: string | null; + isDark: boolean; + foreground: string; +}; + +function SideColumnImpl({ line, side, language, isDark, foreground }: SideColumnProps) { + const tokens = useMemo( + () => highlightLine(line.text, language), + [language, line.text] + ); + const gutterColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light; + const noNewlineColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light; + const gutterText = sideGutterText(line, side); + const noNewlineLabel = line.noNewlineAtEndOfFile ? ` ${NO_NEWLINE_INDICATOR}` : ''; + + return ( + + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme muted color */} + + {gutterText} + + + + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme foreground color */} + + {tokens.map((token, index) => { + const tokenColor = tokenColorFor(token.className, isDark); + return ( + // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- per-token syntax color + + {token.text} + + ); + })} + {noNewlineLabel ? ( + // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic muted color for no-newline marker + {noNewlineLabel} + ) : null} + + + + ); +} + +const SideColumn = memo( + SideColumnImpl, + (prev, next) => + prev.line === next.line && + prev.language === next.language && + prev.side === next.side && + // Include theme inputs so a light/dark switch re-renders the colors. + prev.isDark === next.isDark && + prev.foreground === next.foreground +); + +function EmptySideColumn() { + return ( + + + + + ); +} + +function describeRow(row: SideBySideRowData): string { + if (row.left && row.right) { + return `Old: ${row.left.line.text} | New: ${row.right.line.text}`; + } + if (row.left) { + return `Old only: ${row.left.line.text}`; + } + if (row.right) { + return `New only: ${row.right.line.text}`; + } + return 'Empty diff row'; +} + +function SideBySideRowImpl({ row, language, rowKeyId }: Readonly) { + const colors = useThemeColors(); + const isDark = colors.background === '#0E0E10'; + const leftLine = row.left?.line ?? null; + const rightLine = row.right?.line ?? null; + return ( + + {leftLine ? ( + + ) : ( + + )} + + {rightLine ? ( + + ) : ( + + )} + + ); +} + +export const SideBySideRow = memo( + SideBySideRowImpl, + (prev, next) => + prev.rowKeyId === next.rowKeyId && prev.language === next.language && prev.row === next.row +); + +export type HunkSideBySideHeaderProps = { + hunk: ParsedHunk; +}; + +export function HunkSideBySideHeader({ hunk }: Readonly) { + const colors = useThemeColors(); + return ( + + + {hunk.header} + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx index b758515ba9..2b9a02a03d 100644 --- a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx @@ -1,40 +1,89 @@ -import { useLocalSearchParams } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { type ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { parseParam } from '@/lib/route-params'; +import { useTRPC } from '@/lib/trpc'; -// Thin stub for the file-navigator sheet. S6c implements the list and the -// scroll-to-file bridge producer. S4b only needs the route to mount and -// pin the param contract. type Params = { owner: string; repo: string; number: string; }; +/** + * File-navigator formSheet route. Fetches the PR overview for the head SHA + * (the navigator keys viewed state + the diff list on it) and mounts the S6c + * navigator content. Rendered inside the `[number]` layout's formSheet stack. + */ export function PrReviewFileNavigatorScreen() { + const router = useRouter(); const colors = useThemeColors(); const params = useLocalSearchParams(); + const owner = parseParam(params.owner) ?? ''; + const repo = parseParam(params.repo) ?? ''; + const rawNumber = parseParam(params.number) ?? ''; + const number = Number.parseInt(rawNumber, 10); + + const trpc = useTRPC(); + const pr = useQuery( + trpc.githubPrReview.getPullRequest.queryOptions( + { owner, repo, number }, + { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } + ) + ); + + let content: ReactNode = null; + if (pr.isLoading) { + content = ( + + + + ); + } else if (pr.isError || !pr.data) { + content = ( + + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + + ); + } else { + content = ( + { + router.back(); + }} + /> + ); + } return ( { + router.back(); + }} /> - - - File navigator lands in S6c. - - + {content} ); } diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts index f6c2d3eaa5..e322a03834 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts @@ -7,11 +7,84 @@ import { pushGapItems } from '@/lib/pr-review/diff/pr-diff-gap-builder'; import { buildGithubFileUrl, type BuildItemsArgs, + type DiffViewMode, type ListItem, PR_REVIEW_MAX_LISTED_FILES, shouldShowTruncationBanner, truncationBannerCopy, } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { buildSideBySideRows } from '@/lib/pr-review/diff/side-by-side'; + +type ParsedFile = ReturnType; +type ParsedHunk = ParsedFile['hunks'][number]; + +function pushSideBySideHunk(args: { + items: ListItem[]; + file: BuildItemsArgs['files'][number]; + hunk: ParsedHunk; + hunkIndex: number; + language: string | null; +}): void { + const { items, file, hunk, hunkIndex, language } = args; + items.push({ + kind: 'hunk-side-by-side', + key: `hunk-sbs:${file.path}:${hunkIndex}`, + hunk, + hunkIndex, + language, + }); + const rows = buildSideBySideRows(hunk); + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (!row) { + break; + } + const rowKeyId = `sbs:${file.path}:${hunkIndex}:${rowIndex}`; + items.push({ + kind: 'side-by-side-row', + key: rowKeyId, + rowKey: rowKeyId, + hunkIndex, + rowIndex, + row, + language, + rowKeyId, + }); + } +} + +function pushUnifiedHunk(args: { + items: ListItem[]; + file: BuildItemsArgs['files'][number]; + hunk: ParsedHunk; + hunkIndex: number; + parsed: ParsedFile; + language: string | null; +}): void { + const { items, file, hunk, hunkIndex, parsed, language } = args; + items.push({ + kind: 'hunk-header', + key: `hunk:${file.path}:${hunkIndex}`, + header: hunk.header, + }); + for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex += 1) { + const line = hunk.lines[lineIndex]; + if (!line) { + break; + } + items.push({ + kind: 'diff-line', + key: `line:${file.path}:${hunkIndex}:${lineIndex}`, + lineKey: `line:${file.path}:${hunkIndex}:${lineIndex}`, + hunkIndex, + lineIndex, + parsed, + line, + language, + lineKeyId: `line:${file.path}:${hunkIndex}:${lineIndex}`, + }); + } +} function pushPatchMissingItems(args: { items: ListItem[]; @@ -53,6 +126,7 @@ function pushExpandedFileItems( const language = languageForPath(file.path); const hunks = parsed.hunks; const fileContext = args.expandedContext[file.path] ?? {}; + const viewMode: DiffViewMode = args.viewMode ?? 'unified'; for (let hunkIndex = 0; hunkIndex < hunks.length; hunkIndex += 1) { const hunk = hunks[hunkIndex]; @@ -97,27 +171,10 @@ function pushExpandedFileItems( } } - items.push({ - kind: 'hunk-header', - key: `hunk:${file.path}:${hunkIndex}`, - header: hunk.header, - }); - for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex += 1) { - const line = hunk.lines[lineIndex]; - if (!line) { - break; - } - items.push({ - kind: 'diff-line', - key: `line:${file.path}:${hunkIndex}:${lineIndex}`, - lineKey: `line:${file.path}:${hunkIndex}:${lineIndex}`, - hunkIndex, - lineIndex, - parsed, - line, - language, - lineKeyId: `line:${file.path}:${hunkIndex}:${lineIndex}`, - }); + if (viewMode === 'side-by-side') { + pushSideBySideHunk({ items, file, hunk, hunkIndex, language }); + } else { + pushUnifiedHunk({ items, file, hunk, hunkIndex, parsed, language }); } } diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts index 159d222e0e..9a5079b51b 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts @@ -10,7 +10,12 @@ import { shouldShowTruncationBanner, truncationBannerCopy, } from '@/lib/pr-review/diff/pr-review-truncation'; -import { type ParsedDiffLine, type ParsedPatch } from '@/lib/pr-review/diff/parse-patch'; +import { + type ParsedDiffLine, + type ParsedHunk, + type ParsedPatch, +} from '@/lib/pr-review/diff/parse-patch'; +import { type SideBySideRow } from '@/lib/pr-review/diff/side-by-side'; export type TruncationBannerItem = { kind: 'truncation-banner'; @@ -53,6 +58,25 @@ export type DiffLineItem = { lineKeyId: string; }; +export type SideBySideRowItem = { + kind: 'side-by-side-row'; + key: string; + rowKey: string; + hunkIndex: number; + rowIndex: number; + row: SideBySideRow; + language: string | null; + rowKeyId: string; +}; + +export type HunkSideBySideHeaderItem = { + kind: 'hunk-side-by-side'; + key: string; + hunk: ParsedHunk; + hunkIndex: number; + language: string | null; +}; + export type ExpandSeparatorItem = { kind: 'expand-separator'; key: string; @@ -80,15 +104,21 @@ export type ListItem = | FilePatchMissingItem | HunkHeaderItem | DiffLineItem + | SideBySideRowItem + | HunkSideBySideHeaderItem | ExpandSeparatorItem | PaginationRowItem; +export type DiffViewMode = 'unified' | 'side-by-side'; + export const ITEM_TYPE = { Truncation: 'truncation', FileHeader: 'file-header', FilePatchMissing: 'file-patch-missing', HunkHeader: 'hunk-header', DiffLine: 'diff-line', + SideBySideRow: 'side-by-side-row', + HunkSideBySide: 'hunk-side-by-side', ExpandSeparator: 'expand-separator', Pagination: 'pagination', } as const; @@ -121,6 +151,12 @@ export function itemTypeFor(item: ListItem): string { case 'diff-line': { return ITEM_TYPE.DiffLine; } + case 'side-by-side-row': { + return ITEM_TYPE.SideBySideRow; + } + case 'hunk-side-by-side': { + return ITEM_TYPE.HunkSideBySide; + } case 'expand-separator': { return ITEM_TYPE.ExpandSeparator; } @@ -268,6 +304,8 @@ export type BuildItemsArgs = { fetchToCompletionRunning: boolean; fetchToCompletionLoaded: number; totalFiles: number | null; + /** Unified (default) or side-by-side (tablet only). */ + viewMode?: DiffViewMode; }; export { PR_REVIEW_MAX_LISTED_FILES, shouldShowTruncationBanner, truncationBannerCopy }; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts index 01eba0e818..b547c9cf8c 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts @@ -66,15 +66,24 @@ export function usePrReviewFileListQuery(args: { ); const errorState = query.error ? classifyPrReviewQueryState(query.error) : null; - const firstPageErrorState = errorState; + // A first-page error is one where NO page has loaded yet. A failure while + // fetching a LATER page (already-loaded files present) is a later-page error + // and must not blank the screen — consumers keep the loaded files and offer a + // resume/retry affordance instead. + const hasLoadedPages = (query.data?.pages.length ?? 0) > 0; + const firstPageErrorState = hasLoadedPages ? null : errorState; + const laterPageError = Boolean(query.error) && hasLoadedPages; return { query, errorState, firstPageErrorState, + laterPageError, }; } +export type UsePrReviewFileListQueryResult = ReturnType; + /** * Subscribes the viewed-files store for a specific PR (keyed by * `owner/repo#number` + `headSha`). Returns the current viewed path @@ -83,6 +92,17 @@ export function usePrReviewFileListQuery(args: { * PRs, so the hook re-reads on toggle rather than maintaining a * long-lived in-memory cache. */ +// Module-level notifier so every mounted viewed-files hook (e.g. the diff list +// AND the file navigator sheet mounted over it) re-reads after any toggle, +// keeping their viewed indicators in sync without prop drilling. +const viewedChangeListeners = new Set<() => void>(); + +function notifyViewedChange(): void { + for (const listener of viewedChangeListeners) { + listener(); + } +} + export function usePrReviewViewedFiles( ref: { owner: string; @@ -115,16 +135,22 @@ export function usePrReviewViewedFiles( } void load(); + // Re-read whenever any instance toggles a file so this instance stays in + // sync (the navigator sheet and the underlying diff list share the store). + const onChange = () => { + void load(); + }; + viewedChangeListeners.add(onChange); return () => { cancelled = true; + viewedChangeListeners.delete(onChange); }; }, [owner, repo, number, headSha]); const toggle = useCallback( async (path: string) => { // Optimistic toggle: flip the local set first so the UI updates - // instantly. The store write is durable (SecureStore), and a - // read on the next mount will reconcile any divergence. + // instantly. The store write is durable (SecureStore). setPaths(previous => { if (previous.includes(path)) { return previous.filter(p => p !== path); @@ -132,6 +158,8 @@ export function usePrReviewViewedFiles( return [...previous, path]; }); await toggleViewedFile({ owner, repo, number, headSha, path }); + // Notify other mounted instances (they re-read the durable store). + notifyViewedChange(); }, [owner, repo, number, headSha] ); diff --git a/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts b/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts new file mode 100644 index 0000000000..89f6aeade3 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import { type ParsedHunk, parsePatch } from '@/lib/pr-review/diff/parse-patch'; +import { buildSideBySideRows, type SideBySideRow } from '@/lib/pr-review/diff/side-by-side'; + +function firstHunk(patch: string): ParsedHunk { + const result = parsePatch(patch); + const hunk = result.hunks[0]; + if (!hunk) { + throw new Error('expected at least one hunk'); + } + return hunk; +} + +function typesOf(rows: SideBySideRow[]): string[] { + return rows.map(row => { + if (row.left && row.right) { + if (row.left.line === row.right.line) { + return 'context'; + } + return 'replace'; + } + if (row.left) { + return 'del'; + } + if (row.right) { + return 'add'; + } + return 'empty'; + }); +} + +describe('buildSideBySideRows', () => { + it('pairs a one-line replacement: -a / +b', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,1 @@', '-old', '+new'].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['replace']); + expect(rows[0]?.left?.line.text).toBe('old'); + expect(rows[0]?.left?.line.type).toBe('del'); + expect(rows[0]?.right?.line.text).toBe('new'); + expect(rows[0]?.right?.line.type).toBe('add'); + }); + + it('pairs a multi-line replacement: -a -b / +c +d', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,2 +1,2 @@', '-a', '-b', '+c', '+d'].join( + '\n' + ); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['replace', 'replace']); + expect(rows.map(r => r.left?.line.text)).toEqual(['a', 'b']); + expect(rows.map(r => r.right?.line.text)).toEqual(['c', 'd']); + }); + + it('pairs an uneven replacement: -a -b -c / +x, leaving b and c as left-only', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,3 +1,1 @@', '-a', '-b', '-c', '+x'].join( + '\n' + ); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['replace', 'del', 'del']); + expect(rows[0]?.left?.line.text).toBe('a'); + expect(rows[0]?.right?.line.text).toBe('x'); + expect(rows[1]?.left?.line.text).toBe('b'); + expect(rows[1]?.right).toBeNull(); + expect(rows[2]?.left?.line.text).toBe('c'); + expect(rows[2]?.right).toBeNull(); + }); + + it('pairs an uneven replacement: -a / +x +y +z, leaving y and z as right-only', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,3 @@', '-a', '+x', '+y', '+z'].join( + '\n' + ); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['replace', 'add', 'add']); + expect(rows[0]?.left?.line.text).toBe('a'); + expect(rows[0]?.right?.line.text).toBe('x'); + expect(rows[1]?.left).toBeNull(); + expect(rows[1]?.right?.line.text).toBe('y'); + expect(rows[2]?.left).toBeNull(); + expect(rows[2]?.right?.line.text).toBe('z'); + }); + + it('renders a pure-add line as right-only (left null)', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -0,0 +1,1 @@', '+new'].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['add']); + expect(rows[0]?.left).toBeNull(); + expect(rows[0]?.right?.line.text).toBe('new'); + }); + + it('renders a pure-del line as left-only (right null)', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +0,0 @@', '-gone'].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['del']); + expect(rows[0]?.left?.line.text).toBe('gone'); + expect(rows[0]?.right).toBeNull(); + }); + + it('renders context on both sides and does not pair it with del/add', () => { + const patch = [ + 'diff --git a/foo.ts b/foo.ts', + '@@ -1,3 +1,3 @@', + ' ctx', + '-a', + '+b', + ' end', + ].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['context', 'replace', 'context']); + expect(rows[0]?.left?.line.text).toBe('ctx'); + expect(rows[0]?.right?.line.text).toBe('ctx'); + expect(rows[2]?.left?.line.text).toBe('end'); + expect(rows[2]?.right?.line.text).toBe('end'); + }); + + it('keeps context as a single row shared by both columns (same reference)', () => { + const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,1 @@', ' same'].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['context']); + expect(rows[0]?.left?.line).toBe(rows[0]?.right?.line); + }); + + it('keeps leftover lines after a del/add run as separate rows (no fusion with later context)', () => { + const patch = [ + 'diff --git a/foo.ts b/foo.ts', + '@@ -1,3 +1,3 @@', + '-a', + '+b', + '-c', + ' end', + ].join('\n'); + const rows = buildSideBySideRows(firstHunk(patch)); + expect(typesOf(rows)).toEqual(['replace', 'del', 'context']); + expect(rows[1]?.left?.line.text).toBe('c'); + expect(rows[1]?.right).toBeNull(); + expect(rows[2]?.left?.line.text).toBe('end'); + expect(rows[2]?.right?.line.text).toBe('end'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/side-by-side.ts b/apps/mobile/src/lib/pr-review/diff/side-by-side.ts new file mode 100644 index 0000000000..9ba229fa57 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/side-by-side.ts @@ -0,0 +1,104 @@ +// Side-by-side rendering helper for the tablet PR diff viewer. +// +// A side-by-side view splits each parsed hunk into two columns: +// - LEFT: old (context) + deleted lines, with their old line numbers +// - RIGHT: new (context) + added lines, with their new line numbers +// +// A "replacement" hunk is a contiguous run of `-…` lines followed by +// `+…` lines (in any combination of counts). Each pair of one del + one +// add becomes a single row with both columns populated. Leftover dels +// become left-only rows; leftover adds become right-only rows. Context +// lines (which appear with both old and new line numbers) become rows +// with both columns set to the same text. The result is a fixed grid +// of equal-height rows the FlashList can virtualize without measuring. +// +// This module is pure data — no React, no React Native — so it is +// testable in plain Node and reusable by any future non-mobile surface +// (web, CLI). The visual rendering of these rows lives in +// `pr-diff-side-by-side-row.tsx` so this file stays under the max-lines +// limit. + +import { type ParsedDiffLine, type ParsedHunk } from '@/lib/pr-review/diff/parse-patch'; + +export type SideBySideCell = { + line: ParsedDiffLine; +}; + +export type SideBySideRow = { + left: SideBySideCell | null; + right: SideBySideCell | null; +}; + +function pushReplacementPair( + rows: SideBySideRow[], + del: ParsedDiffLine, + add: ParsedDiffLine +): void { + rows.push({ left: { line: del }, right: { line: add } }); +} + +function pushLeftOnly(rows: SideBySideRow[], del: ParsedDiffLine): void { + rows.push({ left: { line: del }, right: null }); +} + +function pushRightOnly(rows: SideBySideRow[], add: ParsedDiffLine): void { + rows.push({ left: null, right: { line: add } }); +} + +function pushContextPair(rows: SideBySideRow[], context: ParsedDiffLine): void { + rows.push({ left: { line: context }, right: { line: context } }); +} + +function flushRun(rows: SideBySideRow[], dels: ParsedDiffLine[], adds: ParsedDiffLine[]): void { + const pairs = Math.min(dels.length, adds.length); + for (let i = 0; i < pairs; i += 1) { + const del = dels[i]; + const add = adds[i]; + if (!del || !add) { + break; + } + pushReplacementPair(rows, del, add); + } + for (let i = pairs; i < dels.length; i += 1) { + const del = dels[i]; + if (!del) { + break; + } + pushLeftOnly(rows, del); + } + for (let i = pairs; i < adds.length; i += 1) { + const add = adds[i]; + if (!add) { + break; + } + pushRightOnly(rows, add); + } +} + +export function buildSideBySideRows(hunk: ParsedHunk): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let dels: ParsedDiffLine[] = []; + let adds: ParsedDiffLine[] = []; + + const flush = () => { + if (dels.length > 0 || adds.length > 0) { + flushRun(rows, dels, adds); + dels = []; + adds = []; + } + }; + + for (const line of hunk.lines) { + if (line.type === 'context') { + flush(); + pushContextPair(rows, line); + } else if (line.type === 'del') { + dels.push(line); + } else { + adds.push(line); + } + } + flush(); + + return rows; +} From 3aca12d28400f138ae52e09e593b21099feaa812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 05:07:04 +0200 Subject: [PATCH 11/19] feat(mobile): pr review composition and submission --- .../components/pr-review/diff/diff-line.tsx | 47 ++- .../diff/pr-diff-file-list-render.tsx | 67 +++- .../pr-review/diff/pr-diff-file-list.tsx | 35 +- .../diff/pr-diff-floating-actions.tsx | 144 ++++++++ .../diff/pr-diff-side-by-side-row.tsx | 57 +++- .../pr-review/diff/use-diff-selection.ts | 123 +++++++ .../pr-review-comment-composer-screen.tsx | 86 +++-- .../pr-review/pr-review-comment-composer.tsx | 318 ++++++++++++++++++ .../pr-review-review-submit-screen.tsx | 77 ++++- .../components/pr-review/pr-review-submit.tsx | 256 ++++++++++++++ .../build-submit-review-input.test.ts | 144 ++++++++ .../pr-review/build-submit-review-input.ts | 51 +++ .../pr-review/build-suggestion-fence.test.ts | 25 ++ .../lib/pr-review/build-suggestion-fence.ts | 14 + .../classify-pr-review-mutation-error.test.ts | 61 ++++ .../classify-pr-review-query-state.ts | 31 ++ .../pr-review/comment-composer-params.test.ts | 72 ++++ .../lib/pr-review/comment-composer-params.ts | 72 ++++ .../src/lib/pr-review/diff-selection.test.ts | 184 ++++++++++ .../src/lib/pr-review/diff-selection.ts | 102 ++++++ .../lib/pr-review/diff/pr-diff-gap-builder.ts | 2 + .../diff/pr-diff-list-builder.test.ts | 30 ++ .../pr-review/diff/pr-diff-list-builder.ts | 2 + .../lib/pr-review/diff/pr-diff-list-items.ts | 8 + .../lib/pr-review/pending-review-provider.tsx | 22 +- .../lib/pr-review/use-pr-review-mutations.ts | 108 ++++++ 26 files changed, 2076 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx create mode 100644 apps/mobile/src/components/pr-review/diff/use-diff-selection.ts create mode 100644 apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-submit.tsx create mode 100644 apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts create mode 100644 apps/mobile/src/lib/pr-review/build-submit-review-input.ts create mode 100644 apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts create mode 100644 apps/mobile/src/lib/pr-review/build-suggestion-fence.ts create mode 100644 apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts create mode 100644 apps/mobile/src/lib/pr-review/comment-composer-params.test.ts create mode 100644 apps/mobile/src/lib/pr-review/comment-composer-params.ts create mode 100644 apps/mobile/src/lib/pr-review/diff-selection.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff-selection.ts create mode 100644 apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx index 06bd2fe842..51eda8b490 100644 --- a/apps/mobile/src/components/pr-review/diff/diff-line.tsx +++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx @@ -6,9 +6,15 @@ // so FlashList can virtualize without measuring each row. The diff // surface renders thousands of lines and remeasuring on every scroll // frame would destroy scroll perf on mid-tier Android devices. +// +// S7a adds two opt-in behaviours, both passed from the diff list: +// - `onTap` makes the line tappable; the diff list runs the +// selection reducer and updates the bridge / floating action. +// - `isSelected` paints a focus ring around the line when it +// falls inside the current selection range. import { memo, useMemo } from 'react'; -import { Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; +import { Pressable, Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight'; @@ -70,6 +76,10 @@ export type DiffLineProps = { line: ParsedDiffLine; language: string | null; keyId: string; + /** When set, the whole row is pressable; pressing invokes the handler. */ + onTap?: () => void; + /** When true, the row is painted with the selection focus ring. */ + isSelected?: boolean; }; function gutterTextFor(line: ParsedDiffLine): string { @@ -103,7 +113,7 @@ function tokenColorFor(className: string | null, isDark: boolean): string { return isDark ? palette.dark : palette.light; } -function DiffLineImpl({ line, language }: Readonly) { +function DiffLineImpl({ line, language, onTap, isSelected }: Readonly) { const colors = useThemeColors(); const isDark = colors.background === '#0E0E10'; @@ -118,8 +128,13 @@ function DiffLineImpl({ line, language }: Readonly) { const noNewlineColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light; const noNewlineLabel = ` ${NO_NEWLINE_INDICATOR}`; - return ( - + // Selection ring: painted as a thick left border in the primary color + // (works on both add/del/context backgrounds). Concrete Tailwind color + // is required — CSS-var opacity modifiers don't work on theme tokens. + const selectionClass = isSelected ? 'border-l-2 border-primary' : 'border-l-2 border-transparent'; + + const content = ( + {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for gutter */} @@ -150,10 +165,32 @@ function DiffLineImpl({ line, language }: Readonly) { ); + + if (!onTap) { + return content; + } + return ( + + {content} + + ); } export const DiffLine = memo( DiffLineImpl, (prev, next) => - prev.keyId === next.keyId && prev.language === next.language && prev.line === next.line + prev.keyId === next.keyId && + prev.language === next.language && + prev.line === next.line && + prev.onTap === next.onTap && + prev.isSelected === next.isSelected ); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx index 907f2500d3..1d79876597 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx @@ -21,6 +21,8 @@ import { SideBySideRow, } from '@/components/pr-review/diff/pr-diff-side-by-side-row'; import { type ExpandSeparatorItem, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { type ParsedHunk } from '@/lib/pr-review/diff/parse-patch'; +import { sideForDiffLineType } from '@/lib/pr-review/diff-selection'; import { type FetchToCompletionResult, type PrReviewFile, @@ -36,6 +38,31 @@ export type UseDiffRenderItemArgs = { fetchToCompletion: FetchToCompletionResult; handleLoadContext: (item: ExpandSeparatorItem, windowSize: number) => void; setExpanded: React.Dispatch>>; + /** Producer-side tap handler. Receives the parsed data needed to run + * the diff-selection reducer. S7a wires this from `PrDiffFileList`. */ + onLineTap: (args: LineTapArgs) => void; + /** `null` when no selection; otherwise the current selection range. */ + selection: SelectionView; +}; + +/** Lightweight view of the current selection — what the rows need to + * decide whether to paint the focus ring. The full `DiffSelection` + * (incl. `selectedText`) is in the bridge, not here. */ +export type SelectionView = { + filePath: string; + side: 'LEFT' | 'RIGHT'; + startLine: number; + line: number; +} | null; + +export type LineTapArgs = { + filePath: string; + hunkKey: string; + side: 'LEFT' | 'RIGHT'; + line: number; + text: string; + /** The full hunk — the reducer needs the line-number → text map. */ + hunk: ParsedHunk; }; // Re-exported here so the file-list module can import the terminal @@ -52,6 +79,8 @@ export function useDiffRenderItem({ fetchToCompletion, handleLoadContext, setExpanded, + onLineTap, + selection, }: UseDiffRenderItemArgs) { return useCallback( ({ item }: { item: ListItem }) => { @@ -94,10 +123,44 @@ export function useDiffRenderItem({ return ; } case 'side-by-side-row': { + // Commenting is done from the unified view; side-by-side is read-only. return ; } case 'diff-line': { - return ; + const parsedLine = item.line; + const side = sideForDiffLineType(parsedLine.type); + const lineNumber = side === 'LEFT' ? parsedLine.oldLine : parsedLine.newLine; + const hunk = item.parsed.hunks[item.hunkIndex]; + const isSelectable = item.selectable !== false; + return ( + { + onLineTap({ + filePath: item.filePath, + hunkKey: `${item.filePath}:${item.hunkIndex}`, + side, + line: lineNumber, + text: parsedLine.text, + hunk, + }); + } + : undefined + } + isSelected={ + selection !== null && + selection.filePath === item.filePath && + selection.side === side && + typeof lineNumber === 'number' && + lineNumber >= selection.startLine && + lineNumber <= selection.line + } + /> + ); } case 'expand-separator': { return ( @@ -129,6 +192,6 @@ export function useDiffRenderItem({ } } }, - [viewed, query, fetchToCompletion, handleLoadContext, setExpanded] + [viewed, query, fetchToCompletion, handleLoadContext, setExpanded, onLineTap, selection] ); } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index f5961e4a3b..598dd3ecae 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -8,8 +8,11 @@ // * `useFetchToCompletion` lets S6c's navigator drive the query to its end // * `subscribeFileNavigatorRequest` is consumed here so a "scroll to file" // request (emitted by S6c) snaps the list to the right section -// * A compact header above the FlashList hosts the navigator entry -// button and the tablet-only unified/side-by-side toggle (S6c) +// * S7a adds diff-line selection: tapping a line runs the pure +// `selectLine` reducer; the result is mirrored into the +// `diff-selection-bridge` (so the comment composer can read it on +// mount) and a floating action bar (`PrDiffFloatingActions`) +// hosts the "Comment" and "Finish review" affordances. import { FlashList, type FlashListRef } from '@shopify/flash-list'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -20,12 +23,14 @@ import { PrDiffFileListHeader, useDiffViewMode, } from '@/components/pr-review/diff/pr-diff-file-list-header'; +import { PrDiffFloatingActions } from '@/components/pr-review/diff/pr-diff-floating-actions'; +import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render'; +import { useDiffSelection } from '@/components/pr-review/diff/use-diff-selection'; import { EmptyFilesView, LIST_CONTENT_STYLE, TabStateMessage, } from '@/components/pr-review/diff/pr-diff-rows'; -import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render'; import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; import { fileHeaderKey, itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader'; @@ -35,6 +40,7 @@ import { usePrReviewFileListQuery, usePrReviewViewedFiles, } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; import { type FileNavigatorRequest, subscribeFileNavigatorRequest, @@ -78,6 +84,19 @@ export function PrReviewFileList({ }); const { viewMode, setViewMode } = useDiffViewMode(); const isTablet = useIsTablet(); + const { selection, selectionView, handleLineTap, clearSelection } = useDiffSelection({ + owner, + repo, + number, + viewMode, + isTablet, + }); + + // When the component unmounts (user navigates away from the PR or + // pops the PR screen off the stack), drop the bridge so a stale + // selection can never leak into the next mount. Re-mounting this + // list always starts with no selection. + useEffect(() => clearDiffSelection, []); const files = useMemo(() => { const all: PrReviewFile[] = []; @@ -175,6 +194,8 @@ export function PrReviewFileList({ fetchToCompletion, handleLoadContext, setExpanded, + onLineTap: handleLineTap, + selection: selectionView, }); if (files.length === 0) { @@ -243,6 +264,14 @@ export function PrReviewFileList({ contentContainerStyle={LIST_CONTENT_STYLE} ItemSeparatorComponent={null} /> + ); } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx new file mode 100644 index 0000000000..ac5e9d7cea --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx @@ -0,0 +1,144 @@ +// Floating action bar rendered over the PR diff FlashList. Hosts: +// - The "Comment" affordance that pushes the comment-composer route +// when a diff-line selection exists, plus a "Clear" button that +// drops the selection. +// - The "Finish review" button shown when the pending review queue +// is non-empty, which pushes the review-submit route. +// +// Extracted from `pr-diff-file-list.tsx` to keep that file under the +// 300-line repo cap. + +import { type Href, useRouter } from 'expo-router'; +import { MessageCirclePlus } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; +import { type SelectionState } from '@/lib/pr-review/diff-selection'; +import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; +import { cn } from '@/lib/utils'; + +const COMMENT_COMPOSER_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/comment-composer' as const; +const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const; + +export type PrDiffFloatingActionsProps = Readonly<{ + owner: string; + repo: string; + number: number; + /** Unified (default) or side-by-side (tablet only). */ + viewMode: DiffViewMode; + /** `null` when no selection exists. Drives the "Comment" affordance. */ + selection: SelectionState | null; + /** Setter for the parent's selection state — `null` clears. */ + onClearSelection: () => void; +}>; + +export function PrDiffFloatingActions({ + owner, + repo, + number, + viewMode, + selection, + onClearSelection, +}: PrDiffFloatingActionsProps) { + const router = useRouter(); + const colors = useThemeColors(); + const pending = usePendingReview(); + + const showSelectionAction = viewMode === 'unified' && selection !== null; + const showFinishReview = pending.items.length > 0; + if (!showSelectionAction && !showFinishReview) { + return null; + } + + function openCommentComposer() { + if (!selection) { + return; + } + const params: Record = { + owner, + repo, + number, + path: selection.path, + side: selection.side, + line: selection.line, + }; + if (selection.startLine !== selection.line) { + params.startLine = selection.startLine; + } + const href: Href = { + pathname: COMMENT_COMPOSER_PATH, + params, + }; + router.push(href); + } + + function openReviewSubmit() { + const href: Href = { + pathname: REVIEW_SUBMIT_PATH, + params: { owner, repo, number }, + }; + router.push(href); + } + + return ( + + + {showSelectionAction ? ( + + + {selectionDescription(selection)} + + + + + ) : null} + {showFinishReview ? ( + + ) : null} + + + ); +} + +function selectionDescription(selection: SelectionState): string { + const range = + selection.startLine === selection.line + ? `L${selection.startLine}` + : `L${selection.startLine}–L${selection.line}`; + return `${selection.path} ${selection.side} ${range}`; +} diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx index c7c451c3c5..d96d45cd57 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx @@ -11,7 +11,7 @@ // still fit a stable grid. import { memo, useMemo } from 'react'; -import { Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; +import { Pressable, Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; @@ -76,6 +76,13 @@ export type SideBySideRowProps = { row: SideBySideRowData; language: string | null; rowKeyId: string; + /** Optional tap producer — wired by S7a so the side-by-side row can + * start a diff selection. Only the right column participates; the + * side-by-side viewer is tablet-only and RIGHT is the canonical + * post-merge view that the user reads to write a review. */ + onTap?: (cell: { line: ParsedDiffLine; side: 'left' | 'right' }) => void; + /** When true, paint the row with the selection focus ring. */ + isSelected?: boolean; }; function sideGutterText(line: ParsedDiffLine, side: 'left' | 'right'): string { @@ -210,14 +217,31 @@ function describeRow(row: SideBySideRowData): string { return 'Empty diff row'; } -function SideBySideRowImpl({ row, language, rowKeyId }: Readonly) { +function SideBySideRowImpl({ + row, + language, + rowKeyId, + onTap, + isSelected, +}: Readonly) { const colors = useThemeColors(); const isDark = colors.background === '#0E0E10'; const leftLine = row.left?.line ?? null; const rightLine = row.right?.line ?? null; - return ( + // Side-by-side uses the right column when present; a left-only row + // (pure deletion) lets the user comment on the LEFT line instead. + const tappableLine = rightLine ?? leftLine; + let tappableColumn: 'left' | 'right' | null = null; + if (rightLine) { + tappableColumn = 'right'; + } else if (leftLine) { + tappableColumn = 'left'; + } + // Selection ring uses the primary color (matches the unified diff). + const selectionClass = isSelected ? 'border-l-2 border-primary' : 'border-l-2 border-transparent'; + const rowContent = ( ); + if (!onTap || !tappableLine || !tappableColumn) { + return rowContent; + } + return ( + { + onTap({ line: tappableLine, side: tappableColumn }); + }} + accessibilityRole="button" + accessibilityLabel={ + isSelected + ? `Selected diff row (${tappableColumn}), tap to change selection` + : `Comment on diff row (${tappableColumn})` + } + accessibilityState={{ selected: Boolean(isSelected) }} + > + {rowContent} + + ); } export const SideBySideRow = memo( SideBySideRowImpl, (prev, next) => - prev.rowKeyId === next.rowKeyId && prev.language === next.language && prev.row === next.row + prev.rowKeyId === next.rowKeyId && + prev.language === next.language && + prev.row === next.row && + prev.onTap === next.onTap && + prev.isSelected === next.isSelected ); export type HunkSideBySideHeaderProps = { diff --git a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts new file mode 100644 index 0000000000..1f28418b91 --- /dev/null +++ b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts @@ -0,0 +1,123 @@ +// Selection state hook for the PR diff file list. Encapsulates the +// reducer-driven line selection, the bridge mirror, the selection view +// used for row highlight, and the side-by-side mode guard that clears +// the selection. Extracted to keep `pr-diff-file-list.tsx` under the +// max-lines limit. + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { type LineTapArgs } from '@/components/pr-review/diff/pr-diff-file-list-render'; +import { + clearDiffSelection, + type DiffSelection, + setDiffSelection, +} from '@/lib/pr-review/diff-selection-bridge'; +import { type SelectionState, selectLine } from '@/lib/pr-review/diff-selection'; +import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; + +export type UseDiffSelectionArgs = { + owner: string; + repo: string; + number: number; + viewMode: DiffViewMode; + isTablet: boolean; +}; + +export type UseDiffSelectionResult = { + selection: SelectionState | null; + selectionView: SelectionView; + handleLineTap: (args: LineTapArgs) => void; + clearSelection: () => void; +}; + +type SelectionView = { + filePath: string; + side: 'LEFT' | 'RIGHT'; + startLine: number; + line: number; +} | null; + +export function useDiffSelection({ + owner, + repo, + number, + viewMode, + isTablet, +}: UseDiffSelectionArgs): UseDiffSelectionResult { + const [selection, setSelectionState] = useState(null); + + // Selection/commenting is a unified-view interaction; switching to + // side-by-side drops any active selection so a stale unified + // selection doesn't leave the floating affordance up. + useEffect(() => { + if (isTablet && viewMode === 'side-by-side') { + setSelectionState(prev => { + if (prev) { + clearDiffSelection(); + } + return null; + }); + } + }, [isTablet, viewMode]); + + // Diff-line tap producer: build the per-side line-number → text map + // for this hunk, run the reducer, mirror to the bridge, and store + // the result locally so the rows can render the focus ring. + const handleLineTap = useCallback( + (args: LineTapArgs) => { + const map = new Map(); + for (const hunkLine of args.hunk.lines) { + const key = args.side === 'LEFT' ? hunkLine.oldLine : hunkLine.newLine; + if (typeof key === 'number') { + map.set(key, hunkLine.text); + } + } + setSelectionState(prev => { + const next = selectLine( + prev, + { + path: args.filePath, + side: args.side, + line: args.line, + hunkKey: args.hunkKey, + text: args.text, + }, + map + ); + const bridgeSelection: DiffSelection = { + owner, + repo, + number, + path: next.path, + side: next.side, + line: next.line, + ...(next.startLine !== next.line ? { startLine: next.startLine } : {}), + selectedText: next.selectedText, + }; + setDiffSelection(bridgeSelection); + return next; + }); + }, + [owner, repo, number] + ); + + const selectionView: SelectionView = useMemo(() => { + if (!selection) { + return null; + } + return { + filePath: selection.path, + side: selection.side, + startLine: selection.startLine, + line: selection.line, + }; + }, [selection]); + + const clearSelection = useCallback(() => { + setSelectionState(null); + }, []); + + return { selection, selectionView, handleLineTap, clearSelection }; +} + +export type { SelectionView }; diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx index 70322f9861..0ab6bad88f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx @@ -1,55 +1,91 @@ +import { useQuery } from '@tanstack/react-query'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { type ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { PrReviewCommentComposer } from '@/components/pr-review/pr-review-comment-composer'; +import { QueryError } from '@/components/query-error'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { parseComposerParams } from '@/lib/pr-review/comment-composer-params'; +import { useTRPC } from '@/lib/trpc'; -// Thin stub for the comment composer sheet. S7a fills in the body — the -// line/side context, the text input, the diff snippet preview, the -// submit/cancel actions. S4b only needs the route to render something -// inside a ScrollView (iOS formSheet gotcha) and pin the param contract. type Params = { owner: string; repo: string; number: string; path: string; - side: 'LEFT' | 'RIGHT'; + side?: string; line: string; startLine?: string; }; export function PrReviewCommentComposerScreen() { - const colors = useThemeColors(); const router = useRouter(); + const colors = useThemeColors(); const params = useLocalSearchParams(); - const startLine = params.startLine ? Number.parseInt(params.startLine, 10) : undefined; - const lineNumber = Number.parseInt(params.line, 10); + const parsed = parseComposerParams(params); + + const trpc = useTRPC(); + const pr = useQuery( + trpc.githubPrReview.getPullRequest.queryOptions( + { owner: parsed?.owner ?? '', repo: parsed?.repo ?? '', number: parsed?.number ?? 0 }, + { enabled: parsed !== null } + ) + ); + + let content: ReactNode = null; + if (!parsed) { + content = ; + } else if (pr.isLoading) { + content = ( + + + + ); + } else if (pr.isError || !pr.data) { + content = ( + + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + + ); + } else { + content = ( + { + router.back(); + }} + /> + ); + } return ( { router.back(); }} /> - - - {params.path} {params.side} L{Number.isFinite(lineNumber) ? lineNumber : '?'} - {startLine && Number.isFinite(startLine) ? `–L${startLine}` : ''} - - - Comment composer lands in S7a. - - + {content} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx new file mode 100644 index 0000000000..f488679eca --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx @@ -0,0 +1,318 @@ +// S7a comment-composer content component. The orchestrator mounts this +// inside the `comment-composer.tsx` route (S4b left a thin stub there); +// the route supplies the route-params; this component owns the form +// body, the suggestion affordance, and both submit paths. +// +// Two submit actions, both available at once: +// - "Add to review" → enqueue into the `PendingReviewProvider`, +// keep the selection alive, dismiss. The user keeps editing more +// lines and submits the whole batch in the review-submit sheet. +// - "Comment now" → POST a single comment immediately via +// `createReviewComment` and dismiss. No review state is created. +// +// Toasts paint behind formSheets on iOS, so the mutation hook toasts +// `onError` AND the sheet renders an inline error box. A 422 (e.g. +// stale line because the head moved) is a non-retryable inline message. + +import * as Crypto from 'expo-crypto'; +import * as Haptics from 'expo-haptics'; +import { type RefObject, useEffect, useRef, useState } from 'react'; +import { Alert, ScrollView, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence'; +import { type DiffSelection, getDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; +import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; +import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { cn } from '@/lib/utils'; + +export type PrReviewCommentComposerProps = Readonly<{ + owner: string; + repo: string; + number: number; + /** Current PR head SHA — pinned into both the queued and immediate comment. */ + headSha: string; + path: string; + side: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; + /** Invoked after the user submits (either path) or cancels. */ + onDismiss: () => void; +}>; + +const BODY_PLACEHOLDER = 'Leave a comment'; + +export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { + const { owner, repo, number, headSha, path, side, line, startLine, onDismiss } = props; + const pending = usePendingReview(); + const createComment = useCreateReviewCommentMutation({ owner, repo, number }); + + // Read the bridge on mount to render the selected-line context. We + // re-read on every render because the diff may have updated the + // selection while the user was navigating here. + const selection = getDiffSelection({ owner, repo, number }); + + // iOS uncontrolled pattern: text lives in a ref, the input's visible + // value is set via defaultValue once + setNativeProps for the + // suggestion insert. No `value` + state (iOS bug). + const bodyRef = useRef(''); + const bodyInputRef = useRef(null); + const [inlineError, setInlineError] = useState(null); + + const isSubmitting = createComment.isPending; + const lineRangeLabel = rangeLabel(line, startLine); + + // When the mutation errors, mirror its message into the inline error + // box. The hook toasts the same message; this box is the one the + // user actually sees in the formSheet. + useEffect(() => { + if (createComment.error) { + const message = + createComment.error instanceof Error + ? createComment.error.message + : 'Could not post comment.'; + setInlineError(message); + } + }, [createComment.error]); + + function handleAddToReview() { + const body = bodyRef.current; + if (body.trim().length === 0) { + setInlineError('Comment body cannot be empty.'); + return; + } + setInlineError(null); + pending.addComment({ + id: Crypto.randomUUID(), + path, + side, + line, + ...(startLine !== undefined ? { startLine } : {}), + body, + commitSha: headSha, + }); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + onDismiss(); + } + + async function handleCommentNow() { + const body = bodyRef.current; + if (body.trim().length === 0) { + setInlineError('Comment body cannot be empty.'); + return; + } + setInlineError(null); + try { + await createComment.mutateAsync({ + owner, + repo, + number, + body, + path, + line, + side, + // The S3 Zod refine rejects a partial range: startLine and + // startSide must come together. We pass startSide = side so + // the body lands on the same side the user is looking at. + ...(startLine !== undefined ? { startLine, startSide: side } : {}), + commitSha: headSha, + }); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onDismiss(); + } catch (error) { + if (error instanceof Error && error.message) { + setInlineError(error.message); + } else { + setInlineError('Could not post comment.'); + } + } + } + + function handleCancel() { + if (isSubmitting) { + return; + } + if (bodyRef.current.trim().length > 0) { + Alert.alert('Discard comment?', 'Your draft will be lost.', [ + { text: 'Keep editing', style: 'cancel' }, + { text: 'Discard', style: 'destructive', onPress: onDismiss }, + ]); + return; + } + onDismiss(); + } + + function handleInsertSuggestion() { + if (side === 'LEFT') { + return; + } + const selectedText = selection?.selectedText ?? ''; + const block = buildSuggestionFence(selectedText); + if (block === null) { + return; + } + bodyRef.current = block; + bodyInputRef.current?.setNativeProps({ + text: block, + selection: { start: block.length, end: block.length }, + }); + bodyInputRef.current?.focus(); + } + + const suggestionAvailable = side === 'RIGHT' && Boolean(selection?.selectedText); + let suggestionDisabledReason: string | null = null; + if (side === 'LEFT') { + suggestionDisabledReason = 'Suggestions only apply to added lines.'; + } else if (!selection?.selectedText) { + suggestionDisabledReason = 'Tap a diff line to enable suggestions.'; + } + + return ( + + + + + Comment + + + + {inlineError ? ( + + {inlineError} + + ) : null} + + + + + + + + + ); +} + +function CommentBodyField({ + bodyRef, + inputRef, + isDisabled, +}: { + bodyRef: RefObject; + inputRef: RefObject; + isDisabled: boolean; +}) { + const colors = useThemeColors(); + return ( + { + bodyRef.current = value; + }} + multiline + textAlignVertical="top" + // Explicit line-height (no `text-center` per the repo's iOS rule). + // `leading-5` = line-height 20, font-size 14, matching the merge + // sheet's commit message field. + className={cn( + 'min-h-32 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground', + 'focus:border-ring' + )} + /> + ); +} + +function ContextPreview({ + selection, + fallbackPath, + fallbackLineLabel, + fallbackSide, +}: { + selection: DiffSelection | null; + fallbackPath: string; + fallbackLineLabel: string; + fallbackSide: 'LEFT' | 'RIGHT'; +}) { + const path = selection?.path ?? fallbackPath; + const side = selection?.side ?? fallbackSide; + const lineLabel = selection ? rangeLabel(selection.line, selection.startLine) : fallbackLineLabel; + const previewText = selection?.selectedText ?? ''; + return ( + + + {path} {side} {lineLabel} + + {previewText.length > 0 ? ( + + {previewText} + + ) : ( + + Selected line context will appear here. + + )} + + ); +} + +function rangeLabel(line: number, startLine?: number): string { + if (startLine !== undefined && startLine !== line) { + return `L${startLine}–L${line}`; + } + return `L${line}`; +} diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx index cf54f39f53..5ca9e00cb7 100644 --- a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx @@ -1,13 +1,15 @@ -import { useLocalSearchParams } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { useQuery } from '@tanstack/react-query'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { type ReactNode } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { PrReviewSubmit } from '@/components/pr-review/pr-review-submit'; +import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; -import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { parseParam } from '@/lib/route-params'; +import { useTRPC } from '@/lib/trpc'; -// Thin stub for the review-submit sheet (approve / request-changes / -// comment-only summary). S8 implements the actual radio group + submit -// mutation. S4b only needs the route to mount and pin the param contract. type Params = { owner: string; repo: string; @@ -15,26 +17,67 @@ type Params = { }; export function PrReviewReviewSubmitScreen() { + const router = useRouter(); const colors = useThemeColors(); const params = useLocalSearchParams(); + const owner = parseParam(params.owner) ?? ''; + const repo = parseParam(params.repo) ?? ''; + const rawNumber = parseParam(params.number) ?? ''; + const number = Number.parseInt(rawNumber, 10); + + const trpc = useTRPC(); + const pr = useQuery( + trpc.githubPrReview.getPullRequest.queryOptions( + { owner, repo, number }, + { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 } + ) + ); + + let content: ReactNode = null; + if (pr.isLoading) { + content = ( + + + + ); + } else if (pr.isError || !pr.data) { + content = ( + + { + void pr.refetch(); + }} + isRetrying={pr.isFetching} + /> + + ); + } else { + content = ( + { + router.back(); + }} + /> + ); + } return ( { + router.back(); + }} /> - - - Review submit lands in S8. - - + {content} ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx new file mode 100644 index 0000000000..480f849507 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -0,0 +1,256 @@ +// S7a review-submit content component. The orchestrator mounts this +// inside the `review-submit.tsx` route (S4b left a thin stub there); +// the route supplies the route-params; this component owns the form +// body, the event radio, and the single batched submit. +// +// The sheet drains the `PendingReviewProvider` queue into ONE +// `submitReview` call (per the S3 contract). The head SHA submitted +// is the LATEST one — the per-item `commitSha` is only used by the +// sheet's "may be outdated" hint. The queue is cleared on success +// and retained on failure so the user can decide what to drop or retry. +// +// Submit failures are classified: 422/BAD_REQUEST validation errors +// (approve-own-PR, stale) are non-retryable and remove the submit +// affordance until the user changes the event or body; everything +// else is retryable and keeps the submit button enabled. +// +// Toasts paint behind formSheets on iOS, so the mutation hook toasts +// `onError` AND the sheet renders an inline error box. + +import * as Haptics from 'expo-haptics'; +import { type RefObject, useEffect, useRef, useState } from 'react'; +import { ScrollView, TextInput, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { PillGroup } from '@/components/security-agent/settings-pill-group'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + buildSubmitReviewInput, + type ReviewEvent, +} from '@/lib/pr-review/build-submit-review-input'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; +import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations'; +import { cn } from '@/lib/utils'; + +export type PrReviewSubmitProps = Readonly<{ + owner: string; + repo: string; + number: number; + /** Current PR head SHA — submitted as `commitSha` for the review. */ + headSha: string; + /** Invoked after a successful submit or a cancel. */ + onDismiss: () => void; +}>; + +const EVENT_OPTIONS: readonly { value: ReviewEvent; label: string }[] = [ + { value: 'COMMENT', label: 'Comment' }, + { value: 'REQUEST_CHANGES', label: 'Request changes' }, + { value: 'APPROVE', label: 'Approve' }, +]; + +export function PrReviewSubmit(props: PrReviewSubmitProps) { + const { owner, repo, number, headSha, onDismiss } = props; + const pending = usePendingReview(); + const submitReview = useSubmitReviewMutation({ owner, repo, number }); + + const [event, setEvent] = useState('COMMENT'); + const [inlineError, setInlineError] = useState(null); + const [inlineErrorKind, setInlineErrorKind] = useState<'retryable' | 'non-retryable' | null>( + null + ); + + // iOS uncontrolled pattern: body lives in a ref, the input's visible + // value is set via defaultValue once. No `value` + state. + const bodyRef = useRef(''); + const bodyInputRef = useRef(null); + + const isSubmitting = submitReview.isPending; + const queuedCount = pending.items.length; + + // Flag when any queued item was queued against a different head SHA + // than the current one. Submission still uses the latest head SHA. + const hasStaleItems = pending.items.some(item => item.commitSha !== headSha); + + useEffect(() => { + if (submitReview.error) { + const classification = classifyPrReviewMutationError(submitReview.error); + if (classification.kind === 'bad-request') { + setInlineError( + "This review can't be submitted as is. The PR may have changed, or you can't approve your own pull request." + ); + setInlineErrorKind('non-retryable'); + } else { + setInlineError('Could not submit review. Check your connection and try again.'); + setInlineErrorKind('retryable'); + } + } + }, [submitReview.error]); + + async function handleSubmit() { + setInlineError(null); + setInlineErrorKind(null); + try { + const body = bodyRef.current.trim(); + await submitReview.mutateAsync( + buildSubmitReviewInput({ + owner, + repo, + number, + event, + ...(body.length > 0 ? { body } : {}), + commitSha: headSha, + items: pending.items, + }) + ); + pending.clear(); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + onDismiss(); + } catch (error) { + if (error instanceof Error && error.message) { + setInlineError(error.message); + } else { + setInlineError('Could not submit review.'); + } + } + } + + function handleCancel() { + if (isSubmitting) { + return; + } + onDismiss(); + } + + return ( + + + { + setEvent(next); + setInlineError(null); + setInlineErrorKind(null); + }} + /> + + Summary (optional) + { + setInlineError(null); + setInlineErrorKind(null); + }} + /> + + + + + {queuedCount} pending {queuedCount === 1 ? 'comment' : 'comments'} + + + + + {inlineError ? ( + + {inlineError} + + ) : null} + + + + + + + + ); +} + +function PendingQueueHint({ + queuedCount, + hasStaleItems, +}: { + queuedCount: number; + hasStaleItems: boolean; +}) { + let message = ''; + if (queuedCount === 0) { + message = 'No comments queued. The review will be submitted with just the event above.'; + } else if (hasStaleItems) { + message = + 'Some comments may be outdated because the PR head changed after they were queued. Submission will use the current head.'; + } else { + message = 'All comments will be sent in a single batched request.'; + } + return ( + + {message} + + ); +} + +function ReviewBodyField({ + bodyRef, + inputRef, + isDisabled, + onChange, +}: { + bodyRef: RefObject; + inputRef: RefObject; + isDisabled: boolean; + onChange: () => void; +}) { + const colors = useThemeColors(); + return ( + { + bodyRef.current = value; + onChange(); + }} + multiline + textAlignVertical="top" + className={cn( + 'min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground', + 'focus:border-ring' + )} + /> + ); +} diff --git a/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts b/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts new file mode 100644 index 0000000000..9b874a6fd6 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSubmitReviewInput } from '@/lib/pr-review/build-submit-review-input'; +import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider'; + +function makeItem(overrides: Partial = {}): PendingReviewItem { + return { + id: 'id-1', + path: 'src/lib.ts', + side: 'RIGHT', + line: 7, + body: 'Looks good.', + commitSha: 'head-1', + ...overrides, + }; +} + +describe('buildSubmitReviewInput', () => { + it('maps a single-line comment without a summary', () => { + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'COMMENT', + commitSha: 'head-2', + items: [makeItem()], + }); + expect(input).toEqual({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'COMMENT', + commitSha: 'head-2', + comments: [ + { + path: 'src/lib.ts', + line: 7, + side: 'RIGHT', + body: 'Looks good.', + }, + ], + }); + }); + + it('includes startLine and startSide together for multi-line comments', () => { + const item = makeItem({ startLine: 5, line: 7, side: 'RIGHT' }); + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'COMMENT', + commitSha: 'head-2', + items: [item], + }); + expect(input.comments?.[0]).toEqual({ + path: 'src/lib.ts', + line: 7, + side: 'RIGHT', + startLine: 5, + startSide: 'RIGHT', + body: 'Looks good.', + }); + }); + + it('omits startLine/startSide for single-line comments', () => { + const item = makeItem({ side: 'LEFT' }); + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'COMMENT', + commitSha: 'head-2', + items: [item], + }); + const comment = input.comments?.[0]; + expect(comment).not.toHaveProperty('startLine'); + expect(comment).not.toHaveProperty('startSide'); + expect(comment).toEqual({ + path: 'src/lib.ts', + line: 7, + side: 'LEFT', + body: 'Looks good.', + }); + }); + + it('includes a non-empty review body', () => { + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'APPROVE', + body: 'Nice work.', + commitSha: 'head-2', + items: [], + }); + expect(input.body).toBe('Nice work.'); + expect(input.comments).toBeUndefined(); + }); + + it('omits an empty review body', () => { + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'APPROVE', + body: ' ', + commitSha: 'head-2', + items: [makeItem()], + }); + expect(input).not.toHaveProperty('body'); + }); + + it('omits comments when the queue is empty', () => { + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'APPROVE', + commitSha: 'head-2', + items: [], + }); + expect(input.comments).toBeUndefined(); + }); + + it('maps the full queue in order, retaining all items for the submit attempt', () => { + const items = [ + makeItem({ id: 'a', path: 'a.ts', line: 1 }), + makeItem({ id: 'b', path: 'b.ts', line: 2, startLine: 1 }), + ]; + const input = buildSubmitReviewInput({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + event: 'REQUEST_CHANGES', + commitSha: 'head-2', + items, + }); + expect(input.comments).toHaveLength(2); + // On a successful submit, the caller clears the queue; on failure, the + // queue is retained because the input is built from the current items + // without mutating them. + expect(items).toHaveLength(2); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/build-submit-review-input.ts b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts new file mode 100644 index 0000000000..27819d8d18 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts @@ -0,0 +1,51 @@ +import { + type SubmitReviewComment, + type SubmitReviewInput, +} from '@/lib/pr-review/use-pr-review-mutations'; + +export type ReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; + +export type BuildSubmitReviewInputArgs = { + owner: string; + repo: string; + number: number; + event: ReviewEvent; + body?: string; + commitSha: string; + items: readonly { + path: string; + line: number; + side: 'LEFT' | 'RIGHT'; + startLine?: number; + body: string; + }[]; +}; + +/** + * Pure mapper from the pending-review queue + review event to the + * `submitReview` tRPC input. The submission always uses the latest head SHA; + * queued `commitSha` values are only used for the "may be outdated" hint. + * + * Per the S3 contract, `startLine` and `startSide` must be supplied together + * or omitted together. + */ +export function buildSubmitReviewInput(args: BuildSubmitReviewInputArgs): SubmitReviewInput { + const comments: SubmitReviewComment[] = args.items.map(item => ({ + path: item.path, + line: item.line, + side: item.side, + ...(item.startLine !== undefined ? { startLine: item.startLine, startSide: item.side } : {}), + body: item.body, + })); + + const trimmedBody = args.body?.trim() ?? ''; + return { + owner: args.owner, + repo: args.repo, + number: args.number, + event: args.event, + ...(trimmedBody.length > 0 ? { body: trimmedBody } : {}), + commitSha: args.commitSha, + ...(comments.length > 0 ? { comments } : {}), + }; +} diff --git a/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts b/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts new file mode 100644 index 0000000000..af291d275a --- /dev/null +++ b/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence'; + +describe('buildSuggestionFence', () => { + it('returns null for an empty selection', () => { + expect(buildSuggestionFence('')).toBeNull(); + }); + + it('wraps a single line verbatim', () => { + expect(buildSuggestionFence(' const x = 1;')).toBe('```suggestion\n const x = 1;\n```'); + }); + + it('preserves multi-line indentation and spacing exactly', () => { + const selectedText = ['function add(a, b) {', ' return a + b;', '}'].join('\n'); + expect(buildSuggestionFence(selectedText)).toBe( + ['```suggestion', 'function add(a, b) {', ' return a + b;', '}', '```'].join('\n') + ); + }); + + it('preserves leading and trailing blank lines', () => { + const selectedText = '\n indented\n'; + expect(buildSuggestionFence(selectedText)).toBe('```suggestion\n\n indented\n\n```'); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts b/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts new file mode 100644 index 0000000000..6388b4904f --- /dev/null +++ b/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts @@ -0,0 +1,14 @@ +// Pure helper for the PR review comment composer: builds a +// ```suggestion fenced block from the selected right-side text. +// +// The fence content must match the displayed lines EXACTLY — no +// added or removed indentation — otherwise GitHub will apply the +// wrong replacement. + +export function buildSuggestionFence(selectedText: string): string | null { + if (selectedText.length === 0) { + return null; + } + const lines = selectedText.split('\n'); + return ['```suggestion', ...lines, '```'].join('\n'); +} diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts new file mode 100644 index 0000000000..b04012c4f7 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; + +function makeTrpcError(code: string, message?: string): unknown { + return { data: { code, ...(message ? { message } : {}) } }; +} + +function makeNestedTrpcError(code: string): unknown { + return { shape: { data: { code } } }; +} + +function makeTopLevelTrpcError(code: string): unknown { + return { code }; +} + +describe('classifyPrReviewMutationError', () => { + it('classifies BAD_REQUEST as non-retryable', () => { + const error = new Error('Cannot approve your own pull request'); + Object.assign(error, { data: { code: 'BAD_REQUEST' } }); + expect(classifyPrReviewMutationError(error)).toEqual({ + kind: 'bad-request', + message: 'Cannot approve your own pull request', + }); + }); + + it('classifies BAD_REQUEST from the nested shape too', () => { + expect(classifyPrReviewMutationError(makeNestedTrpcError('BAD_REQUEST'))).toEqual({ + kind: 'bad-request', + message: 'Bad request', + }); + }); + + it('classifies BAD_REQUEST from a top-level code field', () => { + expect(classifyPrReviewMutationError(makeTopLevelTrpcError('BAD_REQUEST'))).toEqual({ + kind: 'bad-request', + message: 'Bad request', + }); + }); + + it('classifies network errors as retryable', () => { + expect(classifyPrReviewMutationError(new Error('Network request failed'))).toEqual({ + kind: 'retryable', + }); + }); + + it('classifies 5xx tRPC errors as retryable', () => { + expect(classifyPrReviewMutationError(makeTrpcError('INTERNAL_SERVER_ERROR'))).toEqual({ + kind: 'retryable', + }); + expect(classifyPrReviewMutationError(makeTrpcError('TIMEOUT'))).toEqual({ + kind: 'retryable', + }); + }); + + it('classifies unknown / malformed errors as retryable', () => { + expect(classifyPrReviewMutationError('string error')).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewMutationError(null)).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewMutationError(undefined)).toEqual({ kind: 'retryable' }); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts index de1c34d292..dc641ebe8b 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts @@ -95,3 +95,34 @@ export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState { } return { kind: 'retryable' }; } + +export type PrReviewMutationErrorState = + | { + /** + * The mutation failed with a transient error (network, 5xx, etc.). + * The caller should keep the retry affordance available. + */ + kind: 'retryable'; + } + | { + /** + * The mutation failed with a validation / 422-class error (e.g. + * approving your own PR or a stale comment line). The caller should + * show a specific inline message and remove the retry affordance; + * the user must change the input or event before submitting again. + */ + kind: 'bad-request'; + /** Original error message, suitable for logging but not the UI. */ + message: string; + }; + +export function classifyPrReviewMutationError(error: unknown): PrReviewMutationErrorState { + const code = readTrpcErrorCode(error); + if (code === 'BAD_REQUEST') { + return { + kind: 'bad-request', + message: error instanceof Error ? error.message : 'Bad request', + }; + } + return { kind: 'retryable' }; +} diff --git a/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts b/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts new file mode 100644 index 0000000000..7e45af4b0c --- /dev/null +++ b/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { parseComposerParams } from '@/lib/pr-review/comment-composer-params'; + +function valid(overrides: Record = {}) { + return { + owner: 'kilocode', + repo: 'kilo', + number: '42', + path: 'src/lib.ts', + side: 'RIGHT', + line: '7', + ...overrides, + }; +} + +describe('parseComposerParams', () => { + it('returns parsed params for the happy path', () => { + expect(parseComposerParams(valid())).toEqual({ + owner: 'kilocode', + repo: 'kilo', + number: 42, + path: 'src/lib.ts', + side: 'RIGHT', + line: 7, + }); + }); + + it('parses optional startLine when present and <= line', () => { + expect(parseComposerParams(valid({ startLine: '5' }))).toEqual( + expect.objectContaining({ startLine: 5 }) + ); + }); + + it('rejects a missing owner', () => { + expect(parseComposerParams(valid({ owner: undefined }))).toBeNull(); + }); + + it('rejects a missing repo', () => { + expect(parseComposerParams(valid({ repo: undefined }))).toBeNull(); + }); + + it('rejects a non-positive number', () => { + expect(parseComposerParams(valid({ number: '0' }))).toBeNull(); + expect(parseComposerParams(valid({ number: '-1' }))).toBeNull(); + expect(parseComposerParams(valid({ number: 'abc' }))).toBeNull(); + }); + + it('rejects an empty path', () => { + expect(parseComposerParams(valid({ path: '' }))).toBeNull(); + }); + + it('rejects an invalid side', () => { + expect(parseComposerParams(valid({ side: 'MIDDLE' }))).toBeNull(); + expect(parseComposerParams(valid({ side: undefined }))).toBeNull(); + }); + + it('rejects a non-positive line', () => { + expect(parseComposerParams(valid({ line: '0' }))).toBeNull(); + expect(parseComposerParams(valid({ line: '-3' }))).toBeNull(); + expect(parseComposerParams(valid({ line: 'abc' }))).toBeNull(); + }); + + it('rejects startLine greater than line', () => { + expect(parseComposerParams(valid({ line: '5', startLine: '6' }))).toBeNull(); + }); + + it('rejects a non-positive startLine', () => { + expect(parseComposerParams(valid({ startLine: '0' }))).toBeNull(); + expect(parseComposerParams(valid({ startLine: '-1' }))).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/comment-composer-params.ts b/apps/mobile/src/lib/pr-review/comment-composer-params.ts new file mode 100644 index 0000000000..195813e617 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/comment-composer-params.ts @@ -0,0 +1,72 @@ +import { parseParam } from '@/lib/route-params'; + +type RawComposerParams = { + owner?: string | string[] | undefined; + repo?: string | string[] | undefined; + number?: string | string[] | undefined; + path?: string | string[] | undefined; + side?: string | string[] | undefined; + line?: string | string[] | undefined; + startLine?: string | string[] | undefined; +}; + +export type ParsedComposerParams = { + owner: string; + repo: string; + number: number; + path: string; + side: 'LEFT' | 'RIGHT'; + line: number; + startLine?: number; +}; + +function parsePositiveInt(value: string | string[] | undefined): number | null { + const text = parseParam(value); + if (!text) { + return null; + } + const parsed = Number.parseInt(text, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +/** + * Runtime-validates the comment-composer route params before the screen + * queries or renders the composer. Returns `null` for any invalid + * combination (missing owner/repo/number, empty path, invalid side, + * non-positive line, or startLine greater than line). + */ +export function parseComposerParams(raw: RawComposerParams): ParsedComposerParams | null { + const owner = parseParam(raw.owner); + const repo = parseParam(raw.repo); + const number = parsePositiveInt(raw.number); + const path = parseParam(raw.path); + const side = parseParam(raw.side, ['LEFT', 'RIGHT'] as const); + const line = parsePositiveInt(raw.line); + const startLine = parsePositiveInt(raw.startLine); + const hasStartLine = parseParam(raw.startLine) !== null; + + if (!owner || !repo || !number || !path || !side || !line) { + return null; + } + + if (hasStartLine && startLine === null) { + return null; + } + + if (startLine !== null && startLine > line) { + return null; + } + + return { + owner, + repo, + number, + path, + side, + line, + ...(startLine !== null ? { startLine } : {}), + }; +} diff --git a/apps/mobile/src/lib/pr-review/diff-selection.test.ts b/apps/mobile/src/lib/pr-review/diff-selection.test.ts new file mode 100644 index 0000000000..7b3bac5784 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff-selection.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { + clearSelection, + isLineInSelection, + type SelectionTap, + selectLine, + sideForDiffLineType, +} from '@/lib/pr-review/diff-selection'; + +function tap(overrides: Partial = {}): SelectionTap { + return { + path: 'src/foo.ts', + side: 'RIGHT', + line: 5, + hunkKey: 'src/foo.ts:0', + text: 'line 5', + ...overrides, + }; +} + +function hunkLines(entries: [number, string][]): Map { + return new Map(entries); +} + +describe('selectLine', () => { + it('starts a new single-line selection when state is null', () => { + const result = selectLine(null, tap({ line: 5, text: 'a' }), hunkLines([])); + expect(result).toEqual({ + path: 'src/foo.ts', + side: 'RIGHT', + hunkKey: 'src/foo.ts:0', + startLine: 5, + line: 5, + selectedText: 'a', + }); + }); + + it('extends the range when tapping the same path/side/hunk in ascending order', () => { + const first = selectLine(null, tap({ line: 5, text: 'a' }), hunkLines([])); + const second = selectLine( + first, + tap({ line: 7, text: 'c' }), + hunkLines([ + [5, 'a'], + [6, 'b'], + [7, 'c'], + ]) + ); + expect(second.startLine).toBe(5); + expect(second.line).toBe(7); + expect(second.selectedText).toBe('a\nb\nc'); + }); + + it('extends the range in descending order, taking min(startLine) and max(line)', () => { + const first = selectLine(null, tap({ line: 8, text: 'd' }), hunkLines([])); + const second = selectLine( + first, + tap({ line: 5, text: 'a' }), + hunkLines([ + [5, 'a'], + [6, 'b'], + [7, 'c'], + [8, 'd'], + ]) + ); + expect(second.startLine).toBe(5); + expect(second.line).toBe(8); + expect(second.selectedText).toBe('a\nb\nc\nd'); + }); + + it('replaces the selection when tapping the other side (LEFT ↔ RIGHT)', () => { + const first = selectLine(null, tap({ side: 'RIGHT', line: 5, text: 'a' }), hunkLines([])); + const second = selectLine( + first, + tap({ side: 'LEFT', line: 12, text: 'del-12' }), + hunkLines([]) + ); + expect(second).toEqual({ + path: 'src/foo.ts', + side: 'LEFT', + hunkKey: 'src/foo.ts:0', + startLine: 12, + line: 12, + selectedText: 'del-12', + }); + }); + + it('replaces the selection when crossing to a different hunk', () => { + const first = selectLine( + null, + tap({ line: 5, hunkKey: 'src/foo.ts:0', text: 'a' }), + hunkLines([]) + ); + const second = selectLine( + first, + tap({ line: 30, hunkKey: 'src/foo.ts:1', text: 'x' }), + hunkLines([]) + ); + expect(second).toEqual({ + path: 'src/foo.ts', + side: 'RIGHT', + hunkKey: 'src/foo.ts:1', + startLine: 30, + line: 30, + selectedText: 'x', + }); + }); + + it('replaces the selection when tapping a different file', () => { + const first = selectLine(null, tap({ path: 'src/a.ts', line: 1, text: 'a' }), hunkLines([])); + const second = selectLine(first, tap({ path: 'src/b.ts', line: 10, text: 'b' }), hunkLines([])); + expect(second.path).toBe('src/b.ts'); + expect(second.startLine).toBe(10); + expect(second.line).toBe(10); + }); + + it('uses the hunk line map for selected text, not the tap text, when the map is complete', () => { + const first = selectLine(null, tap({ line: 5, text: 'tap-text' }), hunkLines([[5, 'a']])); + const second = selectLine( + first, + tap({ line: 7, text: 'tap-text' }), + hunkLines([ + [5, 'a'], + [6, 'b'], + [7, 'c'], + ]) + ); + expect(second.selectedText).toBe('a\nb\nc'); + }); +}); + +describe('sideForDiffLineType', () => { + it('classifies add lines as RIGHT', () => { + expect(sideForDiffLineType('add')).toBe('RIGHT'); + }); + + it('classifies context lines as RIGHT', () => { + expect(sideForDiffLineType('context')).toBe('RIGHT'); + }); + + it('classifies del lines as LEFT', () => { + expect(sideForDiffLineType('del')).toBe('LEFT'); + }); +}); + +describe('clearSelection', () => { + it('returns null', () => { + expect(clearSelection()).toBeNull(); + }); +}); + +describe('isLineInSelection', () => { + it('returns false when no selection', () => { + expect(isLineInSelection(null, { line: 5 })).toBe(false); + }); + + it('returns true for a line inside the range', () => { + const state = { + path: 'src/foo.ts', + side: 'RIGHT' as const, + hunkKey: 'src/foo.ts:0', + startLine: 5, + line: 8, + selectedText: 'a\nb\nc\nd', + }; + expect(isLineInSelection(state, { line: 5 })).toBe(true); + expect(isLineInSelection(state, { line: 6 })).toBe(true); + expect(isLineInSelection(state, { line: 8 })).toBe(true); + }); + + it('returns false for a line outside the range', () => { + const state = { + path: 'src/foo.ts', + side: 'RIGHT' as const, + hunkKey: 'src/foo.ts:0', + startLine: 5, + line: 8, + selectedText: 'a\nb\nc\nd', + }; + expect(isLineInSelection(state, { line: 4 })).toBe(false); + expect(isLineInSelection(state, { line: 9 })).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff-selection.ts b/apps/mobile/src/lib/pr-review/diff-selection.ts new file mode 100644 index 0000000000..84920f9bf4 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff-selection.ts @@ -0,0 +1,102 @@ +// Pure diff-line selection reducer for the PR review composer. +// +// A selection is a contiguous range of commentable lines within a single +// hunk. The producer (the diff) reports each tap as +// `{path, side, line, hunkKey, text}` together with a `hunkLines` map +// keyed by the line number visible on the selected side. The reducer +// returns the next selection state, or `null` to mean "no selection". +// +// Rules (S7a): +// - No selection, or different path / different side / different +// hunk → START a new single-line selection. +// - Same path + same side + same hunk → EXTEND the range so that +// `startLine = min(state.startLine, tap.line)` and +// `line = max(state.line, tap.line)`. This keeps the range +// contiguous from the user's point of view even if they tap +// out of order, and matches the spec's "min/max" rule. +// +// Selection side is derived by the producer from the line type: +// `del` lines live on the LEFT, `add` and `context` lines live on +// the RIGHT (GitHub's review-comment model). Mixed-side ranges are +// rejected by the producer before they reach the reducer — a single +// tap can never carry both sides. + +import { type DiffLineType } from '@/lib/pr-review/diff/parse-patch'; + +export type SelectionSide = 'LEFT' | 'RIGHT'; + +export type SelectionTap = { + path: string; + side: SelectionSide; + line: number; + hunkKey: string; + /** The text of the tapped line (the right-side text for context/add, the left-side text for del). */ + text: string; +}; + +export type SelectionState = { + path: string; + side: SelectionSide; + hunkKey: string; + startLine: number; + line: number; + selectedText: string; +}; + +export function selectLine( + state: SelectionState | null, + tap: SelectionTap, + hunkLines: ReadonlyMap +): SelectionState { + if ( + !state || + state.path !== tap.path || + state.side !== tap.side || + state.hunkKey !== tap.hunkKey + ) { + return { + path: tap.path, + side: tap.side, + hunkKey: tap.hunkKey, + startLine: tap.line, + line: tap.line, + selectedText: tap.text, + }; + } + + const startLine = Math.min(state.startLine, tap.line); + const endLine = Math.max(state.line, tap.line); + const lines: string[] = []; + for (let current = startLine; current <= endLine; current += 1) { + lines.push(hunkLines.get(current) ?? tap.text); + } + return { + path: tap.path, + side: tap.side, + hunkKey: tap.hunkKey, + startLine, + line: endLine, + selectedText: lines.join('\n'), + }; +} + +export function clearSelection(): null { + return null; +} + +export function isLineInSelection(state: SelectionState | null, tap: { line: number }): boolean { + if (!state) { + return false; + } + return tap.line >= state.startLine && tap.line <= state.line; +} + +/** + * Producer-side classification: which side of the diff a commentable line + * lives on. `del` lines are on the LEFT; `add` and `context` lines are on + * the RIGHT (GitHub's review-comment model). Mixed-side ranges are rejected + * by the producer before they reach the reducer. + */ +export function sideForDiffLineType(type: DiffLineType): SelectionSide { + return type === 'del' ? 'LEFT' : 'RIGHT'; +} diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts index 0930945a38..4205a85981 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts @@ -53,6 +53,7 @@ export function pushGapItems(args: { kind: 'diff-line', key: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, lineKey: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + filePath: args.file.path, hunkIndex: args.hunkIndex, lineIndex: lineIdx, parsed: args.parsed, @@ -64,6 +65,7 @@ export function pushGapItems(args: { }, language: args.language, lineKeyId: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + selectable: false, }); } diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts index cb9a96deb3..b5ab064d85 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts @@ -218,6 +218,36 @@ describe('buildItems progressive context windowing', () => { context: { startLine: 28, endLine: 44 }, }); }); + + it('marks expanded gap lines as non-selectable', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(largeGapPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { + 0: { status: 'partial', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) }, + }, + }, + }) + ); + const gapLines = gapLinesFor(items, 'a.ts', 0); + expect(gapLines.length).toBeGreaterThan(0); + for (const gapLine of gapLines) { + expect(gapLine.selectable).toBe(false); + } + }); + + it('leaves real parsed-hunk lines selectable by default', () => { + const items = buildItems( + baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } }) + ); + const lines = diffLines(items).filter(l => l.lineKey.startsWith('line:')); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.selectable).not.toBe(false); + } + }); }); describe('buildItems trailing gap totalLines', () => { diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts index e322a03834..5b9f5ecfb9 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts @@ -44,6 +44,7 @@ function pushSideBySideHunk(args: { kind: 'side-by-side-row', key: rowKeyId, rowKey: rowKeyId, + filePath: file.path, hunkIndex, rowIndex, row, @@ -76,6 +77,7 @@ function pushUnifiedHunk(args: { kind: 'diff-line', key: `line:${file.path}:${hunkIndex}:${lineIndex}`, lineKey: `line:${file.path}:${hunkIndex}:${lineIndex}`, + filePath: file.path, hunkIndex, lineIndex, parsed, diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts index 9a5079b51b..758c74c017 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts @@ -50,18 +50,26 @@ export type DiffLineItem = { kind: 'diff-line'; key: string; lineKey: string; + /** The owning file path — needed by the S7a tap producer to build a `DiffSelection`. */ + filePath: string; hunkIndex: number; lineIndex: number; parsed: ParsedPatch; line: ParsedDiffLine; language: string | null; lineKeyId: string; + /** + * Gap/expanded-context lines are read-only and must not participate in + * diff-line selection. Real parsed-hunk lines are selectable by default. + */ + selectable?: boolean; }; export type SideBySideRowItem = { kind: 'side-by-side-row'; key: string; rowKey: string; + filePath: string; hunkIndex: number; rowIndex: number; row: SideBySideRow; diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx index 9cb5f5da50..5215ce5120 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx @@ -1,8 +1,14 @@ import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; -// Minimal item shape — S7a owns the final pending-comment fields. Keeping -// the type intentionally loose lets the provider land in S4b without -// baking in fields that the comment composer hasn't decided on yet. +// One queued inline comment in the pending review. The composer fills +// this in when the user taps "Add to review"; the submit sheet drains +// the whole list into one `submitReview` batch call. +// +// `commitSha` records the PR head SHA at the time the comment was +// queued so the submit sheet can flag "may be outdated" if the head +// moves between queue and submit. Submission itself always uses the +// LATEST head SHA (per the S3 contract) — a per-item 422 surfaces +// inline so the user can decide whether to retry or drop the comment. export type PendingReviewItem = { id: string; path: string; @@ -10,11 +16,13 @@ export type PendingReviewItem = { line: number; startLine?: number; body: string; + commitSha: string; }; type PendingReviewContextValue = { items: PendingReviewItem[]; addComment: (item: PendingReviewItem) => void; + updateComment: (id: string, body: string) => void; removeComment: (id: string) => void; clear: () => void; }; @@ -28,6 +36,10 @@ export function PendingReviewProvider({ children }: { readonly children: ReactNo setItems(previous => [...previous, item]); }, []); + const updateComment = useCallback((id: string, body: string) => { + setItems(previous => previous.map(item => (item.id === id ? { ...item, body } : item))); + }, []); + const removeComment = useCallback((id: string) => { setItems(previous => previous.filter(item => item.id !== id)); }, []); @@ -37,8 +49,8 @@ export function PendingReviewProvider({ children }: { readonly children: ReactNo }, []); const value = useMemo( - () => ({ items, addComment, removeComment, clear }), - [items, addComment, removeComment, clear] + () => ({ items, addComment, updateComment, removeComment, clear }), + [items, addComment, updateComment, removeComment, clear] ); return {children}; diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts new file mode 100644 index 0000000000..fa4ca1f5b4 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts @@ -0,0 +1,108 @@ +// S7a mutation hooks for inline PR review comments and the pending- +// review batch submit. The pattern mirrors the existing S8 merge +// mutations: +// - `onError` toasts the message +// - `onSettled` invalidates the PR review queries that the +// mutation could have invalidated (overview `getPullRequest` for +// `submitReview` because reviewDecision may flip; `listReviewThreads` +// for both because a new thread lands immediately) +// - the sheet / composer ALSO renders inline errors because toasts +// paint behind formSheets on iOS +// +// `createReviewComment` posts ONE comment immediately (no pending +// review). `submitReview` posts a BATCH — the composer enqueues +// comments into the `PendingReviewProvider` and the submit sheet +// drains that queue into one `submitReview` call. The submission +// uses the LATEST head SHA (per the S3 contract) regardless of what +// SHA each item was queued under; a per-item 422 surfaces inline. + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner-native'; + +import { useTRPC } from '@/lib/trpc'; + +type PrRef = { owner: string; repo: string; number: number }; + +function usePrRefKeys(ref: PrRef) { + const trpc = useTRPC(); + return { + getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref), + listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + }; +} + +async function invalidateReviewCaches( + queryClient: ReturnType, + keys: ReturnType +): Promise { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: keys.getPullRequest }), + queryClient.invalidateQueries(keys.listReviewThreadsPath), + ]); +} + +export type CreateReviewCommentInput = { + owner: string; + repo: string; + number: number; + body: string; + path: string; + line: number; + side: 'LEFT' | 'RIGHT'; + startLine?: number; + startSide?: 'LEFT' | 'RIGHT'; + commitSha: string; +}; + +export function useCreateReviewCommentMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.createReviewComment.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidateReviewCaches(queryClient, keys); + }, + }) + ); +} + +export type SubmitReviewComment = { + path: string; + line: number; + side: 'LEFT' | 'RIGHT'; + startLine?: number; + startSide?: 'LEFT' | 'RIGHT'; + body: string; +}; + +export type SubmitReviewInput = { + owner: string; + repo: string; + number: number; + event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; + body?: string; + commitSha: string; + comments?: SubmitReviewComment[]; +}; + +export function useSubmitReviewMutation(ref: PrRef) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = usePrRefKeys(ref); + + return useMutation( + trpc.githubPrReview.submitReview.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidateReviewCaches(queryClient, keys); + }, + }) + ); +} From 4a77e730ba84792e2797fce95619330636c50a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 06:32:37 +0200 Subject: [PATCH 12/19] feat(mobile): pr discussion threads --- .../pr-review/discussion/comment-row.tsx | 67 ++++ .../discussion/discussion-thread.tsx | 339 ++++++++++++++++++ .../pr-review/discussion/reactions-row.tsx | 133 +++++++ .../pr-review/pr-review-discussion-tab.tsx | 292 ++++++++++++++- .../components/pr-review/pr-review-screen.tsx | 11 +- .../review-discussion-reducers.test.ts | 184 ++++++++++ .../review-discussion-types.test.ts | 195 ++++++++++ .../discussion/review-discussion-types.ts | 300 ++++++++++++++++ .../use-pr-review-discussion-threads.ts | 57 +++ .../use-review-discussion-mutations.ts | 233 ++++++++++++ 10 files changed, 1793 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/discussion/comment-row.tsx create mode 100644 apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx create mode 100644 apps/mobile/src/components/pr-review/discussion/reactions-row.tsx create mode 100644 apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts create mode 100644 apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts create mode 100644 apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts create mode 100644 apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts create mode 100644 apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx new file mode 100644 index 0000000000..7417515fc4 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx @@ -0,0 +1,67 @@ +// Single review-comment row: author block + Markdown body + reactions. +// +// `useThemeColors` drives the Lucide / accent colors. Author +// rendering reuses the same "avatar + login / 'deleted user'" +// pattern as the Overview tab's `PrAuthorRow`, so a deleted +// account surfaces as a muted circle + "deleted user" label. +// +// Reactions are rendered via the `ReactionsRow` subcomponent; the +// toggle is a single callback so the comment row does not need to +// know about the mutations. + +import { MarkdownText } from '@/components/agents/markdown-text'; +import { Image } from '@/components/ui/image'; +import { Text } from '@/components/ui/text'; +import { ReactionsRow } from '@/components/pr-review/discussion/reactions-row'; +import { + type ReviewComment, + type ReviewReactionContent, + selectCommentAuthorName, +} from '@/lib/pr-review/discussion/review-discussion-types'; +import { parseTimestamp, timeAgo } from '@/lib/utils'; +import { View } from 'react-native'; + +export type CommentRowProps = { + readonly comment: ReviewComment; + readonly onToggleReaction: (content: ReviewReactionContent) => void; + readonly reactionsDisabled?: boolean; +}; + +export function CommentRow({ + comment, + onToggleReaction, + reactionsDisabled, +}: Readonly) { + const authorName = selectCommentAuthorName(comment.author); + const timestamp = parseTimestamp(comment.createdAt); + const relative = timeAgo(timestamp); + + return ( + + + {comment.author?.avatarUrl ? ( + + ) : ( + + )} + + {authorName} + + + {relative} + + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx new file mode 100644 index 0000000000..4675184157 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -0,0 +1,339 @@ +// Single review-thread card: anchor header + comments list + reply input. +// +// - The thread header shows the anchor label ("src/a.ts L10 (RIGHT)" +// or "File comment on src/a.ts" or "Outdated on ...") and the +// "Outdated" / "Resolved" badges when applicable. +// - Resolved threads are COLLAPSED by default (tapping the header +// expands them). The repo's UI/UX rule for compact product rhythm +// is to keep the noise level down on the happy path, so an +// accepted PR's collapsed thread pile shouldn't dominate the tab. +// - The reply input is uncontrolled (iOS ref pattern) per the +// repo's iOS rule and per the comment-composer reference +// implementation. Submit calls the (non-optimistic) reply +// mutation and re-fetches the list on settle. +// - The resolve / unresolve / reaction toggles are OPTIMISTIC; +// the mutation hooks own the cache update + rollback, so the +// thread just routes the events and lets the cache flow. + +import * as Haptics from 'expo-haptics'; +import { Check, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react-native'; +import { useEffect, useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { CommentRow } from '@/components/pr-review/discussion/comment-row'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { + type ReviewComment, + type ReviewReactionContent, + type ReviewThread, + selectThreadAnchorLabel, + selectThreadBadges, +} from '@/lib/pr-review/discussion/review-discussion-types'; +import { + useAddReactionMutation, + useRemoveReactionMutation, + useReplyToCommentMutation, + useResolveThreadMutation, + useUnresolveThreadMutation, +} from '@/lib/pr-review/discussion/use-review-discussion-mutations'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; + +const REPLY_PLACEHOLDER = 'Reply…'; + +export type DiscussionThreadProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly thread: ReviewThread; +}; + +export function DiscussionThread({ owner, repo, number, thread }: Readonly) { + // Resolved threads start collapsed; active threads start expanded. + const [expanded, setExpanded] = useState(!thread.isResolved); + + const resolve = useResolveThreadMutation(); + const unresolve = useUnresolveThreadMutation(); + const addReaction = useAddReactionMutation(thread.threadId); + const removeReaction = useRemoveReactionMutation(thread.threadId); + const reply = useReplyToCommentMutation(); + + const anchorLabel = selectThreadAnchorLabel(thread); + const badges = selectThreadBadges(thread); + const firstComment = thread.comments[0]; + const isResolving = resolve.isPending || unresolve.isPending; + const isReacting = addReaction.isPending || removeReaction.isPending; + + const onToggleResolve = () => { + void Haptics.selectionAsync(); + if (thread.isResolved) { + unresolve.mutate({ threadId: thread.threadId }); + } else { + resolve.mutate({ threadId: thread.threadId }); + } + }; + + const onToggleReaction = (comment: ReviewComment, content: ReviewReactionContent) => { + // Haptic is emitted by ReactionsRow's press handler; don't double-fire here. + const existing = comment.reactions.find(r => r.content === content); + if (existing?.viewerHasReacted) { + removeReaction.mutate({ commentNodeId: comment.nodeId, content }); + } else { + addReaction.mutate({ commentNodeId: comment.nodeId, content }); + } + }; + + return ( + + { + setExpanded(prev => !prev); + }} + onToggleResolve={onToggleResolve} + resolveDisabled={isResolving} + /> + {expanded ? ( + <> + + {thread.comments.map((comment, index) => ( + 0 && 'border-t border-border pt-4')}> + { + onToggleReaction(comment, content); + }} + /> + + ))} + + {firstComment ? ( + + ) : null} + + ) : null} + + ); +} + +// ── Header ──────────────────────────────────────────────────────────── + +type ThreadHeaderProps = { + readonly anchorLabel: string; + readonly resolved: boolean; + readonly outdated: boolean; + readonly fileLevel: boolean; + readonly commentCount: number; + readonly firstTimestamp: string | null; + readonly expanded: boolean; + readonly onToggleExpand: () => void; + readonly onToggleResolve: () => void; + readonly resolveDisabled: boolean; +}; + +function ThreadHeader({ + anchorLabel, + resolved, + outdated, + fileLevel, + commentCount, + firstTimestamp, + expanded, + onToggleExpand, + onToggleResolve, + resolveDisabled, +}: Readonly) { + const colors = useThemeColors(); + const relative = firstTimestamp ? timeAgo(parseTimestamp(firstTimestamp)) : null; + return ( + + + + {expanded ? ( + + ) : ( + + )} + + {anchorLabel} + + + + + + {resolved ? : null} + {outdated ? : null} + {fileLevel && !resolved ? : null} + + {commentCount === 1 ? '1 comment' : `${commentCount} comments`} + {relative ? ` · started ${relative}` : ''} + + + + ); +} + +type BadgeProps = { + readonly tone: 'good' | 'muted' | 'warn' | 'destructive'; + readonly icon?: typeof Check; + readonly label: string; +}; + +const BADGE_TONE_CLASS: Record = { + good: 'bg-secondary text-good', + warn: 'bg-secondary text-warn', + destructive: 'bg-secondary text-destructive', + muted: 'bg-secondary text-muted-foreground', +}; + +function Badge({ tone, icon: Icon, label }: Readonly) { + const colors = useThemeColors(); + const toneClass = BADGE_TONE_CLASS[tone]; + // Native Lucide icons don't resolve NativeWind text classes, so set the + // icon color explicitly per tone from the theme tokens. + const iconColor: Record = { + good: colors.good, + warn: colors.warn, + destructive: colors.destructive, + muted: colors.mutedForeground, + }; + return ( + + {Icon ? : null} + {label} + + ); +} + +type ResolveToggleProps = { + readonly resolved: boolean; + readonly disabled: boolean; + readonly onPress: () => void; +}; + +function ResolveToggle({ resolved, disabled, onPress }: Readonly) { + const colors = useThemeColors(); + return ( + + + + {resolved ? 'Resolved' : 'Resolve'} + + + ); +} + +// ── Reply input (uncontrolled) ──────────────────────────────────────── + +type ReplyInputProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly commentId: number; + readonly reply: ReturnType; +}; + +function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) { + const colors = useThemeColors(); + const bodyRef = useRef(''); + const inputRef = useRef(null); + const [inlineError, setInlineError] = useState(null); + const [resetKey, setResetKey] = useState(0); + + // Mirror mutation error into the inline box. Reply is NOT + // optimistic, so the user can hit the inline error and retry + // without waiting for a re-fetch. + useEffect(() => { + if (reply.error) { + const message = reply.error instanceof Error ? reply.error.message : 'Could not reply.'; + setInlineError(message); + } + }, [reply.error]); + + const submit = () => { + const body = bodyRef.current.trim(); + if (!body || reply.isPending) { + return; + } + setInlineError(null); + reply.mutate( + { owner, repo, number, commentId, body }, + { + onSuccess: () => { + bodyRef.current = ''; + setResetKey(prev => prev + 1); + }, + } + ); + }; + + return ( + + { + bodyRef.current = value; + if (inlineError) { + setInlineError(null); + } + }} + multiline + textAlignVertical="top" + className="min-h-16 rounded-md border border-input bg-background px-3 py-2 text-sm leading-5 text-foreground" + /> + {inlineError ? {inlineError} : null} + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx new file mode 100644 index 0000000000..a58c77b42e --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx @@ -0,0 +1,133 @@ +// Reactions row for a single review comment. +// +// GitHub's review-comment reactions are a fixed set of 8 emoji +// (`THUMBS_UP, THUMBS_DOWN, LAUGH, HOORAY, CONFUSED, HEART, +// ROCKET, EYES`). Each one is rendered as a small pill that shows +// the current count when > 0 and a darker fill when the viewer +// has already reacted. +// +// Tapping a pill toggles: if the viewer has reacted, fire +// `removeReaction`; otherwise `addReaction`. The optimistic cache +// reducer (`applyReactionToggle`) updates the row instantly, so the +// count + fill flip in the same frame as the tap. +// +// Disabled state is exposed for callers that want to lock the row +// during the mutation's pending phase (rare — the optimistic update +// makes the row look responsive; the hook will still rollback on +// error). + +import * as Haptics from 'expo-haptics'; +import { Pressable, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { + REVIEW_REACTION_CONTENTS, + type ReviewReactionContent, +} from '@/lib/pr-review/discussion/review-discussion-types'; +import { cn } from '@/lib/utils'; + +// Map each GitHub reaction content to the emoji that GitHub itself +// renders in its UI. Kept inline (not in a shared emoji module) so +// the discussion tab stays a self-contained slice. +const REACTION_EMOJI: Record = { + THUMBS_UP: '👍', + THUMBS_DOWN: '👎', + LAUGH: '😄', + HOORAY: '🎉', + CONFUSED: '😕', + HEART: '❤️', + ROCKET: '🚀', + EYES: '👀', +}; + +export type ReactionsRowProps = { + // Raw reactions from the DTO — `content` is a plain string (GitHub can + // return content outside the 8 emoji). We index by string and only render + // + toggle the fixed 8 known reactions. + readonly reactions: readonly { + readonly content: string; + readonly count: number; + readonly viewerHasReacted: boolean; + }[]; + readonly onToggle: (content: ReviewReactionContent) => void; + readonly disabled?: boolean; +}; + +export function ReactionsRow({ reactions, onToggle, disabled }: Readonly) { + // Index existing reactions by content for O(1) lookup. Missing + // reactions render as an empty pill (no count) so the user can + // discover the full set. + const byContent = new Map(); + for (const r of reactions) { + byContent.set(r.content, { count: r.count, viewerHasReacted: r.viewerHasReacted }); + } + return ( + + {REVIEW_REACTION_CONTENTS.map(content => { + const existing = byContent.get(content); + const count = existing?.count ?? 0; + const reacted = existing?.viewerHasReacted ?? false; + return ( + { + void Haptics.selectionAsync(); + onToggle(content); + }} + /> + ); + })} + + ); +} + +type ReactionPillProps = { + readonly content: ReviewReactionContent; + readonly emoji: string; + readonly count: number; + readonly viewerHasReacted: boolean; + readonly disabled: boolean; + readonly onPress: () => void; +}; + +function ReactionPill({ + emoji, + count, + viewerHasReacted, + disabled, + onPress, +}: Readonly) { + // Reacted pills get the accent-soft fill (same as the rest of the + // product's "active toggle" surface) so they read as selected in + // both light and dark themes. Unreacted pills use a flat border + // so the row stays calm when the user hasn't engaged. + return ( + + {emoji} + {count > 0 ? ( + + {count} + + ) : null} + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index 7f0738e456..e7655485bf 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -1,32 +1,290 @@ -import { MessageSquare } from 'lucide-react-native'; +// PR review Discussion tab body. +// +// State matrix (per S7b §6 Discussion): +// - happy: threads render, grouped by file path; first +// page auto-loads, "Load more" paginates. +// - loading: first page in flight; render `Skeleton` +// placeholders matching the row dimensions. +// - retryable: first page failed with a transient error; +// render `QueryError` with the standard Retry +// CTA wired to `refetch()`. +// - permission: first page failed with FORBIDDEN / UNAUTHORIZED; +// terminal message, no CTA (per the repo's +// rule that permanent permission errors must not +// offer a retry). +// - not-found: first page failed with NOT_FOUND; terminal +// message, no CTA (the PR is gone). +// - reconnect: first page failed with PRECONDITION_FAILED; +// terminal message pointing the user at the +// connect gate (the connect flow is owned by the +// screen-level `PrReviewConnectGate`, which is +// already mounted by the parent screen). +// - empty: first page returned zero threads AND no +// terminal error; render `EmptyState` with the +// "No review comments yet" copy and a "Review +// files" CTA that switches to the Files tab via +// the `onRequestFiles` prop (the screen must +// pass it; we degrade gracefully if it's +// omitted). +// +// - later-page error: a per-page refetch failure during a +// "Load more" tap. The current loaded +// threads are kept and a small retry row +// renders at the bottom of the list. +// +// The component does NOT own a ScrollView — the tab is mounted +// inside the screen's tab shell and needs a fresh FlatList so the +// list can virtualize when a PR has hundreds of threads. (Same +// approach as the Files tab.) + +import { FlashList } from '@shopify/flash-list'; +import { MessageSquarePlus } from 'lucide-react-native'; import { View } from 'react-native'; +import { DiscussionThread } from '@/components/pr-review/discussion/discussion-thread'; +import { EmptyState } from '@/components/empty-state'; +import { QueryError } from '@/components/query-error'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; -import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + groupThreadsByPath, + type ReviewThread, +} from '@/lib/pr-review/discussion/review-discussion-types'; +import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads'; +import { cn } from '@/lib/utils'; export type PrReviewDiscussionTabProps = { readonly owner: string; readonly repo: string; readonly number: number; + /** + * Invoked by the empty state ("No review comments yet") to switch + * to the Files tab. Optional; if absent, the CTA is hidden. + */ + readonly onRequestFiles?: () => void; }; -/** - * Placeholder body for the Discussion tab. S5 renders a centered - * "Discussion coming soon" so the tab shell can be exercised end-to-end; - * S7b replaces the body with the review-thread list and pending-comment - * summary. - */ -export function PrReviewDiscussionTab({ owner, repo, number }: PrReviewDiscussionTabProps) { - const colors = useThemeColors(); +const SKELETON_ROW_COUNT = 4; +const DISCUSSION_LIST_CONTENT_STYLE = { paddingTop: 12 }; + +export function PrReviewDiscussionTab({ + owner, + repo, + number, + onRequestFiles, +}: PrReviewDiscussionTabProps) { + const { query, threads, firstPageErrorState, laterPageError } = usePrReviewDiscussionThreads({ + owner, + repo, + number, + }); + + // ── First-page error / terminal states ───────────────────────────── + if (firstPageErrorState) { + if (firstPageErrorState.kind === 'permission') { + return ( + + ); + } + if (firstPageErrorState.kind === 'not-found') { + return ( + + ); + } + if (firstPageErrorState.kind === 'reconnect') { + return ( + + ); + } + // retryable + return ( + { + void query.refetch(); + }} + isRetrying={query.isFetching} + /> + ); + } + + // ── Loading (first page in flight) ───────────────────────────────── + if (query.isPending) { + return ( + + {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key -- skeleton placeholders have no stable id + + + + + + + ))} + + ); + } + + // ── Empty ────────────────────────────────────────────────────────── + if (threads.length === 0) { + return ( + + + Review files + + ) : null + } + /> + + ); + } + + // ── Happy / paginated list ───────────────────────────────────────── + const groups = groupThreadsByPath(threads); + // Flatten the grouped list into a single list with separator + // rows between groups. Separator rows have `type: 'separator'` + // and the threads have `type: 'thread'`. + const listItems: ListItem[] = []; + for (const group of groups) { + if (groups.length > 1) { + listItems.push({ type: 'separator', path: group.path }); + } + for (const thread of group.threads) { + listItems.push({ type: 'thread', thread }); + } + } + return ( - - - - Discussion for {owner}/{repo}#{number} coming soon. + item.type} + renderItem={({ item }) => { + if (item.type === 'separator') { + return ; + } + return ( + + + + ); + }} + contentContainerStyle={DISCUSSION_LIST_CONTENT_STYLE} + keyboardShouldPersistTaps="handled" + automaticallyAdjustKeyboardInsets + ListFooterComponent={ + { + void query.fetchNextPage(); + }} + onRetryLoadMore={() => { + void query.refetch(); + }} + /> + } + /> + ); +} + +// ── List item shape ────────────────────────────────────────────────── + +type ListItem = + | { readonly type: 'separator'; readonly path: string } + | { + readonly type: 'thread'; + readonly thread: ReviewThread; + }; + +function keyForItem(item: ListItem): string { + return item.type === 'separator' ? `sep:${item.path}` : `thread:${item.thread.threadId}`; +} + +function GroupSeparator({ path }: Readonly<{ path: string }>) { + return ( + + + {path} + + + ); +} + +// ── Footer (Load more / error row) ─────────────────────────────────── + +type ListFooterProps = { + readonly hasNextPage: boolean; + readonly isFetchingNextPage: boolean; + readonly laterPageError: boolean; + readonly onLoadMore: () => void; + readonly onRetryLoadMore: () => void; +}; + +function ListFooter({ + hasNextPage, + isFetchingNextPage, + laterPageError, + onLoadMore, + onRetryLoadMore, +}: Readonly) { + if (laterPageError) { + return ( + + + Could not load more comments. + + + + ); + } + if (!hasNextPage) { + return ; + } + return ( + + ); } diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx index 0d0b85dfed..164b35ea2f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx @@ -121,7 +121,16 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) { /> ); } else { - body = ; + body = ( + { + setTab('files'); + }} + /> + ); } return ( diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts new file mode 100644 index 0000000000..820312bc72 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyReactionToggle, + applyResolveToggle, + findReviewComment, + type ReviewThread, + type ReviewThreadsInfiniteData, +} from './review-discussion-types'; + +function makeThread(overrides: Partial = {}): ReviewThread { + return { + threadId: 'T1', + isResolved: false, + isOutdated: false, + subjectType: 'LINE', + path: 'src/index.ts', + line: 10, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: 'RIGHT', + comments: [ + { + commentId: 1, + nodeId: 'C1', + author: { login: 'alice', avatarUrl: 'https://example.com/a.png' }, + bodyMarkdown: 'hello', + createdAt: '2024-01-01T00:00:00Z', + reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }], + }, + ], + ...overrides, + }; +} + +function makeData(threads: ReviewThread[]): ReviewThreadsInfiniteData { + return { + pages: [{ threads, nextCursor: null }], + pageParams: [null], + }; +} + +describe('applyResolveToggle', () => { + it('flips the matching threadId to the next value', () => { + const data = makeData([makeThread({ threadId: 'A' }), makeThread({ threadId: 'B' })]); + const next = applyResolveToggle(data, 'A', true); + expect(next?.pages[0]?.threads.find(t => t.threadId === 'A')?.isResolved).toBe(true); + expect(next?.pages[0]?.threads.find(t => t.threadId === 'B')?.isResolved).toBe(false); + }); + + it('returns the same reference when no thread matched', () => { + const data = makeData([makeThread({ threadId: 'A' })]); + const next = applyResolveToggle(data, 'ZZZ', true); + expect(next).toBe(data); + }); + + it('returns the same reference when the threadId matched but the value is already next', () => { + const data = makeData([makeThread({ threadId: 'A', isResolved: true })]); + const next = applyResolveToggle(data, 'A', true); + expect(next).toBe(data); + }); + + it('returns undefined for undefined input', () => { + expect(applyResolveToggle(undefined, 'A', true)).toBeUndefined(); + }); + + it('walks all pages, not just the first', () => { + const data: ReviewThreadsInfiniteData = { + pages: [ + { threads: [makeThread({ threadId: 'A' })], nextCursor: 'p2' }, + { threads: [makeThread({ threadId: 'B' })], nextCursor: null }, + ], + pageParams: [null, 'p2'], + }; + const next = applyResolveToggle(data, 'B', true); + expect(next?.pages[1]?.threads[0]?.isResolved).toBe(true); + expect(next?.pages[0]?.threads[0]?.isResolved).toBe(false); + }); +}); + +describe('applyReactionToggle', () => { + it('adds a reaction when the viewer is not yet reacted', () => { + const data = makeData([ + makeThread({ + comments: [ + { + commentId: 1, + nodeId: 'C1', + author: { login: 'alice', avatarUrl: null }, + bodyMarkdown: 'hi', + createdAt: '2024-01-01T00:00:00Z', + reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }], + }, + ], + }), + ]); + const next = applyReactionToggle({ + data, + threadId: 'T1', + commentNodeId: 'C1', + content: 'HEART', + }); + const comment = findReviewComment(next, 'T1', 'C1'); + expect(comment?.reactions).toEqual([ + { content: 'THUMBS_UP', count: 2, viewerHasReacted: false }, + { content: 'HEART', count: 1, viewerHasReacted: true }, + ]); + }); + + it('removes a reaction when the viewer is already reacted', () => { + const data = makeData([ + makeThread({ + comments: [ + { + commentId: 1, + nodeId: 'C1', + author: { login: 'alice', avatarUrl: null }, + bodyMarkdown: 'hi', + createdAt: '2024-01-01T00:00:00Z', + reactions: [{ content: 'THUMBS_UP', count: 3, viewerHasReacted: true }], + }, + ], + }), + ]); + const next = applyReactionToggle({ + data, + threadId: 'T1', + commentNodeId: 'C1', + content: 'THUMBS_UP', + }); + const comment = findReviewComment(next, 'T1', 'C1'); + expect(comment?.reactions).toEqual([ + { content: 'THUMBS_UP', count: 2, viewerHasReacted: false }, + ]); + }); + + it('clamps the count at 0 on a remove-from-zero race', () => { + const data = makeData([ + makeThread({ + comments: [ + { + commentId: 1, + nodeId: 'C1', + author: { login: 'alice', avatarUrl: null }, + bodyMarkdown: 'hi', + createdAt: '2024-01-01T00:00:00Z', + reactions: [{ content: 'THUMBS_UP', count: 0, viewerHasReacted: true }], + }, + ], + }), + ]); + const next = applyReactionToggle({ + data, + threadId: 'T1', + commentNodeId: 'C1', + content: 'THUMBS_UP', + }); + const comment = findReviewComment(next, 'T1', 'C1'); + expect(comment?.reactions[0]?.count).toBe(0); + }); + + it('returns the same reference when no matching comment exists', () => { + const data = makeData([makeThread()]); + const next = applyReactionToggle({ + data, + threadId: 'T1', + commentNodeId: 'NOT-THERE', + content: 'HEART', + }); + expect(next).toBe(data); + }); + + it('returns the same reference when no matching thread exists', () => { + const data = makeData([makeThread({ threadId: 'A' })]); + const next = applyReactionToggle({ + data, + threadId: 'ZZZ', + commentNodeId: 'C1', + content: 'HEART', + }); + expect(next).toBe(data); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts new file mode 100644 index 0000000000..fd4cbf390d --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from 'vitest'; + +import { + groupThreadsByPath, + type ReviewThread, + selectCommentAuthorName, + selectThreadAnchorLabel, + selectThreadBadges, +} from './review-discussion-types'; + +function makeThread(overrides: Partial = {}): ReviewThread { + return { + threadId: 'T1', + isResolved: false, + isOutdated: false, + subjectType: 'LINE', + path: 'src/index.ts', + line: 10, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: 'RIGHT', + comments: [ + { + commentId: 1, + nodeId: 'C1', + author: { login: 'alice', avatarUrl: 'https://example.com/a.png' }, + bodyMarkdown: 'hello', + createdAt: '2024-01-01T00:00:00Z', + reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }], + }, + ], + ...overrides, + }; +} + +describe('selectThreadAnchorLabel', () => { + it('renders an active line anchor with side', () => { + expect(selectThreadAnchorLabel(makeThread({ line: 10, diffSide: 'RIGHT' }))).toBe( + 'src/index.ts L10 (RIGHT)' + ); + }); + + it('renders an active range anchor with start/end', () => { + expect(selectThreadAnchorLabel(makeThread({ line: 12, startLine: 10, diffSide: 'LEFT' }))).toBe( + 'src/index.ts L10–L12 (LEFT)' + ); + }); + + it('renders a file-level thread label when subjectType is FILE', () => { + expect(selectThreadAnchorLabel(makeThread({ subjectType: 'FILE' }))).toBe( + 'File comment on src/index.ts' + ); + }); + + it('renders a file-level thread label when line is null', () => { + expect(selectThreadAnchorLabel(makeThread({ line: null }))).toBe( + 'File comment on src/index.ts' + ); + }); + + it('renders an outdated thread label using originalLine', () => { + expect( + selectThreadAnchorLabel( + makeThread({ + isOutdated: true, + line: null, + startLine: null, + originalLine: 8, + originalStartLine: 5, + diffSide: 'LEFT', + }) + ) + ).toBe('Outdated on src/index.ts L5–L8 (LEFT)'); + }); + + it('renders an outdated label with a single line', () => { + expect( + selectThreadAnchorLabel( + makeThread({ + isOutdated: true, + line: null, + startLine: null, + originalLine: 8, + originalStartLine: null, + diffSide: null, + }) + ) + ).toBe('Outdated on src/index.ts L8'); + }); + + it('renders a path-only outdated label when originalLine is null (no dangling L)', () => { + expect( + selectThreadAnchorLabel( + makeThread({ + isOutdated: true, + line: null, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: 'RIGHT', + }) + ) + ).toBe('Outdated on src/index.ts (RIGHT)'); + }); + + it('renders a bare Outdated label when both path and originalLine are null', () => { + expect( + selectThreadAnchorLabel( + makeThread({ + isOutdated: true, + path: null, + line: null, + startLine: null, + originalLine: null, + originalStartLine: null, + diffSide: null, + }) + ) + ).toBe('Outdated'); + }); + + it('ignores a null originalStartLine and shows only the original line', () => { + expect( + selectThreadAnchorLabel( + makeThread({ + isOutdated: true, + line: null, + originalLine: 8, + originalStartLine: null, + }) + ) + ).toBe('Outdated on src/index.ts L8 (RIGHT)'); + }); + + it('omits side when diffSide is null', () => { + expect(selectThreadAnchorLabel(makeThread({ diffSide: null, line: 10 }))).toBe( + 'src/index.ts L10' + ); + }); +}); + +describe('selectThreadBadges', () => { + it('flags resolved + outdated + file-level independently', () => { + expect(selectThreadBadges(makeThread({ isResolved: true }))).toEqual({ + resolved: true, + outdated: false, + fileLevel: false, + }); + expect(selectThreadBadges(makeThread({ isOutdated: true }))).toEqual({ + resolved: false, + outdated: true, + fileLevel: false, + }); + expect(selectThreadBadges(makeThread({ subjectType: 'FILE' }))).toEqual({ + resolved: false, + outdated: false, + fileLevel: true, + }); + expect(selectThreadBadges(makeThread({ line: null }))).toEqual({ + resolved: false, + outdated: false, + fileLevel: true, + }); + }); +}); + +describe('selectCommentAuthorName', () => { + it('returns the login when present', () => { + expect(selectCommentAuthorName({ login: 'alice', avatarUrl: null })).toBe('alice'); + }); + + it('returns the "deleted user" fallback when author is null', () => { + expect(selectCommentAuthorName(null)).toBe('deleted user'); + }); +}); + +describe('groupThreadsByPath', () => { + it('groups threads by path, preserving insertion order', () => { + const a = makeThread({ threadId: 'A', path: 'src/a.ts' }); + const b = makeThread({ threadId: 'B', path: 'src/b.ts' }); + const a2 = makeThread({ threadId: 'A2', path: 'src/a.ts' }); + const groups = groupThreadsByPath([a, b, a2]); + expect(groups.map(g => g.path)).toEqual(['src/a.ts', 'src/b.ts']); + expect(groups[0]?.threads.map(t => t.threadId)).toEqual(['A', 'A2']); + expect(groups[1]?.threads.map(t => t.threadId)).toEqual(['B']); + }); + + it('buckets null-path threads under "(no file)"', () => { + const orphan = makeThread({ threadId: 'X', path: null }); + const a = makeThread({ threadId: 'A', path: 'src/a.ts' }); + const groups = groupThreadsByPath([a, orphan]); + expect(groups.map(g => g.path)).toEqual(['src/a.ts', '(no file)']); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts new file mode 100644 index 0000000000..bd2012e4dc --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts @@ -0,0 +1,300 @@ +// Pure types, helpers, and reducers for the PR review Discussion tab. +// +// The discussion tab is built from three pure contracts: +// 1. The trpc DTO shapes (thread / comment / reaction) — these +// mirror the committed S2 read DTO and S3 mutation DTOs. +// We re-declare the narrowest types here (rather than depending +// on the trpc root type) so the helpers stay testable in plain +// Node and so the type contracts are explicit in this slice. +// 2. `selectThreadAnchorLabel` — turns a thread's nullable anchor +// (file-level vs line-anchored vs outdated) into the label shown +// in the thread header. Pure, no React. +// 3. `applyResolveToggle` and `applyReactionToggle` — pure cache +// reducers that flip a single thread / comment in the +// `InfiniteData<{threads, nextCursor}>` cache used by the +// `listReviewThreads` infinite query. Used by the optimistic +// `onMutate` of the resolve / unresolve / reaction mutations and +// by the rollback in `onError`. +// +// Keeping these pure (no React, no trpc, no expo) means they can be +// unit-tested in plain Node and reused by the diff viewer if it later +// wants to compute its own indicators from the same cache. + +import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc'; + +// GitHub's 8 review-comment reaction content values. The trpc DTO +// exposes this as a plain `string`; we narrow to the union here so +// the reducer and the pill row get exhaustiveness checking. +export type ReviewReactionContent = + | 'THUMBS_UP' + | 'THUMBS_DOWN' + | 'LAUGH' + | 'HOORAY' + | 'CONFUSED' + | 'HEART' + | 'ROCKET' + | 'EYES'; + +export const REVIEW_REACTION_CONTENTS: readonly ReviewReactionContent[] = [ + 'THUMBS_UP', + 'THUMBS_DOWN', + 'LAUGH', + 'HOORAY', + 'CONFUSED', + 'HEART', + 'ROCKET', + 'EYES', +] as const; + +function isReviewReactionContent(value: string): value is ReviewReactionContent { + return (REVIEW_REACTION_CONTENTS as readonly string[]).includes(value); +} + +// Derive the wire (DTO) shapes from the tRPC `listReviewThreads` output so a +// backend contract change (fields, nullability) is type-checked here rather +// than silently drifting from a hand-copied shape (apps/mobile/AGENTS.md). +// `reactions[].content` is typed as a plain `string` by tRPC; we narrow it to +// `ReviewReactionContent` at the comment-row boundary via `narrowReactionContent`. +type RouterOutputs = inferRouterOutputs; +export type ReviewThreadsPage = RouterOutputs['githubPrReview']['listReviewThreads']; +export type ReviewThread = ReviewThreadsPage['threads'][number]; +export type ReviewComment = ReviewThread['comments'][number]; +export type ReviewReaction = ReviewComment['reactions'][number]; + +// The shape of a single page in the cached `InfiniteData` +// produced by `useInfiniteQuery(trpc.githubPrReview.listReviewThreads…)`. +export type ReviewThreadsInfiniteData = { + readonly pages: readonly ReviewThreadsPage[]; + readonly pageParams: readonly unknown[]; +}; + +/** + * Display label for a thread's anchor. The label reflects THREE + * different nullable shapes: + * - file-level (`subjectType==='FILE'` or `line===null`): + * "File comment on " + * - outdated (`isOutdated` true, with `originalLine`): + * "Outdated on L ()" + * - active line: + * " L ()" (or just L if no path) + * - active range: + * " L–L ()" + */ +export function selectThreadAnchorLabel(thread: ReviewThread): string { + const path = thread.path ?? ''; + const side = thread.diffSide ? ` (${thread.diffSide})` : ''; + // Outdated threads anchor via `original*` (their current-line `line` + // field is null because the diff has moved past the comment). + if (thread.isOutdated) { + const { originalLine } = thread; + // No complete original anchor — fall back to a path-only outdated label + // (never render a dangling "L"). + if (originalLine === null) { + return path ? `Outdated on ${path}${side}` : `Outdated${side}`; + } + const original = + thread.originalStartLine && thread.originalStartLine !== originalLine + ? `L${thread.originalStartLine}–L${originalLine}` + : `L${originalLine}`; + return path ? `Outdated on ${path} ${original}${side}` : `Outdated on ${original}${side}`; + } + // File-level (subjectType FILE or no line) — only show path. + if (thread.subjectType === 'FILE' || thread.line === null) { + return path ? `File comment on ${path}` : 'File comment'; + } + // Active line / range. + const line = + thread.startLine && thread.startLine !== thread.line + ? `L${thread.startLine}–L${thread.line}` + : `L${thread.line}`; + return path ? `${path} ${line}${side}` : `${line}${side}`; +} + +/** + * Returns the same `isResolved` value with the `isOutdated` label + * surfaced for the badges in the thread header. Purely presentational. + */ +export function selectThreadBadges(thread: ReviewThread): { + readonly resolved: boolean; + readonly outdated: boolean; + readonly fileLevel: boolean; +} { + return { + resolved: thread.isResolved, + outdated: thread.isOutdated, + fileLevel: thread.subjectType === 'FILE' || thread.line === null, + }; +} + +/** + * Display name for a comment author. Returns "deleted user" when the + * author is null (deleted / banned GitHub accounts surface as + * `author: null` in the GraphQL DTO). + */ +export function selectCommentAuthorName(author: ReviewComment['author']): string { + return author?.login ?? 'deleted user'; +} + +/** + * Group threads by `path` for the sectioned list. Unanchored / null-path + * threads (rare but possible) are bucketed under "(no file)". The + * grouping is deterministic (Map insertion order = API order) so + * snapshots are stable across renders. + */ +export type ReviewThreadGroup = { + readonly path: string; + readonly threads: readonly ReviewThread[]; +}; + +export function groupThreadsByPath(threads: readonly ReviewThread[]): readonly ReviewThreadGroup[] { + const byPath = new Map(); + for (const thread of threads) { + const path = thread.path ?? '(no file)'; + const bucket = byPath.get(path); + if (bucket) { + bucket.push(thread); + } else { + byPath.set(path, [thread]); + } + } + return Array.from(byPath, ([path, group]) => ({ path, threads: group })); +} + +/** + * Toggle a single thread's `isResolved` flag in the cached + * `InfiniteData`. The reducer walks every page + * and flips the matching threadId. Returns a new object so the + * cache update is detected by react-query. Returns the same + * reference when no thread matched or when the value is already + * at the target state. + */ +export function applyResolveToggle( + data: ReviewThreadsInfiniteData | undefined, + threadId: string, + nextIsResolved: boolean +): ReviewThreadsInfiniteData | undefined { + if (!data) { + return data; + } + const nextPages = data.pages.map(page => { + const nextThreads = page.threads.map(thread => + thread.threadId === threadId && thread.isResolved !== nextIsResolved + ? { ...thread, isResolved: nextIsResolved } + : thread + ); + const pageChanged = nextThreads.some((thread, index) => thread !== page.threads[index]); + return pageChanged ? { ...page, threads: nextThreads } : page; + }); + const changed = nextPages.some((page, index) => page !== data.pages[index]); + return changed ? { ...data, pages: nextPages } : data; +} + +/** + * Toggle a single reaction in a single comment. When the viewer is + * already reacted, the reaction is removed (count -1, viewerHasReacted + * false). When the viewer is not yet reacted, it is added (count +1, + * viewerHasReacted true). If the reaction's content is not in the + * known set (unknown future GitHub value), it is treated as an + * upsert by the existing-bucket path. If the bucket is not present + * yet, it is appended. Returns a new InfiniteData so react-query + * sees the change. + */ +export function applyReactionToggle(args: { + data: ReviewThreadsInfiniteData | undefined; + threadId: string; + commentNodeId: string; + content: string; +}): ReviewThreadsInfiniteData | undefined { + const { data, threadId, commentNodeId, content } = args; + if (!data) { + return data; + } + const nextPages = data.pages.map(page => { + const nextThreads = page.threads.map(thread => { + if (thread.threadId !== threadId) { + return thread; + } + const nextComments = thread.comments.map(comment => { + if (comment.nodeId !== commentNodeId) { + return comment; + } + const existing = comment.reactions.find(r => r.content === content); + if (existing) { + if (existing.viewerHasReacted) { + // Remove: count -1, clear viewer flag. Guard against + // underflow if the server count was already 0. + return { + ...comment, + reactions: comment.reactions.map(r => + r.content === content + ? { + ...r, + count: Math.max(0, r.count - 1), + viewerHasReacted: false, + } + : r + ), + }; + } + // Add to existing bucket. + return { + ...comment, + reactions: comment.reactions.map(r => + r.content === content ? { ...r, count: r.count + 1, viewerHasReacted: true } : r + ), + }; + } + // Reaction bucket not present yet — append a new one. + return { + ...comment, + reactions: [...comment.reactions, { content, count: 1, viewerHasReacted: true }], + }; + }); + const commentsChanged = nextComments.some( + (comment, index) => comment !== thread.comments[index] + ); + return commentsChanged ? { ...thread, comments: nextComments } : thread; + }); + const pageChanged = nextThreads.some((thread, index) => thread !== page.threads[index]); + return pageChanged ? { ...page, threads: nextThreads } : page; + }); + const changed = nextPages.some((page, index) => page !== data.pages[index]); + if (!changed) { + return data; + } + return { ...data, pages: nextPages }; +} + +/** + * Locate the thread + comment in the cached data. Returns `null` when + * either is missing so the optimistic reducer can early-out. + */ +export function findReviewComment( + data: ReviewThreadsInfiniteData | undefined, + threadId: string, + commentNodeId: string +): ReviewComment | null { + if (!data) { + return null; + } + for (const page of data.pages) { + for (const thread of page.threads) { + if (thread.threadId === threadId) { + const match = thread.comments.find(comment => comment.nodeId === commentNodeId); + if (match) { + return match; + } + } + } + } + return null; +} + +/** + * Narrow a `string` reaction content to the known union. Returns + * `null` for unknown values so callers can fall back to a safe + * default (e.g. show the emoji verbatim or skip rendering). + */ +export function narrowReactionContent(value: string): ReviewReactionContent | null { + return isReviewReactionContent(value) ? value : null; +} diff --git a/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts b/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts new file mode 100644 index 0000000000..de76fa951a --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts @@ -0,0 +1,57 @@ +// Discussion-tab list query hook. +// +// - `usePrReviewDiscussionThreads` — wraps the tRPC +// `listReviewThreads` infinite query and returns the tab-level +// error classification (via `classifyPrReviewQueryState`) so +// the tab can short-circuit to a terminal state when the FIRST +// page fails (a later-page error is rare here but should be +// surfaced as a "Retry" affordance, not a tab-level blank). +// +// `useInfiniteQuery` returns `error` for both first-page and later- +// page errors. A first-page error is one where `pages.length === 0` +// AND the query has finished (no longer `isPending`). The +// `firstPageErrorState` helper below encodes that distinction so +// the tab UI doesn't have to. + +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; +import { useTRPC } from '@/lib/trpc'; + +export function usePrReviewDiscussionThreads(args: { + owner: string; + repo: string; + number: number; +}) { + const { owner, repo, number } = args; + const trpc = useTRPC(); + const query = useInfiniteQuery( + trpc.githubPrReview.listReviewThreads.infiniteQueryOptions( + { owner, repo, number }, + { + staleTime: 15_000, + getNextPageParam: lastPage => lastPage.nextCursor ?? undefined, + } + ) + ); + + const hasLoadedPages = (query.data?.pages.length ?? 0) > 0; + const firstPagePending = query.isPending; + const firstPageErrorState = + !firstPagePending && !hasLoadedPages && query.error + ? classifyPrReviewQueryState(query.error) + : null; + const laterPageError = Boolean(query.error) && hasLoadedPages; + + // Flat list of all threads across all loaded pages, in page order. + // We return a fresh array on every render so the consumer can + // `.map` without memoizing; the rows themselves are stable. + const threads = (query.data?.pages ?? []).flatMap(page => page.threads); + + return { + query, + threads, + firstPageErrorState, + laterPageError, + }; +} diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts new file mode 100644 index 0000000000..832987de2b --- /dev/null +++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts @@ -0,0 +1,233 @@ +// Discussion-tab mutations for the PR review surface. +// +// - `replyToComment` — NOT optimistic (per S7b contract): +// the comment is appended only after the +// server confirms, and the list is +// invalidated on settle so the next +// render includes the new comment. +// The mutation hook toasts `onError` and +// the inline reply input keeps its own +// error state so the user can retry. +// +// - `resolveThread` / +// `unresolveThread` — OPTIMISTIC. The reducer flips the +// thread's `isResolved` in the cached +// `listReviewThreads` infinite query, +// snapshots the previous data in +// `onMutate`, and rolls it back in +// `onError`. `onSettled` invalidates the +// path so a re-fetch reconciles with +// the server's eventual state. +// +// - `addReaction` / +// `removeReaction` — OPTIMISTIC. Same pattern as resolve, +// but the reducer walks into a specific +// comment inside a specific thread to +// flip `count` + `viewerHasReacted`. +// Invalidates on settle. +// +// Why we do NOT coalesce these into the existing +// `useCreateReviewCommentMutation` / `useSubmitReviewMutation` hooks: +// those are the inline / pending-review path; discussion replies and +// reactions are independent mutations on already-posted comments, so +// they belong in their own hook (and their own file) to keep the +// queryKey surface area narrow. + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner-native'; + +import { useTRPC } from '@/lib/trpc'; + +import { + applyReactionToggle, + applyResolveToggle, + type ReviewReactionContent, + type ReviewThreadsInfiniteData, +} from './review-discussion-types'; + +function useDiscussionKeys() { + const trpc = useTRPC(); + return { + listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(), + }; +} + +async function invalidateDiscussionCaches( + queryClient: ReturnType, + keys: ReturnType +): Promise { + await queryClient.invalidateQueries(keys.listReviewThreadsPath); +} + +// ── Reply (not optimistic) ──────────────────────────────────────────── + +export function useReplyToCommentMutation() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + + return useMutation( + trpc.githubPrReview.replyToComment.mutationOptions({ + onError: (error: { message: string }) => { + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }) + ); +} + +// ── Resolve / unresolve (optimistic) ────────────────────────────────── + +export function useResolveThreadMutation() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + + return useMutation( + trpc.githubPrReview.resolveThread.mutationOptions({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + onMutate: async ({ threadId }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyResolveToggle(old, threadId, true) + ); + return { previous }; + }, + onError: (error, _input, context) => { + const previous = context?.previous; + if (previous) { + for (const [key, data] of previous) { + queryClient.setQueryData(key, data); + } + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }) + ); +} + +export function useUnresolveThreadMutation() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + + return useMutation( + trpc.githubPrReview.unresolveThread.mutationOptions({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + onMutate: async ({ threadId }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyResolveToggle(old, threadId, false) + ); + return { previous }; + }, + onError: (error, _input, context) => { + const previous = context?.previous; + if (previous) { + for (const [key, data] of previous) { + queryClient.setQueryData(key, data); + } + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }) + ); +} + +// ── Reactions (optimistic) ──────────────────────────────────────────── + +// The reaction mutation DTO only carries `{commentNodeId, content}`. The +// optimistic cache walk also needs the owning `threadId`, which is NOT a DTO +// field — so the hook is constructed PER THREAD and closes over `threadId` +// (the caller passes only the DTO fields to `.mutate`). +export function useAddReactionMutation(threadId: string) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + + return useMutation( + trpc.githubPrReview.addReaction.mutationOptions({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + onMutate: async ({ commentNodeId, content }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyReactionToggle({ + data: old, + threadId, + commentNodeId, + content: content as ReviewReactionContent, + }) + ); + return { previous }; + }, + onError: (error, _input, context) => { + const previous = context?.previous; + if (previous) { + for (const [key, data] of previous) { + queryClient.setQueryData(key, data); + } + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }) + ); +} + +export function useRemoveReactionMutation(threadId: string) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const keys = useDiscussionKeys(); + + return useMutation( + trpc.githubPrReview.removeReaction.mutationOptions({ + // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + onMutate: async ({ commentNodeId, content }) => { + await queryClient.cancelQueries(keys.listReviewThreadsPath); + const previous = queryClient.getQueriesData( + keys.listReviewThreadsPath + ); + queryClient.setQueriesData(keys.listReviewThreadsPath, old => + applyReactionToggle({ + data: old, + threadId, + commentNodeId, + content: content as ReviewReactionContent, + }) + ); + return { previous }; + }, + onError: (error, _input, context) => { + const previous = context?.previous; + if (previous) { + for (const [key, data] of previous) { + queryClient.setQueryData(key, data); + } + } + toast.error(error.message); + }, + onSettled: async () => { + await invalidateDiscussionCaches(queryClient, keys); + }, + }) + ); +} From e5391b859cb16fe83ab46e02f8bab05fe63d49ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 06:44:31 +0200 Subject: [PATCH 13/19] chore(mobile): prune unused pr-review exports --- .../src/components/pr-review/diff/diff-line.tsx | 2 +- .../pr-review/diff/pr-diff-file-list-header.tsx | 2 +- .../pr-review/diff/pr-diff-file-list-render.tsx | 15 ++------------- .../pr-review/diff/pr-diff-file-list.tsx | 4 ++-- .../pr-review/diff/pr-diff-file-navigator.tsx | 4 ++-- .../pr-review/diff/pr-diff-file-rows.tsx | 2 +- .../pr-review/diff/pr-diff-floating-actions.tsx | 2 +- .../pr-review/diff/pr-diff-hunk-rows.tsx | 4 ++-- .../diff/pr-diff-navigator-file-row.tsx | 2 +- .../components/pr-review/diff/pr-diff-rows.tsx | 2 -- .../pr-review/diff/pr-diff-side-by-side-row.tsx | 4 ++-- .../pr-review/diff/use-diff-selection.ts | 6 ++---- .../pr-review/discussion/comment-row.tsx | 2 +- .../pr-review/discussion/discussion-thread.tsx | 2 +- .../pr-review/discussion/reactions-row.tsx | 2 +- .../pr-review/merge/pr-merge-section-parts.tsx | 16 +--------------- .../pr-review/merge/pr-merge-section.tsx | 2 +- .../pr-review/merge/pr-merge-sheet.tsx | 4 ++-- .../pr-review/pr-review-checks-section.tsx | 2 +- .../pr-review/pr-review-comment-composer.tsx | 2 +- .../pr-review/pr-review-discussion-tab.tsx | 2 +- .../pr-review/pr-review-files-tab.tsx | 2 +- .../pr-review/pr-review-overview-parts.tsx | 4 ++-- .../components/pr-review/pr-review-overview.tsx | 2 +- .../components/pr-review/pr-review-submit.tsx | 2 +- apps/mobile/src/lib/hooks/use-is-tablet.ts | 3 --- .../lib/pr-review/build-submit-review-input.ts | 2 +- .../pr-review/classify-pr-review-query-state.ts | 4 ++-- .../lib/pr-review/comment-composer-params.ts | 2 +- .../lib/pr-review/diff/pr-diff-gap-builder.ts | 2 +- .../lib/pr-review/diff/pr-diff-list-items.ts | 2 +- .../pr-review/diff/pr-review-file-list-state.ts | 17 +---------------- .../lib/pr-review/diff/pr-review-file-types.ts | 1 - .../diff/use-pr-diff-context-loader.ts | 2 +- .../discussion/review-discussion-types.ts | 17 +---------------- .../pr-review/merge/merge-blocked-reasons.ts | 8 +------- .../lib/pr-review/use-pr-review-mutations.ts | 13 ------------- apps/mobile/src/lib/pr-review/viewed-files.ts | 6 +++--- 38 files changed, 45 insertions(+), 127 deletions(-) diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx index 51eda8b490..37c2f2514d 100644 --- a/apps/mobile/src/components/pr-review/diff/diff-line.tsx +++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx @@ -72,7 +72,7 @@ const TOKEN_DARK_LIGHT: Record = { const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; -export type DiffLineProps = { +type DiffLineProps = { line: ParsedDiffLine; language: string | null; keyId: string; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx index a820fbf406..a36db111cc 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -19,7 +19,7 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; import { cn } from '@/lib/utils'; -export type PrDiffFileListHeaderProps = { +type PrDiffFileListHeaderProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx index 1d79876597..00ee8a5125 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx @@ -7,13 +7,11 @@ import { useCallback } from 'react'; import { DiffLine } from '@/components/pr-review/diff/diff-line'; import { - EmptyFilesView, ExpandSeparatorRow, FileHeaderRow, HunkHeaderRow, PaginationRow, PatchMissingRow, - TabStateMessage, TruncationBannerRow, } from '@/components/pr-review/diff/pr-diff-rows'; import { @@ -25,11 +23,10 @@ import { type ParsedHunk } from '@/lib/pr-review/diff/parse-patch'; import { sideForDiffLineType } from '@/lib/pr-review/diff-selection'; import { type FetchToCompletionResult, - type PrReviewFile, type UsePrReviewFileListQueryResult, } from '@/lib/pr-review/diff/pr-review-file-list-state'; -export type UseDiffRenderItemArgs = { +type UseDiffRenderItemArgs = { viewed: { isViewed: (path: string) => boolean; toggle: (path: string) => Promise; @@ -48,7 +45,7 @@ export type UseDiffRenderItemArgs = { /** Lightweight view of the current selection — what the rows need to * decide whether to paint the focus ring. The full `DiffSelection` * (incl. `selectedText`) is in the bridge, not here. */ -export type SelectionView = { +type SelectionView = { filePath: string; side: 'LEFT' | 'RIGHT'; startLine: number; @@ -65,14 +62,6 @@ export type LineTapArgs = { hunk: ParsedHunk; }; -// Re-exported here so the file-list module can import the terminal -// state views from one place. -export { EmptyFilesView, TabStateMessage }; - -// Re-export so the file-list module can keep its PrReviewFile import -// without touching the file-list-types module. -export type { PrReviewFile }; - export function useDiffRenderItem({ viewed, query, diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index 598dd3ecae..bb9249d6ad 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -35,11 +35,11 @@ import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; import { fileHeaderKey, itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader'; import { - type PrReviewFile, useFetchToCompletion, usePrReviewFileListQuery, usePrReviewViewedFiles, } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge'; import { type FileNavigatorRequest, @@ -47,7 +47,7 @@ import { } from '@/lib/pr-review/file-navigator-bridge'; import { useIsTablet } from '@/lib/hooks/use-is-tablet'; -export type PrReviewFileListProps = { +type PrReviewFileListProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx index 1b4b3ce231..e412886090 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx @@ -26,14 +26,14 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { requestScrollToFile } from '@/lib/pr-review/file-navigator-bridge'; import { - type PrReviewFile, useFetchToCompletion, usePrReviewFileListQuery, usePrReviewViewedFiles, } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -export type PrDiffFileNavigatorProps = { +type PrDiffFileNavigatorProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx index df1158c68a..f12011af89 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx @@ -8,7 +8,7 @@ import { ChoiceRow } from '@/components/ui/choice-row'; import { Text } from '@/components/ui/text'; import { openExternalUrl } from '@/lib/external-link'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; function ExpandChevron({ hasDiff, expanded }: { hasDiff: boolean; expanded: boolean }) { const colors = useThemeColors(); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx index ac5e9d7cea..c314a65361 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx @@ -24,7 +24,7 @@ import { cn } from '@/lib/utils'; const COMMENT_COMPOSER_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/comment-composer' as const; const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const; -export type PrDiffFloatingActionsProps = Readonly<{ +type PrDiffFloatingActionsProps = Readonly<{ owner: string; repo: string; number: number; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx index 57bd761501..8372220d02 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx @@ -7,8 +7,8 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type ExpandSeparatorItem } from '@/lib/pr-review/diff/pr-diff-list-items'; -export const DEFAULT_EXPAND_WINDOW = 20; -export const EXPAND_ALL_MAX = 100; +const DEFAULT_EXPAND_WINDOW = 20; +const EXPAND_ALL_MAX = 100; // Module-level style constants so FlashList content containers avoid // recreating object literals (and so no-inline-styles is satisfied). diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx index 2b325fe920..767da5608a 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx @@ -12,7 +12,7 @@ import { Pressable, View } from 'react-native'; import { Text } from '@/components/ui/text'; -import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-list-state'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; function splitPath(path: string): { dir: string; basename: string } { diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx index b296b697e1..d58f3a1909 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx @@ -7,9 +7,7 @@ export { TruncationBannerRow, } from '@/components/pr-review/diff/pr-diff-file-rows'; export { - DEFAULT_EXPAND_WINDOW, EmptyFilesView, - EXPAND_ALL_MAX, ExpandSeparatorRow, HunkHeaderRow, LIST_CONTENT_STYLE, diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx index d96d45cd57..107bee2ef5 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx @@ -72,7 +72,7 @@ const TOKEN_DARK_LIGHT: Record = { const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; -export type SideBySideRowProps = { +type SideBySideRowProps = { row: SideBySideRowData; language: string | null; rowKeyId: string; @@ -302,7 +302,7 @@ export const SideBySideRow = memo( prev.isSelected === next.isSelected ); -export type HunkSideBySideHeaderProps = { +type HunkSideBySideHeaderProps = { hunk: ParsedHunk; }; diff --git a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts index 1f28418b91..14946b59da 100644 --- a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts +++ b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts @@ -15,7 +15,7 @@ import { import { type SelectionState, selectLine } from '@/lib/pr-review/diff-selection'; import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items'; -export type UseDiffSelectionArgs = { +type UseDiffSelectionArgs = { owner: string; repo: string; number: number; @@ -23,7 +23,7 @@ export type UseDiffSelectionArgs = { isTablet: boolean; }; -export type UseDiffSelectionResult = { +type UseDiffSelectionResult = { selection: SelectionState | null; selectionView: SelectionView; handleLineTap: (args: LineTapArgs) => void; @@ -119,5 +119,3 @@ export function useDiffSelection({ return { selection, selectionView, handleLineTap, clearSelection }; } - -export type { SelectionView }; diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx index 7417515fc4..00effc3eba 100644 --- a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx +++ b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx @@ -21,7 +21,7 @@ import { import { parseTimestamp, timeAgo } from '@/lib/utils'; import { View } from 'react-native'; -export type CommentRowProps = { +type CommentRowProps = { readonly comment: ReviewComment; readonly onToggleReaction: (content: ReviewReactionContent) => void; readonly reactionsDisabled?: boolean; diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index 4675184157..5e329ffa54 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -42,7 +42,7 @@ import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; const REPLY_PLACEHOLDER = 'Reply…'; -export type DiscussionThreadProps = { +type DiscussionThreadProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx index a58c77b42e..43a2f0d2d5 100644 --- a/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx +++ b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx @@ -40,7 +40,7 @@ const REACTION_EMOJI: Record = { EYES: '👀', }; -export type ReactionsRowProps = { +type ReactionsRowProps = { // Raw reactions from the DTO — `content` is a plain string (GitHub can // return content outside the 8 emoji). We index by string and only render // + toggle the fixed 8 known reactions. diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx index 863d48eb5e..170a35501b 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx @@ -2,7 +2,7 @@ // `pr-merge-section.tsx` so the section file stays under the // repo's 300-line limit. -import { GitMerge, type LucideIcon, RefreshCw } from 'lucide-react-native'; +import { type LucideIcon, RefreshCw } from 'lucide-react-native'; import { ActivityIndicator, View } from 'react-native'; import { Button } from '@/components/ui/button'; @@ -148,17 +148,3 @@ export function AutoMergeEnabledBanner({ ); } - -export function MergeNowButton({ - onPress, - accessibilityLabel, -}: Readonly<{ onPress: () => void; accessibilityLabel: string }>) { - return ( - - ); -} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx index e0a3d0e36e..827b108248 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx @@ -36,7 +36,7 @@ import { TerminalChip, } from '@/components/pr-review/merge/pr-merge-section-parts'; -export type PrMergeSectionProps = Readonly<{ +type PrMergeSectionProps = Readonly<{ owner: string; repo: string; overview: PrOverviewDto; diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index ecd273a0ab..be8129b0c9 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -38,9 +38,9 @@ import { MethodPicker, } from '@/components/pr-review/merge/pr-merge-sheet-parts'; -export type PrMergeSheetMode = 'merge' | 'enable-auto-merge'; +type PrMergeSheetMode = 'merge' | 'enable-auto-merge'; -export type PrMergeSheetProps = Readonly<{ +type PrMergeSheetProps = Readonly<{ owner: string; /** The GitHub repository name (the `repo` path segment, not the settings object). */ repoName: string; diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx index a27ef690a2..8d939ee166 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -19,7 +19,7 @@ import { useTRPC } from '@/lib/trpc'; import { cn } from '@/lib/utils'; import { openExternalUrl } from '@/lib/external-link'; -export type PrReviewChecksSectionProps = { +type PrReviewChecksSectionProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx index f488679eca..62edc50ae0 100644 --- a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx @@ -28,7 +28,7 @@ import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations'; import { cn } from '@/lib/utils'; -export type PrReviewCommentComposerProps = Readonly<{ +type PrReviewCommentComposerProps = Readonly<{ owner: string; repo: string; number: number; diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index e7655485bf..d200c074c0 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -54,7 +54,7 @@ import { import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads'; import { cn } from '@/lib/utils'; -export type PrReviewDiscussionTabProps = { +type PrReviewDiscussionTabProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx index dcaca12053..87f015aa75 100644 --- a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx @@ -1,6 +1,6 @@ import { PrReviewFileList } from '@/components/pr-review/diff/pr-diff-file-list'; -export type PrReviewFilesTabProps = { +type PrReviewFilesTabProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx index 85d80f31e4..905eb53f4f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx @@ -13,9 +13,9 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn } from '@/lib/utils'; -export type PrStateChipTone = 'good' | 'warn' | 'muted' | 'destructive'; +type PrStateChipTone = 'good' | 'warn' | 'muted' | 'destructive'; -export type PrStateChipDescriptor = { +type PrStateChipDescriptor = { label: string; tone: PrStateChipTone; icon: LucideIcon; diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx index 6df6627eeb..61f79b42a6 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx @@ -24,7 +24,7 @@ import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-q import { WEB_BASE_URL } from '@/lib/config'; import { useTRPC } from '@/lib/trpc'; -export type PrReviewOverviewProps = { +type PrReviewOverviewProps = { readonly owner: string; readonly repo: string; readonly number: number; diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx index 480f849507..0204e6ba77 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -34,7 +34,7 @@ import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations'; import { cn } from '@/lib/utils'; -export type PrReviewSubmitProps = Readonly<{ +type PrReviewSubmitProps = Readonly<{ owner: string; repo: string; number: number; diff --git a/apps/mobile/src/lib/hooks/use-is-tablet.ts b/apps/mobile/src/lib/hooks/use-is-tablet.ts index 5c36b15b7a..10639a20e7 100644 --- a/apps/mobile/src/lib/hooks/use-is-tablet.ts +++ b/apps/mobile/src/lib/hooks/use-is-tablet.ts @@ -2,9 +2,6 @@ import { Platform, useWindowDimensions } from 'react-native'; import { isTabletFromDimensions } from '@/lib/hooks/is-tablet'; -// Re-export so the existing import path keeps working for callers. -export { isTabletFromDimensions } from '@/lib/hooks/is-tablet'; - // `Platform.isPad` is a runtime constant on iOS hardware (not in the public // TS types for cross-platform code), so we narrow at the call site. function readIsPad(): boolean { diff --git a/apps/mobile/src/lib/pr-review/build-submit-review-input.ts b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts index 27819d8d18..34a6a2090a 100644 --- a/apps/mobile/src/lib/pr-review/build-submit-review-input.ts +++ b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts @@ -5,7 +5,7 @@ import { export type ReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; -export type BuildSubmitReviewInputArgs = { +type BuildSubmitReviewInputArgs = { owner: string; repo: string; number: number; diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts index dc641ebe8b..14b8d92547 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts @@ -9,7 +9,7 @@ // the same decision tree. No React, no react-query, no expo modules — // that keeps it testable in plain Node. -export type PrReviewQueryState = +type PrReviewQueryState = | { /** * The fetch failed with a transient error (network, 5xx, etc.). The @@ -96,7 +96,7 @@ export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState { return { kind: 'retryable' }; } -export type PrReviewMutationErrorState = +type PrReviewMutationErrorState = | { /** * The mutation failed with a transient error (network, 5xx, etc.). diff --git a/apps/mobile/src/lib/pr-review/comment-composer-params.ts b/apps/mobile/src/lib/pr-review/comment-composer-params.ts index 195813e617..f195ba66eb 100644 --- a/apps/mobile/src/lib/pr-review/comment-composer-params.ts +++ b/apps/mobile/src/lib/pr-review/comment-composer-params.ts @@ -10,7 +10,7 @@ type RawComposerParams = { startLine?: string | string[] | undefined; }; -export type ParsedComposerParams = { +type ParsedComposerParams = { owner: string; repo: string; number: number; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts index 4205a85981..8071417aa8 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts @@ -11,7 +11,7 @@ import { type ListItem, } from '@/lib/pr-review/diff/pr-diff-list-items'; -export function deriveSeparatorState( +function deriveSeparatorState( state: ExpandSeparatorState, loadedCount: number ): ExpandSeparatorItem['state'] { diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts index 758c74c017..31b94e6044 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts @@ -119,7 +119,7 @@ export type ListItem = export type DiffViewMode = 'unified' | 'side-by-side'; -export const ITEM_TYPE = { +const ITEM_TYPE = { Truncation: 'truncation', FileHeader: 'file-header', FilePatchMissing: 'file-patch-missing', diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts index b547c9cf8c..c018def9f8 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts @@ -22,25 +22,10 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useState } from 'react'; import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state'; -import { - PR_REVIEW_FILES_PAGE_SIZE, - PR_REVIEW_MAX_LISTED_FILES, - PR_REVIEW_MAX_PAGES, - type PrReviewFile, -} from '@/lib/pr-review/diff/pr-review-file-types'; +import { PR_REVIEW_MAX_PAGES } from '@/lib/pr-review/diff/pr-review-file-types'; import { getViewedFiles, toggleViewedFile } from '@/lib/pr-review/viewed-files'; import { useTRPC } from '@/lib/trpc'; -// Re-export the pure file types/constants so existing consumers that import -// them from this module keep working (single source of truth lives in -// `pr-review-file-types`, which has no React/react-query import). -export { - PR_REVIEW_FILES_PAGE_SIZE, - PR_REVIEW_MAX_LISTED_FILES, - PR_REVIEW_MAX_PAGES, - type PrReviewFile, -}; - export function usePrReviewFileListQuery(args: { owner: string; repo: string; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts index 475d90e40b..72623ce0c5 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts @@ -12,7 +12,6 @@ export type PrReviewFile = { patchMissing: boolean; }; -export const PR_REVIEW_FILES_PAGE_SIZE = 100; export const PR_REVIEW_MAX_LISTED_FILES = 3000; // Server caps cursor at 60 (per the S2 read DTO contract). Pages after diff --git a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts index db1acee408..c7eb58a857 100644 --- a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts +++ b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts @@ -13,7 +13,7 @@ import { } from '@/lib/pr-review/diff/pr-diff-list-items'; import { trpcClient } from '@/lib/trpc'; -export type UsePrDiffContextLoaderResult = { +type UsePrDiffContextLoaderResult = { expandedContext: Record>; setExpandedContext: React.Dispatch< React.SetStateAction>> diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts index bd2012e4dc..b11cabba48 100644 --- a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts +++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts @@ -46,20 +46,14 @@ export const REVIEW_REACTION_CONTENTS: readonly ReviewReactionContent[] = [ 'EYES', ] as const; -function isReviewReactionContent(value: string): value is ReviewReactionContent { - return (REVIEW_REACTION_CONTENTS as readonly string[]).includes(value); -} - // Derive the wire (DTO) shapes from the tRPC `listReviewThreads` output so a // backend contract change (fields, nullability) is type-checked here rather // than silently drifting from a hand-copied shape (apps/mobile/AGENTS.md). // `reactions[].content` is typed as a plain `string` by tRPC; we narrow it to -// `ReviewReactionContent` at the comment-row boundary via `narrowReactionContent`. type RouterOutputs = inferRouterOutputs; export type ReviewThreadsPage = RouterOutputs['githubPrReview']['listReviewThreads']; export type ReviewThread = ReviewThreadsPage['threads'][number]; export type ReviewComment = ReviewThread['comments'][number]; -export type ReviewReaction = ReviewComment['reactions'][number]; // The shape of a single page in the cached `InfiniteData` // produced by `useInfiniteQuery(trpc.githubPrReview.listReviewThreads…)`. @@ -141,7 +135,7 @@ export function selectCommentAuthorName(author: ReviewComment['author']): string * grouping is deterministic (Map insertion order = API order) so * snapshots are stable across renders. */ -export type ReviewThreadGroup = { +type ReviewThreadGroup = { readonly path: string; readonly threads: readonly ReviewThread[]; }; @@ -289,12 +283,3 @@ export function findReviewComment( } return null; } - -/** - * Narrow a `string` reaction content to the known union. Returns - * `null` for unknown values so callers can fall back to a safe - * default (e.g. show the emoji verbatim or skip rendering). - */ -export function narrowReactionContent(value: string): ReviewReactionContent | null { - return isReviewReactionContent(value) ? value : null; -} diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts index 19e3596191..fdafb0c809 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts @@ -38,7 +38,7 @@ export type PrOverviewDto = { repo: PrOverviewRepoSettings; }; -export type MergeabilityStatus = 'unknown' | 'blocked' | 'mergeable' | 'terminal'; +type MergeabilityStatus = 'unknown' | 'blocked' | 'mergeable' | 'terminal'; export type MergeBlockedReasonId = | 'conflicts' @@ -262,12 +262,6 @@ export const PR_MERGE_LABELS: Record = { rebase: 'Rebase and merge', }; -export const PR_MERGE_AUTO_METHODS: Record = { - merge: 'MERGE', - squash: 'SQUASH', - rebase: 'REBASE', -}; - export const PR_MERGE_DESCRIPTIONS: Record = { merge: 'Combine all commits from this branch into the base branch with a merge commit.', squash: 'Combine all commits from this branch into a single commit on the base branch.', diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts index fa4ca1f5b4..3df2532958 100644 --- a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts +++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts @@ -41,19 +41,6 @@ async function invalidateReviewCaches( ]); } -export type CreateReviewCommentInput = { - owner: string; - repo: string; - number: number; - body: string; - path: string; - line: number; - side: 'LEFT' | 'RIGHT'; - startLine?: number; - startSide?: 'LEFT' | 'RIGHT'; - commitSha: string; -}; - export function useCreateReviewCommentMutation(ref: PrRef) { const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/mobile/src/lib/pr-review/viewed-files.ts b/apps/mobile/src/lib/pr-review/viewed-files.ts index 7b93ff7364..caa9ad8476 100644 --- a/apps/mobile/src/lib/pr-review/viewed-files.ts +++ b/apps/mobile/src/lib/pr-review/viewed-files.ts @@ -11,7 +11,7 @@ type ViewedFileMap = Record; const VIEWED_FILES_PR_LIMIT = 20; -export type ViewedFilePrRef = { +type ViewedFilePrRef = { owner: string; repo: string; number: number; @@ -36,7 +36,7 @@ async function enqueueWrite(op: () => Promise): Promise { await next; } -export function viewedFilesKey(ref: ViewedFilePrRef): string { +function viewedFilesKey(ref: ViewedFilePrRef): string { return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`; } @@ -116,7 +116,7 @@ export async function getViewedFiles(ref: ViewedFilePrRef, headSha: string): Pro * almost certainly stale and shouldn't be re-marked). The map itself is * LRU-trimmed to VIEWED_FILES_PR_LIMIT PRs by most-recently-touched. */ -export type ToggleViewedFileInput = ViewedFilePrRef & { +type ToggleViewedFileInput = ViewedFilePrRef & { headSha: string; path: string; }; From bec20335bf5b0a8f181faf4eb2e396226c4be3d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 07:19:47 +0200 Subject: [PATCH 14/19] fix(pr-review): reconnect states, mutation error classes, and cleanups - getPullRequest: propagate raw 401 from GraphQL enrichment to the token retry boundary instead of silently degrading - Checks/Files/Discussion: dedicated reconnect state (re-check connection) instead of an ineffective same-query retry - classifyPrReviewMutationError: forbidden (terminal) and reconnect classes; applied to comment/reply/submit/merge surfaces - de-duplicate the diff syntax token-color palette into syntax-colors.ts - explicit useThemeColors colors for merge Lucide icons - drop the dead side-by-side selection path (read-only) --- .../components/pr-review/diff/diff-line.tsx | 35 +---- .../pr-review/diff/pr-diff-file-list.tsx | 10 +- .../diff/pr-diff-side-by-side-row.tsx | 96 ++------------ .../discussion/discussion-thread.tsx | 89 +------------ .../pr-review/discussion/reply-input.tsx | 120 ++++++++++++++++++ .../merge/pr-merge-section-parts.tsx | 3 +- .../pr-review/merge/pr-merge-section.tsx | 6 +- .../pr-review/merge/pr-merge-sheet.tsx | 63 +++++---- .../pr-review/pr-review-checks-section.tsx | 17 ++- .../pr-review/pr-review-comment-composer.tsx | 59 ++++++--- .../pr-review/pr-review-discussion-tab.tsx | 9 +- .../pr-review/pr-review-reconnect-notice.tsx | 39 ++++++ .../components/pr-review/pr-review-submit.tsx | 32 +++-- .../classify-pr-review-mutation-error.test.ts | 40 +++++- .../classify-pr-review-query-state.ts | 35 ++++- .../lib/pr-review/diff/syntax-colors.test.ts | 23 ++++ .../src/lib/pr-review/diff/syntax-colors.ts | 40 ++++++ .../pr-review/merge/merge-commit-defaults.ts | 18 +++ .../src/routers/github-pr-review-router.ts | 13 +- 19 files changed, 470 insertions(+), 277 deletions(-) create mode 100644 apps/mobile/src/components/pr-review/discussion/reply-input.tsx create mode 100644 apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx create mode 100644 apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts create mode 100644 apps/mobile/src/lib/pr-review/diff/syntax-colors.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx index 37c2f2514d..a77fad4226 100644 --- a/apps/mobile/src/components/pr-review/diff/diff-line.tsx +++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx @@ -19,6 +19,7 @@ import { Pressable, Text as RNText, type TextStyle, View, type ViewStyle } from import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight'; import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; +import { MUTED_COLOR, tokenColorFor } from '@/lib/pr-review/diff/syntax-colors'; import { cn } from '@/lib/utils'; const LINE_HEIGHT = 18; @@ -49,29 +50,6 @@ const NO_NEWLINE_BASE: TextStyle = { lineHeight: LINE_HEIGHT, }; -const TOKEN_DARK_LIGHT: Record = { - keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, - builtin: { light: '#1F6FEB', dark: '#79B8FF' }, - literal: { light: '#7B2CBF', dark: '#D8B4FE' }, - number: { light: '#B27214', dark: '#F2B05F' }, - string: { light: '#278150', dark: '#5FCB8E' }, - comment: { light: '#6F6A61', dark: '#8A8680' }, - type: { light: '#1F6FEB', dark: '#79B8FF' }, - function: { light: '#1F6FEB', dark: '#79B8FF' }, - variable: { light: '#14130F', dark: '#F2F0EB' }, - property: { light: '#1F6FEB', dark: '#79B8FF' }, - tag: { light: '#BE4E3F', dark: '#F28B7A' }, - selector: { light: '#7B2CBF', dark: '#D8B4FE' }, - attribute: { light: '#1F6FEB', dark: '#79B8FF' }, - operator: { light: '#6F6A61', dark: '#8A8680' }, - meta: { light: '#6F6A61', dark: '#8A8680' }, - add: { light: '#278150', dark: '#5FCB8E' }, - del: { light: '#BE4E3F', dark: '#F28B7A' }, -}; - -const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; -const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; - type DiffLineProps = { line: ParsedDiffLine; language: string | null; @@ -102,17 +80,6 @@ function rowBackgroundFor(type: ParsedDiffLine['type']): string { return 'bg-transparent'; } -function tokenColorFor(className: string | null, isDark: boolean): string { - if (!className) { - return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; - } - const palette = TOKEN_DARK_LIGHT[className]; - if (!palette) { - return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; - } - return isDark ? palette.dark : palette.light; -} - function DiffLineImpl({ line, language, onTap, isSelected }: Readonly) { const colors = useThemeColors(); const isDark = colors.background === '#0E0E10'; diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index bb9249d6ad..4e21e556a6 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -19,6 +19,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { View } from 'react-native'; import { QueryError } from '@/components/query-error'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { PrDiffFileListHeader, useDiffViewMode, @@ -215,7 +216,14 @@ export function PrReviewFileList({ /> ); } - if (firstPageErrorState?.kind === 'retryable' || firstPageErrorState?.kind === 'reconnect') { + if (firstPageErrorState?.kind === 'reconnect') { + return ( + + + + ); + } + if (firstPageErrorState?.kind === 'retryable') { return ( = { - keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, - builtin: { light: '#1F6FEB', dark: '#79B8FF' }, - literal: { light: '#7B2CBF', dark: '#D8B4FE' }, - number: { light: '#B27214', dark: '#F2B05F' }, - string: { light: '#278150', dark: '#5FCB8E' }, - comment: { light: '#6F6A61', dark: '#8A8680' }, - type: { light: '#1F6FEB', dark: '#79B8FF' }, - function: { light: '#1F6FEB', dark: '#79B8FF' }, - variable: { light: '#14130F', dark: '#F2F0EB' }, - property: { light: '#1F6FEB', dark: '#79B8FF' }, - tag: { light: '#BE4E3F', dark: '#F28B7A' }, - selector: { light: '#7B2CBF', dark: '#D8B4FE' }, - attribute: { light: '#1F6FEB', dark: '#79B8FF' }, - operator: { light: '#6F6A61', dark: '#8A8680' }, - meta: { light: '#6F6A61', dark: '#8A8680' }, - add: { light: '#278150', dark: '#5FCB8E' }, - del: { light: '#BE4E3F', dark: '#F28B7A' }, -}; - -const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; -const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; - type SideBySideRowProps = { row: SideBySideRowData; language: string | null; rowKeyId: string; - /** Optional tap producer — wired by S7a so the side-by-side row can - * start a diff selection. Only the right column participates; the - * side-by-side viewer is tablet-only and RIGHT is the canonical - * post-merge view that the user reads to write a review. */ - onTap?: (cell: { line: ParsedDiffLine; side: 'left' | 'right' }) => void; - /** When true, paint the row with the selection focus ring. */ - isSelected?: boolean; }; function sideGutterText(line: ParsedDiffLine, side: 'left' | 'right'): string { @@ -98,17 +72,6 @@ function sideGutterText(line: ParsedDiffLine, side: 'left' | 'right'): string { return `${line.newLine ?? line.oldLine ?? ''}`; } -function tokenColorFor(className: string | null, isDark: boolean): string { - if (!className) { - return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; - } - const palette = TOKEN_DARK_LIGHT[className]; - if (!palette) { - return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; - } - return isDark ? palette.dark : palette.light; -} - function rowBackgroundFor(type: ParsedDiffLine['type']): string { if (type === 'add') { return 'bg-good-tile-bg'; @@ -217,31 +180,15 @@ function describeRow(row: SideBySideRowData): string { return 'Empty diff row'; } -function SideBySideRowImpl({ - row, - language, - rowKeyId, - onTap, - isSelected, -}: Readonly) { +function SideBySideRowImpl({ row, language, rowKeyId }: Readonly) { const colors = useThemeColors(); const isDark = colors.background === '#0E0E10'; const leftLine = row.left?.line ?? null; const rightLine = row.right?.line ?? null; - // Side-by-side uses the right column when present; a left-only row - // (pure deletion) lets the user comment on the LEFT line instead. - const tappableLine = rightLine ?? leftLine; - let tappableColumn: 'left' | 'right' | null = null; - if (rightLine) { - tappableColumn = 'right'; - } else if (leftLine) { - tappableColumn = 'left'; - } - // Selection ring uses the primary color (matches the unified diff). - const selectionClass = isSelected ? 'border-l-2 border-primary' : 'border-l-2 border-transparent'; - const rowContent = ( + + return ( ); - if (!onTap || !tappableLine || !tappableColumn) { - return rowContent; - } - return ( - { - onTap({ line: tappableLine, side: tappableColumn }); - }} - accessibilityRole="button" - accessibilityLabel={ - isSelected - ? `Selected diff row (${tappableColumn}), tap to change selection` - : `Comment on diff row (${tappableColumn})` - } - accessibilityState={{ selected: Boolean(isSelected) }} - > - {rowContent} - - ); } export const SideBySideRow = memo( SideBySideRowImpl, (prev, next) => - prev.rowKeyId === next.rowKeyId && - prev.language === next.language && - prev.row === next.row && - prev.onTap === next.onTap && - prev.isSelected === next.isSelected + prev.rowKeyId === next.rowKeyId && prev.language === next.language && prev.row === next.row ); type HunkSideBySideHeaderProps = { diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index 5e329ffa54..95c2c88344 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -17,11 +17,11 @@ import * as Haptics from 'expo-haptics'; import { Check, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react-native'; -import { useEffect, useRef, useState } from 'react'; -import { Pressable, TextInput, View } from 'react-native'; +import { useState } from 'react'; +import { Pressable, View } from 'react-native'; import { CommentRow } from '@/components/pr-review/discussion/comment-row'; -import { Button } from '@/components/ui/button'; +import { ReplyInput } from '@/components/pr-review/discussion/reply-input'; import { Text } from '@/components/ui/text'; import { type ReviewComment, @@ -40,8 +40,6 @@ import { import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; -const REPLY_PLACEHOLDER = 'Reply…'; - type DiscussionThreadProps = { readonly owner: string; readonly repo: string; @@ -256,84 +254,3 @@ function ResolveToggle({ resolved, disabled, onPress }: Readonly ); } - -// ── Reply input (uncontrolled) ──────────────────────────────────────── - -type ReplyInputProps = { - readonly owner: string; - readonly repo: string; - readonly number: number; - readonly commentId: number; - readonly reply: ReturnType; -}; - -function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) { - const colors = useThemeColors(); - const bodyRef = useRef(''); - const inputRef = useRef(null); - const [inlineError, setInlineError] = useState(null); - const [resetKey, setResetKey] = useState(0); - - // Mirror mutation error into the inline box. Reply is NOT - // optimistic, so the user can hit the inline error and retry - // without waiting for a re-fetch. - useEffect(() => { - if (reply.error) { - const message = reply.error instanceof Error ? reply.error.message : 'Could not reply.'; - setInlineError(message); - } - }, [reply.error]); - - const submit = () => { - const body = bodyRef.current.trim(); - if (!body || reply.isPending) { - return; - } - setInlineError(null); - reply.mutate( - { owner, repo, number, commentId, body }, - { - onSuccess: () => { - bodyRef.current = ''; - setResetKey(prev => prev + 1); - }, - } - ); - }; - - return ( - - { - bodyRef.current = value; - if (inlineError) { - setInlineError(null); - } - }} - multiline - textAlignVertical="top" - className="min-h-16 rounded-md border border-input bg-background px-3 py-2 text-sm leading-5 text-foreground" - /> - {inlineError ? {inlineError} : null} - - - - - ); -} diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx new file mode 100644 index 0000000000..b6d35eed66 --- /dev/null +++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx @@ -0,0 +1,120 @@ +// Reply input for a single review thread. The input is uncontrolled +// (iOS ref pattern) per the repo's iOS rule. Submit calls the +// (non-optimistic) reply mutation and re-fetches the list on settle. + +import { useEffect, useRef, useState } from 'react'; +import { TextInput, View } from 'react-native'; + +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; +import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations'; + +const REPLY_PLACEHOLDER = 'Reply…'; + +type ReplyInputProps = { + readonly owner: string; + readonly repo: string; + readonly number: number; + readonly commentId: number; + readonly reply: ReturnType; +}; + +export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) { + const colors = useThemeColors(); + const bodyRef = useRef(''); + const inputRef = useRef(null); + const [inlineError, setInlineError] = useState(null); + const [inlineErrorKind, setInlineErrorKind] = useState< + 'retryable' | 'bad-request' | 'forbidden' | 'reconnect' | null + >(null); + const [resetKey, setResetKey] = useState(0); + + // Mirror mutation error into the inline box. Reply is NOT + // optimistic, so the user can hit the inline error and retry + // without waiting for a re-fetch. + useEffect(() => { + if (reply.error) { + const classification = classifyPrReviewMutationError(reply.error); + if (classification.kind === 'bad-request') { + setInlineError("This reply can't be posted. The thread may have changed."); + setInlineErrorKind('bad-request'); + } else if (classification.kind === 'forbidden') { + setInlineError("You don't have permission to reply to this pull request."); + setInlineErrorKind('forbidden'); + } else if (classification.kind === 'reconnect') { + setInlineError('GitHub connection expired.'); + setInlineErrorKind('reconnect'); + } else { + const message = reply.error instanceof Error ? reply.error.message : 'Could not reply.'; + setInlineError(message); + setInlineErrorKind('retryable'); + } + } + }, [reply.error]); + + const submit = () => { + const body = bodyRef.current.trim(); + if (!body || reply.isPending) { + return; + } + setInlineError(null); + setInlineErrorKind(null); + reply.mutate( + { owner, repo, number, commentId, body }, + { + onSuccess: () => { + bodyRef.current = ''; + setResetKey(prev => prev + 1); + }, + } + ); + }; + + return ( + + { + bodyRef.current = value; + if (inlineError) { + setInlineError(null); + setInlineErrorKind(null); + } + }} + multiline + textAlignVertical="top" + className="min-h-16 rounded-md border border-input bg-background px-3 py-2 text-sm leading-5 text-foreground" + /> + {inlineError && inlineErrorKind !== 'reconnect' ? ( + {inlineError} + ) : null} + {inlineErrorKind === 'reconnect' ? : null} + + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx index 170a35501b..3814d3b155 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx @@ -41,6 +41,7 @@ export function MergeabilityTimedOutRow({ onRefresh, isRefreshing, }: Readonly<{ onRefresh: () => void; isRefreshing: boolean }>) { + const colors = useThemeColors(); return ( Couldn't determine mergeability. @@ -54,7 +55,7 @@ export function MergeabilityTimedOutRow({ accessibilityLabel="Refresh mergeability" > - + Refresh diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx index 827b108248..9742bbc010 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx @@ -16,6 +16,7 @@ import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type AllowedMergeMethod, defaultMergeMethodFor, @@ -89,6 +90,7 @@ export function PrMergeSection({ isRefetching, }: PrMergeSectionProps) { const router = useRouter(); + const colors = useThemeColors(); const status = getMergeabilityStatus(overview); const reasons = useMemo( () => @@ -211,7 +213,7 @@ export function PrMergeSection({ accessibilityLabel="Merge now" > - + Merge now @@ -280,7 +282,7 @@ export function PrMergeSection({ accessibilityLabel="Merge pull request" > - + Merge diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index be8129b0c9..6d44e29b83 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -27,6 +27,8 @@ import { useEnableAutoMergeMutation, useMergePullRequestMutation, } from '@/lib/pr-review/merge/use-pr-merge-mutations'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; +import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { defaultMergeMethodOptionFor, mergeMethodOptionsFor, @@ -37,6 +39,10 @@ import { DeleteBranchToggle, MethodPicker, } from '@/components/pr-review/merge/pr-merge-sheet-parts'; +import { + defaultCommitMessage, + defaultCommitTitle, +} from '@/lib/pr-review/merge/merge-commit-defaults'; type PrMergeSheetMode = 'merge' | 'enable-auto-merge'; @@ -84,20 +90,6 @@ type AutoMergeInput = { commitMessage?: string; }; -function defaultCommitTitle(title: string, number: number): string { - return `${title} (#${number})`; -} - -function defaultCommitMessage(method: PrMergeMethod, body: string | null): string { - if (method === 'squash') { - return body && body.trim().length > 0 ? body : ''; - } - if (body && body.trim().length > 0) { - return body; - } - return ''; -} - export function PrMergeSheet(props: PrMergeSheetProps) { const { owner, @@ -137,6 +129,9 @@ export function PrMergeSheet(props: PrMergeSheetProps) { const messageRef = useRef(defaultCommitMessage(safeInitial, bodyMarkdown)); const [inlineError, setInlineError] = useState(null); + const [inlineErrorKind, setInlineErrorKind] = useState< + 'retryable' | 'non-retryable' | 'reconnect' | null + >(null); const ref: { owner: string; repo: string; number: number } = useMemo( () => ({ owner, repo: repoName, number }), @@ -153,9 +148,23 @@ export function PrMergeSheet(props: PrMergeSheetProps) { useEffect(() => { if (lastError) { - const message = - lastError instanceof Error ? lastError.message : 'Could not merge pull request.'; - setInlineError(message); + const classification = classifyPrReviewMutationError(lastError); + if (classification.kind === 'bad-request' || classification.kind === 'forbidden') { + setInlineError( + classification.kind === 'forbidden' + ? "You don't have permission to merge this pull request." + : 'This pull request cannot be merged as is.' + ); + setInlineErrorKind('non-retryable'); + } else if (classification.kind === 'reconnect') { + setInlineError('GitHub connection expired.'); + setInlineErrorKind('reconnect'); + } else { + setInlineError( + lastError instanceof Error ? lastError.message : 'Could not merge pull request.' + ); + setInlineErrorKind('retryable'); + } } }, [lastError]); @@ -202,6 +211,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { async function performSubmit() { setInlineError(null); + setInlineErrorKind(null); try { // eslint-disable-next-line typescript-eslint/prefer-ternary -- awaits inside branches can't be a ternary expression if (mode === 'merge') { @@ -215,12 +225,9 @@ export function PrMergeSheet(props: PrMergeSheetProps) { // refreshed PR review screen visible. Do NOT also call router.back() // here or it would pop the review screen too. onDismiss(); - } catch (error) { - if (error instanceof Error && error.message) { - setInlineError(error.message); - } else { - setInlineError('Could not merge pull request.'); - } + } catch { + // The effect above classifies the mutation error into inlineError; + // swallow here to avoid an unhandled promise rejection. } } @@ -229,6 +236,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { return; } setInlineError(null); + setInlineErrorKind(null); const submit = () => { void performSubmit(); @@ -302,7 +310,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { isDisabled={isMutating} /> ) : null} - {inlineError ? ( + {inlineError && inlineErrorKind !== 'reconnect' ? ( {inlineError} ) : null} + {inlineErrorKind === 'reconnect' ? : null} - {inlineError ? ( + {inlineError && inlineErrorKind !== 'reconnect' ? ( {inlineError} ) : null} + {inlineErrorKind === 'reconnect' ? : null} @@ -215,7 +237,12 @@ export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) { void handleCommentNow(); }} loading={isSubmitting} - disabled={isSubmitting} + disabled={ + isSubmitting || + inlineErrorKind === 'bad-request' || + inlineErrorKind === 'forbidden' || + inlineErrorKind === 'reconnect' + } accessibilityLabel="Comment now" > Comment now diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx index d200c074c0..1b83131c86 100644 --- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx @@ -42,6 +42,7 @@ import { MessageSquarePlus } from 'lucide-react-native'; import { View } from 'react-native'; import { DiscussionThread } from '@/components/pr-review/discussion/discussion-thread'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { EmptyState } from '@/components/empty-state'; import { QueryError } from '@/components/query-error'; import { Button } from '@/components/ui/button'; @@ -102,11 +103,9 @@ export function PrReviewDiscussionTab({ } if (firstPageErrorState.kind === 'reconnect') { return ( - + + + ); } // retryable diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx new file mode 100644 index 0000000000..6315100bd3 --- /dev/null +++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx @@ -0,0 +1,39 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { useTRPC } from '@/lib/trpc'; + +/** + * Shared reconnect affordance for PR Review surfaces. A + * PRECONDITION_FAILED on a query or mutation means the gate's GitHub + * authorization is no longer valid even though the gate passed. We + * force a refetch of the gate's query so the wrapping + * `PrReviewConnectGate` renders its own connect/reconnect CTA. The + * caller owns section/tab framing; this component is just the + * message + button. + */ +export function PrReviewReconnectNotice() { + const queryClient = useQueryClient(); + const trpc = useTRPC(); + + const handleReconnect = () => { + void queryClient.invalidateQueries({ + queryKey: trpc.githubApps.getUserAuthorization.queryKey(), + }); + }; + + return ( + + GitHub connection expired + + Your GitHub connection is no longer valid. Re-check your connection — you'll be + prompted to reconnect if needed. + + + + ); +} diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx index 0204e6ba77..2ccfd1ff0f 100644 --- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx @@ -29,6 +29,7 @@ import { buildSubmitReviewInput, type ReviewEvent, } from '@/lib/pr-review/build-submit-review-input'; +import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice'; import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state'; import { usePendingReview } from '@/lib/pr-review/pending-review-provider'; import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations'; @@ -57,9 +58,9 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { const [event, setEvent] = useState('COMMENT'); const [inlineError, setInlineError] = useState(null); - const [inlineErrorKind, setInlineErrorKind] = useState<'retryable' | 'non-retryable' | null>( - null - ); + const [inlineErrorKind, setInlineErrorKind] = useState< + 'retryable' | 'non-retryable' | 'reconnect' | null + >(null); // iOS uncontrolled pattern: body lives in a ref, the input's visible // value is set via defaultValue once. No `value` + state. @@ -76,11 +77,16 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { useEffect(() => { if (submitReview.error) { const classification = classifyPrReviewMutationError(submitReview.error); - if (classification.kind === 'bad-request') { + if (classification.kind === 'bad-request' || classification.kind === 'forbidden') { setInlineError( - "This review can't be submitted as is. The PR may have changed, or you can't approve your own pull request." + classification.kind === 'forbidden' + ? "You don't have permission to submit this review." + : "This review can't be submitted as is. The PR may have changed, or you can't approve your own pull request." ); setInlineErrorKind('non-retryable'); + } else if (classification.kind === 'reconnect') { + setInlineError('GitHub connection expired.'); + setInlineErrorKind('reconnect'); } else { setInlineError('Could not submit review. Check your connection and try again.'); setInlineErrorKind('retryable'); @@ -107,12 +113,9 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { pending.clear(); void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); onDismiss(); - } catch (error) { - if (error instanceof Error && error.message) { - setInlineError(error.message); - } else { - setInlineError('Could not submit review.'); - } + } catch { + // The effect above classifies the mutation error into inlineError; + // swallow here to avoid an unhandled promise rejection. } } @@ -163,7 +166,7 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { - {inlineError ? ( + {inlineError && inlineErrorKind !== 'reconnect' ? ( {inlineError} ) : null} + {inlineErrorKind === 'reconnect' ? : null} @@ -179,7 +183,9 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) { void handleSubmit(); }} loading={isSubmitting} - disabled={isSubmitting || inlineErrorKind === 'non-retryable'} + disabled={ + isSubmitting || inlineErrorKind === 'non-retryable' || inlineErrorKind === 'reconnect' + } accessibilityLabel="Submit review" > Submit review diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts index b04012c4f7..305a7604fd 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts @@ -15,7 +15,7 @@ function makeTopLevelTrpcError(code: string): unknown { } describe('classifyPrReviewMutationError', () => { - it('classifies BAD_REQUEST as non-retryable', () => { + it('classifies BAD_REQUEST as non-retryable bad-request', () => { const error = new Error('Cannot approve your own pull request'); Object.assign(error, { data: { code: 'BAD_REQUEST' } }); expect(classifyPrReviewMutationError(error)).toEqual({ @@ -38,6 +38,44 @@ describe('classifyPrReviewMutationError', () => { }); }); + it('classifies FORBIDDEN as terminal forbidden', () => { + const error = new Error('Resource not accessible by integration'); + Object.assign(error, { data: { code: 'FORBIDDEN' } }); + expect(classifyPrReviewMutationError(error)).toEqual({ + kind: 'forbidden', + message: 'Resource not accessible by integration', + }); + }); + + it('classifies FORBIDDEN from a top-level code field', () => { + expect(classifyPrReviewMutationError(makeTopLevelTrpcError('FORBIDDEN'))).toEqual({ + kind: 'forbidden', + message: 'Forbidden', + }); + }); + + it('classifies PRECONDITION_FAILED as reconnect', () => { + expect(classifyPrReviewMutationError(makeTrpcError('PRECONDITION_FAILED'))).toEqual({ + kind: 'reconnect', + message: 'GitHub connection expired', + }); + }); + + it('classifies UNAUTHORIZED as reconnect', () => { + const error = new Error('Bad credentials'); + Object.assign(error, { data: { code: 'UNAUTHORIZED' } }); + expect(classifyPrReviewMutationError(error)).toEqual({ + kind: 'reconnect', + message: 'Bad credentials', + }); + }); + + it('classifies TOO_MANY_REQUESTS as retryable', () => { + expect(classifyPrReviewMutationError(makeTrpcError('TOO_MANY_REQUESTS'))).toEqual({ + kind: 'retryable', + }); + }); + it('classifies network errors as retryable', () => { expect(classifyPrReviewMutationError(new Error('Network request failed'))).toEqual({ kind: 'retryable', diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts index 14b8d92547..225d58a1e2 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts @@ -99,8 +99,9 @@ export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState { type PrReviewMutationErrorState = | { /** - * The mutation failed with a transient error (network, 5xx, etc.). - * The caller should keep the retry affordance available. + * The mutation failed with a transient error (network, 5xx, rate + * limit, etc.). The caller should keep the retry affordance + * available. */ kind: 'retryable'; } @@ -114,6 +115,24 @@ type PrReviewMutationErrorState = kind: 'bad-request'; /** Original error message, suitable for logging but not the UI. */ message: string; + } + | { + /** + * The mutation failed with a permanent permission error. The + * caller should show a specific inline message and remove the + * retry affordance; no retry can succeed. + */ + kind: 'forbidden'; + message: string; + } + | { + /** + * The mutation failed because the GitHub authorization is no + * longer valid. The caller should remove the retry affordance and + * surface a reconnect CTA that invalidates the gate's auth query. + */ + kind: 'reconnect'; + message: string; }; export function classifyPrReviewMutationError(error: unknown): PrReviewMutationErrorState { @@ -124,5 +143,17 @@ export function classifyPrReviewMutationError(error: unknown): PrReviewMutationE message: error instanceof Error ? error.message : 'Bad request', }; } + if (code === 'FORBIDDEN') { + return { + kind: 'forbidden', + message: error instanceof Error ? error.message : 'Forbidden', + }; + } + if (code === 'PRECONDITION_FAILED' || code === 'UNAUTHORIZED') { + return { + kind: 'reconnect', + message: error instanceof Error ? error.message : 'GitHub connection expired', + }; + } return { kind: 'retryable' }; } diff --git a/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts b/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts new file mode 100644 index 0000000000..8215015fdf --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_TOKEN_COLOR, tokenColorFor } from './syntax-colors'; + +describe('tokenColorFor', () => { + it('returns the light color for a known class in light mode', () => { + expect(tokenColorFor('keyword', false)).toBe('#7B2CBF'); + }); + + it('returns the dark color for a known class in dark mode', () => { + expect(tokenColorFor('keyword', true)).toBe('#D8B4FE'); + }); + + it('falls back to the default color for an unknown class', () => { + expect(tokenColorFor('unknown-token', false)).toBe(DEFAULT_TOKEN_COLOR.light); + expect(tokenColorFor('unknown-token', true)).toBe(DEFAULT_TOKEN_COLOR.dark); + }); + + it('falls back to the default color when className is null', () => { + expect(tokenColorFor(null, false)).toBe(DEFAULT_TOKEN_COLOR.light); + expect(tokenColorFor(null, true)).toBe(DEFAULT_TOKEN_COLOR.dark); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts new file mode 100644 index 0000000000..6a4cab5029 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts @@ -0,0 +1,40 @@ +// Shared runtime palette for diff syntax highlighting. These colors are +// applied as inline `style={{ color }}` values because the token class +// (e.g. 'keyword', 'string') is only known at runtime from the highlighter; +// NativeWind cannot map arbitrary token classes to theme variables at +// build time. Centralizing the palette keeps the two diff renderers +// (unified `DiffLine` and tablet `SideBySideRow`) consistent. + +const TOKEN_DARK_LIGHT: Record = { + keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, + builtin: { light: '#1F6FEB', dark: '#79B8FF' }, + literal: { light: '#7B2CBF', dark: '#D8B4FE' }, + number: { light: '#B27214', dark: '#F2B05F' }, + string: { light: '#278150', dark: '#5FCB8E' }, + comment: { light: '#6F6A61', dark: '#8A8680' }, + type: { light: '#1F6FEB', dark: '#79B8FF' }, + function: { light: '#1F6FEB', dark: '#79B8FF' }, + variable: { light: '#14130F', dark: '#F2F0EB' }, + property: { light: '#1F6FEB', dark: '#79B8FF' }, + tag: { light: '#BE4E3F', dark: '#F28B7A' }, + selector: { light: '#7B2CBF', dark: '#D8B4FE' }, + attribute: { light: '#1F6FEB', dark: '#79B8FF' }, + operator: { light: '#6F6A61', dark: '#8A8680' }, + meta: { light: '#6F6A61', dark: '#8A8680' }, + add: { light: '#278150', dark: '#5FCB8E' }, + del: { light: '#BE4E3F', dark: '#F28B7A' }, +}; + +export const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; +export const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' }; + +export function tokenColorFor(className: string | null, isDark: boolean): string { + if (!className) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + const palette = TOKEN_DARK_LIGHT[className]; + if (!palette) { + return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; + } + return isDark ? palette.dark : palette.light; +} diff --git a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts new file mode 100644 index 0000000000..66313cf33e --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts @@ -0,0 +1,18 @@ +// Pure defaults for the merge sheet's commit title/message fields. Kept +// out of the component so they stay unit-testable and the sheet stays +// under the max-lines limit. +import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; + +export function defaultCommitTitle(title: string, number: number): string { + return `${title} (#${number})`; +} + +export function defaultCommitMessage(method: PrMergeMethod, body: string | null): string { + if (method === 'squash') { + return body && body.trim().length > 0 ? body : ''; + } + if (body && body.trim().length > 0) { + return body; + } + return ''; +} diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index 16b6478d1b..1d83d45fb6 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -532,7 +532,18 @@ export const githubPrReviewRouter = createTRPCRouter({ graphQl = gqlResp.data.data ?? null; } catch (error) { if (error instanceof TRPCError) throw error; - // GraphQL failure should not block the rest of the overview. + // A raw 401 must reach withGitHubUserTokenRetry so it can rotate the + // credential (and report a terminal rejection) — never silently + // degrade an authorization failure. + if ( + error !== null && + typeof error === 'object' && + (error as { status?: number }).status === 401 + ) { + throw error; + } + // Other GraphQL failures (5xx, field errors) should not block the + // rest of the overview — degrade the reviewDecision/viewer enrichment. graphQl = null; } return buildOverviewDto({ From 61747a14932ca57f9037e90e85141019b458e1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 08:04:47 +0200 Subject: [PATCH 15/19] fix(web): accept tRPC infinite-query direction in listFiles/listReviewThreads The mobile useInfiniteQuery integration injects a direction discriminator into the procedure input; the .strict() schemas rejected it, 400-ing every files/ threads page. Accept it explicitly and add regression coverage. --- .../routers/github-pr-review-router.test.ts | 38 +++++++++++++++++++ .../src/routers/github-pr-review-router.ts | 8 ++++ 2 files changed, 46 insertions(+) diff --git a/apps/web/src/routers/github-pr-review-router.test.ts b/apps/web/src/routers/github-pr-review-router.test.ts index 4a147cc585..3e331d84e8 100644 --- a/apps/web/src/routers/github-pr-review-router.test.ts +++ b/apps/web/src/routers/github-pr-review-router.test.ts @@ -23,6 +23,7 @@ type OctokitMock = { createReviewComment: jest.Mock; createReplyForReviewComment: jest.Mock; updateBranch: jest.Mock; + listFiles: jest.Mock; }; git: { deleteRef: jest.Mock }; request: jest.Mock; @@ -41,6 +42,7 @@ function buildOctokit(token: string): OctokitMock { createReviewComment: jest.fn(), createReplyForReviewComment: jest.fn(), updateBranch: jest.fn(), + listFiles: jest.fn(), }, git: { deleteRef: jest.fn() }, request: jest.fn(), @@ -163,6 +165,42 @@ describe('githubPrReviewRouter.mergePullRequest', () => { }); }); +describe('githubPrReviewRouter infinite-query inputs accept the tRPC direction field', () => { + // tRPC's useInfiniteQuery integration injects `direction: 'forward'|'backward'` + // into the procedure input. The inputs are `.strict()`, so without an explicit + // `direction` field every page 400s (only reproducible end-to-end, since the + // mobile client — not these unit callers — is what sends `direction`). + it('listFiles accepts direction: "forward" and returns the page', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + buildOctokit('t1').pulls.listFiles.mockResolvedValueOnce({ data: [] }); + + await expect( + caller.listFiles({ owner: 'octocat', repo: 'hello', number: 1, direction: 'forward' }) + ).resolves.toMatchObject({ files: [] }); + }); + + it('listReviewThreads accepts direction: "forward"', async () => { + getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1)); + const caller = createCaller({ user: { id: 'user-1' } as User }); + buildOctokit('t1').request.mockResolvedValue({ + data: { + data: { + repository: { + pullRequest: { + reviewThreads: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } }, + }, + }, + }, + }, + }); + + await expect( + caller.listReviewThreads({ owner: 'octocat', repo: 'hello', number: 1, direction: 'forward' }) + ).resolves.toBeDefined(); + }); +}); + describe('githubPrReviewRouter mutations go through withGitHubUserTokenRetry', () => { it('rotates the credential and retries on a raw 401', async () => { getGitHubUserAccessToken diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index 1d83d45fb6..68bfbd3e12 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -56,10 +56,17 @@ const GetPullRequestInput = ownerRepoSchema.extend({ number: prNumberSchema }).s const ListChecksInput = ownerRepoSchema.extend({ ref: z.string().min(1).max(255) }).strict(); +// tRPC's `useInfiniteQuery` integration injects a `direction` discriminator +// ('forward'|'backward') into the procedure input alongside `cursor`. The input +// stays `.strict()` (unknown fields still rejected), so it must accept it +// explicitly or every infinite-query page 400s. +const infiniteQueryDirection = z.enum(['forward', 'backward']).optional(); + const ListFilesInput = ownerRepoSchema .extend({ number: prNumberSchema, cursor: z.number().int().min(1).max(FILES_MAX_PAGES).optional(), + direction: infiniteQueryDirection, }) .strict(); @@ -79,6 +86,7 @@ const ListReviewThreadsInput = ownerRepoSchema .extend({ number: prNumberSchema, cursor: z.string().min(1).optional(), + direction: infiniteQueryDirection, }) .strict(); From 782661490a552626be1661cb93307d8e77b9429c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 08:04:48 +0200 Subject: [PATCH 16/19] fix(mobile): use plain Href for pr-review dynamic navigation Href with a group-prefixed literal fails the generated typed-routes constraint; match the working plain-Href pattern used elsewhere in the feature. --- .../diff/pr-diff-file-list-header.tsx | 2 +- .../diff/pr-diff-floating-actions.tsx | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx index a36db111cc..83384b0ccc 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -46,7 +46,7 @@ export function PrDiffFileListHeader({ const isTablet = useIsTablet(); const colors = useThemeColors(); - const navigatorHref = useMemo>( + const navigatorHref = useMemo( () => ({ pathname: FILE_NAVIGATOR_PATH, params: { owner, repo, number } }), [owner, repo, number] ); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx index c314a65361..1c7ceb12b0 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx @@ -58,26 +58,23 @@ export function PrDiffFloatingActions({ if (!selection) { return; } - const params: Record = { - owner, - repo, - number, - path: selection.path, - side: selection.side, - line: selection.line, - }; - if (selection.startLine !== selection.line) { - params.startLine = selection.startLine; - } - const href: Href = { + const href: Href = { pathname: COMMENT_COMPOSER_PATH, - params, + params: { + owner, + repo, + number, + path: selection.path, + side: selection.side, + line: selection.line, + ...(selection.startLine !== selection.line ? { startLine: selection.startLine } : {}), + }, }; router.push(href); } function openReviewSubmit() { - const href: Href = { + const href: Href = { pathname: REVIEW_SUBMIT_PATH, params: { owner, repo, number }, }; From 9ba0fc885456ecf53106ca8b2285e50471f3679e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 09:49:24 +0200 Subject: [PATCH 17/19] fix(web): nest GraphQL variables in pr-review octokit requests octokit.request('POST /graphql', {query, ...vars}) sends variables at the top level, where GitHub ignores them (all $variables resolve null). This silently dropped nested comment pages and would break thread pagination cursors and every GraphQL mutation (resolve/unresolve/reactions/auto-merge) against real GitHub. Nest under `variables` at all four call sites; update the follow-up test. E2E against a strict GraphQL mock now returns the second comment page. --- .../review-thread-comments.test.ts | 19 ++++++++-------- .../src/routers/github-pr-review-router.ts | 22 +++++++++---------- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts index 9fdd5b853b..106efa0212 100644 --- a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts +++ b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts @@ -63,19 +63,18 @@ describe('fetchAllThreadComments', () => { expect(comments.map(c => c.databaseId)).toEqual([1, 2, 3]); expect(request).toHaveBeenCalledTimes(2); - // The follow-up query must reference only $threadId/$first/$after (no - // unused $owner/$name/$number that GraphQL would reject). - const [, firstVars] = request.mock.calls[0] as [string, Record]; - expect(firstVars.query).toBe(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST); - expect(firstVars).toEqual({ + // GraphQL variables must be nested under `variables` (GitHub — and a + // faithful mock — ignore top-level params), and the follow-up query must + // reference only $threadId/$first/$after (no unused $owner/$name/$number). + const [, firstArgs] = request.mock.calls[0] as [string, Record]; + expect(firstArgs.query).toBe(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST); + expect(firstArgs).toEqual({ query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST, - threadId: 'thread_1', - first: 50, - after: 'c1', + variables: { threadId: 'thread_1', first: 50, after: 'c1' }, }); expect(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST).not.toMatch(/\$owner|\$name|\$number/); - const [, secondVars] = request.mock.calls[1] as [string, Record]; - expect(secondVars.after).toBe('c2'); + const [, secondArgs] = request.mock.calls[1] as [string, { variables: Record }]; + expect(secondArgs.variables.after).toBe('c2'); }); }); diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts index 68bfbd3e12..212dd10285 100644 --- a/apps/web/src/routers/github-pr-review-router.ts +++ b/apps/web/src/routers/github-pr-review-router.ts @@ -411,9 +411,7 @@ export async function fetchAllThreadComments(args: { while (hasNext && cursor) { const response = (await octokit.request('POST /graphql', { query: REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY, - threadId, - first: 50, - after: cursor, + variables: { threadId, first: 50, after: cursor }, })) as { data: { data: { node: { comments: GraphQlCommentConnection } | null } | null; @@ -440,11 +438,13 @@ async function fetchReviewThreadsPage(args: { const { octokit, owner, repo, number, cursor } = args; const response = (await octokit.request('POST /graphql', { query: REVIEW_THREADS_QUERY, - owner, - name: repo, - number, - first: REVIEW_THREADS_PAGE_SIZE, - after: cursor ?? null, + variables: { + owner, + name: repo, + number, + first: REVIEW_THREADS_PAGE_SIZE, + after: cursor ?? null, + }, })) as { data: { data: { @@ -479,7 +479,7 @@ async function runGraphQlMutation(args: { const { octokit, query, variables } = args; const response = (await octokit.request('POST /graphql', { query, - ...variables, + variables, })) as GraphQlMutationResponse; throwTrpcFromGraphQlErrors(response.data.errors as never); const payload = response.data.data; @@ -532,9 +532,7 @@ export const githubPrReviewRouter = createTRPCRouter({ try { const gqlResp = (await octokit.request('POST /graphql', { query: PULL_REQUEST_FRAGMENT_QUERY, - owner: input.owner, - name: input.repo, - number: input.number, + variables: { owner: input.owner, name: input.repo, number: input.number }, })) as { data: { data: OverviewGraphQl | null; errors?: unknown } }; throwTrpcFromGraphQlErrors(gqlResp.data.errors as never); graphQl = gqlResp.data.data ?? null; From 73905d047072fdb4317f809742f6060a467221a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 11:07:37 +0200 Subject: [PATCH 18/19] style: apply repo oxfmt formatting to pr-review files --- .../review-thread-comments.test.ts | 5 +++- .../src/routers/github-apps-router.test.ts | 4 +--- .../github-user-authorization-service.test.ts | 24 +++++++++---------- .../src/github-user-authorization-service.ts | 6 ++--- services/git-token-service/src/index.ts | 4 +--- 5 files changed, 19 insertions(+), 24 deletions(-) diff --git a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts index 106efa0212..9d4ab05059 100644 --- a/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts +++ b/apps/web/src/lib/github-pr-review/review-thread-comments.test.ts @@ -74,7 +74,10 @@ describe('fetchAllThreadComments', () => { }); expect(REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST).not.toMatch(/\$owner|\$name|\$number/); - const [, secondArgs] = request.mock.calls[1] as [string, { variables: Record }]; + const [, secondArgs] = request.mock.calls[1] as [ + string, + { variables: Record }, + ]; expect(secondArgs.variables.after).toBe('c2'); }); }); diff --git a/apps/web/src/routers/github-apps-router.test.ts b/apps/web/src/routers/github-apps-router.test.ts index 87cea3abb6..dbba604e49 100644 --- a/apps/web/src/routers/github-apps-router.test.ts +++ b/apps/web/src/routers/github-apps-router.test.ts @@ -31,9 +31,7 @@ const mockFetchGitHubRepositories = jest.fn<(installationId: string, appType: GitHubAppType) => Promise>(); const mockSeedUserGithubToken = jest.fn< - ( - input: Record - ) => Promise<{ upserted: boolean; githubLogin: string }> + (input: Record) => Promise<{ upserted: boolean; githubLogin: string }> >(); jest.mock('@/lib/integrations/github-apps-service', () => ({})); diff --git a/services/git-token-service/src/github-user-authorization-service.test.ts b/services/git-token-service/src/github-user-authorization-service.test.ts index 7542f1ee6c..e5a01a0155 100644 --- a/services/git-token-service/src/github-user-authorization-service.test.ts +++ b/services/git-token-service/src/github-user-authorization-service.test.ts @@ -613,16 +613,14 @@ describe('GitHubUserAuthorizationService.getUserAccessToken', () => { refresh_token_expires_at: new Date(Date.now() + 7200 * 1000).toISOString(), }; database.updatedRow = refreshedRow; - const fetchMock = vi - .fn() - .mockResolvedValue( - Response.json({ - access_token: 'refreshed-access', - expires_in: 3600, - refresh_token: 'refreshed-refresh', - refresh_token_expires_in: 7200, - }) - ); + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access', + expires_in: 3600, + refresh_token: 'refreshed-refresh', + refresh_token_expires_in: 7200, + }) + ); vi.stubGlobal('fetch', fetchMock); await makeService({ GITHUB_OAUTH_BASE_URL: 'https://github.test/' }).getUserAccessToken( @@ -781,9 +779,9 @@ describe('GitHubUserAuthorizationService.getUserAccessToken', () => { database.rows = [row]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 500 }))); - await expect( - makeService().getUserAccessToken('user_1', { op: 'fetch' }) - ).rejects.toThrow('temporarily_unavailable'); + await expect(makeService().getUserAccessToken('user_1', { op: 'fetch' })).rejects.toThrow( + 'temporarily_unavailable' + ); expect(database.updates).toHaveLength(0); }); }); diff --git a/services/git-token-service/src/github-user-authorization-service.ts b/services/git-token-service/src/github-user-authorization-service.ts index 795d340b0c..2eb7d028c9 100644 --- a/services/git-token-service/src/github-user-authorization-service.ts +++ b/services/git-token-service/src/github-user-authorization-service.ts @@ -216,8 +216,7 @@ export class GitHubUserAuthorizationService { if ( forceRefresh || - new Date(authorization.access_token_expires_at).getTime() - Date.now() < - EXPIRY_BUFFER_MS + new Date(authorization.access_token_expires_at).getTime() - Date.now() < EXPIRY_BUFFER_MS ) { const refreshResult = await this.refreshAuthorizationWithLock(db, authorization, { force: forceRefresh, @@ -476,8 +475,7 @@ export class GitHubUserAuthorizationService { } if ( !options.force && - new Date(authorization.access_token_expires_at).getTime() - Date.now() >= - EXPIRY_BUFFER_MS + new Date(authorization.access_token_expires_at).getTime() - Date.now() >= EXPIRY_BUFFER_MS ) { return 'refreshed'; } diff --git a/services/git-token-service/src/index.ts b/services/git-token-service/src/index.ts index cdd103ba0f..7e91d54164 100644 --- a/services/git-token-service/src/index.ts +++ b/services/git-token-service/src/index.ts @@ -1193,9 +1193,7 @@ export default { // shared early-return error paths (405/401/503). The GitHub user-access // token endpoint joins the GitLab private endpoints here. const privateNoStoreHeaders = - isGitLabCredentialAudit || - isGitLabCredentialBroker || - url.pathname === USER_ACCESS_TOKEN_PATH + isGitLabCredentialAudit || isGitLabCredentialBroker || url.pathname === USER_ACCESS_TOKEN_PATH ? { 'Cache-Control': 'no-store' } : undefined; const codeReviewAudience = bitbucketCodeReviewAudiences.get(url.pathname); From 203c67a6cb1363e8cd044bc6f12b591cd23ad145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 18 Jul 2026 11:46:57 +0200 Subject: [PATCH 19/19] fix(pr-review): address PR review findings - github-pr-url: restrict owner/repo to GitHub's charset and reject dot-only segments (no path-traversal-like values) - classify query state: UNAUTHORIZED -> reconnect (matches mutation path); FORBIDDEN stays terminal permission - merge-commit-defaults: drop the no-op method param - diff list builder: route non-empty-but-unparsable patches through the patch-missing fallback; render gap/expand rows correctly in side-by-side - git-token-service: persist revocation on terminal_rejection during selection; rewrite the OAuth base-URL regex to avoid polynomial backtracking (ReDoS) --- .../pr-review/merge/pr-merge-sheet.tsx | 6 +- apps/mobile/src/lib/github-pr-url.test.ts | 15 ++- apps/mobile/src/lib/github-pr-url.ts | 13 ++- .../classify-pr-review-query-state.test.ts | 37 ++++++- .../classify-pr-review-query-state.ts | 5 +- .../lib/pr-review/diff/pr-diff-gap-builder.ts | 57 +++++++--- .../pr-diff-list-builder-side-by-side.test.ts | 102 ++++++++++++++++++ .../diff/pr-diff-list-builder.test.ts | 23 ++++ .../pr-review/diff/pr-diff-list-builder.ts | 99 ++++++----------- .../merge/merge-commit-defaults.test.ts | 30 ++++++ .../pr-review/merge/merge-commit-defaults.ts | 6 +- .../github-user-authorization-service.test.ts | 57 ++++++++++ .../src/github-user-authorization-service.ts | 7 +- 13 files changed, 355 insertions(+), 102 deletions(-) create mode 100644 apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts create mode 100644 apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx index 6d44e29b83..2fbd3e16bd 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx @@ -126,7 +126,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { const titleInputRef = useRef(null); const messageInputRef = useRef(null); const titleRef = useRef(defaultCommitTitle(title, number)); - const messageRef = useRef(defaultCommitMessage(safeInitial, bodyMarkdown)); + const messageRef = useRef(defaultCommitMessage(bodyMarkdown)); const [inlineError, setInlineError] = useState(null); const [inlineErrorKind, setInlineErrorKind] = useState< @@ -170,7 +170,6 @@ export function PrMergeSheet(props: PrMergeSheetProps) { function resetForNewMethod(next: AllowedMergeMethod) { setMethod(next); - messageRef.current = defaultCommitMessage(next, bodyMarkdown); } function buildMergeInput(): MergePullRequestInput { @@ -295,10 +294,7 @@ export function PrMergeSheet(props: PrMergeSheetProps) { placeholder={defaultCommitTitle(title, number)} isDisabled={isMutating} /> - {/* Remount on method change so the visible commit message matches the - method-specific default that will actually be submitted. */} { expect(parseGitHubPrUrl('')).toBeNull(); }); - it('returns null for a URL missing the owner', () => { - expect(parseGitHubPrUrl('https://github.com/hello-world/pull/42')).toBeNull(); + it('rejects owner or repo composed solely of dots', () => { + expect(parseGitHubPrUrl('https://github.com/./repo/pull/5')).toBeNull(); + expect(parseGitHubPrUrl('https://github.com/owner/./pull/5')).toBeNull(); + expect(parseGitHubPrUrl('https://github.com/../repo/pull/5')).toBeNull(); + expect(parseGitHubPrUrl('https://github.com/owner/../pull/5')).toBeNull(); + }); + + it('parses owners and repos with dots, dashes and underscores', () => { + expect(parseGitHubPrUrl('https://github.com/my.repo/a-b_c/pull/1')).toEqual({ + owner: 'my.repo', + repo: 'a-b_c', + number: 1, + }); }); }); diff --git a/apps/mobile/src/lib/github-pr-url.ts b/apps/mobile/src/lib/github-pr-url.ts index 6a5db28d3e..4fa29addc3 100644 --- a/apps/mobile/src/lib/github-pr-url.ts +++ b/apps/mobile/src/lib/github-pr-url.ts @@ -5,7 +5,11 @@ type GitHubPrUrl = { }; const GITHUB_PR_PATTERN = - /^https?:\/\/(www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#][^\s]*)?$/i; + /^https?:\/\/(www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#][^\s]*)?$/i; + +function isValidIdentifier(value: string): boolean { + return value !== '.' && value !== '..'; +} /** * Parse a GitHub pull request URL into its owner, repo and PR number. @@ -32,5 +36,10 @@ export function parseGitHubPrUrl(href: string): GitHubPrUrl | null { if (!Number.isInteger(number) || number <= 0) { return null; } - return { owner: match[2] ?? '', repo: match[3] ?? '', number }; + const owner = match[2] ?? ''; + const repo = match[3] ?? ''; + if (!isValidIdentifier(owner) || !isValidIdentifier(repo)) { + return null; + } + return { owner, repo, number }; } diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts index 586637af4e..061a1d3c11 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { classifyPrReviewQueryState } from './classify-pr-review-query-state'; +import { + classifyPrReviewMutationError, + classifyPrReviewQueryState, +} from './classify-pr-review-query-state'; function makeTrpcError(code: string): unknown { // Mirrors the shape tRPC v11 surfaces on a thrown TRPCClientError from @@ -38,9 +41,9 @@ describe('classifyPrReviewQueryState', () => { }); }); - it('classifies UNAUTHORIZED as a permanent permission error (no CTA, no retry)', () => { + it('classifies UNAUTHORIZED as a reconnect state (revoked connection, reconnect CTA)', () => { expect(classifyPrReviewQueryState(makeTrpcError('UNAUTHORIZED'))).toEqual({ - kind: 'permission', + kind: 'reconnect', }); }); @@ -73,3 +76,31 @@ describe('classifyPrReviewQueryState', () => { expect(classifyPrReviewQueryState(makeTrpcError('TIMEOUT'))).toEqual({ kind: 'retryable' }); }); }); + +describe('classifyPrReviewMutationError', () => { + it('classifies FORBIDDEN as a terminal permission error', () => { + expect(classifyPrReviewMutationError(makeTrpcError('FORBIDDEN'))).toEqual({ + kind: 'forbidden', + message: 'Forbidden', + }); + }); + + it('classifies UNAUTHORIZED as a reconnect state', () => { + expect(classifyPrReviewMutationError(makeTrpcError('UNAUTHORIZED'))).toEqual({ + kind: 'reconnect', + message: 'GitHub connection expired', + }); + }); + + it('classifies PRECONDITION_FAILED as a reconnect state', () => { + expect(classifyPrReviewMutationError(makeTrpcError('PRECONDITION_FAILED'))).toEqual({ + kind: 'reconnect', + message: 'GitHub connection expired', + }); + }); + + it('falls back to retryable for unknown / non-tRPC errors', () => { + expect(classifyPrReviewMutationError(new Error('network down'))).toEqual({ kind: 'retryable' }); + expect(classifyPrReviewMutationError('string error')).toEqual({ kind: 'retryable' }); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts index 225d58a1e2..52a6b49099 100644 --- a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts +++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts @@ -90,9 +90,12 @@ export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState { if (code === 'NOT_FOUND') { return { kind: 'not-found' }; } - if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') { + if (code === 'FORBIDDEN') { return { kind: 'permission' }; } + if (code === 'UNAUTHORIZED') { + return { kind: 'reconnect' }; + } return { kind: 'retryable' }; } diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts index 8071417aa8..52522c247d 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts @@ -1,15 +1,17 @@ // Gap builder helpers for the PR diff FlashList. Kept separate so the // main list builder stays under the max-lines limit. -import { type ParsedPatch } from '@/lib/pr-review/diff/parse-patch'; +import { type ParsedDiffLine, type ParsedPatch } from '@/lib/pr-review/diff/parse-patch'; import { type BuildItemsArgs, + type DiffViewMode, type ExpandSeparatorItem, type ExpandSeparatorState, getCumulativeLines, getTotalLines, type ListItem, } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { type SideBySideRow } from '@/lib/pr-review/diff/side-by-side'; function deriveSeparatorState( state: ExpandSeparatorState, @@ -38,6 +40,7 @@ export function pushGapItems(args: { parsed: ParsedPatch; language: string | null; headSha: string; + viewMode?: DiffViewMode; }): void { const state = args.fileContext[args.gapIndex] ?? { status: 'idle' as const }; const cumulativeLines = getCumulativeLines(state); @@ -45,28 +48,52 @@ export function pushGapItems(args: { const effectiveEndLine = getTotalLines(state) ?? args.endLine; const gapSize = effectiveEndLine - args.startLine + 1; const isComplete = loadedCount >= gapSize; + const viewMode: DiffViewMode = args.viewMode ?? 'unified'; for (let lineIdx = 0; lineIdx < loadedCount; lineIdx += 1) { const lineText = cumulativeLines[lineIdx] ?? ''; const newLineNo = args.startLine + lineIdx; - args.items.push({ - kind: 'diff-line', - key: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, - lineKey: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, - filePath: args.file.path, - hunkIndex: args.hunkIndex, - lineIndex: lineIdx, - parsed: args.parsed, - line: { + if (viewMode === 'side-by-side') { + const line: ParsedDiffLine = { type: 'context', + oldLine: newLineNo, newLine: newLineNo, text: lineText, noNewlineAtEndOfFile: false, - }, - language: args.language, - lineKeyId: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, - selectable: false, - }); + }; + const row: SideBySideRow = { left: { line }, right: { line } }; + const rowKeyId = `gap-sbs:${args.file.path}:${args.gapIndex}:${lineIdx}`; + args.items.push({ + kind: 'side-by-side-row', + key: rowKeyId, + rowKey: rowKeyId, + filePath: args.file.path, + hunkIndex: args.hunkIndex, + rowIndex: lineIdx, + row, + language: args.language, + rowKeyId, + }); + } else { + args.items.push({ + kind: 'diff-line', + key: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + lineKey: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + filePath: args.file.path, + hunkIndex: args.hunkIndex, + lineIndex: lineIdx, + parsed: args.parsed, + line: { + type: 'context', + newLine: newLineNo, + text: lineText, + noNewlineAtEndOfFile: false, + }, + language: args.language, + lineKeyId: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`, + selectable: false, + }); + } } if (isComplete || state.status === 'unavailable') { diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts new file mode 100644 index 0000000000..82b5c9b63e --- /dev/null +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; +import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; +import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; + +type DiffLineListItem = Extract; +type SeparatorItem = Extract; +type SideBySideRowItem = Extract; + +function diffLines(items: ListItem[]): DiffLineListItem[] { + return items.filter((i): i is DiffLineListItem => i.kind === 'diff-line'); +} +function separators(items: ListItem[]): SeparatorItem[] { + return items.filter((i): i is SeparatorItem => i.kind === 'expand-separator'); +} +function sideBySideRows(items: ListItem[]): SideBySideRowItem[] { + return items.filter((i): i is SideBySideRowItem => i.kind === 'side-by-side-row'); +} +function separatorFor(items: ListItem[], gapIndex: number): SeparatorItem | undefined { + return separators(items).find(s => s.context.gapIndex === gapIndex); +} + +function makeFile(patch: string, path = 'a.ts'): PrReviewFile { + return { + path, + previousPath: null, + status: 'modified', + additions: 1, + deletions: 1, + patch, + patchMissing: false, + }; +} + +function baseArgs(overrides: Partial = {}): BuildItemsArgs { + return { + files: [], + expanded: {}, + expandedContext: {}, + viewed: () => false, + headSha: 'abc', + owner: 'owner', + repo: 'repo', + number: 1, + changedFiles: 0, + isLoading: false, + isFetchingNextPage: false, + hasNextPage: false, + laterPageError: false, + fetchToCompletionRunning: false, + fetchToCompletionLoaded: 0, + totalFiles: null, + viewMode: 'side-by-side', + ...overrides, + }; +} + +const singleHunkPatch = [ + 'diff --git a/a.ts b/a.ts', + '@@ -5,3 +5,3 @@', + ' context line 5', + '-old line 6', + '+new line 6', + ' context line 7', +].join('\n'); + +describe('buildItems side-by-side gaps', () => { + it('renders loaded leading-gap context as side-by-side rows, not unified diff lines', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(singleHunkPatch)], + expanded: { 'a.ts': true }, + expandedContext: { + 'a.ts': { [-1]: { status: 'partial', lines: ['leading 1', 'leading 2'], totalLines: 4 } }, + }, + }) + ); + + const gapUnified = diffLines(items).filter(l => l.lineKey.startsWith('gap-line:')); + expect(gapUnified).toHaveLength(0); + + const gapSbs = sideBySideRows(items).filter(r => r.rowKey.startsWith('gap-sbs:a.ts:-1:')); + expect(gapSbs).toHaveLength(2); + expect(gapSbs[0]?.row.left?.line.text).toBe('leading 1'); + expect(gapSbs[0]?.row.right?.line.text).toBe('leading 1'); + expect(gapSbs[0]?.row.left?.line.oldLine).toBe(1); + expect(gapSbs[0]?.row.left?.line.newLine).toBe(1); + expect(gapSbs[1]?.row.left?.line.text).toBe('leading 2'); + expect(gapSbs[1]?.row.left?.line.newLine).toBe(2); + }); + + it('keeps the leading gap expand-separator full-width in side-by-side mode', () => { + const items = buildItems( + baseArgs({ + files: [makeFile(singleHunkPatch)], + expanded: { 'a.ts': true }, + }) + ); + expect(separatorFor(items, -1)).toMatchObject({ kind: 'expand-separator' }); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts index b5ab064d85..1ff1898e52 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts @@ -4,10 +4,16 @@ import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder'; import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items'; import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; +type FilePatchMissingItem = Extract; + type SeparatorItem = Extract; type DiffLineListItem = Extract; type PaginationItem = Extract; +function patchMissingItems(items: ListItem[]): FilePatchMissingItem[] { + return items.filter((i): i is FilePatchMissingItem => i.kind === 'file-patch-missing'); +} + function separators(items: ListItem[]): SeparatorItem[] { return items.filter((i): i is SeparatorItem => i.kind === 'expand-separator'); } @@ -250,6 +256,23 @@ describe('buildItems progressive context windowing', () => { }); }); +describe('buildItems malformed patch', () => { + it('routes a non-empty patch that parses to zero hunks through the patch-missing fallback', () => { + const items = buildItems( + baseArgs({ + files: [makeFile('this is not a valid patch', 'bad.ts')], + expanded: { 'bad.ts': true }, + }) + ); + const missing = patchMissingItems(items); + expect(missing).toHaveLength(1); + expect(missing[0]).toMatchObject({ + kind: 'file-patch-missing', + file: { path: 'bad.ts' }, + }); + }); +}); + describe('buildItems trailing gap totalLines', () => { it('uses totalLines to bound the trailing gap once known', () => { const items = buildItems( diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts index 5b9f5ecfb9..97cbf15357 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts @@ -9,6 +9,7 @@ import { type BuildItemsArgs, type DiffViewMode, type ListItem, + type PaginationRowItem, PR_REVIEW_MAX_LISTED_FILES, shouldShowTruncationBanner, truncationBannerCopy, @@ -114,19 +115,15 @@ function pushExpandedFileItems( number: args.number, path: file.path, }); - if (file.patchMissing || !file.patch) { - pushPatchMissingItems({ - items, - file, - viewed: args.viewed(file.path), - githubUrl, - }); + const parsed: ParsedFile = file.patch ? parsePatch(file.patch) : { isRename: false, hunks: [] }; + const hunks = parsed.hunks; + + if (file.patchMissing || hunks.length === 0) { + pushPatchMissingItems({ items, file, viewed: args.viewed(file.path), githubUrl }); return; } - const parsed = parsePatch(file.patch); const language = languageForPath(file.path); - const hunks = parsed.hunks; const fileContext = args.expandedContext[file.path] ?? {}; const viewMode: DiffViewMode = args.viewMode ?? 'unified'; @@ -148,6 +145,7 @@ function pushExpandedFileItems( parsed, language, headSha: args.headSha, + viewMode, }); } @@ -168,6 +166,7 @@ function pushExpandedFileItems( parsed, language, headSha: args.headSha, + viewMode, }); } } @@ -193,91 +192,55 @@ function pushExpandedFileItems( parsed, language, headSha: args.headSha, + viewMode, }); } } +function pushPaginationState( + items: ListItem[], + state: PaginationRowItem['state'], + args: BuildItemsArgs +): void { + items.push({ + kind: 'pagination-row', + key: 'pagination-row', + state, + loadedFiles: args.isLoading ? 0 : args.fetchToCompletionLoaded, + totalFiles: args.totalFiles, + }); +} + function pushPaginationItem(items: ListItem[], args: BuildItemsArgs): void { if (args.isLoading) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'loading', - loadedFiles: 0, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'loading', args); return; } - if (args.hasNextPage) { if (args.laterPageError && !args.isFetchingNextPage && !args.fetchToCompletionRunning) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'error', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'error', args); return; } if (args.fetchToCompletionLoaded >= PR_REVIEW_MAX_LISTED_FILES) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'all-loaded', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'all-loaded', args); return; } if (args.fetchToCompletionRunning) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'fetch-to-completion', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'fetch-to-completion', args); return; } if (args.isFetchingNextPage) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'loading', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'loading', args); return; } - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'no-pages', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'no-pages', args); return; } - if (args.isFetchingNextPage) { - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'loading', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'loading', args); return; } - - items.push({ - kind: 'pagination-row', - key: 'pagination-row', - state: 'all-loaded', - loadedFiles: args.fetchToCompletionLoaded, - totalFiles: args.totalFiles, - }); + pushPaginationState(items, 'all-loaded', args); } export function buildItems(args: BuildItemsArgs): ListItem[] { diff --git a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts new file mode 100644 index 0000000000..0baabb8dd5 --- /dev/null +++ b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + defaultCommitMessage, + defaultCommitTitle, +} from '@/lib/pr-review/merge/merge-commit-defaults'; + +describe('defaultCommitTitle', () => { + it('returns the PR title with the number appended', () => { + expect(defaultCommitTitle('Add feature', 42)).toBe('Add feature (#42)'); + }); +}); + +describe('defaultCommitMessage', () => { + it('returns the PR body when it is non-empty', () => { + expect(defaultCommitMessage('Description line')).toBe('Description line'); + }); + + it('returns an empty string when the body is null', () => { + expect(defaultCommitMessage(null)).toBe(''); + }); + + it('returns an empty string when the body is whitespace only', () => { + expect(defaultCommitMessage(' ')).toBe(''); + }); + + it('returns the body after trimming surrounding whitespace', () => { + expect(defaultCommitMessage(' meaningful body ')).toBe(' meaningful body '); + }); +}); diff --git a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts index 66313cf33e..de1641078d 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts @@ -1,16 +1,12 @@ // Pure defaults for the merge sheet's commit title/message fields. Kept // out of the component so they stay unit-testable and the sheet stays // under the max-lines limit. -import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons'; export function defaultCommitTitle(title: string, number: number): string { return `${title} (#${number})`; } -export function defaultCommitMessage(method: PrMergeMethod, body: string | null): string { - if (method === 'squash') { - return body && body.trim().length > 0 ? body : ''; - } +export function defaultCommitMessage(body: string | null): string { if (body && body.trim().length > 0) { return body; } diff --git a/services/git-token-service/src/github-user-authorization-service.test.ts b/services/git-token-service/src/github-user-authorization-service.test.ts index e5a01a0155..4141f7efaa 100644 --- a/services/git-token-service/src/github-user-authorization-service.test.ts +++ b/services/git-token-service/src/github-user-authorization-service.test.ts @@ -199,6 +199,28 @@ describe('GitHubUserAuthorizationService envelope selection', () => { ).resolves.toEqual({ selected: false, reason: 'credential_unreadable' }); }); + it('revokes the current generation when the refresh token is rejected during selection', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); + database.rows = [row]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ error: 'bad_refresh_token' })) + ); + + const result = await makeService().selectUserAuthorization({ + userId: 'user_1', + githubRepo: 'acme/repo', + }); + + expect(result).toEqual({ selected: false, reason: 'revoked' }); + expect(database.updates).toHaveLength(1); + expect(database.updates[0]).toMatchObject({ + revoked_at: expect.any(String), + revocation_reason: 'github_token_rejected', + }); + }); + it('classifies an unknown envelope key id as unreadable', async () => { database.rows = [makeRow(retiredPublicKey, 'retired')]; @@ -599,6 +621,41 @@ describe('GitHubUserAuthorizationService.getUserAccessToken', () => { expect(database.lockExecutions).toBe(1); }); + it('strips multiple trailing slashes from the configured OAuth base URL', async () => { + const row = makeRow(); + row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); + database.rows = [row]; + const refreshedRow = { + ...makeRow(activePublicKey, 'active', { + access: 'refreshed-access', + refresh: 'refreshed-refresh', + }), + credential_version: 2, + access_token_expires_at: new Date(Date.now() + 3600 * 1000).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 7200 * 1000).toISOString(), + }; + database.updatedRow = refreshedRow; + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'refreshed-access', + expires_in: 3600, + refresh_token: 'refreshed-refresh', + refresh_token_expires_in: 7200, + }) + ); + vi.stubGlobal('fetch', fetchMock); + + await makeService({ GITHUB_OAUTH_BASE_URL: 'https://github.test///' }).getUserAccessToken( + 'user_1', + { op: 'fetch' } + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://github.test/login/oauth/access_token', + expect.anything() + ); + }); + it('honors the GITHUB_OAUTH_BASE_URL env seam for the refresh request', async () => { const row = makeRow(); row.access_token_expires_at = new Date(Date.now() - 1000).toISOString(); diff --git a/services/git-token-service/src/github-user-authorization-service.ts b/services/git-token-service/src/github-user-authorization-service.ts index 2eb7d028c9..946977eb3f 100644 --- a/services/git-token-service/src/github-user-authorization-service.ts +++ b/services/git-token-service/src/github-user-authorization-service.ts @@ -112,6 +112,7 @@ export class GitHubUserAuthorizationService { ) { const refreshResult = await this.refreshAuthorizationWithLock(db, authorization); if (refreshResult === 'terminal_rejection') { + await this.revokeCurrentGeneration(db, authorization, 'github_token_rejected'); return { selected: false, reason: 'revoked' }; } if (refreshResult === 'transient_failure') { @@ -357,7 +358,11 @@ export class GitHubUserAuthorizationService { private getGitHubOAuthBaseUrl(): string { const baseUrl = this.env.GITHUB_OAUTH_BASE_URL ?? DEFAULT_GITHUB_OAUTH_BASE_URL; - return baseUrl.replace(/\/+$/, ''); + let end = baseUrl.length; + while (end > 0 && baseUrl[end - 1] === '/') { + end -= 1; + } + return baseUrl.slice(0, end); } private getGitHubOAuthAccessTokenUrl(): string {