+ The Dicty Stock Center Team
+ Northwestern University +
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4bf830b..214b8b6 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -11,6 +11,6 @@ jobs: go-version: 1.22 cache: false - name: run linter - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: - version: v1.58.2 + version: v2.7.1 diff --git a/.golangci.yml b/.golangci.yml index aa482e6..7384679 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,17 +1,10 @@ -linters-settings: - lll: - line-length: 2380 - funlen: - lines: 75 - errcheck: - ignore : "" +version: "2" linters: - # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint - disable-all: true + default: none enable: - asciicheck - bodyclose - - cyclop + - cyclop - decorder - dogsled - dupl @@ -19,16 +12,13 @@ linters: - errname - funlen - gochecknoinits + - gocognit - goconst - gocritic - gocyclo - godot - - gofmt - - goimports - gosec - - gosimple - govet - - gocognit - ineffassign - lll - maintidx @@ -37,25 +27,45 @@ linters: - nestif - nilerr - nolintlint - - prealloc - paralleltest + - prealloc - revive - rowserrcheck - staticcheck - - stylecheck - - typecheck - - unconvert - thelper - tparallel - - unparam - - unused - unconvert - unparam + - unused - varnamelen - wastedassign - whitespace - wrapcheck - - # don't enable: - # - godox - maligned,prealloc - # - gochecknoglobals + settings: + errcheck: + disable-default-exclusions: true + funlen: + lines: 75 + lll: + line-length: 2380 + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/cmd/github-actions/main.go b/cmd/github-actions/main.go index 307cc04..03f5493 100644 --- a/cmd/github-actions/main.go +++ b/cmd/github-actions/main.go @@ -42,6 +42,7 @@ func main() { app.Commands = []cli.Command{ cmd.IssueCommentCmds(), cmd.CommentsCountByDateCmds(), + cmd.IssueLabelEmailCmds(), cmd.StoreReportCmd(), cmd.DeployStatusCmd(), cmd.ShareDeployPayloadCmd(), diff --git a/go.mod b/go.mod index 304a978..e6fe79a 100644 --- a/go.mod +++ b/go.mod @@ -4,15 +4,17 @@ go 1.22 require ( github.com/Jeffail/gabs/v2 v2.7.0 - github.com/google/go-github/v32 v32.1.0 github.com/google/go-github/v62 v62.0.0 + github.com/mailgun/mailgun-go/v5 v5.13.1 github.com/minio/minio-go v6.0.14+incompatible github.com/repeale/fp-go v0.11.1 github.com/sethvargo/go-githubactions v1.3.0 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.16 + github.com/yuin/goldmark v1.7.16 golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d + golang.org/x/net v0.34.0 golang.org/x/oauth2 v0.26.0 golang.org/x/sync v0.11.0 golang.org/x/text v0.22.0 @@ -34,9 +36,13 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/kr/text v0.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect @@ -44,7 +50,6 @@ require ( go.opentelemetry.io/otel/metric v1.32.0 // indirect go.opentelemetry.io/otel/trace v1.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect - golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/grpc v1.70.0 // indirect diff --git a/go.sum b/go.sum index 41202d3..16137d0 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-ini/ini v1.66.6 h1:h6k2Bb0HWS/BXXHCXj4QHjxPmlIU4NK+7MuLp9SD+4k= github.com/go-ini/ini v1.66.6/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -22,19 +24,16 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= -github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -43,16 +42,27 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailgun/mailgun-go/v5 v5.13.1 h1:593rwOzqjdG7eoBRmHz1x8otqsDfWgNqSy3OBLu6no4= +github.com/mailgun/mailgun-go/v5 v5.13.1/go.mod h1:8jl24zvg8DPd5R3dUGIM77J76CWE+esAO+3w0/1c9AA= github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o= github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/repeale/fp-go v0.11.1 h1:Q/e+gNyyHaxKAyfdbBqvip3DxhVWH453R+kthvSr9Mk= @@ -67,15 +77,18 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= @@ -88,30 +101,24 @@ go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiy go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d h1:vtUKgx8dahOomfFzLREU8nSv25YHnTgLBn4rDnWZdU0= golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.219.0 h1:nnKIvxKs/06jWawp2liznTBnMRQBEPpGo7I+oEypTX0= google.golang.org/api v0.219.0/go.mod h1:K6OmjGm+NtLrIkHxv1U3a0qIf/0JOvAHd5O/6AoyKYE= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I= diff --git a/internal/app/chatops/deploy.go b/internal/app/chatops/deploy.go index e0981eb..f407d79 100644 --- a/internal/app/chatops/deploy.go +++ b/internal/app/chatops/deploy.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/dictyBase-docker/github-actions/internal/logger" - "github.com/google/go-github/v32/github" + "github.com/google/go-github/v62/github" "github.com/sethvargo/go-githubactions" "github.com/urfave/cli" ) @@ -35,6 +35,7 @@ type branchGetter interface { owner string, repo string, branch string, + maxRedirects int, ) (*github.Branch, *github.Response, error) } @@ -221,6 +222,7 @@ func (bc *branchClient) getHeadCommitFromBranch( owner, name, branch, + 0, ) if err != nil { return "", fmt.Errorf("error getting pull request info %s", err) diff --git a/internal/app/chatops/deploy_test.go b/internal/app/chatops/deploy_test.go index 5b24630..923532c 100644 --- a/internal/app/chatops/deploy_test.go +++ b/internal/app/chatops/deploy_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "testing" - "github.com/google/go-github/v32/github" + "github.com/google/go-github/v62/github" "github.com/stretchr/testify/require" ) @@ -33,6 +33,7 @@ func (m *mockBranchClient) GetBranch( _ string, _ string, _ string, + _ int, ) (*github.Branch, *github.Response, error) { return m.resp, nil, nil } diff --git a/internal/app/dagger/dagger.go b/internal/app/dagger/dagger.go index 1c09849..77e5a55 100644 --- a/internal/app/dagger/dagger.go +++ b/internal/app/dagger/dagger.go @@ -255,7 +255,7 @@ func RemoveInvalidControlChars(strc string) string { var builder strings.Builder for _, rtc := range strc { if rtc >= 32 && rtc != 127 { - builder.WriteRune(rtc) + _, _ = builder.WriteRune(rtc) } } diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 1ddd577..5a2df7f 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -8,11 +8,13 @@ import ( "strconv" "time" + "github.com/dictyBase-docker/github-actions/internal/client" + "github.com/dictyBase-docker/github-actions/internal/email" "github.com/dictyBase-docker/github-actions/internal/logger" + parser "github.com/dictyBase-docker/github-actions/internal/parser" "github.com/google/go-github/v62/github" - - "github.com/dictyBase-docker/github-actions/internal/client" "github.com/urfave/cli" + "golang.org/x/net/html" ) const ( @@ -169,3 +171,153 @@ func issueOpts(c *cli.Context) *github.IssueListByRepoOptions { ListOptions: github.ListOptions{PerPage: 30}, } } + +func getIssue(gclient *github.Client, clt *cli.Context) (*github.Issue, error) { + // Get issue number from context + issueNumber := clt.Int("issueid") + if issueNumber == 0 { + return nil, fmt.Errorf("issue number is required") + } + + // Get issue using the Issues API + issue, _, err := gclient.Issues.Get( + context.Background(), + clt.GlobalString("owner"), + clt.GlobalString("repository"), + issueNumber, + ) + if err != nil { + return nil, fmt.Errorf("error fetching issue: %w", err) + } + + return issue, nil +} + +func getIssueBody(issue *github.Issue) (string, error) { + body := issue.GetBody() + if body == "" { + return "", fmt.Errorf("issue body is empty") + } + + return body, nil +} + +func extractAndValidateOrderData(htmlNode *html.Node, label string) (email.OrderEmailData, error) { + issueData, err := parser.ExtractOrderData(htmlNode) + if err != nil { + return email.OrderEmailData{}, fmt.Errorf("error extracting order data: %w", err) + } + + if issueData.RecipientEmail == "" { + return email.OrderEmailData{}, fmt.Errorf("no recipient email found in issue") + } + if issueData.OrderID == "" { + return email.OrderEmailData{}, fmt.Errorf("no order ID found in issue") + } + + return email.OrderEmailData{ + RecipientEmail: issueData.RecipientEmail, + OrderID: issueData.OrderID, + Label: label, + StockData: issueData.StockData, + }, nil +} + +func fetchAndParseIssue(clt *cli.Context) (*html.Node, error) { + // Get GitHub client + gclient, err := client.GetGithubClient(clt.GlobalString("token")) + if err != nil { + return nil, cli.NewExitError( + fmt.Sprintf("error getting github client: %s", err), + 2, + ) + } + + // Fetch the issue + issue, err := getIssue(gclient, clt) + if err != nil { + return nil, cli.NewExitError( + fmt.Sprintf("error fetching issue: %s", err), + 2, + ) + } + + // Get the markdown body + markdownBody, err := getIssueBody(issue) + if err != nil { + return nil, cli.NewExitError( + fmt.Sprintf("error getting issue body: %s", err), + 2, + ) + } + + // Convert markdown to HTML node + htmlNode, err := parser.MarkdownToHTML(markdownBody) + if err != nil { + return nil, cli.NewExitError( + fmt.Sprintf("error converting markdown to HTML: %s", err), + 2, + ) + } + + return htmlNode, nil +} + +func SendIssueLabelEmail(clt *cli.Context) error { + htmlNode, err := fetchAndParseIssue(clt) + if err != nil { + return err + } + + emailData, err := extractAndValidateOrderData(htmlNode, clt.String("label")) + if err != nil { + return cli.NewExitError(err.Error(), 2) + } + + log := logger.GetLogger(clt) + log.WithFields(map[string]any{ + "order_id": emailData.OrderID, + "recipient": emailData.RecipientEmail, + "label": emailData.Label, + }).Info("Extracted order data from issue") + + // Get Mailgun configuration from flags + domain := clt.String("domain") + apiKey := clt.String("apiKey") + fromEmail := clt.String("fromEmail") + + if domain == "" || apiKey == "" { + return cli.NewExitError( + "Mailgun domain and apiKey are required", + 2, + ) + } + + // Create email client and send email + emailClient := email.NewEmailClient(domain, apiKey, fromEmail) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData); err != nil { + log.WithFields(map[string]any{ + "order_id": emailData.OrderID, + "recipient": emailData.RecipientEmail, + "label": emailData.Label, + "error": err.Error(), + }).Error("Failed to send email") + return cli.NewExitError( + fmt.Sprintf("error sending email: %s", err), + 2, + ) + } + + log.WithFields(map[string]any{ + "order_id": emailData.OrderID, + "recipient": emailData.RecipientEmail, + "label": emailData.Label, + "status": "sent", + }).Info("Successfully sent order update email") + + return nil +} diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go new file mode 100644 index 0000000..7ded399 --- /dev/null +++ b/internal/app/issue/issue_test.go @@ -0,0 +1,220 @@ +package issue + +import ( + "flag" + "testing" + + "github.com/dictyBase-docker/github-actions/internal/fake" + parser "github.com/dictyBase-docker/github-actions/internal/parser" + "github.com/google/go-github/v62/github" + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +func TestGetIssue(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Set up fake server and client + server, client := fake.GhServerClient() + defer server.Close() + + // Create CLI context with required flags + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + set.String("owner", "dictybase-playground", "repository owner") + set.String("repository", "learn-github-action", "repository name") + set.Int("issueid", 122, "issue number") + err := set.Parse([]string{}) + assert.NoError(err, "should parse flags without error") + + ctx := cli.NewContext(app, set, nil) + + // Call getIssue + issue, issueErr := getIssue(client, ctx) + assert.NoError(issueErr, "should not return error when fetching issue") + assert.NotNil(issue, "should return a non-nil issue") + + // Assert that the issue data matches issue.json + assert.Equal(122, issue.GetNumber(), "should have correct issue number") + assert.Equal("Order ID:10283618 kevin.tun@northwestern.edu", issue.GetTitle(), "should have correct title") + assert.Equal("open", issue.GetState(), "should have correct state") + assert.NotEmpty(issue.GetBody(), "should have non-empty body") + assert.Contains(issue.GetBody(), "**Order ID:** 10283618", "body should contain order ID") + assert.Contains(issue.GetBody(), "Billing address", "body should contain billing address") +} + +func TestGetIssueBody(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + issue *github.Issue + want string + wantErr bool + }{ + { + name: "valid body", + issue: &github.Issue{ + Body: github.String("This is the issue body with **Order ID:** 12345"), + }, + want: "This is the issue body with **Order ID:** 12345", + wantErr: false, + }, + { + name: "empty body", + issue: &github.Issue{ + Body: github.String(""), + }, + want: "", + wantErr: true, + }, + { + name: "nil body pointer", + issue: &github.Issue{ + Body: nil, + }, + want: "", + wantErr: true, + }, + { + name: "body with whitespace only", + issue: &github.Issue{ + Body: github.String(" "), + }, + want: " ", + wantErr: false, + }, + { + name: "multiline body", + issue: &github.Issue{ + Body: github.String("Line 1\nLine 2\n**Order ID:** 99999"), + }, + want: "Line 1\nLine 2\n**Order ID:** 99999", + wantErr: false, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assert := require.New(t) + + body, err := getIssueBody(testCase.issue) + + if testCase.wantErr { + assert.Error(err, "expected error for test case: %s", testCase.name) + assert.Contains(err.Error(), "issue body is empty", + "error message should indicate empty body") + return + } + + assert.NoError(err, "unexpected error for test case: %s", testCase.name) + assert.Equal(testCase.want, body, "body should match expected value") + }) + } +} + +var extractAndValidateOrderDataTests = []struct { + name string + markdown string + label string + wantOrderID string + wantEmail string + wantLabel string + wantErr bool + errContains string +}{ + { + name: "valid order data", + markdown: `**Order ID:** 12345 + +| Shipping address | | Billing address | +|-----------------|---|-----------------| +| John Doe | | jane@example.com | +| 123 Main St | | 456 Elm St |`, + label: "shipped", + wantOrderID: "12345", + wantEmail: "jane@example.com", + wantLabel: "shipped", + wantErr: false, + }, + { + name: "missing order ID", + markdown: `Some text without order ID + +| Shipping address | | Billing address | +|-----------------|---|-----------------| +| John Doe | | jane@example.com |`, + label: "processing", + wantErr: true, + errContains: "error extracting order data", + }, + { + name: "missing email", + markdown: `**Order ID:** 99999 + +| Shipping address | | Billing address | +|-----------------|---|-----------------| +| John Doe | | No email here |`, + label: "shipped", + wantErr: true, + errContains: "error extracting order data", + }, + { + name: "empty order ID after extraction", + markdown: `**Order ID:** + +| Shipping address | | Billing address | +|-----------------|---|-----------------| +| John Doe | | test@example.com |`, + label: "shipped", + wantErr: true, + errContains: "error extracting order data", + }, + { + name: "different label", + markdown: `**Order ID:** 54321 + +| Shipping address | | Billing address | +|-----------------|---|-----------------| +| John Doe | | admin@test.com |`, + label: "cancelled", + wantOrderID: "54321", + wantEmail: "admin@test.com", + wantLabel: "cancelled", + wantErr: false, + }, +} + +func TestExtractAndValidateOrderData(t *testing.T) { + t.Parallel() + + for _, testCase := range extractAndValidateOrderDataTests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Convert markdown to HTML + htmlNode, err := parser.MarkdownToHTML(testCase.markdown) + assert.NoError(err, "should convert markdown to HTML") + + // Call the function under test + emailData, err := extractAndValidateOrderData(htmlNode, testCase.label) + + if testCase.wantErr { + assert.Error(err, "expected error for test case: %s", testCase.name) + if testCase.errContains != "" { + assert.Contains(err.Error(), testCase.errContains, + "error should contain %q", testCase.errContains) + } + return + } + + assert.NoError(err, "unexpected error for test case: %s", testCase.name) + assert.Equal(testCase.wantOrderID, emailData.OrderID, "order ID should match") + assert.Equal(testCase.wantEmail, emailData.RecipientEmail, "email should match") + assert.Equal(testCase.wantLabel, emailData.Label, "label should match") + }) + } +} diff --git a/internal/app/repository/commit.go b/internal/app/repository/commit.go index ec86635..2013582 100644 --- a/internal/app/repository/commit.go +++ b/internal/app/repository/commit.go @@ -12,7 +12,7 @@ import ( ) func FilesCommited(clt *cli.Context) error { - gclient, err := client.GetLegacyGithubClient(clt.GlobalString("token")) + gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { return cli.NewExitError( fmt.Sprintf("error in getting github client %s", err), diff --git a/internal/app/repository/migrate.go b/internal/app/repository/migrate.go index 771243d..e1b4cb6 100644 --- a/internal/app/repository/migrate.go +++ b/internal/app/repository/migrate.go @@ -10,7 +10,7 @@ import ( "github.com/dictyBase-docker/github-actions/internal/client" "github.com/dictyBase-docker/github-actions/internal/logger" - gh "github.com/google/go-github/v32/github" + gh "github.com/google/go-github/v62/github" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) @@ -158,7 +158,7 @@ func (m *migration) delRepo() error { } func MigrateRepositories(clt *cli.Context) error { - gclient, err := client.GetLegacyGithubClient(clt.GlobalString("token")) + gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { return cli.NewExitError( fmt.Sprintf("error in getting github client %s", err), diff --git a/internal/app/repository/migrate_test.go b/internal/app/repository/migrate_test.go index 8d934cc..879f0d5 100644 --- a/internal/app/repository/migrate_test.go +++ b/internal/app/repository/migrate_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/dictyBase-docker/github-actions/internal/fake" - gh "github.com/google/go-github/v32/github" + gh "github.com/google/go-github/v62/github" "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" diff --git a/internal/client/client.go b/internal/client/client.go index 39e0ca6..1a7eee8 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -3,20 +3,10 @@ package client import ( "context" - lgh "github.com/google/go-github/v32/github" "github.com/google/go-github/v62/github" "golang.org/x/oauth2" ) -func GetLegacyGithubClient(token string) (*lgh.Client, error) { - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - tc := oauth2.NewClient(context.Background(), ts) - - return lgh.NewClient(tc), nil -} - func GetGithubClient(token string) (*github.Client, error) { ts := oauth2.StaticTokenSource( &oauth2.Token{AccessToken: token}, diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index 3c4740e..2905fb2 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -39,3 +39,34 @@ func IssueCommentCmds() cli.Command { }, } } + +func IssueLabelEmailCmds() cli.Command { + return cli.Command{ + Name: "issue-label-email", + Aliases: []string{"ile"}, + Usage: "sends an email to a recipient of an order when certain labels are added to the issue", + Action: issue.SendIssueLabelEmail, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "apiKey", + Usage: "API key for mailgun", + }, + cli.IntFlag{ + Name: "issueid", + Usage: "The id of the issue", + }, + cli.StringFlag{ + Name: "label", + Usage: "The label that was added to the issue", + }, + cli.StringFlag{ + Name: "domain", + Usage: "Domain of mailgun endpoint", + }, + cli.StringFlag{ + Name: "fromEmail", + Usage: "Email address of the sender", + }, + }, + } +} diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..e9cbc02 --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,116 @@ +package email + +import ( + "bytes" + "context" + "embed" + "fmt" + "html/template" + "time" + + "github.com/dictyBase-docker/github-actions/internal/parser" + "github.com/mailgun/mailgun-go/v5" +) + +//go:embed order_update.tmpl +var templateFS embed.FS + +// OrderEmailData represents the data structure for order update emails. +type OrderEmailData struct { + RecipientEmail string + OrderID string + Label string + StockData parser.StockData +} + +// MailgunConfig holds Mailgun configuration. +type MailgunConfig struct { + Domain string + From string // Sender email address +} + +// MailgunClient wraps the Mailgun client. +type MailgunClient struct { + mg *mailgun.Client + config MailgunConfig +} + +// NewEmailClient creates a new email client with Mailgun configuration. +func NewEmailClient(domain, from, apiKey string) *MailgunClient { + mg := mailgun.NewMailgun(apiKey) + return &MailgunClient{ + mg: mg, + config: MailgunConfig{ + Domain: domain, + From: from, + }, + } +} + +// SendOrderUpdateEmail sends an order update email to the recipient. +func (ec *MailgunClient) SendOrderUpdateEmail( + ctx context.Context, + recipient string, + subject string, + htmlBody string, +) error { + // Create a new message - domain is now passed to NewMessage in v5 + message := mailgun.NewMessage( + ec.config.Domain, + ec.config.From, + subject, + "", // Plain text body (empty, using HTML only) + recipient, + ) + + // Set HTML body + message.SetHTML(htmlBody) + + // Send the message with a 10 second timeout + sendCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + _, err := ec.mg.Send(sendCtx, message) + if err != nil { + return fmt.Errorf("failed to send email via Mailgun: %w", err) + } + + return nil +} + +func createEmailHTML(data OrderEmailData) (string, error) { + // Parse the embedded template file + tmpl, err := template.ParseFS(templateFS, "order_update.tmpl") + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + // Execute template with data + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + return buf.String(), nil +} + +// SendOrderUpdateFromTemplate sends an order update email using the template. +func (ec *MailgunClient) SendOrderUpdateFromTemplate( + ctx context.Context, + data OrderEmailData, +) error { + html, err := createEmailHTML(data) + + if err != nil { + return fmt.Errorf("failed to create email HTML: %w", err) + } + + // Create subject line + subject := fmt.Sprintf("Dicty Stock Center - Order Update #%s", data.OrderID) + + // Send the email + if err := ec.SendOrderUpdateEmail(ctx, data.RecipientEmail, subject, html); err != nil { + return fmt.Errorf("failed to send order update email: %w", err) + } + + return nil +} diff --git a/internal/email/email_test.go b/internal/email/email_test.go new file mode 100644 index 0000000..4c0b19c --- /dev/null +++ b/internal/email/email_test.go @@ -0,0 +1,242 @@ +package email + +import ( + "os" + "reflect" + "regexp" + "testing" + + "github.com/dictyBase-docker/github-actions/internal/parser" + "github.com/stretchr/testify/require" +) + +func TestNewEmailClient(t *testing.T) { + t.Parallel() + assert := require.New(t) + + domain := "test.mailgun.org" + apiKey := "test-api-key-123" + fromEmail := "test@example.com" + + client := NewEmailClient(domain, fromEmail, apiKey) + + assert.NotNil(client, "client should not be nil") + assert.NotNil(client.mg, "mailgun client should not be nil") + assert.Equal(domain, client.config.Domain, "domain should match") + assert.Equal(fromEmail, client.config.From, "from email should match") +} + +func TestTemplateFieldsMatchStruct(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Read the template file + templatePath := "order_update.tmpl" + templateContent, err := os.ReadFile(templatePath) + if os.IsNotExist(err) { + t.Skip("Skipping test: order_update.tmpl not found in current directory") + } + assert.NoError(err, "should read template file") + + content := string(templateContent) + + // Remove content within {{range}}...{{end}} blocks to exclude iteration-scoped fields + rangePattern := regexp.MustCompile(`(?s)\{\{range\s+[^}]+\}\}.*?\{\{end\}\}`) + contentWithoutRanges := rangePattern.ReplaceAllString(content, "") + + // Extract all template field references from non-range content + // Matches {{.Field}}, {{.Field.Nested}}, {{if .Field}}, etc. + fieldPattern := regexp.MustCompile(`\{\{[^}]*?\.(\w+)(?:\.\w+)*[^}]*?\}\}`) + matches := fieldPattern.FindAllStringSubmatch(contentWithoutRanges, -1) + + // Collect unique top-level field names used in template + templateFields := make(map[string]bool) + for _, match := range matches { + if len(match) > 1 { + // Extract only the first field name after the dot + templateFields[match[1]] = true + } + } + + assert.NotEmpty(templateFields, "template should use at least one field") + + // Get actual struct fields using reflection + structType := reflect.TypeOf(OrderEmailData{}) + structFields := make(map[string]bool) + for i := 0; i < structType.NumField(); i++ { + field := structType.Field(i) + if field.IsExported() { + structFields[field.Name] = true + } + } + + // Verify every top-level template field exists in the struct + for templateField := range templateFields { + assert.True(structFields[templateField], + "template uses field %q which doesn't exist in OrderEmailData struct", templateField) + } + + // Note: We don't require all struct fields to be used in the template, + // as RecipientEmail is used for addressing but not in the email body +} + +//nolint:funlen // Test cases table +func getEmailHTMLTestCases() []struct { + name string + data OrderEmailData + wantErr bool + contains []string +} { + return []struct { + name string + data OrderEmailData + wantErr bool + contains []string + }{ + { + name: "valid order data", + data: OrderEmailData{ + RecipientEmail: "test@example.com", + OrderID: "ORD-12345", + Label: "shipped", + }, + wantErr: false, + contains: []string{ + "Order Number: ORD-12345", + "Current Status", + "shipped", + "Dicty Stock Center", + "dictystocks@northwestern.edu", + }, + }, + { + name: "order with different status", + data: OrderEmailData{ + RecipientEmail: "user@test.com", + OrderID: "ORD-99999", + Label: "processing", + }, + wantErr: false, + contains: []string{ + "Order Number: ORD-99999", + "processing", + }, + }, + { + name: "empty order data", + data: OrderEmailData{ + RecipientEmail: "", + OrderID: "", + Label: "", + }, + wantErr: false, + contains: []string{ + "Order Number:", + "Current Status", + }, + }, + { + name: "order with strain data", + data: OrderEmailData{ + RecipientEmail: "test@example.com", + OrderID: "ORD-11111", + Label: "shipped", + StockData: parser.StockData{ + StrainInfo: []parser.StrainInfo{ + {ID: "DBS0351362", Descriptor: "HL16/HL106"}, + {ID: "DBS0351363", Descriptor: "HL84/XM101"}, + }, + }, + }, + wantErr: false, + contains: []string{ + "Order Number: ORD-11111", + "shipped", + "Strains Ordered", + "DBS0351362", + "HL16/HL106", + "DBS0351363", + "HL84/XM101", + }, + }, + { + name: "order with plasmid data", + data: OrderEmailData{ + RecipientEmail: "test@example.com", + OrderID: "ORD-22222", + Label: "processing", + StockData: parser.StockData{ + PlasmidInfo: []parser.PlasmidInfo{ + {ID: "DBP0001064", Name: "pDDB_G0279361/lacZ"}, + {ID: "DBP0001065", Name: "pDDB_G0279362/lacZ"}, + }, + }, + }, + wantErr: false, + contains: []string{ + "Order Number: ORD-22222", + "processing", + "Plasmids Ordered", + "DBP0001064", + "pDDB_G0279361/lacZ", + "DBP0001065", + "pDDB_G0279362/lacZ", + }, + }, + { + name: "order with both strains and plasmids", + data: OrderEmailData{ + RecipientEmail: "test@example.com", + OrderID: "ORD-33333", + Label: "shipped", + StockData: parser.StockData{ + StrainInfo: []parser.StrainInfo{ + {ID: "DBS0351362", Descriptor: "HL16/HL106"}, + }, + PlasmidInfo: []parser.PlasmidInfo{ + {ID: "DBP0001064", Name: "pDDB_G0279361/lacZ"}, + }, + }, + }, + wantErr: false, + contains: []string{ + "Order Number: ORD-33333", + "Strains Ordered", + "DBS0351362", + "Plasmids Ordered", + "DBP0001064", + }, + }, + } +} + +func TestCreateEmailHTML(t *testing.T) { + t.Parallel() + + for _, testCase := range getEmailHTMLTestCases() { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assert := require.New(t) + + html, err := createEmailHTML(testCase.data) + + if testCase.wantErr { + assert.Error(err, "expected error for test case: %s", testCase.name) + return + } + + assert.NoError(err, "unexpected error for test case: %s", testCase.name) + assert.NotEmpty(html, "HTML should not be empty") + + // Verify HTML contains expected content + for _, expectedContent := range testCase.contains { + assert.Contains(html, expectedContent, + "HTML should contain %q in test case: %s", expectedContent, testCase.name) + } + + // Verify it's valid HTML structure + assert.Contains(html, "", "should end with closing html tag") + }) + } +} diff --git a/internal/email/order_update.tmpl b/internal/email/order_update.tmpl new file mode 100644 index 0000000..544c7db --- /dev/null +++ b/internal/email/order_update.tmpl @@ -0,0 +1,312 @@ + + + +
+ + +|
+
+
+
+
+
+
+
+ Dicty Stock Center+
+
+
+
+
+
+
+
+ Your Order Has Been Updated
+
+ Order Number: {{.OrderID}}
+
+
+
+
+
+
+
+
+ Current Status
+ {{.Label}}
+
+
+
+ Best regards,
+ Contact Us
+ + The Dicty Stock Center Team + Northwestern University + |
+
This is a paragraph.
", + }, + }, + { + name: "headers", + markdown: "# Header 1\n## Header 2", + wantErr: false, + containsHTML: []string{ + "| Column 1 | ", + "Column 2 | ", + "Value 1 | ", + "Value 2 | ", + }, + }, + { + name: "links", + markdown: "[GitHub](https://github.com)", + wantErr: false, + containsHTML: []string{ + "GitHub", + }, + }, + { + name: "mixed content", + markdown: "**Order ID:** 37500885\n\nSome text with [link](http://example.com)", + wantErr: false, + containsHTML: []string{ + "Order ID:", + "37500885", + "link", + }, + }, + { + name: "empty string", + markdown: "", + wantErr: false, + containsHTML: []string{}, + }, +} + +func TestParseTables(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Load the test data + testDataPath := filepath.Join("..", "..", "testdata", "issue.json") + data, err := os.ReadFile(testDataPath) + assert.NoError(err, "should be able to read testdata/issue.json") + + // Parse JSON to extract body_html + var issue IssueData + err = json.Unmarshal(data, &issue) + assert.NoError(err, "should be able to parse JSON") + + // Parse HTML string to *html.Node + doc, err := html.Parse(strings.NewReader(issue.BodyHTML)) + assert.NoError(err, "should be able to parse HTML") + + // Parse tables from HTML node + tables, err := ParseTables(doc) + assert.NoError(err, "should be able to parse tables from HTML") + + // Verify we extracted 2 tables + assert.Len(tables, 2, "should extract 2 tables from body_html") + + // Verify the table headers + assert.Equal([]string{"Item", "Quantity", "Unit price($)", "Total($)"}, tables[0].Headers, "first table should be stocks ordered") + assert.Equal( + []string{"ID - Strain Plasmid Name", "Strain characteristics", "Stored As", "Location", "No of vials", "Color", "Verification", "Storage comments", "Other comments and feedback"}, + tables[1].Headers, + "second table should be combined strain and plasmid info", + ) +} + +func TestExtractBillingEmail(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Load the test data + testDataPath := filepath.Join("..", "..", "testdata", "issue.json") + data, err := os.ReadFile(testDataPath) + assert.NoError(err, "should be able to read testdata/issue.json") + + // Parse JSON to extract body_html + var issue IssueData + err = json.Unmarshal(data, &issue) + assert.NoError(err, "should be able to parse JSON") + + // Parse HTML string to *html.Node + doc, err := html.Parse(strings.NewReader(issue.BodyHTML)) + assert.NoError(err, "should be able to parse HTML") + + // Extract billing email + email, err := ExtractBillingEmail(doc) + assert.NoError(err, "should be able to extract billing email") + assert.Equal("kevin.tun@northwestern.edu", email, "should extract the correct email from billing address column") +} + +func TestExtractOrderID(t *testing.T) { + t.Parallel() + assert := require.New(t) + + // Load the test data + testDataPath := filepath.Join("..", "..", "testdata", "issue.json") + data, err := os.ReadFile(testDataPath) + assert.NoError(err, "should be able to read testdata/issue.json") + + // Parse JSON to extract body_html + var issue IssueData + err = json.Unmarshal(data, &issue) + assert.NoError(err, "should be able to parse JSON") + + // Parse HTML string to *html.Node + doc, err := html.Parse(strings.NewReader(issue.BodyHTML)) + assert.NoError(err, "should be able to parse HTML") + + // Extract order ID + orderID, err := ExtractOrderID(doc) + assert.NoError(err, "should be able to extract order ID") + assert.Equal("10283618", orderID, "should extract the correct order ID from paragraph") +} + +func verifyHTMLContent(t *testing.T, htmlNode *html.Node, containsHTML, notContainsHTML []string) { + t.Helper() + assert := require.New(t) + + // Convert HTML node back to string for verification + var buf strings.Builder + err := html.Render(&buf, htmlNode) + assert.NoError(err, "should render HTML node to string") + + htmlString := buf.String() + + // Verify expected HTML is present + for _, expected := range containsHTML { + assert.Contains(htmlString, expected, "HTML should contain %q", expected) + } + + // Verify unexpected HTML is not present + for _, notExpected := range notContainsHTML { + assert.NotContains(htmlString, notExpected, "HTML should not contain %q", notExpected) + } +} + +func TestMarkdownToHTML(t *testing.T) { + t.Parallel() + + for _, testCase := range markdownToHTMLTests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + assert := require.New(t) + + htmlNode, err := MarkdownToHTML(testCase.markdown) + + if testCase.wantErr { + assert.Error(err, "expected error for test case: %s", testCase.name) + return + } + + assert.NoError(err, "unexpected error for test case: %s", testCase.name) + assert.NotNil(htmlNode, "HTML node should not be nil") + + verifyHTMLContent(t, htmlNode, testCase.containsHTML, testCase.notContainsHTML) + }) + } +} + +func getEmailExtractionTestCases() []struct { + name string + input string + expected string +} { + return []struct { + name string + input string + expected string + }{ + { + name: "simple email", + input: "user@example.com", + expected: "user@example.com", + }, + { + name: "email with name", + input: "John Doe
|---|
| Item | \nQuantity | \nUnit price($) | \nTotal($) | \n
|---|---|---|---|
| Strain | \n2 | \n30 | \n60 | \n
| Plasmid | \n4 | \n15 | \n60 | \n
| \n | \n | \n | 120 | \n
| ID - Strain Plasmid Name | \nStrain characteristics | \nStored As | \nLocation | \nNo of vials | \nColor | \nVerification | \nStorage comments | \nOther comments and feedback | \n
|---|---|---|---|---|---|---|---|---|
| Strain-DBS0351362 - HL16/HL106 | \n\n | cells | \n4-46(47) | \n3 | \nRed | \n\n | \n | \n |
| Strain-DBS0351363 - HL84/XM101 | \n\n | cells | \n4-47(17) | \n2 | \nPink | \n\n | \n | \n |
| Plasmid-DBP0001064 - pDDB_G0279361/lacZ | \n\n | bacteria | \n23(9,18) | \n\n | \n | \n | \n | \n |
| Plasmid-DBP0001064 - pDDB_G0279361/lacZ | \n\n | bacteria | \n23(9,18) | \n\n | \n | \n | \n | \n |
| Plasmid-DBP0001064 - pDDB_G0279361/lacZ | \n\n | bacteria | \n23(9,18) | \n\n | \n | \n | \n | \n |
| Plasmid-DBP0001064 - pDDB_G0279361/lacZ | \n\n | bacteria | \n23(9,18) | \n\n | \n | \n | \n | \n |