Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/internal/handler/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 8 additions & 1 deletion api/internal/service/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +129 to +136
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new transaction only covers sequence assignment + the issues insert; assignee/label mutations happen after the transaction and their errors are ignored, so a partially-created issue can be returned without its requested relationships and the sequence number can be consumed even though the overall create request effectively failed. To match the stated atomicity goal, perform relationship writes inside the same transaction and propagate any errors (returning a failure so the whole create rolls back).

Copilot uses AI. Check for mistakes.
return nil, err
}
if len(assigneeIDs) > 0 {
Expand Down
12 changes: 10 additions & 2 deletions api/internal/service/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import (
"encoding/hex"
"errors"
"strings"
"unicode/utf8"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/google/uuid"
)

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.
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions api/internal/store/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package store

import (
"context"
"encoding/binary"

"github.com/Devlaner/devlane/api/internal/model"
"github.com/google/uuid"
Expand All @@ -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`,
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NextSequenceID filters on deleted_at IS NULL when computing MAX(sequence_id), which will reuse sequence IDs after a soft-deleted issue is removed (e.g., deleting the highest-numbered issue allows that number to be reassigned). For work-item IDs, sequence numbers generally must be monotonic and never reused; include soft-deleted rows in the max calculation (or use an unscoped query) so new issues always get a brand-new sequence ID.

Suggested change
`SELECT COALESCE(MAX(sequence_id), 0) FROM issues WHERE project_id = ? AND deleted_at IS NULL`,
`SELECT COALESCE(MAX(sequence_id), 0) FROM issues WHERE project_id = ?`,

Copilot uses AI. Check for mistakes.
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
Expand Down
10 changes: 7 additions & 3 deletions lint-staged.config.mjs
Original file line number Diff line number Diff line change
@@ -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(' ')}`] : []),
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 ./..."
},
Expand Down
4 changes: 2 additions & 2 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Devlane UI",
"private": true,
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
39 changes: 27 additions & 12 deletions ui/src/components/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 = () => (
<svg
width="14"
height="14"
Expand All @@ -42,8 +43,8 @@ const IconInfo = () => (
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
<path d="M12 8v5" />
<path d="M12 17h.01" />
</svg>
);

Expand Down Expand Up @@ -232,18 +233,32 @@ export function CreateProjectModal({
<Input
value={identifier}
onChange={(e) =>
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"
/>
<span
className="absolute right-3 top-9 text-(--txt-icon-tertiary)"
title="Short identifier used in issue IDs (e.g. PROJ-123)"
>
<IconInfo />
</span>
<div className="absolute right-3 top-1/2 z-[1] flex size-8 -translate-y-1/2 items-center justify-center text-(--txt-placeholder)">
<Tooltip
content="Helps you identify work items in the project uniquely. Max 7 characters."
placement="top"
>
<button
type="button"
className="flex size-8 items-center justify-center rounded-md text-(--txt-placeholder) hover:bg-(--bg-layer-transparent-hover) hover:text-(--txt-secondary) focus:outline-none focus-visible:ring-2 focus-visible:ring-(--border-strong)"
aria-label="About project ID"
>
<IconIdentifierHint />
</button>
</Tooltip>
</div>
</div>
</div>
<div className="mt-4">
Expand Down
83 changes: 43 additions & 40 deletions ui/src/components/layout/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -1779,45 +1779,48 @@ function ProjectSectionHeader({
)}
</div>
<div className="flex h-8 overflow-hidden rounded-lg border border-(--border-subtle) bg-(--bg-layer-2) p-0.5">
<button
type="button"
onClick={() => modulesFilter.setLayout('list')}
className={`flex size-7 items-center justify-center rounded-l-md text-(--txt-icon-secondary) transition-colors ${
listActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={listActive}
title="List layout"
>
<IconList />
</button>
<button
type="button"
onClick={() => modulesFilter.setLayout('gallery')}
className={`flex size-7 items-center justify-center text-(--txt-icon-secondary) transition-colors ${
galleryActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={galleryActive}
title="Gallery layout"
>
<IconLayoutGrid />
</button>
<button
type="button"
onClick={() => modulesFilter.setLayout('timeline')}
className={`flex size-7 items-center justify-center rounded-r-md text-(--txt-icon-secondary) transition-colors ${
timelineActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={timelineActive}
title="Timeline layout"
>
<IconStack />
</button>
<Tooltip content="List layout">
<button
type="button"
onClick={() => modulesFilter.setLayout('list')}
className={`flex size-7 items-center justify-center rounded-l-md text-(--txt-icon-secondary) transition-colors ${
listActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={listActive}
>
<IconList />
</button>
</Tooltip>
<Tooltip content="Gallery layout">
<button
type="button"
onClick={() => modulesFilter.setLayout('gallery')}
className={`flex size-7 items-center justify-center text-(--txt-icon-secondary) transition-colors ${
galleryActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={galleryActive}
>
<IconLayoutGrid />
</button>
</Tooltip>
<Tooltip content="Timeline layout">
<button
type="button"
onClick={() => modulesFilter.setLayout('timeline')}
className={`flex size-7 items-center justify-center rounded-r-md text-(--txt-icon-secondary) transition-colors ${
timelineActive
? 'bg-white shadow-sm text-(--txt-primary)'
: 'bg-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-2-hover)'
}`}
aria-pressed={timelineActive}
>
<IconStack />
</button>
</Tooltip>
</div>
<Button
size="sm"
Expand Down
Loading
Loading