From e98b35e0f00948f3008bc366b14593e7c32e9643 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 4 May 2026 14:49:04 +0700 Subject: [PATCH 1/2] [tasks] add `kind` field --- .../20260429000000_add_task_kind.sql | 11 +++++ internal/server/docs/docs.go | 25 +++++++++++ internal/server/tasks/dto.go | 14 ++++++ internal/tasks/domain.go | 45 +++++++++++++++++++ internal/tasks/models.go | 3 ++ internal/tasks/repository.go | 3 ++ 6 files changed, 101 insertions(+) create mode 100644 internal/db/migrations/20260429000000_add_task_kind.sql diff --git a/internal/db/migrations/20260429000000_add_task_kind.sql b/internal/db/migrations/20260429000000_add_task_kind.sql new file mode 100644 index 0000000..e4df90a --- /dev/null +++ b/internal/db/migrations/20260429000000_add_task_kind.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE `tasks` +ADD COLUMN `kind` ENUM('Bug', 'Enhancement', 'Task', 'Proposal') NOT NULL DEFAULT 'Task' +AFTER `status`; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +ALTER TABLE `tasks` DROP COLUMN `kind`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 6b681b3..5f9110d 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -1545,6 +1545,15 @@ const docTemplate = `{ "due_date": { "type": "string" }, + "kind": { + "type": "string", + "enum": [ + "Bug", + "Enhancement", + "Task", + "Proposal" + ] + }, "priority": { "type": "string", "enum": [ @@ -1598,6 +1607,9 @@ const docTemplate = `{ "id": { "type": "integer" }, + "kind": { + "type": "string" + }, "number": { "type": "integer" }, @@ -1655,6 +1667,9 @@ const docTemplate = `{ "id": { "type": "integer" }, + "kind": { + "type": "string" + }, "number": { "type": "integer" }, @@ -1690,6 +1705,16 @@ const docTemplate = `{ "due_date": { "type": "string" }, + "kind": { + "type": "string", + "default": "Task", + "enum": [ + "Bug", + "Enhancement", + "Task", + "Proposal" + ] + }, "priority": { "type": "string", "default": "Minor", diff --git a/internal/server/tasks/dto.go b/internal/server/tasks/dto.go index 9e4c85f..77eeaa3 100644 --- a/internal/server/tasks/dto.go +++ b/internal/server/tasks/dto.go @@ -69,6 +69,7 @@ type TaskCreateRequest struct { Title string `json:"title" validate:"required,max=255"` Description string `json:"description,omitempty" validate:"max=10000"` Priority string `json:"priority,omitempty" validate:"omitempty,oneof=Trivial Minor Major Critical Blocker"` + Kind string `json:"kind,omitempty" validate:"omitempty,oneof=Bug Enhancement Task Proposal"` AssigneeID *int64 `json:"assignee_id,omitempty" validate:"omitempty,min=1"` DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` } @@ -82,6 +83,7 @@ type TaskUpdateRequest struct { Description *string `json:"description,omitempty" validate:"omitempty,max=10000"` Priority *string `json:"priority,omitempty" validate:"omitempty,oneof=Trivial Minor Major Critical Blocker" default:"Minor"` Status *string `json:"status,omitempty" validate:"omitempty,oneof=New Open 'In Progress' Resolved Closed Reopened"` + Kind *string `json:"kind,omitempty" validate:"omitempty,oneof=Bug Enhancement Task Proposal" default:"Task"` AssigneeID *int64 `json:"assignee_id,omitempty" validate:"omitempty,min=0"` DueDate *string `json:"due_date,omitempty" validate:"omitzero,datetime=2006-01-02"` } @@ -97,6 +99,7 @@ type TaskResponse struct { Description string `json:"description,omitempty"` Priority string `json:"priority"` Status string `json:"status"` + Kind string `json:"kind,omitempty"` Author dto.UserBrief `json:"author"` Assignee *dto.UserBrief `json:"assignee,omitempty"` DueDate *string `json:"due_date,omitempty"` @@ -116,6 +119,7 @@ func newTaskResponse(task *tasks.Task, usersMap map[int64]users.User) TaskRespon Description: task.Description, Priority: string(task.Priority), Status: string(task.Status), + Kind: string(task.Kind), Author: *dto.ResolveUserBrief(&task.AuthorID, usersMap), Assignee: dto.ResolveUserBrief(task.AssigneeID, usersMap), DueDate: task.DueDate, @@ -183,11 +187,18 @@ func (req TaskUpdateRequest) toTaskUpdate() tasks.TaskUpdate { status = &s } + var kind *tasks.Kind + if req.Kind != nil { + k := tasks.Kind(*req.Kind) + kind = &k + } + return tasks.TaskUpdate{ Title: req.Title, Description: req.Description, Priority: priority, Status: status, + Kind: kind, AssigneeID: req.AssigneeID, DueDate: req.DueDate, } @@ -196,12 +207,15 @@ func (req TaskUpdateRequest) toTaskUpdate() tasks.TaskUpdate { // toTaskCreateInput converts a TaskCreateRequest DTO to a domain TaskInput. func (req TaskCreateRequest) toTaskInput(authorID int64) tasks.TaskInput { priority := tasks.Priority(req.Priority) + kind := tasks.Kind(req.Kind) return tasks.TaskInput{ ProjectSlug: req.ProjectSlug, Title: req.Title, Description: req.Description, Priority: lo.CoalesceOrEmpty(priority, tasks.PriorityMinor), + Status: tasks.StatusNew, // API always creates as "New" + Kind: lo.CoalesceOrEmpty(kind, tasks.KindTask), AuthorID: authorID, AssigneeID: req.AssigneeID, DueDate: req.DueDate, diff --git a/internal/tasks/domain.go b/internal/tasks/domain.go index aa77dc7..3c659ba 100644 --- a/internal/tasks/domain.go +++ b/internal/tasks/domain.go @@ -33,6 +33,32 @@ func (p Priority) IsValid() bool { } } +// Kind represents the type of task, matching BitBucket values. +type Kind string + +// Kind constants. +const ( + KindBug Kind = "Bug" + KindEnhancement Kind = "Enhancement" + KindTask Kind = "Task" + KindProposal Kind = "Proposal" +) + +// IsValid checks if the kind value is one of the allowed constants. +func (k Kind) IsValid() bool { + switch k { + case KindBug, KindEnhancement, KindTask, KindProposal: + return true + default: + return false + } +} + +// String returns the string representation of Kind. +func (k Kind) String() string { + return string(k) +} + // Status represents task lifecycle states, matching BitBucket values. type Status string @@ -65,6 +91,7 @@ type Task struct { Description string Priority Priority Status Status + Kind Kind AuthorID int64 AssigneeID *int64 DueDate *string @@ -79,6 +106,8 @@ type TaskInput struct { Title string Description string Priority Priority + Status Status + Kind Kind AuthorID int64 AssigneeID *int64 DueDate *string // YYYY-MM-DD format @@ -107,6 +136,16 @@ func (i TaskInput) Validate() error { return fmt.Errorf("%w: invalid priority value", ErrValidationFailed) } + // Validate status + if !i.Status.IsValid() { + return fmt.Errorf("%w: invalid status value", ErrValidationFailed) + } + + // Validate kind + if !i.Kind.IsValid() { + return fmt.Errorf("%w: invalid kind value", ErrValidationFailed) + } + // AuthorID must be positive if i.AuthorID <= 0 { return fmt.Errorf("%w: author_id must be positive", ErrValidationFailed) @@ -134,6 +173,7 @@ type TaskUpdate struct { Description *string Priority *Priority Status *Status + Kind *Kind AssigneeID *int64 // nil=unchanged, 0=set to NULL, value=set to ID DueDate *string // nil=unchanged, ""=set to NULL, value=set date string } @@ -144,6 +184,7 @@ func (u TaskUpdate) IsEmpty() bool { u.Description == nil && u.Priority == nil && u.Status == nil && + u.Kind == nil && u.AssigneeID == nil && u.DueDate == nil } @@ -169,6 +210,10 @@ func (u TaskUpdate) Validate() error { return fmt.Errorf("%w: invalid status value", ErrValidationFailed) } + if u.Kind != nil && !u.Kind.IsValid() { + return fmt.Errorf("%w: invalid kind value", ErrValidationFailed) + } + if u.AssigneeID != nil && *u.AssigneeID < 0 { return fmt.Errorf("%w: assignee_id must be non-negative", ErrValidationFailed) } diff --git a/internal/tasks/models.go b/internal/tasks/models.go index 2a809d5..e54804b 100644 --- a/internal/tasks/models.go +++ b/internal/tasks/models.go @@ -21,6 +21,7 @@ type taskModel struct { Description string `bun:"description,type:text,nullzero"` Priority Priority `bun:"priority,notnull,default:'Minor'"` Status Status `bun:"status,notnull,default:'New'"` + Kind Kind `bun:"kind,nullzero"` AuthorID int64 `bun:"author_id,notnull"` AssigneeID *int64 `bun:"assignee_id,nullzero"` DueDate *time.Time `bun:"due_date,nullzero"` @@ -40,6 +41,7 @@ func newTaskModel(input TaskInput, number int) *taskModel { Description: input.Description, Priority: input.Priority, Status: StatusNew, // Always start as "New" + Kind: input.Kind, AuthorID: input.AuthorID, AssigneeID: input.AssigneeID, // Convert *string to *time.Time @@ -72,6 +74,7 @@ func (m *taskModel) toDomain() *Task { Description: m.Description, Priority: m.Priority, Status: m.Status, + Kind: m.Kind, AuthorID: m.AuthorID, AssigneeID: m.AssigneeID, // Convert *time.Time to *string (YYYY-MM-DD format) diff --git a/internal/tasks/repository.go b/internal/tasks/repository.go index 5f89172..355ea2e 100644 --- a/internal/tasks/repository.go +++ b/internal/tasks/repository.go @@ -168,6 +168,9 @@ func (r *Repository) Update(ctx context.Context, id int64, update TaskUpdate) er if update.Status != nil { query = query.Set("status = ?", *update.Status) } + if update.Kind != nil { + query = query.Set("kind = ?", *update.Kind) + } if update.AssigneeID != nil { if *update.AssigneeID == 0 { query = query.Set("assignee_id = NULL") From 26a9debaa8b15395361ac6f35fd7e94c1fe675ad Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Mon, 4 May 2026 14:49:28 +0700 Subject: [PATCH 2/2] [commands] add import command --- go.mod | 4 +- go.sum | 8 +- internal/app.go | 15 +- internal/commands/commands.go | 15 ++ internal/commands/importer/config.go | 45 ++++ internal/commands/importer/import.go | 97 ++++++++ internal/commands/importer/importer.go | 300 +++++++++++++++++++++++++ internal/commands/{ => serve}/serve.go | 19 +- internal/comments/models.go | 13 ++ internal/comments/repository.go | 14 ++ internal/comments/service.go | 9 + internal/tasks/models.go | 29 +++ internal/tasks/repository.go | 23 ++ internal/tasks/service.go | 10 + pkg/bitbucket/issues.go | 47 ++++ 15 files changed, 626 insertions(+), 22 deletions(-) create mode 100644 internal/commands/commands.go create mode 100644 internal/commands/importer/config.go create mode 100644 internal/commands/importer/import.go create mode 100644 internal/commands/importer/importer.go rename internal/commands/{ => serve}/serve.go (83%) create mode 100644 pkg/bitbucket/issues.go diff --git a/go.mod b/go.mod index 68be556..0a65dff 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/go-core-fx/bunfx v0.0.2-0.20260409044649-8812f14d88ce github.com/go-core-fx/config v0.1.0 github.com/go-core-fx/fiberfx v0.5.0 + github.com/go-core-fx/fxutil v0.0.1 github.com/go-core-fx/goosefx v0.0.1 github.com/go-core-fx/healthfx v0.0.2-0.20260109013230-f7729a0a06bc github.com/go-core-fx/logger v0.0.1 @@ -25,7 +26,7 @@ require ( github.com/uptrace/bun/dialect/mysqldialect v1.2.18 github.com/urfave/cli/v3 v3.8.0 go.uber.org/fx v1.24.0 - go.uber.org/zap v1.27.1 + go.uber.org/zap v1.28.0 golang.org/x/crypto v0.50.0 ) @@ -43,7 +44,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect - github.com/go-core-fx/fxutil v0.0.0-20251027105421-acea37162eb9 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.19.6 // indirect diff --git a/go.sum b/go.sum index fc5b428..929fc7f 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/go-core-fx/config v0.1.0 h1:uKmo+mTt5a8Gtusb7Xf4gkrGcLIbm2doTEUMkdd6o github.com/go-core-fx/config v0.1.0/go.mod h1:gvoLaHr5fHfG5DlYYtNSPTqRlbnxWMqiWL4iWy2oezY= github.com/go-core-fx/fiberfx v0.5.0 h1:e42gDP7R5k2T93pt1ibVS6nrnPNaGR/+6efHO90a398= github.com/go-core-fx/fiberfx v0.5.0/go.mod h1:9MZcRCgYzqGLOpdnisW4+jQef8O9kcfaUKXAiAJZGTY= -github.com/go-core-fx/fxutil v0.0.0-20251027105421-acea37162eb9 h1:0rpJfg+QM3TAFCTignjzPovNkBHeEjOe+ILo+2Mz9aA= -github.com/go-core-fx/fxutil v0.0.0-20251027105421-acea37162eb9/go.mod h1:nljLWn+Ck1Rrqv9DPdD7Czdd0j5uF+9YRPNs/DpCfnw= +github.com/go-core-fx/fxutil v0.0.1 h1:A5pqeiTleSw4vDxhVh38uXkt8IPhCEEFpvb5kZJfHvo= +github.com/go-core-fx/fxutil v0.0.1/go.mod h1:nljLWn+Ck1Rrqv9DPdD7Czdd0j5uF+9YRPNs/DpCfnw= github.com/go-core-fx/goosefx v0.0.1 h1:R5JsN44HMjsacqJe5VX38Acr+tFsfDUfxSxyBhoK9h4= github.com/go-core-fx/goosefx v0.0.1/go.mod h1:C4Xw9ea44//B3JEpWmZ7MbeMrHOnWWLBGa182STrkXo= github.com/go-core-fx/healthfx v0.0.2-0.20260109013230-f7729a0a06bc h1:jJTE0YeQwyqsr9qhvKZxDmxoR322tBZjvwFjQTauM84= @@ -228,8 +228,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/internal/app.go b/internal/app.go index 2a8d506..c4cd326 100644 --- a/internal/app.go +++ b/internal/app.go @@ -2,7 +2,6 @@ package internal import ( "context" - "fmt" "os" "os/signal" "syscall" @@ -21,19 +20,7 @@ func Run(version healthfx.Version) { Version: version.Version, DefaultCommand: "serve", Flags: []cli.Flag{}, - Commands: []*cli.Command{ - { - Name: "serve", - Usage: "Start the HTTP server", - Description: `Start the HTTP server`, - Action: func(ctx context.Context, _ *cli.Command) error { - if err := commands.Serve(ctx, version); err != nil { - return fmt.Errorf("serve: %w", err) - } - return nil - }, - }, - }, + Commands: commands.Commands(version), } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000..61c8853 --- /dev/null +++ b/internal/commands/commands.go @@ -0,0 +1,15 @@ +package commands + +import ( + "github.com/bit-issues/backend/internal/commands/importer" + "github.com/bit-issues/backend/internal/commands/serve" + "github.com/go-core-fx/healthfx" + "github.com/urfave/cli/v3" +) + +func Commands(version healthfx.Version) []*cli.Command { + return []*cli.Command{ + serve.Command(version), + importer.Command(version), + } +} diff --git a/internal/commands/importer/config.go b/internal/commands/importer/config.go new file mode 100644 index 0000000..d45971c --- /dev/null +++ b/internal/commands/importer/config.go @@ -0,0 +1,45 @@ +package importer + +import "github.com/urfave/cli/v3" + +type Config struct { + Filename string + + ProjectSlug string + DefaultUser string + DryRun bool +} + +func (c *Config) Flags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "project", + Usage: "Target project slug (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "file", + Usage: "Path to the JSON file (required)", + Required: true, + }, + &cli.StringFlag{ + Name: "default-user", + Usage: "Default user ID or slug for unmapped authors (required)", + Required: true, + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "Simulate import without writing to database", + Value: false, + }, + } +} + +func parseConfig(cmd *cli.Command) Config { + return Config{ + Filename: cmd.String("file"), + ProjectSlug: cmd.String("project"), + DefaultUser: cmd.String("default-user"), + DryRun: cmd.Bool("dry-run"), + } +} diff --git a/internal/commands/importer/import.go b/internal/commands/importer/import.go new file mode 100644 index 0000000..58553c3 --- /dev/null +++ b/internal/commands/importer/import.go @@ -0,0 +1,97 @@ +package importer + +import ( + "context" + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/attachments" + "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/config" + "github.com/bit-issues/backend/internal/db" + "github.com/bit-issues/backend/internal/projects" + "github.com/bit-issues/backend/internal/storage" + "github.com/bit-issues/backend/internal/tasks" + "github.com/bit-issues/backend/internal/users" + "github.com/go-core-fx/bunfx" + "github.com/go-core-fx/fxutil" + "github.com/go-core-fx/healthfx" + "github.com/go-core-fx/logger" + "github.com/go-core-fx/sqlfx" + "github.com/urfave/cli/v3" + "go.uber.org/fx" +) + +func Command(_ healthfx.Version) *cli.Command { + return &cli.Command{ + Name: "import", + Usage: "Import issues from a JSON file", + Description: `Import issues from a BitBucket export JSON file into a specified project`, + Flags: (*Config)(nil).Flags(), + Action: run, + } +} + +// ImportResult holds the results of an import operation. +type ImportResult struct { + IssuesImported int + IssuesSkipped int + CommentsImported int + CommentsSkipped int +} + +// run imports issues from a BitBucket export JSON file. +func run(ctx context.Context, cmd *cli.Command) error { + // Run the import within an FX app + app := fx.New( + logger.Module(), + logger.WithFxDefaultLogger(), + bunfx.Module(), + sqlfx.Module(), + + config.Module(), + db.Module(), + storage.Module(), + + users.Module(), + projects.Module(), + tasks.Module(), + attachments.Module(), + comments.Module(), + + fx.Supply(parseConfig(cmd)), + + fx.Provide(newImporter), + fx.Invoke(fxutil.RegisterRunnable[*importer]()), + ) + + startCtx, cancelStart := context.WithTimeout(ctx, app.StartTimeout()) + defer cancelStart() + + if startErr := app.Start(startCtx); startErr != nil { + return fmt.Errorf("failed to start app: %w", startErr) + } + + var runErr error + select { + case <-ctx.Done(): + runErr = ctx.Err() + case sig := <-app.Wait(): + if sig.ExitCode != 0 { + runErr = cli.Exit("app exited with non-zero status code", sig.ExitCode) + } + } + + stopCtx, cancelStop := context.WithTimeout(context.Background(), app.StopTimeout()) + defer cancelStop() + + if stopErr := app.Stop(stopCtx); stopErr != nil { + return fmt.Errorf("failed to stop app: %w", stopErr) + } + + if runErr != nil && !errors.Is(runErr, context.Canceled) { + return fmt.Errorf("import failed: %w", runErr) + } + + return nil +} diff --git a/internal/commands/importer/importer.go b/internal/commands/importer/importer.go new file mode 100644 index 0000000..fd2badf --- /dev/null +++ b/internal/commands/importer/importer.go @@ -0,0 +1,300 @@ +package importer + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/bit-issues/backend/internal/comments" + "github.com/bit-issues/backend/internal/tasks" + "github.com/bit-issues/backend/internal/users" + "github.com/bit-issues/backend/pkg/bitbucket" + "go.uber.org/fx" + "go.uber.org/zap" +) + +type importer struct { + config Config + tasksSvc *tasks.Service + commentsSvc *comments.Service + usersSvc *users.Service + logger *zap.Logger + + sh fx.Shutdowner +} + +func newImporter( + config Config, + tasksSvc *tasks.Service, + commentsSvc *comments.Service, + usersSvc *users.Service, + logger *zap.Logger, + sh fx.Shutdowner, +) *importer { + return &importer{ + config: config, + tasksSvc: tasksSvc, + commentsSvc: commentsSvc, + usersSvc: usersSvc, + logger: logger, + + sh: sh, + } +} + +func (i *importer) Run(ctx context.Context) error { + var result ImportResult + + logger := i.logger.With(zap.String("project", i.config.ProjectSlug), zap.Bool("dryRun", i.config.DryRun)) + + // Parse the JSON file + export, err := i.parseExportFile(i.config.Filename) + if err != nil { + return fmt.Errorf("failed to parse export file: %w", err) + } + + logger.Info("Parsed export file", zap.Int("issues", len(export.Issues)), zap.Int("comments", len(export.Comments))) + + if i.config.DryRun { + logger.Info("DRY RUN MODE - no changes will be made") + } + + // Resolve default user + defaultUser, err := i.resolveUser(ctx, i.usersSvc, i.config.DefaultUser) + if err != nil { + return fmt.Errorf("failed to resolve default user: %w", err) + } + + logger.Info("Starting import") + + // Build a map of issue ID to imported task for comments + issueToTask := make(map[int]int64) // BitBucket issue ID -> internal task ID + + // Import issues + for _, issue := range export.Issues { + if i.config.DryRun { + logger.Info("Would import issue", zap.Int("issueID", issue.ID)) + result.IssuesImported++ + continue + } + + task, importErr := i.importIssue(ctx, i.tasksSvc, i.config.ProjectSlug, defaultUser, &issue) + if importErr != nil { + result.IssuesSkipped++ + logger.Warn("Failed to import issue", zap.Int("issueID", issue.ID), zap.Error(importErr)) + continue + } + + issueToTask[issue.ID] = task.ID + result.IssuesImported++ + logger.Info("Imported issue", zap.Int("issueID", issue.ID), zap.Int64("taskID", task.ID)) + } + + // Import comments + for _, comment := range export.Comments { + if i.config.DryRun { + logger.Info("Would import comment", zap.Int("commentID", comment.ID)) + result.CommentsImported++ + continue + } + + taskID, ok := issueToTask[comment.Issue] + if !ok { + result.CommentsSkipped++ + logger.Warn( + "Could not find task for comment", + zap.Int("commentID", comment.ID), + zap.Int("issueID", comment.Issue), + ) + continue + } + + if importErr := i.importComment(ctx, i.commentsSvc, taskID, defaultUser, &comment); importErr != nil { + result.CommentsSkipped++ + logger.Warn("Failed to import comment", zap.Int("commentID", comment.ID), zap.Error(importErr)) + continue + } + + result.CommentsImported++ + logger.Info("Imported comment", zap.Int("commentID", comment.ID), zap.Int64("taskID", taskID)) + } + + // Print results + logger.Info("Import complete", + zap.Int("issuesImported", result.IssuesImported), + zap.Int("issuesSkipped", result.IssuesSkipped), + zap.Int("commentsImported", result.CommentsImported), + zap.Int("commentsSkipped", result.CommentsSkipped), + ) + + if shErr := i.sh.Shutdown(); shErr != nil { + return fmt.Errorf("failed to shutdown importer: %w", shErr) + } + + return nil +} + +// importIssue imports a single issue. +func (i *importer) importIssue( + ctx context.Context, + tasksSvc *tasks.Service, + projectSlug string, + author *users.User, + issue *bitbucket.Issue, +) (*tasks.Task, error) { + // Normalize priority (BitBucket uses lowercase) + priority := i.normalizePriority(issue.Priority) + + // Normalize status (BitBucket uses lowercase) + status := i.normalizeStatus(issue.Status) + + // Normalize kind + kind := i.normalizeKind(issue.Kind) + + // Create task domain object + task := &tasks.Task{ + ID: 0, + ProjectSlug: projectSlug, + Number: issue.ID, // BitBucket issue ID becomes the task number + Title: issue.Title, + Description: issue.Content, + Priority: priority, + Status: status, + Kind: kind, + AuthorID: author.ID, + AssigneeID: nil, // Always clear assignee per requirements + DueDate: nil, + CreatedAt: issue.CreatedOn, + UpdatedAt: issue.UpdatedOn, + DeletedAt: nil, + } + + // Import task + var err error + if task, err = tasksSvc.Import(ctx, *task); err != nil { + return nil, fmt.Errorf("failed to import task: %w", err) + } + + return task, nil +} + +// importComment imports a single comment. +func (i *importer) importComment( + ctx context.Context, + commentsSvc *comments.Service, + taskID int64, + author *users.User, + comment *bitbucket.Comment, +) error { + updatedOn := time.Time{} + if comment.UpdatedOn != nil { + updatedOn = *comment.UpdatedOn + } + + // Create comment domain object + commentDomain := comments.Comment{ + ID: 0, + TaskID: taskID, + AuthorID: author.ID, + Content: comment.Content, + CreatedAt: comment.CreatedOn, + UpdatedAt: updatedOn, + DeletedAt: nil, + } + + // Import comment + if _, err := commentsSvc.Import(ctx, commentDomain); err != nil { + return fmt.Errorf("failed to import comment: %w", err) + } + + return nil +} + +// parseExportFile reads and parses the BitBucket export JSON file. +func (i *importer) parseExportFile(filePath string) (*bitbucket.Export, error) { + var export bitbucket.Export + + h, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer h.Close() + + if jsonErr := json.NewDecoder(h).Decode(&export); jsonErr != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", jsonErr) + } + + return &export, nil +} + +// resolveUser finds a user by numeric ID. +func (i *importer) resolveUser(ctx context.Context, usersSvc *users.Service, userStr string) (*users.User, error) { + userID, err := strconv.Atoi(userStr) + if err != nil { + return nil, fmt.Errorf("default user must be a numeric user ID: %w", err) + } + + user, err := usersSvc.GetByID(ctx, int64(userID)) + if err != nil { + return nil, fmt.Errorf("failed to get user by ID: %w", err) + } + + return user, nil +} + +// normalizePriority converts BitBucket priority to internal format. +func (i *importer) normalizePriority(priority string) tasks.Priority { + switch strings.ToLower(priority) { + case "trivial": + return tasks.PriorityTrivial + case "minor": + return tasks.PriorityMinor + case "major": + return tasks.PriorityMajor + case "critical": + return tasks.PriorityCritical + case "blocker": + return tasks.PriorityBlocker + default: + return tasks.PriorityMinor // default + } +} + +// normalizeStatus converts BitBucket status to internal format. +func (i *importer) normalizeStatus(status string) tasks.Status { + switch strings.ToLower(status) { + case "new": + return tasks.StatusNew + case "open": + return tasks.StatusOpen + case "in progress": + return tasks.StatusInProgress + case "resolved": + return tasks.StatusResolved + case "closed": + return tasks.StatusClosed + case "reopened": + return tasks.StatusReopened + default: + return tasks.StatusNew // default + } +} + +func (i *importer) normalizeKind(kind string) tasks.Kind { + switch strings.ToLower(kind) { + case "bug": + return tasks.KindBug + case "enhancement": + return tasks.KindEnhancement + case "task": + return tasks.KindTask + case "proposal": + return tasks.KindProposal + default: + return tasks.KindTask // default + } +} diff --git a/internal/commands/serve.go b/internal/commands/serve/serve.go similarity index 83% rename from internal/commands/serve.go rename to internal/commands/serve/serve.go index 4aaca28..39a774b 100644 --- a/internal/commands/serve.go +++ b/internal/commands/serve/serve.go @@ -1,4 +1,4 @@ -package commands +package serve import ( "context" @@ -22,11 +22,26 @@ import ( "github.com/go-core-fx/logger" "github.com/go-core-fx/sqlfx" "github.com/go-core-fx/validatorfx" + "github.com/urfave/cli/v3" "go.uber.org/fx" "go.uber.org/zap" ) -func Serve(ctx context.Context, version healthfx.Version) error { +func Command(version healthfx.Version) *cli.Command { + return &cli.Command{ + Name: "serve", + Usage: "Start the HTTP server", + Description: `Start the HTTP server`, + Action: func(ctx context.Context, _ *cli.Command) error { + if err := run(ctx, version); err != nil { + return fmt.Errorf("run: %w", err) + } + return nil + }, + } +} + +func run(ctx context.Context, version healthfx.Version) error { app := fx.New( // CORE MODULES logger.Module(), diff --git a/internal/comments/models.go b/internal/comments/models.go index 4527bcd..222db10 100644 --- a/internal/comments/models.go +++ b/internal/comments/models.go @@ -34,6 +34,19 @@ func newCommentModel(input CommentInput) *commentModel { } } +// newCommentImport creates a new commentModel for import with explicit timestamps. +func newCommentImport(input Comment) *commentModel { + return &commentModel{ + BaseModel: bun.BaseModel{}, + TimedModel: bunfx.TimedModel{CreatedAt: input.CreatedAt, UpdatedAt: input.UpdatedAt}, + ID: 0, + TaskID: input.TaskID, + AuthorID: input.AuthorID, + Content: input.Content, + DeletedAt: input.DeletedAt, + } +} + // toDomain converts the database model to a domain Comment entity. // Returns nil if the model is nil. func (m *commentModel) toDomain() *Comment { diff --git a/internal/comments/repository.go b/internal/comments/repository.go index 4b53483..018ab61 100644 --- a/internal/comments/repository.go +++ b/internal/comments/repository.go @@ -30,6 +30,20 @@ func (r *Repository) Create(ctx context.Context, input CommentInput) (*Comment, return model.toDomain(), nil } +// Import creates a comment with explicit timestamps for import. +func (r *Repository) Import( + ctx context.Context, + input Comment, +) (*Comment, error) { + model := newCommentImport(input) + + if _, err := r.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil { + return nil, fmt.Errorf("failed to insert comment for import: %w", err) + } + + return model.toDomain(), nil +} + // GetByID retrieves a comment by its ID. func (r *Repository) GetByID(ctx context.Context, id int64) (*Comment, error) { var model commentModel diff --git a/internal/comments/service.go b/internal/comments/service.go index 576cdb7..dc0b185 100644 --- a/internal/comments/service.go +++ b/internal/comments/service.go @@ -46,6 +46,15 @@ func (s *Service) Create(ctx context.Context, input CommentInput) (*Comment, err return s.comments.Create(ctx, input) } +// Import creates a comment with explicit timestamps for import. +func (s *Service) Import( + ctx context.Context, + input Comment, +) (*Comment, error) { + // Create comment with import-specific data + return s.comments.Import(ctx, input) +} + // GetByID retrieves a comment by its ID. func (s *Service) GetByID(ctx context.Context, id int64) (*Comment, error) { return s.comments.GetByID(ctx, id) diff --git a/internal/tasks/models.go b/internal/tasks/models.go index e54804b..183e67b 100644 --- a/internal/tasks/models.go +++ b/internal/tasks/models.go @@ -60,6 +60,35 @@ func newTaskModel(input TaskInput, number int) *taskModel { } } +// newTaskImport creates a new taskModel for import with explicit values. +func newTaskImport(input Task) *taskModel { + return &taskModel{ + BaseModel: schema.BaseModel{}, + TimedModel: bunfx.TimedModel{CreatedAt: input.CreatedAt, UpdatedAt: input.UpdatedAt}, + ID: 0, + ProjectSlug: input.ProjectSlug, + Number: input.Number, + Title: input.Title, + Description: input.Description, + Priority: input.Priority, + Status: input.Status, + Kind: input.Kind, + AuthorID: input.AuthorID, + AssigneeID: input.AssigneeID, + DueDate: func() *time.Time { + if input.DueDate != nil { + t, err := time.Parse(time.DateOnly, *input.DueDate) + if err != nil { + return nil + } + return &t + } + return nil + }(), + DeletedAt: nil, + } +} + // toDomain converts the database model to a domain Task entity. // Returns nil if the model is nil. func (m *taskModel) toDomain() *Task { diff --git a/internal/tasks/repository.go b/internal/tasks/repository.go index 355ea2e..38c55f4 100644 --- a/internal/tasks/repository.go +++ b/internal/tasks/repository.go @@ -67,6 +67,29 @@ func (r *Repository) Create(ctx context.Context, input TaskInput) (*Task, error) return task, nil } +// Import creates a task with explicit number, timestamps, and kind for import. +// This bypasses auto-number generation and allows setting custom CreatedAt/UpdatedAt. +func (r *Repository) Import( + ctx context.Context, + input Task, +) (*Task, error) { + model := newTaskImport(input) + + if _, err := r.db.NewInsert().Model(model).Exec(ctx); err != nil { + if db.IsUniqueViolation(err) { + return nil, fmt.Errorf( + "%w: task number %d already exists for project %s", + ErrValidationFailed, + input.Number, + input.ProjectSlug, + ) + } + return nil, fmt.Errorf("failed to insert task for import: %w", err) + } + + return model.toDomain(), nil +} + func (r *Repository) Exists(ctx context.Context, id int64) (bool, error) { ok, err := r.db.NewSelect().Model((*taskModel)(nil)).Where("id = ?", id).Exists(ctx) if err != nil { diff --git a/internal/tasks/service.go b/internal/tasks/service.go index 6c44fc1..6163639 100644 --- a/internal/tasks/service.go +++ b/internal/tasks/service.go @@ -49,6 +49,16 @@ func (s *Service) Create(ctx context.Context, input TaskInput) (*Task, error) { return s.tasks.Create(ctx, input) } +// Import creates a task with explicit number, timestamps, and kind for import. +// Bypasses auto-number generation and allows preserving original metadata. +func (s *Service) Import( + ctx context.Context, + input Task, +) (*Task, error) { + // Create task with import-specific data + return s.tasks.Import(ctx, input) +} + // Exists checks if a task with the given ID exists. func (s *Service) Exists(ctx context.Context, id int64) (bool, error) { return s.tasks.Exists(ctx, id) diff --git a/pkg/bitbucket/issues.go b/pkg/bitbucket/issues.go new file mode 100644 index 0000000..abdd2f5 --- /dev/null +++ b/pkg/bitbucket/issues.go @@ -0,0 +1,47 @@ +package bitbucket + +import "time" + +// Issue represents a single issue from BitBucket export. +type Issue struct { + ID int `json:"id"` + Title string `json:"title"` + Reporter User `json:"reporter"` + Assignee *User `json:"assignee"` + Content string `json:"content"` + CreatedOn time.Time `json:"created_on"` + UpdatedOn time.Time `json:"updated_on"` + Status string `json:"status"` + Priority string `json:"priority"` + Kind string `json:"kind"` + Milestone any `json:"milestone"` + Component any `json:"component"` + Version any `json:"version"` + Watchers []User `json:"watchers"` + Voters []User `json:"voters"` +} + +// User represents a user in BitBucket export. +type User struct { + DisplayName string `json:"display_name"` + AccountID string `json:"account_id"` +} + +// Comment represents a comment from BitBucket export. +type Comment struct { + ID int `json:"id"` + Issue int `json:"issue"` + User User `json:"user"` + Content string `json:"content"` + CreatedOn time.Time `json:"created_on"` + UpdatedOn *time.Time `json:"updated_on"` +} + +// Export represents the full JSON export structure. +type Export struct { + Meta map[string]any `json:"meta"` + Issues []Issue `json:"issues"` + Attachments []any `json:"attachments"` + Comments []Comment `json:"comments"` + Logs []any `json:"logs"` +}