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": "",
"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 = () => (
);
@@ -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"
/>
-
-