merge: resolve conflict in issue-detail breadcrumb

Keep identifier removed from breadcrumbs per design decision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-29 17:45:53 +08:00
commit 3a8aec7d08
34 changed files with 1606 additions and 1191 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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) &&

View file

@ -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 {