diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go index 68c81b7..d810e7a 100644 --- a/api/internal/handler/project.go +++ b/api/internal/handler/project.go @@ -111,6 +111,10 @@ func (h *ProjectHandler) Create(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"}) return } + if err == service.ErrProjectIdentifierTooLong { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } @@ -310,6 +314,10 @@ func (h *ProjectHandler) Update(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } + if err == service.ErrProjectIdentifierTooLong { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"}) return } diff --git a/api/internal/service/issue.go b/api/internal/service/issue.go index ff3eefc..2bfca0c 100644 --- a/api/internal/service/issue.go +++ b/api/internal/service/issue.go @@ -126,7 +126,14 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project if parentID != nil { issue.ParentID = parentID } - if err := s.is.Create(ctx, issue); err != nil { + if err := s.is.Transaction(ctx, func(tx *gorm.DB) error { + seq, err := s.is.NextSequenceID(ctx, tx, projectID) + if err != nil { + return err + } + issue.SequenceID = seq + return tx.WithContext(ctx).Create(issue).Error + }); err != nil { return nil, err } if len(assigneeIDs) > 0 { diff --git a/api/internal/service/project.go b/api/internal/service/project.go index bfbfe50..a6e0151 100644 --- a/api/internal/service/project.go +++ b/api/internal/service/project.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "strings" + "unicode/utf8" "github.com/Devlaner/devlane/api/internal/model" "github.com/Devlaner/devlane/api/internal/store" @@ -13,8 +14,9 @@ import ( ) var ( - ErrProjectNotFound = errors.New("project not found") - ErrProjectForbidden = errors.New("no access to this project") + ErrProjectNotFound = errors.New("project not found") + ErrProjectForbidden = errors.New("no access to this project") + ErrProjectIdentifierTooLong = errors.New("project identifier must be at most 7 characters") ) // ProjectService handles project business logic. @@ -66,6 +68,9 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi if !ok { return nil, ErrProjectForbidden } + if identifier != "" && utf8.RuneCountInString(identifier) > 7 { + return nil, ErrProjectIdentifierTooLong + } p := &model.Project{ WorkspaceID: wrk.ID, Name: name, @@ -87,6 +92,9 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje p.Name = *name } if identifier != nil { + if *identifier != "" && utf8.RuneCountInString(*identifier) > 7 { + return nil, ErrProjectIdentifierTooLong + } p.Identifier = *identifier } if description != nil { diff --git a/api/internal/store/issue.go b/api/internal/store/issue.go index 9e3b528..38cb225 100644 --- a/api/internal/store/issue.go +++ b/api/internal/store/issue.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/binary" "github.com/Devlaner/devlane/api/internal/model" "github.com/google/uuid" @@ -17,6 +18,29 @@ func (s *IssueStore) Create(ctx context.Context, i *model.Issue) error { return s.db.WithContext(ctx).Create(i).Error } +// Transaction runs fn inside a DB transaction (same connection). +func (s *IssueStore) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { + return s.db.WithContext(ctx).Transaction(fn) +} + +// NextSequenceID returns the next per-project issue number (1-based), serialized with an advisory lock. +func (s *IssueStore) NextSequenceID(ctx context.Context, tx *gorm.DB, projectID uuid.UUID) (int, error) { + k1 := int32(binary.BigEndian.Uint32(projectID[0:4])) + k2 := int32(binary.BigEndian.Uint32(projectID[4:8])) + if err := tx.Exec("SELECT pg_advisory_xact_lock(?, ?)", k1, k2).Error; err != nil { + return 0, err + } + var max int + err := tx.WithContext(ctx).Raw( + `SELECT COALESCE(MAX(sequence_id), 0) FROM issues WHERE project_id = ? AND deleted_at IS NULL`, + projectID, + ).Scan(&max).Error + if err != nil { + return 0, err + } + return max + 1, nil +} + func (s *IssueStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Issue, error) { var i model.Issue err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&i).Error diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 94a8f2c..12a2cd6 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -1,8 +1,12 @@ +const q = (f) => (/\s/.test(f) ? `"${f}"` : f); + export default { 'ui/**/*.{ts,tsx}': (files) => { - const rel = files.map((f) => f.replace(/^ui[/\\]/, '')); - if (rel.length === 0) return []; - return [`npm --prefix ui exec -- eslint --max-warnings=0 --fix ${rel.join(' ')}`]; + if (files.length === 0) return []; + const args = files.map(q).join(' '); + return [ + `npm --prefix ui exec -- eslint --max-warnings=0 --fix --config ui/eslint.config.js ${args}`, + ]; }, 'ui/**/*.{css,json,md}': (files) => (files.length ? [`npx prettier --write ${files.join(' ')}`] : []), 'api/**/*.go': (files) => (files.length ? [`gofmt -w ${files.join(' ')}`] : []), diff --git a/package.json b/package.json index 3838208..285a7a1 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "devlane", "version": "1.0.0", + "private": true, "description": "![Devlane](./ui/public/devlane-1-dark.png)", "main": "index.js", "directories": { "doc": "docs" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "npm run validate", "prepare": "husky", "validate": "npm --prefix ui run typecheck && npm --prefix ui run lint && npm --prefix ui run format:check && cd api && go vet ./... && go test ./..." }, diff --git a/ui/package-lock.json b/ui/package-lock.json index bd040be..9d96200 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "Devlane UI", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Devlane UI", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@headlessui/react": "^2.2.9", "@tailwindcss/vite": "^4.1.18", diff --git a/ui/package.json b/ui/package.json index 0765c85..a5d8970 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "Devlane UI", "private": true, - "version": "0.5.0", + "version": "0.5.1", "type": "module", "scripts": { "dev": "vite", diff --git a/ui/src/components/CreateProjectModal.tsx b/ui/src/components/CreateProjectModal.tsx index 4d56cad..6f7876b 100644 --- a/ui/src/components/CreateProjectModal.tsx +++ b/ui/src/components/CreateProjectModal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Button, Input } from './ui'; +import { Button, Input, Tooltip } from './ui'; import { CoverImageModal } from './CoverImageModal'; import { ProjectIconDisplay, @@ -29,7 +29,8 @@ const COVER_GRADIENTS = [ 'linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)', ]; -const IconInfo = () => ( +/** Exclamation-in-circle — same tone as placeholder (tooltip explains project key). */ +const IconIdentifierHint = () => ( ( aria-hidden > - - + + ); @@ -232,18 +233,32 @@ export function CreateProjectModal({ - setIdentifier(e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '')) + setIdentifier( + e.target.value + .toUpperCase() + .replace(/[^A-Z0-9-]/g, '') + .slice(0, 7), + ) } - placeholder="e.g. PROJ" + placeholder="Project ID" + maxLength={7} disabled={submitting} className="w-full pr-9" /> - - - +
+ + + +
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 66fe0cc..a6ac6c9 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Button } from '../ui'; +import { Button, Tooltip } from '../ui'; import { Dropdown } from '../work-item'; import { useModulesFilter } from '../../contexts/ModulesFilterContext'; import { useWorkspaceViewsState } from '../../contexts/WorkspaceViewsStateContext'; @@ -1779,45 +1779,48 @@ function ProjectSectionHeader({ )}
- - - + + + + + + + + +