From d6d4a762e1f5b871b7649b51f4dbb9c53ce4b070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=BA?= Date: Sun, 24 May 2026 07:41:57 -0400 Subject: [PATCH] feat(issue-templates): add issue template CRUD with template picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add issue template feature — workspace-scoped templates that pre-fill title and description when creating issues. Backend: - Go handler with CRUD endpoints under /api/issue-templates - sqlc queries: list (summaries without content), detail, create, update, delete - Migration 108: issue_template table with workspace_id FK, unique(workspace_id, name) - Config validation: reject non-object JSONB to prevent list poisoning - Permission: owner/admin or template creator can update/delete Frontend: - Template picker in create-issue modal with overwrite confirmation dialog - Issue templates list page with search, detail page with inline editing - Sidebar navigation and search command integration for both web and desktop - Desktop route wiring (list + detail pages) - Zod schemas with parseWithFallback for API response drift safety - Atomic template apply: form disabled during fetch, submit blocked, token-guarded async responses, upload generation tracking - Partial update payload (only changed fields sent) - ContentEditor.setMarkdown imperative method for template content injection Co-Authored-By: Claude Opus 4.7 --- .../src/pages/issue-template-detail-page.tsx | 8 + apps/desktop/src/renderer/src/routes.tsx | 8 + .../(dashboard)/issue-templates/[id]/page.tsx | 13 + .../(dashboard)/issue-templates/page.tsx | 7 + .../plans/2026/05/12/issue-template-plan.md | 861 ++++++++++++++++++ .../specs/2026/05/12/issue-template-design.md | 247 +++++ packages/core/api/client.ts | 49 + packages/core/issue-templates/index.ts | 2 + packages/core/issue-templates/mutations.ts | 69 ++ packages/core/issue-templates/queries.ts | 24 + packages/core/issue-templates/schemas.test.ts | 101 ++ packages/core/issue-templates/schemas.ts | 41 + packages/core/package.json | 3 + packages/core/paths/consistency.test.ts | 4 +- packages/core/paths/paths.test.ts | 2 + packages/core/paths/paths.ts | 2 + .../use-realtime-sync-ws-instance.test.tsx | 4 +- packages/core/realtime/use-realtime-sync.ts | 6 + packages/core/types/events.ts | 3 + packages/core/types/index.ts | 6 + packages/core/types/issue-template.ts | 36 + packages/views/editor/content-editor.test.tsx | 13 + packages/views/editor/content-editor.tsx | 8 + packages/views/editor/utils/link-handler.ts | 1 + packages/views/i18n/resources-types.ts | 2 + .../create-issue-template-dialog.tsx | 162 ++++ .../views/issue-templates/components/index.ts | 2 + .../components/issue-template-columns.tsx | 127 +++ .../components/issue-template-detail-page.tsx | 432 +++++++++ .../components/issue-templates-page.tsx | 234 +++++ packages/views/issue-templates/index.ts | 1 + packages/views/issue-templates/lib/origin.ts | 9 + packages/views/layout/app-sidebar.test.tsx | 1 + packages/views/layout/app-sidebar.tsx | 6 +- .../views/locales/en/issue-templates.json | 85 ++ packages/views/locales/en/layout.json | 1 + packages/views/locales/en/modals.json | 13 + packages/views/locales/en/search.json | 1 + packages/views/locales/index.ts | 4 + .../locales/zh-Hans/issue-templates.json | 85 ++ packages/views/locales/zh-Hans/layout.json | 1 + packages/views/locales/zh-Hans/modals.json | 13 + packages/views/locales/zh-Hans/search.json | 1 + packages/views/modals/create-issue.test.tsx | 100 +- packages/views/modals/create-issue.tsx | 172 +++- packages/views/package.json | 1 + packages/views/search/search-command.test.tsx | 1 + packages/views/search/search-command.tsx | 2 + server/cmd/server/router.go | 12 + server/internal/handler/issue_template.go | 293 ++++++ .../internal/handler/issue_template_test.go | 173 ++++ .../migrations/108_issue_templates.down.sql | 1 + server/migrations/108_issue_templates.up.sql | 14 + server/pkg/db/generated/issue_template.sql.go | 179 ++++ server/pkg/db/generated/models.go | 14 +- server/pkg/db/queries/issue_template.sql | 30 + server/pkg/protocol/events.go | 5 + 57 files changed, 3675 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/renderer/src/pages/issue-template-detail-page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx create mode 100644 apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx create mode 100644 docs/superpowers/plans/2026/05/12/issue-template-plan.md create mode 100644 docs/superpowers/specs/2026/05/12/issue-template-design.md create mode 100644 packages/core/issue-templates/index.ts create mode 100644 packages/core/issue-templates/mutations.ts create mode 100644 packages/core/issue-templates/queries.ts create mode 100644 packages/core/issue-templates/schemas.test.ts create mode 100644 packages/core/issue-templates/schemas.ts create mode 100644 packages/core/types/issue-template.ts create mode 100644 packages/views/issue-templates/components/create-issue-template-dialog.tsx create mode 100644 packages/views/issue-templates/components/index.ts create mode 100644 packages/views/issue-templates/components/issue-template-columns.tsx create mode 100644 packages/views/issue-templates/components/issue-template-detail-page.tsx create mode 100644 packages/views/issue-templates/components/issue-templates-page.tsx create mode 100644 packages/views/issue-templates/index.ts create mode 100644 packages/views/issue-templates/lib/origin.ts create mode 100644 packages/views/locales/en/issue-templates.json create mode 100644 packages/views/locales/zh-Hans/issue-templates.json create mode 100644 server/internal/handler/issue_template.go create mode 100644 server/internal/handler/issue_template_test.go create mode 100644 server/migrations/108_issue_templates.down.sql create mode 100644 server/migrations/108_issue_templates.up.sql create mode 100644 server/pkg/db/generated/issue_template.sql.go create mode 100644 server/pkg/db/queries/issue_template.sql diff --git a/apps/desktop/src/renderer/src/pages/issue-template-detail-page.tsx b/apps/desktop/src/renderer/src/pages/issue-template-detail-page.tsx new file mode 100644 index 0000000000..712bc6f137 --- /dev/null +++ b/apps/desktop/src/renderer/src/pages/issue-template-detail-page.tsx @@ -0,0 +1,8 @@ +import { useParams } from "react-router-dom"; +import { IssueTemplateDetailPage as SharedIssueTemplateDetailPage } from "@multica/views/issue-templates"; + +export function IssueTemplateDetailPage() { + const { id } = useParams<{ id: string }>(); + if (!id) return null; + return ; +} diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index 46cef3af97..89038803ea 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -10,6 +10,7 @@ import { IssueDetailPage } from "./pages/issue-detail-page"; import { ProjectDetailPage } from "./pages/project-detail-page"; import { AutopilotDetailPage } from "./pages/autopilot-detail-page"; import { SkillDetailPage } from "./pages/skill-detail-page"; +import { IssueTemplateDetailPage } from "./pages/issue-template-detail-page"; import { AgentDetailPage } from "./pages/agent-detail-page"; import { MemberDetailPage } from "./pages/member-detail-page"; import { RuntimeDetailPage } from "./pages/runtime-detail-page"; @@ -20,6 +21,7 @@ import { DashboardPage } from "@multica/views/dashboard"; import { AutopilotsPage } from "@multica/views/autopilots/components"; import { MyIssuesPage } from "@multica/views/my-issues"; import { SkillsPage } from "@multica/views/skills"; +import { IssueTemplatesPage } from "@multica/views/issue-templates"; import { DesktopRuntimesPage } from "./components/desktop-runtimes-page"; import { AgentsPage } from "@multica/views/agents"; import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components"; @@ -188,6 +190,12 @@ export const appRoutes: RouteObject[] = [ element: , handle: { title: "Squad" }, }, + { path: "issue-templates", element: , handle: { title: "Issue Templates" } }, + { + path: "issue-templates/:id", + element: , + handle: { title: "Issue Template" }, + }, { path: "inbox", element: , handle: { title: "Inbox" } }, { path: "attachments/:id/preview", diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx new file mode 100644 index 0000000000..a2390ee589 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { use } from "react"; +import { IssueTemplateDetailPage } from "@multica/views/issue-templates"; + +export default function IssueTemplateDetailRoute({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + return ; +} diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx new file mode 100644 index 0000000000..fcdc04fc37 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { IssueTemplatesPage } from "@multica/views/issue-templates"; + +export default function Page() { + return ; +} diff --git a/docs/superpowers/plans/2026/05/12/issue-template-plan.md b/docs/superpowers/plans/2026/05/12/issue-template-plan.md new file mode 100644 index 0000000000..60995ba157 --- /dev/null +++ b/docs/superpowers/plans/2026/05/12/issue-template-plan.md @@ -0,0 +1,861 @@ +# Issue Template Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add workspace-level `Issue模板` management and let users apply an issue template while manually creating an issue. + +**Architecture:** Add a backend `issue_template` resource parallel to `skill`, expose typed core API/query/mutation helpers, build a Skill-style list/detail UI, add a sidebar route, and integrate template application into the manual create issue modal. The template only stores and applies template name, issue title, issue content, source metadata, creator, and timestamps. + +**Tech Stack:** Go/chi/pgx/sqlc backend, PostgreSQL migrations, TypeScript, React, TanStack Query/Table, Next app routes, Vitest/Testing Library, existing Multica UI components. + +--- + +## File Structure + +Backend: + +- Create `server/migrations/083_issue_templates.up.sql` and `server/migrations/083_issue_templates.down.sql`: create/drop `issue_template`. +- Create `server/pkg/db/queries/issue_template.sql`: sqlc CRUD queries. +- Modify generated sqlc files after running the repo's generator. +- Create `server/internal/handler/issue_template.go`: request/response types, validation, permissions, CRUD handlers. +- Modify `server/cmd/server/router.go`: register `/api/issue-templates`. +- Create `server/internal/handler/issue_template_test.go`: CRUD, validation, workspace scoping, permissions. + +Core/shared frontend: + +- Create `packages/core/types/issue-template.ts`: issue template API types. +- Modify `packages/core/types/index.ts`: export issue template types. +- Modify `packages/core/api/client.ts`: add issue template client methods. +- Create `packages/core/issue-templates/queries.ts`: query keys/options. +- Create `packages/core/issue-templates/mutations.ts`: create/update/delete mutations and invalidation. +- Modify `packages/core/paths/paths.ts`, `packages/core/paths/paths.test.ts`, `packages/core/paths/consistency.test.ts`, and `packages/core/paths/reserved-slugs.ts`: add `issueTemplates` and `issueTemplateDetail`. + +Views/app: + +- Modify `packages/views/layout/app-sidebar.tsx`: add `Issue模板` after Skill. +- Modify `packages/views/locales/en/layout.json`, `packages/views/locales/zh-Hans/layout.json`, `packages/views/locales/en/search.json`, `packages/views/locales/zh-Hans/search.json`: add sidebar/search labels. +- Create `packages/views/issue-templates/index.ts`: public exports. +- Create `packages/views/issue-templates/lib/origin.ts`: read manual origin metadata. +- Create `packages/views/issue-templates/components/issue-template-columns.tsx`: table columns. +- Create `packages/views/issue-templates/components/issue-templates-page.tsx`: list page. +- Create `packages/views/issue-templates/components/create-issue-template-dialog.tsx`: create dialog. +- Create `packages/views/issue-templates/components/issue-template-detail-page.tsx`: edit/delete detail page. +- Create `packages/views/locales/en/issue-templates.json` and `packages/views/locales/zh-Hans/issue-templates.json`: page strings. +- Modify `packages/views/locales/index.ts`: register namespace. +- Create Next routes: + - `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx` + - `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx` +- Modify `packages/views/modals/create-issue.tsx`: template selector entry, apply flow, overwrite confirmation. +- Modify `packages/views/modals/create-issue.test.tsx`: template apply coverage. + +## Task 1: Backend Storage And Queries + +**Files:** +- Create: `server/migrations/083_issue_templates.up.sql` +- Create: `server/migrations/083_issue_templates.down.sql` +- Create: `server/pkg/db/queries/issue_template.sql` +- Generated after sqlc: `server/pkg/db/generated/issue_template.sql.go` +- Generated after sqlc: `server/pkg/db/generated/models.go` + +- [ ] **Step 1: Add a failing query compilation target** + +Create `server/pkg/db/queries/issue_template.sql` with: + +```sql +-- Issue Template CRUD + +-- name: ListIssueTemplateSummariesByWorkspace :many +SELECT id, workspace_id, name, issue_title, issue_content, config, created_by, created_at, updated_at +FROM issue_template +WHERE workspace_id = $1 +ORDER BY name ASC; + +-- name: GetIssueTemplateInWorkspace :one +SELECT * +FROM issue_template +WHERE id = $1 AND workspace_id = $2; + +-- name: CreateIssueTemplate :one +INSERT INTO issue_template (workspace_id, name, issue_title, issue_content, config, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: UpdateIssueTemplate :one +UPDATE issue_template SET + name = COALESCE(sqlc.narg('name'), name), + issue_title = COALESCE(sqlc.narg('issue_title'), issue_title), + issue_content = COALESCE(sqlc.narg('issue_content'), issue_content), + config = COALESCE(sqlc.narg('config'), config), + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: DeleteIssueTemplate :exec +DELETE FROM issue_template WHERE id = $1; +``` + +- [ ] **Step 2: Add the migration** + +Create `server/migrations/083_issue_templates.up.sql`: + +```sql +CREATE TABLE issue_template ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + name TEXT NOT NULL, + issue_title TEXT NOT NULL, + issue_content TEXT NOT NULL DEFAULT '', + config JSONB NOT NULL DEFAULT '{}', + created_by UUID REFERENCES "user"(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(workspace_id, name) +); + +CREATE INDEX idx_issue_template_workspace ON issue_template(workspace_id); +``` + +Create `server/migrations/083_issue_templates.down.sql`: + +```sql +DROP TABLE IF EXISTS issue_template; +``` + +- [ ] **Step 3: Run sqlc generation** + +Run the repo's existing generation command. First inspect `server/sqlc.yaml` or package scripts, then run the matching command, expected to be one of: + +```bash +pnpm --filter @multica/server sqlc +``` + +or: + +```bash +cd server && sqlc generate +``` + +Expected: generated `server/pkg/db/generated/issue_template.sql.go` and an `IssueTemplate` model in `server/pkg/db/generated/models.go`. + +- [ ] **Step 4: Commit backend storage** + +```bash +git add server/migrations server/pkg/db/queries/issue_template.sql server/pkg/db/generated +git commit -m "feat: add issue template storage" +``` + +## Task 2: Backend Handlers And Tests + +**Files:** +- Create: `server/internal/handler/issue_template.go` +- Create: `server/internal/handler/issue_template_test.go` +- Modify: `server/cmd/server/router.go` + +- [ ] **Step 1: Write handler tests first** + +Create `server/internal/handler/issue_template_test.go` with tests for: + +```go +func TestIssueTemplateCRUD(t *testing.T) { + createReq := map[string]any{ + "name": "Bug report", + "issue_title": "Investigate {{area}} bug", + "issue_content": "## Context\n\n## Steps\n", + } + w := httptest.NewRecorder() + testHandler.CreateIssueTemplate(w, newRequest("POST", "/api/issue-templates?workspace_id="+testWorkspaceID, createReq)) + if w.Code != http.StatusCreated { + t.Fatalf("CreateIssueTemplate status = %d body=%s", w.Code, w.Body.String()) + } + + var created map[string]any + if err := json.NewDecoder(w.Body).Decode(&created); err != nil { + t.Fatal(err) + } + id := created["id"].(string) + if created["name"] != "Bug report" || created["issue_title"] != "Investigate {{area}} bug" { + t.Fatalf("created response mismatch: %#v", created) + } + + w = httptest.NewRecorder() + testHandler.ListIssueTemplates(w, newRequest("GET", "/api/issue-templates?workspace_id="+testWorkspaceID, nil)) + if w.Code != http.StatusOK { + t.Fatalf("ListIssueTemplates status = %d body=%s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + testHandler.GetIssueTemplate(w, newRequest("GET", "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil)) + if w.Code != http.StatusOK { + t.Fatalf("GetIssueTemplate status = %d body=%s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + testHandler.UpdateIssueTemplate(w, newRequest("PUT", "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, map[string]any{ + "name": "Bug triage", + "issue_title": "Triage bug", + "issue_content": "Updated body", + })) + if w.Code != http.StatusOK { + t.Fatalf("UpdateIssueTemplate status = %d body=%s", w.Code, w.Body.String()) + } + + w = httptest.NewRecorder() + testHandler.DeleteIssueTemplate(w, newRequest("DELETE", "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil)) + if w.Code != http.StatusNoContent { + t.Fatalf("DeleteIssueTemplate status = %d body=%s", w.Code, w.Body.String()) + } +} +``` + +Add tests that assert missing `name` and missing `issue_title` return `400`. + +- [ ] **Step 2: Run tests and verify failure** + +Run: + +```bash +go test ./server/internal/handler -run 'TestIssueTemplate' +``` + +Expected: compile failure because handlers do not exist. + +- [ ] **Step 3: Implement handlers** + +Create `server/internal/handler/issue_template.go` with: + +```go +package handler + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type IssueTemplateResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + IssueTitle string `json:"issue_title"` + IssueContent string `json:"issue_content"` + Config any `json:"config"` + CreatedBy *string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type CreateIssueTemplateRequest struct { + Name string `json:"name"` + IssueTitle string `json:"issue_title"` + IssueContent string `json:"issue_content"` + Config any `json:"config"` +} + +type UpdateIssueTemplateRequest struct { + Name *string `json:"name"` + IssueTitle *string `json:"issue_title"` + IssueContent *string `json:"issue_content"` + Config any `json:"config"` +} + +func issueTemplateToResponse(t db.IssueTemplate) IssueTemplateResponse { + return IssueTemplateResponse{ + ID: uuidToString(t.ID), + WorkspaceID: uuidToString(t.WorkspaceID), + Name: t.Name, + IssueTitle: t.IssueTitle, + IssueContent: t.IssueContent, + Config: decodeSkillConfig(t.Config), + CreatedBy: uuidToPtr(t.CreatedBy), + CreatedAt: timestampToString(t.CreatedAt), + UpdatedAt: timestampToString(t.UpdatedAt), + } +} +``` + +Then implement `ListIssueTemplates`, `GetIssueTemplate`, `CreateIssueTemplate`, `UpdateIssueTemplate`, and `DeleteIssueTemplate` following `skill.go` patterns: + +- Use `h.resolveWorkspaceID(r)`. +- Use `requireUserID` on create. +- Use `loadIssueTemplateForUser`. +- Use `canManageIssueTemplate`, same rule as skill: creator or owner/admin can update/delete. +- Trim `name` and `issue_title`, reject empty. +- Sanitize null bytes using `sanitizeNullBytes`. +- Marshal nil config to `{}`. +- Use `isUniqueViolation` to return conflict for duplicate template names. + +- [ ] **Step 4: Register routes** + +In `server/cmd/server/router.go`, near `/api/skills`, add: + +```go +r.Route("/api/issue-templates", func(r chi.Router) { + r.Get("/", h.ListIssueTemplates) + r.Post("/", h.CreateIssueTemplate) + r.Route("/{id}", func(r chi.Router) { + r.Get("/", h.GetIssueTemplate) + r.Put("/", h.UpdateIssueTemplate) + r.Delete("/", h.DeleteIssueTemplate) + }) +}) +``` + +- [ ] **Step 5: Run backend tests** + +```bash +go test ./server/internal/handler -run 'TestIssueTemplate' +``` + +Expected: PASS. + +- [ ] **Step 6: Commit backend handlers** + +```bash +git add server/internal/handler/issue_template.go server/internal/handler/issue_template_test.go server/cmd/server/router.go +git commit -m "feat: add issue template API" +``` + +## Task 3: Core TypeScript API, Paths, And Query Helpers + +**Files:** +- Create: `packages/core/types/issue-template.ts` +- Modify: `packages/core/types/index.ts` +- Modify: `packages/core/api/client.ts` +- Create: `packages/core/issue-templates/queries.ts` +- Create: `packages/core/issue-templates/mutations.ts` +- Modify: `packages/core/paths/paths.ts` +- Modify: `packages/core/paths/paths.test.ts` +- Modify: `packages/core/paths/consistency.test.ts` +- Modify: `packages/core/paths/reserved-slugs.ts` + +- [ ] **Step 1: Write path tests first** + +In `packages/core/paths/paths.test.ts`, add: + +```ts +expect(ws.issueTemplates()).toBe("/acme/issue-templates"); +expect(ws.issueTemplateDetail("tpl_123")).toBe("/acme/issue-templates/tpl_123"); +``` + +In `packages/core/paths/consistency.test.ts`, include `issue-templates` in reserved/dashboard route expectations using the existing test pattern. + +- [ ] **Step 2: Run path tests and verify failure** + +```bash +pnpm --filter @multica/core test -- paths +``` + +Expected: FAIL because the path methods do not exist. + +- [ ] **Step 3: Add types** + +Create `packages/core/types/issue-template.ts`: + +```ts +export interface IssueTemplateSummary { + id: string; + workspace_id: string; + name: string; + issue_title: string; + issue_content: string; + config: Record; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface IssueTemplate extends IssueTemplateSummary {} + +export interface CreateIssueTemplateRequest { + name: string; + issue_title: string; + issue_content?: string; + config?: Record; +} + +export interface UpdateIssueTemplateRequest { + name?: string; + issue_title?: string; + issue_content?: string; + config?: Record; +} +``` + +Export these from `packages/core/types/index.ts`. + +- [ ] **Step 4: Add client methods** + +In `packages/core/api/client.ts`, import the new request/response types and add: + +```ts +async listIssueTemplates(): Promise { + return this.fetch("/api/issue-templates"); +} + +async getIssueTemplate(id: string): Promise { + return this.fetch(`/api/issue-templates/${id}`); +} + +async createIssueTemplate(data: CreateIssueTemplateRequest): Promise { + return this.fetch("/api/issue-templates", { + method: "POST", + body: JSON.stringify(data), + }); +} + +async updateIssueTemplate(id: string, data: UpdateIssueTemplateRequest): Promise { + return this.fetch(`/api/issue-templates/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); +} + +async deleteIssueTemplate(id: string): Promise { + await this.fetch(`/api/issue-templates/${id}`, { method: "DELETE" }); +} +``` + +- [ ] **Step 5: Add query and mutation helpers** + +Create `packages/core/issue-templates/queries.ts`: + +```ts +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const issueTemplateKeys = { + all: (workspaceId: string | null | undefined) => ["workspaces", workspaceId, "issue-templates"] as const, + detail: (workspaceId: string | null | undefined, id: string) => + ["workspaces", workspaceId, "issue-templates", id] as const, +}; + +export function issueTemplateListOptions(workspaceId: string | null | undefined) { + return queryOptions({ + queryKey: issueTemplateKeys.all(workspaceId), + queryFn: () => api.listIssueTemplates(), + enabled: !!workspaceId, + }); +} + +export function issueTemplateDetailOptions(workspaceId: string | null | undefined, id: string) { + return queryOptions({ + queryKey: issueTemplateKeys.detail(workspaceId, id), + queryFn: () => api.getIssueTemplate(id), + enabled: !!workspaceId && !!id, + }); +} +``` + +Create `packages/core/issue-templates/mutations.ts` with `useCreateIssueTemplate`, `useUpdateIssueTemplate`, and `useDeleteIssueTemplate` using `useMutation`, `useQueryClient`, and invalidating `issueTemplateKeys.all(workspaceId)`. + +- [ ] **Step 6: Add paths** + +In `packages/core/paths/paths.ts`, add methods: + +```ts +issueTemplates: () => `${ws}/issue-templates`, +issueTemplateDetail: (id: string) => `${ws}/issue-templates/${encode(id)}`, +``` + +Add `"issue-templates"` to `reserved-slugs.ts`. + +- [ ] **Step 7: Run core tests** + +```bash +pnpm --filter @multica/core test -- paths +``` + +Expected: PASS. + +- [ ] **Step 8: Commit core helpers** + +```bash +git add packages/core +git commit -m "feat: add issue template core API" +``` + +## Task 4: Sidebar, Routes, Locales, And List Page + +**Files:** +- Modify: `packages/views/layout/app-sidebar.tsx` +- Modify: `packages/views/layout/app-sidebar.test.tsx` +- Create: `packages/views/locales/en/issue-templates.json` +- Create: `packages/views/locales/zh-Hans/issue-templates.json` +- Modify: `packages/views/locales/index.ts` +- Modify: `packages/views/locales/en/layout.json` +- Modify: `packages/views/locales/zh-Hans/layout.json` +- Create: `packages/views/issue-templates/index.ts` +- Create: `packages/views/issue-templates/lib/origin.ts` +- Create: `packages/views/issue-templates/components/issue-template-columns.tsx` +- Create: `packages/views/issue-templates/components/issue-templates-page.tsx` +- Create: `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx` + +- [ ] **Step 1: Write sidebar test first** + +Update `packages/views/layout/app-sidebar.test.tsx` mocks to include: + +```ts +issueTemplates: () => "/acme/issue-templates", +``` + +Add an assertion that `Issue模板` appears after `Skill` and before settings in the rendered sidebar. + +- [ ] **Step 2: Run sidebar test and verify failure** + +```bash +pnpm --filter @multica/views test -- app-sidebar +``` + +Expected: FAIL because sidebar label/path does not exist. + +- [ ] **Step 3: Add locale namespaces** + +Create `packages/views/locales/zh-Hans/issue-templates.json`: + +```json +{ + "page": { + "title": "Issue模板", + "create": "新建 Issue模板", + "search_placeholder": "搜索 Issue模板...", + "intro": "在工作区内共享可复用的 issue 标题和内容模板,创建 issue 时可以套用后再调整。", + "empty_title": "还没有 Issue模板", + "empty_body": "创建第一个模板,让重复 issue 更快开始。", + "no_matches": "没有匹配的 Issue模板" + }, + "table": { + "name": "名称", + "source": "来源 · 添加者", + "updated": "更新时间", + "source_manual": "手动创建", + "by_creator": "由 {{name}} 创建", + "no_content": "暂无内容" + }, + "create_dialog": { + "title": "新建 Issue模板", + "name_label": "模板名称", + "issue_title_label": "issue标题", + "issue_content_label": "issue内容", + "cancel": "取消", + "submit": "创建", + "toast_created": "Issue模板已创建", + "toast_failed": "创建 Issue模板失败" + } +} +``` + +Create `packages/views/locales/en/issue-templates.json` with equivalent English strings. Register both in `packages/views/locales/index.ts`. + +- [ ] **Step 4: Add sidebar item** + +In `packages/views/layout/app-sidebar.tsx`: + +- Add `FileText` or `SquarePen` icon import from lucide if needed. +- Extend `NavKey` and `NavLabelKey` with `issueTemplates`. +- Add `{ key: "issueTemplates", labelKey: "issue_templates", icon: FileText }` after skills in `configureNav`. + +In layout locale JSON files, add: + +```json +"issue_templates": "Issue模板" +``` + +for Chinese and: + +```json +"issue_templates": "Issue Templates" +``` + +for English. + +- [ ] **Step 5: Build list page files** + +Create `packages/views/issue-templates/index.ts`: + +```ts +export { default as IssueTemplatesPage } from "./components/issue-templates-page"; +``` + +Create a list page modeled on `SkillsPage`: use `issueTemplateListOptions`, member list options for creators, `useReactTable`, a search input, empty state, and `DataTable`. Implement columns in `issue-template-columns.tsx`: + +- `name`: primary `template.name`, secondary `template.issue_title || template.issue_content`. +- `source`: manual source plus creator avatar/name, copied from Skill `SourceCell` pattern. +- `updated`: `timeAgo(template.updated_at)`. +- chevron. + +- [ ] **Step 6: Add Next route** + +Create `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/page.tsx`: + +```tsx +import { IssueTemplatesPage } from "@multica/views/issue-templates"; + +export default function IssueTemplatesRoute() { + return ; +} +``` + +- [ ] **Step 7: Run views tests** + +```bash +pnpm --filter @multica/views test -- app-sidebar +pnpm --filter @multica/views typecheck +``` + +Expected: PASS. If full typecheck reports pre-existing unrelated errors, capture the exact unrelated files before continuing. + +- [ ] **Step 8: Commit sidebar/list page** + +```bash +git add packages/views apps/web/app/[workspaceSlug]/(dashboard)/issue-templates packages/core/paths +git commit -m "feat: add issue template list page" +``` + +## Task 5: Create And Detail Issue Template UI + +**Files:** +- Create: `packages/views/issue-templates/components/create-issue-template-dialog.tsx` +- Create: `packages/views/issue-templates/components/issue-template-detail-page.tsx` +- Modify: `packages/views/issue-templates/components/issue-templates-page.tsx` +- Create: `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx` +- Modify: `packages/views/locales/en/issue-templates.json` +- Modify: `packages/views/locales/zh-Hans/issue-templates.json` + +- [ ] **Step 1: Add route and dialog tests** + +Create focused tests near issue template components that assert: + +- Clicking `新建 Issue模板` opens a dialog. +- Submitting valid name/title/content calls `api.createIssueTemplate`. +- Detail page loads a template and calls update on save. +- Delete asks for confirmation and calls delete. + +- [ ] **Step 2: Run tests and verify failure** + +```bash +pnpm --filter @multica/views test -- issue-template +``` + +Expected: FAIL because components do not exist. + +- [ ] **Step 3: Implement create dialog** + +Build `CreateIssueTemplateDialog` using existing dialog/button/input patterns: + +- Local state for `name`, `issueTitle`, `issueContent`. +- Disable submit unless `name.trim()` and `issueTitle.trim()` exist. +- On submit call `useCreateIssueTemplate`. +- On success toast and navigate to `paths.issueTemplateDetail(created.id)`. +- On failure toast. + +- [ ] **Step 4: Implement detail page** + +Build `IssueTemplateDetailPage({ templateId })`: + +- Query `issueTemplateDetailOptions(wsId, templateId)`. +- Editable fields for template name, issue title, issue content. +- Save button calls `useUpdateIssueTemplate`. +- Delete button opens confirmation and calls `useDeleteIssueTemplate`. +- After delete navigate to `paths.issueTemplates()`. + +- [ ] **Step 5: Add detail route** + +Create `apps/web/app/[workspaceSlug]/(dashboard)/issue-templates/[id]/page.tsx`: + +```tsx +import { IssueTemplateDetailPage } from "@multica/views/issue-templates"; + +export default function IssueTemplateDetailRoute({ + params, +}: { + params: { id: string }; +}) { + return ; +} +``` + +- [ ] **Step 6: Run UI tests/typecheck** + +```bash +pnpm --filter @multica/views test -- issue-template +pnpm --filter @multica/views typecheck +pnpm --filter @multica/web typecheck +``` + +Expected: PASS or only documented unrelated pre-existing failures. + +- [ ] **Step 7: Commit create/detail UI** + +```bash +git add packages/views/issue-templates apps/web/app/[workspaceSlug]/(dashboard)/issue-templates packages/views/locales +git commit -m "feat: add issue template editor" +``` + +## Task 6: Manual Create Issue Template Application + +**Files:** +- Modify: `packages/views/modals/create-issue.tsx` +- Modify: `packages/views/modals/create-issue.test.tsx` +- Modify: `packages/views/locales/en/modals.json` +- Modify: `packages/views/locales/zh-Hans/modals.json` + +- [ ] **Step 1: Write create modal tests first** + +In `packages/views/modals/create-issue.test.tsx`, add mocks for issue template list/detail queries and tests: + +```ts +it("applies an issue template to empty title and description", async () => { + // render modal with project_id + // click "Select template" + // choose "Bug report" + // expect title input to have issue_title + // expect description textarea to have issue_content +}); + +it("confirms before overwriting existing title or description", async () => { + // type existing title and description + // choose template + // expect overwrite confirmation + // cancel keeps existing values + // choose again and confirm overwrites values +}); + +it("does not change non-template issue fields when applying a template", async () => { + // select project/status/priority through mocked controls or inspect mutation payload + // apply template + // submit + // expect only title/description changed; project/status/priority remain as before +}); +``` + +- [ ] **Step 2: Run modal tests and verify failure** + +```bash +pnpm --filter @multica/views test -- create-issue +``` + +Expected: FAIL because template UI is missing. + +- [ ] **Step 3: Add template picker UI** + +In `ManualCreatePanel`: + +- Query `issueTemplateListOptions(wsId)`. +- Add a toolbar button labeled from locale, e.g. `套用 Issue 模板`. +- Open a small dropdown/dialog listing templates by name and issue title. +- Empty state links/navigates to `paths.issueTemplates()`. + +- [ ] **Step 4: Add apply logic** + +Add helper logic: + +```ts +const applyTemplate = async (templateId: string) => { + const hasExisting = !!title.trim() || !!descEditorRef.current?.getMarkdown()?.trim(); + if (hasExisting) { + setPendingTemplateId(templateId); + setOverwriteConfirmOpen(true); + return; + } + await applyTemplateNow(templateId); +}; +``` + +`applyTemplateNow` should fetch detail with `api.getIssueTemplate(templateId)` or use a query client fetch, then: + +- `updateTitle(template.issue_title)` +- set editor content to `template.issue_content` +- `setDraft({ title: template.issue_title, description: template.issue_content })` + +If `ContentEditorRef` lacks a set method, extend the editor ref in `packages/views/editor` with a minimal `setMarkdown(markdown: string)` method and update tests accordingly. + +- [ ] **Step 5: Add overwrite confirmation** + +Use existing dialog/alert patterns. Copy: + +- Title: `覆盖当前标题和内容?` +- Description: `套用模板会替换当前 issue 标题和内容。其他字段不会改变。` +- Cancel: `取消` +- Confirm: `覆盖` + +On cancel, leave current fields unchanged. On confirm, call `applyTemplateNow(pendingTemplateId)`. + +- [ ] **Step 6: Run modal tests/typecheck** + +```bash +pnpm --filter @multica/views test -- create-issue +pnpm --filter @multica/views typecheck +``` + +Expected: PASS or only documented unrelated pre-existing failures. + +- [ ] **Step 7: Commit create issue integration** + +```bash +git add packages/views/modals packages/views/editor packages/views/locales +git commit -m "feat: apply issue templates when creating issues" +``` + +## Task 7: End-To-End Validation And Final Push + +**Files:** +- No planned source changes unless validation finds defects. + +- [ ] **Step 1: Run backend tests** + +```bash +go test ./server/internal/handler -run 'TestIssueTemplate|TestListSkills' +``` + +Expected: PASS. + +- [ ] **Step 2: Run frontend focused tests** + +```bash +pnpm --filter @multica/core test -- paths +pnpm --filter @multica/views test -- 'app-sidebar|issue-template|create-issue' +``` + +Expected: PASS. + +- [ ] **Step 3: Run typechecks** + +```bash +pnpm --filter @multica/core typecheck +pnpm --filter @multica/views typecheck +pnpm --filter @multica/web typecheck +``` + +Expected: PASS. If existing unrelated failures remain, record exact files and commands. + +- [ ] **Step 4: Inspect changed files** + +```bash +git status --short +git diff --stat origin/agent/ai-gpt/98dde0bc...HEAD +``` + +Expected: only files for issue template feature are changed. + +- [ ] **Step 5: Push branch** + +```bash +git push +``` + +Expected: branch `agent/ai-gpt/98dde0bc` pushed. + +- [ ] **Step 6: Post implementation summary** + +Post a Multica issue comment using `--content-stdin` summarizing: + +- Backend API/storage added. +- `Issue模板` page and sidebar entry added. +- Create issue modal can apply templates with overwrite confirmation. +- Tests run and any known unrelated failures. + +Then move issue to the next requested status only after code review workflow completes. diff --git a/docs/superpowers/specs/2026/05/12/issue-template-design.md b/docs/superpowers/specs/2026/05/12/issue-template-design.md new file mode 100644 index 0000000000..d6ee3a642b --- /dev/null +++ b/docs/superpowers/specs/2026/05/12/issue-template-design.md @@ -0,0 +1,247 @@ +# Issue Template Design + +## Goal + +Add workspace-level issue templates so users can manage reusable issue title/content blueprints and apply one while creating a new issue. + +## Confirmed Scope + +This feature is the smaller product-scoped version of issue templates. It does not add a new issue status, template-specific issue IDs, duplicate workflows, or template-to-issue conversion rules. + +Confirmed requirements: + +- Add an `Issue模板` item to the left sidebar under the `配置` group. +- Place `Issue模板` after `Skill` and before `设置`. +- The page title is `Issue模板`. +- Issue templates are workspace-level records. +- Template fields are: + - template name + - issue title + - issue content + - source / creator + - updated time +- The source / creator and updated time presentation should follow the Skill page table pattern. +- Creating an issue from a template copies only issue title and issue content. +- Applying a template never changes status, priority, assignee, project, due date, parent/child issues, attachments, or create-another settings. +- If the create issue form already has a title or content, applying a template first shows a confirmation dialog. Confirming overwrites title/content; cancelling keeps the current inputs. + +## Current Context + +The current create issue flow lives mainly in `packages/views/modals/create-issue.tsx`, with `CreateIssueDialog` in `packages/views/modals/create-issue-dialog.tsx`. + +The sidebar configuration group is defined in `packages/views/layout/app-sidebar.tsx`. Current entries are runtimes, skills, and settings. + +The Skill management experience provides the closest product and implementation reference: + +- `packages/views/skills/components/skills-page.tsx` +- `packages/views/skills/components/skill-columns.tsx` +- `packages/views/skills/components/create-skill-dialog.tsx` +- `packages/core/api/client.ts` skill methods +- `packages/core/types/agent.ts` skill types + +## Recommended Approach + +Use a first-class workspace-level `issue_templates` model and a dedicated `Issue模板` management section. The page should mirror the Skill list/detail shape rather than embedding management into the create issue modal. + +This keeps the issue creation flow focused while giving template content enough space to be edited comfortably. + +## Information Architecture + +Add a new sidebar destination: + +- Group: `配置` +- Order: `运行时`, `Skill`, `Issue模板`, `设置` +- Label: `Issue模板` +- Page title: `Issue模板` + +The route can use an English/internal path such as `//issue-templates`, while the visible label remains `Issue模板`. + +The create issue modal gets a template selection entry. It consumes templates only; template creation and management stays on the `Issue模板` page. + +## Data Model And API + +Add a workspace-level `issue_templates` model. Each template belongs to one workspace. + +Fields: + +- `id` +- `workspace_id` +- `name` +- `issue_title` +- `issue_content` +- `config` or equivalent source metadata +- `created_by` +- `created_at` +- `updated_at` + +Initial source behavior: + +- First version only needs to display `手动创建`. +- Keep source metadata extensible, similar to Skill origin handling, so later imports or generated templates can be represented without changing the main table shape. + +API behavior: + +- List templates for the current workspace. +- Get template detail, including full issue content. +- Create template. +- Update template. +- Delete template. + +The list endpoint should return enough fields for the table. The detail endpoint should return full `issue_content`. + +Permissions: + +- Workspace members can view templates. +- Create, edit, and delete permissions should align with the existing Skill permissions model unless implementation discovers a stronger existing workspace settings convention. + +## Issue Template Page + +The `Issue模板` page should reuse the Skill page density and table style. + +Header: + +- Title: `Issue模板` +- Count +- Short explanatory copy +- Primary action: `新建 Issue模板` + +Table: + +- Search supports template name, issue title, and issue content. +- Columns: + - `名称` + - `来源 · 添加者` + - `更新时间` + - right-side chevron +- The name cell shows template name as the primary line. +- The secondary line shows issue title or a short issue content excerpt. +- `来源 · 添加者` follows the Skill table pattern, with manual source and creator display. +- `更新时间` uses the same relative time style as Skill. + +Empty state: + +- Explain that templates can be reused when creating issues. +- Offer the same primary create action. + +## Detail And Editing + +Clicking a row opens an issue template detail page. + +The detail page supports editing: + +- Template name +- Issue title +- Issue content + +Save behavior: + +- Persist changes through the update API. +- Refresh `updated_at` in list/detail data after save. +- Show success and failure feedback consistent with existing Skill pages. + +Delete behavior: + +- Provide delete from the detail page. +- Show a confirmation dialog before deleting. +- After deletion, navigate back to the `Issue模板` list. + +## Create Issue Integration + +Add a template selection entry in the manual create issue modal. + +Flow: + +1. User opens the create issue modal. +2. User clicks the template entry. +3. Template selector lists available issue templates. +4. User selects a template. +5. If the current title or content is non-empty, show an overwrite confirmation. +6. If confirmed, fetch/apply the selected template details. +7. Set form title to `issue_title`. +8. Set editor content to `issue_content`. + +Template application must not change: + +- status +- priority +- assignee +- project +- due date +- parent issue +- child issues +- attachments +- create-another setting + +If no templates exist, the selector should show an empty state and link or direct the user to the `Issue模板` page. + +If loading or applying a template fails, show a toast and keep the create issue modal open. + +## Error Handling + +Backend validation: + +- `name` is required and trimmed. +- `issue_title` is required and trimmed. +- `issue_content` can be empty unless existing issue creation constraints require otherwise. +- Requests must be scoped to the current workspace. +- Updating or deleting a missing template returns not found. + +Frontend handling: + +- List and detail pages show retryable load errors. +- Create, update, delete, and apply failures show toast feedback. +- Applying a template failure does not clear current create issue draft fields. + +## Localization + +Add English and Simplified Chinese strings for the new section, even though the confirmed visible Chinese label is `Issue模板`. + +Chinese UI terms: + +- `Issue模板` +- `新建 Issue模板` +- `选择模板` +- `套用 Issue 模板` +- `来源 · 添加者` +- `更新时间` +- `手动创建` + +English terms can use: + +- `Issue Templates` +- `New Issue Template` +- `Select template` +- `Apply Issue Template` +- `Source · Creator` +- `Updated` +- `Manually created` + +## Testing + +Backend tests: + +- Create, list, get, update, and delete issue templates. +- Workspace scoping prevents access across workspaces. +- Validation rejects missing template name or issue title. +- Permission behavior matches the chosen Skill-aligned rules. + +Frontend tests: + +- Sidebar renders `Issue模板` in the configuration group after `Skill`. +- `Issue模板` list renders table columns and template rows. +- Search matches template name, issue title, and issue content. +- Detail page saves edits and deletes with confirmation. +- Create issue modal applies selected template title/content. +- Applying a template with existing title/content shows confirmation before overwrite. +- Cancelling overwrite leaves existing title/content unchanged. +- Applying a template does not change status, priority, assignee, project, due date, parent/child issue state, attachments, or create-another state. +- Empty template selector state points users to the `Issue模板` page. + +## Out Of Scope + +- Dedicated template issue status. +- Template-specific issue numbering. +- Duplicate or duplicate-to actions. +- Assigning templates to agents. +- Default template status, priority, assignee, project, due date, labels, attachments, or parent/child relationships. +- Importing templates from external sources. diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index a08e6264c3..19bc97d6cb 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -36,6 +36,10 @@ import type { CreateSkillRequest, UpdateSkillRequest, SetAgentSkillsRequest, + IssueTemplate, + IssueTemplateSummary, + CreateIssueTemplateRequest, + UpdateIssueTemplateRequest, PersonalAccessToken, CreatePersonalAccessTokenRequest, CreatePersonalAccessTokenResponse, @@ -149,6 +153,12 @@ import { UserSchema, WebhookDeliveryResponseSchema, } from "./schemas"; +import { + EMPTY_ISSUE_TEMPLATE_DETAIL, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + IssueTemplateDetailSchema, + IssueTemplateSummaryListSchema, +} from "../issue-templates/schemas"; /** Identifies the calling client to the server. * Sent on every HTTP request as X-Client-Platform / X-Client-Version / @@ -1286,6 +1296,45 @@ export class ApiClient { }); } + // Issue templates + async listIssueTemplates(): Promise { + const raw = await this.fetch("/api/issue-templates"); + return parseWithFallback(raw, IssueTemplateSummaryListSchema, EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, { + endpoint: "listIssueTemplates", + }); + } + + async getIssueTemplate(id: string): Promise { + const raw = await this.fetch(`/api/issue-templates/${id}`); + return parseWithFallback(raw, IssueTemplateDetailSchema, EMPTY_ISSUE_TEMPLATE_DETAIL, { + endpoint: "getIssueTemplate", + }); + } + + async createIssueTemplate(data: CreateIssueTemplateRequest): Promise { + const raw = await this.fetch("/api/issue-templates", { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueTemplateDetailSchema, EMPTY_ISSUE_TEMPLATE_DETAIL, { + endpoint: "createIssueTemplate", + }); + } + + async updateIssueTemplate(id: string, data: UpdateIssueTemplateRequest): Promise { + const raw = await this.fetch(`/api/issue-templates/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, IssueTemplateDetailSchema, EMPTY_ISSUE_TEMPLATE_DETAIL, { + endpoint: "updateIssueTemplate", + }); + } + + async deleteIssueTemplate(id: string): Promise { + await this.fetch(`/api/issue-templates/${id}`, { method: "DELETE" }); + } + // Personal Access Tokens async listPersonalAccessTokens(): Promise { return this.fetch("/api/tokens"); diff --git a/packages/core/issue-templates/index.ts b/packages/core/issue-templates/index.ts new file mode 100644 index 0000000000..868bf87e53 --- /dev/null +++ b/packages/core/issue-templates/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./mutations"; diff --git a/packages/core/issue-templates/mutations.ts b/packages/core/issue-templates/mutations.ts new file mode 100644 index 0000000000..6c0ef60648 --- /dev/null +++ b/packages/core/issue-templates/mutations.ts @@ -0,0 +1,69 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { useWorkspaceId } from "../hooks"; +import type { CreateIssueTemplateRequest, IssueTemplate, UpdateIssueTemplateRequest } from "../types"; +import { issueTemplateKeys } from "./queries"; + +export function useCreateIssueTemplate() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: (data: CreateIssueTemplateRequest) => api.createIssueTemplate(data), + onSuccess: (template) => { + qc.setQueryData(issueTemplateKeys.list(wsId), (old) => + old && !old.some((item) => item.id === template.id) + ? [...old, template].sort((a, b) => a.name.localeCompare(b.name)) + : old, + ); + qc.setQueryData(issueTemplateKeys.detail(wsId, template.id), template); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueTemplateKeys.list(wsId) }); + }, + }); +} + +export function useUpdateIssueTemplate() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: ({ id, ...data }: { id: string } & UpdateIssueTemplateRequest) => + api.updateIssueTemplate(id, data), + onSuccess: (template) => { + qc.setQueryData(issueTemplateKeys.list(wsId), (old) => + old ? old.map((item) => (item.id === template.id ? template : item)) : old, + ); + qc.setQueryData(issueTemplateKeys.detail(wsId, template.id), template); + }, + onSettled: (_data, _err, vars) => { + qc.invalidateQueries({ queryKey: issueTemplateKeys.list(wsId) }); + qc.invalidateQueries({ queryKey: issueTemplateKeys.detail(wsId, vars.id) }); + }, + }); +} + +export function useDeleteIssueTemplate() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: (id: string) => api.deleteIssueTemplate(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: issueTemplateKeys.list(wsId) }); + const prevList = qc.getQueryData(issueTemplateKeys.list(wsId)); + qc.setQueryData(issueTemplateKeys.list(wsId), (old) => + old ? old.filter((item) => item.id !== id) : old, + ); + qc.removeQueries({ queryKey: issueTemplateKeys.detail(wsId, id) }); + return { prevList }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prevList) qc.setQueryData(issueTemplateKeys.list(wsId), ctx.prevList); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: issueTemplateKeys.list(wsId) }); + }, + }); +} diff --git a/packages/core/issue-templates/queries.ts b/packages/core/issue-templates/queries.ts new file mode 100644 index 0000000000..19ea76ceeb --- /dev/null +++ b/packages/core/issue-templates/queries.ts @@ -0,0 +1,24 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +export const issueTemplateKeys = { + all: (wsId: string) => ["issue-templates", wsId] as const, + list: (wsId: string) => [...issueTemplateKeys.all(wsId), "list"] as const, + detail: (wsId: string, id: string) => + [...issueTemplateKeys.all(wsId), "detail", id] as const, +}; + +export function issueTemplateListOptions(wsId: string) { + return queryOptions({ + queryKey: issueTemplateKeys.list(wsId), + queryFn: () => api.listIssueTemplates(), + }); +} + +export function issueTemplateDetailOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: issueTemplateKeys.detail(wsId, id), + queryFn: () => api.getIssueTemplate(id), + enabled: !!id, + }); +} diff --git a/packages/core/issue-templates/schemas.test.ts b/packages/core/issue-templates/schemas.test.ts new file mode 100644 index 0000000000..472952c0ba --- /dev/null +++ b/packages/core/issue-templates/schemas.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { parseWithFallback } from "../api/schema"; +import { + IssueTemplateSummaryListSchema, + IssueTemplateDetailSchema, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + EMPTY_ISSUE_TEMPLATE_DETAIL, +} from "./schemas"; + +const validSummary = { + id: "tpl-1", + workspace_id: "ws-1", + name: "Bug report", + issue_title: "Investigate {{area}} bug", + config: {}, + created_by: "user-1", + created_at: "2026-05-12T00:00:00Z", + updated_at: "2026-05-12T00:00:00Z", +}; + +const validDetail = { + ...validSummary, + issue_content: "## Context\n\nSteps to reproduce", +}; + +describe("IssueTemplateSummaryListSchema", () => { + it("parses a valid list response", () => { + const result = parseWithFallback( + [validSummary], + IssueTemplateSummaryListSchema, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + { endpoint: "listIssueTemplates" }, + ); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("Bug report"); + }); + + it("falls back to empty array on non-array body", () => { + const result = parseWithFallback( + null, + IssueTemplateSummaryListSchema, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + { endpoint: "listIssueTemplates" }, + ); + expect(result).toEqual([]); + }); + + it("falls back to empty array on malformed items", () => { + const result = parseWithFallback( + [{ id: "x" }], + IssueTemplateSummaryListSchema, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + { endpoint: "listIssueTemplates" }, + ); + expect(result).toEqual([]); + }); + + it("tolerates missing optional fields (created_by null)", () => { + const result = parseWithFallback( + [{ ...validSummary, created_by: null }], + IssueTemplateSummaryListSchema, + EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST, + { endpoint: "listIssueTemplates" }, + ); + expect(result).toHaveLength(1); + expect(result[0]!.created_by).toBeNull(); + }); +}); + +describe("IssueTemplateDetailSchema", () => { + it("parses a valid detail response", () => { + const result = parseWithFallback( + validDetail, + IssueTemplateDetailSchema, + EMPTY_ISSUE_TEMPLATE_DETAIL, + { endpoint: "getIssueTemplate" }, + ); + expect(result.id).toBe("tpl-1"); + expect(result.issue_content).toBe("## Context\n\nSteps to reproduce"); + }); + + it("falls back on missing required fields", () => { + const result = parseWithFallback( + { id: "x" }, + IssueTemplateDetailSchema, + EMPTY_ISSUE_TEMPLATE_DETAIL, + { endpoint: "getIssueTemplate" }, + ); + expect(result).toEqual(EMPTY_ISSUE_TEMPLATE_DETAIL); + }); + + it("falls back on null body", () => { + const result = parseWithFallback( + null, + IssueTemplateDetailSchema, + EMPTY_ISSUE_TEMPLATE_DETAIL, + { endpoint: "getIssueTemplate" }, + ); + expect(result).toEqual(EMPTY_ISSUE_TEMPLATE_DETAIL); + }); +}); diff --git a/packages/core/issue-templates/schemas.ts b/packages/core/issue-templates/schemas.ts new file mode 100644 index 0000000000..c546c6a634 --- /dev/null +++ b/packages/core/issue-templates/schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { IssueTemplate, IssueTemplateSummary } from "../types"; + +const IssueTemplateSummarySchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string(), + issue_title: z.string(), + config: z.record(z.string(), z.unknown()).default({}), + created_by: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export const IssueTemplateSummaryListSchema = z.array(IssueTemplateSummarySchema); + +export const IssueTemplateDetailSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + name: z.string(), + issue_title: z.string(), + issue_content: z.string(), + config: z.record(z.string(), z.unknown()).default({}), + created_by: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}); + +export const EMPTY_ISSUE_TEMPLATE_SUMMARY_LIST: IssueTemplateSummary[] = []; + +export const EMPTY_ISSUE_TEMPLATE_DETAIL: IssueTemplate = { + id: "", + workspace_id: "", + name: "", + issue_title: "", + issue_content: "", + config: {}, + created_by: null, + created_at: "", + updated_at: "", +}; diff --git a/packages/core/package.json b/packages/core/package.json index 52ce1cc753..06f60f2d7f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,9 @@ "./issues/stores": "./issues/stores/index.ts", "./issues/stores/view-store-context": "./issues/stores/view-store-context.tsx", "./issues/stores/*": "./issues/stores/*.ts", + "./issue-templates": "./issue-templates/index.ts", + "./issue-templates/queries": "./issue-templates/queries.ts", + "./issue-templates/mutations": "./issue-templates/mutations.ts", "./inbox": "./inbox/index.ts", "./inbox/queries": "./inbox/queries.ts", "./inbox/mutations": "./inbox/mutations.ts", diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts index 422b47df50..a54127c92f 100644 --- a/packages/core/paths/consistency.test.ts +++ b/packages/core/paths/consistency.test.ts @@ -27,7 +27,7 @@ describe("paths.workspace() shape", () => { "myIssues", "runtimes", "skills", - "squads", + "issueTemplates", "settings", ]), ); @@ -48,7 +48,7 @@ describe("paths.workspace() shape", () => { ["myIssues", "my-issues"], ["runtimes", "runtimes"], ["skills", "skills"], - ["squads", "squads"], + ["issueTemplates", "issue-templates"], ["settings", "settings"], ]; const wsAsAny = ws as unknown as Record string>; diff --git a/packages/core/paths/paths.test.ts b/packages/core/paths/paths.test.ts index 5df542a301..9620660576 100644 --- a/packages/core/paths/paths.test.ts +++ b/packages/core/paths/paths.test.ts @@ -19,6 +19,8 @@ describe("paths.workspace(slug)", () => { expect(ws.runtimes()).toBe("/acme/runtimes"); expect(ws.skills()).toBe("/acme/skills"); expect(ws.skillDetail("skl_123")).toBe("/acme/skills/skl_123"); + expect(ws.issueTemplates()).toBe("/acme/issue-templates"); + expect(ws.issueTemplateDetail("tpl_123")).toBe("/acme/issue-templates/tpl_123"); expect(ws.squads()).toBe("/acme/squads"); expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1"); expect(ws.settings()).toBe("/acme/settings"); diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index 8743c9e375..5db328abb3 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -36,6 +36,8 @@ function workspaceScoped(slug: string) { runtimeDetail: (id: string) => `${ws}/runtimes/${encode(id)}`, skills: () => `${ws}/skills`, skillDetail: (id: string) => `${ws}/skills/${encode(id)}`, + issueTemplates: () => `${ws}/issue-templates`, + issueTemplateDetail: (id: string) => `${ws}/issue-templates/${encode(id)}`, settings: () => `${ws}/settings`, attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`, }; diff --git a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx index 58ae33d647..7825ca3642 100644 --- a/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx +++ b/packages/core/realtime/use-realtime-sync-ws-instance.test.tsx @@ -102,8 +102,8 @@ describe("useRealtimeSync — ws instance change", () => { rerender({ ws: ws2 }); // Should have called invalidateQueries for all workspace-scoped keys - // (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls) - expect(invalidateSpy).toHaveBeenCalledTimes(13); + // (13 workspace-scoped + 1 workspaceKeys.list() = 14 calls) + expect(invalidateSpy).toHaveBeenCalledTimes(14); }); it("does not re-invalidate when rerendered with the same ws instance", () => { diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 87b5660dac..6cd4e47ce8 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -21,6 +21,7 @@ import { agentTasksKeys, } from "../agents/queries"; import { githubKeys } from "../github/queries"; +import { issueTemplateKeys } from "../issue-templates/queries"; import { onIssueCreated, onIssueUpdated, @@ -162,6 +163,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void { qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) }); qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) }); qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueTemplateKeys.all(wsId) }); } qc.invalidateQueries({ queryKey: workspaceKeys.list() }); } @@ -238,6 +240,10 @@ export function useRealtimeSync( const wsId = getCurrentWsId(); if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) }); }, + issue_template: () => { + const wsId = getCurrentWsId(); + if (wsId) qc.invalidateQueries({ queryKey: issueTemplateKeys.all(wsId) }); + }, project: () => { const wsId = getCurrentWsId(); if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) }); diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index 62165ae16f..42511fe307 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -43,6 +43,9 @@ export type WSEventType = | "skill:created" | "skill:updated" | "skill:deleted" + | "issue_template:created" + | "issue_template:updated" + | "issue_template:deleted" | "subscriber:added" | "subscriber:removed" | "activity:created" diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index 2612ffefbf..a62fd2572d 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -1,4 +1,10 @@ export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue"; +export type { + IssueTemplate, + IssueTemplateSummary, + CreateIssueTemplateRequest, + UpdateIssueTemplateRequest, +} from "./issue-template"; export type { Agent, AgentStatus, diff --git a/packages/core/types/issue-template.ts b/packages/core/types/issue-template.ts new file mode 100644 index 0000000000..33242ec036 --- /dev/null +++ b/packages/core/types/issue-template.ts @@ -0,0 +1,36 @@ +export interface IssueTemplate { + id: string; + workspace_id: string; + name: string; + issue_title: string; + issue_content: string; + config: Record; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface IssueTemplateSummary { + id: string; + workspace_id: string; + name: string; + issue_title: string; + config: Record; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export interface CreateIssueTemplateRequest { + name: string; + issue_title: string; + issue_content?: string; + config?: Record; +} + +export interface UpdateIssueTemplateRequest { + name?: string; + issue_title?: string; + issue_content?: string; + config?: Record; +} diff --git a/packages/views/editor/content-editor.test.tsx b/packages/views/editor/content-editor.test.tsx index 2cd502c99a..7b98218116 100644 --- a/packages/views/editor/content-editor.test.tsx +++ b/packages/views/editor/content-editor.test.tsx @@ -1,3 +1,4 @@ +import { createRef } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; @@ -70,6 +71,7 @@ vi.mock("@tiptap/react", () => ({ })); import { ContentEditor } from "./content-editor"; +import type { ContentEditorRef } from "./content-editor"; describe("ContentEditor", () => { beforeEach(() => { @@ -100,6 +102,17 @@ describe("ContentEditor", () => { expect(mockFocus).not.toHaveBeenCalled(); }); + it("sets markdown content through the imperative ref", () => { + const ref = createRef(); + render(); + + ref.current?.setMarkdown("## Template\n\nBody"); + + expect(mockSetContent).toHaveBeenCalledWith("## Template\n\nBody", { + contentType: "markdown", + }); + }); + it("syncs editor content when defaultValue changes externally and editor is unfocused", () => { editorState.markdown = "old content"; const { rerender } = render(); diff --git a/packages/views/editor/content-editor.tsx b/packages/views/editor/content-editor.tsx index 53ba12c6d1..4c6c4c3db0 100644 --- a/packages/views/editor/content-editor.tsx +++ b/packages/views/editor/content-editor.tsx @@ -109,6 +109,7 @@ interface ContentEditorProps { interface ContentEditorRef { getMarkdown: () => string; + setMarkdown: (markdown: string) => void; clearContent: () => void; focus: () => void; /** Drop focus from the editor — used by chat after send so the caret @@ -280,6 +281,13 @@ const ContentEditor = forwardRef( useImperativeHandle(ref, () => ({ getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""), + setMarkdown: (markdown: string) => { + editor?.commands.setContent(preprocessMarkdown(markdown), { + contentType: "markdown", + }); + lastEmittedRef.current = stripBlobUrls(markdown).trimEnd(); + onUpdateRef.current?.(stripBlobUrls(markdown).trimEnd()); + }, clearContent: () => { editor?.commands.clearContent(); }, diff --git a/packages/views/editor/utils/link-handler.ts b/packages/views/editor/utils/link-handler.ts index 4f4e580283..bd19137afa 100644 --- a/packages/views/editor/utils/link-handler.ts +++ b/packages/views/editor/utils/link-handler.ts @@ -28,6 +28,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([ "my-issues", "runtimes", "skills", + "issue-templates", "settings", ]); diff --git a/packages/views/i18n/resources-types.ts b/packages/views/i18n/resources-types.ts index efc3cfa260..e7209437fd 100644 --- a/packages/views/i18n/resources-types.ts +++ b/packages/views/i18n/resources-types.ts @@ -20,6 +20,7 @@ import type workspace from "../locales/en/workspace.json"; import type projects from "../locales/en/projects.json"; import type autopilots from "../locales/en/autopilots.json"; import type skills from "../locales/en/skills.json"; +import type issueTemplates from "../locales/en/issue-templates.json"; import type chat from "../locales/en/chat.json"; import type modals from "../locales/en/modals.json"; import type runtimes from "../locales/en/runtimes.json"; @@ -60,6 +61,7 @@ declare global { projects: typeof projects; autopilots: typeof autopilots; skills: typeof skills; + "issue-templates": typeof issueTemplates; chat: typeof chat; modals: typeof modals; runtimes: typeof runtimes; diff --git a/packages/views/issue-templates/components/create-issue-template-dialog.tsx b/packages/views/issue-templates/components/create-issue-template-dialog.tsx new file mode 100644 index 0000000000..19780472cc --- /dev/null +++ b/packages/views/issue-templates/components/create-issue-template-dialog.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useState } from "react"; +import { AlertCircle, FileText, Loader2, Plus, X } from "lucide-react"; +import { toast } from "sonner"; +import type { IssueTemplate } from "@multica/core/types"; +import { useCreateIssueTemplate } from "@multica/core/issue-templates"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@multica/ui/components/ui/dialog"; +import { Button } from "@multica/ui/components/ui/button"; +import { Input } from "@multica/ui/components/ui/input"; +import { Label } from "@multica/ui/components/ui/label"; +import { Textarea } from "@multica/ui/components/ui/textarea"; +import { useT } from "../../i18n"; + +export function CreateIssueTemplateDialog({ + onClose, + onCreated, +}: { + onClose: () => void; + onCreated: (template: IssueTemplate) => void; +}) { + const { t } = useT("issue-templates"); + const createTemplate = useCreateIssueTemplate(); + const [name, setName] = useState(""); + const [issueTitle, setIssueTitle] = useState(""); + const [issueContent, setIssueContent] = useState(""); + const [error, setError] = useState(""); + + const submit = async () => { + const trimmedName = name.trim(); + const trimmedTitle = issueTitle.trim(); + if (!trimmedName || !trimmedTitle) return; + + setError(""); + try { + const template = await createTemplate.mutateAsync({ + name: trimmedName, + issue_title: trimmedTitle, + issue_content: issueContent, + }); + toast.success(t(($) => $.create.toast_created)); + onCreated(template); + } catch (err) { + setError(err instanceof Error ? err.message : t(($) => $.create.fallback_error)); + } + }; + + const loading = createTemplate.isPending; + + return ( + !open && onClose()}> + +
+
+ + {t(($) => $.create.title)} + +

+ {t(($) => $.create.description)} +

+
+ +
+ +
+
+ + { + setName(e.target.value); + setError(""); + }} + placeholder={t(($) => $.create.name_placeholder)} + /> +
+ +
+ + { + setIssueTitle(e.target.value); + setError(""); + }} + placeholder={t(($) => $.create.issue_title_placeholder)} + /> +
+ +
+ +