+
descEditorRef.current?.uploadFile(file)}
/>
+
+
+
+
+
>
)}
>
diff --git a/packages/views/package.json b/packages/views/package.json
index acc53a0bc1..4e6e7a924f 100644
--- a/packages/views/package.json
+++ b/packages/views/package.json
@@ -24,6 +24,7 @@
"./modals/create-issue": "./modals/create-issue.tsx",
"./my-issues": "./my-issues/index.ts",
"./skills": "./skills/index.ts",
+ "./issue-templates": "./issue-templates/index.ts",
"./agents": "./agents/index.ts",
"./members": "./members/index.ts",
"./inbox": "./inbox/index.ts",
diff --git a/packages/views/search/search-command.test.tsx b/packages/views/search/search-command.test.tsx
index 66bc153cd6..d8180aa6ba 100644
--- a/packages/views/search/search-command.test.tsx
+++ b/packages/views/search/search-command.test.tsx
@@ -108,6 +108,7 @@ vi.mock("@multica/core/paths", () => ({
agents: () => "/ws-test/agents",
runtimes: () => "/ws-test/runtimes",
skills: () => "/ws-test/skills",
+ issueTemplates: () => "/ws-test/issue-templates",
settings: () => "/ws-test/settings",
issueDetail: (id: string) => `/ws-test/issues/${id}`,
memberDetail: (id: string) => `/ws-test/members/${id}`,
diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx
index 88e285ff48..8f7be9d277 100644
--- a/packages/views/search/search-command.tsx
+++ b/packages/views/search/search-command.tsx
@@ -118,6 +118,7 @@ type NavKey =
| "agents"
| "runtimes"
| "skills"
+ | "issueTemplates"
| "settings";
interface NavPage {
@@ -171,6 +172,7 @@ export function SearchCommand() {
{ key: "agents", label: t(($) => $.pages.agents), icon: Bot, keywords: ["agents", "bots", "ai"] },
{ key: "runtimes", label: t(($) => $.pages.runtimes), icon: Monitor, keywords: ["runtimes", "environments"] },
{ key: "skills", label: t(($) => $.pages.skills), icon: BookOpenText, keywords: ["skills", "library"] },
+ { key: "issueTemplates", label: t(($) => $.pages.issue_templates), icon: FileText, keywords: ["issue", "templates", "模板"] },
{ key: "settings", label: t(($) => $.pages.settings), icon: Settings, keywords: ["settings", "config", "preferences", "设置"] },
];
const { push, pathname, getShareableUrl } = useNavigation();
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index 0f996878f6..091824e17b 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -563,6 +563,18 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
})
})
+ // Issue templates
+ 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)
+ })
+ })
+
+
// Dashboard — workspace-wide token + run-time rollups for the
// "/{slug}/dashboard" page. Optional ?project_id filter scopes
// the rollup to a single project.
diff --git a/server/internal/handler/issue_template.go b/server/internal/handler/issue_template.go
new file mode 100644
index 0000000000..5630b71744
--- /dev/null
+++ b/server/internal/handler/issue_template.go
@@ -0,0 +1,293 @@
+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"
+ "github.com/multica-ai/multica/server/pkg/protocol"
+)
+
+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 IssueTemplateSummaryResponse struct {
+ ID string `json:"id"`
+ WorkspaceID string `json:"workspace_id"`
+ Name string `json:"name"`
+ IssueTitle string `json:"issue_title"`
+ 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),
+ }
+}
+
+func validateIssueTemplateFields(name, issueTitle string) (string, string, bool) {
+ trimmedName := strings.TrimSpace(name)
+ trimmedTitle := strings.TrimSpace(issueTitle)
+ return trimmedName, trimmedTitle, trimmedName != "" && trimmedTitle != ""
+}
+
+func (h *Handler) loadIssueTemplateForUser(w http.ResponseWriter, r *http.Request, id string) (db.IssueTemplate, bool) {
+ workspaceID := h.resolveWorkspaceID(r)
+ if workspaceID == "" {
+ writeError(w, http.StatusBadRequest, "workspace_id is required")
+ return db.IssueTemplate{}, false
+ }
+
+ templateID, ok := parseUUIDOrBadRequest(w, id, "id")
+ if !ok {
+ return db.IssueTemplate{}, false
+ }
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return db.IssueTemplate{}, false
+ }
+
+ template, err := h.Queries.GetIssueTemplateInWorkspace(r.Context(), db.GetIssueTemplateInWorkspaceParams{
+ ID: templateID,
+ WorkspaceID: workspaceUUID,
+ })
+ if err != nil {
+ writeError(w, http.StatusNotFound, "issue template not found")
+ return template, false
+ }
+ return template, true
+}
+
+func issueTemplateSummaryToResponse(t db.ListIssueTemplateSummariesByWorkspaceRow) IssueTemplateSummaryResponse {
+ return IssueTemplateSummaryResponse{
+ ID: uuidToString(t.ID),
+ WorkspaceID: uuidToString(t.WorkspaceID),
+ Name: t.Name,
+ IssueTitle: t.IssueTitle,
+ Config: decodeSkillConfig(t.Config),
+ CreatedBy: uuidToPtr(t.CreatedBy),
+ CreatedAt: timestampToString(t.CreatedAt),
+ UpdatedAt: timestampToString(t.UpdatedAt),
+ }
+}
+
+func (h *Handler) ListIssueTemplates(w http.ResponseWriter, r *http.Request) {
+ workspaceID := h.resolveWorkspaceID(r)
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+
+ templates, err := h.Queries.ListIssueTemplateSummariesByWorkspace(r.Context(), workspaceUUID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to list issue templates")
+ return
+ }
+
+ resp := make([]IssueTemplateSummaryResponse, len(templates))
+ for i, template := range templates {
+ resp[i] = issueTemplateSummaryToResponse(template)
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+func (h *Handler) GetIssueTemplate(w http.ResponseWriter, r *http.Request) {
+ template, ok := h.loadIssueTemplateForUser(w, r, chi.URLParam(r, "id"))
+ if !ok {
+ return
+ }
+ writeJSON(w, http.StatusOK, issueTemplateToResponse(template))
+}
+
+func (h *Handler) CreateIssueTemplate(w http.ResponseWriter, r *http.Request) {
+ workspaceID := h.resolveWorkspaceID(r)
+ workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
+ if !ok {
+ return
+ }
+ creatorID, ok := requireUserID(w, r)
+ if !ok {
+ return
+ }
+ if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin", "member"); !ok {
+ return
+ }
+
+ var req CreateIssueTemplateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ name, issueTitle, ok := validateIssueTemplateFields(req.Name, req.IssueTitle)
+ if !ok {
+ writeError(w, http.StatusBadRequest, "name and issue_title are required")
+ return
+ }
+
+ config, err := json.Marshal(req.Config)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid config")
+ return
+ }
+ if req.Config == nil {
+ config = []byte("{}")
+ } else if len(config) == 0 || config[0] != '{' {
+ writeError(w, http.StatusBadRequest, "config must be a JSON object")
+ return
+ }
+
+ template, err := h.Queries.CreateIssueTemplate(r.Context(), db.CreateIssueTemplateParams{
+ WorkspaceID: workspaceUUID,
+ Name: sanitizeNullBytes(name),
+ IssueTitle: sanitizeNullBytes(issueTitle),
+ IssueContent: sanitizeNullBytes(req.IssueContent),
+ Config: config,
+ CreatedBy: parseUUID(creatorID),
+ })
+ if err != nil {
+ if isUniqueViolation(err) {
+ writeError(w, http.StatusConflict, "an issue template with this name already exists")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to create issue template")
+ return
+ }
+
+ resp := issueTemplateToResponse(template)
+ wsID := uuidToString(workspaceUUID)
+ h.publish(protocol.EventIssueTemplateCreated, wsID, "member", creatorID, map[string]any{"issue_template": resp})
+ writeJSON(w, http.StatusCreated, resp)
+}
+
+func (h *Handler) canManageIssueTemplate(w http.ResponseWriter, r *http.Request, template db.IssueTemplate) bool {
+ wsID := uuidToString(template.WorkspaceID)
+ member, ok := h.requireWorkspaceRole(w, r, wsID, "issue template not found", "owner", "admin", "member")
+ if !ok {
+ return false
+ }
+ isAdmin := roleAllowed(member.Role, "owner", "admin")
+ isCreator := template.CreatedBy.Valid && uuidToString(template.CreatedBy) == requestUserID(r)
+ if !isAdmin && !isCreator {
+ writeError(w, http.StatusForbidden, "only the issue template creator can manage this issue template")
+ return false
+ }
+ return true
+}
+
+func (h *Handler) UpdateIssueTemplate(w http.ResponseWriter, r *http.Request) {
+ template, ok := h.loadIssueTemplateForUser(w, r, chi.URLParam(r, "id"))
+ if !ok {
+ return
+ }
+ if !h.canManageIssueTemplate(w, r, template) {
+ return
+ }
+
+ var req UpdateIssueTemplateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ params := db.UpdateIssueTemplateParams{ID: template.ID}
+ if req.Name != nil {
+ name := strings.TrimSpace(*req.Name)
+ if name == "" {
+ writeError(w, http.StatusBadRequest, "name is required")
+ return
+ }
+ params.Name = pgtype.Text{String: sanitizeNullBytes(name), Valid: true}
+ }
+ if req.IssueTitle != nil {
+ issueTitle := strings.TrimSpace(*req.IssueTitle)
+ if issueTitle == "" {
+ writeError(w, http.StatusBadRequest, "issue_title is required")
+ return
+ }
+ params.IssueTitle = pgtype.Text{String: sanitizeNullBytes(issueTitle), Valid: true}
+ }
+ if req.IssueContent != nil {
+ params.IssueContent = pgtype.Text{String: sanitizeNullBytes(*req.IssueContent), Valid: true}
+ }
+ if req.Config != nil {
+ config, err := json.Marshal(req.Config)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid config")
+ return
+ }
+ if len(config) == 0 || config[0] != '{' {
+ writeError(w, http.StatusBadRequest, "config must be a JSON object")
+ return
+ }
+ params.Config = config
+ }
+
+ template, err := h.Queries.UpdateIssueTemplate(r.Context(), params)
+ if err != nil {
+ if isUniqueViolation(err) {
+ writeError(w, http.StatusConflict, "an issue template with this name already exists")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to update issue template")
+ return
+ }
+
+ resp := issueTemplateToResponse(template)
+ h.publish(protocol.EventIssueTemplateUpdated, uuidToString(template.WorkspaceID), "member", requestUserID(r), map[string]any{"issue_template": resp})
+ writeJSON(w, http.StatusOK, resp)
+}
+
+func (h *Handler) DeleteIssueTemplate(w http.ResponseWriter, r *http.Request) {
+ template, ok := h.loadIssueTemplateForUser(w, r, chi.URLParam(r, "id"))
+ if !ok {
+ return
+ }
+ if !h.canManageIssueTemplate(w, r, template) {
+ return
+ }
+
+ if err := h.Queries.DeleteIssueTemplate(r.Context(), template.ID); err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to delete issue template")
+ return
+ }
+ h.publish(protocol.EventIssueTemplateDeleted, uuidToString(template.WorkspaceID), "member", requestUserID(r), map[string]any{"issue_template_id": uuidToString(template.ID)})
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/server/internal/handler/issue_template_test.go b/server/internal/handler/issue_template_test.go
new file mode 100644
index 0000000000..e2e3bf9cd9
--- /dev/null
+++ b/server/internal/handler/issue_template_test.go
@@ -0,0 +1,173 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestIssueTemplateCRUD(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ 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(http.MethodPost, "/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 id == "" {
+ t.Fatalf("created response missing id: %#v", created)
+ }
+ if created["name"] != "Bug report" || created["issue_title"] != "Investigate {{area}} bug" {
+ t.Fatalf("created response mismatch: %#v", created)
+ }
+ t.Cleanup(func() {
+ req := withURLParam(newRequest(http.MethodDelete, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil), "id", id)
+ testHandler.DeleteIssueTemplate(httptest.NewRecorder(), req)
+ })
+
+ w = httptest.NewRecorder()
+ testHandler.ListIssueTemplates(w, newRequest(http.MethodGet, "/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()
+ req := withURLParam(newRequest(http.MethodGet, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil), "id", id)
+ testHandler.GetIssueTemplate(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GetIssueTemplate status = %d body=%s", w.Code, w.Body.String())
+ }
+
+ w = httptest.NewRecorder()
+ req = withURLParam(newRequest(http.MethodPut, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, map[string]any{
+ "name": "Bug triage",
+ "issue_title": "Triage bug",
+ "issue_content": "Updated body",
+ }), "id", id)
+ testHandler.UpdateIssueTemplate(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("UpdateIssueTemplate status = %d body=%s", w.Code, w.Body.String())
+ }
+
+ w = httptest.NewRecorder()
+ req = withURLParam(newRequest(http.MethodDelete, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil), "id", id)
+ testHandler.DeleteIssueTemplate(w, req)
+ if w.Code != http.StatusNoContent {
+ t.Fatalf("DeleteIssueTemplate status = %d body=%s", w.Code, w.Body.String())
+ }
+}
+
+func TestIssueTemplateListOmitsContent(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ createReq := map[string]any{
+ "name": "Content test template",
+ "issue_title": "Title here",
+ "issue_content": "## Long body that should not appear in list",
+ }
+ w := httptest.NewRecorder()
+ testHandler.CreateIssueTemplate(w, newRequest(http.MethodPost, "/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
+ json.NewDecoder(w.Body).Decode(&created)
+ id := created["id"].(string)
+ t.Cleanup(func() {
+ req := withURLParam(newRequest(http.MethodDelete, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil), "id", id)
+ testHandler.DeleteIssueTemplate(httptest.NewRecorder(), req)
+ })
+
+ w = httptest.NewRecorder()
+ testHandler.ListIssueTemplates(w, newRequest(http.MethodGet, "/api/issue-templates?workspace_id="+testWorkspaceID, nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("ListIssueTemplates status = %d", w.Code)
+ }
+ var list []map[string]any
+ json.NewDecoder(w.Body).Decode(&list)
+ for _, item := range list {
+ if _, hasContent := item["issue_content"]; hasContent {
+ t.Fatalf("list response should not contain issue_content, got: %v", item)
+ }
+ }
+
+ w = httptest.NewRecorder()
+ req := withURLParam(newRequest(http.MethodGet, "/api/issue-templates/"+id+"?workspace_id="+testWorkspaceID, nil), "id", id)
+ testHandler.GetIssueTemplate(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GetIssueTemplate status = %d", w.Code)
+ }
+ var detail map[string]any
+ json.NewDecoder(w.Body).Decode(&detail)
+ if detail["issue_content"] != "## Long body that should not appear in list" {
+ t.Fatalf("detail should contain issue_content, got: %v", detail["issue_content"])
+ }
+}
+
+func TestIssueTemplateValidation(t *testing.T) {
+ if testHandler == nil {
+ t.Skip("database not available")
+ }
+
+ tests := []struct {
+ name string
+ body map[string]any
+ }{
+ {
+ name: "missing name",
+ body: map[string]any{
+ "issue_title": "Title",
+ "issue_content": "Content",
+ },
+ },
+ {
+ name: "missing issue title",
+ body: map[string]any{
+ "name": "Template",
+ "issue_content": "Content",
+ },
+ },
+ {
+ name: "config is array",
+ body: map[string]any{
+ "name": "Template",
+ "issue_title": "Title",
+ "config": []any{"a", "b"},
+ },
+ },
+ {
+ name: "config is scalar",
+ body: map[string]any{
+ "name": "Template",
+ "issue_title": "Title",
+ "config": "invalid",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w := httptest.NewRecorder()
+ testHandler.CreateIssueTemplate(w, newRequest(http.MethodPost, "/api/issue-templates?workspace_id="+testWorkspaceID, tt.body))
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("CreateIssueTemplate status = %d body=%s", w.Code, w.Body.String())
+ }
+ })
+ }
+}
diff --git a/server/migrations/108_issue_templates.down.sql b/server/migrations/108_issue_templates.down.sql
new file mode 100644
index 0000000000..917cfc3227
--- /dev/null
+++ b/server/migrations/108_issue_templates.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS issue_template;
diff --git a/server/migrations/108_issue_templates.up.sql b/server/migrations/108_issue_templates.up.sql
new file mode 100644
index 0000000000..9670df02c9
--- /dev/null
+++ b/server/migrations/108_issue_templates.up.sql
@@ -0,0 +1,14 @@
+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);
diff --git a/server/pkg/db/generated/issue_template.sql.go b/server/pkg/db/generated/issue_template.sql.go
new file mode 100644
index 0000000000..6f00a6b5a1
--- /dev/null
+++ b/server/pkg/db/generated/issue_template.sql.go
@@ -0,0 +1,179 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: issue_template.sql
+
+package db
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const createIssueTemplate = `-- 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 id, workspace_id, name, issue_title, issue_content, config, created_by, created_at, updated_at
+`
+
+type CreateIssueTemplateParams struct {
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ IssueTitle string `json:"issue_title"`
+ IssueContent string `json:"issue_content"`
+ Config []byte `json:"config"`
+ CreatedBy pgtype.UUID `json:"created_by"`
+}
+
+func (q *Queries) CreateIssueTemplate(ctx context.Context, arg CreateIssueTemplateParams) (IssueTemplate, error) {
+ row := q.db.QueryRow(ctx, createIssueTemplate,
+ arg.WorkspaceID,
+ arg.Name,
+ arg.IssueTitle,
+ arg.IssueContent,
+ arg.Config,
+ arg.CreatedBy,
+ )
+ var i IssueTemplate
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.IssueTitle,
+ &i.IssueContent,
+ &i.Config,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const deleteIssueTemplate = `-- name: DeleteIssueTemplate :exec
+DELETE FROM issue_template WHERE id = $1
+`
+
+func (q *Queries) DeleteIssueTemplate(ctx context.Context, id pgtype.UUID) error {
+ _, err := q.db.Exec(ctx, deleteIssueTemplate, id)
+ return err
+}
+
+const getIssueTemplateInWorkspace = `-- name: GetIssueTemplateInWorkspace :one
+SELECT id, workspace_id, name, issue_title, issue_content, config, created_by, created_at, updated_at
+FROM issue_template
+WHERE id = $1 AND workspace_id = $2
+`
+
+type GetIssueTemplateInWorkspaceParams struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetIssueTemplateInWorkspace(ctx context.Context, arg GetIssueTemplateInWorkspaceParams) (IssueTemplate, error) {
+ row := q.db.QueryRow(ctx, getIssueTemplateInWorkspace, arg.ID, arg.WorkspaceID)
+ var i IssueTemplate
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.IssueTitle,
+ &i.IssueContent,
+ &i.Config,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const listIssueTemplateSummariesByWorkspace = `-- name: ListIssueTemplateSummariesByWorkspace :many
+
+SELECT id, workspace_id, name, issue_title, config, created_by, created_at, updated_at
+FROM issue_template
+WHERE workspace_id = $1
+ORDER BY name ASC
+`
+
+type ListIssueTemplateSummariesByWorkspaceRow struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ IssueTitle string `json:"issue_title"`
+ Config []byte `json:"config"`
+ CreatedBy pgtype.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+// Issue Template CRUD
+func (q *Queries) ListIssueTemplateSummariesByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]ListIssueTemplateSummariesByWorkspaceRow, error) {
+ rows, err := q.db.Query(ctx, listIssueTemplateSummariesByWorkspace, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListIssueTemplateSummariesByWorkspaceRow{}
+ for rows.Next() {
+ var i ListIssueTemplateSummariesByWorkspaceRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.IssueTitle,
+ &i.Config,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const updateIssueTemplate = `-- name: UpdateIssueTemplate :one
+UPDATE issue_template SET
+ name = COALESCE($2, name),
+ issue_title = COALESCE($3, issue_title),
+ issue_content = COALESCE($4, issue_content),
+ config = COALESCE($5, config),
+ updated_at = now()
+WHERE id = $1
+RETURNING id, workspace_id, name, issue_title, issue_content, config, created_by, created_at, updated_at
+`
+
+type UpdateIssueTemplateParams struct {
+ ID pgtype.UUID `json:"id"`
+ Name pgtype.Text `json:"name"`
+ IssueTitle pgtype.Text `json:"issue_title"`
+ IssueContent pgtype.Text `json:"issue_content"`
+ Config []byte `json:"config"`
+}
+
+func (q *Queries) UpdateIssueTemplate(ctx context.Context, arg UpdateIssueTemplateParams) (IssueTemplate, error) {
+ row := q.db.QueryRow(ctx, updateIssueTemplate,
+ arg.ID,
+ arg.Name,
+ arg.IssueTitle,
+ arg.IssueContent,
+ arg.Config,
+ )
+ var i IssueTemplate
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.IssueTitle,
+ &i.IssueContent,
+ &i.Config,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go
index c78177312f..72b56ccd63 100644
--- a/server/pkg/db/generated/models.go
+++ b/server/pkg/db/generated/models.go
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
-// sqlc v1.31.1
+// sqlc v1.29.0
package db
@@ -399,6 +399,18 @@ type IssueSubscriber struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
+type IssueTemplate struct {
+ ID pgtype.UUID `json:"id"`
+ WorkspaceID pgtype.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ IssueTitle string `json:"issue_title"`
+ IssueContent string `json:"issue_content"`
+ Config []byte `json:"config"`
+ CreatedBy pgtype.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
type IssueToLabel struct {
IssueID pgtype.UUID `json:"issue_id"`
LabelID pgtype.UUID `json:"label_id"`
diff --git a/server/pkg/db/queries/issue_template.sql b/server/pkg/db/queries/issue_template.sql
new file mode 100644
index 0000000000..bd1624e533
--- /dev/null
+++ b/server/pkg/db/queries/issue_template.sql
@@ -0,0 +1,30 @@
+-- Issue Template CRUD
+
+-- name: ListIssueTemplateSummariesByWorkspace :many
+SELECT id, workspace_id, name, issue_title, 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;
diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go
index dd736ba68b..001a31b5d2 100644
--- a/server/pkg/protocol/events.go
+++ b/server/pkg/protocol/events.go
@@ -67,6 +67,11 @@ const (
EventSkillUpdated = "skill:updated"
EventSkillDeleted = "skill:deleted"
+ // Issue template events
+ EventIssueTemplateCreated = "issue_template:created"
+ EventIssueTemplateUpdated = "issue_template:updated"
+ EventIssueTemplateDeleted = "issue_template:deleted"
+
// Chat events
EventChatMessage = "chat:message"
EventChatDone = "chat:done"