Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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: 7 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ Devlane is a monorepo with two deployable apps under `apps/`:
1. **Infra** — `docker compose up -d` (Postgres is published on host port **15432**, not 5432).
2. **API** — from `apps/api`, copy `.env.example` to `.env`, set `DB_PORT=15432` plus your Postgres/Redis (and optional RabbitMQ/MinIO) settings, then `go run ./cmd/api`. Migrations run automatically on startup.
3. **Web** — from `apps/web`, run `npm install` then `npm run dev` (defaults to port 5173).
4. **First run** — complete instance setup in the browser (create the admin account, then a workspace and project).
4. **First run** — either complete instance setup in the browser (create the admin account, then a workspace and project), **or** seed a ready-to-explore instance from `apps/api`:

```sh
go run ./cmd/api seed
```

This creates a demo user (`demo@devlane.test` / `Demo1234!`), a **Demo Workspace**, a **Getting Started** project with the standard workflow states, and a handful of sample work items. It's idempotent — re-running it is a no-op once the demo user exists. Sign in with those credentials at the web app. (Local-only demo credentials; never use them in a real deployment.)

Before pushing, run the full check from the repo root:

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Instance administrators can manage workspaces and instance settings from the ins

1. **API** — From the `apps/api` directory, copy `.env.example` to `.env`, set your PostgreSQL and Redis (and optional RabbitMQ/MinIO) settings, run migrations, then start the server (see `apps/api/README.md`).
2. **UI** — From the `apps/web` directory, run `npm install` and `npm run dev`. Point the UI at your local API using the configured base URL.
3. **First run** — Complete instance setup in the browser (create admin account, then create a workspace and project).
3. **First run** — Complete instance setup in the browser (create admin account, then create a workspace and project), or run `go run ./cmd/api seed` from `apps/api` to populate a demo user, workspace, project, and sample work items in one step (see [CONTRIBUTING](CONTRIBUTING.md)).

For contribution workflow and code style, see [CONTRIBUTING](CONTRIBUTING.md) if present.

Expand Down
7 changes: 7 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,18 @@ commonly changed ones for local dev:

```sh
go run ./cmd/api # start the API server (auto-runs migrations)
go run ./cmd/api seed # seed a demo workspace/project/issues for local dev
go run ./cmd/api admin grant <email> # grant instance-admin to an existing user
go vet ./... # static analysis
go test ./... # run all tests
go test ./internal/auth -run TestMagicCode # run a single package/test
```

The `seed` command creates a demo user (`demo@devlane.test` / `Demo1234!`), a
workspace, a project with default workflow states, and sample work items so a
fresh database has something to explore. It's idempotent (a no-op once the demo
user exists). Local-only demo credentials — never use them in a real deployment.

## Migrations

Add paired files under `migrations/`: `NNNNNN_<name>.up.sql` and
Expand Down
4 changes: 4 additions & 0 deletions apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func main() {
if len(os.Args) > 1 && os.Args[1] == "admin" {
os.Exit(runAdmin(os.Args[2:]))
}
// Local development: `api seed` populates a demo workspace/project/issues.
if len(os.Args) > 1 && os.Args[1] == "seed" {
os.Exit(runSeed(os.Args[2:]))
}

log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
Expand Down
170 changes: 170 additions & 0 deletions apps/api/cmd/api/seed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package main

import (
"context"
"fmt"
"log/slog"
"os"

"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/config"
"github.com/Devlaner/devlane/api/internal/database"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/google/uuid"
"gorm.io/gorm"
)

// Demo credentials for the local-development seed. Not secret; documented in the
// local dev guide. Never use in a real deployment.
const (
seedEmail = "demo@devlane.test"
seedPassword = "Demo1234!"
seedFirstName = "Demo"
seedLastName = "User"
seedWorkspaceName = "Demo Workspace"
seedWorkspaceSlug = "demo"
seedProjectName = "Getting Started"
seedProjectIdent = "DEMO"
)

// runSeed handles `api seed`: it populates a local database with a demo user,
// workspace, project, workflow states, and sample work items so a fresh clone
// has something to explore. Idempotent — a second run is a no-op.
func runSeed(args []string) int {
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
if len(args) != 0 {
fmt.Fprintln(os.Stderr, "usage: api seed")
return 2
}
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
return 1
}
db, err := database.NewDB(cfg, log)
if err != nil {
fmt.Fprintf(os.Stderr, "database: %v\n", err)
return 1
}
if sqlDB, err := db.DB(); err == nil {
defer sqlDB.Close()
}
if err := seedDevData(context.Background(), db); err != nil {
fmt.Fprintf(os.Stderr, "seed failed: %v\n", err)
return 1
}
return 0
}

