From e952bbffc5a32a1bbbd982490bc33d70c7c1b136 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Thu, 25 Jun 2026 14:51:26 +0200 Subject: [PATCH 1/2] feat(git): add pull-request generation from CMS UI --- README.md | 2 +- docs/content/2.setup.md | 31 +++ docs/content/3.git-providers.md | 6 +- docs/content/9.advanced.md | 4 + .../src/components/header/HeaderReview.vue | 20 +- src/app/src/composables/useContext.ts | 46 +++- src/app/src/composables/useStudio.ts | 1 + src/app/src/locales/en.json | 16 +- src/app/src/locales/fr.json | 16 +- src/app/src/pages/success.vue | 88 +++++++ src/app/src/types/context.ts | 7 + src/app/src/types/git.ts | 33 +++ src/app/src/utils/providers/github.ts | 90 ++++++- src/app/src/utils/providers/gitlab.ts | 64 ++++- src/app/src/utils/providers/null.ts | 3 +- src/app/test/integration/publish.test.ts | 170 +++++++++++++ src/app/test/mocks/git.ts | 1 + .../test/unit/utils/providers/github.test.ts | 239 ++++++++++++++++++ .../test/unit/utils/providers/gitlab.test.ts | 120 ++++++++- src/module/src/module.ts | 11 + 20 files changed, 951 insertions(+), 17 deletions(-) create mode 100644 src/app/test/unit/utils/providers/github.test.ts diff --git a/README.md b/README.md index c7702e61a..b49ab2bef 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ pnpm lint ### 🔮 Future - [ ] Advanced conflict resolution -- [ ] Pull request generation (from a branch to the main one) +- [x] Pull request generation (from a branch to the main one) - [x] AI-powered content suggestions - [ ] Media optimization diff --git a/docs/content/2.setup.md b/docs/content/2.setup.md index 09c65f82e..3dd472632 100644 --- a/docs/content/2.setup.md +++ b/docs/content/2.setup.md @@ -219,6 +219,37 @@ export default defineNuxtConfig({ }) ``` +#### Pull request target branch + +When Studio commits to a staging branch, you can automatically open a pull request (GitHub) or merge request (GitLab) into your production branch after each publish. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + studio: { + repository: { + provider: 'github', + owner: 'your-username', + repo: 'your-repo', + branch: 'staging', + pullRequest: { + base: 'main', + }, + }, + }, +}) +``` + +- `branch` — where Studio commits content changes (typically your staging or preview branch). +- `pullRequest.base` — the target branch for the review request (typically `main` or production). + +When `pullRequest.base` is omitted, Studio keeps the default direct-commit behavior. + +You can override the base branch at runtime: + +```bash [.env] +NUXT_PUBLIC_STUDIO_REPOSITORY_PULL_REQUEST_BASE=main +``` + ### Internationalization Nuxt Studio includes built-in internationalization support with the following languages available: diff --git a/docs/content/3.git-providers.md b/docs/content/3.git-providers.md index 842316b27..df572ed51 100644 --- a/docs/content/3.git-providers.md +++ b/docs/content/3.git-providers.md @@ -210,5 +210,9 @@ Navigate to `https://staging.yourdomain.com/_studio` to edit content. All commit ### Merging to Production -Once you're satisfied with changes on your staging branch, create a pull request from your staging branch to your main branch to deploy to production. +Once you're satisfied with changes on your staging branch, merge them into production. + +If `repository.pullRequest.base` is configured, Studio automatically creates or reuses an open pull request (GitHub) or merge request (GitLab) from your staging branch to the base branch after each publish. The success screen links directly to the review request. + +Otherwise, create a pull request from your staging branch to your main branch manually to deploy to production. :: diff --git a/docs/content/9.advanced.md b/docs/content/9.advanced.md index bca44cb9b..f6348a189 100644 --- a/docs/content/9.advanced.md +++ b/docs/content/9.advanced.md @@ -118,6 +118,10 @@ Studio gathers all draft items that contain changes. Using your Git provider's API, Studio creates a new commit with all updated files. +#### Review Request (optional) + +When `repository.pullRequest.base` is configured, Studio also creates or reuses an open pull request (GitHub) or merge request (GitLab) from the configured branch into the base branch. + #### Deployment Trigger Your CI/CD platform detects the commit and automatically rebuilds and redeploys your website. diff --git a/src/app/src/components/header/HeaderReview.vue b/src/app/src/components/header/HeaderReview.vue index 0dc3d5fa9..58ffb9945 100644 --- a/src/app/src/components/header/HeaderReview.vue +++ b/src/app/src/components/header/HeaderReview.vue @@ -74,10 +74,26 @@ async function publishChanges() { isPublishing.value = true try { const changeCount = context.draftCount.value - await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: state.commitMessage }) + const publishResult = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: state.commitMessage }) state.commitMessage = '' - await router.push({ path: '/success', query: { changeCount: changeCount.toString() } }) + await router.push({ + path: '/success', + query: { + changeCount: changeCount.toString(), + commitUrl: publishResult.commit.url, + ...(publishResult.reviewRequest + ? { + reviewRequestUrl: publishResult.reviewRequest.url, + reviewRequestKind: publishResult.reviewRequest.kind, + reviewRequestState: publishResult.reviewRequest.state, + } + : {}), + ...(publishResult.reviewRequestError + ? { reviewRequestError: publishResult.reviewRequestError } + : {}), + }, + }) } catch (error) { const err = error as Error diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 6c627ab46..23d4c9904 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -5,6 +5,7 @@ import { } from '../types' import type { PublishBranchParams, + PublishBranchResult, RenameFileParams, TreeItem, UploadMediaParams, @@ -227,19 +228,56 @@ export const useContext = createSharedComposable(( })) }) - const branchActionHandler: { [K in StudioBranchActionId]: (args: ActionHandlerParams[K]) => Promise } = { - [StudioBranchActionId.PublishBranch]: async (params: PublishBranchParams) => { + const branchActionHandler: { + [StudioBranchActionId.PublishBranch]: (params: PublishBranchParams) => Promise + } = { + [StudioBranchActionId.PublishBranch]: async (params: PublishBranchParams): Promise => { const { commitMessage } = params const prefix = host.meta.git?.commit?.messagePrefix const finalMessage = prefix ? `${prefix} ${commitMessage.trim()}`.trim() : commitMessage.trim() const documentFiles = await documentTree.draft.listAsRawFiles() const mediaFiles = await mediaTree.draft.listAsRawFiles() - await gitProvider.api.commitFiles([...documentFiles, ...mediaFiles], finalMessage) + const commitResult = await gitProvider.api.commitFiles([...documentFiles, ...mediaFiles], finalMessage) + + if (!commitResult?.success) { + throw new Error('Failed to publish changes') + } + + const pullRequestBase = host.repository.pullRequest?.base + const sourceBranch = host.repository.branch + let reviewRequest = null + let reviewRequestError: string | undefined + + if (pullRequestBase && pullRequestBase !== sourceBranch) { + try { + reviewRequest = await gitProvider.api.ensureReviewRequest({ + title: finalMessage, + head: sourceBranch, + base: pullRequestBase, + commitUrl: commitResult.url, + }) + + if (!reviewRequest) { + reviewRequestError = 'Failed to create review request' + } + } + catch (error) { + reviewRequestError = error instanceof Error ? error.message : 'Failed to create review request' + logger.warn('Review request creation failed after successful commit', error) + } + } + else if (pullRequestBase && pullRequestBase === sourceBranch) { + logger.warn('repository.pullRequest.base matches repository.branch; skipping review request creation') + } // @ts-expect-error params is null await itemActionHandler[StudioItemActionId.RevertAllItems]() - await router.push('/content') + return { + commit: commitResult, + reviewRequest, + reviewRequestError, + } }, } diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index a1af392ae..f6c8ab3d4 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -33,6 +33,7 @@ export const useStudio = createSharedComposable(() => { authorName: host.user.get().name, authorEmail: host.user.get().email, instanceUrl: host.repository.instanceUrl, + pullRequest: host.repository.pullRequest, } const gitProvider = useGitProvider(gitOptions, devMode.value) diff --git a/src/app/src/locales/en.json b/src/app/src/locales/en.json index ced4912b7..7193885a8 100644 --- a/src/app/src/locales/en.json +++ b/src/app/src/locales/en.json @@ -58,7 +58,21 @@ "alertTitleWaiting": "Waiting for deployment...", "alertTitleComplete": "Deployment complete", "alertDescWaiting": "The website needs to be deployed for changes to be visible in Studio.", - "alertDescComplete": "A new version of your website has been deployed. Please refresh your app to see changes in Studio." + "alertDescComplete": "A new version of your website has been deployed. Please refresh your app to see changes in Studio.", + "reviewRequest": { + "openPullRequest": "Open pull request", + "openMergeRequest": "Open merge request", + "openCommit": "View published commit", + "failedTitle": "Review request could not be created", + "pullRequest": { + "created": "Pull request created", + "existing": "Existing pull request updated" + }, + "mergeRequest": { + "created": "Merge request created", + "existing": "Existing merge request updated" + } + } }, "newVersionBanner": { "title": "New website version detected", diff --git a/src/app/src/locales/fr.json b/src/app/src/locales/fr.json index 9a6a23830..e7664939e 100644 --- a/src/app/src/locales/fr.json +++ b/src/app/src/locales/fr.json @@ -57,7 +57,21 @@ "alertTitleWaiting": "En attente de déploiement...", "alertTitleComplete": "Déploiement terminé", "alertDescWaiting": "Le site web doit être déployé pour que les changements soient visibles dans le Studio.", - "alertDescComplete": "Une nouvelle version de votre site a été déployée. Veuillez rafraîchir l'application pour voir les changements dans le Studio." + "alertDescComplete": "Une nouvelle version de votre site a été déployée. Veuillez rafraîchir l'application pour voir les changements dans le Studio.", + "reviewRequest": { + "openPullRequest": "Ouvrir la pull request", + "openMergeRequest": "Ouvrir la merge request", + "openCommit": "Voir le commit publié", + "failedTitle": "Impossible de créer la demande de revue", + "pullRequest": { + "created": "Pull request créée", + "existing": "Pull request existante mise à jour" + }, + "mergeRequest": { + "created": "Merge request créée", + "existing": "Merge request existante mise à jour" + } + } }, "newVersionBanner": { "title": "Nouvelle version du site détectée", diff --git a/src/app/src/pages/success.vue b/src/app/src/pages/success.vue index 80b29b32c..1ee77b686 100644 --- a/src/app/src/pages/success.vue +++ b/src/app/src/pages/success.vue @@ -20,6 +20,52 @@ const changeCount = computed(() => { }) const repositoryInfo = computed(() => gitProvider.api.getRepositoryInfo()) +const reviewRequestUrl = computed(() => { + const url = route.query.reviewRequestUrl + return typeof url === 'string' ? url : undefined +}) + +const reviewRequestKind = computed(() => { + const kind = route.query.reviewRequestKind + return kind === 'pull-request' || kind === 'merge-request' ? kind : undefined +}) + +const reviewRequestState = computed(() => { + const state = route.query.reviewRequestState + return state === 'created' || state === 'existing' ? state : undefined +}) + +const reviewRequestError = computed(() => { + const error = route.query.reviewRequestError + return typeof error === 'string' ? error : undefined +}) + +const commitUrl = computed(() => { + const url = route.query.commitUrl + return typeof url === 'string' ? url : undefined +}) + +const reviewRequestTitle = computed(() => { + if (!reviewRequestKind.value || !reviewRequestState.value) { + return '' + } + + const kindKey = reviewRequestKind.value === 'pull-request' ? 'pullRequest' : 'mergeRequest' + const stateKey = reviewRequestState.value === 'created' ? 'created' : 'existing' + + return t(`studio.publishSuccess.reviewRequest.${kindKey}.${stateKey}`) +}) + +const reviewRequestButtonLabel = computed(() => { + if (!reviewRequestKind.value) { + return '' + } + + return reviewRequestKind.value === 'pull-request' + ? t('studio.publishSuccess.reviewRequest.openPullRequest') + : t('studio.publishSuccess.reviewRequest.openMergeRequest') +}) + const alertDescription = computed(() => { if (isWaitingForDeployment.value) { return t('studio.publishSuccess.alertDescWaiting') @@ -98,6 +144,48 @@ onMounted(() => { + + + + + + + + commitFiles(files: RawFile[], message: string): Promise + ensureReviewRequest(options: EnsureReviewRequestOptions): Promise getRepositoryUrl(): string getBranchUrl(): string getCommitUrl(sha: string): string diff --git a/src/app/src/utils/providers/github.ts b/src/app/src/utils/providers/github.ts index ff727aa19..7097be811 100644 --- a/src/app/src/utils/providers/github.ts +++ b/src/app/src/utils/providers/github.ts @@ -1,7 +1,7 @@ import { ofetch } from 'ofetch' import { joinURL, withoutTrailingSlash } from 'ufo' import { consola } from 'consola' -import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult, CommitFilesOptions } from '../../types' +import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult, CommitFilesOptions, EnsureReviewRequestOptions, ReviewRequestResult } from '../../types' import { StudioFeature } from '../../types' import { DraftStatus } from '../../types/draft' @@ -244,6 +244,93 @@ export function createGitHubProvider(options: GitOptions): GitProviderAPI { } } + async function findOpenPullRequest(headRef: string, base: string) { + const existingPulls = await $repositoryApi>('/pulls', { + query: { + state: 'open', + head: headRef, + base, + }, + }) + + return existingPulls[0] ?? null + } + + async function ensureReviewRequest({ title, head, base, body, commitUrl }: EnsureReviewRequestOptions): Promise { + if (!token) { + return null + } + + const headRef = `${owner}:${head}` + const existingPull = await findOpenPullRequest(headRef, base) + + if (existingPull) { + return { + kind: 'pull-request', + state: 'existing', + url: existingPull.html_url, + head: existingPull.head.ref, + base: existingPull.base.ref, + number: existingPull.number, + } + } + + const description = [ + body, + commitUrl ? `Commit: ${commitUrl}` : undefined, + 'Published via [Nuxt Studio](https://nuxt.studio/)', + ].filter(Boolean).join('\n\n') + + try { + const createdPull = await $repositoryApi<{ + number: number + html_url: string + head: { ref: string } + base: { ref: string } + }>('/pulls', { + method: 'POST', + body: JSON.stringify({ + title, + head, + base, + body: description, + }), + }) + + return { + kind: 'pull-request', + state: 'created', + url: createdPull.html_url, + head: createdPull.head.ref, + base: createdPull.base.ref, + number: createdPull.number, + } + } + catch (error) { + if ((error as { status?: number }).status === 422) { + const racedPull = await findOpenPullRequest(headRef, base) + + if (racedPull) { + return { + kind: 'pull-request', + state: 'existing', + url: racedPull.html_url, + head: racedPull.head.ref, + base: racedPull.base.ref, + number: racedPull.number, + } + } + } + + throw error + } + } + function getRepositoryUrl() { return `${instanceUrl}/${owner}/${repo}` } @@ -274,6 +361,7 @@ export function createGitHubProvider(options: GitOptions): GitProviderAPI { return { fetchFile, commitFiles, + ensureReviewRequest, getRepositoryUrl, getBranchUrl, getCommitUrl, diff --git a/src/app/src/utils/providers/gitlab.ts b/src/app/src/utils/providers/gitlab.ts index bf912dcdf..99811d676 100644 --- a/src/app/src/utils/providers/gitlab.ts +++ b/src/app/src/utils/providers/gitlab.ts @@ -1,7 +1,7 @@ import { ofetch } from 'ofetch' import { joinURL, withoutTrailingSlash } from 'ufo' import { consola } from 'consola' -import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult, CommitFilesOptions } from '../../types' +import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult, CommitFilesOptions, EnsureReviewRequestOptions, ReviewRequestResult } from '../../types' import { DraftStatus } from '../../types/draft' import { StudioFeature } from '../../types' @@ -171,6 +171,67 @@ export function createGitLabProvider(options: GitOptions): GitProviderAPI { } } + async function ensureReviewRequest({ title, head, base, body, commitUrl }: EnsureReviewRequestOptions): Promise { + if (!token) { + return null + } + + const existingMergeRequests = await $api>('/merge_requests', { + query: { + state: 'opened', + source_branch: head, + target_branch: base, + }, + }) + + const existingMergeRequest = existingMergeRequests[0] + if (existingMergeRequest) { + return { + kind: 'merge-request', + state: 'existing', + url: existingMergeRequest.web_url, + head: existingMergeRequest.source_branch, + base: existingMergeRequest.target_branch, + iid: existingMergeRequest.iid, + } + } + + const description = [ + body, + commitUrl ? `Commit: ${commitUrl}` : undefined, + 'Published via [Nuxt Studio](https://nuxt.studio/)', + ].filter(Boolean).join('\n\n') + + const createdMergeRequest = await $api<{ + iid: number + web_url: string + source_branch: string + target_branch: string + }>('/merge_requests', { + method: 'POST', + body: { + title, + source_branch: head, + target_branch: base, + description, + }, + }) + + return { + kind: 'merge-request', + state: 'created', + url: createdMergeRequest.web_url, + head: createdMergeRequest.source_branch, + base: createdMergeRequest.target_branch, + iid: createdMergeRequest.iid, + } + } + function getRepositoryUrl() { return `${normalizedInstanceUrl}/${owner}/${repo}` } @@ -201,6 +262,7 @@ export function createGitLabProvider(options: GitOptions): GitProviderAPI { return { fetchFile, commitFiles, + ensureReviewRequest, getRepositoryUrl, getBranchUrl, getCommitUrl, diff --git a/src/app/src/utils/providers/null.ts b/src/app/src/utils/providers/null.ts index 94a3bff3d..9b89a6121 100644 --- a/src/app/src/utils/providers/null.ts +++ b/src/app/src/utils/providers/null.ts @@ -1,4 +1,4 @@ -import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult } from '../../types' +import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult, EnsureReviewRequestOptions, ReviewRequestResult } from '../../types' /** * Null provider for development/local usage @@ -8,6 +8,7 @@ export function createNullProvider(_options: GitOptions): GitProviderAPI { return { fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), commitFiles: (_files: RawFile[], _message: string): Promise => Promise.resolve(null), + ensureReviewRequest: (_options: EnsureReviewRequestOptions): Promise => Promise.resolve(null), getRepositoryUrl: () => '', getBranchUrl: () => '', getCommitUrl: () => '', diff --git a/src/app/test/integration/publish.test.ts b/src/app/test/integration/publish.test.ts index 5ab0f8c7e..b044b072f 100644 --- a/src/app/test/integration/publish.test.ts +++ b/src/app/test/integration/publish.test.ts @@ -2,6 +2,7 @@ import { type vi, describe, it, expect, beforeEach } from 'vitest' import { StudioBranchActionId, type StudioHost } from '../../src/types' import { generateUniqueDocumentFsPath } from '../utils' import { mockHost, mockGit, routeState, cleanAndSetupContext } from '../utils/context' +import { createMockGit } from '../mocks/git' describe('PublishBranch - Commit Message Prefix', () => { let context: Awaited> @@ -69,3 +70,172 @@ describe('PublishBranch - Commit Message Prefix', () => { expect(commitMessage).toBe('docs: Update readme') }) }) + +describe('PublishBranch - Review requests', () => { + let context: Awaited> + let documentFsPath: string + + beforeEach(async () => { + routeState.name = 'content' + documentFsPath = generateUniqueDocumentFsPath('document') + context = await cleanAndSetupContext(mockHost, mockGit) + }) + + it('does not create a review request when pullRequest.base is not configured', async () => { + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(mockGit.api.ensureReviewRequest).not.toHaveBeenCalled() + expect(result.reviewRequest).toBeNull() + expect(result.reviewRequestError).toBeUndefined() + }) + + it('creates a review request after a successful commit when pullRequest.base is configured', async () => { + const hostWithPullRequest: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'staging', + pullRequest: { base: 'main' }, + }, + } + const git = createMockGit() + ;(git.api.ensureReviewRequest as ReturnType).mockResolvedValue({ + kind: 'pull-request', + state: 'created', + url: 'https://github.com/owner/repo/pull/1', + head: 'staging', + base: 'main', + number: 1, + }) + context = await cleanAndSetupContext(hostWithPullRequest, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(git.api.ensureReviewRequest).toHaveBeenCalledWith({ + title: 'Update content', + head: 'staging', + base: 'main', + commitUrl: 'https://example.com/commit/abc123', + }) + expect(result.reviewRequest).toMatchObject({ + kind: 'pull-request', + state: 'created', + url: 'https://github.com/owner/repo/pull/1', + }) + }) + + it('returns a partial success when review request creation fails after commit', async () => { + const hostWithPullRequest: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'staging', + pullRequest: { base: 'main' }, + }, + } + const git = createMockGit() + ;(git.api.ensureReviewRequest as ReturnType).mockRejectedValue(new Error('API rate limit exceeded')) + context = await cleanAndSetupContext(hostWithPullRequest, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(git.api.commitFiles).toHaveBeenCalledTimes(1) + expect(result.commit.success).toBe(true) + expect(result.reviewRequest).toBeNull() + expect(result.reviewRequestError).toBe('API rate limit exceeded') + expect(context.draftCount.value).toBe(0) + }) + + it('returns a partial success when ensureReviewRequest resolves null', async () => { + const hostWithPullRequest: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'staging', + pullRequest: { base: 'main' }, + }, + } + const git = createMockGit() + ;(git.api.ensureReviewRequest as ReturnType).mockResolvedValue(null) + context = await cleanAndSetupContext(hostWithPullRequest, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(result.commit.success).toBe(true) + expect(result.reviewRequest).toBeNull() + expect(result.reviewRequestError).toBe('Failed to create review request') + }) + + it('skips review request creation when pullRequest.base matches repository.branch', async () => { + const hostWithSameBranch: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'main', + pullRequest: { base: 'main' }, + }, + } + const git = createMockGit() + context = await cleanAndSetupContext(hostWithSameBranch, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(git.api.ensureReviewRequest).not.toHaveBeenCalled() + expect(result.reviewRequest).toBeNull() + expect(result.reviewRequestError).toBeUndefined() + }) + + it('reuses an existing review request when the provider returns one', async () => { + const hostWithPullRequest: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'staging', + pullRequest: { base: 'main' }, + }, + } + const git = createMockGit() + ;(git.api.ensureReviewRequest as ReturnType).mockResolvedValue({ + kind: 'pull-request', + state: 'existing', + url: 'https://github.com/owner/repo/pull/42', + head: 'staging', + base: 'main', + number: 42, + }) + context = await cleanAndSetupContext(hostWithPullRequest, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + const result = await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(result.reviewRequest).toMatchObject({ + kind: 'pull-request', + state: 'existing', + url: 'https://github.com/owner/repo/pull/42', + }) + expect(result.reviewRequestError).toBeUndefined() + }) +}) diff --git a/src/app/test/mocks/git.ts b/src/app/test/mocks/git.ts index cd613136f..3e8132911 100644 --- a/src/app/test/mocks/git.ts +++ b/src/app/test/mocks/git.ts @@ -8,6 +8,7 @@ export const createMockGit = (remoteFile?: GithubFile): ReturnType, body?: string }, + ) => { + if (request === '/pulls' && !requestOptions?.method) { + return Promise.resolve(options?.existingPull ? [options.existingPull] : []) + } + + if (request === '/pulls' && requestOptions?.method === 'POST') { + return Promise.resolve(createdPull) + } + + if (request === '/git/refs/heads/dev') { + return Promise.resolve({ object: { sha: 'base-sha' } }) + } + + if (request === '/git/commits/base-sha') { + return Promise.resolve({ tree: { sha: 'tree-sha' } }) + } + + if (request === '/git/blobs' && requestOptions?.method === 'POST') { + return Promise.resolve({ sha: 'blob-sha' }) + } + + if (request === '/git/trees' && requestOptions?.method === 'POST') { + return Promise.resolve({ sha: 'new-tree-sha' }) + } + + if (request === '/git/commits' && requestOptions?.method === 'POST') { + return Promise.resolve({ sha: 'commit-sha' }) + } + + if (request === '/git/refs/heads/dev' && requestOptions?.method === 'PATCH') { + return Promise.resolve({}) + } + + return Promise.reject(new Error(`Unexpected request: ${request} ${requestOptions?.method ?? ''}`)) + } +} + +const mock$repositoryApi = vi.fn(createApiHandler()) +const mock$userApi = vi.fn(() => Promise.resolve({ login: 'author', email: 'author@example.com', name: 'Author' })) + +vi.mock('ofetch', () => ({ + ofetch: { + create: vi.fn((config: { baseURL?: string }) => { + if (config.baseURL?.endsWith('/repos/acme/docs')) { + return mock$repositoryApi + } + + return mock$userApi + }), + }, +})) + +const baseGitOptions = { + provider: 'github' as const, + owner: 'acme', + repo: 'docs', + branch: 'dev', + rootDir: '', + authorName: 'Test Author', + authorEmail: 'author@example.com', + token: 'ghp_test-token', + instanceUrl: 'https://github.com', +} as const + +describe('createGitHubProvider / ensureReviewRequest', () => { + beforeEach(() => { + mock$repositoryApi.mockImplementation(createApiHandler()) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns an existing open pull request when one already exists', async () => { + mock$repositoryApi.mockImplementation(createApiHandler({ existingPull })) + + const provider = createGitHubProvider({ ...baseGitOptions }) + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'staging', + base: 'main', + commitUrl: 'https://github.com/acme/docs/commit/abc123', + }) + + expect(result).toEqual({ + kind: 'pull-request', + state: 'existing', + url: existingPull.html_url, + head: 'staging', + base: 'main', + number: 42, + }) + + expect(mock$repositoryApi).toHaveBeenCalledWith('/pulls', { + query: { + state: 'open', + head: 'acme:staging', + base: 'main', + }, + }) + }) + + it('creates a pull request when none exists', async () => { + const provider = createGitHubProvider({ ...baseGitOptions }) + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'staging', + base: 'main', + commitUrl: 'https://github.com/acme/docs/commit/abc123', + }) + + expect(result).toEqual({ + kind: 'pull-request', + state: 'created', + url: createdPull.html_url, + head: 'staging', + base: 'main', + number: 43, + }) + + const [, requestInit] = mock$repositoryApi.mock.calls.find( + ([request, options]) => request === '/pulls' && options?.method === 'POST', + ) as [string, { method: string, body: string }] + + expect(JSON.parse(requestInit.body)).toMatchObject({ + title: 'content: update homepage', + head: 'staging', + base: 'main', + }) + }) + + it('does not call the API when token is missing', async () => { + const provider = createGitHubProvider({ + ...baseGitOptions, + token: '', + }) + + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'staging', + base: 'main', + }) + + expect(result).toBeNull() + expect(mock$repositoryApi).not.toHaveBeenCalled() + }) + + it('returns an existing pull request when creation races with a duplicate 422', async () => { + let pullListCalls = 0 + + mock$repositoryApi.mockImplementation(( + request: string, + requestOptions?: { method?: string, query?: Record }, + ) => { + if (request === '/pulls' && !requestOptions?.method) { + pullListCalls += 1 + return Promise.resolve(pullListCalls === 1 ? [] : [existingPull]) + } + + if (request === '/pulls' && requestOptions?.method === 'POST') { + const error = new Error('Validation Failed') as Error & { status: number } + error.status = 422 + return Promise.reject(error) + } + + return Promise.reject(new Error(`Unexpected request: ${request} ${requestOptions?.method ?? ''}`)) + }) + + const provider = createGitHubProvider({ ...baseGitOptions }) + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'staging', + base: 'main', + }) + + expect(result).toEqual({ + kind: 'pull-request', + state: 'existing', + url: existingPull.html_url, + head: 'staging', + base: 'main', + number: 42, + }) + + expect(pullListCalls).toBe(2) + }) +}) + +describe('createGitHubProvider / commitFiles', () => { + beforeEach(() => { + mock$repositoryApi.mockImplementation(createApiHandler()) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('commits files to the configured branch', async () => { + const provider = createGitHubProvider({ ...baseGitOptions }) + + const result = await provider.commitFiles( + [{ + path: 'content/index.md', + status: DraftStatus.Created, + content: '# Hello', + encoding: 'utf-8', + }], + 'docs: welcome', + ) + + expect(result).toEqual({ + success: true, + commitSha: 'commit-sha', + url: 'https://github.com/acme/docs/commit/commit-sha', + }) + }) +}) diff --git a/src/app/test/unit/utils/providers/gitlab.test.ts b/src/app/test/unit/utils/providers/gitlab.test.ts index 6b1527e94..c2a834308 100644 --- a/src/app/test/unit/utils/providers/gitlab.test.ts +++ b/src/app/test/unit/utils/providers/gitlab.test.ts @@ -2,15 +2,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { DraftStatus } from '../../../../src/types/draft' import { createGitLabProvider } from '../../../../src/utils/providers/gitlab' -function createCommitsApiHandler() { +function createCommitsApiHandler(options?: { + existingMergeRequest?: { + iid: number + web_url: string + source_branch: string + target_branch: string + } | null +}) { return ( request: string, - options?: { method?: string, body?: Record }, + requestOptions?: { method?: string, query?: Record, body?: Record }, ) => { - if (request === '/repository/commits' && options?.method === 'POST') { + if (request === '/merge_requests' && !requestOptions?.method) { + return Promise.resolve(options?.existingMergeRequest ? [options.existingMergeRequest] : []) + } + + if (request === '/merge_requests' && requestOptions?.method === 'POST') { + return Promise.resolve({ + iid: 12, + web_url: 'https://gitlab.example.com/team-communication/numberly-2026/-/merge_requests/12', + source_branch: 'dev', + target_branch: 'main', + }) + } + + if (request === '/repository/commits' && requestOptions?.method === 'POST') { return Promise.resolve({ id: 'a1b2c3d4e5f6' }) } - return Promise.reject(new Error(`Unexpected request: ${request} ${options?.method ?? ''}`)) + + return Promise.reject(new Error(`Unexpected request: ${request} ${requestOptions?.method ?? ''}`)) } } @@ -187,3 +208,94 @@ describe('createGitLabProvider / commitFiles', () => { expect(mock$api).not.toHaveBeenCalled() }) }) + +describe('createGitLabProvider / ensureReviewRequest', () => { + const existingMergeRequest = { + iid: 7, + web_url: 'https://gitlab.example.com/team-communication/numberly-2026/-/merge_requests/7', + source_branch: 'dev', + target_branch: 'main', + } + + beforeEach(() => { + mock$api.mockImplementation(createCommitsApiHandler()) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns an existing open merge request when one already exists', async () => { + mock$api.mockImplementation(createCommitsApiHandler({ existingMergeRequest })) + + const provider = createGitLabProvider({ ...baseGitOptions }) + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'dev', + base: 'main', + commitUrl: 'https://gitlab.example.com/team-communication/numberly-2026/-/commit/a1b2c3d4e5f6', + }) + + expect(result).toEqual({ + kind: 'merge-request', + state: 'existing', + url: existingMergeRequest.web_url, + head: 'dev', + base: 'main', + iid: 7, + }) + + expect(mock$api).toHaveBeenCalledWith('/merge_requests', { + query: { + state: 'opened', + source_branch: 'dev', + target_branch: 'main', + }, + }) + }) + + it('creates a merge request when none exists', async () => { + const provider = createGitLabProvider({ ...baseGitOptions }) + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'dev', + base: 'main', + commitUrl: 'https://gitlab.example.com/team-communication/numberly-2026/-/commit/a1b2c3d4e5f6', + }) + + expect(result).toEqual({ + kind: 'merge-request', + state: 'created', + url: 'https://gitlab.example.com/team-communication/numberly-2026/-/merge_requests/12', + head: 'dev', + base: 'main', + iid: 12, + }) + + const [, requestInit] = mock$api.mock.calls.find( + ([request, options]) => request === '/merge_requests' && options?.method === 'POST', + ) as [string, { method: string, body: Record }] + + expect(requestInit.body).toMatchObject({ + title: 'content: update homepage', + source_branch: 'dev', + target_branch: 'main', + }) + }) + + it('does not call the API when token is missing', async () => { + const provider = createGitLabProvider({ + ...baseGitOptions, + token: '', + }) + + const result = await provider.ensureReviewRequest({ + title: 'content: update homepage', + head: 'dev', + base: 'main', + }) + + expect(result).toBeNull() + expect(mock$api).not.toHaveBeenCalled() + }) +}) diff --git a/src/module/src/module.ts b/src/module/src/module.ts index 0fac79cdf..f2644c36b 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -124,6 +124,17 @@ interface RepositoryOptions { * @default true */ private?: boolean + /** + * Pull/merge request settings for staging-to-production workflows. + * When `base` is set, Studio commits to `branch` then creates or reuses a review request into `base`. + */ + pullRequest?: { + /** + * Target branch for the pull request (GitHub) or merge request (GitLab). + * @example 'main' + */ + base?: string + } } interface GitHubRepositoryOptions extends RepositoryOptions { From 401e34684ba08a16b8d8ef3b3ff35cc974968977 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Mon, 29 Jun 2026 22:27:44 +0200 Subject: [PATCH 2/2] feat(git): add repository.pullRequest.name option to customize MR/PR name --- docs/content/2.setup.md | 5 +++- src/app/src/composables/useContext.ts | 3 ++- src/app/src/types/git.ts | 5 ++++ src/app/test/integration/publish.test.ts | 34 ++++++++++++++++++++++++ src/module/src/module.ts | 6 +++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/content/2.setup.md b/docs/content/2.setup.md index 3dd472632..08c1510cb 100644 --- a/docs/content/2.setup.md +++ b/docs/content/2.setup.md @@ -233,6 +233,7 @@ export default defineNuxtConfig({ branch: 'staging', pullRequest: { base: 'main', + name: 'Content updates from Studio', }, }, }, @@ -241,13 +242,15 @@ export default defineNuxtConfig({ - `branch` — where Studio commits content changes (typically your staging or preview branch). - `pullRequest.base` — the target branch for the review request (typically `main` or production). +- `pullRequest.name` — optional title for the review request. When omitted, Studio uses the commit message. When `pullRequest.base` is omitted, Studio keeps the default direct-commit behavior. -You can override the base branch at runtime: +You can override these options at runtime: ```bash [.env] NUXT_PUBLIC_STUDIO_REPOSITORY_PULL_REQUEST_BASE=main +NUXT_PUBLIC_STUDIO_REPOSITORY_PULL_REQUEST_NAME="Content updates from Studio" ``` ### Internationalization diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 23d4c9904..ad88d2efc 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -244,6 +244,7 @@ export const useContext = createSharedComposable(( } const pullRequestBase = host.repository.pullRequest?.base + const pullRequestName = host.repository.pullRequest?.name?.trim() const sourceBranch = host.repository.branch let reviewRequest = null let reviewRequestError: string | undefined @@ -251,7 +252,7 @@ export const useContext = createSharedComposable(( if (pullRequestBase && pullRequestBase !== sourceBranch) { try { reviewRequest = await gitProvider.api.ensureReviewRequest({ - title: finalMessage, + title: pullRequestName || finalMessage, head: sourceBranch, base: pullRequestBase, commitUrl: commitResult.url, diff --git a/src/app/src/types/git.ts b/src/app/src/types/git.ts index dbb385188..7a5a6dcc1 100644 --- a/src/app/src/types/git.ts +++ b/src/app/src/types/git.ts @@ -9,6 +9,11 @@ export interface RepositoryPullRequestOptions { * When set, Studio commits to `branch` then opens or reuses a review request into this base branch. */ base?: string + /** + * Title for pull/merge requests created after publishing. + * When omitted, the commit message is used. + */ + name?: string } export interface Repository { diff --git a/src/app/test/integration/publish.test.ts b/src/app/test/integration/publish.test.ts index b044b072f..9e8f2a334 100644 --- a/src/app/test/integration/publish.test.ts +++ b/src/app/test/integration/publish.test.ts @@ -205,6 +205,40 @@ describe('PublishBranch - Review requests', () => { expect(result.reviewRequestError).toBeUndefined() }) + it('uses a custom review request title when pullRequest.name is configured', async () => { + const hostWithPullRequest: StudioHost = { + ...mockHost, + repository: { + ...mockHost.repository, + branch: 'staging', + pullRequest: { base: 'main', name: 'Content updates from Studio' }, + }, + } + const git = createMockGit() + ;(git.api.ensureReviewRequest as ReturnType).mockResolvedValue({ + kind: 'pull-request', + state: 'created', + url: 'https://github.com/owner/repo/pull/1', + head: 'staging', + base: 'main', + number: 1, + }) + context = await cleanAndSetupContext(hostWithPullRequest, git) + + await mockHost.document.db.create(documentFsPath, 'Test content') + await context.activeTree.value.draft.load() + await context.activeTree.value.selectItemByFsPath(documentFsPath) + + await context.branchActionHandler[StudioBranchActionId.PublishBranch]({ commitMessage: 'Update content' }) + + expect(git.api.ensureReviewRequest).toHaveBeenCalledWith({ + title: 'Content updates from Studio', + head: 'staging', + base: 'main', + commitUrl: 'https://example.com/commit/abc123', + }) + }) + it('reuses an existing review request when the provider returns one', async () => { const hostWithPullRequest: StudioHost = { ...mockHost, diff --git a/src/module/src/module.ts b/src/module/src/module.ts index f2644c36b..665d124b5 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -134,6 +134,12 @@ interface RepositoryOptions { * @example 'main' */ base?: string + /** + * Title for the pull request (GitHub) or merge request (GitLab) when one is created. + * When omitted, the commit message is used. + * @example 'Content updates from Studio' + */ + name?: string } }