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
16 changes: 11 additions & 5 deletions apps/api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ require (
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0
golang.org/x/crypto v0.52.0
golang.org/x/net v0.54.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
Expand Down Expand Up @@ -91,14 +91,20 @@ require (
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.11.0 // indirect
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
Expand All @@ -107,9 +113,9 @@ require (
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
Expand Down
23 changes: 23 additions & 0 deletions apps/api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
Expand All @@ -216,6 +220,8 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo=
github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
Expand All @@ -226,6 +232,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand All @@ -252,20 +264,31 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
Expand Down
86 changes: 86 additions & 0 deletions apps/api/internal/handler/export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package handler

import (
"net/http"

"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)

const xlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"

// ExportHandler serves server-side issue exports.
type ExportHandler struct {
Export *service.ExportService
}

// CreateExport generates an .xlsx of the selected projects' issues and streams
// it back, recording the request in the export history.
// POST /api/workspaces/:slug/exports/
func (h *ExportHandler) CreateExport(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
var body struct {
ProjectIDs []string `json:"project_ids"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
projectIDs := make([]uuid.UUID, 0, len(body.ProjectIDs))
for _, s := range body.ProjectIDs {
id, err := uuid.Parse(s)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID: " + s})
return
}
projectIDs = append(projectIDs, id)
}
filename, data, err := h.Export.ExportIssues(c.Request.Context(), slug, user.ID, projectIDs, body.Name)
if err != nil {
switch err {
case service.ErrWorkspaceNotFound, service.ErrProjectNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
case service.ErrWorkspaceForbidden:
c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"})
case service.ErrNoProjectsSelected:
c.JSON(http.StatusBadRequest, gin.H{"error": "Select at least one project to export"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate export"})
}
return
}
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Data(http.StatusOK, xlsxContentType, data)
}

// ListExports returns the workspace's export history.
// GET /api/workspaces/:slug/exports/
func (h *ExportHandler) ListExports(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
slug := c.Param("slug")
list, err := h.Export.ListHistory(c.Request.Context(), slug, user.ID)
if err != nil {
switch err {
case service.ErrWorkspaceNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"})
case service.ErrWorkspaceForbidden:
c.JSON(http.StatusForbidden, gin.H{"error": "Not a member of this workspace"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list exports"})
}
return
}
c.JSON(http.StatusOK, gin.H{"exports": list})
}
70 changes: 70 additions & 0 deletions apps/api/internal/handler/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package handler_test

import (
"bytes"
"encoding/json"
"net/http"
"testing"

"github.com/Devlaner/devlane/api/internal/testutil"
"github.com/stretchr/testify/require"
"github.com/xuri/excelize/v2"
)

// A workspace member can export a project's issues as a real .xlsx workbook that
// parses back and contains the issues, the request is recorded in the export
// history, and non-members / empty selections are refused. Covers #198.
func TestExport_IssuesXLSXAndHistory(t *testing.T) {
ts := testutil.NewTestServer(t)
w := testutil.SeedWorld(t, ts.DB)
url := "/api/workspaces/" + w.Workspace.Slug + "/exports/"

i1 := testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)
testutil.CreateIssue(t, ts.DB, w.Project.ID, w.Workspace.ID, w.User.ID)

rr := ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, w.Session)
require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String())
require.Contains(t, rr.Header().Get("Content-Type"), "spreadsheetml.sheet")

body := rr.Body.Bytes()
require.True(t, len(body) > 4 && body[0] == 'P' && body[1] == 'K', "response should be an xlsx (zip) file")

// It opens as a real workbook and contains the exported issue.
f, err := excelize.OpenReader(bytes.NewReader(body))
require.NoError(t, err)
rows, err := f.GetRows("Issues")
require.NoError(t, err)
require.GreaterOrEqual(t, len(rows), 3, "header row + two issues")
require.Equal(t, "Title", rows[0][2])
found := false
for _, r := range rows[1:] {
if len(r) > 2 && r[2] == i1.Name {
found = true
}
}
require.True(t, found, "exported sheet should contain the issue title")

// The request is recorded in the export history.
lr := ts.GET(url, w.Session)
require.Equal(t, http.StatusOK, lr.Code)
var hist struct {
Exports []struct {
Provider string `json:"provider"`
Status string `json:"status"`
} `json:"exports"`
}
require.NoError(t, json.Unmarshal(lr.Body.Bytes(), &hist))
require.Len(t, hist.Exports, 1)
require.Equal(t, "xlsx", hist.Exports[0].Provider)
require.Equal(t, "completed", hist.Exports[0].Status)

// A non-member is refused.
outsider := testutil.CreateUser(t, ts.DB)
outsiderSession := testutil.LoginAs(t, ts.DB, outsider)
require.Equal(t, http.StatusForbidden,
ts.POST(url, map[string]any{"project_ids": []string{w.Project.ID.String()}}, outsiderSession).Code)

// An empty selection is a 400.
require.Equal(t, http.StatusBadRequest,
ts.POST(url, map[string]any{"project_ids": []string{}}, w.Session).Code)
}
34 changes: 34 additions & 0 deletions apps/api/internal/model/exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package model

import (
"time"

"github.com/google/uuid"
"gorm.io/gorm"
)

// Exporter matches the exporters table: one row per issue-export request, kept
// as the export history. project_ids are recorded inside Filters so we avoid the
// UUID[] column's array-driver quirks.
type Exporter struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Name string `gorm:"type:varchar(255)" json:"name,omitempty"`
Type string `gorm:"type:varchar(50);default:issue_exports" json:"type"`
Provider string `gorm:"type:varchar(50);not null" json:"provider"`
Status string `gorm:"type:varchar(50);not null;default:queued" json:"status"`
Reason string `gorm:"type:text;default:''" json:"reason,omitempty"`
Filters JSONMap `gorm:"type:jsonb;serializer:json" json:"filters,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
InitiatedByID uuid.UUID `gorm:"type:uuid;not null" json:"initiated_by_id"`
}

func (Exporter) TableName() string { return "exporters" }

func (e *Exporter) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
5 changes: 5 additions & 0 deletions apps/api/internal/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ func New(cfg Config) *gin.Engine {
attachmentSvc := service.NewAttachmentService(issueStore, projectStore, workspaceStore, cfg.Minio)
attachmentSvc.SetActivityStore(issueActivityStore)
cycleSvc := service.NewCycleService(cycleStore, projectStore, workspaceStore)
exporterStore := store.NewExporterStore(cfg.DB)
exportSvc := service.NewExportService(exporterStore, issueStore, stateStore, projectStore, workspaceStore)
cycleSvc.SetIssueStore(issueStore)
moduleSvc := service.NewModuleService(moduleStore, projectStore, workspaceStore)
moduleSvc.SetIssueStore(issueStore)
Expand Down Expand Up @@ -241,6 +243,7 @@ func New(cfg Config) *gin.Engine {
attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc}
epicHandler := &handler.EpicHandler{Issue: issueSvc}
cycleHandler := &handler.CycleHandler{Cycle: cycleSvc}
exportHandler := &handler.ExportHandler{Export: exportSvc}
moduleHandler := &handler.ModuleHandler{Module: moduleSvc}
issueViewHandler := &handler.IssueViewHandler{IssueView: issueViewSvc}
pageHandler := &handler.PageHandler{Page: pageSvc}
Expand Down Expand Up @@ -285,6 +288,8 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/", workspaceHandler.GetBySlug)
api.PATCH("/workspaces/:slug/", workspaceHandler.Update)
api.DELETE("/workspaces/:slug/", workspaceHandler.Delete)
api.POST("/workspaces/:slug/exports/", exportHandler.CreateExport)
api.GET("/workspaces/:slug/exports/", exportHandler.ListExports)
api.GET("/workspaces/:slug/members/", workspaceHandler.ListMembers)
api.POST("/workspaces/:slug/members/leave/", workspaceHandler.Leave)
api.GET("/workspaces/:slug/members/:pk/", workspaceHandler.GetMember)
Expand Down
Loading
Loading