func seedDevData(ctx context.Context, db *gorm.DB) error {
userStore := store.NewUserStore(db)

// Idempotency: if the demo user already exists, assume the DB is seeded.
if u, _ := userStore.GetByEmail(ctx, seedEmail); u != nil {
fmt.Printf("seed: %s already exists — nothing to do\n", seedEmail)
return nil
}

authSvc := auth.NewService(userStore, store.NewSessionStore(db), store.NewPasswordResetTokenStore(db))
_, user, err := authSvc.SignUp(ctx, auth.SignUpRequest{
Email: seedEmail,
Password: seedPassword,
FirstName: seedFirstName,
LastName: seedLastName,
})
if err != nil {
return fmt.Errorf("create demo user: %w", err)
}

// Make the demo user an instance admin and mark the instance as set up, so
// the app is immediately usable without the first-run setup wizard.
admins := store.NewInstanceAdminStore(db)
if n, _ := admins.CountActive(ctx); n == 0 {
_ = admins.Create(ctx, &model.InstanceAdmin{UserID: user.ID, Role: model.RoleOwner, IsVerified: true})
}
settings := store.NewInstanceSettingStore(db)
if row, _ := settings.Get(ctx, "general"); row == nil {
_ = settings.Upsert(ctx, "general", model.JSONMap{
"instance_id": "devlocalseed00000000000",
"admin_email": seedEmail,
"instance_name": "Devlane (local)",
"only_admin_can_create_workspace": false,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

wsSvc := service.NewWorkspaceService(store.NewWorkspaceStore(db), store.NewWorkspaceInviteStore(db), userStore)
wrk, err := wsSvc.Create(ctx, seedWorkspaceName, seedWorkspaceSlug, "", user.ID)
if err != nil {
return fmt.Errorf("create workspace: %w", err)
}

projSvc := service.NewProjectService(store.NewProjectStore(db), store.NewProjectInviteStore(db), store.NewWorkspaceStore(db), userStore)
proj, err := projSvc.Create(ctx, wrk.Slug, seedProjectName, seedProjectIdent, user.ID)
if err != nil {
return fmt.Errorf("create project: %w", err)
}

// Seed a standard set of workflow states, one marked default.
stateStore := store.NewStateStore(db)
seedStates := []struct {
name, group, color string
def bool
seq float64
}{
{"Backlog", "backlog", "#94a3b8", false, 1000},
{"Todo", "unstarted", "#6366f1", true, 2000},
{"In Progress", "started", "#f59e0b", false, 3000},
{"Done", "completed", "#22c55e", false, 4000},
{"Cancelled", "cancelled", "#ef4444", false, 5000},
}
for _, st := range seedStates {
m := &model.State{
Name: st.name, Group: st.group, Color: st.color, Default: st.def,
Sequence: st.seq, ProjectID: proj.ID, WorkspaceID: wrk.ID,
}
if err := stateStore.RestoreOrCreateByNameAndProject(ctx, m); err != nil {
return fmt.Errorf("create state %q: %w", st.name, err)
}
}
stateByName := map[string]uuid.UUID{}
if all, err := stateStore.ListByProjectID(ctx, proj.ID); err == nil {
for i := range all {
stateByName[all[i].Name] = all[i].ID
}
}

issueSvc := service.NewIssueService(store.NewIssueStore(db), store.NewProjectStore(db), store.NewWorkspaceStore(db))
issueSvc.SetActivityStore(store.NewIssueActivityStore(db))
issueSvc.SetStateStore(stateStore)
issueSvc.SetLabelStore(store.NewLabelStore(db))

seedIssues := []struct {
name, desc, priority, state string
}{
{"Welcome to Devlane 👋", "This is a sample work item. Open it to see the detail view, then try editing the state, priority, and assignees.", "high", "Todo"},
{"Set up your first project", "Projects group work items. Create your own from the sidebar when you're ready.", "medium", "In Progress"},
{"Explore the board and list layouts", "Switch layouts from the work-item view to see the same issues grouped differently.", "low", "Backlog"},
{"Try importing issues from CSV", "The project work-item list has an Import CSV action for bulk-adding items.", "none", "Backlog"},
{"Mark something Done", "Move a work item to the Done state to see it settle.", "medium", "Done"},
}
created := 0
for _, di := range seedIssues {
var stateID *uuid.UUID
if id, ok := stateByName[di.state]; ok {
id := id
stateID = &id
}
if _, err := issueSvc.Create(ctx, wrk.Slug, proj.ID, user.ID,
di.name, di.desc, di.priority, stateID, nil, nil, nil, nil, nil, false); err != nil {
return fmt.Errorf("create issue %q: %w", di.name, err)
}
created++
}

fmt.Printf("seed: created user %s (password %s), workspace %q, project %q with %d work items\n",
seedEmail, seedPassword, seedWorkspaceSlug, seedProjectIdent, created)
fmt.Printf("seed: sign in at the web app with %s / %s\n", seedEmail, seedPassword)
return nil
}
55 changes: 55 additions & 0 deletions apps/api/cmd/api/seed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"context"
"testing"

"github.com/Devlaner/devlane/api/internal/store"
"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// The local-dev seed creates a demo user, workspace, project, states, and
// issues, and is idempotent on a second run. Covers #24.
func TestSeedDevData_CreatesDemoAndIsIdempotent(t *testing.T) {
ts := testutil.NewTestServer(t)
ctx := context.Background()

require.NoError(t, seedDevData(ctx, ts.DB))

users := store.NewUserStore(ts.DB)
u, err := users.GetByEmail(ctx, seedEmail)
require.NoError(t, err)
require.NotNil(t, u, "demo user should exist")

ws := store.NewWorkspaceStore(ts.DB)
wrk, err := ws.GetBySlug(ctx, seedWorkspaceSlug)
require.NoError(t, err)
require.NotNil(t, wrk)

projects, err := store.NewProjectStore(ts.DB).ListByWorkspaceID(ctx, wrk.ID)
require.NoError(t, err)
require.Len(t, projects, 1)

states, err := store.NewStateStore(ts.DB).ListByProjectID(ctx, projects[0].ID)
require.NoError(t, err)
require.Len(t, states, 5)
defaults := 0
for _, s := range states {
if s.Default {
defaults++
}
}
assert.Equal(t, 1, defaults, "exactly one default state")

issues, err := store.NewIssueStore(ts.DB).ListByProjectID(ctx, projects[0].ID, 100, 0)
require.NoError(t, err)
assert.Len(t, issues, 5)

// Second run is a no-op: no error and no duplicate workspace/issues.
require.NoError(t, seedDevData(ctx, ts.DB))
issues2, err := store.NewIssueStore(ts.DB).ListByProjectID(ctx, projects[0].ID, 100, 0)
require.NoError(t, err)
assert.Len(t, issues2, 5, "second seed should not add issues")
}
Loading