diff --git a/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md new file mode 100644 index 0000000..9b1cac3 --- /dev/null +++ b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md @@ -0,0 +1,18 @@ +# TECH_LOG_STUDIO_RELEASES accessibility review + +Status: pending-manual-review +Route ID: TECH_LOG_STUDIO_RELEASES +Release ID: +Reviewer: +Reviewed at: +Signature: +Attestation: pending +M1 Keyboard: pending +M2 Visible focus: pending +M3 Route focus: pending +M4 Modal focus: pending +M5 Error association: pending +M6 Color signal: pending +M7 Reduced motion: pending +Screen reader: pending +Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review. diff --git a/config/ci/gates.json b/config/ci/gates.json index b4655a8..75d5dc9 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -1202,6 +1202,12 @@ "schemaId": "markdown", "production": "source-controlled" }, + { + "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", + "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md", + "schemaId": "markdown", + "production": "source-controlled" + }, { "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", @@ -1956,6 +1962,7 @@ "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md", + "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-report-json" diff --git a/public/release-manifest.json b/public/release-manifest.json index 2ec5e30..8987f27 100644 --- a/public/release-manifest.json +++ b/public/release-manifest.json @@ -38,28 +38,28 @@ }, "contractSet": { "setAlgorithm": "CA_CONTRACT_SET_V1", - "setDigest": "sha256:ac1448ced933beece92924be4ffb7beb182c3df26371a83ffeffbb174ce98e72", + "setDigest": "sha256:0872fd944d931d70550549496a8086322ea6c86adc1d5802030300eb0076d2f2", "packages": [ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f", + "digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" }, { "packageId": "@tech-log/public-contract", "version": "2.0.0", "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" }, { "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", "runtimeProtocolVersion": 1, - "sourceRevision": "6ef5c1c" + "sourceRevision": "0c10a4a" } ] } diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 2db948e..8ca6127 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -464,7 +464,11 @@ const CANONICAL_GATE_SHAPE_SHA256 = // lists one evidence artifact per installed route and refuses a set that does // not match the route scope exactly, so adding a route necessarily moves this // digest — that is the point of pinning it. - "8c73d4477392a63e91088ff91e53293e7fc3ace891f8eb3da3b0d05fa714df60"; + // Release authoring: recomputed again after FE-GATE-009 gained the + // TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447… + // was first reproduced from the previous gates.json, so the computation that + // produced this value is known to be the one the constant was pinned under. + "187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -519,8 +523,8 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] // Alignment follow-up, item 2 added the TechLog junit report. // The taxonomy route added its own manual a11y evidence file — every installed // route carries one, and the gate checks that the two sets match exactly. - if (contract.artifacts.length !== 131) { - failures.push(`artifact authority baseline must contain exactly 131 artifacts; received ${contract.artifacts.length}`); + if (contract.artifacts.length !== 132) { + failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); diff --git a/src/features/tech-log/adapters/http/http-management-gateway.ts b/src/features/tech-log/adapters/http/http-management-gateway.ts index cc1092c..0c01da5 100644 --- a/src/features/tech-log/adapters/http/http-management-gateway.ts +++ b/src/features/tech-log/adapters/http/http-management-gateway.ts @@ -3,6 +3,10 @@ import type { ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, + PublishResponse, + ReleaseEditResponse, + ReleaseIndexPage, + ReleaseUpdateRequest, TopicEdit, } from "../../contracts/management/contract.ts"; import type { ManagementGateway } from "../../application/ports/management-gateway.ts"; @@ -68,6 +72,19 @@ export function createHttpManagementGateway( createProject: (title: string) => run("createProject", { title }), updateProject: (id: string, body: ProjectUpdateRequest) => run("updateProject", { id, body }), + listReleases: (page?: number, size?: number) => + run("listStudioReleases", { ...(page !== undefined ? { page } : {}), ...(size !== undefined ? { size } : {}) }), + getRelease: (id: string) => run("getReleaseForEdit", { id }), + createRelease: (title: string) => run("createRelease", { title }), + updateRelease: (id: string, body: ReleaseUpdateRequest) => + run("updateRelease", { id, body }), + deleteRelease: async (id: string, expectedVersion: number) => { + await run("deleteRelease", { id, expectedVersion }); + }, + publishRelease: (id: string, expectedVersion: number) => + run("publishRelease", { id, expectedVersion }), + archiveRelease: (id: string, expectedVersion: number) => + run("archiveRelease", { id, expectedVersion }), deleteProject: async (id: string, expectedVersion: number) => { await run("deleteProject", { id, expectedVersion }); }, diff --git a/src/features/tech-log/application/ports/management-gateway.ts b/src/features/tech-log/application/ports/management-gateway.ts index e0a7057..f135a6d 100644 --- a/src/features/tech-log/application/ports/management-gateway.ts +++ b/src/features/tech-log/application/ports/management-gateway.ts @@ -3,11 +3,15 @@ import type { ProjectEditResponse, ProjectIndexPage, ProjectUpdateRequest, + PublishResponse, + ReleaseEditResponse, + ReleaseIndexPage, + ReleaseUpdateRequest, TopicEdit, } from "../../contracts/management/contract.ts"; /** - * 주제·프로젝트 관리 표면. + * 주제·프로젝트·릴리즈 관리 표면. * *

