From f6e22d377cc0f339674156b8023b1ec626b90829 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 13 Oct 2026 23:27:45 +0200 Subject: [PATCH] feat: link an existing repository to a request claim - POST /api/requests/:id/repo records a repository the claimer maintains (same maintain/admin check as submitting a plugin); null unlinks it. Emits the new request.repo_linked webhook event. - GET /api/submit/owners lists the GitHub account and its organizations, the valid owners for a scaffolded repository. - Both are exposed over MCP (linkRequestRepo, listRepoOwners). - The scaffold dialog gains a "link an existing one" mode and an organization select. --- apps/api/src/lib/mcp/catalog.ts | 4 +- apps/api/src/lib/scaffold/github.ts | 16 ++ apps/api/src/lib/webhooks/events.ts | 7 +- apps/api/src/routes/api/requests/[id]/repo.ts | 124 +++++++++++++ apps/api/src/routes/api/submit/owners.ts | 53 ++++++ apps/api/tests/routes/request-repo.test.ts | 125 ++++++++++++++ apps/docs/src/routes/admin/features/+page.md | 2 +- apps/frontend/messages/de.json | 9 +- apps/frontend/messages/en.json | 9 +- apps/frontend/messages/es.json | 9 +- apps/frontend/messages/fr.json | 9 +- apps/frontend/messages/it.json | 9 +- apps/frontend/messages/zh-CN.json | 9 +- .../components/requests/ScaffoldDialog.svelte | 163 +++++++++++++++++- 14 files changed, 530 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/routes/api/requests/[id]/repo.ts create mode 100644 apps/api/src/routes/api/submit/owners.ts create mode 100644 apps/api/tests/routes/request-repo.test.ts diff --git a/apps/api/src/lib/mcp/catalog.ts b/apps/api/src/lib/mcp/catalog.ts index b9674e6..3312742 100644 --- a/apps/api/src/lib/mcp/catalog.ts +++ b/apps/api/src/lib/mcp/catalog.ts @@ -26,7 +26,7 @@ const groups: Record = { 'getI18nConfig', 'listProviders', ], - 'account:read': ['getMe', 'listTransfers', 'listSubmittableRepos'], + 'account:read': ['getMe', 'listTransfers', 'listSubmittableRepos', 'listRepoOwners'], 'plugins:write': [ 'publishRelease', 'submitPreview', @@ -38,7 +38,7 @@ const groups: Record = { 'authorRehashRelease', 'authorYankRelease', ], - 'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote', 'scaffoldRequest'], + 'requests:write': ['createRequest', 'toggleRequestClaim', 'toggleUpvote', 'scaffoldRequest', 'linkRequestRepo'], } // Explicit allowlist: new REST endpoints are never exposed automatically. const adminOperations = new Set([ diff --git a/apps/api/src/lib/scaffold/github.ts b/apps/api/src/lib/scaffold/github.ts index 8219784..4a902ad 100644 --- a/apps/api/src/lib/scaffold/github.ts +++ b/apps/api/src/lib/scaffold/github.ts @@ -60,6 +60,22 @@ export async function grantedScopes( } } +export type RepoOwner = { login: string; type: 'user' | 'org'; avatarUrl: string | null } + +/** The user's own account, then every org they belong to (needs `read:org` for private memberships). */ +export async function listGithubOwners(instance: ProviderInstance, token: string): Promise { + const gh = client(instance, token) + type Account = { login: string; avatar_url?: string } + const { data: me } = await gh('GET', '/user') + const { data: orgs } = await gh('GET', '/user/orgs?per_page=100') + return [ + { login: me.login, type: 'user' as const, avatarUrl: me.avatar_url ?? null }, + ...orgs + .map((o) => ({ login: o.login, type: 'org' as const, avatarUrl: o.avatar_url ?? null })) + .sort((a, b) => a.login.localeCompare(b.login)), + ] +} + export type CreatedRepo = { owner: string; name: string; fullName: string; htmlUrl: string; defaultBranch: string } /** diff --git a/apps/api/src/lib/webhooks/events.ts b/apps/api/src/lib/webhooks/events.ts index 1c5a864..3dc8e8c 100644 --- a/apps/api/src/lib/webhooks/events.ts +++ b/apps/api/src/lib/webhooks/events.ts @@ -26,6 +26,7 @@ export const WEBHOOK_EVENTS = [ 'request.claimed', 'request.unclaimed', 'request.scaffolded', + 'request.repo_linked', 'request.fulfilled', ] as const @@ -53,6 +54,7 @@ const META: Record = { 'request.claimed': { title: 'Plugin request claimed', color: 0x06b6d4 }, 'request.unclaimed': { title: 'Plugin request claim released', color: 0x64748b }, 'request.scaffolded': { title: 'Plugin repository created', color: 0x8b5cf6 }, + 'request.repo_linked': { title: 'Plugin repository linked', color: 0x8b5cf6 }, 'request.fulfilled': { title: 'Plugin request fulfilled', color: 0x22c55e }, } @@ -293,7 +295,10 @@ export function sampleContext(type: WebhookEvent): EventContext { kind: null, status: SAMPLE_REQUEST_STATUS[type] ?? 'approved', claims: type === 'request.created' ? 0 : 1, - repoUrl: type === 'request.scaffolded' ? 'https://github.com/janedoe/example-plugin' : null, + repoUrl: + type === 'request.scaffolded' || type === 'request.repo_linked' + ? 'https://github.com/janedoe/example-plugin' + : null, url, } return ctx diff --git a/apps/api/src/routes/api/requests/[id]/repo.ts b/apps/api/src/routes/api/requests/[id]/repo.ts new file mode 100644 index 0000000..eee6cea --- /dev/null +++ b/apps/api/src/routes/api/requests/[id]/repo.ts @@ -0,0 +1,124 @@ +import { Elysia, t } from 'elysia' +import { and, eq } from 'drizzle-orm' +import { authMiddleware } from '$middleware/auth' +import { rateLimit } from '$middleware/rate-limit' +import { db } from '$db' +import { pluginRequestClaims } from '$db/schema' +import { hasClaimedRequest } from '$lib/claims' +import { getValidAccessToken, OAuthExpiredError } from '$lib/oauth-tokens' +import { checkOwnership, parseRepoUrl } from '$lib/providers' +import { claimerSchema, loadClaimers } from '$lib/request-view' +import { emitEvent, webBaseUrl } from '$lib/webhooks/events' + +const errorSchema = t.Object({ error: t.String(), reauthUrl: t.Optional(t.String()) }) + +export default new Elysia() + .use(authMiddleware) + .use(rateLimit({ bucket: 'request-repo', limit: 20, windowSeconds: 3600 })) + .post( + '/', + async ({ params, body, user, set }) => { + const request = await db.query.pluginRequests.findFirst({ where: { id: params.id } }) + if (!request) { + set.status = 404 + return { error: 'Request not found' } + } + if (!(await hasClaimedRequest(request.id, user.sub))) { + set.status = 409 + return { error: 'Claim this request before linking a repository.' } + } + const where = and(eq(pluginRequestClaims.requestId, request.id), eq(pluginRequestClaims.userId, user.sub)) + + // Unlinking needs no provider round-trip. + if (body.repoUrl === null) { + await db.update(pluginRequestClaims).set({ repoUrl: null, scaffoldedAt: null }).where(where) + const claimers = (await loadClaimers([request.id])).get(request.id) ?? [] + return { repoUrl: null, fullName: null, submitUrl: null, claimers } + } + + if (request.status !== 'approved') { + set.status = 409 + return { error: 'Only approved requests can get a repository.' } + } + const ref = parseRepoUrl(body.repoUrl) + if (!ref) { + set.status = 422 + return { error: 'Not a repository URL on a provider this registry knows.' } + } + + const identity = (await db.query.identities.findMany({ where: { userId: user.sub } })).find( + (i) => i.providerInstanceId === ref.instance.id, + ) + if (!identity?.accessToken) { + set.status = 412 + return { error: `Link a ${ref.instance.displayName} account to prove you maintain ${ref.fullName}.` } + } + let token: string + try { + token = await getValidAccessToken(identity, ref.instance) + } catch (err) { + if (!(err instanceof OAuthExpiredError)) throw err + set.status = 401 + return { + error: `Your ${ref.instance.displayName} session expired.`, + reauthUrl: `${webBaseUrl()}/auth/${encodeURIComponent(ref.instance.id)}?return_to=${encodeURIComponent(body.returnTo ?? `${webBaseUrl()}/requests`)}`, + } + } + // Same bar as submitting a plugin: maintain (or admin) on the repository. + const ownership = await checkOwnership(token, ref) + if (!ownership.owned) { + set.status = 403 + return { error: `${ref.fullName}: ${ownership.reason}` } + } + + const repoUrl = `${ref.instance.baseUrl}/${ref.fullName}` + await db.update(pluginRequestClaims).set({ repoUrl, scaffoldedAt: null }).where(where) + const claimers = (await loadClaimers([request.id])).get(request.id) ?? [] + await emitEvent('request.repo_linked', { + actor: { id: user.sub, name: user.username ?? null }, + request: { ...request, claims: claimers.length, repoUrl }, + }) + return { + repoUrl, + fullName: ref.fullName, + submitUrl: `${webBaseUrl()}/submit?repo=${encodeURIComponent(repoUrl)}`, + claimers, + } + }, + { + detail: { + tags: ['Requests'], + summary: 'Link an existing repository to your claim', + description: + "Records a repository you already maintain as the work for your claim on an approved request, instead of scaffolding a new one. The repository must be on a provider you have linked, with maintain or admin permission (the same check as submitting a plugin). Pass `repoUrl: null` to unlink. The request still ships only when a plugin with the request's slug is listed.", + operationId: 'linkRequestRepo', + security: [{ bearerAuth: [] }, { cookieAuth: [] }], + }, + params: t.Object({ id: t.String() }), + body: t.Object({ + repoUrl: t.Nullable( + t.String({ maxLength: 500, description: 'Repository URL, e.g. https://github.com/acme/my-plugin.' }), + ), + returnTo: t.Optional( + t.String({ + maxLength: 512, + description: 'Where to come back after re-authorizing. Must be a trusted origin.', + }), + ), + }), + response: { + 200: t.Object({ + repoUrl: t.Nullable(t.String()), + fullName: t.Nullable(t.String()), + submitUrl: t.Nullable(t.String()), + claimers: t.Array(claimerSchema), + }), + 401: errorSchema, + 403: errorSchema, + 404: errorSchema, + 409: errorSchema, + 412: errorSchema, + 422: errorSchema, + }, + }, + ) diff --git a/apps/api/src/routes/api/submit/owners.ts b/apps/api/src/routes/api/submit/owners.ts new file mode 100644 index 0000000..20fff48 --- /dev/null +++ b/apps/api/src/routes/api/submit/owners.ts @@ -0,0 +1,53 @@ +import { Elysia, t } from 'elysia' +import { authMiddleware } from '$middleware/auth' +import { db } from '$db' +import { getValidAccessToken, OAuthExpiredError } from '$lib/oauth-tokens' +import { getInstance } from '$lib/provider-instance' +import { listGithubOwners, GithubScaffoldError } from '$lib/scaffold/github' + +const ownerSchema = t.Object({ + login: t.String(), + type: t.Union([t.Literal('user'), t.Literal('org')]), + avatarUrl: t.Nullable(t.String()), +}) + +export default new Elysia().use(authMiddleware).get( + '/', + async ({ user, query, set }) => { + const candidates = await db.query.identities.findMany({ where: { userId: user.sub } }) + const identity = candidates.find( + (i) => (!query.identityId || i.id === query.identityId) && getInstance(i.providerInstanceId)?.kind === 'github', + ) + const instance = identity ? getInstance(identity.providerInstanceId) : undefined + if (!identity || !instance) { + set.status = 412 + return { error: 'Listing repository owners needs a linked GitHub account.' } + } + try { + const token = await getValidAccessToken(identity, instance) + return { identityId: identity.id, owners: await listGithubOwners(instance, token) } + } catch (err) { + if (err instanceof OAuthExpiredError || (err instanceof GithubScaffoldError && err.status === 401)) { + set.status = 401 + return { error: `Your ${instance.displayName} session expired.` } + } + throw err + } + }, + { + detail: { + tags: ['Submit'], + summary: 'List the accounts you can create repositories in', + description: + 'Your GitHub account first, then the organizations it belongs to: the valid `owner` values for scaffolding a request repository. Uses the first linked GitHub identity unless `identityId` is given. Organizations that restrict repository creation are still listed; GitHub refuses the scaffold if you lack the right.', + operationId: 'listRepoOwners', + security: [{ bearerAuth: [] }, { cookieAuth: [] }], + }, + query: t.Object({ identityId: t.Optional(t.String({ minLength: 1 })) }), + response: { + 200: t.Object({ identityId: t.String(), owners: t.Array(ownerSchema) }), + 401: t.Object({ error: t.String() }), + 412: t.Object({ error: t.String() }), + }, + }, +) diff --git a/apps/api/tests/routes/request-repo.test.ts b/apps/api/tests/routes/request-repo.test.ts new file mode 100644 index 0000000..eb9427c --- /dev/null +++ b/apps/api/tests/routes/request-repo.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, spyOn } from 'bun:test' +import { ulid } from 'ulid' +import { clearDb, makeUser, buildApp, userHeaders, type TestUser } from '../helpers' +import { db } from '../../src/db' +import { pluginRequests, pluginRequestClaims } from '../../src/db/schema' + +async function seed(user: TestUser) { + const row = { + id: ulid(), + slug: 'trino-presto', + name: 'Trino / Presto', + description: 'Trino driver', + kind: 'driver', + status: 'approved' as const, + requesterId: user.id, + } + await db.insert(pluginRequests).values(row) + return row +} + +async function call(user: TestUser, method: string, path: string, body?: unknown) { + const app = await buildApp() + return app.handle( + new Request(`http://localhost${path}`, { + method, + headers: { 'Content-Type': 'application/json', ...userHeaders(user) }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ) +} + +function mockGithub(routes: Record) { + const spy = spyOn(global, 'fetch').mockImplementation((async (input: string | URL | Request) => { + const url = new URL(String(input)) + const data = routes[url.pathname] + return data === undefined + ? new Response('{"message":"Not Found"}', { status: 404 }) + : new Response(JSON.stringify(data), { status: 200 }) + }) as unknown as typeof fetch) + return () => spy.mockRestore() +} + +describe('POST /api/requests/:id/repo', () => { + beforeEach(clearDb) + + it('requires a claim', async () => { + const user = await makeUser() + const row = await seed(user) + const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { + repoUrl: 'https://github.com/acme/trino', + }) + expect(res.status).toBe(409) + }) + + it('links a repository the user maintains and records it on the claim', async () => { + const user = await makeUser() + const row = await seed(user) + await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id }) + const restore = mockGithub({ '/repos/acme/trino': { permissions: { admin: false, maintain: true } } }) + const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { + repoUrl: 'https://github.com/acme/trino.git', + }) + restore() + + expect(res.status).toBe(200) + const data = (await res.json()) as { repoUrl: string; fullName: string; claimers: Array<{ repoUrl: string }> } + expect(data.repoUrl).toBe('https://github.com/acme/trino') + expect(data.fullName).toBe('acme/trino') + expect(data.claimers[0].repoUrl).toBe('https://github.com/acme/trino') + const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId: row.id } }) + expect(claim?.repoUrl).toBe('https://github.com/acme/trino') + }) + + it('refuses a repository without maintain permission', async () => { + const user = await makeUser() + const row = await seed(user) + await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id }) + const restore = mockGithub({ '/repos/acme/trino': { permissions: { push: true } } }) + const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { + repoUrl: 'https://github.com/acme/trino', + }) + restore() + expect(res.status).toBe(403) + }) + + it('refuses URLs outside the configured providers', async () => { + const user = await makeUser() + const row = await seed(user) + await db.insert(pluginRequestClaims).values({ requestId: row.id, userId: user.id }) + const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { + repoUrl: 'https://example.com/acme/trino', + }) + expect(res.status).toBe(422) + }) + + it('unlinks with a null repoUrl', async () => { + const user = await makeUser() + const row = await seed(user) + await db + .insert(pluginRequestClaims) + .values({ requestId: row.id, userId: user.id, repoUrl: 'https://github.com/acme/trino' }) + const res = await call(user, 'POST', `/api/requests/${row.id}/repo`, { repoUrl: null }) + expect(res.status).toBe(200) + const claim = await db.query.pluginRequestClaims.findFirst({ where: { requestId: row.id } }) + expect(claim?.repoUrl).toBeNull() + }) +}) + +describe('GET /api/submit/owners', () => { + beforeEach(clearDb) + + it('lists the account first, then its organizations', async () => { + const user = await makeUser() + const restore = mockGithub({ + '/user': { login: 'builder', avatar_url: 'https://avatars.example/u' }, + '/user/orgs': [{ login: 'zeta' }, { login: 'Acme', avatar_url: 'https://avatars.example/a' }], + }) + const res = await call(user, 'GET', '/api/submit/owners') + restore() + + expect(res.status).toBe(200) + const data = (await res.json()) as { owners: Array<{ login: string; type: string }> } + expect(data.owners.map((o) => `${o.type}:${o.login}`)).toEqual(['user:builder', 'org:Acme', 'org:zeta']) + }) +}) diff --git a/apps/docs/src/routes/admin/features/+page.md b/apps/docs/src/routes/admin/features/+page.md index 844396b..d8d4d4a 100644 --- a/apps/docs/src/routes/admin/features/+page.md +++ b/apps/docs/src/routes/admin/features/+page.md @@ -34,6 +34,6 @@ Plugin requests are reviewed before they become public bounties: Claimers of a `driver` or `theme` request can create a repository scaffolded by `@tabularis/create-plugin` from `/requests` (`POST /api/requests/:id/scaffold`). GitHub asks for the extra `workflow` scope the first time, because the scaffold includes CI workflows. -Each transition emits a webhook event: `request.created`, `request.approved`, `request.rejected`, `request.claimed`, `request.unclaimed`, `request.scaffolded` and `request.fulfilled`. The **Static site rebuild** preset posts them to a deploy hook, for sites that bake request data in at build time. +Each transition emits a webhook event: `request.created`, `request.approved`, `request.rejected`, `request.claimed`, `request.unclaimed`, `request.scaffolded`, `request.repo_linked` and `request.fulfilled`. The **Static site rebuild** preset posts them to a deploy hook, for sites that bake request data in at build time. To seed requests from a curated list in the tabularis.dev `pluginBounties.json` format, run `bun run import:bounties [file-or-url]` in `apps/api` (inside the running container: `bun scripts/import-bounties.ts`). It uses the same database as the server — `database.url` from the instance `config.json`, falling back to `DATABASE_URL`. Use `--dry-run` to preview. diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index 14c470a..1299df5 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -1217,5 +1217,12 @@ "requests_scaffold_done": "{repo} ist bereit.", "requests_scaffold_next": "Entwickle das Plugin, veröffentliche ein Release und reiche dann das Repository bei der Registry ein.", "requests_scaffold_open_repo": "Repository öffnen", - "requests_scaffold_submit": "Bei der Registry einreichen" + "requests_scaffold_submit": "Bei der Registry einreichen", + "requests_scaffold_mode_create": "Neues Repository", + "requests_scaffold_mode_link": "Vorhandenes verknüpfen", + "requests_scaffold_link_repo": "Repository-URL", + "requests_scaffold_link_hint": "Wähle eines deiner Repositories oder füge seine URL ein. Du brauchst Maintain-Rechte, wie beim Einreichen eines Plugins.", + "requests_scaffold_link": "Repository verknüpfen", + "requests_scaffold_linking": "Wird verknüpft…", + "requests_scaffold_linked": "{repo} ist mit deinem Claim verknüpft." } diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index 0711c45..b68bdf1 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -1242,5 +1242,12 @@ "requests_scaffold_done": "{repo} is ready.", "requests_scaffold_next": "Build the plugin, publish a release, then submit the repository to the registry.", "requests_scaffold_open_repo": "Open repository", - "requests_scaffold_submit": "Submit to the registry" + "requests_scaffold_submit": "Submit to the registry", + "requests_scaffold_mode_create": "New repository", + "requests_scaffold_mode_link": "Link an existing one", + "requests_scaffold_link_repo": "Repository URL", + "requests_scaffold_link_hint": "Pick one of your repositories or paste its URL. You need maintain access, as when submitting a plugin.", + "requests_scaffold_link": "Link repository", + "requests_scaffold_linking": "Linking…", + "requests_scaffold_linked": "{repo} is linked to your claim." } diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 1de328f..ecccac8 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -1217,5 +1217,12 @@ "requests_scaffold_done": "{repo} está listo.", "requests_scaffold_next": "Desarrolla el plugin, publica una release y luego envía el repositorio al registro.", "requests_scaffold_open_repo": "Abrir repositorio", - "requests_scaffold_submit": "Enviar al registro" + "requests_scaffold_submit": "Enviar al registro", + "requests_scaffold_mode_create": "Repositorio nuevo", + "requests_scaffold_mode_link": "Vincular uno existente", + "requests_scaffold_link_repo": "URL del repositorio", + "requests_scaffold_link_hint": "Elige uno de tus repositorios o pega su URL. Necesitas permiso de maintain, igual que para enviar un plugin.", + "requests_scaffold_link": "Vincular repositorio", + "requests_scaffold_linking": "Vinculando…", + "requests_scaffold_linked": "{repo} está vinculado a tu reclamación." } diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 393fb83..2e45cf3 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -1217,5 +1217,12 @@ "requests_scaffold_done": "{repo} est prêt.", "requests_scaffold_next": "Développez le plugin, publiez une release, puis soumettez le dépôt au registre.", "requests_scaffold_open_repo": "Ouvrir le dépôt", - "requests_scaffold_submit": "Soumettre au registre" + "requests_scaffold_submit": "Soumettre au registre", + "requests_scaffold_mode_create": "Nouveau dépôt", + "requests_scaffold_mode_link": "Lier un dépôt existant", + "requests_scaffold_link_repo": "URL du dépôt", + "requests_scaffold_link_hint": "Choisissez l'un de vos dépôts ou collez son URL. Il faut le droit maintain, comme pour soumettre un plugin.", + "requests_scaffold_link": "Lier le dépôt", + "requests_scaffold_linking": "Liaison…", + "requests_scaffold_linked": "{repo} est lié à votre réservation." } diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index 72ddb95..ce258af 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -1242,5 +1242,12 @@ "requests_scaffold_done": "{repo} è pronto.", "requests_scaffold_next": "Sviluppa il plugin, pubblica una release, poi invia il repository al registry.", "requests_scaffold_open_repo": "Apri il repository", - "requests_scaffold_submit": "Invia al registry" + "requests_scaffold_submit": "Invia al registry", + "requests_scaffold_mode_create": "Nuovo repository", + "requests_scaffold_mode_link": "Collega uno esistente", + "requests_scaffold_link_repo": "URL del repository", + "requests_scaffold_link_hint": "Scegli uno dei tuoi repository o incollane l'URL. Serve il permesso di maintain, come per inviare un plugin.", + "requests_scaffold_link": "Collega repository", + "requests_scaffold_linking": "Collegamento…", + "requests_scaffold_linked": "{repo} è collegato alla tua presa in carico." } diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 625c08c..8d16255 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -1217,5 +1217,12 @@ "requests_scaffold_done": "{repo} 已就绪。", "requests_scaffold_next": "开发插件、发布一个 release,然后将仓库提交到注册中心。", "requests_scaffold_open_repo": "打开仓库", - "requests_scaffold_submit": "提交到注册中心" + "requests_scaffold_submit": "提交到注册中心", + "requests_scaffold_mode_create": "新建仓库", + "requests_scaffold_mode_link": "关联已有仓库", + "requests_scaffold_link_repo": "仓库 URL", + "requests_scaffold_link_hint": "选择你的某个仓库或粘贴其 URL。需要 maintain 权限,与提交插件相同。", + "requests_scaffold_link": "关联仓库", + "requests_scaffold_linking": "正在关联…", + "requests_scaffold_linked": "{repo} 已关联到你的认领。" } diff --git a/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte b/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte index e08db0b..ed4a6ee 100644 --- a/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte +++ b/apps/frontend/src/lib/components/requests/ScaffoldDialog.svelte @@ -17,8 +17,16 @@ onClose: () => void onScaffolded: (claimers: RequestClaimer[]) => void } - type ScaffoldResult = { repoUrl: string; fullName: string; submitUrl: string; claimers: RequestClaimer[] } + type ScaffoldResult = { + repoUrl: string + fullName: string + submitUrl: string + claimers: RequestClaimer[] + linked?: boolean + } type ScaffoldError = { error?: string; reauthUrl?: string; hint?: string } + type Owner = { login: string; type: 'user' | 'org' } + type Repo = { fullName: string; url: string } let { request, onClose, onScaffolded }: Props = $props() @@ -27,6 +35,10 @@ // svelte-ignore state_referenced_locally let repoName = $state(request.slug) let owner = $state('') + let owners = $state(null) + let mode = $state<'create' | 'link'>('create') + let linkUrl = $state('') + let repos = $state([]) let isPrivate = $state(false) let dbType = $state<'network' | 'file' | 'folder' | 'api'>('network') let withUi = $state(false) @@ -46,6 +58,65 @@ if (!open) onClose() }) + // Accounts the user can create the repository in; falls back to a free-text field. + async function loadOwners() { + try { + const { data, error: err } = await eden.api.submit.owners.get({ query: {} }) + if (!err) owners = (data as { owners: Owner[] }).owners + } catch { + // Keep the free-text field. + } + } + $effect(() => { + loadOwners() + }) + + // Repositories the user maintains, as suggestions for the link field. + let reposLoaded = false + async function loadRepos() { + if (reposLoaded) return + reposLoaded = true + try { + const { data, error: err } = await eden.api.submit.repos.get({ query: { page: 1 } }) + if (err) throw err + const { groups } = data as { groups: Array<{ repos: Repo[] }> } + repos = groups.flatMap((g) => g.repos.map((r) => ({ fullName: r.fullName, url: r.url }))) + } catch { + reposLoaded = false + } + } + + function setMode(next: 'create' | 'link') { + mode = next + error = null + if (next === 'link') loadRepos() + } + + async function link(e: SubmitEvent) { + e.preventDefault() + busy = true + error = null + try { + const { data, error: err } = await eden.api.requests({ id: request.id }).repo.post({ + repoUrl: linkUrl.trim(), + returnTo: window.location.href, + }) + if (err) { + error = + typeof err.value === 'object' && err.value + ? (err.value as ScaffoldError) + : { error: `Request failed (${err.status})` } + return + } + result = { ...(data as ScaffoldResult), linked: true } + onScaffolded(result.claimers) + } catch (e) { + error = { error: e instanceof Error ? e.message : String(e) } + } finally { + busy = false + } + } + async function create(e: SubmitEvent) { e.preventDefault() busy = true @@ -97,7 +168,11 @@ {#if result}
-

{m.requests_scaffold_done({ repo: result.fullName })}

+

+ {result.linked + ? m.requests_scaffold_linked({ repo: result.fullName }) + : m.requests_scaffold_done({ repo: result.fullName })} +

{m.requests_scaffold_next()}

{:else} +
+ {#each [['create', m.requests_scaffold_mode_create()], ['link', m.requests_scaffold_mode_link()]] as [id, label] (id)} + + {/each} +
+ {/if} + + {#if !result && mode === 'link'} +
+
+ + + + {#each repos as repo (repo.url)} + + {/each} + +

{m.requests_scaffold_link_hint()}

+
+ + {#if error} + + {/if} + +
+ + +
+
+ {:else if !result}
@@ -116,12 +252,23 @@
- + {#if owners} + + {:else} + + {/if}