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
21 changes: 21 additions & 0 deletions internal/attachments/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,27 @@ func newAttachmentModel(input AttachmentInput, storageKey string) *attachmentMod
}
}

func newAttachmentImport(
taskID int64,
fileName, storageKey string,
sizeBytes uint64,
uploadedBy int64,
) *attachmentModel {
return &attachmentModel{
BaseModel: schema.BaseModel{},
ID: 0,

TaskID: taskID,
FileName: sanitizeFileName(fileName),
StorageKey: storageKey,
SizeBytes: sizeBytes,
Status: string(StatusUploaded),
UploadedBy: uploadedBy,
UploadedAt: time.Now().UTC(),
DeletedAt: nil,
}
}

func (m *attachmentModel) toDomain() *Attachment {
if m == nil {
return nil
Expand Down
8 changes: 8 additions & 0 deletions internal/attachments/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ func NewRepository(db *bun.DB) *Repository {
return &Repository{db: db}
}

func (r *Repository) Import(ctx context.Context, model *attachmentModel) (*Attachment, error) {
if _, err := r.db.NewInsert().Model(model).Returning("*").Exec(ctx); err != nil {
return nil, fmt.Errorf("failed to import attachment: %w", err)
}

return model.toDomain(), nil
}

func (r *Repository) Create(ctx context.Context, input AttachmentInput, storageKey string) (*Attachment, error) {
model := newAttachmentModel(input, storageKey)

Expand Down
37 changes: 37 additions & 0 deletions internal/attachments/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package attachments
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -79,6 +80,42 @@ func (s *Service) InitUpload(ctx context.Context, input AttachmentInput) (*Uploa
}, nil
}

func (s *Service) Import(
ctx context.Context,
taskID int64,
fileName, localFilePath string,
uploadedBy int64,
) (*Attachment, error) {
fi, err := os.Stat(localFilePath)
if err != nil {
return nil, fmt.Errorf("failed to stat attachment file: %w", err)
}

storageKey := s.buildStorageKey(taskID, fileName)

if putErr := s.storageSvc.PutObject(ctx, storageKey, localFilePath); putErr != nil {
return nil, fmt.Errorf("failed to upload attachment to storage: %w", putErr)
}

//nolint:gosec // file size is always non-negative
attachment, createErr := s.attachments.Import(
ctx,
newAttachmentImport(taskID, fileName, storageKey, uint64(fi.Size()), uploadedBy),
)
if createErr != nil {
if cleanupErr := s.storageSvc.Delete(ctx, storageKey); cleanupErr != nil {
s.logger.Warn(
"failed to cleanup storage after failed attachment import",
zap.String("storageKey", storageKey),
zap.Error(cleanupErr),
)
}
return nil, createErr
}

return attachment, nil
}

func (s *Service) ListByTask(ctx context.Context, taskID int64) ([]AttachmentWithURL, error) {
items, err := s.attachments.ListByTask(ctx, taskID)
if err != nil {
Expand Down
12 changes: 8 additions & 4 deletions internal/commands/importer/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/bit-issues/backend/internal/storage"
"github.com/bit-issues/backend/internal/tasks"
"github.com/bit-issues/backend/internal/users"
"github.com/bit-issues/backend/pkg/miniofx"
"github.com/go-core-fx/bunfx"
"github.com/go-core-fx/fxutil"
"github.com/go-core-fx/healthfx"
Expand All @@ -34,10 +35,12 @@ func Command(_ healthfx.Version) *cli.Command {

// ImportResult holds the results of an import operation.
type ImportResult struct {
IssuesImported int
IssuesSkipped int
CommentsImported int
CommentsSkipped int
IssuesImported int
IssuesSkipped int
CommentsImported int
CommentsSkipped int
AttachmentsImported int
AttachmentsSkipped int
}

// run imports issues from a BitBucket export JSON file.
Expand All @@ -48,6 +51,7 @@ func run(ctx context.Context, cmd *cli.Command) error {
logger.WithFxDefaultLogger(),
bunfx.Module(),
sqlfx.Module(),
miniofx.Module(),

config.Module(),
db.Module(),
Expand Down
143 changes: 117 additions & 26 deletions internal/commands/importer/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/bit-issues/backend/internal/attachments"
"github.com/bit-issues/backend/internal/comments"
"github.com/bit-issues/backend/internal/tasks"
"github.com/bit-issues/backend/internal/users"
Expand All @@ -18,11 +20,12 @@ import (
)

type importer struct {
config Config
tasksSvc *tasks.Service
commentsSvc *comments.Service
usersSvc *users.Service
logger *zap.Logger
config Config
tasksSvc *tasks.Service
commentsSvc *comments.Service
usersSvc *users.Service
attachmentsSvc *attachments.Service
logger *zap.Logger

sh fx.Shutdowner
}
Expand All @@ -32,15 +35,17 @@ func newImporter(
tasksSvc *tasks.Service,
commentsSvc *comments.Service,
usersSvc *users.Service,
attachmentsSvc *attachments.Service,
logger *zap.Logger,
sh fx.Shutdowner,
) *importer {
return &importer{
config: config,
tasksSvc: tasksSvc,
commentsSvc: commentsSvc,
usersSvc: usersSvc,
logger: logger,
config: config,
tasksSvc: tasksSvc,
commentsSvc: commentsSvc,
usersSvc: usersSvc,
attachmentsSvc: attachmentsSvc,
logger: logger,

sh: sh,
}
Expand All @@ -57,7 +62,12 @@ func (i *importer) Run(ctx context.Context) error {
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)))
logger.Info(
"Parsed export file",
zap.Int("issues", len(export.Issues)),
zap.Int("comments", len(export.Comments)),
zap.Int("attachments", len(export.Attachments)),
)

if i.config.DryRun {
logger.Info("DRY RUN MODE - no changes will be made")
Expand All @@ -71,10 +81,38 @@ func (i *importer) Run(ctx context.Context) error {

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
issueToTask := make(map[int]int64)

i.importIssues(ctx, logger, export, defaultUser, &result, issueToTask)
i.importComments(ctx, logger, export, defaultUser, &result, issueToTask)
attachmentsDir := filepath.Join(filepath.Dir(i.config.Filename), "attachments")
i.importAttachments(ctx, logger, export, defaultUser, &result, issueToTask, attachmentsDir)

// 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),
zap.Int("attachmentsImported", result.AttachmentsImported),
zap.Int("attachmentsSkipped", result.AttachmentsSkipped),
)

if shErr := i.sh.Shutdown(); shErr != nil {
return fmt.Errorf("failed to shutdown importer: %w", shErr)
}

// Import issues
return nil
}

func (i *importer) importIssues(
ctx context.Context,
logger *zap.Logger,
export *bitbucket.Export,
defaultUser *users.User,
result *ImportResult,
issueToTask map[int]int64,
) {
for _, issue := range export.Issues {
if i.config.DryRun {
logger.Info("Would import issue", zap.Int("issueID", issue.ID))
Expand All @@ -93,8 +131,16 @@ func (i *importer) Run(ctx context.Context) error {
result.IssuesImported++
logger.Info("Imported issue", zap.Int("issueID", issue.ID), zap.Int64("taskID", task.ID))
}
}

// Import comments
func (i *importer) importComments(
ctx context.Context,
logger *zap.Logger,
export *bitbucket.Export,
defaultUser *users.User,
result *ImportResult,
issueToTask map[int]int64,
) {
for _, comment := range export.Comments {
if i.config.DryRun {
logger.Info("Would import comment", zap.Int("commentID", comment.ID))
Expand Down Expand Up @@ -122,20 +168,65 @@ func (i *importer) Run(ctx context.Context) error {
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),
)
func (i *importer) importAttachments(
ctx context.Context,
logger *zap.Logger,
export *bitbucket.Export,
defaultUser *users.User,
result *ImportResult,
issueToTask map[int]int64,
attachmentsDir string,
) {
for _, attachment := range export.Attachments {
if i.config.DryRun {
logger.Info(
"Would import attachment",
zap.Int("issueID", attachment.Issue),
zap.String("filename", attachment.Filename),
)
result.AttachmentsImported++
continue
}

if shErr := i.sh.Shutdown(); shErr != nil {
return fmt.Errorf("failed to shutdown importer: %w", shErr)
}
taskID, ok := issueToTask[attachment.Issue]
if !ok {
result.AttachmentsSkipped++
logger.Warn(
"Could not find task for attachment",
zap.String("filename", attachment.Filename),
zap.Int("issueID", attachment.Issue),
)
continue
}

return nil
localPath := filepath.Join(attachmentsDir, filepath.Base(attachment.Path))
if _, statErr := os.Stat(localPath); statErr != nil {
result.AttachmentsSkipped++
logger.Warn("Attachment file not found on disk", zap.String("path", localPath), zap.Error(statErr))
continue
}

if _, importErr := i.attachmentsSvc.Import(
ctx,
taskID,
attachment.Filename,
localPath,
defaultUser.ID,
); importErr != nil {
result.AttachmentsSkipped++
logger.Warn(
"Failed to import attachment",
zap.String("filename", attachment.Filename),
zap.Error(importErr),
)
continue
}

result.AttachmentsImported++
logger.Info("Imported attachment", zap.String("filename", attachment.Filename), zap.Int64("taskID", taskID))
}
}

// importIssue imports a single issue.
Expand Down
9 changes: 9 additions & 0 deletions internal/storage/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ func (s *Service) PresignedPutObject(ctx context.Context, key string) (string, e
return u.String(), nil
}

func (s *Service) PutObject(ctx context.Context, key string, filePath string) error {
_, err := s.client.FPutObject(ctx, s.bucketName, s.objectKey(key), filePath, minio.PutObjectOptions{})
if err != nil {
return fmt.Errorf("failed to put object: %w", err)
}

return nil
}

func (s *Service) PresignedGetObject(ctx context.Context, key string) (string, error) {
u, err := s.client.PresignedGetObject(
ctx,
Expand Down
11 changes: 10 additions & 1 deletion pkg/bitbucket/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ type Comment struct {
UpdatedOn *time.Time `json:"updated_on"`
}

// Attachment represents a single attachment from BitBucket export.
type Attachment struct {
User User `json:"user"`
Issue int `json:"issue"`
URL string `json:"url"`
Filename string `json:"filename"`
Path string `json:"path"`
}

// Export represents the full JSON export structure.
type Export struct {
Meta map[string]any `json:"meta"`
Issues []Issue `json:"issues"`
Attachments []any `json:"attachments"`
Attachments []Attachment `json:"attachments"`
Comments []Comment `json:"comments"`
Logs []any `json:"logs"`
}
Loading