diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 84b53d68..86847a30 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -134,6 +134,8 @@ vi.mock("@/shared/api", () => ({ const mockIssue: Issue = { id: "issue-1", workspace_id: "ws-1", + number: 1, + identifier: "TES-1", title: "Implement authentication", description: "Add JWT auth to the backend", status: "in_progress", diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index ad28a800..62a24c6c 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -211,6 +211,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-1", workspace_id: "ws-1", + number: 1, + identifier: "TES-1", title: "Implement auth", description: "Add JWT authentication", status: "todo", @@ -227,6 +229,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-2", workspace_id: "ws-1", + number: 2, + identifier: "TES-2", title: "Design landing page", description: null, status: "in_progress", @@ -243,6 +247,8 @@ const mockIssues: Issue[] = [ ...issueDefaults, id: "issue-3", workspace_id: "ws-1", + number: 3, + identifier: "TES-3", title: "Write tests", description: null, status: "backlog", diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index f770639c..0596796d 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -480,7 +480,7 @@ export function IssueDetail({ issueId, onDelete }: IssueDetailProps) { )} - {issue.id.slice(0, 8)} + {issue.identifier} {issue.title} diff --git a/apps/web/features/issues/components/list-row.tsx b/apps/web/features/issues/components/list-row.tsx index 7f2cfe19..ce03b9f2 100644 --- a/apps/web/features/issues/components/list-row.tsx +++ b/apps/web/features/issues/components/list-row.tsx @@ -20,7 +20,7 @@ export function ListRow({ issue }: { issue: Issue }) { > - {issue.id.slice(0, 8)} + {issue.identifier} {issue.title} {issue.due_date && ( diff --git a/apps/web/shared/types/issue.ts b/apps/web/shared/types/issue.ts index 98620386..808d569d 100644 --- a/apps/web/shared/types/issue.ts +++ b/apps/web/shared/types/issue.ts @@ -14,6 +14,8 @@ export type IssueAssigneeType = "member" | "agent"; export interface Issue { id: string; workspace_id: string; + number: number; + identifier: string; title: string; description: string | null; status: IssueStatus; diff --git a/apps/web/shared/types/workspace.ts b/apps/web/shared/types/workspace.ts index 9fdc45a4..a6fb98e6 100644 --- a/apps/web/shared/types/workspace.ts +++ b/apps/web/shared/types/workspace.ts @@ -13,6 +13,7 @@ export interface Workspace { context: string | null; settings: Record; repos: WorkspaceRepo[]; + issue_prefix: string; created_at: string; updated_at: string; } diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx index 5bb9fe3f..3eb15b31 100644 --- a/apps/web/test/helpers.tsx +++ b/apps/web/test/helpers.tsx @@ -22,6 +22,7 @@ export const mockWorkspace: Workspace = { context: null, settings: {}, repos: [], + issue_prefix: "TES", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index db482fb6..aaee0cd5 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -179,6 +179,14 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return db.Issue{}, false } + // Try identifier format first (e.g., "JIA-42"). + if issue, ok := h.resolveIssueByIdentifier(r.Context(), issueID, resolveWorkspaceID(r)); ok { + if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok { + return db.Issue{}, false + } + return issue, true + } + issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID)) if err != nil { writeError(w, http.StatusNotFound, "issue not found") @@ -192,6 +200,64 @@ func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issue return issue, true } +// resolveIssueByIdentifier tries to look up an issue by "PREFIX-NUMBER" format. +func (h *Handler) resolveIssueByIdentifier(ctx context.Context, id, workspaceID string) (db.Issue, bool) { + parts := splitIdentifier(id) + if parts == nil { + return db.Issue{}, false + } + if workspaceID == "" { + return db.Issue{}, false + } + issue, err := h.Queries.GetIssueByNumber(ctx, db.GetIssueByNumberParams{ + WorkspaceID: parseUUID(workspaceID), + Number: parts.number, + }) + if err != nil { + return db.Issue{}, false + } + return issue, true +} + +type identifierParts struct { + prefix string + number int32 +} + +func splitIdentifier(id string) *identifierParts { + idx := -1 + for i := len(id) - 1; i >= 0; i-- { + if id[i] == '-' { + idx = i + break + } + } + if idx <= 0 || idx >= len(id)-1 { + return nil + } + numStr := id[idx+1:] + num := 0 + for _, c := range numStr { + if c < '0' || c > '9' { + return nil + } + num = num*10 + int(c-'0') + } + if num <= 0 { + return nil + } + return &identifierParts{prefix: id[:idx], number: int32(num)} +} + +// getIssuePrefix fetches the issue_prefix for a workspace. +func (h *Handler) getIssuePrefix(ctx context.Context, workspaceID pgtype.UUID) string { + ws, err := h.Queries.GetWorkspace(ctx, workspaceID) + if err != nil { + return "" + } + return ws.IssuePrefix +} + func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) { if _, ok := requireUserID(w, r); !ok { return db.Agent{}, false diff --git a/server/internal/handler/handler_test.go b/server/internal/handler/handler_test.go index 25f1408b..e18630f6 100644 --- a/server/internal/handler/handler_test.go +++ b/server/internal/handler/handler_test.go @@ -90,10 +90,10 @@ func setupHandlerTestFixture(ctx context.Context, pool *pgxpool.Pool) (string, s var workspaceID string if err := pool.QueryRow(ctx, ` - INSERT INTO workspace (name, slug, description) - VALUES ($1, $2, $3) + INSERT INTO workspace (name, slug, description, issue_prefix) + VALUES ($1, $2, $3, $4) RETURNING id - `, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests").Scan(&workspaceID); err != nil { + `, "Handler Tests", handlerTestWorkspaceSlug, "Temporary workspace for handler tests", "HAN").Scan(&workspaceID); err != nil { return "", "", err } diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index 08369f5d..7ad02c9a 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -20,6 +20,8 @@ import ( type IssueResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` + Number int32 `json:"number"` + Identifier string `json:"identifier"` Title string `json:"title"` Description *string `json:"description"` Status string `json:"status"` @@ -41,10 +43,13 @@ type agentTriggerSnapshot struct { Config map[string]any `json:"config"` } -func issueToResponse(i db.Issue) IssueResponse { +func issueToResponse(i db.Issue, issuePrefix string) IssueResponse { + identifier := issuePrefix + "-" + strconv.Itoa(int(i.Number)) return IssueResponse{ ID: uuidToString(i.ID), WorkspaceID: uuidToString(i.WorkspaceID), + Number: i.Number, + Identifier: identifier, Title: i.Title, Description: textToPtr(i.Description), Status: i.Status, @@ -109,9 +114,10 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { return } + prefix := h.getIssuePrefix(ctx, parseUUID(workspaceID)) resp := make([]IssueResponse, len(issues)) for i, issue := range issues { - resp[i] = issueToResponse(issue) + resp[i] = issueToResponse(issue, prefix) } writeJSON(w, http.StatusOK, map[string]any{ @@ -126,7 +132,8 @@ func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) { if !ok { return } - writeJSON(w, http.StatusOK, issueToResponse(issue)) + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + writeJSON(w, http.StatusOK, issueToResponse(issue, prefix)) } type CreateIssueRequest struct { @@ -196,7 +203,24 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { dueDate = pgtype.Timestamptz{Time: t, Valid: true} } - issue, err := h.Queries.CreateIssue(r.Context(), db.CreateIssueParams{ + // Use a transaction to atomically increment the workspace issue counter + // and create the issue with the assigned number. + tx, err := h.TxStarter.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + defer tx.Rollback(r.Context()) + + qtx := h.Queries.WithTx(tx) + issueNumber, err := qtx.IncrementIssueCounter(r.Context(), parseUUID(workspaceID)) + if err != nil { + slog.Warn("increment issue counter failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + + issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{ WorkspaceID: parseUUID(workspaceID), Title: req.Title, Description: ptrToText(req.Description), @@ -209,6 +233,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { ParentIssueID: parentIssueID, Position: 0, DueDate: dueDate, + Number: issueNumber, }) if err != nil { slog.Warn("create issue failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) @@ -216,7 +241,13 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) { return } - resp := issueToResponse(issue) + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "failed to create issue") + return + } + + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + resp := issueToResponse(issue, prefix) slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...) h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp}) @@ -326,7 +357,8 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) { return } - resp := issueToResponse(issue) + prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID) + resp := issueToResponse(issue, prefix) slog.Info("issue updated", append(logger.RequestAttrs(r), "issue_id", id, "workspace_id", workspaceID)...) assigneeChanged := (req.AssigneeType != nil || req.AssigneeID != nil) && diff --git a/server/internal/handler/workspace.go b/server/internal/handler/workspace.go index b8560555..2823be60 100644 --- a/server/internal/handler/workspace.go +++ b/server/internal/handler/workspace.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "regexp" "strings" "github.com/go-chi/chi/v5" @@ -13,6 +14,22 @@ import ( "github.com/multica-ai/multica/server/pkg/protocol" ) +var nonAlpha = regexp.MustCompile(`[^a-zA-Z]`) + +// generateIssuePrefix produces a 2-5 char uppercase prefix from a workspace name. +// Examples: "Jiayuan's Workspace" → "JIA", "My Team" → "MYT", "AB" → "AB". +func generateIssuePrefix(name string) string { + letters := nonAlpha.ReplaceAllString(name, "") + if len(letters) == 0 { + return "WS" + } + letters = strings.ToUpper(letters) + if len(letters) > 3 { + letters = letters[:3] + } + return letters +} + type WorkspaceResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -21,6 +38,7 @@ type WorkspaceResponse struct { Context *string `json:"context"` Settings any `json:"settings"` Repos any `json:"repos"` + IssuePrefix string `json:"issue_prefix"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -48,6 +66,7 @@ func workspaceToResponse(w db.Workspace) WorkspaceResponse { Context: textToPtr(w.Context), Settings: settings, Repos: repos, + IssuePrefix: w.IssuePrefix, CreatedAt: timestampToString(w.CreatedAt), UpdatedAt: timestampToString(w.UpdatedAt), } @@ -110,6 +129,7 @@ type CreateWorkspaceRequest struct { Slug string `json:"slug"` Description *string `json:"description"` Context *string `json:"context"` + IssuePrefix *string `json:"issue_prefix"` } func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { @@ -138,12 +158,18 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + issuePrefix := generateIssuePrefix(req.Name) + if req.IssuePrefix != nil && strings.TrimSpace(*req.IssuePrefix) != "" { + issuePrefix = strings.ToUpper(strings.TrimSpace(*req.IssuePrefix)) + } + qtx := h.Queries.WithTx(tx) ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{ Name: req.Name, Slug: req.Slug, Description: ptrToText(req.Description), Context: ptrToText(req.Context), + IssuePrefix: issuePrefix, }) if err != nil { if isUniqueViolation(err) { @@ -179,6 +205,7 @@ type UpdateWorkspaceRequest struct { Context *string `json:"context"` Settings any `json:"settings"` Repos any `json:"repos"` + IssuePrefix *string `json:"issue_prefix"` } func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { @@ -218,6 +245,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { reposJSON, _ := json.Marshal(req.Repos) params.Repos = reposJSON } + if req.IssuePrefix != nil { + prefix := strings.ToUpper(strings.TrimSpace(*req.IssuePrefix)) + if prefix != "" { + params.IssuePrefix = pgtype.Text{String: prefix, Valid: true} + } + } ws, err := h.Queries.UpdateWorkspace(r.Context(), params) if err != nil { diff --git a/server/internal/service/task.go b/server/internal/service/task.go index 7fe74d6d..621fad09 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -7,6 +7,8 @@ import ( "fmt" "log/slog" + "strconv" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" @@ -361,15 +363,24 @@ func (s *TaskService) broadcastTaskEvent(ctx context.Context, eventType string, } func (s *TaskService) broadcastIssueUpdated(issue db.Issue) { + prefix := s.getIssuePrefix(issue.WorkspaceID) s.Bus.Publish(events.Event{ Type: protocol.EventIssueUpdated, WorkspaceID: util.UUIDToString(issue.WorkspaceID), ActorType: "system", ActorID: "", - Payload: map[string]any{"issue": issueToMap(issue)}, + Payload: map[string]any{"issue": issueToMap(issue, prefix)}, }) } +func (s *TaskService) getIssuePrefix(workspaceID pgtype.UUID) string { + ws, err := s.Queries.GetWorkspace(context.Background(), workspaceID) + if err != nil { + return "" + } + return ws.IssuePrefix +} + func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID pgtype.UUID, content, commentType string) { if content == "" { return @@ -410,23 +421,25 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p }) } -func issueToMap(issue db.Issue) map[string]any { +func issueToMap(issue db.Issue, issuePrefix string) map[string]any { return map[string]any{ - "id": util.UUIDToString(issue.ID), - "workspace_id": util.UUIDToString(issue.WorkspaceID), - "title": issue.Title, - "description": util.TextToPtr(issue.Description), - "status": issue.Status, - "priority": issue.Priority, - "assignee_type": util.TextToPtr(issue.AssigneeType), - "assignee_id": util.UUIDToPtr(issue.AssigneeID), - "creator_type": issue.CreatorType, - "creator_id": util.UUIDToString(issue.CreatorID), + "id": util.UUIDToString(issue.ID), + "workspace_id": util.UUIDToString(issue.WorkspaceID), + "number": issue.Number, + "identifier": issuePrefix + "-" + strconv.Itoa(int(issue.Number)), + "title": issue.Title, + "description": util.TextToPtr(issue.Description), + "status": issue.Status, + "priority": issue.Priority, + "assignee_type": util.TextToPtr(issue.AssigneeType), + "assignee_id": util.UUIDToPtr(issue.AssigneeID), + "creator_type": issue.CreatorType, + "creator_id": util.UUIDToString(issue.CreatorID), "parent_issue_id": util.UUIDToPtr(issue.ParentIssueID), - "position": issue.Position, - "due_date": util.TimestampToPtr(issue.DueDate), - "created_at": util.TimestampToString(issue.CreatedAt), - "updated_at": util.TimestampToString(issue.UpdatedAt), + "position": issue.Position, + "due_date": util.TimestampToPtr(issue.DueDate), + "created_at": util.TimestampToString(issue.CreatedAt), + "updated_at": util.TimestampToString(issue.UpdatedAt), } } diff --git a/server/migrations/020_issue_number.down.sql b/server/migrations/020_issue_number.down.sql new file mode 100644 index 00000000..5c6a3061 --- /dev/null +++ b/server/migrations/020_issue_number.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_issue_workspace_number; +ALTER TABLE issue DROP CONSTRAINT IF EXISTS uq_issue_workspace_number; +ALTER TABLE issue DROP COLUMN IF EXISTS number; +ALTER TABLE workspace DROP COLUMN IF EXISTS issue_prefix; +ALTER TABLE workspace DROP COLUMN IF EXISTS issue_counter; diff --git a/server/migrations/020_issue_number.up.sql b/server/migrations/020_issue_number.up.sql new file mode 100644 index 00000000..2800cb0e --- /dev/null +++ b/server/migrations/020_issue_number.up.sql @@ -0,0 +1,36 @@ +-- Add issue_prefix and issue_counter to workspace for human-readable issue IDs. +ALTER TABLE workspace + ADD COLUMN issue_prefix TEXT NOT NULL DEFAULT '', + ADD COLUMN issue_counter INT NOT NULL DEFAULT 0; + +-- Add per-workspace issue number. +ALTER TABLE issue + ADD COLUMN number INT NOT NULL DEFAULT 0; + +-- Backfill: generate issue_prefix from workspace name (first 3 uppercase chars). +UPDATE workspace SET issue_prefix = UPPER( + LEFT(REGEXP_REPLACE(name, '[^a-zA-Z]', '', 'g'), 3) +); + +-- Fallback for workspaces with empty prefix after cleanup. +UPDATE workspace SET issue_prefix = 'WS' WHERE issue_prefix = ''; + +-- Backfill: assign sequential numbers to existing issues per workspace. +WITH numbered AS ( + SELECT id, workspace_id, + ROW_NUMBER() OVER (PARTITION BY workspace_id ORDER BY created_at ASC) AS rn + FROM issue +) +UPDATE issue SET number = numbered.rn +FROM numbered WHERE issue.id = numbered.id; + +-- Update workspace counters to match. +UPDATE workspace SET issue_counter = COALESCE( + (SELECT MAX(number) FROM issue WHERE issue.workspace_id = workspace.id), 0 +); + +-- Add unique constraint. +ALTER TABLE issue ADD CONSTRAINT uq_issue_workspace_number UNIQUE (workspace_id, number); + +-- Index for fast lookup by workspace + number. +CREATE INDEX idx_issue_workspace_number ON issue(workspace_id, number); diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index 8279be45..aae29518 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -15,10 +15,10 @@ const createIssue = `-- name: CreateIssue :one INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date + parent_issue_id, position, due_date, number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 -) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 +) RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type CreateIssueParams struct { @@ -34,6 +34,7 @@ type CreateIssueParams struct { ParentIssueID pgtype.UUID `json:"parent_issue_id"` Position float64 `json:"position"` DueDate pgtype.Timestamptz `json:"due_date"` + Number int32 `json:"number"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -50,6 +51,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.ParentIssueID, arg.Position, arg.DueDate, + arg.Number, ) var i Issue err := row.Scan( @@ -70,6 +72,7 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } @@ -84,7 +87,7 @@ func (q *Queries) DeleteIssue(ctx context.Context, id pgtype.UUID) error { } const getIssue = `-- name: GetIssue :one -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE id = $1 ` @@ -109,12 +112,49 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) { &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, + ) + return i, err +} + +const getIssueByNumber = `-- name: GetIssueByNumber :one +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue +WHERE workspace_id = $1 AND number = $2 +` + +type GetIssueByNumberParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + Number int32 `json:"number"` +} + +func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberParams) (Issue, error) { + row := q.db.QueryRow(ctx, getIssueByNumber, arg.WorkspaceID, arg.Number) + var i Issue + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.Title, + &i.Description, + &i.Status, + &i.Priority, + &i.AssigneeType, + &i.AssigneeID, + &i.CreatorType, + &i.CreatorID, + &i.ParentIssueID, + &i.AcceptanceCriteria, + &i.ContextRefs, + &i.Position, + &i.DueDate, + &i.CreatedAt, + &i.UpdatedAt, + &i.Number, ) return i, err } const listIssues = `-- name: ListIssues :many -SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at FROM issue +SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number FROM issue WHERE workspace_id = $1 AND ($4::text IS NULL OR status = $4) AND ($5::text IS NULL OR priority = $5) @@ -166,6 +206,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ); err != nil { return nil, err } @@ -189,7 +230,7 @@ UPDATE issue SET due_date = $9, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type UpdateIssueParams struct { @@ -235,6 +276,7 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } @@ -244,7 +286,7 @@ UPDATE issue SET status = $2, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at +RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number ` type UpdateIssueStatusParams struct { @@ -273,6 +315,7 @@ func (q *Queries) UpdateIssueStatus(ctx context.Context, arg UpdateIssueStatusPa &i.DueDate, &i.CreatedAt, &i.UpdatedAt, + &i.Number, ) return i, err } diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 546a3bad..91c8aad6 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -152,6 +152,7 @@ type Issue struct { DueDate pgtype.Timestamptz `json:"due_date"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Number int32 `json:"number"` } type IssueDependency struct { @@ -256,13 +257,15 @@ type VerificationCode struct { } type Workspace struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - Description pgtype.Text `json:"description"` - Settings []byte `json:"settings"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - UpdatedAt pgtype.Timestamptz `json:"updated_at"` - Context pgtype.Text `json:"context"` - Repos []byte `json:"repos"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description pgtype.Text `json:"description"` + Settings []byte `json:"settings"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Context pgtype.Text `json:"context"` + Repos []byte `json:"repos"` + IssuePrefix string `json:"issue_prefix"` + IssueCounter int32 `json:"issue_counter"` } diff --git a/server/pkg/db/generated/workspace.sql.go b/server/pkg/db/generated/workspace.sql.go index bd527a91..c751ca39 100644 --- a/server/pkg/db/generated/workspace.sql.go +++ b/server/pkg/db/generated/workspace.sql.go @@ -12,9 +12,9 @@ import ( ) const createWorkspace = `-- name: CreateWorkspace :one -INSERT INTO workspace (name, slug, description, context) -VALUES ($1, $2, $3, $4) -RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos +INSERT INTO workspace (name, slug, description, context, issue_prefix) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter ` type CreateWorkspaceParams struct { @@ -22,6 +22,7 @@ type CreateWorkspaceParams struct { Slug string `json:"slug"` Description pgtype.Text `json:"description"` Context pgtype.Text `json:"context"` + IssuePrefix string `json:"issue_prefix"` } func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) { @@ -30,6 +31,7 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams arg.Slug, arg.Description, arg.Context, + arg.IssuePrefix, ) var i Workspace err := row.Scan( @@ -42,6 +44,8 @@ func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } @@ -56,7 +60,7 @@ func (q *Queries) DeleteWorkspace(ctx context.Context, id pgtype.UUID) error { } const getWorkspace = `-- name: GetWorkspace :one -SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace WHERE id = $1 ` @@ -73,12 +77,14 @@ func (q *Queries) GetWorkspace(ctx context.Context, id pgtype.UUID) (Workspace, &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } const getWorkspaceBySlug = `-- name: GetWorkspaceBySlug :one -SELECT id, name, slug, description, settings, created_at, updated_at, context, repos FROM workspace +SELECT id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter FROM workspace WHERE slug = $1 ` @@ -95,12 +101,27 @@ func (q *Queries) GetWorkspaceBySlug(ctx context.Context, slug string) (Workspac &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } +const incrementIssueCounter = `-- name: IncrementIssueCounter :one +UPDATE workspace SET issue_counter = issue_counter + 1 +WHERE id = $1 +RETURNING issue_counter +` + +func (q *Queries) IncrementIssueCounter(ctx context.Context, id pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, incrementIssueCounter, id) + var issue_counter int32 + err := row.Scan(&issue_counter) + return issue_counter, err +} + const listWorkspaces = `-- name: ListWorkspaces :many -SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos FROM workspace w +SELECT w.id, w.name, w.slug, w.description, w.settings, w.created_at, w.updated_at, w.context, w.repos, w.issue_prefix, w.issue_counter FROM workspace w JOIN member m ON m.workspace_id = w.id WHERE m.user_id = $1 ORDER BY w.created_at ASC @@ -125,6 +146,8 @@ func (q *Queries) ListWorkspaces(ctx context.Context, userID pgtype.UUID) ([]Wor &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ); err != nil { return nil, err } @@ -143,9 +166,10 @@ UPDATE workspace SET context = COALESCE($4, context), settings = COALESCE($5, settings), repos = COALESCE($6, repos), + issue_prefix = COALESCE($7, issue_prefix), updated_at = now() WHERE id = $1 -RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos +RETURNING id, name, slug, description, settings, created_at, updated_at, context, repos, issue_prefix, issue_counter ` type UpdateWorkspaceParams struct { @@ -155,6 +179,7 @@ type UpdateWorkspaceParams struct { Context pgtype.Text `json:"context"` Settings []byte `json:"settings"` Repos []byte `json:"repos"` + IssuePrefix pgtype.Text `json:"issue_prefix"` } func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) { @@ -165,6 +190,7 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams arg.Context, arg.Settings, arg.Repos, + arg.IssuePrefix, ) var i Workspace err := row.Scan( @@ -177,6 +203,8 @@ func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams &i.UpdatedAt, &i.Context, &i.Repos, + &i.IssuePrefix, + &i.IssueCounter, ) return i, err } diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index efabdc0d..ed5614ba 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -15,11 +15,15 @@ WHERE id = $1; INSERT INTO issue ( workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, - parent_issue_id, position, due_date + parent_issue_id, position, due_date, number ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 ) RETURNING *; +-- name: GetIssueByNumber :one +SELECT * FROM issue +WHERE workspace_id = $1 AND number = $2; + -- name: UpdateIssue :one UPDATE issue SET title = COALESCE(sqlc.narg('title'), title), diff --git a/server/pkg/db/queries/workspace.sql b/server/pkg/db/queries/workspace.sql index 73172ede..b69a262c 100644 --- a/server/pkg/db/queries/workspace.sql +++ b/server/pkg/db/queries/workspace.sql @@ -13,8 +13,8 @@ SELECT * FROM workspace WHERE slug = $1; -- name: CreateWorkspace :one -INSERT INTO workspace (name, slug, description, context) -VALUES ($1, $2, $3, $4) +INSERT INTO workspace (name, slug, description, context, issue_prefix) +VALUES ($1, $2, $3, $4, $5) RETURNING *; -- name: UpdateWorkspace :one @@ -24,9 +24,15 @@ UPDATE workspace SET context = COALESCE(sqlc.narg('context'), context), settings = COALESCE(sqlc.narg('settings'), settings), repos = COALESCE(sqlc.narg('repos'), repos), + issue_prefix = COALESCE(sqlc.narg('issue_prefix'), issue_prefix), updated_at = now() WHERE id = $1 RETURNING *; +-- name: IncrementIssueCounter :one +UPDATE workspace SET issue_counter = issue_counter + 1 +WHERE id = $1 +RETURNING issue_counter; + -- name: DeleteWorkspace :exec DELETE FROM workspace WHERE id = $1;