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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions docs/content/2.setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,40 @@ 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',
name: 'Content updates from Studio',
},
},
},
})
```

- `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 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

Nuxt Studio includes built-in internationalization support with the following languages available:
Expand Down
6 changes: 5 additions & 1 deletion docs/content/3.git-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
::
4 changes: 4 additions & 0 deletions docs/content/9.advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 18 additions & 2 deletions src/app/src/components/header/HeaderReview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 43 additions & 4 deletions src/app/src/composables/useContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '../types'
import type {
PublishBranchParams,
PublishBranchResult,
RenameFileParams,
TreeItem,
UploadMediaParams,
Expand Down Expand Up @@ -227,19 +228,57 @@ export const useContext = createSharedComposable((
}))
})

const branchActionHandler: { [K in StudioBranchActionId]: (args: ActionHandlerParams[K]) => Promise<void> } = {
[StudioBranchActionId.PublishBranch]: async (params: PublishBranchParams) => {
const branchActionHandler: {
[StudioBranchActionId.PublishBranch]: (params: PublishBranchParams) => Promise<PublishBranchResult>
} = {
[StudioBranchActionId.PublishBranch]: async (params: PublishBranchParams): Promise<PublishBranchResult> => {
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 pullRequestName = host.repository.pullRequest?.name?.trim()
const sourceBranch = host.repository.branch
let reviewRequest = null
let reviewRequestError: string | undefined

if (pullRequestBase && pullRequestBase !== sourceBranch) {
try {
reviewRequest = await gitProvider.api.ensureReviewRequest({
title: pullRequestName || 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,
}
},
}

Expand Down
1 change: 1 addition & 0 deletions src/app/src/composables/useStudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion src/app/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion src/app/src/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
88 changes: 88 additions & 0 deletions src/app/src/pages/success.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -98,6 +144,48 @@ onMounted(() => {
</i18n-t>
</div>

<UAlert
v-if="reviewRequestUrl"
icon="i-lucide-git-pull-request"
:title="reviewRequestTitle"
color="primary"
variant="soft"
>
<template #description>
<UButton
:label="reviewRequestButtonLabel"
icon="i-lucide-external-link"
:to="reviewRequestUrl"
variant="link"
target="_blank"
:padded="false"
/>
</template>
</UAlert>

<UAlert
v-if="reviewRequestError"
icon="i-lucide-alert-triangle"
:title="$t('studio.publishSuccess.reviewRequest.failedTitle')"
color="warning"
variant="soft"
>
<template #description>
<div class="flex flex-col gap-2 items-start">
<p>{{ reviewRequestError }}</p>
<UButton
v-if="commitUrl"
:label="$t('studio.publishSuccess.reviewRequest.openCommit')"
icon="i-lucide-git-commit-horizontal"
:to="commitUrl"
variant="link"
target="_blank"
:padded="false"
/>
</div>
</template>
</UAlert>

<UAlert
:icon="isWaitingForDeployment ? 'i-lucide-loader' : 'i-lucide-check'"
:title="isWaitingForDeployment ? $t('studio.publishSuccess.alertTitleWaiting') : $t('studio.publishSuccess.alertTitleComplete')"
Expand Down
7 changes: 7 additions & 0 deletions src/app/src/types/context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CommitResult, ReviewRequestResult } from './git'
import type { TreeItem } from './tree'

export enum StudioFeature {
Expand Down Expand Up @@ -58,6 +59,12 @@ export interface PublishBranchParams {
commitMessage: string
}

export interface PublishBranchResult {
commit: CommitResult
reviewRequest: ReviewRequestResult | null
reviewRequestError?: string
}

export type ActionHandlerParams = {
// Items
[StudioItemActionId.CreateDocumentFolder]: CreateFolderParams
Expand Down
Loading
Loading