From 14285868c73fa9531c95c9dd34015ebcaccd85b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 21:14:13 +0200 Subject: [PATCH 1/4] feat(provider-review): define review contracts and vocabulary --- apps/mobile/src/i18n/locales/en.json | 127 ++++++ .../i18n/provider-review-vocabulary.test.ts | 183 +++++++++ .../src/i18n/provider-review-vocabulary.ts | 153 +++++++ .../src/provider-review/contracts.test.ts | 341 ++++++++++++++++ .../src/provider-review/contracts.ts | 384 ++++++++++++++++++ .../src/provider-review/fixtures.ts | 140 +++++++ .../app-shared/src/provider-review/index.ts | 2 + .../intent-fingerprint.test.ts | 299 ++++++++++++++ .../src/provider-review/intent-fingerprint.ts | 60 +++ 9 files changed, 1689 insertions(+) create mode 100644 apps/mobile/src/i18n/provider-review-vocabulary.test.ts create mode 100644 apps/mobile/src/i18n/provider-review-vocabulary.ts create mode 100644 packages/app-shared/src/provider-review/contracts.test.ts create mode 100644 packages/app-shared/src/provider-review/contracts.ts create mode 100644 packages/app-shared/src/provider-review/fixtures.ts create mode 100644 packages/app-shared/src/provider-review/index.ts create mode 100644 packages/app-shared/src/provider-review/intent-fingerprint.test.ts create mode 100644 packages/app-shared/src/provider-review/intent-fingerprint.ts diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index e58d43e4ce..b8fb24cefd 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -1,4 +1,131 @@ { + "providerReview": { + "title": "Review code", + "pullRequest": { + "title": "Pull request review", + "open": "Open pull request", + "review": "Review pull request", + "merge": "Merge pull request", + "confirmMerge": "Merge pull request?", + "paste": "Paste a pull request link", + "urlAccessibility": "Pull request URL", + "clearLink": "Clear pull request link", + "share": "Share pull request", + "badge": "Pull request #{{number}}", + "loading": "Loading pull request", + "unavailable": "This pull request is unavailable. Check the link and your repository access.", + "noReviews": "No pull requests in this scope. Paste a link or select a repository.", + "noDiscussion": "No discussion on this pull request yet. You can add a comment." + }, + "mergeRequest": { + "title": "Merge request review", + "open": "Open merge request", + "review": "Review merge request", + "merge": "Merge merge request", + "confirmMerge": "Merge merge request?", + "paste": "Paste a merge request link", + "urlAccessibility": "Merge request URL", + "clearLink": "Clear merge request link", + "share": "Share merge request", + "badge": "Merge request !{{number}}", + "loading": "Loading merge request", + "unavailable": "This merge request is unavailable. Check the link and your repository access.", + "noReviews": "No merge requests in this scope. Paste a link or select a repository.", + "noDiscussion": "No discussion on this merge request yet. You can add a comment." + }, + "identity": { + "owner": "Kilo owner: {{owner}}", + "actor": "{{provider}} actor: {{actor}}", + "integration": "Connection: {{integration}}", + "instance": "Instance: {{instance}}", + "repository": "Repository: {{repository}}", + "revision": "Revision: {{revision}}" + }, + "connection": { + "connect": "Connect {{provider}}", + "reconnect": "Reconnect {{provider}}", + "expired": "Your {{provider}} connection expired. Reconnect to continue with your saved work.", + "personalBitbucket": "Bitbucket review is available in an organization, not Personal.", + "switchOrganization": "Switch organization", + "loading": "Loading {{provider}}" + }, + "entry": { + "paste": "Paste a pull request or merge request link", + "invalidLink": "This link does not identify a supported review. Correct the link and try again.", + "ambiguous": "More than one connection matches this review. Select the exact connection.", + "noRecents": "No recent reviews. Paste a link to start.", + "openProvider": "View on {{provider}}" + }, + "inbox": { + "actorScope": "Reviews for {{actor}} on {{provider}}", + "repositoryScope": "Reviews in {{repository}} using {{actor}}", + "pageFailed": "Could not load the next page. Your loaded reviews are still available.", + "retryPage": "Retry this page" + }, + "files": { + "empty": "No changed files", + "binary": "This file is binary. View it on {{provider}}.", + "truncated": "This diff is incomplete. View the full diff on {{provider}}.", + "unavailable": "Context is unavailable at this revision. View the file on {{provider}}.", + "contextFailed": "Could not load context. Your file and line selection are unchanged.", + "position": "{{path}}, {{side}} side, line {{line}}, revision {{revision}}" + }, + "checks": { + "empty": "No checks reported. This does not mean the checks passed.", + "unavailable": "Check results are unavailable for this connection.", + "openProvider": "View checks on {{provider}}" + }, + "permission": { + "forbidden": "You do not have permission to perform this action. {{reason}}", + "unsupported": "This provider does not support this action. {{reason}}", + "version": "This action is unavailable on this provider version. {{reason}}", + "license": "This action requires a different provider license. {{reason}}", + "restricted": "A current repository restriction blocks this action. {{reason}}", + "unknown": "The connection has not confirmed whether this action is available.", + "readOnly": "You can read this review. This connection needs additional permission to write.", + "replaceToken": "Replace the connection token" + }, + "draft": { + "stale": "The revision changed. Your text and original position are saved. Refresh before choosing a new position.", + "quarantined": "This saved draft has an unresolved account or repository identity. It has not been attached to this review.", + "emptyQueue": "No comments queued. Add a comment or a review summary.", + "saved": "Your review summary and choice are saved for this revision.", + "refresh": "Refresh review" + }, + "outcome": { + "confirmed": "{{provider}} confirmed this action.", + "accepted": "{{provider}} accepted this action. Completion is not confirmed yet.", + "partial": "Confirmed: {{confirmed}}. Unfinished: {{unfinished}}. Your remaining work is saved.", + "unresolved": "The outcome is unknown. Check the status before sending anything again.", + "retryable": "The action did not start. Your work is saved. You can retry the same action.", + "rejected": "The action was rejected. Your work is saved. {{reason}}", + "checkStatus": "Check action status", + "storageFailed": "Could not save this action safely. Nothing was sent. Your text is still here." + }, + "merge": { + "noMethods": "No merge method is available. {{reason}}", + "confirmDelete": "{{actor}} will merge {{source}} into {{target}} using {{method}} and delete the source branch {{source}}.", + "confirmKeep": "{{actor}} will merge {{source}} into {{target}} using {{method}} and keep the source branch {{source}}.", + "deleteBranch": "Delete source branch {{branch}} after merging", + "pendingTask": "The merge task is still running. You can leave and check its status later.", + "deletionFailed": "The merge is confirmed, but branch deletion failed. {{reason}}", + "deletionUnknown": "The merge is confirmed. Branch deletion is not confirmed.", + "changedHead": "The source or target revision changed. Refresh the review before merging." + }, + "gitlab": { + "approvals": "Approvals needed: {{displayCount}}", + "changesRequested": "Changes requested", + "changesDoNotBlock": "Requested changes are recorded, but this instance does not enforce them as a merge block.", + "squashPolicy": "Squash policy: {{policy}}" + }, + "bitbucket": { + "noExpectedHeadGuard": "Bitbucket cannot atomically bind this action to the reviewed revision. Head checks detect changes but cannot prevent a concurrent update.", + "participantState": "{{actor}}: {{state}}", + "noAutoMerge": "Bitbucket does not provide an API to schedule or cancel automatic merging.", + "noBranchSync": "Bitbucket does not provide an API to synchronize this branch.", + "noReactions": "Bitbucket does not provide comment reactions through this API." + } + }, "common": { "cancel": "Cancel", "done": "Done", diff --git a/apps/mobile/src/i18n/provider-review-vocabulary.test.ts b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts new file mode 100644 index 0000000000..bc9e19669c --- /dev/null +++ b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts @@ -0,0 +1,183 @@ +import { CODE_REVIEW_PLATFORMS } from '@kilocode/app-shared/code-review'; +import { createInstance } from 'i18next'; +import { describe, expect, it } from 'vitest'; + +import en from './locales/en.json'; +import { + PROVIDER_REVIEW_REQUEST_KEYS, + PROVIDER_REVIEW_STATE_KEYS, +} from './provider-review-vocabulary'; + +const i18n = createInstance(); +await i18n.init({ + resources: { en: { translation: en } }, + lng: 'en', + fallbackLng: 'en', + interpolation: { escapeValue: false }, + initAsync: false, + returnNull: false, +}); + +const pullRequestCopy = { + title: 'Pull request review', + open: 'Open pull request', + review: 'Review pull request', + merge: 'Merge pull request', + confirmMerge: 'Merge pull request?', + paste: 'Paste a pull request link', + urlAccessibility: 'Pull request URL', + clearLink: 'Clear pull request link', + share: 'Share pull request', + badge: 'Pull request #42', + loading: 'Loading pull request', + unavailable: 'This pull request is unavailable. Check the link and your repository access.', + noReviews: 'No pull requests in this scope. Paste a link or select a repository.', + noDiscussion: 'No discussion on this pull request yet. You can add a comment.', +}; +const mergeRequestCopy = { + title: 'Merge request review', + open: 'Open merge request', + review: 'Review merge request', + merge: 'Merge merge request', + confirmMerge: 'Merge merge request?', + paste: 'Paste a merge request link', + urlAccessibility: 'Merge request URL', + clearLink: 'Clear merge request link', + share: 'Share merge request', + badge: 'Merge request !42', + loading: 'Loading merge request', + unavailable: 'This merge request is unavailable. Check the link and your repository access.', + noReviews: 'No merge requests in this scope. Paste a link or select a repository.', + noDiscussion: 'No discussion on this merge request yet. You can add a comment.', +}; +const expectedRequestCopy = { + github: pullRequestCopy, + gitlab: mergeRequestCopy, + bitbucket: pullRequestCopy, +}; + +type StringTree = { [key: string]: string | StringTree }; + +function leafEntries(tree: StringTree, prefix = ''): [string, string][] { + return Object.entries(tree).flatMap(([name, value]) => { + const path = prefix ? `${prefix}.${name}` : name; + return typeof value === 'string' ? [[path, value]] : leafEntries(value, path); + }); +} + +describe('provider review vocabulary', () => { + it.each(CODE_REVIEW_PLATFORMS)('renders the request terminology for %s', provider => { + const rendered = Object.fromEntries( + Object.entries(PROVIDER_REVIEW_REQUEST_KEYS[provider]).map(([field, key]) => [ + field, + i18n.t(key, { number: 42 }), + ]) + ); + + expect(rendered).toEqual(expectedRequestCopy[provider]); + }); + + it('resolves exactly the English providerReview leaves without missing or extra keys', () => { + const keys = new Set( + [ + ...leafEntries(PROVIDER_REVIEW_REQUEST_KEYS), + ...leafEntries(PROVIDER_REVIEW_STATE_KEYS), + ].map(([, key]) => key) + ); + const resolved = Object.fromEntries( + [...keys].map(key => [key, i18n.getResource('en', 'translation', key)]) + ); + + expect(resolved).toStrictEqual( + Object.fromEntries(leafEntries(en.providerReview, 'providerReview')) + ); + }); + + it('keeps permission failures distinct from provider limitations and recovery labels', () => { + const rendered = Object.fromEntries( + Object.entries(PROVIDER_REVIEW_STATE_KEYS.permission).map(([state, key]) => [ + state, + i18n.t(key, { reason: 'Repository policy.' }), + ]) + ); + + expect(rendered).toEqual({ + forbidden: 'You do not have permission to perform this action. Repository policy.', + unsupported: 'This provider does not support this action. Repository policy.', + version: 'This action is unavailable on this provider version. Repository policy.', + license: 'This action requires a different provider license. Repository policy.', + restricted: 'A current repository restriction blocks this action. Repository policy.', + unknown: 'The connection has not confirmed whether this action is available.', + readOnly: 'You can read this review. This connection needs additional permission to write.', + replaceToken: 'Replace the connection token', + }); + }); + + it('preserves stale draft positions and keeps unresolved identities quarantined', () => { + const { draft } = PROVIDER_REVIEW_STATE_KEYS; + + expect(i18n.t(draft.stale)).toBe( + 'The revision changed. Your text and original position are saved. Refresh before choosing a new position.' + ); + expect(i18n.t(draft.quarantined)).toBe( + 'This saved draft has an unresolved account or repository identity. It has not been attached to this review.' + ); + expect(i18n.t(draft.refresh)).toBe('Refresh review'); + }); + + it('distinguishes confirmed, pending, partial, retryable, and rejected outcomes', () => { + const rendered = Object.fromEntries( + Object.entries(PROVIDER_REVIEW_STATE_KEYS.outcome).map(([state, key]) => [ + state, + i18n.t(key, { + provider: 'Bitbucket', + confirmed: 2, + unfinished: 1, + reason: 'Access denied.', + }), + ]) + ); + + expect(rendered).toEqual({ + confirmed: 'Bitbucket confirmed this action.', + accepted: 'Bitbucket accepted this action. Completion is not confirmed yet.', + partial: 'Confirmed: 2. Unfinished: 1. Your remaining work is saved.', + unresolved: 'The outcome is unknown. Check the status before sending anything again.', + retryable: 'The action did not start. Your work is saved. You can retry the same action.', + rejected: 'The action was rejected. Your work is saved. Access denied.', + checkStatus: 'Check action status', + storageFailed: + 'Could not save this action safely. Nothing was sent. Your text is still here.', + }); + }); + + it('does not present missing checks or an empty comment queue as completed work', () => { + expect(i18n.t(PROVIDER_REVIEW_STATE_KEYS.checks.empty)).toBe( + 'No checks reported. This does not mean the checks passed.' + ); + expect(i18n.t(PROVIDER_REVIEW_STATE_KEYS.draft.emptyQueue)).toBe( + 'No comments queued. Add a comment or a review summary.' + ); + }); + + it('preserves provider limits and separates merge completion from branch deletion', () => { + const { bitbucket, gitlab, merge } = PROVIDER_REVIEW_STATE_KEYS; + + expect(i18n.t(gitlab.approvals, { displayCount: '2' })).toBe('Approvals needed: 2'); + expect(i18n.t(gitlab.changesDoNotBlock)).toBe( + 'Requested changes are recorded, but this instance does not enforce them as a merge block.' + ); + expect(i18n.t(bitbucket.noExpectedHeadGuard)).toBe( + 'Bitbucket cannot atomically bind this action to the reviewed revision. Head checks detect changes but cannot prevent a concurrent update.' + ); + expect(i18n.t(merge.pendingTask)).toBe( + 'The merge task is still running. You can leave and check its status later.' + ); + expect(i18n.t(merge.deletionFailed, { reason: 'Protected branch.' })).toBe( + 'The merge is confirmed, but branch deletion failed. Protected branch.' + ); + expect(i18n.t(merge.deletionUnknown)).toBe( + 'The merge is confirmed. Branch deletion is not confirmed.' + ); + }); +}); diff --git a/apps/mobile/src/i18n/provider-review-vocabulary.ts b/apps/mobile/src/i18n/provider-review-vocabulary.ts new file mode 100644 index 0000000000..e7f26f79c7 --- /dev/null +++ b/apps/mobile/src/i18n/provider-review-vocabulary.ts @@ -0,0 +1,153 @@ +import { type CodeReviewPlatform } from '@kilocode/app-shared/code-review'; +import { type ParseKeys } from 'i18next'; + +import type en from './locales/en.json'; + +type ProviderReviewKey = Extract; +type RequestVocabulary = { + readonly [Key in keyof typeof en.providerReview.pullRequest]: Extract< + ProviderReviewKey, + `providerReview.${'pullRequest' | 'mergeRequest'}.${Key}` + >; +}; + +const pullRequest = { + title: 'providerReview.pullRequest.title', + open: 'providerReview.pullRequest.open', + review: 'providerReview.pullRequest.review', + merge: 'providerReview.pullRequest.merge', + confirmMerge: 'providerReview.pullRequest.confirmMerge', + paste: 'providerReview.pullRequest.paste', + urlAccessibility: 'providerReview.pullRequest.urlAccessibility', + clearLink: 'providerReview.pullRequest.clearLink', + share: 'providerReview.pullRequest.share', + badge: 'providerReview.pullRequest.badge', + loading: 'providerReview.pullRequest.loading', + unavailable: 'providerReview.pullRequest.unavailable', + noReviews: 'providerReview.pullRequest.noReviews', + noDiscussion: 'providerReview.pullRequest.noDiscussion', +} as const; + +const mergeRequest = { + title: 'providerReview.mergeRequest.title', + open: 'providerReview.mergeRequest.open', + review: 'providerReview.mergeRequest.review', + merge: 'providerReview.mergeRequest.merge', + confirmMerge: 'providerReview.mergeRequest.confirmMerge', + paste: 'providerReview.mergeRequest.paste', + urlAccessibility: 'providerReview.mergeRequest.urlAccessibility', + clearLink: 'providerReview.mergeRequest.clearLink', + share: 'providerReview.mergeRequest.share', + badge: 'providerReview.mergeRequest.badge', + loading: 'providerReview.mergeRequest.loading', + unavailable: 'providerReview.mergeRequest.unavailable', + noReviews: 'providerReview.mergeRequest.noReviews', + noDiscussion: 'providerReview.mergeRequest.noDiscussion', +} as const; + +/** Pass these keys to the active translator; do not cache translated text. */ +export const PROVIDER_REVIEW_REQUEST_KEYS = { + github: pullRequest, + gitlab: mergeRequest, + bitbucket: pullRequest, +} as const satisfies Record; + +/** State messages stay separate from request terminology and action availability. */ +export const PROVIDER_REVIEW_STATE_KEYS = { + title: 'providerReview.title', + identity: { + owner: 'providerReview.identity.owner', + actor: 'providerReview.identity.actor', + integration: 'providerReview.identity.integration', + instance: 'providerReview.identity.instance', + repository: 'providerReview.identity.repository', + revision: 'providerReview.identity.revision', + }, + connection: { + connect: 'providerReview.connection.connect', + reconnect: 'providerReview.connection.reconnect', + expired: 'providerReview.connection.expired', + personalBitbucket: 'providerReview.connection.personalBitbucket', + switchOrganization: 'providerReview.connection.switchOrganization', + loading: 'providerReview.connection.loading', + }, + entry: { + paste: 'providerReview.entry.paste', + invalidLink: 'providerReview.entry.invalidLink', + ambiguous: 'providerReview.entry.ambiguous', + noRecents: 'providerReview.entry.noRecents', + openProvider: 'providerReview.entry.openProvider', + }, + inbox: { + actorScope: 'providerReview.inbox.actorScope', + repositoryScope: 'providerReview.inbox.repositoryScope', + pageFailed: 'providerReview.inbox.pageFailed', + retryPage: 'providerReview.inbox.retryPage', + }, + files: { + empty: 'providerReview.files.empty', + binary: 'providerReview.files.binary', + truncated: 'providerReview.files.truncated', + unavailable: 'providerReview.files.unavailable', + contextFailed: 'providerReview.files.contextFailed', + position: 'providerReview.files.position', + }, + checks: { + empty: 'providerReview.checks.empty', + unavailable: 'providerReview.checks.unavailable', + openProvider: 'providerReview.checks.openProvider', + }, + permission: { + forbidden: 'providerReview.permission.forbidden', + unsupported: 'providerReview.permission.unsupported', + version: 'providerReview.permission.version', + license: 'providerReview.permission.license', + restricted: 'providerReview.permission.restricted', + unknown: 'providerReview.permission.unknown', + readOnly: 'providerReview.permission.readOnly', + replaceToken: 'providerReview.permission.replaceToken', + }, + draft: { + stale: 'providerReview.draft.stale', + quarantined: 'providerReview.draft.quarantined', + emptyQueue: 'providerReview.draft.emptyQueue', + saved: 'providerReview.draft.saved', + refresh: 'providerReview.draft.refresh', + }, + outcome: { + confirmed: 'providerReview.outcome.confirmed', + accepted: 'providerReview.outcome.accepted', + partial: 'providerReview.outcome.partial', + unresolved: 'providerReview.outcome.unresolved', + retryable: 'providerReview.outcome.retryable', + rejected: 'providerReview.outcome.rejected', + checkStatus: 'providerReview.outcome.checkStatus', + storageFailed: 'providerReview.outcome.storageFailed', + }, + merge: { + noMethods: 'providerReview.merge.noMethods', + confirmDelete: 'providerReview.merge.confirmDelete', + confirmKeep: 'providerReview.merge.confirmKeep', + deleteBranch: 'providerReview.merge.deleteBranch', + pendingTask: 'providerReview.merge.pendingTask', + deletionFailed: 'providerReview.merge.deletionFailed', + deletionUnknown: 'providerReview.merge.deletionUnknown', + changedHead: 'providerReview.merge.changedHead', + }, + gitlab: { + approvals: 'providerReview.gitlab.approvals', + changesRequested: 'providerReview.gitlab.changesRequested', + changesDoNotBlock: 'providerReview.gitlab.changesDoNotBlock', + squashPolicy: 'providerReview.gitlab.squashPolicy', + }, + bitbucket: { + noExpectedHeadGuard: 'providerReview.bitbucket.noExpectedHeadGuard', + participantState: 'providerReview.bitbucket.participantState', + noAutoMerge: 'providerReview.bitbucket.noAutoMerge', + noBranchSync: 'providerReview.bitbucket.noBranchSync', + noReactions: 'providerReview.bitbucket.noReactions', + }, +} as const satisfies Record< + string, + ProviderReviewKey | Readonly> +>; diff --git a/packages/app-shared/src/provider-review/contracts.test.ts b/packages/app-shared/src/provider-review/contracts.test.ts new file mode 100644 index 0000000000..401ac33766 --- /dev/null +++ b/packages/app-shared/src/provider-review/contracts.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, it } from 'vitest'; +import { CODE_REVIEW_PLATFORMS } from '../code-review/enums'; +import { normalizeLegacyGitHubReviewRepository } from '../code-review/repository-identity'; +import { + BitbucketMergeTaskSchema, + ProviderReferenceSchema, + ProviderReviewStateSchema, + ReviewActionSchema, + ReviewCapabilitiesSchema, + ReviewCapabilitySchema, + ReviewChecksSchema, + ReviewMutationResultSchema, + ReviewPositionSchema, + ReviewRevisionSchema, + parseReviewCursor, + reviewActionAvailability, + reviewPageKey, + reviewResourceKey, + serializeReviewWriteRequest, + type ReviewPageScope, +} from './contracts'; +import { + availableCapabilityFixture, + providerReviewFixtures, + reviewCapabilityFixtures, +} from './fixtures'; + +const revision = { headSha: 'head', baseSha: 'base', startSha: 'start', targetHeadSha: 'target' }; +const reference = { provider: 'bitbucket', kind: 'merge-task', id: 'task:opaque', url: null }; +const scope: ReviewPageScope = { + resourceKey: reviewResourceKey('account', providerReviewFixtures.gitlab.org), + surface: 'files', + queryKey: 'all', + revision, +}; +const confirmed = { + status: 'confirmed', + reference: null, + retry: 'never', + reconciliation: 'complete', +}; +const unresolved = { + status: 'unresolved', + reference, + reason: 'revision-unproven', + retry: 'reconcile', + reconciliation: 'required', +}; + +describe('normalized provider review contracts', () => { + it.each(CODE_REVIEW_PLATFORMS)('isolates %s pages from other providers and owners', provider => { + for (const review of Object.values(providerReviewFixtures[provider])) { + if (review === null) continue; + const ownScope = { ...scope, resourceKey: reviewResourceKey('account', review) }; + const cursor = { scopeKey: reviewPageKey(ownScope), token: 'page:2' }; + expect(parseReviewCursor(cursor, ownScope).token).toBe('page:2'); + expect(() => + parseReviewCursor(cursor, { + ...ownScope, + resourceKey: reviewResourceKey('account', { ...review, reviewId: 'different' }), + }) + ).toThrow('identity'); + if (review.authorization.kind === 'ownerIntegration') { + const other = { + ...review, + authorization: { + ...review.authorization, + owner: { ...review.authorization.owner, id: 'other-owner' }, + }, + }; + expect(() => + parseReviewCursor(cursor, { + ...ownScope, + resourceKey: reviewResourceKey('account', other), + }) + ).toThrow('identity'); + } + } + }); + + it('rejects installation authorization for GitHub reviews and Personal Bitbucket', () => { + const github = providerReviewFixtures.github.user; + const bitbucket = providerReviewFixtures.bitbucket.org; + const authorization = { + kind: 'ownerIntegration', + owner: { type: 'user', id: 'owner' }, + integrationId: 'integration', + } as const; + expect(() => reviewResourceKey('account', { ...github, authorization })).toThrow( + 'user authorization' + ); + expect(() => reviewResourceKey('account', { ...bitbucket, authorization })).toThrow( + 'organization' + ); + }); + + it('normalizes absent legacy additions without inventing a default branch or authorization', () => { + const old = { + accountId: 'account', + repository: { repositoryId: 'R_1', fullName: 'Team/Repo' }, + authorization: { kind: 'githubUser', accountId: 'account', authorizationId: 'user-auth' }, + } as const; + const resolved = normalizeLegacyGitHubReviewRepository(old); + expect(resolved).toMatchObject({ + kind: 'resolved', + reference: { + repository: { provider: 'github', instanceUrl: 'https://github.com', defaultBranch: null }, + }, + }); + expect(normalizeLegacyGitHubReviewRepository({ ...old, authorization: null })).toMatchObject({ + kind: 'legacy-unresolved', + repository: old.repository, + }); + }); + + it('requires explicit nullable revision fields and opaque provider IDs', () => { + expect(ReviewRevisionSchema.safeParse({ headSha: 'head' }).success).toBe(false); + expect( + ReviewRevisionSchema.parse({ + ...revision, + baseSha: null, + startSha: null, + targetHeadSha: null, + }) + ).toMatchObject({ baseSha: null, startSha: null, targetHeadSha: null }); + expect(ProviderReferenceSchema.safeParse({ ...reference, id: 42 }).success).toBe(false); + expect(ProviderReferenceSchema.parse(reference).id).toBe('task:opaque'); + }); + + it.each([ + null, + {}, + { scopeKey: '', token: '2' }, + { scopeKey: 'foreign', token: '2' }, + { scopeKey: reviewPageKey(scope), token: 2 }, + { scopeKey: reviewPageKey(scope), token: '' }, + { scopeKey: reviewPageKey(scope), token: '2', repository: 'injected' }, + ])('rejects malformed or foreign pagination identity: %j', cursor => { + expect(() => parseReviewCursor(cursor, scope)).toThrow(); + }); + it.each([ + { revision: { ...revision, headSha: 'changed' } }, + { revision: { ...revision, targetHeadSha: 'changed' } }, + { queryKey: 'different-filter' }, + { surface: 'threads' as const }, + ])('does not reuse a page after its scope changes: %j', change => { + expect(() => + parseReviewCursor({ scopeKey: reviewPageKey(scope), token: '2' }, { ...scope, ...change }) + ).toThrow('identity'); + }); + + it.each(CODE_REVIEW_PLATFORMS)( + 'retains supported %s actions when a write grant is missing', + provider => { + const capabilities = reviewCapabilityFixtures(provider); + for (const action of ReviewActionSchema.options) { + const capability = capabilities[action]; + const denied = { ...capability, permission: 'forbidden' as const }; + expect(reviewActionAvailability(denied)).toBe( + capability.support === 'supported' ? 'forbidden' : capability.support + ); + } + expect(reviewActionAvailability(capabilities.read)).toBe('available'); + expect( + ReviewCapabilitiesSchema.safeParse({ ...capabilities, reply: undefined }).success + ).toBe(false); + } + ); + it.each([ + [{ support: 'unknown' }, 'unknown'], + [{ permission: 'forbidden' }, 'forbidden'], + [{ version: 'unavailable' }, 'version'], + [{ license: 'unavailable' }, 'license'], + [{ restrictions: ['required-check'] }, 'restricted'], + ] as const)('keeps a distinct capability recovery state: %j', (change, expected) => { + expect( + reviewActionAvailability({ + ...availableCapabilityFixture, + ...change, + restrictions: 'restrictions' in change ? [...change.restrictions] : [], + }) + ).toBe(expected); + }); + it('requires evidence for unsupported actions instead of disguising missing implementation', () => { + expect( + ReviewCapabilitySchema.safeParse({ ...availableCapabilityFixture, support: 'unsupported' }) + .success + ).toBe(false); + expect(reviewActionAvailability(reviewCapabilityFixtures('bitbucket').enableAutoMerge)).toBe( + 'unsupported' + ); + }); + + it('keeps GitLab approvals independent from requested changes and licensed blocking', () => { + const state = { + provider: 'gitlab', + approvals: { approved: true, required: null, remaining: 0, actorIds: ['actor'] }, + requestedChanges: { + actorIds: ['reviewer'], + blocksMerge: false, + blockingCapability: { ...availableCapabilityFixture, license: 'unavailable' }, + }, + }; + expect(ProviderReviewStateSchema.parse(state)).toEqual(state); + expect(ProviderReviewStateSchema.safeParse({ ...state, decision: 'APPROVED' }).success).toBe( + false + ); + expect(reviewActionAvailability(reviewCapabilityFixtures('gitlab').requestChanges)).toBe( + 'available' + ); + }); + it('retains Bitbucket participant state without claiming an atomic head guard', () => { + const state = { + provider: 'bitbucket', + expectedHeadProtection: 'none', + participants: [ + { + actor: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + id: '{actor}', + displayName: null, + login: null, + avatarUrl: null, + }, + role: 'REVIEWER', + state: null, + participatedOn: null, + }, + ], + }; + expect(ProviderReviewStateSchema.parse(state)).toEqual(state); + expect( + ProviderReviewStateSchema.safeParse({ ...state, expectedHeadProtection: 'atomicSource' }) + .success + ).toBe(false); + }); + it('does not report empty checks as a successful check report', () => { + expect(ReviewChecksSchema.safeParse({ status: 'reported', checks: [] }).success).toBe(false); + expect(ReviewChecksSchema.parse({ status: 'none', checks: [] })).toEqual({ + status: 'none', + checks: [], + }); + }); + + it.each([ + { provider: 'github' }, + { + provider: 'gitlab', + oldLine: null, + newLine: 9, + lineRange: { + start: { lineCode: 'old_4_4', side: 'new', oldLine: 4, newLine: 4 }, + end: { lineCode: 'new_9_9', side: 'new', oldLine: 9, newLine: 9 }, + }, + }, + { provider: 'bitbucket', to: 9, startTo: 4 }, + { provider: 'bitbucket', from: 9, startFrom: 4 }, + ])('preserves native range fields for $provider without retargeting', native => { + const position = { + revision, + oldPath: 'old.ts', + newPath: 'new.ts', + side: 'from' in native ? 'old' : 'new', + line: 9, + startLine: 4, + startSide: 'from' in native ? 'old' : 'new', + native, + }; + expect(ReviewPositionSchema.parse(position)).toEqual(position); + expect(ReviewPositionSchema.safeParse({ ...position, startSide: undefined }).success).toBe( + false + ); + if (native.provider === 'gitlab') + expect( + ReviewPositionSchema.safeParse({ ...position, revision: { ...revision, baseSha: null } }) + .success + ).toBe(false); + }); + + it('retains accepted tasks and separate unfinished batch effects across serialization', () => { + const task = { reference, state: 'pending', mergeCommitSha: null, error: null }; + const accepted = { + status: 'accepted', + reference, + task, + retry: 'reconcile', + reconciliation: 'pending', + }; + const partial = { + status: 'partial', + items: [ + { itemId: 'merge', effect: 'merge', result: confirmed }, + { itemId: 'delete', effect: 'deleteBranch', result: unresolved }, + ], + retry: 'unfinished-only', + reconciliation: 'required', + }; + for (const result of [confirmed, accepted, partial, unresolved]) { + expect(ReviewMutationResultSchema.parse(JSON.parse(JSON.stringify(result)))).toEqual(result); + } + expect( + BitbucketMergeTaskSchema.safeParse({ + ...task, + reference: { ...reference, provider: 'github' }, + }).success + ).toBe(false); + expect(ReviewMutationResultSchema.safeParse({ ...accepted, retry: 'same-key' }).success).toBe( + false + ); + expect(ReviewMutationResultSchema.safeParse({ ...unresolved, retry: 'same-key' }).success).toBe( + false + ); + }); + it.each(['same-key', 'never'])('retains the rejected action retry policy: %s', retry => { + const result = { + status: 'rejected', + code: 'denied-or-transient', + explanation: 'Saved work', + retry, + reconciliation: 'not-needed', + }; + expect(ReviewMutationResultSchema.parse(JSON.parse(JSON.stringify(result)))).toEqual(result); + }); + it.each([ + 'é'.repeat(127_999), + '\u4e2d'.repeat(85_332) + 'aa', + '\u{20000}'.repeat(63_999) + 'aa', + '%'.repeat(255_998), + '\ud800'.repeat(42_666) + 'aa', + ])('enforces the exact serialized UTF-8 ceiling without truncation', body => { + expect(JSON.parse(serializeReviewWriteRequest(body))).toBe(body); + expect(() => serializeReviewWriteRequest(`${body}a`)).toThrow('byte limit'); + }); + it('counts the complete request envelope, not only the comment text', () => { + const request = { body: 'a'.repeat(255_989) }; + expect(JSON.parse(serializeReviewWriteRequest(request))).toEqual(request); + expect(() => serializeReviewWriteRequest({ body: `${request.body}a` })).toThrow('byte limit'); + expect(() => serializeReviewWriteRequest(undefined)).toThrow(); + }); +}); diff --git a/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts new file mode 100644 index 0000000000..ec04c03630 --- /dev/null +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -0,0 +1,384 @@ +import { z } from 'zod'; +import { CODE_REVIEW_PLATFORMS } from '../code-review/enums'; +import type { RepositoryIdentity, RepositoryReference } from '../code-review/repository-identity'; +import { repositoryResourceKey } from '../code-review/repository-identity'; + +const id = z.string().min(1); +const line = z.number().int().positive(); +const side = z.enum(['old', 'new']); +const url = z.url({ protocol: /^https$/ }); + +// IDs remain opaque. Only adapters interpret provider-native references. +export const ProviderReferenceSchema = z.strictObject({ + provider: z.enum(CODE_REVIEW_PLATFORMS), + kind: z.enum(['review', 'comment', 'thread', 'reaction', 'merge-task']), + id, + url: url.nullable(), +}); +export type ProviderReference = z.infer; +export type ReviewIdentity = RepositoryReference & { + reviewId: string; + number: string; + canonicalUrl: string; // Derived by the authorized server, never an authorization input. +}; + +export function reviewResourceKey(accountId: string, review: ReviewIdentity): string { + const { repository, authorization } = review; + if (repository.provider === 'github' && authorization.kind !== 'githubUser') { + throw new Error('GitHub review requires user authorization'); + } + if ( + repository.provider === 'bitbucket' && + authorization.kind === 'ownerIntegration' && + authorization.owner.type !== 'org' + ) { + throw new Error('Bitbucket review requires an organization'); + } + return JSON.stringify([ + 'provider-review:v1', + repositoryResourceKey(accountId, review), + id.parse(review.reviewId), + id.parse(review.number), + ]); +} + +export const ReviewActorSchema = z.strictObject({ + provider: z.enum(CODE_REVIEW_PLATFORMS), + instanceUrl: url, + id, + displayName: z.string().nullable(), + login: z.string().nullable(), + avatarUrl: url.nullable(), +}); +export type ReviewActor = z.infer; +export const ReviewRevisionSchema = z.strictObject({ + headSha: id, + baseSha: id.nullable(), + startSha: id.nullable(), + targetHeadSha: id.nullable(), +}); +export type ReviewRevision = z.infer; +const rangeEnd = z.strictObject({ + lineCode: id, + side, + oldLine: line.nullable(), + newLine: line.nullable(), +}); +export const ReviewPositionSchema = z + .strictObject({ + revision: ReviewRevisionSchema, + oldPath: id.nullable(), + newPath: id.nullable(), + side, + line, + startLine: line.optional(), + startSide: side.optional(), + native: z.discriminatedUnion('provider', [ + z.strictObject({ provider: z.literal('github') }), + z.strictObject({ + provider: z.literal('gitlab'), + oldLine: line.nullable(), + newLine: line.nullable(), + lineRange: z.strictObject({ start: rangeEnd, end: rangeEnd }).optional(), + }), + z.strictObject({ + provider: z.literal('bitbucket'), + from: line.optional(), + to: line.optional(), + startFrom: line.optional(), + startTo: line.optional(), + }), + ]), + }) + .refine( + position => + (position.startLine === undefined) === (position.startSide === undefined) && + (position.side === 'old' ? position.oldPath !== null : position.newPath !== null) && + (position.native.provider !== 'gitlab' || + (position.revision.baseSha !== null && position.revision.startSha !== null)), + 'Incomplete review position' + ); +export type ReviewPosition = z.infer; + +export const ReviewActionSchema = z.enum([ + 'read', + 'comment', + 'inlineComment', + 'reply', + 'submitReview', + 'approve', + 'unapprove', + 'requestChanges', + 'removeChangeRequest', + 'resolveThread', + 'reopenThread', + 'addReaction', + 'removeReaction', + 'merge', + 'deleteBranch', + 'updateBranch', + 'enableAutoMerge', + 'disableAutoMerge', +]); +export type ReviewAction = z.infer; +export const ReviewCapabilitySchema = z + .strictObject({ + support: z.enum(['supported', 'unsupported', 'unknown']), + version: z.enum(['available', 'unavailable', 'unknown']), + license: z.enum(['available', 'unavailable', 'unknown']), + permission: z.enum(['allowed', 'forbidden', 'unknown']), + restrictions: z.array(id), + explanation: z.string(), + evidenceUrl: url.nullable(), + recovery: z.enum([ + 'none', + 'reconnect', + 'replaceToken', + 'refresh', + 'openProvider', + 'switchOrganization', + ]), + expectedHeadProtection: z.enum(['atomicSource', 'revisionAttachment', 'none', 'unknown']), + }) + .refine( + value => + value.support !== 'unsupported' || + (value.evidenceUrl !== null && value.explanation.length > 0), + 'Unsupported capabilities require provider evidence and an explanation' + ); +export type ReviewCapability = z.infer; +export const ReviewCapabilitiesSchema = z.record(ReviewActionSchema, ReviewCapabilitySchema); +export type ReviewCapabilities = z.infer; +export function reviewActionAvailability(capability: ReviewCapability) { + if (capability.support !== 'supported') return capability.support; + if (capability.version !== 'available') + return capability.version === 'unknown' ? 'unknown' : 'version'; + if (capability.license !== 'available') + return capability.license === 'unknown' ? 'unknown' : 'license'; + if (capability.permission !== 'allowed') return capability.permission; + return capability.restrictions.length > 0 ? 'restricted' : 'available'; +} +export type ReviewAuthorizationContext = { + actor: ReviewActor; + credentialKind: + | 'githubUser' + | 'gitlabOAuth' + | 'gitlabPat' + | 'gitlabProjectToken' + | 'bitbucketOAuth' + | 'bitbucketWorkspaceToken'; + capabilities: ReviewCapabilities; + writeLimits: ReviewWriteLimits; +}; + +export const ProviderReviewStateSchema = z.discriminatedUnion('provider', [ + z.strictObject({ + provider: z.literal('github'), + decision: z.enum(['REVIEW_REQUIRED', 'APPROVED', 'CHANGES_REQUESTED']).nullable(), + }), + z.strictObject({ + provider: z.literal('gitlab'), + approvals: z.strictObject({ + approved: z.boolean().nullable(), + required: z.number().int().nonnegative().nullable(), + remaining: z.number().int().nonnegative().nullable(), + actorIds: z.array(id), + }), + requestedChanges: z.strictObject({ + actorIds: z.array(id), + blocksMerge: z.boolean().nullable(), + blockingCapability: ReviewCapabilitySchema, + }), + }), + z.strictObject({ + provider: z.literal('bitbucket'), + expectedHeadProtection: z.literal('none'), + participants: z.array( + z.strictObject({ + actor: ReviewActorSchema, + role: id, + state: z.enum(['approved', 'changes_requested']).nullable(), + participatedOn: z.string().nullable(), + }) + ), + }), +]); +export const BitbucketMergeTaskSchema = z.strictObject({ + reference: ProviderReferenceSchema.extend({ + provider: z.literal('bitbucket'), + kind: z.literal('merge-task'), + }), + state: z.enum(['pending', 'success', 'failed']), + mergeCommitSha: id.nullable(), + error: z.string().nullable(), +}); +export type BitbucketMergeTask = z.infer; +export const ReviewCheckSchema = z.strictObject({ + id, + name: id, + state: z.enum(['pending', 'running', 'passed', 'failed', 'skipped', 'cancelled', 'unknown']), + required: z.boolean().nullable(), + detailsUrl: url.nullable(), +}); +export const ReviewChecksSchema = z.discriminatedUnion('status', [ + z.strictObject({ status: z.literal('none'), checks: z.tuple([]) }), + z.strictObject({ status: z.literal('unavailable'), explanation: id }), + z.strictObject({ status: z.literal('reported'), checks: z.array(ReviewCheckSchema).min(1) }), +]); + +export type ReviewPageScope = { + resourceKey: string; + surface: 'inbox' | 'files' | 'context' | 'checks' | 'threads'; + queryKey: string; + revision: ReviewRevision | null; +}; +export const ReviewCursorSchema = z.strictObject({ scopeKey: id, token: id.max(4096) }); +export type ReviewCursor = z.infer; +export type ReviewPage = { items: T[]; nextCursor: ReviewCursor | null }; +export function reviewPageKey(scope: ReviewPageScope): string { + return JSON.stringify([ + 'provider-review-page:v1', + id.parse(scope.resourceKey), + scope.surface, + id.parse(scope.queryKey), + scope.revision === null ? null : ReviewRevisionSchema.parse(scope.revision), + ]); +} +// Parse persisted or client-supplied cursors, not trusted provider page objects. +export function parseReviewCursor(value: unknown, scope: ReviewPageScope): ReviewCursor { + const cursor = ReviewCursorSchema.parse(value); + if (cursor.scopeKey !== reviewPageKey(scope)) throw new Error('Pagination identity mismatch'); + return cursor; +} + +export type ReviewFile = { + id: string; + oldPath: string | null; + newPath: string | null; + revision: ReviewRevision; + status: 'added' | 'deleted' | 'modified' | 'renamed' | 'copied' | 'changed'; + patch: string | null; + content: 'available' | 'binary' | 'truncated' | 'unavailable'; + additions: number; + deletions: number; + canonicalUrl: string | null; +}; +export type ReviewFileContext = { + revision: ReviewRevision; + path: string; + side: 'old' | 'new'; + startLine: number; + lines: string[]; + totalLines: number | null; + content: ReviewFile['content']; + canonicalUrl: string | null; +}; +export type ReviewComment = { + id: string; + reference: ProviderReference; + author: ReviewActor | null; + bodyMarkdown: string; + createdAt: string; + reactions: { id: string; content: string; count: number; viewerHasReacted: boolean }[]; +}; +export type ReviewThread = { + id: string; + reference: ProviderReference; + subjectType: 'line' | 'file' | 'conversation'; + file: Pick | null; + position: ReviewPosition | null; + diffHunk: string | null; + resolved: boolean | null; + outdated: boolean | null; + comments: ReviewPage; + capabilities: Partial; +}; +export type ReviewOverview = { + identity: ReviewIdentity; + title: string; + bodyMarkdown: string | null; + author: ReviewActor | null; + state: 'open' | 'closed' | 'merged'; + draft: boolean; + revision: ReviewRevision; + source: { repository: RepositoryIdentity | null; branch: string | null }; + target: { repository: RepositoryIdentity; branch: string }; + authorization: ReviewAuthorizationContext; + providerState: z.infer; + checks: z.infer; + counts: { commits: number; files: number; additions: number; deletions: number }; + merge: { + methods: { id: string; label: string }[]; + squash: 'required' | 'optional' | 'forbidden' | null; + autoMerge: { method: string } | null; + task: BitbucketMergeTask | null; + }; +}; +export type ReviewInboxItem = Pick< + ReviewOverview, + 'identity' | 'title' | 'author' | 'state' | 'draft' +> & { + updatedAt: string; +}; +export type ReviewInbox = ReviewPage & { + scope: + | { kind: 'actor'; actor: ReviewActor } + | { kind: 'repository'; actor: ReviewActor; repository: RepositoryIdentity }; +}; + +export const ReviewEffectResultSchema = z.discriminatedUnion('status', [ + z.strictObject({ + status: z.literal('confirmed'), + reference: ProviderReferenceSchema.nullable(), + retry: z.literal('never'), + reconciliation: z.literal('complete'), + }), + z.strictObject({ + status: z.literal('accepted'), + reference: ProviderReferenceSchema, + task: BitbucketMergeTaskSchema.nullable(), + retry: z.literal('reconcile'), + reconciliation: z.literal('pending'), + }), + z.strictObject({ + status: z.literal('unresolved'), + reference: ProviderReferenceSchema.nullable(), + reason: id, + retry: z.literal('reconcile'), + reconciliation: z.literal('required'), + }), + z.strictObject({ + status: z.literal('rejected'), + code: id, + explanation: id, + retry: z.enum(['same-key', 'never']), + reconciliation: z.literal('not-needed'), + }), +]); +export const ReviewMutationResultSchema = z.union([ + ReviewEffectResultSchema, + z.strictObject({ + status: z.literal('partial'), + items: z + .array( + z.strictObject({ itemId: id, effect: ReviewActionSchema, result: ReviewEffectResultSchema }) + ) + .min(1), + retry: z.literal('unfinished-only'), + reconciliation: z.literal('required'), + }), +]); +export type ReviewMutationResult = z.infer; + +// Only the new interactive transport uses this limit. Legacy endpoints retain their limits. +export const REVIEW_WRITE_REQUEST_MAX_BYTES = 256_000; +export type ReviewWriteLimits = { requestMaxBytes: number; bodyMaxBytes: number | null }; +export function serializeReviewWriteRequest(value: unknown): string { + const serialized = z.string().parse(JSON.stringify(value)); + // JSON escapes lone surrogates. Each URI escape represents one UTF-8 byte, without a platform encoder. + const bytes = encodeURIComponent(serialized).replace(/%[0-9A-F]{2}/g, 'x').length; + if (bytes > REVIEW_WRITE_REQUEST_MAX_BYTES) { + throw new Error('Review request exceeds the serialized byte limit'); + } + return serialized; +} diff --git a/packages/app-shared/src/provider-review/fixtures.ts b/packages/app-shared/src/provider-review/fixtures.ts new file mode 100644 index 0000000000..5c9ad72a32 --- /dev/null +++ b/packages/app-shared/src/provider-review/fixtures.ts @@ -0,0 +1,140 @@ +import type { CodeReviewPlatform } from '../code-review/enums'; +import type { Owner, RepositoryIdentity } from '../code-review/repository-identity'; +import { + ReviewCapabilitiesSchema, + ReviewActionSchema, + type ReviewAction, + type ReviewCapability, + type ReviewIdentity, +} from './contracts'; + +// Test inputs only. Adapters must obtain capabilities from the actual authorized provider. +const repositories = { + github: { + provider: 'github', + instanceUrl: 'https://github.com', + repositoryId: 'R_repo', + fullName: 'Team/Repo', + defaultBranch: null, + }, + gitlab: { + provider: 'gitlab', + instanceUrl: 'https://git.example/GitLab', + repositoryId: 'project:42', + fullName: 'Group/Sub/Repo', + defaultBranch: 'trunk', + }, + bitbucket: { + provider: 'bitbucket', + instanceUrl: 'https://bitbucket.org', + repositoryId: '{22222222-2222-4222-8222-222222222222}', + workspaceUuid: '{11111111-1111-4111-8111-111111111111}', + fullName: 'Workspace/Repo', + defaultBranch: null, + }, +} satisfies Record; +function reference(provider: CodeReviewPlatform, type: Owner['type']): ReviewIdentity { + const repository = repositories[provider]; + const review = { + reviewId: 'review:7', + number: '7', + canonicalUrl: `${repository.instanceUrl}/${repository.fullName}/${provider === 'gitlab' ? '-/merge_requests' : provider === 'github' ? 'pull' : 'pull-requests'}/7`, + }; + if (repository.provider === 'github') { + return { + ...review, + repository, + authorization: { kind: 'githubUser', accountId: 'account', authorizationId: 'user-auth' }, + }; + } + return { + ...review, + repository, + authorization: { + kind: 'ownerIntegration', + owner: { type, id: 'owner' }, + integrationId: 'integration', + }, + }; +} +export const providerReviewFixtures = { + github: { user: reference('github', 'user'), org: reference('github', 'org') }, + gitlab: { user: reference('gitlab', 'user'), org: reference('gitlab', 'org') }, + bitbucket: { user: null, org: reference('bitbucket', 'org') }, +} satisfies Record>; + +const supported = { + read: 'supported', + comment: 'supported', + inlineComment: 'supported', + reply: 'supported', + submitReview: 'supported', + approve: 'supported', + unapprove: 'supported', + requestChanges: 'supported', + removeChangeRequest: 'supported', + resolveThread: 'supported', + reopenThread: 'supported', + addReaction: 'supported', + removeReaction: 'supported', + merge: 'supported', + deleteBranch: 'supported', + updateBranch: 'supported', + enableAutoMerge: 'supported', + disableAutoMerge: 'supported', +} satisfies Record; +const support = { + github: { ...supported, unapprove: 'unknown', removeChangeRequest: 'unknown' }, + gitlab: supported, + bitbucket: { + ...supported, + addReaction: 'unsupported', + removeReaction: 'unsupported', + updateBranch: 'unsupported', + enableAutoMerge: 'unsupported', + disableAutoMerge: 'unsupported', + }, +} satisfies Record>; +export const availableCapabilityFixture: ReviewCapability = { + support: 'supported', + version: 'available', + license: 'available', + permission: 'allowed', + restrictions: [], + explanation: '', + evidenceUrl: null, + recovery: 'none', + expectedHeadProtection: 'none', +}; +const bitbucketEvidence: Partial> = { + addReaction: 'https://jira.atlassian.com/browse/BCLOUD-21346', + removeReaction: 'https://jira.atlassian.com/browse/BCLOUD-21346', + updateBranch: 'https://jira.atlassian.com/browse/BCLOUD-20489', + enableAutoMerge: 'https://jira.atlassian.com/browse/BCLOUD-22062', + disableAutoMerge: 'https://jira.atlassian.com/browse/BCLOUD-22062', +}; +export function reviewCapabilityFixtures(provider: CodeReviewPlatform) { + return ReviewCapabilitiesSchema.parse( + Object.fromEntries( + ReviewActionSchema.options.map(action => [ + action, + { + ...availableCapabilityFixture, + support: support[provider][action], + explanation: + support[provider][action] === 'unsupported' + ? 'The public provider API does not expose this action.' + : '', + evidenceUrl: provider === 'bitbucket' ? (bitbucketEvidence[action] ?? null) : null, + expectedHeadProtection: + provider !== 'bitbucket' && + (action === 'merge' || (provider === 'gitlab' && action === 'approve')) + ? 'atomicSource' + : provider !== 'bitbucket' && action === 'inlineComment' + ? 'revisionAttachment' + : 'none', + }, + ]) + ) + ); +} diff --git a/packages/app-shared/src/provider-review/index.ts b/packages/app-shared/src/provider-review/index.ts new file mode 100644 index 0000000000..739921c9db --- /dev/null +++ b/packages/app-shared/src/provider-review/index.ts @@ -0,0 +1,2 @@ +export * from './contracts'; +export * from './intent-fingerprint'; diff --git a/packages/app-shared/src/provider-review/intent-fingerprint.test.ts b/packages/app-shared/src/provider-review/intent-fingerprint.test.ts new file mode 100644 index 0000000000..73d4bf604a --- /dev/null +++ b/packages/app-shared/src/provider-review/intent-fingerprint.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest'; +import { + legacyGitHubIntentFingerprint, + providerReviewIntentFingerprint, + type ReviewIntent, + type ReviewIntentInput, +} from './intent-fingerprint'; +import { providerReviewFixtures } from './fixtures'; + +const revision = { headSha: 'head', baseSha: 'base', startSha: 'start', targetHeadSha: 'target' }; +const target = { provider: 'gitlab', kind: 'thread', id: 'thread:1', url: null } as const; +const position = { + revision, + oldPath: 'before.ts', + newPath: 'after.ts', + side: 'new', + line: 5, + startLine: 2, + startSide: 'new', + native: { provider: 'gitlab', oldLine: null, newLine: 5 }, +} as const; +const intent: ReviewIntent = { + accountId: 'account', + review: providerReviewFixtures.gitlab.org, + actorId: 'actor', + revision, + input: { + action: 'comment', + body: 'Exact text', + target, + position, + choice: 'comment', + comments: [{ itemId: 'item', body: 'Inline text', position }], + draftReferences: [target], + reaction: 'thumbsup', + method: 'merge', + squash: false, + commitTitle: 'Title', + commitMessage: 'Message', + deletion: { + effect: 'keep', + repositoryKey: 'source-repository', + branch: 'feature', + expectedHeadSha: 'head', + }, + }, +}; +const changes = { + action: 'merge', + body: 'Exact text\n', + target: { ...target, id: 'thread:2' }, + position: { ...position, line: 6 }, + choice: 'approve', + comments: [{ itemId: 'item', body: 'Different inline text', position }], + draftReferences: [], + reaction: 'heart', + method: 'fast_forward', + squash: true, + commitTitle: 'New title', + commitMessage: 'New message', + deletion: { + effect: 'delete', + repositoryKey: 'source-repository', + branch: 'feature', + expectedHeadSha: 'head', + }, +} satisfies { [K in keyof ReviewIntentInput]-?: ReviewIntentInput[K] }; + +function expectNewAdmission(changed: ReviewIntent) { + const admissions = new Map([[providerReviewIntentFingerprint(intent), 'existing-operation']]); + expect(admissions.get(providerReviewIntentFingerprint(changed))).toBeUndefined(); +} + +describe('versioned provider intent fingerprints', () => { + it.each(Object.entries(changes))( + 'does not replay an operation after changing %s', + (key, value) => { + expectNewAdmission({ ...intent, input: { ...intent.input, [key]: value } }); + } + ); + it.each(['headSha', 'baseSha', 'startSha', 'targetHeadSha'] as const)( + 'binds the expected %s', + field => { + expectNewAdmission({ ...intent, revision: { ...revision, [field]: 'changed' } }); + expectNewAdmission({ + ...intent, + input: { + ...intent.input, + position: { ...position, revision: { ...revision, [field]: 'changed' } }, + }, + }); + } + ); + it.each([ + { oldPath: 'renamed.ts' }, + { newPath: 'renamed.ts' }, + { side: 'old' as const }, + { startLine: 3 }, + { startSide: 'old' as const }, + { native: { provider: 'gitlab' as const, oldLine: 5, newLine: 5 } }, + { + native: { + ...position.native, + lineRange: { + start: { lineCode: 'line:2', side: 'new' as const, oldLine: 2, newLine: 2 }, + end: { lineCode: 'line:5', side: 'new' as const, oldLine: 5, newLine: 5 }, + }, + }, + }, + ])('binds the exact original position: %j', change => { + expectNewAdmission({ + ...intent, + input: { ...intent.input, position: { ...position, ...change } }, + }); + }); + it.each([ + { repositoryKey: 'different-repository' }, + { branch: 'different-branch' }, + { expectedHeadSha: 'changed' }, + ])('binds the selected branch deletion target: %j', change => { + expectNewAdmission({ + ...intent, + input: { + ...intent.input, + deletion: { + effect: 'keep', + repositoryKey: 'source-repository', + branch: 'feature', + expectedHeadSha: 'head', + ...change, + }, + }, + }); + }); + it('isolates accounts, actors, reviews, repositories, integrations, instances, and owners', () => { + for (const change of [ + { accountId: 'other' }, + { actorId: 'other' }, + { review: { ...intent.review, reviewId: 'other' } }, + { review: { ...intent.review, number: '8' } }, + ]) + expectNewAdmission({ ...intent, ...change }); + const review = providerReviewFixtures.gitlab.org; + for (const change of [ + { instanceUrl: 'https://git.example/other' }, + { fullName: 'group/Sub/Repo' }, + { repositoryId: 'other' }, + ]) { + if (review.authorization.kind === 'ownerIntegration') { + expectNewAdmission({ + ...intent, + review: { + ...review, + authorization: review.authorization, + repository: { ...review.repository, ...change }, + }, + }); + } + } + if (review.authorization.kind === 'ownerIntegration') { + for (const change of [ + { integrationId: 'other' }, + { owner: { type: 'user' as const, id: 'owner' } }, + { owner: { type: 'org' as const, id: 'other' } }, + ]) { + expectNewAdmission({ + ...intent, + review: { ...review, authorization: { ...review.authorization, ...change } }, + }); + } + } + for (const review of [providerReviewFixtures.github.user, providerReviewFixtures.bitbucket.org]) + expectNewAdmission({ ...intent, review }); + }); + it('isolates GitHub authorization replacement and Bitbucket workspace UUIDs', () => { + const github = providerReviewFixtures.github.user; + if (github.authorization.kind === 'githubUser' && github.repository.provider === 'github') { + const changed = { + ...github, + repository: { ...github.repository, provider: 'github' as const }, + authorization: { ...github.authorization, authorizationId: 'replacement' }, + }; + expect(providerReviewIntentFingerprint({ ...intent, review: changed })).not.toBe( + providerReviewIntentFingerprint({ ...intent, review: github }) + ); + } + const bitbucket = providerReviewFixtures.bitbucket.org; + if ( + bitbucket.authorization.kind === 'ownerIntegration' && + bitbucket.repository.provider === 'bitbucket' + ) { + const changed = { + ...bitbucket, + authorization: bitbucket.authorization, + repository: { + ...bitbucket.repository, + workspaceUuid: '{33333333-3333-4333-8333-333333333333}', + }, + }; + expect(providerReviewIntentFingerprint({ ...intent, review: changed })).not.toBe( + providerReviewIntentFingerprint({ ...intent, review: bitbucket }) + ); + } + }); + it('preserves bytes when callers reorder properties and rejects unknown intent fields', () => { + const reordered = { + ...intent, + revision: { targetHeadSha: 'target', startSha: 'start', baseSha: 'base', headSha: 'head' }, + input: Object.fromEntries(Object.entries(intent.input).reverse()) as ReviewIntentInput, + }; + expect(providerReviewIntentFingerprint(reordered)).toBe( + providerReviewIntentFingerprint(intent) + ); + expect(() => + providerReviewIntentFingerprint({ + ...intent, + input: { ...intent.input, unrecordedEffect: true }, + } as ReviewIntent) + ).toThrow(); + expect(providerReviewIntentFingerprint({ ...intent, input: { action: 'comment' } })).not.toBe( + providerReviewIntentFingerprint({ ...intent, input: { action: 'comment', body: '' } }) + ); + }); +}); + +describe('legacy GitHub compatibility bytes', () => { + it('preserves absent versus explicit-null legacy fields', () => { + const input = { + owner: 'Owner', + repo: 'Repo', + number: 7, + method: 'merge', + deleteBranch: true, + expectedHeadSha: 'sha', + }; + expect(legacyGitHubIntentFingerprint('merge', { ...input, commitTitle: undefined })).toBe( + '{"resource":["Owner","Repo",7],"method":"merge","deleteBranch":true,"expectedHeadSha":"sha"}' + ); + expect(legacyGitHubIntentFingerprint('merge', { ...input, commitTitle: null })).toBe( + '{"resource":["Owner","Repo",7],"method":"merge","commitTitle":null,"deleteBranch":true,"expectedHeadSha":"sha"}' + ); + }); + it.each([ + [ + 'create_review_comment', + { + body: 'text', + path: 'a.ts', + line: 4, + side: 'RIGHT', + startLine: 2, + startSide: 'RIGHT', + commitSha: 'sha', + }, + '{"resource":["Owner","Repo",7],"body":"text","path":"a.ts","line":4,"side":"RIGHT","startLine":2,"startSide":"RIGHT","commitSha":"sha"}', + ], + [ + 'create_review_comment', + { body: 'text', path: 'a.ts', line: 4, side: 'RIGHT', commitSha: 'sha' }, + '{"resource":["Owner","Repo",7],"body":"text","path":"a.ts","line":4,"side":"RIGHT","commitSha":"sha"}', + ], + [ + 'reply_comment', + { commentId: 42, body: 'reply' }, + '{"resource":["Owner","Repo",7],"commentId":42,"body":"reply"}', + ], + [ + 'submit_review', + { + event: 'APPROVE', + body: 'summary', + commitSha: 'sha', + comments: [{ path: 'a.ts', line: 4, side: 'RIGHT', body: 'text' }], + }, + '{"resource":["Owner","Repo",7],"event":"APPROVE","body":"summary","commitSha":"sha","comments":[{"path":"a.ts","line":4,"side":"RIGHT","body":"text"}]}', + ], + [ + 'merge', + { + method: 'squash', + commitTitle: 'Title', + commitMessage: 'Message', + deleteBranch: false, + expectedHeadSha: 'sha', + }, + '{"resource":["Owner","Repo",7],"method":"squash","commitTitle":"Title","commitMessage":"Message","deleteBranch":false,"expectedHeadSha":"sha"}', + ], + ] as const)('preserves %s including optional ordering', (action, fields, expected) => { + expect( + legacyGitHubIntentFingerprint(action, { + number: 7, + repo: 'Repo', + owner: 'Owner', + operationKey: 'ignored', + ...fields, + }) + ).toBe(expected); + }); +}); diff --git a/packages/app-shared/src/provider-review/intent-fingerprint.ts b/packages/app-shared/src/provider-review/intent-fingerprint.ts new file mode 100644 index 0000000000..d21ae939ce --- /dev/null +++ b/packages/app-shared/src/provider-review/intent-fingerprint.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { prIntentFingerprint } from '../pr-review/intent-fingerprint'; +import { + ProviderReferenceSchema, + ReviewActionSchema, + ReviewPositionSchema, + ReviewRevisionSchema, + reviewResourceKey, + type ReviewIdentity, + type ReviewRevision, +} from './contracts'; + +// Old GitHub clients and ledger rows use these exact bytes and optional-field ordering. +// Remove this path only after old clients/records disappear and the 30-day ledger window expires. +export const legacyGitHubIntentFingerprint = prIntentFingerprint; + +const id = z.string().min(1); +export const ReviewIntentInputSchema = z.strictObject({ + action: ReviewActionSchema.exclude(['read']), + body: z.string().optional(), + target: ProviderReferenceSchema.optional(), + position: ReviewPositionSchema.optional(), + choice: z.enum(['comment', 'approve', 'requestChanges']).optional(), + comments: z + .array(z.strictObject({ itemId: id, body: z.string(), position: ReviewPositionSchema })) + .optional(), + draftReferences: z.array(ProviderReferenceSchema).optional(), + reaction: id.optional(), + method: id.optional(), + squash: z.boolean().optional(), + commitTitle: z.string().optional(), + commitMessage: z.string().optional(), + deletion: z + .strictObject({ + effect: z.enum(['keep', 'delete']), + repositoryKey: id, + branch: id, + expectedHeadSha: id, + }) + .optional(), +}); +export type ReviewIntentInput = z.infer; +export type ReviewIntent = { + accountId: string; + review: ReviewIdentity; + actorId: string; + revision: ReviewRevision; + input: ReviewIntentInput; +}; + +export function providerReviewIntentFingerprint(intent: ReviewIntent): string { + // Parsing fixes field order recursively and rejects unrecognized intent fields instead of dropping them. + return JSON.stringify([ + 'provider-review-intent:v1', + reviewResourceKey(intent.accountId, intent.review), + id.parse(intent.actorId), + ReviewRevisionSchema.parse(intent.revision), + ReviewIntentInputSchema.parse(intent.input), + ]); +} From f95a63648161a516574555afcd6f9fbf695c92c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 30 Aug 2026 01:29:29 +0200 Subject: [PATCH 2/4] fix(provider-review): preserve unknown line counts --- .../src/provider-review/contracts.test.ts | 26 ++++++++++++++++++- .../src/provider-review/contracts.ts | 8 +++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/app-shared/src/provider-review/contracts.test.ts b/packages/app-shared/src/provider-review/contracts.test.ts index 401ac33766..b1d5803052 100644 --- a/packages/app-shared/src/provider-review/contracts.test.ts +++ b/packages/app-shared/src/provider-review/contracts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { CODE_REVIEW_PLATFORMS } from '../code-review/enums'; import { normalizeLegacyGitHubReviewRepository } from '../code-review/repository-identity'; import { @@ -17,6 +17,8 @@ import { reviewPageKey, reviewResourceKey, serializeReviewWriteRequest, + type ReviewFile, + type ReviewOverview, type ReviewPageScope, } from './contracts'; import { @@ -48,6 +50,28 @@ const unresolved = { }; describe('normalized provider review contracts', () => { + // The package typecheck in CI validates these fixtures and type assertions. + const lineCountFixtures: { additions: number | null; deletions: number | null }[] = [ + { additions: null, deletions: null }, + { additions: 0, deletions: 0 }, + { additions: 8, deletions: 3 }, + { additions: null, deletions: 3 }, + { additions: 8, deletions: null }, + ]; + + it('accepts unavailable and confirmed file line counts without widening the contract', () => { + expectTypeOf(lineCountFixtures).toEqualTypeOf[]>(); + }); + + it('accepts unavailable overview line counts while keeping commits and files numeric', () => { + const overviewCountFixtures = lineCountFixtures.map(counts => ({ + commits: 1, + files: 2, + ...counts, + })); + expectTypeOf(overviewCountFixtures).toEqualTypeOf(); + }); + it.each(CODE_REVIEW_PLATFORMS)('isolates %s pages from other providers and owners', provider => { for (const review of Object.values(providerReviewFixtures[provider])) { if (review === null) continue; diff --git a/packages/app-shared/src/provider-review/contracts.ts b/packages/app-shared/src/provider-review/contracts.ts index ec04c03630..db7a82a7d1 100644 --- a/packages/app-shared/src/provider-review/contracts.ts +++ b/packages/app-shared/src/provider-review/contracts.ts @@ -259,8 +259,9 @@ export type ReviewFile = { status: 'added' | 'deleted' | 'modified' | 'renamed' | 'copied' | 'changed'; patch: string | null; content: 'available' | 'binary' | 'truncated' | 'unavailable'; - additions: number; - deletions: number; + // Line additions/deletions: null means unavailable; numeric zero means confirmed zero. + additions: number | null; + deletions: number | null; canonicalUrl: string | null; }; export type ReviewFileContext = { @@ -306,7 +307,8 @@ export type ReviewOverview = { authorization: ReviewAuthorizationContext; providerState: z.infer; checks: z.infer; - counts: { commits: number; files: number; additions: number; deletions: number }; + // Line additions/deletions: null means unavailable; numeric zero means confirmed zero. + counts: { commits: number; files: number; additions: number | null; deletions: number | null }; merge: { methods: { id: string; label: string }[]; squash: 'required' | 'optional' | 'forbidden' | null; From 035e2cef131f309c84e2333d16c7dcf1b552bd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 31 Aug 2026 12:02:13 +0200 Subject: [PATCH 3/4] fix(mobile): restore catalog-backed i18next key validation --- apps/mobile/src/i18n/i18next.d.ts | 6 ++++-- .../src/i18n/provider-review-vocabulary.test.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/i18n/i18next.d.ts b/apps/mobile/src/i18n/i18next.d.ts index 90493ebf8c..d0e2d2d780 100644 --- a/apps/mobile/src/i18n/i18next.d.ts +++ b/apps/mobile/src/i18n/i18next.d.ts @@ -3,10 +3,12 @@ import 'i18next'; import type en from './locales/en.json'; declare module 'i18next' { - type CustomTypeOptions = { + // i18next requires an interface for declaration merging. + // oxlint-disable-next-line typescript/consistent-type-definitions + interface CustomTypeOptions { defaultNS: 'translation'; resources: { translation: typeof en; }; - }; + } } diff --git a/apps/mobile/src/i18n/provider-review-vocabulary.test.ts b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts index bc9e19669c..2e806e5f45 100644 --- a/apps/mobile/src/i18n/provider-review-vocabulary.test.ts +++ b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts @@ -1,6 +1,6 @@ import { CODE_REVIEW_PLATFORMS } from '@kilocode/app-shared/code-review'; -import { createInstance } from 'i18next'; -import { describe, expect, it } from 'vitest'; +import { createInstance, type ParseKeys } from 'i18next'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import en from './locales/en.json'; import { @@ -66,6 +66,14 @@ function leafEntries(tree: StringTree, prefix = ''): [string, string][] { } describe('provider review vocabulary', () => { + it('keeps translator inputs constrained to catalog keys', () => { + const key = PROVIDER_REVIEW_REQUEST_KEYS.gitlab.title satisfies ParseKeys; + + expectTypeOf<'providerReview.mergeRequest.unknown'>().not.toExtend(); + expectTypeOf().not.toExtend(); + expect(i18n.t(key)).toBe('Merge request review'); + }); + it.each(CODE_REVIEW_PLATFORMS)('renders the request terminology for %s', provider => { const rendered = Object.fromEntries( Object.entries(PROVIDER_REVIEW_REQUEST_KEYS[provider]).map(([field, key]) => [ From 2a54c2881fbcd49a3448e8dd2c7ade919d891d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 31 Aug 2026 14:18:37 +0200 Subject: [PATCH 4/4] fix(mobile): isolate catalog-backed provider vocabulary types --- apps/mobile/src/i18n/i18next.d.ts | 14 ------- .../i18n/provider-review-vocabulary.test.ts | 41 ++++++++++++++++--- .../src/i18n/provider-review-vocabulary.ts | 8 +++- 3 files changed, 41 insertions(+), 22 deletions(-) delete mode 100644 apps/mobile/src/i18n/i18next.d.ts diff --git a/apps/mobile/src/i18n/i18next.d.ts b/apps/mobile/src/i18n/i18next.d.ts deleted file mode 100644 index d0e2d2d780..0000000000 --- a/apps/mobile/src/i18n/i18next.d.ts +++ /dev/null @@ -1,14 +0,0 @@ -import 'i18next'; - -import type en from './locales/en.json'; - -declare module 'i18next' { - // i18next requires an interface for declaration merging. - // oxlint-disable-next-line typescript/consistent-type-definitions - interface CustomTypeOptions { - defaultNS: 'translation'; - resources: { - translation: typeof en; - }; - } -} diff --git a/apps/mobile/src/i18n/provider-review-vocabulary.test.ts b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts index 2e806e5f45..33a74f56ff 100644 --- a/apps/mobile/src/i18n/provider-review-vocabulary.test.ts +++ b/apps/mobile/src/i18n/provider-review-vocabulary.test.ts @@ -1,11 +1,15 @@ import { CODE_REVIEW_PLATFORMS } from '@kilocode/app-shared/code-review'; -import { createInstance, type ParseKeys } from 'i18next'; +import { createInstance } from 'i18next'; +import { type UseTranslationResponse } from 'react-i18next'; import { describe, expect, expectTypeOf, it } from 'vitest'; +import { REPO_PLATFORM_LABEL_KEYS } from '@/lib/picker-bridge'; + import en from './locales/en.json'; import { PROVIDER_REVIEW_REQUEST_KEYS, PROVIDER_REVIEW_STATE_KEYS, + type ProviderReviewKey, } from './provider-review-vocabulary'; const i18n = createInstance(); @@ -66,14 +70,39 @@ function leafEntries(tree: StringTree, prefix = ''): [string, string][] { } describe('provider review vocabulary', () => { - it('keeps translator inputs constrained to catalog keys', () => { - const key = PROVIDER_REVIEW_REQUEST_KEYS.gitlab.title satisfies ParseKeys; - - expectTypeOf<'providerReview.mergeRequest.unknown'>().not.toExtend(); - expectTypeOf().not.toExtend(); + it('keeps vocabulary inputs constrained to catalog leaves', () => { + const key = PROVIDER_REVIEW_REQUEST_KEYS.gitlab.title satisfies ProviderReviewKey; + + expectTypeOf().not.toBeNever(); + expectTypeOf<'providerReview.title'>().toExtend(); + expectTypeOf<'providerReview.permission.forbidden'>().toExtend(); + expectTypeOf<'providerReview.mergeRequest.unknown'>().not.toExtend(); + expectTypeOf<'providerReview'>().not.toExtend(); + expectTypeOf<'providerReview.mergeRequest'>().not.toExtend(); + expectTypeOf<'providerReview.permission'>().not.toExtend(); + expectTypeOf<'common.cancel'>().not.toExtend(); + expectTypeOf<`providerReview.${string}`>().not.toExtend(); + expectTypeOf().not.toExtend(); expect(i18n.t(key)).toBe('Merge request review'); }); + it('keeps legacy string inputs usable with the instance translator', () => { + const translate = (key: string) => i18n.t(key); + const label = translate('common.cancel'); + + expectTypeOf(label).toEqualTypeOf(); + expect(label).toBe('Cancel'); + }); + + it('keeps widened repository labels usable with the hook translator', () => { + const translate = (t: UseTranslationResponse<'translation', undefined>['t']) => + t(REPO_PLATFORM_LABEL_KEYS.gitlab); + const label = translate(i18n.t); + + expectTypeOf(label).toEqualTypeOf(); + expect(`${label}: example/repo`).toBe('GitLab: example/repo'); + }); + it.each(CODE_REVIEW_PLATFORMS)('renders the request terminology for %s', provider => { const rendered = Object.fromEntries( Object.entries(PROVIDER_REVIEW_REQUEST_KEYS[provider]).map(([field, key]) => [ diff --git a/apps/mobile/src/i18n/provider-review-vocabulary.ts b/apps/mobile/src/i18n/provider-review-vocabulary.ts index e7f26f79c7..bb43cbdd5b 100644 --- a/apps/mobile/src/i18n/provider-review-vocabulary.ts +++ b/apps/mobile/src/i18n/provider-review-vocabulary.ts @@ -1,9 +1,13 @@ import { type CodeReviewPlatform } from '@kilocode/app-shared/code-review'; -import { type ParseKeys } from 'i18next'; import type en from './locales/en.json'; -type ProviderReviewKey = Extract; +type ProviderReviewCatalog = typeof en.providerReview; +export type ProviderReviewKey = { + [Group in keyof ProviderReviewCatalog]: ProviderReviewCatalog[Group] extends string + ? `providerReview.${Group}` + : `providerReview.${Group}.${keyof ProviderReviewCatalog[Group] & string}`; +}[keyof ProviderReviewCatalog]; type RequestVocabulary = { readonly [Key in keyof typeof en.providerReview.pullRequest]: Extract< ProviderReviewKey,