From 0b1bf6344d3ec5366891fe8d1bfc3be0564bb890 Mon Sep 17 00:00:00 2001 From: "akeem.deviant" Date: Thu, 3 Sep 2026 13:24:15 +0200 Subject: [PATCH] add plblish --- .../workflows/open-studio-pull-request.yml | 26 +++++++ .github/workflows/release-on-main.yml | 17 +++-- data/commanders.json | 22 +++--- studio/apps/web-react/src/ui/App.tsx | 2 +- studio/apps/web-react/src/ui/PublishView.tsx | 13 ++-- studio/apps/web-react/vite.config.ts | 10 ++- .../apps/web-react/vite/PublishApiHandler.ts | 42 ++-------- studio/src/application/index.ts | 6 ++ .../ProductionGameDataProposalGateway.ts | 9 +++ .../CreateProductionGameDataProposal.ts | 37 +++++++++ .../GitProductionGameDataProposalGateway.ts | 76 +++++++++++++++++++ studio/src/infrastructure/index.ts | 1 + studio/store/catalog/commanders.json | 2 +- .../CreateProductionGameDataProposal.test.ts | 36 +++++++++ 14 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/open-studio-pull-request.yml create mode 100644 studio/src/application/ports/ProductionGameDataProposalGateway.ts create mode 100644 studio/src/application/use-cases/CreateProductionGameDataProposal.ts create mode 100644 studio/src/infrastructure/git/GitProductionGameDataProposalGateway.ts create mode 100644 studio/tests/application/use-cases/CreateProductionGameDataProposal.test.ts diff --git a/.github/workflows/open-studio-pull-request.yml b/.github/workflows/open-studio-pull-request.yml new file mode 100644 index 0000000..5155267 --- /dev/null +++ b/.github/workflows/open-studio-pull-request.yml @@ -0,0 +1,26 @@ +name: Open Studio pull request + +on: + push: + branches: + - studio/publish-* + +permissions: + contents: read + pull-requests: write + +jobs: + open-pull-request: + runs-on: ubuntu-latest + steps: + - name: Create pull request + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_BRANCH: ${{ github.ref_name }} + run: | + gh pr create \ + --base main \ + --head "$HEAD_BRANCH" \ + --title "feat(data): update production game data" \ + --body "Proposition générée et validée depuis Game Data Studio." diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index 6c424be..ee7f73a 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -1,4 +1,4 @@ -name: Create patch version +name: Tag merged version on: push: @@ -8,12 +8,11 @@ permissions: contents: write concurrency: - group: release-main + group: tag-main cancel-in-progress: false jobs: release: - if: ${{ !contains(github.event.head_commit.message, 'chore(release)') }} runs-on: ubuntu-latest steps: - name: Checkout @@ -51,9 +50,15 @@ jobs: - name: Build legacy editor run: npm test - - name: Create patch commit and tag + - name: Tag package version run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - npm version patch -m "chore(release): %s [skip ci]" - git push origin HEAD:main --follow-tags + VERSION="$(node -p "require('./package.json').version")" + TAG="v$VERSION" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "$TAG existe déjà, aucun nouveau tag nécessaire." + exit 0 + fi + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" diff --git a/data/commanders.json b/data/commanders.json index c4ff019..127d915 100644 --- a/data/commanders.json +++ b/data/commanders.json @@ -19,7 +19,7 @@ "turnCount": 2, "power": 8, "nonePower": 2, - "visualKey": "Aegis_soldier_melee_red", + "visualKey": "Aegis_soldier_ranged_red", "weaponKey": "short_iron_katana" }, { @@ -99,19 +99,21 @@ } }, { - "id": "Aegis_officer_3", - "displayName": "Archer vert", - "color": "green", + "id": "Aegis_officer_4", + "displayName": "savant rouge", + "color": "red", "type": "ranged", "turnCount": 3, - "power": 24, - "countPawns": 2, + "power": 12, + "countPawns": 1, "moveCount": 2, - "visualKey": "Aegis_officer_ranged_green", - "weaponKey": "arrow", - "requiredInfluencePoints": 8, + "visualKey": "Aegis_officer_ranged_red", + "weaponKey": "shuriken", + "requiredInfluencePoints": 10, "implicitSkillParams": { - "spBonusPerLiaison": 1 + "columnPowerBonusPerDecrement": 6, + "freeWallDestructsOnDecrement": 1, + "liaisonBonusPercent": 10 } } ], diff --git a/studio/apps/web-react/src/ui/App.tsx b/studio/apps/web-react/src/ui/App.tsx index 685127d..175b6e9 100644 --- a/studio/apps/web-react/src/ui/App.tsx +++ b/studio/apps/web-react/src/ui/App.tsx @@ -30,7 +30,7 @@ const commandersModule: ModuleDef = { views: [ { id: 'create', label: 'Créer', icon: 'spark' }, { id: 'edit', label: 'Modifier', icon: 'identity' }, - { id: 'publish', label: 'Publier', icon: 'upload' }, + { id: 'publish', label: 'Proposer en production', icon: 'upload' }, ], }; diff --git a/studio/apps/web-react/src/ui/PublishView.tsx b/studio/apps/web-react/src/ui/PublishView.tsx index da77894..d2721ba 100644 --- a/studio/apps/web-react/src/ui/PublishView.tsx +++ b/studio/apps/web-react/src/ui/PublishView.tsx @@ -40,7 +40,8 @@ export function PublishView() { body: JSON.stringify({ commanderIds: Array.from(selected) }), }); const body = await response.json() as { - published?: { id: string; name: string }[]; + prepared?: { id: string; name: string }[]; + branchName?: string; error?: string; errors?: string[]; }; @@ -49,7 +50,9 @@ export function PublishView() { setMessage(body.errors?.join('\n') ?? body.error ?? `Erreur ${response.status}`); } else { setStatus('success'); - setMessage(`${(body.published ?? []).length} commander(s) publié(s) avec succès.`); + setMessage( + `Proposition créée sur ${body.branchName ?? 'la branche distante'} pour ${(body.prepared ?? []).length} commander(s). La pull request va être ouverte automatiquement.`, + ); } } catch { setStatus('error'); @@ -65,8 +68,8 @@ export function PublishView() {

