From da43c9d4cfa92e9a471c2c8cd55f2268a7424fba Mon Sep 17 00:00:00 2001 From: martian56 Date: Thu, 26 Mar 2026 00:22:03 +0400 Subject: [PATCH] fix: make work item IDs incremental --- api/internal/handler/project.go | 8 + api/internal/service/issue.go | 9 +- api/internal/service/project.go | 12 +- api/internal/store/issue.go | 24 ++ package.json | 3 +- ui/package-lock.json | 4 +- ui/package.json | 2 +- ui/src/components/CreateProjectModal.tsx | 39 +++- ui/src/components/layout/PageHeader.tsx | 281 ++++++++++++----------- ui/src/components/ui/Tooltip.tsx | 146 ++++++++++++ ui/src/components/ui/index.ts | 1 + 11 files changed, 376 insertions(+), 153 deletions(-) create mode 100644 ui/src/components/ui/Tooltip.tsx 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/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 56391ef..31f6f8f 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'; @@ -958,20 +958,20 @@ function ModuleDetailHeader({
{viewButtons.map((b, i) => ( - + + + ))}
- - - - - - + + + + + + + + + + + + + + + + +
- - - + + + + + + + + +
- - - - - - - + + + + + + + + + + + + + + + + +
(null); + const triggerRef = useRef(null); + const id = useId(); + const delayRef = useRef | null>(null); + + const updatePosition = useCallback(() => { + const t = triggerRef.current; + if (!t) return; + setStyle(computeStyle(t, placement)); + }, [placement]); + + const clearDelay = useCallback(() => { + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + }, []); + + const openNow = useCallback(() => { + updatePosition(); + setOpen(true); + }, [updatePosition]); + + const show = useCallback(() => { + clearDelay(); + if (delayMs > 0) { + delayRef.current = setTimeout(openNow, delayMs); + } else { + openNow(); + } + }, [clearDelay, delayMs, openNow]); + + const hide = useCallback(() => { + clearDelay(); + setOpen(false); + setStyle(null); + }, [clearDelay]); + + const hideIfLeavingTrigger = useCallback( + (e: FocusEvent) => { + const next = e.relatedTarget as Node | null; + if (next && e.currentTarget.contains(next)) return; + hide(); + }, + [hide], + ); + + useEffect(() => () => clearDelay(), [clearDelay]); + + useEffect(() => { + if (!open) return; + updatePosition(); + const onScroll = () => updatePosition(); + window.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', onScroll); + return () => { + window.removeEventListener('scroll', onScroll, true); + window.removeEventListener('resize', onScroll); + }; + }, [open, updatePosition]); + + return ( + <> + + {children} + + {open && + style && + createPortal( + , + document.body, + )} + + ); +} diff --git a/ui/src/components/ui/index.ts b/ui/src/components/ui/index.ts index 888bffc..4605f5e 100644 --- a/ui/src/components/ui/index.ts +++ b/ui/src/components/ui/index.ts @@ -6,3 +6,4 @@ export { Input } from './Input'; export { Modal } from './Modal'; export { IconEye, IconEyeOff } from './PasswordRevealIcons'; export { Skeleton } from './Skeleton'; +export { Tooltip } from './Tooltip';