Studio 게이트웨이와 같은 실패 규약이다 — 실패는 던지고, 화면이 잡는다. MOCK 대응물을 두지 * 않는 것도 의도다: 이 표면은 백엔드가 없으면 존재할 이유가 없고, 픽스처를 만들면 실제로는 만들 @@ -23,4 +27,11 @@ export type ManagementGateway = Readonly<{ createProject(title: string): Promise; updateProject(id: string, body: ProjectUpdateRequest): Promise; deleteProject(id: string, expectedVersion: number): Promise; + listReleases(page?: number, size?: number): Promise; + getRelease(id: string): Promise; + createRelease(title: string): Promise; + updateRelease(id: string, body: ReleaseUpdateRequest): Promise; + deleteRelease(id: string, expectedVersion: number): Promise; + publishRelease(id: string, expectedVersion: number): Promise; + archiveRelease(id: string, expectedVersion: number): Promise; }>; diff --git a/src/features/tech-log/contracts/management/canonical-source.json b/src/features/tech-log/contracts/management/canonical-source.json index bf05689..ab3996c 100644 --- a/src/features/tech-log/contracts/management/canonical-source.json +++ b/src/features/tech-log/contracts/management/canonical-source.json @@ -1,8 +1,8 @@ { "packageId": "@tech-log/management-contract", "version": "1.0.0", - "digest": "sha256:ec5e432215fb041abee980787366a6db29ff1ecdd78416aa9c61e09b9b91022f", - "sourceRevision": "6ef5c1c", + "digest": "sha256:e16a9695e9034a273c769792ea8901fd706c61514137228b2c20e1b117ea0c01", + "sourceRevision": "0c10a4a", "operationIds": [ "createCaseDraft", "getCaseForEdit", diff --git a/src/features/tech-log/contracts/management/contract.ts b/src/features/tech-log/contracts/management/contract.ts index 4984668..afc604c 100644 --- a/src/features/tech-log/contracts/management/contract.ts +++ b/src/features/tech-log/contracts/management/contract.ts @@ -10,3 +10,8 @@ export type ProjectUpdateRequest = Schemas["ProjectUpdateRequest"]; export type CreateDraftRequest = Schemas["CreateDraftRequest"]; export type CreateDraftResponse = Schemas["CreateDraftResponse"]; export type ExpectedVersionRequest = Schemas["ExpectedVersionRequest"]; +export type ReleaseEditResponse = Schemas["ReleaseEditResponse"]; +export type ReleaseIndexItem = Schemas["ReleaseIndexItem"]; +export type ReleaseIndexPage = Schemas["ReleaseIndexPage"]; +export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; +export type PublishResponse = Schemas["PublishResponse"]; diff --git a/src/features/tech-log/contracts/management/generated.ts b/src/features/tech-log/contracts/management/generated.ts index a078080..5bfc9e2 100644 --- a/src/features/tech-log/contracts/management/generated.ts +++ b/src/features/tech-log/contracts/management/generated.ts @@ -902,7 +902,7 @@ export interface components { * @description `INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다. * @enum {string} */ - code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "INTERNAL_ERROR"; + code: "AUTHENTICATION_REQUIRED" | "STUDIO_ACCESS_DENIED" | "REQUEST_VALIDATION_FAILED" | "VERSION_CONFLICT" | "TOPIC_NOT_FOUND" | "TOPIC_NAME_TAKEN" | "TOPIC_SLUG_TAKEN" | "TOPIC_IN_USE" | "PROJECT_NOT_FOUND" | "PROJECT_SLUG_TAKEN" | "PROJECT_IN_USE" | "RELEASE_NOT_FOUND" | "RELEASE_VERSION_TAKEN" | "RELEASE_NOT_PUBLISHABLE" | "INTERNAL_ERROR"; /** @enum {string} */ category: "VALIDATION" | "AUTH" | "AUTHZ" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMIT" | "TRANSIENT_DEPENDENCY" | "PERMANENT_DEPENDENCY" | "DATA_INTEGRITY" | "INTERNAL"; message: string; @@ -946,6 +946,24 @@ export interface components { data: components["schemas"]["ProjectIndexPage"]; meta: components["schemas"]["ResponseMeta"]; }; + ReleaseIndexPageEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseIndexPage"]; + meta: components["schemas"]["ResponseMeta"]; + }; + ReleaseEditResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["ReleaseEditResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; + PublishResponseEnvelope: { + /** @constant */ + success: true; + data: components["schemas"]["PublishResponse"]; + meta: components["schemas"]["ResponseMeta"]; + }; ProjectEditResponseEnvelope: { /** @constant */ success: true; @@ -6267,7 +6285,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseIndexPage"]; + "application/json": components["schemas"]["ReleaseIndexPageEnvelope"]; }; }; /** @description Bad Request */ @@ -6276,7 +6294,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6285,7 +6303,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6294,7 +6312,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6303,7 +6321,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6312,7 +6330,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6338,7 +6356,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["CreateDraftResponse"]; + "application/json": components["schemas"]["CreateDraftResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6347,7 +6365,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6356,7 +6374,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6365,7 +6383,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6374,7 +6392,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6383,7 +6401,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6392,7 +6410,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6401,7 +6419,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6423,7 +6441,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6432,7 +6450,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6441,7 +6459,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6450,7 +6468,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6459,7 +6477,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6468,7 +6486,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6496,7 +6514,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6505,7 +6523,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6514,7 +6532,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6523,7 +6541,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6532,7 +6550,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6541,7 +6559,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6550,7 +6568,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6559,7 +6577,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6594,7 +6612,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6603,7 +6621,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6612,7 +6630,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6621,7 +6639,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6630,7 +6648,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6639,7 +6657,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6648,7 +6666,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6676,7 +6694,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PublishResponse"]; + "application/json": components["schemas"]["PublishResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6685,7 +6703,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6694,7 +6712,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6703,7 +6721,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6712,7 +6730,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6721,7 +6739,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6730,7 +6748,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6739,7 +6757,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; @@ -6767,7 +6785,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ReleaseEditResponse"]; + "application/json": components["schemas"]["ReleaseEditResponseEnvelope"]; }; }; /** @description Bad Request */ @@ -6776,7 +6794,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unauthorized */ @@ -6785,7 +6803,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Forbidden */ @@ -6794,7 +6812,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Not Found */ @@ -6803,7 +6821,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Conflict */ @@ -6812,7 +6830,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Unprocessable Content */ @@ -6821,7 +6839,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; /** @description Internal Server Error */ @@ -6830,7 +6848,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/problem+json": components["schemas"]["ProblemDetails"]; + "application/json": components["schemas"]["ErrorEnvelope"]; }; }; }; diff --git a/src/features/tech-log/contracts/management/management-api.openapi.yaml b/src/features/tech-log/contracts/management/management-api.openapi.yaml index 0a65f79..46e7563 100644 --- a/src/features/tech-log/contracts/management/management-api.openapi.yaml +++ b/src/features/tech-log/contracts/management/management-api.openapi.yaml @@ -5,11 +5,11 @@ info: description: |- ⚠ 봉투 결정(ADR-006) 부분 반영 — 이 파일에는 두 모양이 공존한다. - topics 4개와 projects 5개는 studio-v1.yaml과 같은 방식으로 변환했다 - (`application/json` + ErrorEnvelope / Envelope). 그 9개가 첫 구현 - 대상이자 첫 소비자이기 때문이다. + topics 4개, projects 5개, releases 7개를 studio-v1.yaml과 같은 방식으로 + 변환했다 (`application/json` + ErrorEnvelope / Envelope). 그 16개가 + 구현된 것이자 소비자가 있는 것이기 때문이다. - 나머지 70개는 아직 bare payload + `application/problem+json` + ProblemDetails + 나머지 63개는 아직 bare payload + `application/problem+json` + ProblemDetails 다. 구현에 착수할 때 같은 방식으로 따라온다 — 소비자가 없는 operation을 미리 변환해 두면 검증되지 않은 모양이 계약에 고정된다. @@ -3630,49 +3630,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/CreateDraftResponse' + $ref: '#/components/schemas/CreateDraftResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3705,37 +3705,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseIndexPage' + $ref: '#/components/schemas/ReleaseIndexPageEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] /api/v1/studio/releases/{id}: @@ -3756,37 +3756,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' security: - sessionCookie: [] put: @@ -3807,49 +3807,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3876,45 +3876,45 @@ paths: '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -3942,49 +3942,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PublishResponse' + $ref: '#/components/schemas/PublishResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -4012,49 +4012,49 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ReleaseEditResponse' + $ref: '#/components/schemas/ReleaseEditResponseEnvelope' '400': description: Bad Request content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '401': description: Unauthorized content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '403': description: Forbidden content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '404': description: Not Found content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '409': description: Conflict content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '422': description: Unprocessable Content content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' '500': description: Internal Server Error content: - application/problem+json: + application/json: schema: - $ref: '#/components/schemas/ProblemDetails' + $ref: '#/components/schemas/ErrorEnvelope' requestBody: required: true content: @@ -5267,6 +5267,9 @@ components: - PROJECT_NOT_FOUND - PROJECT_SLUG_TAKEN - PROJECT_IN_USE + - RELEASE_NOT_FOUND + - RELEASE_VERSION_TAKEN + - RELEASE_NOT_PUBLISHABLE - INTERNAL_ERROR description: '`INTERNAL_ERROR` 는 이 기능이 아니라 스켈레톤의 공통 처리기가 내는 코드다. 계약이 그것까지 열거해야 500 응답이 계약을 벗어나지 않는다.' category: @@ -5392,6 +5395,51 @@ components: $ref: '#/components/schemas/ProjectIndexPage' meta: $ref: '#/components/schemas/ResponseMeta' + ReleaseIndexPageEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseIndexPage' + meta: + $ref: '#/components/schemas/ResponseMeta' + ReleaseEditResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/ReleaseEditResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' + PublishResponseEnvelope: + type: object + additionalProperties: false + required: + - success + - data + - meta + properties: + success: + type: boolean + const: true + data: + $ref: '#/components/schemas/PublishResponse' + meta: + $ref: '#/components/schemas/ResponseMeta' ProjectEditResponseEnvelope: type: object additionalProperties: false diff --git a/src/features/tech-log/contracts/public-site-config.ts b/src/features/tech-log/contracts/public-site-config.ts index 97a27b4..7400033 100644 --- a/src/features/tech-log/contracts/public-site-config.ts +++ b/src/features/tech-log/contracts/public-site-config.ts @@ -4,5 +4,8 @@ export const publicSiteConfig = Object.freeze({ operator: "동현", contactLabel: "프로필", contactPath: "/profile", - latestRelease: "/releases/0.1.0", + // 변경 기록 목록. 이전에는 특정 버전(`/releases/0.1.0`)을 박아 두었는데, 그 릴리즈가 아직 + // 없어서 푸터 링크가 404 였고, 있었더라도 다음 버전이 나오면 다시 낡는 자리였다. 목록은 어떤 + // 버전이 최신인지 아는 유일한 곳이고 릴리즈가 하나도 없어도 성립한다. + releasesPath: "/releases", }); diff --git a/src/features/tech-log/contracts/public/canonical-source.json b/src/features/tech-log/contracts/public/canonical-source.json index 1b36765..ddea845 100644 --- a/src/features/tech-log/contracts/public/canonical-source.json +++ b/src/features/tech-log/contracts/public/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/public-contract", "version": "2.0.0", "digest": "sha256:8ac71425b38658f34641102b4c2e6e21288c811efebdb92c0a46fb9d4790e23e", - "sourceRevision": "6ef5c1c", + "sourceRevision": "0c10a4a", "operationIds": [ "getPublicSite", "getPublicHome", diff --git a/src/features/tech-log/contracts/studio/canonical-source.json b/src/features/tech-log/contracts/studio/canonical-source.json index cf93a8e..d0cbe28 100644 --- a/src/features/tech-log/contracts/studio/canonical-source.json +++ b/src/features/tech-log/contracts/studio/canonical-source.json @@ -2,7 +2,7 @@ "packageId": "@tech-log/studio-contract", "version": "3.0.0", "digest": "sha256:6cae9924403d0761f401643a022980b8e04183eea0d890c143c9fbbbbc7431e4", - "sourceRevision": "6ef5c1c", + "sourceRevision": "0c10a4a", "operationIds": [ "getStudioSession", "getStudioDashboard", diff --git a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts index 17275c6..d8b2248 100644 --- a/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts +++ b/src/features/tech-log/contracts/tech-log-management-contract-contribution.ts @@ -31,6 +31,9 @@ const MANAGEMENT_ERROR_CODES = Object.freeze([ "PROJECT_NOT_FOUND", "PROJECT_SLUG_TAKEN", "PROJECT_IN_USE", + "RELEASE_NOT_FOUND", + "RELEASE_VERSION_TAKEN", + "RELEASE_NOT_PUBLISHABLE", "INTERNAL_ERROR", ]); @@ -155,6 +158,7 @@ const byId = (input: never) => { const T = "/api/v1/studio/topics"; const P = "/api/v1/studio/projects"; +const R = "/api/v1/studio/releases"; const HTTP_CONTRACTS = Object.freeze([ readOperation("listStudioTopics", T, 131_072), @@ -248,6 +252,84 @@ const HTTP_CONTRACTS = Object.freeze([ }); }, ), + readOperation("listStudioReleases", R, 262_144, (input: never) => { + const value = input as unknown as Readonly<{ page?: number; size?: number }> | undefined; + const entries: (readonly [string, string])[] = []; + if (value?.page !== undefined) entries.push(["page", String(value.page)]); + if (value?.size !== undefined) entries.push(["size", String(value.size)]); + return Object.freeze({ pathValues: NO_PATH, queryEntries: Object.freeze(entries) }); + }), + readOperation("getReleaseForEdit", `${R}/{id}`, 262_144, byId), + writeOperation( + "createRelease", + "POST", + R, + { acceptedStatuses: [201], requestByteLimit: 4_096, responseByteLimit: 8_192 }, + (input: never) => + Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }), + ), + writeOperation( + "updateRelease", + "PUT", + `${R}/{id}`, + // 릴리즈 본문은 마크다운 여섯 구획이라 문서 다음으로 큰 요청이다. + { acceptedStatuses: [200], requestByteLimit: 262_144, responseByteLimit: 262_144 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; body: unknown }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: value.body, + }); + }, + ), + writeOperation( + "deleteRelease", + "DELETE", + `${R}/{id}`, + { + acceptedStatuses: [204], + emptyBodyStatuses: [204], + requestByteLimit: 1_024, + responseByteLimit: 1_024, + }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), + writeOperation( + "publishRelease", + "POST", + `${R}/{id}/publish`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), + writeOperation( + "archiveRelease", + "POST", + `${R}/{id}/archive`, + { acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 }, + (input: never) => { + const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>; + return Object.freeze({ + pathValues: Object.freeze({ id: value.id }), + queryEntries: NO_QUERY, + body: { expectedVersion: value.expectedVersion }, + }); + }, + ), ]); export const TECH_LOG_MANAGEMENT_OPERATION_IDS = Object.freeze( diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts index 6c5292e..836907b 100644 --- a/src/features/tech-log/contracts/tech-log-route-contract.ts +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -49,6 +49,7 @@ const TECH_LOG_ROUTE_SPECS = [ defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }), + defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), ] as const; diff --git a/src/features/tech-log/presentation/public/public-shell.tsx b/src/features/tech-log/presentation/public/public-shell.tsx index 2a8d72e..e9b5c7e 100644 --- a/src/features/tech-log/presentation/public/public-shell.tsx +++ b/src/features/tech-log/presentation/public/public-shell.tsx @@ -29,7 +29,7 @@ export function PublicShell({ children, currentPath }: PublicShellProps) { {publicSiteConfig.contactLabel} - 최신 Release + 변경 기록 diff --git a/src/features/tech-log/presentation/studio/components/release-manager.tsx b/src/features/tech-log/presentation/studio/components/release-manager.tsx new file mode 100644 index 0000000..72c895f --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/release-manager.tsx @@ -0,0 +1,427 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import type { + ReleaseEditResponse, + ReleaseIndexItem, + ReleaseUpdateRequest, +} from "../../../contracts/management/contract.ts"; +import { useStudio } from "../use-studio.ts"; + +/** + * 릴리즈 작성. + * + *

공개 사이트에 변경 기록 화면과 푸터 링크가 있는데 둘 다 비어 있었다 — 읽기는 처음부터 + * 있었고 쓰는 곳이 없었다. 그래서 이 화면의 범위는 목록·작성·발행이다. + * + *

본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 + * 자유 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다. + * + *

새 CSS 를 만들지 않는다 — 문서 편집기가 쓰는 field 클래스와 작업본 목록의 행 클래스를 그대로 + * 재사용하므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다. + */ + +/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */ +const CHANGE_TYPES = [ + { value: "FEATURE", label: "기능" }, + { value: "FIX", label: "수정" }, + { value: "REFACTOR", label: "구조" }, + { value: "DOCS", label: "문서" }, + { value: "INFRA", label: "인프라" }, + { value: "BREAKING", label: "호환 깨짐" }, +] as const; + +const SECTIONS = [ + { key: "reasonMarkdown", label: "왜 바꿨나" }, + { key: "changesMarkdown", label: "무엇을 바꿨나" }, + { key: "userImpactMarkdown", label: "사용자에게 달라지는 것" }, + { key: "implementationImpactMarkdown", label: "구현에 남는 것" }, + { key: "verificationMarkdown", label: "어떻게 검증했나" }, + { key: "knownLimitationsMarkdown", label: "아직 못 한 것" }, +] as const; + +type Draft = Readonly<{ + expectedVersion: number; + versionLabel: string; + title: string; + summary: string; + releasedOn: string; + changeTypes: readonly string[]; + reasonMarkdown: string; + changesMarkdown: string; + userImpactMarkdown: string; + implementationImpactMarkdown: string; + verificationMarkdown: string; + knownLimitationsMarkdown: string; +}>; + +function toDraft(release: ReleaseEditResponse): Draft { + return { + expectedVersion: release.version, + versionLabel: release.versionLabel, + title: release.title, + summary: release.summary ?? "", + releasedOn: release.releasedOn ?? "", + changeTypes: release.changeTypes ?? [], + reasonMarkdown: release.reasonMarkdown ?? "", + changesMarkdown: release.changesMarkdown ?? "", + userImpactMarkdown: release.userImpactMarkdown ?? "", + implementationImpactMarkdown: release.implementationImpactMarkdown ?? "", + verificationMarkdown: release.verificationMarkdown ?? "", + knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "", + }; +} + +const STATUS_LABELS: Readonly> = { + DRAFT: "작성 중", + PUBLISHED: "공개", + ARCHIVED: "보관", +}; + +export function ReleaseManager() { + const { managementGateway, setRequestAnnouncement } = useStudio(); + const [releases, setReleases] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + const [generation, setGeneration] = useState(0); + const [newTitle, setNewTitle] = useState(""); + + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(""); + void managementGateway.listReleases(0, 50).then( + (page) => { + if (cancelled) return; + setReleases(page.items ?? []); + setLoading(false); + }, + () => { + if (cancelled) return; + setError("릴리즈를 불러오지 못했습니다."); + setLoading(false); + }, + ); + return () => { + cancelled = true; + }; + }, [managementGateway, generation]); + + // 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다. + useEffect(() => { + if (selectedId === null) { + setDraft(null); + return undefined; + } + let cancelled = false; + void managementGateway.getRelease(selectedId).then( + (release) => { + if (!cancelled) setDraft(toDraft(release)); + }, + () => { + if (!cancelled) setError("릴리즈를 불러오지 못했습니다."); + }, + ); + return () => { + cancelled = true; + }; + }, [managementGateway, selectedId, generation]); + + const update = (patch: Partial) => + setDraft((current) => (current === null ? current : { ...current, ...patch })); + + const submitNew = async (event: FormEvent) => { + event.preventDefault(); + if (pending) return; + const title = newTitle.trim(); + if (!title) { + setError("릴리즈 제목을 입력해 주세요."); + return; + } + setPending(true); + setError(""); + try { + const created = await managementGateway.createRelease(title); + setNewTitle(""); + setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`); + setSelectedId(created.id); + reload(); + } catch { + setError("릴리즈를 만들지 못했습니다."); + } finally { + setPending(false); + } + }; + + const save = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + const body: ReleaseUpdateRequest = { + expectedVersion: draft.expectedVersion, + versionLabel: draft.versionLabel.trim(), + title: draft.title.trim(), + summary: draft.summary, + changeTypes: [...draft.changeTypes], + changesMarkdown: draft.changesMarkdown, + verificationMarkdown: draft.verificationMarkdown, + ...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}), + reasonMarkdown: draft.reasonMarkdown, + userImpactMarkdown: draft.userImpactMarkdown, + implementationImpactMarkdown: draft.implementationImpactMarkdown, + knownLimitationsMarkdown: draft.knownLimitationsMarkdown, + } as ReleaseUpdateRequest; + const saved = await managementGateway.updateRelease(selectedId, body); + setDraft(toDraft(saved)); + setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`); + reload(); + } catch { + setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다."); + } finally { + setPending(false); + } + }; + + const publish = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion); + setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`); + reload(); + } catch { + // 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로 + // 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다. + setError( + "공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.", + ); + } finally { + setPending(false); + } + }; + + const archive = async () => { + if (pending || draft === null || selectedId === null) return; + setPending(true); + setError(""); + try { + await managementGateway.archiveRelease(selectedId, draft.expectedVersion); + setRequestAnnouncement("릴리즈를 공개에서 내렸습니다."); + reload(); + } catch { + setError("공개에서 내리지 못했습니다."); + } finally { + setPending(false); + } + }; + + const remove = async (release: ReleaseIndexItem) => { + if (pending) return; + setPending(true); + setError(""); + try { + await managementGateway.deleteRelease(release.id, release.version); + if (selectedId === release.id) setSelectedId(null); + setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`); + reload(); + } catch { + setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다."); + } finally { + setPending(false); + } + }; + + const toggleChangeType = (value: string) => { + if (draft === null) return; + const next = draft.changeTypes.includes(value) + ? draft.changeTypes.filter((entry) => entry !== value) + : [...draft.changeTypes, value]; + update({ changeTypes: next }); + }; + + return ( +

+
+
+

RELEASES

+

릴리즈

+

무엇을 왜 바꿨는지 버전으로 묶어 공개 변경 기록에 남깁니다.

+
+
+ +
+
+ +
+ setNewTitle(event.target.value)} + /> + +
+
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

불러오는 중입니다.

+ ) : (releases?.length ?? 0) === 0 ? ( +

아직 릴리즈가 없습니다.

+ ) : ( +
+

{releases?.length}개의 릴리즈

+
+ {(releases ?? []).map((release) => ( +
+
+

{release.title}

+

+ {release.versionLabel} + {release.releasedOn ? ` · ${release.releasedOn}` : ""} +

+
+
+
+
상태
+
{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}
+
+
+ + +
+ ))} +
+
+ )} + + {draft === null ? null : ( +
+

{draft.title || "릴리즈"} 편집

+
+ + + +