Export

-

Publier des commandants

-

Sélectionnez les commandants à exporter vers data/commanders.json.

+

Préparer une pull request

+

Sélectionnez les commandants à proposer dans les données de production.

@@ -139,7 +142,7 @@ export function PublishView() { type="button" > - {status === 'loading' ? 'Publication…' : `Publier ${selected.size > 0 ? `(${selected.size})` : ''}`} + {status === 'loading' ? 'Préparation…' : `Préparer la pull request ${selected.size > 0 ? `(${selected.size})` : ''}`} )} diff --git a/studio/apps/web-react/vite.config.ts b/studio/apps/web-react/vite.config.ts index ed6f696..a860e2f 100644 --- a/studio/apps/web-react/vite.config.ts +++ b/studio/apps/web-react/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vite'; import { fileURLToPath, URL } from 'node:url'; import { CreateCommander, + CreateProductionGameDataProposal, GenerateProductionGameData, ListCommanders, ProductionGameDataValidator, @@ -18,6 +19,7 @@ import { JsonSkillCatalogRepository, JsonWallVisualSetCatalogRepository, JsonWeaponKeyCatalogRepository, + GitProductionGameDataProposalGateway, } from '@game-data/infrastructure'; import { CommanderCatalogApiHandler } from './vite/CommanderCatalogApiHandler.ts'; import { PawnCatalogApiHandler } from './vite/PawnCatalogApiHandler.ts'; @@ -68,7 +70,13 @@ const generateProductionGameData = new GenerateProductionGameData( new JsonWallVisualSetCatalogRepository(fileURLToPath(new URL('../../../data/wallVisualSets.json', import.meta.url))), new ProductionGameDataValidator(), ); -const publishHandler = new PublishApiHandler(generateProductionGameData, productionCommanderCatalogPath); +const repositoryPath = fileURLToPath(new URL('../../../', import.meta.url)); +const publishHandler = new PublishApiHandler( + new CreateProductionGameDataProposal( + generateProductionGameData, + new GitProductionGameDataProposalGateway(repositoryPath), + ), +); export default defineConfig({ plugins: [react(), tailwindcss(), commanderCatalogApiPlugin(handler, pawnHandler, pawnApiHandler, wallVisualSetHandler, publishHandler)], diff --git a/studio/apps/web-react/vite/PublishApiHandler.ts b/studio/apps/web-react/vite/PublishApiHandler.ts index 8bde8ff..3c44d61 100644 --- a/studio/apps/web-react/vite/PublishApiHandler.ts +++ b/studio/apps/web-react/vite/PublishApiHandler.ts @@ -1,35 +1,24 @@ -import { mkdir, rename, rm, writeFile } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; import { ProductionGameDataValidationError, - type GenerateProductionGameData, + type CreateProductionGameDataProposal, + type CreateProductionGameDataProposalResult, } from '@game-data/application'; -export interface PublishResult { - published: { id: string; name: string }[]; -} - interface PublishError { readonly error: string; readonly errors?: readonly string[]; } export class PublishApiHandler { - private readonly generateProductionGameData: Pick; - private readonly productionCatalogPath: string; + private readonly createProposal: Pick; - public constructor( - generateProductionGameData: Pick, - productionCatalogPath: string, - ) { - this.generateProductionGameData = generateProductionGameData; - this.productionCatalogPath = productionCatalogPath; + public constructor(createProposal: Pick) { + this.createProposal = createProposal; } public async handle(input: unknown): Promise<{ status: number; - body: PublishResult | PublishError; + body: CreateProductionGameDataProposalResult | PublishError; }> { const commanderIds = this.parseIds(input); if (!commanderIds) { @@ -40,12 +29,9 @@ export class PublishApiHandler { } try { - const catalog = await this.generateProductionGameData.execute({ commanderIds }); - await this.write(catalog); - const selected = catalog.filter(({ id }) => commanderIds.includes(id)); return { status: 200, - body: { published: selected.map(({ id, name }) => ({ id, name })) }, + body: await this.createProposal.execute({ commanderIds }), }; } catch (error) { if (error instanceof ProductionGameDataValidationError) { @@ -60,7 +46,7 @@ export class PublishApiHandler { if (error instanceof Error && /not found/.test(error.message)) { return { status: 404, body: { error: error.message } }; } - return { status: 500, body: { error: 'La publication a échoué.' } }; + return { status: 500, body: { error: 'La création de la proposition a échoué.' } }; } } @@ -71,16 +57,4 @@ export class PublishApiHandler { if (commanderIds.some((id) => typeof id !== 'string' || id.trim().length === 0)) return null; return commanderIds as string[]; } - - private async write(catalog: unknown): Promise { - const temporaryPath = `${this.productionCatalogPath}.${randomUUID()}.tmp`; - await mkdir(dirname(this.productionCatalogPath), { recursive: true }); - try { - await writeFile(temporaryPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8'); - await rename(temporaryPath, this.productionCatalogPath); - } catch (error) { - await rm(temporaryPath, { force: true }); - throw error; - } - } } diff --git a/studio/src/application/index.ts b/studio/src/application/index.ts index 496db0a..b20b6dd 100644 --- a/studio/src/application/index.ts +++ b/studio/src/application/index.ts @@ -8,6 +8,10 @@ export type { SkillCatalogRepository } from './ports/SkillCatalogRepository.ts'; export type { WeaponKeyCatalogRepository } from './ports/WeaponKeyCatalogRepository.ts'; export type { WallVisualSetCatalogRepository } from './ports/WallVisualSetCatalogRepository.ts'; export type { ProductionCommanderCatalogRepository } from './ports/ProductionCommanderCatalogRepository.ts'; +export type { + ProductionGameDataProposal, + ProductionGameDataProposalGateway, +} from './ports/ProductionGameDataProposalGateway.ts'; export type { ProductionCommanderDocument, ProductionPawnDocument, @@ -44,6 +48,8 @@ export type { GenerateProductionGameDataRequest, } from './use-cases/GenerateProductionGameData.ts'; export { GenerateProductionGameData } from './use-cases/GenerateProductionGameData.ts'; +export type { CreateProductionGameDataProposalResult } from './use-cases/CreateProductionGameDataProposal.ts'; +export { CreateProductionGameDataProposal } from './use-cases/CreateProductionGameDataProposal.ts'; export { ProductionGameDataValidationError, ProductionGameDataValidator, diff --git a/studio/src/application/ports/ProductionGameDataProposalGateway.ts b/studio/src/application/ports/ProductionGameDataProposalGateway.ts new file mode 100644 index 0000000..0200ed8 --- /dev/null +++ b/studio/src/application/ports/ProductionGameDataProposalGateway.ts @@ -0,0 +1,9 @@ +import type { ProductionCommanderDocument } from '../models/ProductionCommanderDocument.ts'; + +export interface ProductionGameDataProposal { + readonly branchName: string; +} + +export interface ProductionGameDataProposalGateway { + create(commanders: readonly ProductionCommanderDocument[]): Promise; +} diff --git a/studio/src/application/use-cases/CreateProductionGameDataProposal.ts b/studio/src/application/use-cases/CreateProductionGameDataProposal.ts new file mode 100644 index 0000000..8b5485d --- /dev/null +++ b/studio/src/application/use-cases/CreateProductionGameDataProposal.ts @@ -0,0 +1,37 @@ +import type { ProductionGameDataProposalGateway } from '../ports/ProductionGameDataProposalGateway.ts'; +import type { + GenerateProductionGameData, + GenerateProductionGameDataRequest, +} from './GenerateProductionGameData.ts'; + +export interface CreateProductionGameDataProposalResult { + readonly branchName: string; + readonly prepared: readonly { readonly id: string; readonly name: string }[]; +} + +export class CreateProductionGameDataProposal { + private readonly generateProductionGameData: Pick; + private readonly proposalGateway: ProductionGameDataProposalGateway; + + public constructor( + generateProductionGameData: Pick, + proposalGateway: ProductionGameDataProposalGateway, + ) { + this.generateProductionGameData = generateProductionGameData; + this.proposalGateway = proposalGateway; + } + + public async execute( + request: GenerateProductionGameDataRequest, + ): Promise { + const commanders = await this.generateProductionGameData.execute(request); + const proposal = await this.proposalGateway.create(commanders); + const requestedIds = new Set(request.commanderIds); + return { + branchName: proposal.branchName, + prepared: commanders + .filter(({ id }) => requestedIds.has(id)) + .map(({ id, name }) => ({ id, name })), + }; + } +} diff --git a/studio/src/infrastructure/git/GitProductionGameDataProposalGateway.ts b/studio/src/infrastructure/git/GitProductionGameDataProposalGateway.ts new file mode 100644 index 0000000..af90c16 --- /dev/null +++ b/studio/src/infrastructure/git/GitProductionGameDataProposalGateway.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { promisify } from 'node:util'; +import { randomUUID } from 'node:crypto'; +import type { + ProductionCommanderDocument, + ProductionGameDataProposal, + ProductionGameDataProposalGateway, +} from '@game-data/application'; + +const executeFile = promisify(execFile); + +export class GitProductionGameDataProposalGateway implements ProductionGameDataProposalGateway { + private readonly repositoryPath: string; + + public constructor(repositoryPath: string) { + this.repositoryPath = repositoryPath; + } + + public async create( + commanders: readonly ProductionCommanderDocument[], + ): Promise { + const branchName = `studio/publish-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}`; + const worktreePath = await mkdtemp(join(tmpdir(), 'game-data-proposal-')); + let worktreeCreated = false; + + try { + await this.git(['fetch', 'origin', 'main']); + await this.git(['worktree', 'add', '-b', branchName, worktreePath, 'origin/main']); + worktreeCreated = true; + + const catalogPath = join(worktreePath, 'data', 'commanders.json'); + await mkdir(dirname(catalogPath), { recursive: true }); + await writeFile(catalogPath, `${JSON.stringify(commanders, null, 2)}\n`, 'utf8'); + await this.npm(['version', 'patch', '--no-git-tag-version'], worktreePath); + await this.git([ + '-C', + worktreePath, + 'add', + '--', + 'data/commanders.json', + 'package.json', + 'package-lock.json', + ]); + await this.git([ + '-C', + worktreePath, + '-c', + 'user.name=Game Data Studio', + '-c', + 'user.email=studio@game-data.local', + 'commit', + '-m', + 'feat(data): propose production commanders', + ]); + await this.git(['-C', worktreePath, 'push', '--set-upstream', 'origin', branchName]); + return { branchName }; + } finally { + if (worktreeCreated) { + await this.git(['worktree', 'remove', '--force', worktreePath]).catch(() => undefined); + } + await rm(worktreePath, { recursive: true, force: true }); + } + } + + private async git(args: readonly string[]): Promise { + await executeFile('git', args, { cwd: this.repositoryPath }); + } + + private async npm(args: readonly string[], cwd: string): Promise { + const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + await executeFile(executable, args, { cwd }); + } +} diff --git a/studio/src/infrastructure/index.ts b/studio/src/infrastructure/index.ts index 8e24be1..750d2ed 100644 --- a/studio/src/infrastructure/index.ts +++ b/studio/src/infrastructure/index.ts @@ -6,3 +6,4 @@ export { JsonSkillCatalogRepository } from './json/JsonSkillCatalogRepository.ts export { JsonWeaponKeyCatalogRepository } from './json/JsonWeaponKeyCatalogRepository.ts'; export { JsonWallVisualSetCatalogRepository } from './json/JsonWallVisualSetCatalogRepository.ts'; export { JsonProductionCommanderCatalogRepository } from './json/JsonProductionCommanderCatalogRepository.ts'; +export { GitProductionGameDataProposalGateway } from './git/GitProductionGameDataProposalGateway.ts'; diff --git a/studio/store/catalog/commanders.json b/studio/store/catalog/commanders.json index ff0d802..64987a5 100644 --- a/studio/store/catalog/commanders.json +++ b/studio/store/catalog/commanders.json @@ -21,7 +21,7 @@ "officerPawnDefinitionIds": [ "Aegis_officer_1", "Aegis_officer_2", - "Aegis_officer_3" + "Aegis_officer_4" ], "movementsPerTurn": 3, "skills": [ diff --git a/studio/tests/application/use-cases/CreateProductionGameDataProposal.test.ts b/studio/tests/application/use-cases/CreateProductionGameDataProposal.test.ts new file mode 100644 index 0000000..7b45320 --- /dev/null +++ b/studio/tests/application/use-cases/CreateProductionGameDataProposal.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import type { ProductionCommanderDocument } from '../../../src/application/models/ProductionCommanderDocument'; +import type { ProductionGameDataProposalGateway } from '../../../src/application/ports/ProductionGameDataProposalGateway'; +import { CreateProductionGameDataProposal } from '../../../src/application/use-cases/CreateProductionGameDataProposal'; +import { ProductionCommanderCatalogMother } from '../../fixtures/production/ProductionCommanderCatalogMother'; + +describe('CreateProductionGameDataProposal', () => { + it('envoie le catalogue validé vers une nouvelle proposition', async () => { + // Arrange + const commanders = ProductionCommanderCatalogMother.valid(); + const generator = { execute: async () => commanders }; + const gateway = new InMemoryProductionGameDataProposalGateway(); + const useCase = new CreateProductionGameDataProposal(generator, gateway); + + // Act + const result = await useCase.execute({ commanderIds: ['commander-1'] }); + + // Assert + expect(gateway.commanders).toEqual(commanders); + expect(result).toEqual({ + branchName: 'studio/publish-test', + prepared: [{ id: 'commander-1', name: 'Commander' }], + }); + }); +}); + +class InMemoryProductionGameDataProposalGateway implements ProductionGameDataProposalGateway { + public commanders: readonly ProductionCommanderDocument[] = []; + + public async create( + commanders: readonly ProductionCommanderDocument[], + ): Promise<{ branchName: string }> { + this.commanders = commanders; + return { branchName: 'studio/publish-test' }; + } +}