From 9a681e278609e76b7965b50001f86881d6098d12 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 4 Nov 2026 05:43:18 +0000 Subject: [PATCH 01/13] Build an inline extension management workspace Unify owner previews and section editing with local drafts, pending submission updates, a compact overview sidebar, and separate release management. Detect source hosts from URLs and derive the latest version from releases. Uses the pending-revision replacement endpoint added in FOSSBilling/api commit 5c903d9. --- openapi/extensions-v2.json | 229 ++++++-- src/components/DetailsCard.astro | 10 +- src/components/ExtensionForm.astro | 27 +- src/components/ExtensionReleases.astro | 93 +++ src/components/ExtensionSectionFields.astro | 145 +++++ src/components/ExtensionWorkspace.astro | 476 +++++++++++++++ src/components/icons/GitHubMark.astro | 34 ++ src/layouts/Base.astro | 2 +- src/lib/api/client.ts | 14 + src/lib/api/generated/extensions-v2/index.ts | 6 + .../api/generated/extensions-v2/sdk.gen.ts | 61 +- .../api/generated/extensions-v2/types.gen.ts | 130 ++++- src/lib/extension-draft.ts | 261 +++++++++ src/lib/extension-form.ts | 33 +- src/lib/license.ts | 11 + src/lib/releases.ts | 36 ++ src/lib/repository.ts | 69 +++ src/pages/account/extensions/[id]/draft.ts | 90 +++ src/pages/account/extensions/[id]/edit.astro | 155 +---- src/pages/account/extensions/[id]/index.astro | 366 ++++++++++++ src/pages/account/extensions/[id]/withdraw.ts | 4 +- src/pages/account/extensions/new.astro | 2 +- src/pages/account/index.astro | 23 +- src/scripts/extension-workspace.ts | 551 ++++++++++++++++++ src/types/index.ts | 38 +- test/extension-draft-submit.test.ts | 183 ++++++ test/extension-draft.test.ts | 282 +++++++++ test/extension-form.test.ts | 28 +- test/license.test.ts | 38 ++ test/releases.test.ts | 65 +++ test/repository.test.ts | 74 +++ 31 files changed, 3221 insertions(+), 315 deletions(-) create mode 100644 src/components/ExtensionReleases.astro create mode 100644 src/components/ExtensionSectionFields.astro create mode 100644 src/components/ExtensionWorkspace.astro create mode 100644 src/components/icons/GitHubMark.astro create mode 100644 src/lib/extension-draft.ts create mode 100644 src/lib/license.ts create mode 100644 src/lib/releases.ts create mode 100644 src/lib/repository.ts create mode 100644 src/pages/account/extensions/[id]/draft.ts create mode 100644 src/pages/account/extensions/[id]/index.astro create mode 100644 src/scripts/extension-workspace.ts create mode 100644 test/extension-draft-submit.test.ts create mode 100644 test/extension-draft.test.ts create mode 100644 test/license.test.ts create mode 100644 test/releases.test.ts create mode 100644 test/repository.test.ts diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index d3199f73..4b437ff5 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -2161,10 +2161,10 @@ } } }, - "/extensions/{id}/revisions": { - "get": { + "/extensions/{id}/revisions/{revisionId}": { + "put": { "tags": ["Extensions"], - "summary": "List an extension's revisions, newest first", + "summary": "Replace the pending submission the owner opened", "security": [ { "Bearer": [] @@ -2174,53 +2174,57 @@ { "schema": { "type": "string", - "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + "example": "acme-gateway" }, "required": true, "name": "id", "in": "path" }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 1000 + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" }, - "required": false, - "name": "cursor", - "in": "query" + "required": true, + "name": "revisionId", + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionUpdate" + } + } + } + }, "responses": { - "200": { - "description": "Every version proposed for this extension, with its review outcome", + "202": { + "description": "Pending content replaced with a new revision id. The old pending id is removed; reviewed history and published content are unchanged.", "content": { "application/json": { "schema": { "type": "object", "properties": { "result": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExtensionRevisionSummary" - } - }, - "pagination": { - "$ref": "#/components/schemas/Pagination" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending"] + } + }, + "required": ["id", "revision_id", "status"] } }, - "required": ["result", "pagination"] + "required": ["result"] } } } @@ -2236,7 +2240,7 @@ } }, "403": { - "description": "The account is inactive, or the caller neither owns this extension nor moderates", + "description": "The account is inactive, or the caller does not own this extension", "content": { "application/json": { "schema": { @@ -2255,8 +2259,38 @@ } } }, + "409": { + "description": "The pending revision changed, was reviewed, ownership changed, or the pending-revision limit was reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Raw request exceeds 512 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "Pagination query failed validation", + "description": "Body failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Extension write allowance exhausted; see Retry-After", "content": { "application/json": { "schema": { @@ -2274,11 +2308,19 @@ } } } + }, + "503": { + "description": "Write admission unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } - } - }, - "/extensions/{id}/revisions/{revisionId}": { + }, "get": { "tags": ["Extensions"], "summary": "Read one revision; compacted content is null", @@ -2387,6 +2429,123 @@ } } }, + "/extensions/{id}/revisions": { + "get": { + "tags": ["Extensions"], + "summary": "List an extension's revisions, newest first", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "required": false, + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Every version proposed for this extension, with its review outcome", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionRevisionSummary" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": ["result", "pagination"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The account is inactive, or the caller neither owns this extension nor moderates", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Pagination query failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/users/me/identity": { "put": { "tags": ["Users"], diff --git a/src/components/DetailsCard.astro b/src/components/DetailsCard.astro index 11169c4d..f054e3f6 100644 --- a/src/components/DetailsCard.astro +++ b/src/components/DetailsCard.astro @@ -2,6 +2,7 @@ import type { Extension } from '@/types'; import { getLatestRelease, repositoryURL } from '@/types'; import { isSafeHttpUrl } from '@/lib/safe-url'; +import { licenseUrl } from '@/lib/license'; import { CircleCheck, Download, GitBranch, Scale, User } from '@lucide/astro'; interface Props { @@ -36,10 +37,10 @@ const details: DetailsItem[] = [ link: `/developer/${ext.developer.id}`, }, { - name: 'Current Version', + name: 'Latest Version', icon: CircleCheck, color: 'green', - text: `v${ext.version}`, + text: `v${(latest?.tag ?? ext.version).replace(/^v/, '')}`, }, { name: 'FOSSBilling Version', @@ -54,10 +55,7 @@ const details: DetailsItem[] = [ icon: Scale, color: 'blue', text: ext.license.name, - link: - ext.license.URL && isSafeHttpUrl(ext.license.URL) - ? ext.license.URL - : undefined, + link: licenseUrl(ext.license), }, { name: 'Source Code', diff --git a/src/components/ExtensionForm.astro b/src/components/ExtensionForm.astro index 6543222a..034e7d95 100644 --- a/src/components/ExtensionForm.astro +++ b/src/components/ExtensionForm.astro @@ -1,5 +1,5 @@ --- -import { EXTENSION_TYPES, SOURCE_TYPES, SPDX_LICENSE_IDS } from '@/types'; +import { EXTENSION_TYPES, SPDX_LICENSE_IDS, repositoryURL } from '@/types'; import type { Developer, Extension } from '@/types'; import { CircleAlert, SquarePen, ChevronDown } from '@lucide/astro'; import { stripUrlScheme } from '@/lib/url-prefix'; @@ -365,27 +365,18 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id));
Source
- - -
-
-
diff --git a/src/components/ExtensionReleases.astro b/src/components/ExtensionReleases.astro new file mode 100644 index 00000000..0e58dd0c --- /dev/null +++ b/src/components/ExtensionReleases.astro @@ -0,0 +1,93 @@ +--- +import type { Release } from '@/types'; +import { latestRelease, releasesDescending } from '@/lib/releases'; +import { isSafeHttpUrl } from '@/lib/safe-url'; +interface Props { + releases: Release[]; + editable: boolean; + disabledReason?: string; +} +const { releases, editable, disabledReason } = Astro.props; +const latest = latestRelease(releases); +--- + + +{releases.length === 0 && ( +

+ No releases are available for this version. +

+)} diff --git a/src/components/ExtensionSectionFields.astro b/src/components/ExtensionSectionFields.astro new file mode 100644 index 00000000..807c6305 --- /dev/null +++ b/src/components/ExtensionSectionFields.astro @@ -0,0 +1,145 @@ +--- +import type { ExtensionUpdate } from '@/lib/api/client'; +import type { DraftSection } from '@/lib/extension-draft'; +import { + EXTENSION_TYPES, + SPDX_LICENSE_IDS, + EXTENSION_TYPE_LABELS, + repositoryURL, +} from '@/types'; +import { OTHER_LICENSE } from '@/lib/extension-form'; +interface Props { + section: DraftSection; + content: ExtensionUpdate; +} +const { section, content } = Astro.props; +--- + +{section === 'listing' && ( + <> + + + + + + +)} +{section === 'readme' && ( + <> + +

+ Save to draft to preview your README below. +

+ +)} +{(section === 'listing' || section === 'details') && ( + <> + + + + + +)} diff --git a/src/components/ExtensionWorkspace.astro b/src/components/ExtensionWorkspace.astro new file mode 100644 index 00000000..6aa0acae --- /dev/null +++ b/src/components/ExtensionWorkspace.astro @@ -0,0 +1,476 @@ +--- +import type { StoredExtensionFields } from '@/lib/extensions-data'; +import { editableContent } from '@/lib/extension-draft'; +import ExtensionReleases from './ExtensionReleases.astro'; +import { EXTENSION_TYPE_LABELS, repositoryURL } from '@/types'; +import { renderMarkdown } from '@/lib/markdown'; +import { sha256Hex } from '@/lib/hash'; +import { licenseUrl } from '@/lib/license'; +import ExtensionSectionFields from './ExtensionSectionFields.astro'; +import type { DraftSection } from '@/lib/extension-draft'; +import GitHubMark from '@/components/icons/GitHubMark.astro'; +import { isSafeHttpUrl } from '@/lib/safe-url'; +import { Pencil, Plus, Link } from '@lucide/astro'; +interface Props { + content: StoredExtensionFields; + id: string; + userId: string; + releases: boolean; + allowEdit?: boolean; + pendingVersion?: boolean; + pendingRevisionId?: string; + disabledReason?: string; + pendingEditUrl?: string; +} +const { + content, + id, + userId, + releases, + allowEdit = true, + pendingVersion = false, + pendingRevisionId, + disabledReason, + pendingEditUrl, +} = Astro.props; +const editorContent = allowEdit ? editableContent(content) : null; +const releaseItems = content.releases ?? []; +const sourceUrl = content.source ? repositoryURL(content.source) : undefined; +const licenseHref = licenseUrl(content.license); +const baseUrl = `/account/extensions/${id}`; +const selectedVersion = pendingVersion ? 'pending' : 'published'; +const overviewUrl = `${baseUrl}?version=${selectedVersion}`; +const releasesUrl = `${baseUrl}?view=releases&version=${selectedVersion}`; +const config = editorContent + ? JSON.stringify({ + base: editorContent, + pendingRevisionId, + submitLabel: pendingRevisionId + ? 'Update Pending Submission' + : 'Submit for Review', + baselineHash: await sha256Hex(JSON.stringify(editorContent)), + key: `extension-draft:${await sha256Hex(userId)}:${id}`, + submitUrl: `${baseUrl}/draft`, + baseUrl, + }).replace(/ = { + listing: 'Overview', + readme: 'README', + details: 'Source & License', +}; +--- + +
+ {editorContent && ( + <> + diff --git a/src/components/icons/GitHubMark.astro b/src/components/icons/GitHubMark.astro new file mode 100644 index 00000000..44aa4e99 --- /dev/null +++ b/src/components/icons/GitHubMark.astro @@ -0,0 +1,34 @@ + + + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index fe16f9b1..f3c1dcd0 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -128,7 +128,7 @@ const shareImageUrl = image ? new URL(image, Astro.site).href : undefined;