From 43b7f9673bcbf3c3a484f222a67cb3c2272a0b4f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 16 Jan 2026 11:23:42 -0600 Subject: [PATCH 01/71] feat: add issue label email command and html parsing This commit introduces a new command, `issue-label-email`, to send emails based on issue labels. It also includes HTML parsing functionality to extract data from issue bodies. --- cmd/github-actions/main.go | 1 + internal/app/issue/issue.go | 61 ++++++++++++++++- internal/app/issue/issue_test.go | 83 ++++++++++++++++++++++++ internal/cmd/issue.go | 27 ++++++++ internal/html/parser.go | 108 +++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 internal/app/issue/issue_test.go create mode 100644 internal/html/parser.go 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/internal/app/issue/issue.go b/internal/app/issue/issue.go index 1ddd577..4ea6469 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -6,12 +6,13 @@ import ( "fmt" "os" "strconv" + "strings" "time" + "github.com/dictyBase-docker/github-actions/internal/client" + htmlparser "github.com/dictyBase-docker/github-actions/internal/html" "github.com/dictyBase-docker/github-actions/internal/logger" "github.com/google/go-github/v62/github" - - "github.com/dictyBase-docker/github-actions/internal/client" "github.com/urfave/cli" ) @@ -21,6 +22,11 @@ const ( fileLayout = "01-02-2006-150405" ) +type OrderData struct { + orderID string + recipientEmail string +} + func CommentsCountByDate(clt *cli.Context) error { gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { @@ -169,3 +175,54 @@ func issueOpts(c *cli.Context) *github.IssueListByRepoOptions { ListOptions: github.ListOptions{PerPage: 30}, } } + +func IssueLabelEmail(c *cli.Context) error { + return nil +} + +func getIssueBodyHTML(c *cli.Context) (string, error) { + // Get GitHub client + gclient, err := client.GetGithubClient(c.GlobalString("token")) + if err != nil { + return "", fmt.Errorf("error getting github client: %w", err) + } + + // Get issue number from context + issueNumber := c.Int("issue") + if issueNumber == 0 { + return "", fmt.Errorf("issue number is required") + } + + // Create custom request to get HTML format + url := fmt.Sprintf( + "repos/%s/%s/issues/%d", + c.GlobalString("owner"), + c.GlobalString("repository"), + issueNumber, + ) + req, err := gclient.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + + // Set Accept header to get HTML format + req.Header.Set("Accept", "application/vnd.github.html+json") + + var issue github.Issue + _, err = gclient.Do(context.Background(), req, &issue) + if err != nil { + return "", fmt.Errorf("error fetching issue: %w", err) + } + + // When Accept header is set to html+json, Body field contains HTML + bodyHTML := issue.GetBody() + if bodyHTML == "" { + return "", fmt.Errorf("issue body is empty") + } + + return bodyHTML, nil +} + +func getOrderDataFromIssueBody(c *cli.Context) (*OrderData, error) { + return &OrderData{}, nil +} diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go new file mode 100644 index 0000000..7103967 --- /dev/null +++ b/internal/app/issue/issue_test.go @@ -0,0 +1,83 @@ +package issue + +import ( + "flag" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli" +) + +func TestGetOrderDataFromIssueBodyBothFields(t *testing.T) { + t.Parallel() + assert := require.New(t) + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + set.String("order-id", "ORD-12345", "order id") + set.String("email", "user@example.com", "recipient email") + set.Parse([]string{}) + ctx := cli.NewContext(app, set, nil) + + result, err := getOrderDataFromIssueBody(ctx) + assert.NoError(err, "should not return error when both fields are provided") + assert.NotNil(result, "should return a non-nil OrderData object") + assert.Equal("ORD-12345", result.orderID, "should extract order ID from context") + assert.Equal("user@example.com", result.recipientEmail, "should extract email from context") +} + +func TestGetOrderDataFromIssueBodyDifferentValues(t *testing.T) { + t.Parallel() + assert := require.New(t) + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + set.String("order-id", "ORD-99999", "order id") + set.String("email", "admin@example.com", "recipient email") + set.Parse([]string{}) + ctx := cli.NewContext(app, set, nil) + + result, err := getOrderDataFromIssueBody(ctx) + assert.NoError(err, "should not return error when both fields are provided") + assert.NotNil(result, "should return a non-nil OrderData object") + assert.Equal("ORD-99999", result.orderID, "should extract order ID from context") + assert.Equal("admin@example.com", result.recipientEmail, "should extract email from context") +} + +func TestGetOrderDataFromIssueBodyMissingOrderID(t *testing.T) { + t.Parallel() + assert := require.New(t) + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + set.String("email", "user@example.com", "recipient email") + set.Parse([]string{}) + ctx := cli.NewContext(app, set, nil) + + result, err := getOrderDataFromIssueBody(ctx) + assert.Error(err, "should return error when order-id is missing") + assert.Nil(result, "should return nil when order-id is missing") +} + +func TestGetOrderDataFromIssueBodyMissingEmail(t *testing.T) { + t.Parallel() + assert := require.New(t) + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + set.String("order-id", "ORD-12345", "order id") + set.Parse([]string{}) + ctx := cli.NewContext(app, set, nil) + + result, err := getOrderDataFromIssueBody(ctx) + assert.Error(err, "should return error when email is missing") + assert.Nil(result, "should return nil when email is missing") +} + +func TestGetOrderDataFromIssueBodyMissingBoth(t *testing.T) { + t.Parallel() + assert := require.New(t) + app := cli.NewApp() + set := flag.NewFlagSet("test", 0) + ctx := cli.NewContext(app, set, nil) + + result, err := getOrderDataFromIssueBody(ctx) + assert.Error(err, "should return error when both fields are missing") + assert.Nil(result, "should return nil when both fields are missing") +} diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index 3c4740e..8a97336 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -39,3 +39,30 @@ 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.IssueLabelEmail, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "label", + Usage: "The label that was added to the issue", + }, + cli.StringFlag{ + Name: "issueid", + Usage: "The id of the issue", + }, + cli.StringFlag{ + Name: "domain", + Usage: "Domain of mailgun endpoint", + }, + cli.StringFlag{ + Name: "apiKey", + Usage: "API key for mailgun", + }, + }, + } +} diff --git a/internal/html/parser.go b/internal/html/parser.go new file mode 100644 index 0000000..2713c99 --- /dev/null +++ b/internal/html/parser.go @@ -0,0 +1,108 @@ +package html + +import ( + "fmt" + "strings" + + "golang.org/x/net/html" +) + +// TableData represents a parsed HTML table. +type TableData struct { + Headers []string + Rows [][]string +} + +// ParseTables extracts all tables from HTML content. +func ParseTables(htmlContent string) ([]TableData, error) { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return nil, fmt.Errorf("error parsing HTML: %w", err) + } + + var tables []TableData + var findTables func(*html.Node) + findTables = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "table" { + tables = append(tables, parseTable(n)) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + findTables(c) + } + } + findTables(doc) + + return tables, nil +} + +// parseTable extracts data from a single table node +func parseTable(tableNode *html.Node) TableData { + var table TableData + + for child := tableNode.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode { + continue + } + + switch child.Data { + case "thead": + table.Headers = parseTableHead(child) + case "tbody": + table.Rows = parseTableBody(child) + case "tr": + // Table without thead/tbody - first row becomes headers + cells := parseTableRow(child) + if len(table.Headers) == 0 { + table.Headers = cells + } else { + table.Rows = append(table.Rows, cells) + } + } + } + + return table +} + +// parseTableHead extracts header cells from thead +func parseTableHead(theadNode *html.Node) []string { + for row := theadNode.FirstChild; row != nil; row = row.NextSibling { + if row.Type == html.ElementNode && row.Data == "tr" { + return parseTableRow(row) + } + } + return nil +} + +// parseTableBody extracts all rows from tbody +func parseTableBody(tbodyNode *html.Node) [][]string { + var rows [][]string + for row := tbodyNode.FirstChild; row != nil; row = row.NextSibling { + if row.Type == html.ElementNode && row.Data == "tr" { + rows = append(rows, parseTableRow(row)) + } + } + return rows +} + +// parseTableRow extracts cells from a table row +func parseTableRow(rowNode *html.Node) []string { + var cells []string + for cell := rowNode.FirstChild; cell != nil; cell = cell.NextSibling { + if cell.Type == html.ElementNode && (cell.Data == "td" || cell.Data == "th") { + cells = append(cells, getTextContent(cell)) + } + } + return cells +} + +// getTextContent recursively extracts all text from a node +func getTextContent(n *html.Node) string { + if n.Type == html.TextNode { + return n.Data + } + var text strings.Builder + for c := n.FirstChild; c != nil; c = c.NextSibling { + text.WriteString(getTextContent(c)) + } + return strings.TrimSpace(text.String()) +} From 48d06356dfb7f5977e1cb74832dd5f17835b21a8 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:11:02 -0600 Subject: [PATCH 02/71] feat: implement issue data extraction using regex and templates This commit introduces functionality to extract data from issue descriptions using regular expressions and templates. It includes functions to extract order IDs and email addresses from text, as well as a generic function to extract data based on a provided template. The commit also includes tests to verify the extraction logic. --- internal/app/issue/extract.go | 66 +++++++++++++++++++++++++++++ internal/app/issue/extract_test.go | 67 ++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 internal/app/issue/extract.go create mode 100644 internal/app/issue/extract_test.go diff --git a/internal/app/issue/extract.go b/internal/app/issue/extract.go new file mode 100644 index 0000000..a4e2ef8 --- /dev/null +++ b/internal/app/issue/extract.go @@ -0,0 +1,66 @@ +package issue + +import ( + "fmt" + "regexp" +) + +func extractFromTemplate(text, templatePattern string) (map[string]string, error) { + // Replace {{.Field}} with named capture groups + regex := regexp.MustCompile(`\{\{\.(\w+)\}\}`) + pattern := regex.ReplaceAllString(templatePattern, `(?P<$1>.+?)`) + + re := regexp.MustCompile(pattern) + matches := re.FindStringSubmatch(text) + + result := make(map[string]string) + for i, name := range re.SubexpNames() { + if i > 0 && i <= len(matches) { + result[name] = matches[i] + } + } + return result, nil +} + +// ExtractOrderID extracts the Order ID from markdown text +// Pattern: Order ID: VALUE (with optional ** markdown bold) +func extractOrderID(text string) (string, error) { + // Match: Order ID: followed by optional whitespace and the ID value + // Handles both "**Order ID:**" and "Order ID:" + pattern := `\*?\*?Order ID:\*?\*?\s*(\S+)` + re := regexp.MustCompile(pattern) + + matches := re.FindStringSubmatch(text) + if len(matches) < 2 { + return "", fmt.Errorf("order ID not found") + } + + return matches[1], nil +} + +func extractEmail(text string) (string, error) { + // Standard email pattern + pattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` + re := regexp.MustCompile(pattern) + + matches := re.FindStringSubmatch(text) + if len(matches) < 2 { + return "", fmt.Errorf("email not found") + } + + return matches[1], nil +} + +func extractWithRegex(text string) (orderID string, email string, err error) { + orderID, err = extractOrderID(text) + if err != nil { + return "", "", fmt.Errorf("failed to extract order ID: %w", err) + } + + email, err = extractEmail(text) + if err != nil { + return "", "", fmt.Errorf("failed to extract email: %w", err) + } + + return orderID, email, nil +} diff --git a/internal/app/issue/extract_test.go b/internal/app/issue/extract_test.go new file mode 100644 index 0000000..4bb60eb --- /dev/null +++ b/internal/app/issue/extract_test.go @@ -0,0 +1,67 @@ +package issue + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractFromTemplate(t *testing.T) { + assert := require.New(t) + + // Read the template file + templatePath := filepath.Join("issue.tmpl") + templateContent, err := os.ReadFile(templatePath) + assert.NoError(err, "should read template file") + + // Read the JSON test data file + testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") + testData, err := os.ReadFile(testDataPath) + assert.NoError(err, "should read test data file") + + // Parse JSON to extract the body field + var issueData struct { + Body string `json:"body"` + } + err = json.Unmarshal(testData, &issueData) + assert.NoError(err, "should parse JSON") + + // Call extractFromTemplate + result, err := extractFromTemplate(issueData.Body, string(templateContent)) + assert.NoError(err, "should extract data from template") + + // Output the result + t.Logf("Extracted data:\n") + for key, value := range result { + t.Logf(" %s: %q\n", key, value) + } +} + +func TestExtractFromTitle(t *testing.T) { + assert := require.New(t) + + // Read the JSON test data file + testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") + testData, err := os.ReadFile(testDataPath) + assert.NoError(err, "should read test data file") + + // Parse JSON to extract the title field + var issueData struct { + Title string `json:"title"` + } + err = json.Unmarshal(testData, &issueData) + assert.NoError(err, "should parse JSON") + + // Call extractWithRegex on the title + // orderID, emailAddress, err := extractWithRegex(issueData.Title) + orderID, emailAddress, err := extractWithRegex(issueData.Title) + assert.NoError(err, "should extract data from title") + + // Output the result + t.Logf("Extracted data:\n") + t.Logf(" %s: %q\n", "order id", orderID) + t.Logf(" %s: %q\n", "email address", emailAddress) +} From 90817721a0437f529d5c47d2e599b0e6d1c8a019 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:11:35 -0600 Subject: [PATCH 03/71] feat: create issue template for generating order reports This commit introduces a new issue template used for generating order reports. The template includes sections for shipping and billing information, stocks ordered, strain information, strain storage, plasmid information and storage, and comments. It uses data passed to it to populate the fields. --- internal/app/issue/issue.tmpl | 63 +++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 internal/app/issue/issue.tmpl diff --git a/internal/app/issue/issue.tmpl b/internal/app/issue/issue.tmpl new file mode 100644 index 0000000..bd42d23 --- /dev/null +++ b/internal/app/issue/issue.tmpl @@ -0,0 +1,63 @@ +**Date:** {{.OrderTimestamp}} + +**Order ID:** {{.Order.Data.Id}} + +{{$o := .Order.Data.Attributes}} + +# Shipping and billing information + +| Shipping address | | Billing address | +| -------------------|----|------------------| +| {{with .Shipper.Data.Attributes }} {{.FirstName}} {{.LastName}}
{{.Organization}}
{{.FirstAddress}}
{{.SecondAddress}}
{{.City}} {{.State}} {{.Zipcode}}
{{.Country}}
Phone: {{.Phone}}
{{.Email}}
{{$o.Courier}} {{$o.CourierAccount}} {{- end }} | | {{- with .Payer.Data.Attributes }} {{.FirstName}} {{.LastName}}
{{.Organization}}
{{.FirstAddress}}
{{.SecondAddress}}
{{.City}} {{.State}} {{.Zipcode}}
{{.Country}}
Phone: {{.Phone}}
{{.Email}}
{{$o.Courier}} {{$o.CourierAccount}}
{{$o.Payment}} {{$o.PurchaseOrderNum}} {{- end }} | + +{{if or .StrainInv .PlasmidInv}} +# Stocks ordered + +| Item | Quantity | Unit price($) | Total($) | +|-----------|--------------------|--------------------|--------------------| +| Strain | {{.StrainItems}} | {{.StrainPrice}} | {{.StrainCost}} | +| Plasmid | {{.PlasmidItems}} | {{.PlasmidPrice}} | {{.PlasmidCost}} | +| | | | {{.TotalCost}} | + +{{- end}} + +{{ if .StrainInfo}} +# Strain information + +|ID | Descriptor | Name(s) | Systematic Name | Characteristics | +|-------|---------------|-----------|--------------------|-----------------| +{{- range $idx,$e := .StrainInfo}} +| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | + +{{- end}} +{{end}} + + +{{if .StrainInv}} +# Strain storage + +| Name | Stored as | Location | No. of vials | Color | +|--------|------------|----------|---------------|----------| +{{- range $idx,$e := .StrainInv}} +| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | + +{{- end}} +{{end}} + + + +{{if .PlasmidInv}} +# Plasmid information and storage + +| ID | Name | Stored as | Location | Color | +|-----|-------|-----------|----------|--------| +{{- range $idx,$e := .PlasmidInv}} +| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | + +{{- end}} +{{end}} + +{{if .Order.Data.Attributes.Comments}} +# Comment +{{.Order.Data.Attributes.Comments}} +{{end}} From 93af551080da644f1197a12c1a67a9db0b3b3ac4 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:11:41 -0600 Subject: [PATCH 04/71] feat(issue): refactor issue processing to use IssueProcessor struct The changes introduce an `IssueProcessor` struct to encapsulate issue processing logic, including extracting order data from the issue body. This improves code organization and readability. The `extractOrderData` method is added to parse the issue body and populate the `orderData` field. Additionally, the `getIssueBody` function is added to retrieve the issue body from GitHub using the Issues API. --- internal/app/issue/issue.go | 56 +++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 4ea6469..282689b 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -6,11 +6,9 @@ import ( "fmt" "os" "strconv" - "strings" "time" "github.com/dictyBase-docker/github-actions/internal/client" - htmlparser "github.com/dictyBase-docker/github-actions/internal/html" "github.com/dictyBase-docker/github-actions/internal/logger" "github.com/google/go-github/v62/github" "github.com/urfave/cli" @@ -27,6 +25,28 @@ type OrderData struct { recipientEmail string } +type IssueProcessor struct { + issueBody string + orderData OrderData +} + +// ExtractOrderData parses the issueBody and populates the orderData field +func (ip *IssueProcessor) extractOrderData() error { + // Use extraction logic to parse issueBody + orderID, email, err := extractWithRegex(ip.issueBody) + if err != nil { + return fmt.Errorf("failed to extract order data: %w", err) + } + + // Write to the orderData field + ip.orderData = OrderData{ + orderID: orderID, + recipientEmail: email, + } + + return nil +} + func CommentsCountByDate(clt *cli.Context) error { gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { @@ -180,6 +200,38 @@ func IssueLabelEmail(c *cli.Context) error { return nil } +func getIssueBody(c *cli.Context) (string, error) { + // Get GitHub client + gclient, err := client.GetGithubClient(c.GlobalString("token")) + if err != nil { + return "", fmt.Errorf("error getting github client: %w", err) + } + + // Get issue number from context + issueNumber := c.Int("issue") + if issueNumber == 0 { + return "", fmt.Errorf("issue number is required") + } + + // Get issue using the Issues API + issue, _, err := gclient.Issues.Get( + context.Background(), + c.GlobalString("owner"), + c.GlobalString("repository"), + issueNumber, + ) + if err != nil { + return "", fmt.Errorf("error fetching issue: %w", err) + } + + body := issue.GetBody() + if body == "" { + return "", fmt.Errorf("issue body is empty") + } + + return body, nil +} + func getIssueBodyHTML(c *cli.Context) (string, error) { // Get GitHub client gclient, err := client.GetGithubClient(c.GlobalString("token")) From ec470cff8ad0e3c9cc416896344fa82e7b4a399f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:11:50 -0600 Subject: [PATCH 05/71] feat: add issue.json test data for integration testing The issue.json file contains data representing a GitHub issue. This file will be used for integration testing to ensure that the application correctly processes and interacts with issue data from the GitHub API. --- testdata/issue.json | 88 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 testdata/issue.json diff --git a/testdata/issue.json b/testdata/issue.json new file mode 100644 index 0000000..48166f6 --- /dev/null +++ b/testdata/issue.json @@ -0,0 +1,88 @@ +{ + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193", + "repository_url": "https://api.github.com/repos/dictybase-playground/learn-github-action", + "labels_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/labels{/name}", + "comments_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/comments", + "events_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/events", + "html_url": "https://github.com/dictybase-playground/learn-github-action/issues/193", + "id": 1056621442, + "node_id": "I_kwDODtS60c4--sOC", + "number": 193, + "title": "Order ID:37500885 art@vandelayindustries.com", + "user": { + "login": "dictybasebot", + "id": 17730815, + "node_id": "MDQ6VXNlcjE3NzMwODE1", + "avatar_url": "https://avatars.githubusercontent.com/u/17730815?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/dictybasebot", + "html_url": "https://github.com/dictybasebot", + "followers_url": "https://api.github.com/users/dictybasebot/followers", + "following_url": "https://api.github.com/users/dictybasebot/following{/other_user}", + "gists_url": "https://api.github.com/users/dictybasebot/gists{/gist_id}", + "starred_url": "https://api.github.com/users/dictybasebot/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/dictybasebot/subscriptions", + "organizations_url": "https://api.github.com/users/dictybasebot/orgs", + "repos_url": "https://api.github.com/users/dictybasebot/repos", + "events_url": "https://api.github.com/users/dictybasebot/events{/privacy}", + "received_events_url": "https://api.github.com/users/dictybasebot/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "labels": [ + { + "id": 2110418861, + "node_id": "MDU6TGFiZWwyMTEwNDE4ODYx", + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/labels/Strain", + "name": "Strain", + "color": "ededed", + "default": false, + "description": null + } + ], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [ + + ], + "milestone": null, + "comments": 0, + "created_at": "2021-11-17T20:52:26Z", + "updated_at": "2021-11-17T20:52:26Z", + "closed_at": null, + "author_association": "MEMBER", + "type": null, + "active_lock_reason": null, + "sub_issues_summary": { + "total": 0, + "completed": 0, + "percent_completed": 0 + }, + "issue_dependencies_summary": { + "blocked_by": 0, + "total_blocked_by": 0, + "blocking": 0, + "total_blocking": 0 + }, + "body_html": "

Date: Nov 17, 2021

\n

Order ID: 37500885

\n

Shipping and billing information

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Shipping addressBilling address
Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
credit $N/A
\n

Stocks ordered

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ItemQuantityUnit price($)Total($)
Strain23060
Plasmid0150
60
\n

Strain information

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
IDDescriptorName(s)Systematic NameCharacteristics
DBS0351365HL501/X55DL66
DBS0351365HL501/X55DL66
\n

Strain storage

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
NameStored asLocationNo. of vialsColor
HL501/X55cells4-47(46)2Blue
HL501/X55cells4-47(46)2Blue
", + "body_text": "Date: Nov 17, 2021\nOrder ID: 37500885\nShipping and billing information\n\n\n\nShipping address\n\nBilling address\n\n\n\n\nArt Vandelay Vandelay Industries\t 123 Fake St Chicago IL 60601 United States Phone: 867-5309 art@vandelayindustries.com prepaid sending prepaid shipping label\n\nArt Vandelay Vandelay Industries\t 123 Fake St Chicago IL 60601 United States Phone: 867-5309 art@vandelayindustries.com prepaid sending prepaid shipping label credit $N/A\n\n\n\nStocks ordered\n\n\n\nItem\nQuantity\nUnit price($)\nTotal($)\n\n\n\n\nStrain\n2\n30\n60\n\n\nPlasmid\n0\n15\n0\n\n\n\n\n\n60\n\n\n\nStrain information\n\n\n\nID\nDescriptor\nName(s)\nSystematic Name\nCharacteristics\n\n\n\n\nDBS0351365\nHL501/X55\n\nDL66\n\n\n\nDBS0351365\nHL501/X55\n\nDL66\n\n\n\n\nStrain storage\n\n\n\nName\nStored as\nLocation\nNo. of vials\nColor\n\n\n\n\nHL501/X55\ncells\n4-47(46)\n2\nBlue\n\n\nHL501/X55\ncells\n4-47(46)\n2\nBlue", + "body": "**Date:** Nov 17, 2021 \n\n**Order ID:** 37500885 \n\n\n\n# Shipping and billing information \n\n|\tShipping address |\t | Billing address\t |\n| -------------------|----|------------------|\n| Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label | | Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
credit $N/A |\n\n\n# Stocks ordered\n\n|\tItem\t|\tQuantity \t |\tUnit price($)\t |\tTotal($)\t |\n|-----------|--------------------|--------------------|--------------------|\n|\tStrain\t| 2 | 30 | 60 |\n|\tPlasmid\t| 0 | 15 | 0 |\n|\t\t\t|\t\t\t\t\t |\t\t\t\t\t |\t 60 |\n\n\n# Strain information\n\n|ID\t| Descriptor |\tName(s) |\tSystematic Name |\tCharacteristics |\n|-------|---------------|-----------|--------------------|-----------------|\n| DBS0351365 | HL501/X55 | | DL66 | |\n| DBS0351365 | HL501/X55 | | DL66 | |\n\n\t\n\n\n# Strain storage\n\n|\tName |\tStored as |\tLocation |\tNo. of vials |\tColor |\n|--------|------------|----------|---------------|----------|\n| HL501/X55 | cells | 4-47(46) | 2 | Blue |\n| HL501/X55 | cells | 4-47(46) | 2 | Blue |\n\n\n\n\n\n\n\n", + "closed_by": null, + "reactions": { + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/timeline", + "performed_via_github_app": null, + "state_reason": null +} From db879e7271871b25776ecefe4d5362be2d986efd Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:26:55 -0600 Subject: [PATCH 06/71] feat(extract): add functions to extract order ID and email from body Added functions to extract order ID and billing email from the issue body, including `extractOrderIDFromBody`, `extractBillingEmailFromBody`, and `extractFromBody`. Also renamed `extractOrderID` to `extractOrderIDFromTitle` and `extractEmail` to `extractEmailFromTitle` to clarify their purpose. `extractWithRegex` was renamed to `extractFromTitle`. These functions extract information from the issue body using regular expressions, specifically targeting the order ID and billing email. The new functions allow extracting the order ID and email from the body of the issue, which is necessary when the information is not available in the title. --- internal/app/issue/extract.go | 84 ++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/internal/app/issue/extract.go b/internal/app/issue/extract.go index a4e2ef8..de5929a 100644 --- a/internal/app/issue/extract.go +++ b/internal/app/issue/extract.go @@ -24,7 +24,7 @@ func extractFromTemplate(text, templatePattern string) (map[string]string, error // ExtractOrderID extracts the Order ID from markdown text // Pattern: Order ID: VALUE (with optional ** markdown bold) -func extractOrderID(text string) (string, error) { +func extractOrderIDFromTitle(text string) (string, error) { // Match: Order ID: followed by optional whitespace and the ID value // Handles both "**Order ID:**" and "Order ID:" pattern := `\*?\*?Order ID:\*?\*?\s*(\S+)` @@ -38,7 +38,7 @@ func extractOrderID(text string) (string, error) { return matches[1], nil } -func extractEmail(text string) (string, error) { +func extractEmailFromTitle(text string) (string, error) { // Standard email pattern pattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` re := regexp.MustCompile(pattern) @@ -51,16 +51,90 @@ func extractEmail(text string) (string, error) { return matches[1], nil } -func extractWithRegex(text string) (orderID string, email string, err error) { - orderID, err = extractOrderID(text) +func extractFromTitle(text string) (orderID string, email string, err error) { + orderID, err = extractOrderIDFromTitle(text) if err != nil { return "", "", fmt.Errorf("failed to extract order ID: %w", err) } - email, err = extractEmail(text) + email, err = extractEmailFromTitle(text) if err != nil { return "", "", fmt.Errorf("failed to extract email: %w", err) } return orderID, email, nil } + +// extractOrderIDFromBody extracts the Order ID from the issue body +// Pattern: **Order ID:** 37500885 +func extractOrderIDFromBody(text string) (string, error) { + pattern := `\*\*Order ID:\*\*\s+(\d+)` + re := regexp.MustCompile(pattern) + + matches := re.FindStringSubmatch(text) + if len(matches) < 2 { + return "", fmt.Errorf("order ID not found in body") + } + + return matches[1], nil +} + +// extractBillingEmailFromBody extracts the email from the Billing address column +// The table structure is: | Shipping address | (empty) | Billing address | +// We find all emails and return the second one (billing email) +func extractBillingEmailFromBody(text string) (string, error) { + // Find the billing section by looking for text after "Billing address" + // Then extract email from that section + billingIdx := regexp.MustCompile(`Billing address`).FindStringIndex(text) + if billingIdx == nil { + return "", fmt.Errorf("billing address section not found") + } + + // Get text starting from billing address header + billingSection := text[billingIdx[0]:] + + // Find the table row after the header (skip the separator line) + // Look for a line with | ... | ... | content | + lines := regexp.MustCompile(`\n`).Split(billingSection, -1) + + // Skip first two lines (header and separator), get the data row + if len(lines) < 3 { + return "", fmt.Errorf("billing data row not found") + } + + dataRow := lines[2] + + // Split by | and get the third column (index 2) + columns := regexp.MustCompile(`\|`).Split(dataRow, -1) + if len(columns) < 4 { + return "", fmt.Errorf("insufficient columns in billing row") + } + + billingColumn := columns[3] // Third column is at index 3 (0=empty, 1=shipping, 2=middle, 3=billing) + + // Extract email from billing column + emailPattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` + emailRe := regexp.MustCompile(emailPattern) + + matches := emailRe.FindStringSubmatch(billingColumn) + if len(matches) < 2 { + return "", fmt.Errorf("email not found in billing column") + } + + return matches[1], nil +} + +// extractFromBody extracts both Order ID and billing email from the issue body +func extractFromBody(text string) (orderID string, email string, err error) { + orderID, err = extractOrderIDFromBody(text) + if err != nil { + return "", "", fmt.Errorf("failed to extract order ID: %w", err) + } + + email, err = extractBillingEmailFromBody(text) + if err != nil { + return "", "", fmt.Errorf("failed to extract billing email: %w", err) + } + + return orderID, email, nil +} From 574bce144256c6f7548e9782a10f5820298d4254 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:27:08 -0600 Subject: [PATCH 07/71] feat(issue): add TestExtractFromBody to verify data extraction from body Added a new test function, TestExtractFromBody, to verify the functionality of extracting data from the issue body. This includes reading a test data file, parsing JSON to extract the body, and asserting that the data extraction is successful. --- internal/app/issue/extract_test.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/app/issue/extract_test.go b/internal/app/issue/extract_test.go index 4bb60eb..15226fe 100644 --- a/internal/app/issue/extract_test.go +++ b/internal/app/issue/extract_test.go @@ -57,7 +57,7 @@ func TestExtractFromTitle(t *testing.T) { // Call extractWithRegex on the title // orderID, emailAddress, err := extractWithRegex(issueData.Title) - orderID, emailAddress, err := extractWithRegex(issueData.Title) + orderID, emailAddress, err := extractFromTitle(issueData.Title) assert.NoError(err, "should extract data from title") // Output the result @@ -65,3 +65,28 @@ func TestExtractFromTitle(t *testing.T) { t.Logf(" %s: %q\n", "order id", orderID) t.Logf(" %s: %q\n", "email address", emailAddress) } + +func TestExtractFromBody(t *testing.T) { + assert := require.New(t) + + // Read the JSON test data file + testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") + testData, err := os.ReadFile(testDataPath) + assert.NoError(err, "should read test data file") + + // Parse JSON to extract the body field + var issueData struct { + Body string `json:"body"` + } + err = json.Unmarshal(testData, &issueData) + assert.NoError(err, "should parse JSON") + + // Call extractFromBody on the body + orderID, emailAddress, err := extractFromBody(issueData.Body) + assert.NoError(err, "should extract data from body") + + // Output the result + t.Logf("Extracted data from body:\n") + t.Logf(" %s: %q\n", "order id", orderID) + t.Logf(" %s: %q\n", "billing email", emailAddress) +} From 15865e2083bd7ef7411b6b71aada63eed26a7175 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:28:02 -0600 Subject: [PATCH 08/71] refactor(issue): rename ExtractOrderData to extractOrderData and extractWithRegex to extractFromBody The function names were changed to follow the naming convention for private methods in Go, which are not exported and should start with a lowercase letter. This improves code readability and maintainability by clearly indicating the scope of these functions within the package. --- internal/app/issue/issue.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 282689b..4db00e8 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -30,10 +30,10 @@ type IssueProcessor struct { orderData OrderData } -// ExtractOrderData parses the issueBody and populates the orderData field +// extractOrderData parses the issueBody and populates the orderData field func (ip *IssueProcessor) extractOrderData() error { // Use extraction logic to parse issueBody - orderID, email, err := extractWithRegex(ip.issueBody) + orderID, email, err := extractFromBody(ip.issueBody) if err != nil { return fmt.Errorf("failed to extract order data: %w", err) } From 02849d8f4886def6922a5a1b0f30c7919b3a7b97 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 20 Jan 2026 11:28:10 -0600 Subject: [PATCH 09/71] refactor(issue): simplify URL creation in getIssueBodyHTML function The URL creation in the `getIssueBodyHTML` function is simplified by using the `owner` and `repo` variables. This improves readability and maintainability of the code. --- internal/app/issue/issue.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 4db00e8..54a345a 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -245,13 +245,12 @@ func getIssueBodyHTML(c *cli.Context) (string, error) { return "", fmt.Errorf("issue number is required") } + owner := c.GlobalString("owner") + repo := c.GlobalString("repository") + // Create custom request to get HTML format - url := fmt.Sprintf( - "repos/%s/%s/issues/%d", - c.GlobalString("owner"), - c.GlobalString("repository"), - issueNumber, - ) + // Note: The standard Issues.Get() doesn't support HTML format via Accept headers + url := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) req, err := gclient.NewRequest("GET", url, nil) if err != nil { return "", fmt.Errorf("error creating request: %w", err) From 606d70810d4c5b228e97de38147c3ac932aab3bd Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 08:30:38 -0600 Subject: [PATCH 10/71] refactor: move OrderData and IssueProcessor to extract package The OrderData struct and IssueProcessor struct were moved from the issue package to the extract package to improve code organization and modularity. This change encapsulates data extraction logic within its own package, promoting better separation of concerns. --- internal/app/issue/extract.go | 27 +++++++++++++++++++++++++++ internal/app/issue/issue.go | 27 --------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/internal/app/issue/extract.go b/internal/app/issue/extract.go index de5929a..8671342 100644 --- a/internal/app/issue/extract.go +++ b/internal/app/issue/extract.go @@ -5,6 +5,33 @@ import ( "regexp" ) +type OrderData struct { + orderID string + recipientEmail string +} + +type IssueProcessor struct { + issueBody string + orderData OrderData +} + +// extractOrderData parses the issueBody and populates the orderData field +func (ip *IssueProcessor) extractOrderData() error { + // Use extraction logic to parse issueBody + orderID, email, err := extractFromBody(ip.issueBody) + if err != nil { + return fmt.Errorf("failed to extract order data: %w", err) + } + + // Write to the orderData field + ip.orderData = OrderData{ + orderID: orderID, + recipientEmail: email, + } + + return nil +} + func extractFromTemplate(text, templatePattern string) (map[string]string, error) { // Replace {{.Field}} with named capture groups regex := regexp.MustCompile(`\{\{\.(\w+)\}\}`) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 54a345a..68fd5a4 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -20,33 +20,6 @@ const ( fileLayout = "01-02-2006-150405" ) -type OrderData struct { - orderID string - recipientEmail string -} - -type IssueProcessor struct { - issueBody string - orderData OrderData -} - -// extractOrderData parses the issueBody and populates the orderData field -func (ip *IssueProcessor) extractOrderData() error { - // Use extraction logic to parse issueBody - orderID, email, err := extractFromBody(ip.issueBody) - if err != nil { - return fmt.Errorf("failed to extract order data: %w", err) - } - - // Write to the orderData field - ip.orderData = OrderData{ - orderID: orderID, - recipientEmail: email, - } - - return nil -} - func CommentsCountByDate(clt *cli.Context) error { gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { From fa4c9beee9cae570ecab7d9a5cfc00df54951b33 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:02:10 -0600 Subject: [PATCH 11/71] feat(issue): remove unused extractFromTemplate function The extractFromTemplate function was removed as it was not being used anywhere in the codebase, which reduces code complexity. --- internal/app/issue/extract.go | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/internal/app/issue/extract.go b/internal/app/issue/extract.go index 8671342..013b37f 100644 --- a/internal/app/issue/extract.go +++ b/internal/app/issue/extract.go @@ -32,25 +32,8 @@ func (ip *IssueProcessor) extractOrderData() error { return nil } -func extractFromTemplate(text, templatePattern string) (map[string]string, error) { - // Replace {{.Field}} with named capture groups - regex := regexp.MustCompile(`\{\{\.(\w+)\}\}`) - pattern := regex.ReplaceAllString(templatePattern, `(?P<$1>.+?)`) - - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(text) - - result := make(map[string]string) - for i, name := range re.SubexpNames() { - if i > 0 && i <= len(matches) { - result[name] = matches[i] - } - } - return result, nil -} - // ExtractOrderID extracts the Order ID from markdown text -// Pattern: Order ID: VALUE (with optional ** markdown bold) +// Pattern: Order ID: VALUE (with optional ** markdown bold). func extractOrderIDFromTitle(text string) (string, error) { // Match: Order ID: followed by optional whitespace and the ID value // Handles both "**Order ID:**" and "Order ID:" From b333025d1356dd26664456f18c627f84a52857e9 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:02:33 -0600 Subject: [PATCH 12/71] feat(fake): add route to fetch issue by number This change adds a new route to the fake HTTP server that allows fetching an issue by its number. This is necessary for testing functionality that relies on retrieving specific issues. --- internal/fake/http.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/fake/http.go b/internal/fake/http.go index 26f526e..dad6be6 100644 --- a/internal/fake/http.go +++ b/internal/fake/http.go @@ -49,6 +49,17 @@ func fetchRoute() []*route { `/repos/([^/]+)/([^/]+)/compare/\w+\.\.\.\w+`, )), }, + { + method: "GET", + file: "../../../testdata/issue.json", + fn: handleSuccess, + regexp: regexp.MustCompile( + fmt.Sprintf( + "^%s%s$", + baseURLPath, + `/repos/([^/]+)/([^/]+)/issues/(\d+)`, + )), + }, } } From 0001f88f5bd4642cfc6af9257484ca01b69dec0c Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:02:48 -0600 Subject: [PATCH 13/71] feat(html): add functions to extract billing email and order ID from HTML This commit introduces two new functions: `ExtractBillingEmail` and `ExtractOrderID`. `ExtractBillingEmail` parses HTML to find a table with a "Billing Address" header and extracts the email from that column. `ExtractOrderID` finds a paragraph containing "Order ID" and extracts the order ID value. These functions use regular expressions to reliably extract the required information. --- internal/html/parser.go | 101 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/internal/html/parser.go b/internal/html/parser.go index 2713c99..250acf6 100644 --- a/internal/html/parser.go +++ b/internal/html/parser.go @@ -2,6 +2,7 @@ package html import ( "fmt" + "regexp" "strings" "golang.org/x/net/html" @@ -106,3 +107,103 @@ func getTextContent(n *html.Node) string { } return strings.TrimSpace(text.String()) } + +// ExtractBillingEmail finds a table with "Billing Address" header and extracts the email from that column. +func ExtractBillingEmail(htmlContent string) (string, error) { + tables, err := ParseTables(htmlContent) + if err != nil { + return "", fmt.Errorf("error parsing tables: %w", err) + } + + // Find table with "Billing Address" header + for _, table := range tables { + billingColIndex := -1 + + // Search for "Billing Address" header (case-insensitive) + for i, header := range table.Headers { + if strings.Contains(strings.ToLower(header), "billing") && + strings.Contains(strings.ToLower(header), "address") { + billingColIndex = i + break + } + } + + // If this table has the billing address column + if billingColIndex != -1 { + // Search all rows for an email in the billing address column + for _, row := range table.Rows { + if billingColIndex < len(row) { + email := extractEmailFromText(row[billingColIndex]) + if email != "" { + return email, nil + } + } + } + } + } + + return "", fmt.Errorf("billing address email not found") +} + +// extractEmailFromText extracts an email address from a string using regex. +func extractEmailFromText(text string) string { + emailPattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` + emailRe := regexp.MustCompile(emailPattern) + + matches := emailRe.FindStringSubmatch(text) + if len(matches) >= 2 { + return matches[1] + } + + return "" +} + +// ExtractOrderID finds a paragraph containing "Order ID" and extracts the order ID value. +func ExtractOrderID(htmlContent string) (string, error) { + doc, err := html.Parse(strings.NewReader(htmlContent)) + if err != nil { + return "", fmt.Errorf("error parsing HTML: %w", err) + } + + var orderID string + var findOrderParagraph func(*html.Node) + findOrderParagraph = func(n *html.Node) { + if orderID != "" { + return // Already found + } + + if n.Type == html.ElementNode && n.Data == "p" { + text := getTextContent(n) + extracted := extractOrderIDFromText(text) + if extracted != "" { + orderID = extracted + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + findOrderParagraph(c) + } + } + findOrderParagraph(doc) + + if orderID == "" { + return "", fmt.Errorf("order ID not found in paragraphs") + } + + return orderID, nil +} + +// extractOrderIDFromText extracts an order ID from text using regex. +func extractOrderIDFromText(text string) string { + // Match "Order ID:" followed by optional whitespace and the ID value + // Handles both with and without ** markdown bold + pattern := `Order\s*ID:\s*(\d+)` + re := regexp.MustCompile(pattern) + + matches := re.FindStringSubmatch(text) + if len(matches) >= 2 { + return matches[1] + } + + return "" +} From aae81db6c97333a932e63ff390ef348e7ab7340d Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:03:07 -0600 Subject: [PATCH 14/71] docs(parser.go): refine comments for parseTable, parseTableHead, parseTableBody, and parseTableRow functions The comments for `parseTable`, `parseTableHead`, `parseTableBody`, and `parseTableRow` functions are updated to end with a period for consistency and improved readability. --- internal/html/parser.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/html/parser.go b/internal/html/parser.go index 250acf6..7e6250f 100644 --- a/internal/html/parser.go +++ b/internal/html/parser.go @@ -36,7 +36,7 @@ func ParseTables(htmlContent string) ([]TableData, error) { return tables, nil } -// parseTable extracts data from a single table node +// parseTable extracts data from a single table node. func parseTable(tableNode *html.Node) TableData { var table TableData @@ -64,7 +64,7 @@ func parseTable(tableNode *html.Node) TableData { return table } -// parseTableHead extracts header cells from thead +// parseTableHead extracts header cells from thead. func parseTableHead(theadNode *html.Node) []string { for row := theadNode.FirstChild; row != nil; row = row.NextSibling { if row.Type == html.ElementNode && row.Data == "tr" { @@ -74,7 +74,7 @@ func parseTableHead(theadNode *html.Node) []string { return nil } -// parseTableBody extracts all rows from tbody +// parseTableBody extracts all rows from tbody. func parseTableBody(tbodyNode *html.Node) [][]string { var rows [][]string for row := tbodyNode.FirstChild; row != nil; row = row.NextSibling { @@ -85,7 +85,7 @@ func parseTableBody(tbodyNode *html.Node) [][]string { return rows } -// parseTableRow extracts cells from a table row +// parseTableRow extracts cells from a table row. func parseTableRow(rowNode *html.Node) []string { var cells []string for cell := rowNode.FirstChild; cell != nil; cell = cell.NextSibling { @@ -96,7 +96,7 @@ func parseTableRow(rowNode *html.Node) []string { return cells } -// getTextContent recursively extracts all text from a node +// getTextContent recursively extracts all text from a node. func getTextContent(n *html.Node) string { if n.Type == html.TextNode { return n.Data From ba27215c87b66db66d74d5c6a90bb35e9900266b Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:03:45 -0600 Subject: [PATCH 15/71] feat(html): add parser_test.go to test html parsing functions This commit introduces parser_test.go, which includes tests for parsing tables, extracting billing emails, and extracting order IDs from HTML content. The tests use data from testdata/issue.json to verify the correctness of the parsing functions. --- internal/html/parser_test.go | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 internal/html/parser_test.go diff --git a/internal/html/parser_test.go b/internal/html/parser_test.go new file mode 100644 index 0000000..31b136b --- /dev/null +++ b/internal/html/parser_test.go @@ -0,0 +1,82 @@ +package html + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +type IssueData struct { + BodyHTML string `json:"body_html"` +} + +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 tables from body_html + tables, err := ParseTables(issue.BodyHTML) + assert.NoError(err, "should be able to parse tables from HTML") + + // Verify we extracted 4 tables + assert.Len(tables, 4, "should extract 4 tables from body_html") + + // Verify the table headers + assert.Equal([]string{"Shipping address", "", "Billing address"}, tables[0].Headers, "first table should be shipping and billing information") + assert.Equal([]string{"Item", "Quantity", "Unit price($)", "Total($)"}, tables[1].Headers, "second table should be stocks ordered") + assert.Equal([]string{"ID", "Descriptor", "Name(s)", "Systematic Name", "Characteristics"}, tables[2].Headers, "third table should be strain information") + assert.Equal([]string{"Name", "Stored as", "Location", "No. of vials", "Color"}, tables[3].Headers, "fourth table should be strain storage") +} + +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") + + // Extract billing email + email, err := ExtractBillingEmail(issue.BodyHTML) + assert.NoError(err, "should be able to extract billing email") + assert.Equal("art@vandelayindustries.com", 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") + + // Extract order ID + orderID, err := ExtractOrderID(issue.BodyHTML) + assert.NoError(err, "should be able to extract order ID") + assert.Equal("37500885", orderID, "should extract the correct order ID from paragraph") +} From 97e97b2739d563a6ad334eb5754654948d4699d0 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:04:13 -0600 Subject: [PATCH 16/71] refactor(issue): simplify issue retrieval with fake GitHub server The commit refactors the issue retrieval process by using a fake GitHub server and client for testing. This eliminates the need for multiple test functions with different flag configurations. The new test function, TestGetIssue, sets up a fake server, creates a CLI context with the necessary flags, and calls the getIssue function. It then asserts that the retrieved issue data matches the expected values from the issue.json file. --- internal/app/issue/issue_test.go | 83 ++++++++------------------------ 1 file changed, 21 insertions(+), 62 deletions(-) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index 7103967..87014e2 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -4,80 +4,39 @@ import ( "flag" "testing" + "github.com/dictyBase-docker/github-actions/internal/fake" "github.com/stretchr/testify/require" "github.com/urfave/cli" ) -func TestGetOrderDataFromIssueBodyBothFields(t *testing.T) { +func TestGetIssue(t *testing.T) { t.Parallel() assert := require.New(t) - app := cli.NewApp() - set := flag.NewFlagSet("test", 0) - set.String("order-id", "ORD-12345", "order id") - set.String("email", "user@example.com", "recipient email") - set.Parse([]string{}) - ctx := cli.NewContext(app, set, nil) - - result, err := getOrderDataFromIssueBody(ctx) - assert.NoError(err, "should not return error when both fields are provided") - assert.NotNil(result, "should return a non-nil OrderData object") - assert.Equal("ORD-12345", result.orderID, "should extract order ID from context") - assert.Equal("user@example.com", result.recipientEmail, "should extract email from context") -} - -func TestGetOrderDataFromIssueBodyDifferentValues(t *testing.T) { - t.Parallel() - assert := require.New(t) - app := cli.NewApp() - set := flag.NewFlagSet("test", 0) - set.String("order-id", "ORD-99999", "order id") - set.String("email", "admin@example.com", "recipient email") - set.Parse([]string{}) - ctx := cli.NewContext(app, set, nil) - - result, err := getOrderDataFromIssueBody(ctx) - assert.NoError(err, "should not return error when both fields are provided") - assert.NotNil(result, "should return a non-nil OrderData object") - assert.Equal("ORD-99999", result.orderID, "should extract order ID from context") - assert.Equal("admin@example.com", result.recipientEmail, "should extract email from context") -} - -func TestGetOrderDataFromIssueBodyMissingOrderID(t *testing.T) { - t.Parallel() - assert := require.New(t) - app := cli.NewApp() - set := flag.NewFlagSet("test", 0) - set.String("email", "user@example.com", "recipient email") - set.Parse([]string{}) - ctx := cli.NewContext(app, set, nil) - result, err := getOrderDataFromIssueBody(ctx) - assert.Error(err, "should return error when order-id is missing") - assert.Nil(result, "should return nil when order-id is missing") -} + // Set up fake server and client + server, client := fake.GhServerClient() + defer server.Close() -func TestGetOrderDataFromIssueBodyMissingEmail(t *testing.T) { - t.Parallel() - assert := require.New(t) + // Create CLI context with required flags app := cli.NewApp() set := flag.NewFlagSet("test", 0) - set.String("order-id", "ORD-12345", "order id") + set.String("owner", "dictybase-playground", "repository owner") + set.String("repository", "learn-github-action", "repository name") + set.Int("issue", 193, "issue number") set.Parse([]string{}) - ctx := cli.NewContext(app, set, nil) - - result, err := getOrderDataFromIssueBody(ctx) - assert.Error(err, "should return error when email is missing") - assert.Nil(result, "should return nil when email is missing") -} -func TestGetOrderDataFromIssueBodyMissingBoth(t *testing.T) { - t.Parallel() - assert := require.New(t) - app := cli.NewApp() - set := flag.NewFlagSet("test", 0) ctx := cli.NewContext(app, set, nil) - result, err := getOrderDataFromIssueBody(ctx) - assert.Error(err, "should return error when both fields are missing") - assert.Nil(result, "should return nil when both fields are missing") + // Call getIssue + issue, err := getIssue(client, ctx) + assert.NoError(err, "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(193, issue.GetNumber(), "should have correct issue number") + assert.Equal("Order ID:37500885 art@vandelayindustries.com", 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:** 37500885", "body should contain order ID") + assert.Contains(issue.GetBody(), "Billing address", "body should contain billing address") } From ac53a9283d9c2adbe9463c9794745f0bda302e96 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:05:04 -0600 Subject: [PATCH 17/71] refactor(issue): refactor getIssueBody to accept *github.Issue as argument The getIssueBody function was refactored to accept a *github.Issue as an argument instead of *cli.Context. This change improves modularity and testability by decoupling the function from the CLI context. A new getIssue function was introduced to fetch the issue from GitHub using the client and context, which is then passed to getIssueBody. The IssueLabelEmail function was moved to the end of the file. --- internal/app/issue/issue.go | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 68fd5a4..30fbd83 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -169,21 +169,11 @@ func issueOpts(c *cli.Context) *github.IssueListByRepoOptions { } } -func IssueLabelEmail(c *cli.Context) error { - return nil -} - -func getIssueBody(c *cli.Context) (string, error) { - // Get GitHub client - gclient, err := client.GetGithubClient(c.GlobalString("token")) - if err != nil { - return "", fmt.Errorf("error getting github client: %w", err) - } - +func getIssue(gclient *github.Client, c *cli.Context) (*github.Issue, error) { // Get issue number from context issueNumber := c.Int("issue") if issueNumber == 0 { - return "", fmt.Errorf("issue number is required") + return nil, fmt.Errorf("issue number is required") } // Get issue using the Issues API @@ -194,9 +184,13 @@ func getIssueBody(c *cli.Context) (string, error) { issueNumber, ) if err != nil { - return "", fmt.Errorf("error fetching issue: %w", err) + 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") @@ -204,6 +198,7 @@ func getIssueBody(c *cli.Context) (string, error) { return body, nil } +func IssueLabelEmail(c *cli.Context) error {} func getIssueBodyHTML(c *cli.Context) (string, error) { // Get GitHub client @@ -246,7 +241,3 @@ func getIssueBodyHTML(c *cli.Context) (string, error) { return bodyHTML, nil } - -func getOrderDataFromIssueBody(c *cli.Context) (*OrderData, error) { - return &OrderData{}, nil -} From 828b99c9534e276fbe6582048fd667a1e706cc6a Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:05:18 -0600 Subject: [PATCH 18/71] feat(issue): remove TestExtractFromTemplate as it is no longer needed The TestExtractFromTemplate test was removed because it was no longer relevant to the current functionality. The extractFromTemplate function was refactored, and this test did not align with the updated implementation. --- internal/app/issue/extract_test.go | 31 ------------------------------ 1 file changed, 31 deletions(-) diff --git a/internal/app/issue/extract_test.go b/internal/app/issue/extract_test.go index 15226fe..7bbc372 100644 --- a/internal/app/issue/extract_test.go +++ b/internal/app/issue/extract_test.go @@ -9,37 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestExtractFromTemplate(t *testing.T) { - assert := require.New(t) - - // Read the template file - templatePath := filepath.Join("issue.tmpl") - templateContent, err := os.ReadFile(templatePath) - assert.NoError(err, "should read template file") - - // Read the JSON test data file - testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") - testData, err := os.ReadFile(testDataPath) - assert.NoError(err, "should read test data file") - - // Parse JSON to extract the body field - var issueData struct { - Body string `json:"body"` - } - err = json.Unmarshal(testData, &issueData) - assert.NoError(err, "should parse JSON") - - // Call extractFromTemplate - result, err := extractFromTemplate(issueData.Body, string(templateContent)) - assert.NoError(err, "should extract data from template") - - // Output the result - t.Logf("Extracted data:\n") - for key, value := range result { - t.Logf(" %s: %q\n", key, value) - } -} - func TestExtractFromTitle(t *testing.T) { assert := require.New(t) From 033535ab55bb988dd3669285ea7c7326d63aab9a Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:05:36 -0600 Subject: [PATCH 19/71] test(issue): add assertions to extract tests and remove logs The extract tests now include assertions to validate the extracted order ID and email address, ensuring the extraction logic functions correctly. The logging statements were removed to keep the test output clean and focused on test results. --- internal/app/issue/extract_test.go | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/internal/app/issue/extract_test.go b/internal/app/issue/extract_test.go index 7bbc372..0be7fed 100644 --- a/internal/app/issue/extract_test.go +++ b/internal/app/issue/extract_test.go @@ -24,15 +24,11 @@ func TestExtractFromTitle(t *testing.T) { err = json.Unmarshal(testData, &issueData) assert.NoError(err, "should parse JSON") - // Call extractWithRegex on the title - // orderID, emailAddress, err := extractWithRegex(issueData.Title) + // Call extractFromTitle on the title orderID, emailAddress, err := extractFromTitle(issueData.Title) assert.NoError(err, "should extract data from title") - - // Output the result - t.Logf("Extracted data:\n") - t.Logf(" %s: %q\n", "order id", orderID) - t.Logf(" %s: %q\n", "email address", emailAddress) + assert.Equal("37500885", orderID, "should extract correct order ID from title") + assert.Equal("art@vandelayindustries.com", emailAddress, "should extract correct email address from title") } func TestExtractFromBody(t *testing.T) { @@ -53,9 +49,6 @@ func TestExtractFromBody(t *testing.T) { // Call extractFromBody on the body orderID, emailAddress, err := extractFromBody(issueData.Body) assert.NoError(err, "should extract data from body") - - // Output the result - t.Logf("Extracted data from body:\n") - t.Logf(" %s: %q\n", "order id", orderID) - t.Logf(" %s: %q\n", "billing email", emailAddress) + assert.Equal("37500885", orderID, "should extract correct order ID from body") + assert.Equal("art@vandelayindustries.com", emailAddress, "should extract correct billing email from body") } From 7aa969cd28ee4933d2176a5dbd2bc4de8ff072b1 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:05:47 -0600 Subject: [PATCH 20/71] fix(go.mod): upgrade github.com/yuin/goldmark to v1.7.16 The github.com/yuin/goldmark dependency added to the project for parsing markdown to html. --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 304a978..1808f64 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.32.0 // indirect go.opentelemetry.io/otel/metric v1.32.0 // indirect diff --git a/go.sum b/go.sum index 41202d3..4a3cf58 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= From 79c11e26d004c240aaddf8732b53fd54f8585e81 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:13:09 -0600 Subject: [PATCH 21/71] feat(issue): implement issue labeling and email extraction functionality The changes implement the IssueLabelEmail function, which fetches an issue, converts its markdown body to HTML, extracts the order ID and billing email from the HTML, and logs the extracted data. It uses the htmlparser package for HTML conversion and data extraction. --- internal/app/issue/issue.go | 73 ++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 30fbd83..337687f 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -9,6 +9,7 @@ import ( "time" "github.com/dictyBase-docker/github-actions/internal/client" + htmlparser "github.com/dictyBase-docker/github-actions/internal/html" "github.com/dictyBase-docker/github-actions/internal/logger" "github.com/google/go-github/v62/github" "github.com/urfave/cli" @@ -198,7 +199,77 @@ func getIssueBody(issue *github.Issue) (string, error) { return body, nil } -func IssueLabelEmail(c *cli.Context) error {} + +func IssueLabelEmail(c *cli.Context) error { + // Get GitHub client + gclient, err := client.GetGithubClient(c.GlobalString("token")) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error getting github client: %s", err), + 2, + ) + } + + // Fetch the issue + issue, err := getIssue(gclient, c) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error fetching issue: %s", err), + 2, + ) + } + + // Get the markdown body + markdownBody, err := getIssueBody(issue) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error getting issue body: %s", err), + 2, + ) + } + + // Convert markdown to HTML + htmlBody, err := htmlparser.MarkdownToHTML(markdownBody) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error converting markdown to HTML: %s", err), + 2, + ) + } + + // Extract order ID from HTML + orderID, err := htmlparser.ExtractOrderID(htmlBody) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error extracting order ID: %s", err), + 2, + ) + } + + // Extract billing email from HTML + email, err := htmlparser.ExtractBillingEmail(htmlBody) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error extracting billing email: %s", err), + 2, + ) + } + + // Create OrderData struct + orderData := OrderData{ + orderID: orderID, + recipientEmail: email, + } + + // Log the extracted data + logger.GetLogger(c).Infof( + "Extracted order data - Order ID: %s, Email: %s", + orderData.orderID, + orderData.recipientEmail, + ) + + return nil +} func getIssueBodyHTML(c *cli.Context) (string, error) { // Get GitHub client From 60a23cc5199107370f7e4ef6afe5e3145418d0d5 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 21 Jan 2026 11:27:58 -0600 Subject: [PATCH 22/71] feat(html): add MarkdownToHTML function to convert markdown to HTML The new function uses the goldmark library with GitHub Flavored Markdown extensions to convert markdown text to HTML. This allows the application to render markdown content as HTML. --- internal/html/parser.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/html/parser.go b/internal/html/parser.go index 7e6250f..5569adc 100644 --- a/internal/html/parser.go +++ b/internal/html/parser.go @@ -1,10 +1,13 @@ package html import ( + "bytes" "fmt" "regexp" "strings" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" "golang.org/x/net/html" ) @@ -207,3 +210,17 @@ func extractOrderIDFromText(text string) string { return "" } + +// MarkdownToHTML converts markdown text to HTML using goldmark with GitHub Flavored Markdown extensions. +func MarkdownToHTML(markdown string) (string, error) { + md := goldmark.New( + goldmark.WithExtensions(extension.GFM), + ) + + var buf bytes.Buffer + if err := md.Convert([]byte(markdown), &buf); err != nil { + return "", fmt.Errorf("error converting markdown: %w", err) + } + + return buf.String(), nil +} From af02adaa831dc65a8dce21b6b3165ecba7ab4297 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Mon, 2 Feb 2026 14:46:38 -0600 Subject: [PATCH 23/71] feat: upgrade github client to v62 and remove legacy client The github client has been upgraded to v62 to use the latest features and improvements. The legacy client has been removed to reduce code duplication and simplify the codebase. The GetBranch function in deploy.go now accepts maxRedirects as a parameter. --- go.mod | 5 ++--- go.sum | 10 ---------- internal/app/chatops/deploy.go | 4 +++- internal/app/chatops/deploy_test.go | 3 ++- internal/app/repository/commit.go | 2 +- internal/app/repository/migrate.go | 4 ++-- internal/app/repository/migrate_test.go | 2 +- internal/client/client.go | 10 ---------- internal/fake/github.go | 2 +- internal/fake/http.go | 2 +- internal/github/manager.go | 4 +++- 11 files changed, 16 insertions(+), 32 deletions(-) diff --git a/go.mod b/go.mod index 1808f64..c7a4f94 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ 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/minio/minio-go v6.0.14+incompatible github.com/repeale/fp-go v0.11.1 @@ -12,7 +11,9 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 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 @@ -39,13 +40,11 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/yuin/goldmark v1.7.16 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect go.opentelemetry.io/otel v1.32.0 // indirect 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 4a3cf58..2d5d15f 100644 --- a/go.sum +++ b/go.sum @@ -22,17 +22,13 @@ 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -90,30 +86,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/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/fake/github.go b/internal/fake/github.go index 9dbbc3f..58f22a5 100644 --- a/internal/fake/github.go +++ b/internal/fake/github.go @@ -7,7 +7,7 @@ import ( "os" "path/filepath" - gh "github.com/google/go-github/v32/github" + gh "github.com/google/go-github/v62/github" ) func GithubCommitComparison() (*gh.CommitsComparison, error) { diff --git a/internal/fake/http.go b/internal/fake/http.go index dad6be6..82b84a2 100644 --- a/internal/fake/http.go +++ b/internal/fake/http.go @@ -9,7 +9,7 @@ import ( "path/filepath" "regexp" - gh "github.com/google/go-github/v32/github" + gh "github.com/google/go-github/v62/github" ) const ( diff --git a/internal/github/manager.go b/internal/github/manager.go index d393145..3499d0e 100644 --- a/internal/github/manager.go +++ b/internal/github/manager.go @@ -6,7 +6,7 @@ import ( "fmt" "io" - gh "github.com/google/go-github/v32/github" + gh "github.com/google/go-github/v62/github" ) type CommittedFilesParams struct { @@ -48,6 +48,7 @@ func (g *Manager) CommittedFilesInPull( pev.GetRepo().GetName(), before, after, + nil, ) if err != nil { return bcf, fmt.Errorf("error in comparing commits %s", err) @@ -70,6 +71,7 @@ func (g *Manager) CommittedFilesInPush( pev.GetRepo().GetName(), pev.GetBefore(), pev.GetAfter(), + nil, ) if err != nil { return bfl, fmt.Errorf("error in comparing commits %s", err) From c152d539c81a6f43981f9086f7aa7953eb2b50af Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 09:43:54 -0600 Subject: [PATCH 24/71] feat(html/parser.go): refactor HTML parsing to use html.Node and add order data extraction The ParseTables, ExtractBillingEmail, and ExtractOrderID functions now accept a *html.Node as input instead of an HTML string. The MarkdownToHTML function now returns a *html.Node instead of an HTML string. Added extractOrderData function to extract order data from html node. This change improves the efficiency and flexibility of the HTML parsing logic. --- internal/html/parser.go | 59 ++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/internal/html/parser.go b/internal/html/parser.go index 5569adc..04d76b4 100644 --- a/internal/html/parser.go +++ b/internal/html/parser.go @@ -17,13 +17,13 @@ type TableData struct { Rows [][]string } -// ParseTables extracts all tables from HTML content. -func ParseTables(htmlContent string) ([]TableData, error) { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return nil, fmt.Errorf("error parsing HTML: %w", err) - } +type IssueBodyData struct { + RecipientEmail string + OrderID string +} +// ParseTables extracts all tables from HTML content. +func ParseTables(doc *html.Node) ([]TableData, error) { var tables []TableData var findTables func(*html.Node) findTables = func(n *html.Node) { @@ -112,8 +112,8 @@ func getTextContent(n *html.Node) string { } // ExtractBillingEmail finds a table with "Billing Address" header and extracts the email from that column. -func ExtractBillingEmail(htmlContent string) (string, error) { - tables, err := ParseTables(htmlContent) +func ExtractBillingEmail(doc *html.Node) (string, error) { + tables, err := ParseTables(doc) if err != nil { return "", fmt.Errorf("error parsing tables: %w", err) } @@ -162,28 +162,23 @@ func extractEmailFromText(text string) string { } // ExtractOrderID finds a paragraph containing "Order ID" and extracts the order ID value. -func ExtractOrderID(htmlContent string) (string, error) { - doc, err := html.Parse(strings.NewReader(htmlContent)) - if err != nil { - return "", fmt.Errorf("error parsing HTML: %w", err) - } - +func ExtractOrderID(doc *html.Node) (string, error) { var orderID string var findOrderParagraph func(*html.Node) - findOrderParagraph = func(n *html.Node) { + findOrderParagraph = func(node *html.Node) { if orderID != "" { return // Already found } - if n.Type == html.ElementNode && n.Data == "p" { - text := getTextContent(n) + if node.Type == html.ElementNode && node.Data == "p" { + text := getTextContent(node) extracted := extractOrderIDFromText(text) if extracted != "" { orderID = extracted } } - for c := n.FirstChild; c != nil; c = c.NextSibling { + for c := node.FirstChild; c != nil; c = c.NextSibling { findOrderParagraph(c) } } @@ -212,15 +207,37 @@ func extractOrderIDFromText(text string) string { } // MarkdownToHTML converts markdown text to HTML using goldmark with GitHub Flavored Markdown extensions. -func MarkdownToHTML(markdown string) (string, error) { +func MarkdownToHTML(markdown string) (*html.Node, error) { md := goldmark.New( goldmark.WithExtensions(extension.GFM), ) var buf bytes.Buffer if err := md.Convert([]byte(markdown), &buf); err != nil { - return "", fmt.Errorf("error converting markdown: %w", err) + return nil, fmt.Errorf("error converting markdown: %w", err) + } + + doc, err := html.Parse(strings.NewReader(buf.String())) + if err != nil { + return nil, fmt.Errorf("error parsing HTML: %w", err) + } + + return doc, nil +} + +func extractOrderData(htmlNode *html.Node) (IssueBodyData, error) { + orderID, err := ExtractOrderID(htmlNode) + if err != nil { + return IssueBodyData{}, fmt.Errorf("failed to extract order ID: %w", err) + } + + billingEmail, err := ExtractBillingEmail(htmlNode) + if err != nil { + return IssueBodyData{}, fmt.Errorf("failed to extract billing email: %w", err) } - return buf.String(), nil + return IssueBodyData{ + OrderID: orderID, + RecipientEmail: billingEmail, + }, nil } From 948641db70e5a41ea9f58b822aa583d78e64d81c Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 09:45:31 -0600 Subject: [PATCH 25/71] feat(parser): rename package `html` to `parser` `parser` better reflects the overall purpose of the package. --- internal/{html => parser}/parser.go | 0 internal/{html => parser}/parser_test.go | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename internal/{html => parser}/parser.go (100%) rename internal/{html => parser}/parser_test.go (99%) diff --git a/internal/html/parser.go b/internal/parser/parser.go similarity index 100% rename from internal/html/parser.go rename to internal/parser/parser.go diff --git a/internal/html/parser_test.go b/internal/parser/parser_test.go similarity index 99% rename from internal/html/parser_test.go rename to internal/parser/parser_test.go index 31b136b..277090f 100644 --- a/internal/html/parser_test.go +++ b/internal/parser/parser_test.go @@ -1,4 +1,4 @@ -package html +package parser import ( "encoding/json" From 24beed3eaa1412168350595999d0b327e59da70f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 09:51:10 -0600 Subject: [PATCH 26/71] refactor: rename html package to parser for better clarity The package was renamed from `html` to `parser` to more accurately reflect its purpose, which is to parse HTML rather than represent the entire HTML structure. This change improves code readability and maintainability by providing a more descriptive name. --- internal/parser/parser.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 04d76b4..914a4d0 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -1,4 +1,4 @@ -package html +package parser import ( "bytes" From 554467d6cddebd025682daa198d978364960fd16 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 09:51:19 -0600 Subject: [PATCH 27/71] refactor(issue): rename htmlparser package to parser and use email package to send order update The htmlparser package was renamed to parser to better reflect its purpose. The email sending logic was moved to a separate email package for better organization and reusability. The IssueLabelEmail function now uses the email package to send order update emails. --- internal/app/issue/issue.go | 90 +++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 44 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 337687f..6384153 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -9,8 +9,8 @@ import ( "time" "github.com/dictyBase-docker/github-actions/internal/client" - htmlparser "github.com/dictyBase-docker/github-actions/internal/html" "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/urfave/cli" ) @@ -229,7 +229,7 @@ func IssueLabelEmail(c *cli.Context) error { } // Convert markdown to HTML - htmlBody, err := htmlparser.MarkdownToHTML(markdownBody) + htmlBody, err := parser.MarkdownToHTML(markdownBody) if err != nil { return cli.NewExitError( fmt.Sprintf("error converting markdown to HTML: %s", err), @@ -238,7 +238,7 @@ func IssueLabelEmail(c *cli.Context) error { } // Extract order ID from HTML - orderID, err := htmlparser.ExtractOrderID(htmlBody) + orderID, err := parser.ExtractOrderID(htmlBody) if err != nil { return cli.NewExitError( fmt.Sprintf("error extracting order ID: %s", err), @@ -247,7 +247,7 @@ func IssueLabelEmail(c *cli.Context) error { } // Extract billing email from HTML - email, err := htmlparser.ExtractBillingEmail(htmlBody) + email, err := parser.ExtractBillingEmail(htmlBody) if err != nil { return cli.NewExitError( fmt.Sprintf("error extracting billing email: %s", err), @@ -257,58 +257,60 @@ func IssueLabelEmail(c *cli.Context) error { // Create OrderData struct orderData := OrderData{ - orderID: orderID, - recipientEmail: email, + OrderID: orderID, + RecipientEmail: email, + Label: c.String("label"), + IssueID: c.String("issueid"), + User: "Customer", // Default greeting, could extract from issue if needed } + log := logger.GetLogger(c) + // Log the extracted data - logger.GetLogger(c).Infof( - "Extracted order data - Order ID: %s, Email: %s", - orderData.orderID, - orderData.recipientEmail, + log.Infof( + "Extracted order data - Order ID: %s, Email: %s, Label: %s", + orderData.OrderID, + orderData.RecipientEmail, + orderData.Label, ) - return nil -} - -func getIssueBodyHTML(c *cli.Context) (string, error) { - // Get GitHub client - gclient, err := client.GetGithubClient(c.GlobalString("token")) - if err != nil { - return "", fmt.Errorf("error getting github client: %w", err) - } + // Get Mailgun configuration from flags + domain := c.String("domain") + apiKey := c.String("apiKey") - // Get issue number from context - issueNumber := c.Int("issue") - if issueNumber == 0 { - return "", fmt.Errorf("issue number is required") + if domain == "" || apiKey == "" { + return cli.NewExitError( + "Mailgun domain and apiKey are required", + 2, + ) } - owner := c.GlobalString("owner") - repo := c.GlobalString("repository") + // Create email client + fromEmail := "dictystocks@northwestern.edu" // Default sender + emailClient := email.NewEmailClient(domain, apiKey, fromEmail) - // Create custom request to get HTML format - // Note: The standard Issues.Get() doesn't support HTML format via Accept headers - url := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) - req, err := gclient.NewRequest("GET", url, nil) - if err != nil { - return "", fmt.Errorf("error creating request: %w", err) + // Prepare email data + emailData := email.OrderEmailData{ + IssueID: orderData.IssueID, + OrderID: orderData.OrderID, + User: orderData.User, + Label: orderData.Label, } - // Set Accept header to get HTML format - req.Header.Set("Accept", "application/vnd.github.html+json") - - var issue github.Issue - _, err = gclient.Do(context.Background(), req, &issue) - if err != nil { - return "", fmt.Errorf("error fetching issue: %w", err) + // Send the email + ctx := context.Background() + if err := emailClient.SendOrderUpdateFromTemplate(ctx, orderData.RecipientEmail, emailData); err != nil { + return cli.NewExitError( + fmt.Sprintf("error sending email: %s", err), + 2, + ) } - // When Accept header is set to html+json, Body field contains HTML - bodyHTML := issue.GetBody() - if bodyHTML == "" { - return "", fmt.Errorf("issue body is empty") - } + log.Infof( + "Successfully sent order update email to %s for order #%s", + orderData.RecipientEmail, + orderData.OrderID, + ) - return bodyHTML, nil + return nil } From 1e21cd0870f63af23ab3d19035c0349f3264c172 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:01:24 -0600 Subject: [PATCH 28/71] feat: add order update email template for sending order status updates The new email template is added to send order status updates to users. The template includes basic HTML styling and placeholders for order ID and status label. --- internal/email/order_update.tmpl | 160 +++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 internal/email/order_update.tmpl diff --git a/internal/email/order_update.tmpl b/internal/email/order_update.tmpl new file mode 100644 index 0000000..c5217ac --- /dev/null +++ b/internal/email/order_update.tmpl @@ -0,0 +1,160 @@ + + + + + + + Alerts e.g. approaching your limit + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ Dicty Stock Center +
+ + +

+ Order Update # {{.OrderID}} +

+ +
+ + + + + +
+

Your order status: {{.Label}}

+

Please let us know if you have any questions.

+ Best regards,
+ The DSC Team
+ + dictystocks@northwestern.edu +
+

+
+
+
+
+ + + From 91d7d827416fa7971b3729f6fadcf8adea27fe45 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:01:48 -0600 Subject: [PATCH 29/71] feat(email): implement email sending functionality using Mailgun This commit introduces the functionality to send emails using Mailgun. It includes the creation of an EmailClient struct, configuration settings, template rendering, and functions for sending order update emails. The implementation supports sending emails from a template and directly with HTML content. --- internal/email/email.go | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 internal/email/email.go diff --git a/internal/email/email.go b/internal/email/email.go new file mode 100644 index 0000000..37d324f --- /dev/null +++ b/internal/email/email.go @@ -0,0 +1,122 @@ +package email + +import ( + "bytes" + "context" + "fmt" + "html/template" + "path/filepath" + "time" + + "github.com/mailgun/mailgun-go/v4" +) + +// OrderEmailData represents the data structure for order update emails. +type OrderEmailData struct { + RecipientEmail string + OrderID string + Label string +} + +// MailgunConfig holds Mailgun configuration. +type MailgunConfig struct { + Domain string + APIKey string + From string // Sender email address +} + +// EmailClient wraps the Mailgun client. +type EmailClient struct { + mg *mailgun.MailgunImpl + config MailgunConfig +} + +// NewEmailClient creates a new email client with Mailgun configuration +func NewEmailClient(domain, apiKey, from string) *EmailClient { + mg := mailgun.NewMailgun(domain, apiKey) + return &EmailClient{ + mg: mg, + config: MailgunConfig{ + Domain: domain, + APIKey: apiKey, + From: from, + }, + } +} + +// RenderTemplate renders the order update email template with provided data +func RenderTemplate(templatePath string, data OrderEmailData) (string, error) { + // Parse the template file + tmpl, err := template.ParseFiles(templatePath) + if err != nil { + return "", fmt.Errorf("failed to parse template %s: %w", templatePath, 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 +} + +// SendOrderUpdateEmail sends an order update email to the recipient +func (ec *EmailClient) SendOrderUpdateEmail( + ctx context.Context, + recipient string, + subject string, + htmlBody string, +) error { + // Create a new message + message := ec.mg.NewMessage( + 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() + + resp, id, err := ec.mg.Send(sendCtx, message) + if err != nil { + return fmt.Errorf("failed to send email via Mailgun: %w", err) + } + + // Log success (could use logger here if needed) + _ = resp // Response body + _ = id // Message ID + + return nil +} + +// SendOrderUpdateFromTemplate sends an order update email using the template +func (ec *EmailClient) SendOrderUpdateFromTemplate( + ctx context.Context, + recipient string, + data OrderEmailData, +) error { + // Get the template path (relative to the email package) + templatePath := filepath.Join("internal", "email", "order_update.tmpl") + + // Render the template + htmlBody, err := RenderTemplate(templatePath, data) + if err != nil { + return fmt.Errorf("failed to render email template: %w", err) + } + + // Create subject line + subject := fmt.Sprintf("Dicty Stock Center - Order Update #%s", data.OrderID) + + // Send the email + if err := ec.SendOrderUpdateEmail(ctx, recipient, subject, htmlBody); err != nil { + return fmt.Errorf("failed to send order update email: %w", err) + } + + return nil +} From 19373e6573d74f95512a8a365e089db4a32dae4a Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:26:18 -0600 Subject: [PATCH 30/71] refactor(email): use mailgun package function for new message creation The change replaces the method call `ec.mg.NewMessage` with the package function `mailgun.NewMessage` to create a new message. This aligns with the intended usage of the Mailgun package and removes the dependency on the EmailClient's Mailgun instance for message creation. Additionally, the method `message.SetHtml` is replaced with `message.SetHTML` to align with the mailgun library. --- internal/email/email.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 37d324f..0357a28 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -31,7 +31,7 @@ type EmailClient struct { config MailgunConfig } -// NewEmailClient creates a new email client with Mailgun configuration +// NewEmailClient creates a new email client with Mailgun configuration. func NewEmailClient(domain, apiKey, from string) *EmailClient { mg := mailgun.NewMailgun(domain, apiKey) return &EmailClient{ @@ -44,7 +44,7 @@ func NewEmailClient(domain, apiKey, from string) *EmailClient { } } -// RenderTemplate renders the order update email template with provided data +// RenderTemplate renders the order update email template with provided data. func RenderTemplate(templatePath string, data OrderEmailData) (string, error) { // Parse the template file tmpl, err := template.ParseFiles(templatePath) @@ -61,15 +61,15 @@ func RenderTemplate(templatePath string, data OrderEmailData) (string, error) { return buf.String(), nil } -// SendOrderUpdateEmail sends an order update email to the recipient +// SendOrderUpdateEmail sends an order update email to the recipient. func (ec *EmailClient) SendOrderUpdateEmail( ctx context.Context, recipient string, subject string, htmlBody string, ) error { - // Create a new message - message := ec.mg.NewMessage( + // Create a new message using the package function instead of method + message := mailgun.NewMessage( ec.config.From, subject, "", // Plain text body (empty, using HTML only) @@ -77,25 +77,25 @@ func (ec *EmailClient) SendOrderUpdateEmail( ) // Set HTML body - message.SetHtml(htmlBody) + message.SetHTML(htmlBody) // Send the message with a 10 second timeout sendCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - resp, id, err := ec.mg.Send(sendCtx, message) + resp, messageID, err := ec.mg.Send(sendCtx, message) if err != nil { return fmt.Errorf("failed to send email via Mailgun: %w", err) } // Log success (could use logger here if needed) - _ = resp // Response body - _ = id // Message ID + _ = resp // Response body + _ = messageID // Message ID return nil } -// SendOrderUpdateFromTemplate sends an order update email using the template +// SendOrderUpdateFromTemplate sends an order update email using the template. func (ec *EmailClient) SendOrderUpdateFromTemplate( ctx context.Context, recipient string, From 757743f53bf619d765ae4eb984a8858d1bcb7afa Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:49:17 -0600 Subject: [PATCH 31/71] fix(issue): rename issue flag to issueid to avoid conflicts The flag name "issue" was too generic and could potentially conflict with other flags or variables. Renaming it to "issueid" makes it more specific and reduces the risk of naming collisions. --- internal/app/issue/issue_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index 87014e2..b21db65 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -22,7 +22,7 @@ func TestGetIssue(t *testing.T) { set := flag.NewFlagSet("test", 0) set.String("owner", "dictybase-playground", "repository owner") set.String("repository", "learn-github-action", "repository name") - set.Int("issue", 193, "issue number") + set.Int("issueid", 193, "issue number") set.Parse([]string{}) ctx := cli.NewContext(app, set, nil) From 7042fef7e8eb7ef6fba8b8c43df443937158b461 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:51:34 -0600 Subject: [PATCH 32/71] feat(parser): rename extractOrderData to ExtractOrderData and add comment The function name `extractOrderData` is changed to `ExtractOrderData` to follow golang's convention for exported functions. A comment is added to improve readability and documentation. --- internal/parser/parser.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 914a4d0..a37d121 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -225,7 +225,8 @@ func MarkdownToHTML(markdown string) (*html.Node, error) { return doc, nil } -func extractOrderData(htmlNode *html.Node) (IssueBodyData, error) { +// ExtractOrderData extracts order ID and billing email from HTML and returns structured data +func ExtractOrderData(htmlNode *html.Node) (IssueBodyData, error) { orderID, err := ExtractOrderID(htmlNode) if err != nil { return IssueBodyData{}, fmt.Errorf("failed to extract order ID: %w", err) From 24a00f2531d928d6b93b9d32ace2886e5dbc733c Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 10:58:39 -0600 Subject: [PATCH 33/71] refactor(email): rename EmailClient to MailgunClient and inline template rendering The EmailClient struct and its methods are renamed to MailgunClient to better reflect its dependency on Mailgun. The RenderTemplate function was inlined into the SendOrderUpdateFromTemplate function to reduce the number of exported functions and improve code locality. --- internal/email/email.go | 43 +++++++++++++++-------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 0357a28..47e5a8f 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -25,16 +25,16 @@ type MailgunConfig struct { From string // Sender email address } -// EmailClient wraps the Mailgun client. -type EmailClient struct { +// MailgunClient wraps the Mailgun client. +type MailgunClient struct { mg *mailgun.MailgunImpl config MailgunConfig } // NewEmailClient creates a new email client with Mailgun configuration. -func NewEmailClient(domain, apiKey, from string) *EmailClient { +func NewEmailClient(domain, apiKey, from string) *MailgunClient { mg := mailgun.NewMailgun(domain, apiKey) - return &EmailClient{ + return &MailgunClient{ mg: mg, config: MailgunConfig{ Domain: domain, @@ -44,25 +44,8 @@ func NewEmailClient(domain, apiKey, from string) *EmailClient { } } -// RenderTemplate renders the order update email template with provided data. -func RenderTemplate(templatePath string, data OrderEmailData) (string, error) { - // Parse the template file - tmpl, err := template.ParseFiles(templatePath) - if err != nil { - return "", fmt.Errorf("failed to parse template %s: %w", templatePath, 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 -} - // SendOrderUpdateEmail sends an order update email to the recipient. -func (ec *EmailClient) SendOrderUpdateEmail( +func (ec *MailgunClient) SendOrderUpdateEmail( ctx context.Context, recipient string, subject string, @@ -96,7 +79,7 @@ func (ec *EmailClient) SendOrderUpdateEmail( } // SendOrderUpdateFromTemplate sends an order update email using the template. -func (ec *EmailClient) SendOrderUpdateFromTemplate( +func (ec *MailgunClient) SendOrderUpdateFromTemplate( ctx context.Context, recipient string, data OrderEmailData, @@ -104,17 +87,23 @@ func (ec *EmailClient) SendOrderUpdateFromTemplate( // Get the template path (relative to the email package) templatePath := filepath.Join("internal", "email", "order_update.tmpl") - // Render the template - htmlBody, err := RenderTemplate(templatePath, data) + // Parse the template file + tmpl, err := template.ParseFiles(templatePath) if err != nil { - return fmt.Errorf("failed to render email template: %w", err) + return fmt.Errorf("failed to parse template %s: %w", templatePath, 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) } // Create subject line subject := fmt.Sprintf("Dicty Stock Center - Order Update #%s", data.OrderID) // Send the email - if err := ec.SendOrderUpdateEmail(ctx, recipient, subject, htmlBody); err != nil { + if err := ec.SendOrderUpdateEmail(ctx, recipient, subject, buf.String()); err != nil { return fmt.Errorf("failed to send order update email: %w", err) } From 9f95e1d25989a948afeefeae72396b0689e9da7a Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:00:28 -0600 Subject: [PATCH 34/71] feat: remove issue extraction logic and tests The issue extraction logic is no longer needed, so the files have been removed. --- internal/app/issue/extract.go | 150 ----------------------------- internal/app/issue/extract_test.go | 54 ----------- 2 files changed, 204 deletions(-) delete mode 100644 internal/app/issue/extract.go delete mode 100644 internal/app/issue/extract_test.go diff --git a/internal/app/issue/extract.go b/internal/app/issue/extract.go deleted file mode 100644 index 013b37f..0000000 --- a/internal/app/issue/extract.go +++ /dev/null @@ -1,150 +0,0 @@ -package issue - -import ( - "fmt" - "regexp" -) - -type OrderData struct { - orderID string - recipientEmail string -} - -type IssueProcessor struct { - issueBody string - orderData OrderData -} - -// extractOrderData parses the issueBody and populates the orderData field -func (ip *IssueProcessor) extractOrderData() error { - // Use extraction logic to parse issueBody - orderID, email, err := extractFromBody(ip.issueBody) - if err != nil { - return fmt.Errorf("failed to extract order data: %w", err) - } - - // Write to the orderData field - ip.orderData = OrderData{ - orderID: orderID, - recipientEmail: email, - } - - return nil -} - -// ExtractOrderID extracts the Order ID from markdown text -// Pattern: Order ID: VALUE (with optional ** markdown bold). -func extractOrderIDFromTitle(text string) (string, error) { - // Match: Order ID: followed by optional whitespace and the ID value - // Handles both "**Order ID:**" and "Order ID:" - pattern := `\*?\*?Order ID:\*?\*?\s*(\S+)` - re := regexp.MustCompile(pattern) - - matches := re.FindStringSubmatch(text) - if len(matches) < 2 { - return "", fmt.Errorf("order ID not found") - } - - return matches[1], nil -} - -func extractEmailFromTitle(text string) (string, error) { - // Standard email pattern - pattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` - re := regexp.MustCompile(pattern) - - matches := re.FindStringSubmatch(text) - if len(matches) < 2 { - return "", fmt.Errorf("email not found") - } - - return matches[1], nil -} - -func extractFromTitle(text string) (orderID string, email string, err error) { - orderID, err = extractOrderIDFromTitle(text) - if err != nil { - return "", "", fmt.Errorf("failed to extract order ID: %w", err) - } - - email, err = extractEmailFromTitle(text) - if err != nil { - return "", "", fmt.Errorf("failed to extract email: %w", err) - } - - return orderID, email, nil -} - -// extractOrderIDFromBody extracts the Order ID from the issue body -// Pattern: **Order ID:** 37500885 -func extractOrderIDFromBody(text string) (string, error) { - pattern := `\*\*Order ID:\*\*\s+(\d+)` - re := regexp.MustCompile(pattern) - - matches := re.FindStringSubmatch(text) - if len(matches) < 2 { - return "", fmt.Errorf("order ID not found in body") - } - - return matches[1], nil -} - -// extractBillingEmailFromBody extracts the email from the Billing address column -// The table structure is: | Shipping address | (empty) | Billing address | -// We find all emails and return the second one (billing email) -func extractBillingEmailFromBody(text string) (string, error) { - // Find the billing section by looking for text after "Billing address" - // Then extract email from that section - billingIdx := regexp.MustCompile(`Billing address`).FindStringIndex(text) - if billingIdx == nil { - return "", fmt.Errorf("billing address section not found") - } - - // Get text starting from billing address header - billingSection := text[billingIdx[0]:] - - // Find the table row after the header (skip the separator line) - // Look for a line with | ... | ... | content | - lines := regexp.MustCompile(`\n`).Split(billingSection, -1) - - // Skip first two lines (header and separator), get the data row - if len(lines) < 3 { - return "", fmt.Errorf("billing data row not found") - } - - dataRow := lines[2] - - // Split by | and get the third column (index 2) - columns := regexp.MustCompile(`\|`).Split(dataRow, -1) - if len(columns) < 4 { - return "", fmt.Errorf("insufficient columns in billing row") - } - - billingColumn := columns[3] // Third column is at index 3 (0=empty, 1=shipping, 2=middle, 3=billing) - - // Extract email from billing column - emailPattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` - emailRe := regexp.MustCompile(emailPattern) - - matches := emailRe.FindStringSubmatch(billingColumn) - if len(matches) < 2 { - return "", fmt.Errorf("email not found in billing column") - } - - return matches[1], nil -} - -// extractFromBody extracts both Order ID and billing email from the issue body -func extractFromBody(text string) (orderID string, email string, err error) { - orderID, err = extractOrderIDFromBody(text) - if err != nil { - return "", "", fmt.Errorf("failed to extract order ID: %w", err) - } - - email, err = extractBillingEmailFromBody(text) - if err != nil { - return "", "", fmt.Errorf("failed to extract billing email: %w", err) - } - - return orderID, email, nil -} diff --git a/internal/app/issue/extract_test.go b/internal/app/issue/extract_test.go deleted file mode 100644 index 0be7fed..0000000 --- a/internal/app/issue/extract_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package issue - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestExtractFromTitle(t *testing.T) { - assert := require.New(t) - - // Read the JSON test data file - testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") - testData, err := os.ReadFile(testDataPath) - assert.NoError(err, "should read test data file") - - // Parse JSON to extract the title field - var issueData struct { - Title string `json:"title"` - } - err = json.Unmarshal(testData, &issueData) - assert.NoError(err, "should parse JSON") - - // Call extractFromTitle on the title - orderID, emailAddress, err := extractFromTitle(issueData.Title) - assert.NoError(err, "should extract data from title") - assert.Equal("37500885", orderID, "should extract correct order ID from title") - assert.Equal("art@vandelayindustries.com", emailAddress, "should extract correct email address from title") -} - -func TestExtractFromBody(t *testing.T) { - assert := require.New(t) - - // Read the JSON test data file - testDataPath := filepath.Join("..", "..", "..", "testdata", "issue.json") - testData, err := os.ReadFile(testDataPath) - assert.NoError(err, "should read test data file") - - // Parse JSON to extract the body field - var issueData struct { - Body string `json:"body"` - } - err = json.Unmarshal(testData, &issueData) - assert.NoError(err, "should parse JSON") - - // Call extractFromBody on the body - orderID, emailAddress, err := extractFromBody(issueData.Body) - assert.NoError(err, "should extract data from body") - assert.Equal("37500885", orderID, "should extract correct order ID from body") - assert.Equal("art@vandelayindustries.com", emailAddress, "should extract correct billing email from body") -} From b073e0b8eba3d10b3a925c749766508020182d62 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:01:03 -0600 Subject: [PATCH 35/71] feat(issue): refactor issue label email to use new parser and email package The issue label email functionality has been refactored to use the new parser and email packages. The changes include extracting order data using the new parser, converting the extracted data to the email data structure, and sending the email using the new email package. The issueid flag is now used instead of issue. --- internal/app/issue/issue.go | 63 ++++++++++--------------------------- 1 file changed, 17 insertions(+), 46 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 6384153..8d183a0 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -9,6 +9,7 @@ import ( "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" @@ -172,7 +173,7 @@ func issueOpts(c *cli.Context) *github.IssueListByRepoOptions { func getIssue(gclient *github.Client, c *cli.Context) (*github.Issue, error) { // Get issue number from context - issueNumber := c.Int("issue") + issueNumber := c.Int("issueid") if issueNumber == 0 { return nil, fmt.Errorf("issue number is required") } @@ -228,8 +229,8 @@ func IssueLabelEmail(c *cli.Context) error { ) } - // Convert markdown to HTML - htmlBody, err := parser.MarkdownToHTML(markdownBody) + // Convert markdown to HTML node + htmlNode, err := parser.MarkdownToHTML(markdownBody) if err != nil { return cli.NewExitError( fmt.Sprintf("error converting markdown to HTML: %s", err), @@ -237,42 +238,25 @@ func IssueLabelEmail(c *cli.Context) error { ) } - // Extract order ID from HTML - orderID, err := parser.ExtractOrderID(htmlBody) + // Extract order data using parser + issueData, err := parser.ExtractOrderData(htmlNode) if err != nil { return cli.NewExitError( - fmt.Sprintf("error extracting order ID: %s", err), + fmt.Sprintf("error extracting order data: %s", err), 2, ) } - // Extract billing email from HTML - email, err := parser.ExtractBillingEmail(htmlBody) - if err != nil { - return cli.NewExitError( - fmt.Sprintf("error extracting billing email: %s", err), - 2, - ) - } - - // Create OrderData struct - orderData := OrderData{ - OrderID: orderID, - RecipientEmail: email, + // Convert to email data structure + emailData := email.OrderEmailData{ + RecipientEmail: issueData.RecipientEmail, + OrderID: issueData.OrderID, Label: c.String("label"), - IssueID: c.String("issueid"), - User: "Customer", // Default greeting, could extract from issue if needed } log := logger.GetLogger(c) - - // Log the extracted data - log.Infof( - "Extracted order data - Order ID: %s, Email: %s, Label: %s", - orderData.OrderID, - orderData.RecipientEmail, - orderData.Label, - ) + log.Infof("Extracted: Order=%s, Email=%s, Label=%s", + emailData.OrderID, emailData.RecipientEmail, emailData.Label) // Get Mailgun configuration from flags domain := c.String("domain") @@ -285,32 +269,19 @@ func IssueLabelEmail(c *cli.Context) error { ) } - // Create email client - fromEmail := "dictystocks@northwestern.edu" // Default sender + // Create email client and send email + fromEmail := "dictystocks@northwestern.edu" emailClient := email.NewEmailClient(domain, apiKey, fromEmail) - // Prepare email data - emailData := email.OrderEmailData{ - IssueID: orderData.IssueID, - OrderID: orderData.OrderID, - User: orderData.User, - Label: orderData.Label, - } - - // Send the email ctx := context.Background() - if err := emailClient.SendOrderUpdateFromTemplate(ctx, orderData.RecipientEmail, emailData); err != nil { + if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData.RecipientEmail, emailData); err != nil { return cli.NewExitError( fmt.Sprintf("error sending email: %s", err), 2, ) } - log.Infof( - "Successfully sent order update email to %s for order #%s", - orderData.RecipientEmail, - orderData.OrderID, - ) + log.Infof("Sent email to %s for order %s", emailData.RecipientEmail, emailData.OrderID) return nil } From 4ad521195000c7300022cf3dd97fe34c38d8b200 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:02:19 -0600 Subject: [PATCH 36/71] docs(parser.go): refine ExtractOrderData godoc to improve clarity The godoc for ExtractOrderData is updated to be more clear and concise by adding a period at the end of the sentence. --- internal/parser/parser.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index a37d121..b63292f 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -225,7 +225,7 @@ func MarkdownToHTML(markdown string) (*html.Node, error) { return doc, nil } -// ExtractOrderData extracts order ID and billing email from HTML and returns structured data +// ExtractOrderData extracts order ID and billing email from HTML and returns structured data. func ExtractOrderData(htmlNode *html.Node) (IssueBodyData, error) { orderID, err := ExtractOrderID(htmlNode) if err != nil { From 71974d0d2d2da6b8d5db0d2a3dd5fd8c87e367ce Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:09:50 -0600 Subject: [PATCH 37/71] refactor(issue): rename context variable 'c' to 'clt' for clarity The variable 'c' of type *cli.Context has been renamed to 'clt' to improve readability and reduce ambiguity, as 'clt' more clearly suggests that it represents a CLI context. The function name `IssueLabelEmail` is changed to `SendIssueLabelEmail` to better reflect the function's purpose. --- internal/app/issue/issue.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 8d183a0..ffe8966 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -171,9 +171,9 @@ func issueOpts(c *cli.Context) *github.IssueListByRepoOptions { } } -func getIssue(gclient *github.Client, c *cli.Context) (*github.Issue, error) { +func getIssue(gclient *github.Client, clt *cli.Context) (*github.Issue, error) { // Get issue number from context - issueNumber := c.Int("issueid") + issueNumber := clt.Int("issueid") if issueNumber == 0 { return nil, fmt.Errorf("issue number is required") } @@ -181,8 +181,8 @@ func getIssue(gclient *github.Client, c *cli.Context) (*github.Issue, error) { // Get issue using the Issues API issue, _, err := gclient.Issues.Get( context.Background(), - c.GlobalString("owner"), - c.GlobalString("repository"), + clt.GlobalString("owner"), + clt.GlobalString("repository"), issueNumber, ) if err != nil { @@ -201,9 +201,9 @@ func getIssueBody(issue *github.Issue) (string, error) { return body, nil } -func IssueLabelEmail(c *cli.Context) error { +func SendIssueLabelEmail(clt *cli.Context) error { // Get GitHub client - gclient, err := client.GetGithubClient(c.GlobalString("token")) + gclient, err := client.GetGithubClient(clt.GlobalString("token")) if err != nil { return cli.NewExitError( fmt.Sprintf("error getting github client: %s", err), @@ -212,7 +212,7 @@ func IssueLabelEmail(c *cli.Context) error { } // Fetch the issue - issue, err := getIssue(gclient, c) + issue, err := getIssue(gclient, clt) if err != nil { return cli.NewExitError( fmt.Sprintf("error fetching issue: %s", err), @@ -251,16 +251,16 @@ func IssueLabelEmail(c *cli.Context) error { emailData := email.OrderEmailData{ RecipientEmail: issueData.RecipientEmail, OrderID: issueData.OrderID, - Label: c.String("label"), + Label: clt.String("label"), } - log := logger.GetLogger(c) + log := logger.GetLogger(clt) log.Infof("Extracted: Order=%s, Email=%s, Label=%s", emailData.OrderID, emailData.RecipientEmail, emailData.Label) // Get Mailgun configuration from flags - domain := c.String("domain") - apiKey := c.String("apiKey") + domain := clt.String("domain") + apiKey := clt.String("apiKey") if domain == "" || apiKey == "" { return cli.NewExitError( From 45a20d0b165202bcc7c4e36b7c678593b1fe83e6 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:15:24 -0600 Subject: [PATCH 38/71] build(go.mod): update indirect dependencies to their latest versions The go.mod file has been updated to include the latest versions of indirect dependencies. This ensures that the project uses the most up-to-date versions of its dependencies, which can include bug fixes, performance improvements, and new features. --- go.mod | 6 ++++++ go.sum | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/go.mod b/go.mod index c7a4f94..f846185 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect github.com/go-ini/ini v1.66.6 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -35,8 +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/mailgun/errors v0.4.0 // indirect + github.com/mailgun/mailgun-go/v4 v4.23.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 2d5d15f..5c6b968 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.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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= @@ -31,6 +33,7 @@ github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwM github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= 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= @@ -39,14 +42,24 @@ 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/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8= +github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0= +github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk= +github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU= 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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -63,6 +76,7 @@ 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= From ec99e614723c250274f7ddccc1169c36f5bcc56c Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:18:17 -0600 Subject: [PATCH 39/71] refactor(parser_test.go): parse HTML string to *html.Node for parsing The ParseTables, ExtractBillingEmail, and ExtractOrderID functions now accept *html.Node instead of a raw HTML string. This change improves the parsing process by using a structured HTML representation, ensuring more robust and accurate data extraction. The HTML string is parsed into an *html.Node using html.Parse from the "golang.org/x/net/html" package before being passed to these functions. --- internal/parser/parser_test.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 277090f..26ba2b2 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -4,9 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" + "golang.org/x/net/html" ) type IssueData struct { @@ -27,8 +29,12 @@ func TestParseTables(t *testing.T) { err = json.Unmarshal(data, &issue) assert.NoError(err, "should be able to parse JSON") - // Parse tables from body_html - tables, err := ParseTables(issue.BodyHTML) + // 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 4 tables @@ -55,8 +61,12 @@ func TestExtractBillingEmail(t *testing.T) { 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(issue.BodyHTML) + email, err := ExtractBillingEmail(doc) assert.NoError(err, "should be able to extract billing email") assert.Equal("art@vandelayindustries.com", email, "should extract the correct email from billing address column") } @@ -75,8 +85,12 @@ func TestExtractOrderID(t *testing.T) { 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(issue.BodyHTML) + orderID, err := ExtractOrderID(doc) assert.NoError(err, "should be able to extract order ID") assert.Equal("37500885", orderID, "should extract the correct order ID from paragraph") } From a120f99af6922c7a43534e40440a0a12c988e863 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 3 Feb 2026 11:22:17 -0600 Subject: [PATCH 40/71] feat(email): add email client tests to verify configuration This commit introduces a new test file for the email client, ensuring that the client is properly configured with the provided domain, API key, and sender email address. The tests use testify/require to assert that the client and its Mailgun instance are not nil and that the configuration values match the expected values. --- internal/email/email_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 internal/email/email_test.go diff --git a/internal/email/email_test.go b/internal/email/email_test.go new file mode 100644 index 0000000..4828b30 --- /dev/null +++ b/internal/email/email_test.go @@ -0,0 +1,24 @@ +package email + +import ( + "testing" + + "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, apiKey, fromEmail) + + 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(apiKey, client.config.APIKey, "API key should match") + assert.Equal(fromEmail, client.config.From, "from email should match") +} From 8a1437bf4792f0189ee163694196fb207b54a135 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 10:36:18 -0600 Subject: [PATCH 41/71] refactor(email): create createEmailHTML function to generate email HTML The createEmailHTML function encapsulates the logic for parsing the template file and executing the template with data. This improves code readability and maintainability by separating the HTML generation from the email sending process. It also makes it easier to test the HTML generation logic in isolation. --- internal/email/email.go | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 47e5a8f..a6c699a 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -78,32 +78,40 @@ func (ec *MailgunClient) SendOrderUpdateEmail( return nil } -// SendOrderUpdateFromTemplate sends an order update email using the template. -func (ec *MailgunClient) SendOrderUpdateFromTemplate( - ctx context.Context, - recipient string, - data OrderEmailData, -) error { - // Get the template path (relative to the email package) +func createEmailHTML(data OrderEmailData) (string, error) { templatePath := filepath.Join("internal", "email", "order_update.tmpl") // Parse the template file tmpl, err := template.ParseFiles(templatePath) if err != nil { - return fmt.Errorf("failed to parse template %s: %w", templatePath, err) + return "", fmt.Errorf("failed to parse template %s: %w", templatePath, 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 "", 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, + recipient string, + 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, recipient, subject, buf.String()); err != nil { + if err := ec.SendOrderUpdateEmail(ctx, recipient, subject, html); err != nil { return fmt.Errorf("failed to send order update email: %w", err) } From a2d5188df1498f99fd12cd18192c13053c3a444b Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 17:40:42 -0600 Subject: [PATCH 42/71] feat(email): upgrade mailgun-go to v5 and use embedded templates This commit upgrades the mailgun-go library to v5, updates the Mailgun client initialization, and uses embedded templates for email content. The Mailgun client initialization now uses the API key directly, and the domain is passed to NewMessage. The email template is now embedded using `go:embed`, eliminating the need for file system access. --- internal/email/email.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index a6c699a..436328c 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -3,14 +3,17 @@ package email import ( "bytes" "context" + "embed" "fmt" "html/template" - "path/filepath" "time" - "github.com/mailgun/mailgun-go/v4" + "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 @@ -27,13 +30,13 @@ type MailgunConfig struct { // MailgunClient wraps the Mailgun client. type MailgunClient struct { - mg *mailgun.MailgunImpl + mg *mailgun.Client config MailgunConfig } // NewEmailClient creates a new email client with Mailgun configuration. func NewEmailClient(domain, apiKey, from string) *MailgunClient { - mg := mailgun.NewMailgun(domain, apiKey) + mg := mailgun.NewMailgun(apiKey) return &MailgunClient{ mg: mg, config: MailgunConfig{ @@ -51,8 +54,9 @@ func (ec *MailgunClient) SendOrderUpdateEmail( subject string, htmlBody string, ) error { - // Create a new message using the package function instead of method + // 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) @@ -66,25 +70,22 @@ func (ec *MailgunClient) SendOrderUpdateEmail( sendCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - resp, messageID, err := ec.mg.Send(sendCtx, message) + resp, err := ec.mg.Send(sendCtx, message) if err != nil { return fmt.Errorf("failed to send email via Mailgun: %w", err) } // Log success (could use logger here if needed) - _ = resp // Response body - _ = messageID // Message ID + _ = resp // Response contains ID and message return nil } func createEmailHTML(data OrderEmailData) (string, error) { - templatePath := filepath.Join("internal", "email", "order_update.tmpl") - - // Parse the template file - tmpl, err := template.ParseFiles(templatePath) + // Parse the embedded template file + tmpl, err := template.ParseFS(templateFS, "order_update.tmpl") if err != nil { - return "", fmt.Errorf("failed to parse template %s: %w", templatePath, err) + return "", fmt.Errorf("failed to parse template: %w", err) } // Execute template with data From 30e39e96a70b9b27cba04589376d9c441eeaafe0 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 17:40:57 -0600 Subject: [PATCH 43/71] feat: upgrade mailgun-go/v5 and testify, remove go-chi/chi/v5 The mailgun-go library was upgraded to v5.13.1 to use the latest features and fixes. The testify library was upgraded to v1.11.1 to use the latest features and fixes. The go-chi/chi/v5 library was removed because it is no longer needed. --- go.mod | 9 ++++----- go.sum | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/go.mod b/go.mod index f846185..e6fe79a 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.22 require ( github.com/Jeffail/gabs/v2 v2.7.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 @@ -27,7 +28,6 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-chi/chi/v5 v5.2.1 // indirect github.com/go-ini/ini v1.66.6 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -38,12 +38,11 @@ require ( 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/mailgun/errors v0.4.0 // indirect - github.com/mailgun/mailgun-go/v4 v4.23.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // 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 diff --git a/go.sum b/go.sum index 5c6b968..16137d0 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +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.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +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= @@ -48,20 +48,21 @@ 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/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8= -github.com/mailgun/errors v0.4.0/go.mod h1:xGBaaKdEdQT0/FhwvoXv4oBaqqmVZz9P1XEnvD/onc0= -github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk= -github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU= +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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 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= @@ -82,8 +83,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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= From d431c0a18528d6bc5118b950c25c18759aa910ae Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 18:24:18 -0600 Subject: [PATCH 44/71] feat(.golangci.yml): configure golangci-lint with specific linters and settings The commit configures golangci-lint with a defined set of linters, settings, and exclusions to enforce code quality standards. It enables specific linters, configures settings for errcheck, funlen, and lll, and defines exclusions for generated code, presets, and paths. Additionally, it enables gofmt and goimports formatters with similar exclusions. --- .golangci.yml | 58 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 24 deletions(-) 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$ From 5814648c138cf4f1a3646f41383895426b09f5d0 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 18:51:37 -0600 Subject: [PATCH 45/71] refactor(issue): rename IssueLabelEmail to SendIssueLabelEmail for clarity The function name `IssueLabelEmail` was ambiguous, so it was renamed to `SendIssueLabelEmail` to clearly indicate its purpose of sending an email when a label is added to an issue. --- internal/cmd/issue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index 8a97336..86d0592 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -45,7 +45,7 @@ func IssueLabelEmailCmds() 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.IssueLabelEmail, + Action: issue.SendIssueLabelEmail, Flags: []cli.Flag{ cli.StringFlag{ Name: "label", From ea3ccac405bc2e307eb8c08185480016abb58e87 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 18:51:46 -0600 Subject: [PATCH 46/71] refactor: use io.Writer interface return value to avoid potential errors The return values of the `WriteRune` and `WriteString` methods of the `strings.Builder` type, which implement the `io.Writer` interface, are now being ignored. This change ensures that potential errors during write operations are properly handled, improving the reliability of the application. --- internal/app/dagger/dagger.go | 2 +- internal/parser/parser.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/parser/parser.go b/internal/parser/parser.go index b63292f..baaf98a 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -106,7 +106,7 @@ func getTextContent(n *html.Node) string { } var text strings.Builder for c := n.FirstChild; c != nil; c = c.NextSibling { - text.WriteString(getTextContent(c)) + _, _ = text.WriteString(getTextContent(c)) } return strings.TrimSpace(text.String()) } From 634bbeed0cf0a0f055bbcd9927796b45857b8481 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Tue, 17 Feb 2026 20:29:47 -0600 Subject: [PATCH 47/71] test(issue): add error check for flag parsing in TestGetIssue The test now checks for errors during flag parsing, ensuring that the test setup is correct before proceeding with the issue retrieval. This prevents unexpected behavior due to incorrect flag configurations. --- internal/app/issue/issue_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index b21db65..cf48c16 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -23,13 +23,14 @@ func TestGetIssue(t *testing.T) { set.String("owner", "dictybase-playground", "repository owner") set.String("repository", "learn-github-action", "repository name") set.Int("issueid", 193, "issue number") - set.Parse([]string{}) + err := set.Parse([]string{}) + assert.NoError(err, "should parse flags without error") ctx := cli.NewContext(app, set, nil) // Call getIssue - issue, err := getIssue(client, ctx) - assert.NoError(err, "should not return error when fetching issue") + 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 From e890abf5ed1dddc2187a214f4508a8e2d2958120 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 09:19:17 -0600 Subject: [PATCH 48/71] fix(lint.yaml): update golangci-lint-action version to v2.7.1 The golangci-lint-action version is updated to the latest v2.7.1 to ensure that the linter uses the most recent rules and checks, improving code quality and consistency. --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4bf830b..efefdb2 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,4 +13,4 @@ jobs: - name: run linter uses: golangci/golangci-lint-action@v6 with: - version: v1.58.2 + version: v2.7.1 From 8f020d1ff3161c29eb1ac221ac6a53de31792b61 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 09:20:20 -0600 Subject: [PATCH 49/71] feat(lint.yaml): upgrade golangci-lint-action to v7 The golangci-lint-action is upgraded from v6 to v7 to take advantage of the latest improvements and bug fixes in the linter. --- .github/workflows/lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index efefdb2..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: v2.7.1 From ed6030d76a5484bfd4eef4c2b95d610ae0a10916 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 09:27:59 -0600 Subject: [PATCH 50/71] feat(issue): add flag for specifying the sender email address The sender email address is now configurable via a command-line flag, enhancing the flexibility of the email sending functionality. The fromEmail flag allows users to specify the email address from which the issue label emails are sent. --- internal/app/issue/issue.go | 2 +- internal/cmd/issue.go | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index ffe8966..94cf072 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -261,6 +261,7 @@ func SendIssueLabelEmail(clt *cli.Context) error { // Get Mailgun configuration from flags domain := clt.String("domain") apiKey := clt.String("apiKey") + fromEmail := clt.String("fromEmail") if domain == "" || apiKey == "" { return cli.NewExitError( @@ -270,7 +271,6 @@ func SendIssueLabelEmail(clt *cli.Context) error { } // Create email client and send email - fromEmail := "dictystocks@northwestern.edu" emailClient := email.NewEmailClient(domain, apiKey, fromEmail) ctx := context.Background() diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index 86d0592..d774d73 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -63,6 +63,10 @@ func IssueLabelEmailCmds() cli.Command { Name: "apiKey", Usage: "API key for mailgun", }, + cli.StringFlag{ + Name: "fromEmail", + Usage: "Email address of the sender", + }, }, } } From 4dd110d12570b799c6ab2b67701eb5ddc77c0f7d Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 09:45:28 -0600 Subject: [PATCH 51/71] fix(issue): add timeout to email sending to prevent indefinite delays The email sending process now includes a timeout to prevent the application from being indefinitely delayed if the email service is unresponsive. A context with a 30-second timeout is created and passed to the SendOrderUpdateFromTemplate function. The defer cancel() ensures that the context is always cancelled, freeing resources. --- internal/app/issue/issue.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 94cf072..90d6ba3 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -273,7 +273,9 @@ func SendIssueLabelEmail(clt *cli.Context) error { // Create email client and send email emailClient := email.NewEmailClient(domain, apiKey, fromEmail) - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData.RecipientEmail, emailData); err != nil { return cli.NewExitError( fmt.Sprintf("error sending email: %s", err), From a156b2b2c66573c930c1bb307295e93aa6536a94 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 09:56:21 -0600 Subject: [PATCH 52/71] refactor(issue): extract issue fetching and parsing into a function The function `fetchAndParseIssue` was created to encapsulate the logic of fetching the issue and parsing the markdown body into an HTML node. This improves code readability and maintainability by reducing the size and complexity of the `SendIssueLabelEmail` function. It also promotes code reuse, as this logic can be used in other parts of the application if needed. --- internal/app/issue/issue.go | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 90d6ba3..e1b8560 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -14,6 +14,7 @@ import ( parser "github.com/dictyBase-docker/github-actions/internal/parser" "github.com/google/go-github/v62/github" "github.com/urfave/cli" + "golang.org/x/net/html" ) const ( @@ -201,20 +202,11 @@ func getIssueBody(issue *github.Issue) (string, error) { return body, nil } -func SendIssueLabelEmail(clt *cli.Context) error { - // Get GitHub client - gclient, err := client.GetGithubClient(clt.GlobalString("token")) - if err != nil { - return cli.NewExitError( - fmt.Sprintf("error getting github client: %s", err), - 2, - ) - } - +func fetchAndParseIssue(gclient *github.Client, clt *cli.Context) (*html.Node, error) { // Fetch the issue issue, err := getIssue(gclient, clt) if err != nil { - return cli.NewExitError( + return nil, cli.NewExitError( fmt.Sprintf("error fetching issue: %s", err), 2, ) @@ -223,7 +215,7 @@ func SendIssueLabelEmail(clt *cli.Context) error { // Get the markdown body markdownBody, err := getIssueBody(issue) if err != nil { - return cli.NewExitError( + return nil, cli.NewExitError( fmt.Sprintf("error getting issue body: %s", err), 2, ) @@ -232,12 +224,30 @@ func SendIssueLabelEmail(clt *cli.Context) error { // Convert markdown to HTML node htmlNode, err := parser.MarkdownToHTML(markdownBody) if err != nil { - return cli.NewExitError( + return nil, cli.NewExitError( fmt.Sprintf("error converting markdown to HTML: %s", err), 2, ) } + return htmlNode, nil +} + +func SendIssueLabelEmail(clt *cli.Context) error { + // Get GitHub client + gclient, err := client.GetGithubClient(clt.GlobalString("token")) + if err != nil { + return cli.NewExitError( + fmt.Sprintf("error getting github client: %s", err), + 2, + ) + } + + htmlNode, err := fetchAndParseIssue(gclient, clt) + if err != nil { + return err + } + // Extract order data using parser issueData, err := parser.ExtractOrderData(htmlNode) if err != nil { From a085c583513cc0ace79f31c51b6796d6f2e9203f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:00:47 -0600 Subject: [PATCH 53/71] refactor(issue): move github client initialization to fetchAndParseIssue The github client initialization was moved inside the `fetchAndParseIssue` function to encapsulate the logic of retrieving the client. The `SendIssueLabelEmail` function now calls `fetchAndParseIssue` without needing to pass the github client. Also added validation for recipient email and order ID. --- internal/app/issue/issue.go | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index e1b8560..f865302 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -202,7 +202,16 @@ func getIssueBody(issue *github.Issue) (string, error) { return body, nil } -func fetchAndParseIssue(gclient *github.Client, clt *cli.Context) (*html.Node, error) { +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 { @@ -234,16 +243,7 @@ func fetchAndParseIssue(gclient *github.Client, clt *cli.Context) (*html.Node, e } func SendIssueLabelEmail(clt *cli.Context) error { - // Get GitHub client - gclient, err := client.GetGithubClient(clt.GlobalString("token")) - if err != nil { - return cli.NewExitError( - fmt.Sprintf("error getting github client: %s", err), - 2, - ) - } - - htmlNode, err := fetchAndParseIssue(gclient, clt) + htmlNode, err := fetchAndParseIssue(clt) if err != nil { return err } @@ -257,6 +257,14 @@ func SendIssueLabelEmail(clt *cli.Context) error { ) } + // Validate extracted data + if issueData.RecipientEmail == "" { + return cli.NewExitError("no recipient email found in issue", 2) + } + if issueData.OrderID == "" { + return cli.NewExitError("no order ID found in issue", 2) + } + // Convert to email data structure emailData := email.OrderEmailData{ RecipientEmail: issueData.RecipientEmail, From eb518f58933a2325795439a0c9eb18d2ec3d4970 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:22:46 -0600 Subject: [PATCH 54/71] fix(issue): change issueid flag type from StringFlag to IntFlag The issueid flag was incorrectly defined as a StringFlag, but it represents the ID of an issue, which is an integer. This commit corrects the flag type to IntFlag to ensure that the issue ID is properly parsed and handled as an integer. --- internal/cmd/issue.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index d774d73..36c8cd8 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -51,7 +51,7 @@ func IssueLabelEmailCmds() cli.Command { Name: "label", Usage: "The label that was added to the issue", }, - cli.StringFlag{ + cli.IntFlag{ Name: "issueid", Usage: "The id of the issue", }, From 8ef5a63428916cc3b916f6edb5e681f15bfb9e14 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:23:10 -0600 Subject: [PATCH 55/71] refactor(issue): enhance logging in SendIssueLabelEmail function The logging in the SendIssueLabelEmail function was refactored to use structured logging with fields for order ID, recipient email, and label. This provides more context and makes it easier to search and analyze logs. Additionally, error logging was added to capture failures in sending emails, and success logging was added to confirm successful email dispatches. --- internal/app/issue/issue.go | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index f865302..b0d5aaa 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -273,8 +273,11 @@ func SendIssueLabelEmail(clt *cli.Context) error { } log := logger.GetLogger(clt) - log.Infof("Extracted: Order=%s, Email=%s, Label=%s", - emailData.OrderID, emailData.RecipientEmail, emailData.Label) + 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") @@ -295,13 +298,24 @@ func SendIssueLabelEmail(clt *cli.Context) error { defer cancel() if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData.RecipientEmail, 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.Infof("Sent email to %s for order %s", emailData.RecipientEmail, emailData.OrderID) + 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 } From a0f40b98d03c37dfd0b181721806d7fa7db61df7 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:23:19 -0600 Subject: [PATCH 56/71] refactor(parser): precompile email and order ID regex patterns The email and order ID regex patterns are now precompiled and stored as global variables. This improves performance by avoiding recompilation of the same regex patterns each time the extraction functions are called. --- internal/parser/parser.go | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index baaf98a..5441b07 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -22,6 +22,11 @@ type IssueBodyData struct { OrderID string } +var ( + emailRe = regexp.MustCompile(`([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})`) + orderIDRe = regexp.MustCompile(`Order\s*ID:\s*(\d+)`) +) + // ParseTables extracts all tables from HTML content. func ParseTables(doc *html.Node) ([]TableData, error) { var tables []TableData @@ -150,9 +155,6 @@ func ExtractBillingEmail(doc *html.Node) (string, error) { // extractEmailFromText extracts an email address from a string using regex. func extractEmailFromText(text string) string { - emailPattern := `([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})` - emailRe := regexp.MustCompile(emailPattern) - matches := emailRe.FindStringSubmatch(text) if len(matches) >= 2 { return matches[1] @@ -193,12 +195,7 @@ func ExtractOrderID(doc *html.Node) (string, error) { // extractOrderIDFromText extracts an order ID from text using regex. func extractOrderIDFromText(text string) string { - // Match "Order ID:" followed by optional whitespace and the ID value - // Handles both with and without ** markdown bold - pattern := `Order\s*ID:\s*(\d+)` - re := regexp.MustCompile(pattern) - - matches := re.FindStringSubmatch(text) + matches := orderIDRe.FindStringSubmatch(text) if len(matches) >= 2 { return matches[1] } From eba042a6da8ac7eb8c5729d791847cbd32a99bd1 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:34:55 -0600 Subject: [PATCH 57/71] feat(parser): add MarkdownToHTML function and tests for markdown parsing This commit introduces the `MarkdownToHTML` function, which converts markdown to HTML using the `github.com/yuin/goldmark` library. Comprehensive test cases are included to verify correct parsing of various markdown elements such as paragraphs, headers, bold text, GFM tables, and links. The tests ensure that the generated HTML contains the expected elements and attributes. --- internal/parser/parser_test.go | 120 +++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 26ba2b2..ea62435 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -15,6 +15,81 @@ type IssueData struct { BodyHTML string `json:"body_html"` } +var markdownToHTMLTests = []struct { + name string + markdown string + wantErr bool + containsHTML []string + notContainsHTML []string +}{ + { + name: "basic paragraph", + markdown: "This is a paragraph.", + wantErr: false, + containsHTML: []string{ + "

This is a paragraph.

", + }, + }, + { + name: "headers", + markdown: "# Header 1\n## Header 2", + wantErr: false, + containsHTML: []string{ + "

Header 1

", + "

Header 2

", + }, + }, + { + name: "bold text", + markdown: "**Order ID:** 12345", + wantErr: false, + containsHTML: []string{ + "Order ID:", + "12345", + }, + }, + { + name: "GFM table", + markdown: `| Column 1 | Column 2 | +|----------|----------| +| Value 1 | Value 2 |`, + wantErr: false, + containsHTML: []string{ + "", + "", + "", + "", + "", + "", + "", + }, + }, + { + 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) @@ -94,3 +169,48 @@ func TestExtractOrderID(t *testing.T) { assert.NoError(err, "should be able to extract order ID") assert.Equal("37500885", 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) + }) + } +} From b8b1cc479185c3e409347b59b7892053d59b1337 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 10:42:01 -0600 Subject: [PATCH 58/71] feat(issue): add test cases for getIssueBody function to improve coverage This commit adds new test cases for the getIssueBody function, including tests for valid, empty, nil, whitespace-only, and multiline bodies. This increases test coverage and ensures the function handles various scenarios correctly, especially edge cases like empty or nil issue bodies. --- internal/app/issue/issue_test.go | 72 ++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index cf48c16..7f83c29 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/dictyBase-docker/github-actions/internal/fake" + "github.com/google/go-github/v62/github" "github.com/stretchr/testify/require" "github.com/urfave/cli" ) @@ -41,3 +42,74 @@ func TestGetIssue(t *testing.T) { assert.Contains(issue.GetBody(), "**Order ID:** 37500885", "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") + }) + } +} From ceac8ccdda56e3444b90c5486249ca2a6408cdb9 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 11:01:01 -0600 Subject: [PATCH 59/71] refactor(issue): extract and validate order data into a separate function This commit refactors the `SendIssueLabelEmail` function to extract and validate order data using a new function `extractAndValidateOrderData`. This improves code readability and maintainability by separating the data extraction and validation logic from the main function. Additionally, unit tests were added to ensure the new function works as expected. --- internal/app/issue/issue.go | 43 ++++++------- internal/app/issue/issue_test.go | 105 +++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 21 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index b0d5aaa..85e9dde 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -202,6 +202,26 @@ func getIssueBody(issue *github.Issue) (string, error) { 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, + }, nil +} + func fetchAndParseIssue(clt *cli.Context) (*html.Node, error) { // Get GitHub client gclient, err := client.GetGithubClient(clt.GlobalString("token")) @@ -248,28 +268,9 @@ func SendIssueLabelEmail(clt *cli.Context) error { return err } - // Extract order data using parser - issueData, err := parser.ExtractOrderData(htmlNode) + emailData, err := extractAndValidateOrderData(htmlNode, clt.String("label")) if err != nil { - return cli.NewExitError( - fmt.Sprintf("error extracting order data: %s", err), - 2, - ) - } - - // Validate extracted data - if issueData.RecipientEmail == "" { - return cli.NewExitError("no recipient email found in issue", 2) - } - if issueData.OrderID == "" { - return cli.NewExitError("no order ID found in issue", 2) - } - - // Convert to email data structure - emailData := email.OrderEmailData{ - RecipientEmail: issueData.RecipientEmail, - OrderID: issueData.OrderID, - Label: clt.String("label"), + return cli.NewExitError(err.Error(), 2) } log := logger.GetLogger(clt) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index 7f83c29..71d9348 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -5,6 +5,7 @@ import ( "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" @@ -113,3 +114,107 @@ func TestGetIssueBody(t *testing.T) { }) } } + +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") + }) + } +} From 79723ac3f409e5dd1f262d24ebee5efd9813522c Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 11:15:36 -0600 Subject: [PATCH 60/71] feat(email): add tests for email template and HTML creation This commit introduces tests to validate the email template fields against the OrderEmailData struct and to verify the HTML creation process for order update emails. The tests ensure that all template fields exist in the struct and that the generated HTML contains the expected content and structure. --- internal/email/email_test.go | 129 +++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/internal/email/email_test.go b/internal/email/email_test.go index 4828b30..0c54e48 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -1,6 +1,9 @@ package email import ( + "os" + "reflect" + "regexp" "testing" "github.com/stretchr/testify/require" @@ -22,3 +25,129 @@ func TestNewEmailClient(t *testing.T) { assert.Equal(apiKey, client.config.APIKey, "API key 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") + + // Extract all template field references ({{.FieldName}}) + fieldPatestCaseern := regexp.MustCompile(`\{\{\.(\w+)\}\}`) + matches := fieldPatestCaseern.FindAllStringSubmatch(string(templateContent), -1) + + // Collect unique field names used in template + templateFields := make(map[string]bool) + for _, match := range matches { + if len(match) > 1 { + 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 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 +} + +func TestCreateEmailHTML(t *testing.T) { + t.Parallel() + + tests := []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 Update # ORD-12345", + "Your order 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 Update # ORD-99999", + "Your order status: processing", + }, + }, + { + name: "empty order data", + data: OrderEmailData{ + RecipientEmail: "", + OrderID: "", + Label: "", + }, + wantErr: false, + contains: []string{ + "Order Update # ", + "Your order status: ", + }, + }, + } + + for _, testCase := range tests { + 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") + }) + } +} From bfbbf6de020d5b2a3b81e9970e04f6389af3f61d Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 11:55:48 -0600 Subject: [PATCH 61/71] refactor(email): remove unused resp variable from SendOrderUpdateEmail The resp variable was assigned the response from the Mailgun API but was never used, so it has been removed to clean up the code. --- internal/email/email.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 436328c..08a3780 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -70,14 +70,11 @@ func (ec *MailgunClient) SendOrderUpdateEmail( sendCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - resp, err := ec.mg.Send(sendCtx, message) + _, err := ec.mg.Send(sendCtx, message) if err != nil { return fmt.Errorf("failed to send email via Mailgun: %w", err) } - // Log success (could use logger here if needed) - _ = resp // Response contains ID and message - return nil } From a8d868fb69781fb7af3d4e79bab122a9b79a62b4 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 12:00:58 -0600 Subject: [PATCH 62/71] feat(email): remove API key from Mailgun config struct The API key is no longer stored in the MailgunConfig struct, as it is not needed there. The Mailgun client is initialized with the API key directly, so storing it in the config struct is redundant. --- internal/email/email.go | 2 -- internal/email/email_test.go | 1 - 2 files changed, 3 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 08a3780..3a7decc 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -24,7 +24,6 @@ type OrderEmailData struct { // MailgunConfig holds Mailgun configuration. type MailgunConfig struct { Domain string - APIKey string From string // Sender email address } @@ -41,7 +40,6 @@ func NewEmailClient(domain, apiKey, from string) *MailgunClient { mg: mg, config: MailgunConfig{ Domain: domain, - APIKey: apiKey, From: from, }, } diff --git a/internal/email/email_test.go b/internal/email/email_test.go index 0c54e48..2e62c0b 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -22,7 +22,6 @@ func TestNewEmailClient(t *testing.T) { 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(apiKey, client.config.APIKey, "API key should match") assert.Equal(fromEmail, client.config.From, "from email should match") } From c02de0551b11f3fb851bfcdc31c641748fbd8fea Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 12:02:52 -0600 Subject: [PATCH 63/71] fix(email): correct the order of arguments in NewEmailClient function The arguments in the NewEmailClient function were in the wrong order, with the apiKey being passed before the fromEmail. This commit corrects the order to match the expected order of arguments. --- internal/email/email.go | 2 +- internal/email/email_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/email/email.go b/internal/email/email.go index 3a7decc..84bd5ae 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -34,7 +34,7 @@ type MailgunClient struct { } // NewEmailClient creates a new email client with Mailgun configuration. -func NewEmailClient(domain, apiKey, from string) *MailgunClient { +func NewEmailClient(domain, from, apiKey string) *MailgunClient { mg := mailgun.NewMailgun(apiKey) return &MailgunClient{ mg: mg, diff --git a/internal/email/email_test.go b/internal/email/email_test.go index 2e62c0b..de67003 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -17,7 +17,7 @@ func TestNewEmailClient(t *testing.T) { apiKey := "test-api-key-123" fromEmail := "test@example.com" - client := NewEmailClient(domain, apiKey, fromEmail) + client := NewEmailClient(domain, fromEmail, apiKey) assert.NotNil(client, "client should not be nil") assert.NotNil(client.mg, "mailgun client should not be nil") From aeeeb287a43adf287c9e57c78349ea0b18f7c77b Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 12:06:28 -0600 Subject: [PATCH 64/71] feat(issue): add apiKey flag to issue label email commands The apiKey flag was moved to be the first flag in the list. This change improves the organization and readability of the command-line flags, making it easier for users to understand the required parameters. --- internal/cmd/issue.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index 36c8cd8..e91580f 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -47,6 +47,10 @@ func IssueLabelEmailCmds() cli.Command { 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.StringFlag{ Name: "label", Usage: "The label that was added to the issue", @@ -59,10 +63,6 @@ func IssueLabelEmailCmds() cli.Command { Name: "domain", Usage: "Domain of mailgun endpoint", }, - cli.StringFlag{ - Name: "apiKey", - Usage: "API key for mailgun", - }, cli.StringFlag{ Name: "fromEmail", Usage: "Email address of the sender", From 9ff9edcdb3b321470cbaa4205d683af9045efa13 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Wed, 18 Feb 2026 12:08:54 -0600 Subject: [PATCH 65/71] fix(issue): reorder flags to ensure logical grouping and consistency The order of the flags has been adjusted to group related options together, improving readability and maintainability. The 'label' flag is moved after 'issueid' to follow a more logical sequence. --- internal/cmd/issue.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cmd/issue.go b/internal/cmd/issue.go index e91580f..2905fb2 100644 --- a/internal/cmd/issue.go +++ b/internal/cmd/issue.go @@ -51,14 +51,14 @@ func IssueLabelEmailCmds() cli.Command { Name: "apiKey", Usage: "API key for mailgun", }, - cli.StringFlag{ - Name: "label", - Usage: "The label that was added to the issue", - }, 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", From ca439cc854c428b6a4bf9cd828087e9b758edc8d Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 09:10:12 -0600 Subject: [PATCH 66/71] fix: update issue number and data in tests and testdata to match issue 122 The tests and associated testdata have been updated to use issue number 122 instead of 193. This includes updating the issue number, title, and content verifications in the tests, as well as the contents of the issue.json file. The number of tables extracted from the HTML was also updated. This ensures that the tests are accurate and reflect the current state of the application. --- internal/app/issue/issue_test.go | 8 +++--- internal/parser/parser_test.go | 6 ++-- testdata/issue.json | 48 +++++++++++++++++++------------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/internal/app/issue/issue_test.go b/internal/app/issue/issue_test.go index 71d9348..7ded399 100644 --- a/internal/app/issue/issue_test.go +++ b/internal/app/issue/issue_test.go @@ -24,7 +24,7 @@ func TestGetIssue(t *testing.T) { set := flag.NewFlagSet("test", 0) set.String("owner", "dictybase-playground", "repository owner") set.String("repository", "learn-github-action", "repository name") - set.Int("issueid", 193, "issue number") + set.Int("issueid", 122, "issue number") err := set.Parse([]string{}) assert.NoError(err, "should parse flags without error") @@ -36,11 +36,11 @@ func TestGetIssue(t *testing.T) { assert.NotNil(issue, "should return a non-nil issue") // Assert that the issue data matches issue.json - assert.Equal(193, issue.GetNumber(), "should have correct issue number") - assert.Equal("Order ID:37500885 art@vandelayindustries.com", issue.GetTitle(), "should have correct title") + 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:** 37500885", "body should contain order ID") + assert.Contains(issue.GetBody(), "**Order ID:** 10283618", "body should contain order ID") assert.Contains(issue.GetBody(), "Billing address", "body should contain billing address") } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index ea62435..7cd45ba 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -113,7 +113,7 @@ func TestParseTables(t *testing.T) { assert.NoError(err, "should be able to parse tables from HTML") // Verify we extracted 4 tables - assert.Len(tables, 4, "should extract 4 tables from body_html") + assert.Len(tables, 5, "should extract 5 tables from body_html") // Verify the table headers assert.Equal([]string{"Shipping address", "", "Billing address"}, tables[0].Headers, "first table should be shipping and billing information") @@ -143,7 +143,7 @@ func TestExtractBillingEmail(t *testing.T) { // Extract billing email email, err := ExtractBillingEmail(doc) assert.NoError(err, "should be able to extract billing email") - assert.Equal("art@vandelayindustries.com", email, "should extract the correct email from billing address column") + assert.Equal("kevin.tun@northwestern.edu", email, "should extract the correct email from billing address column") } func TestExtractOrderID(t *testing.T) { @@ -167,7 +167,7 @@ func TestExtractOrderID(t *testing.T) { // Extract order ID orderID, err := ExtractOrderID(doc) assert.NoError(err, "should be able to extract order ID") - assert.Equal("37500885", orderID, "should extract the correct order ID from paragraph") + assert.Equal("10283618", orderID, "should extract the correct order ID from paragraph") } func verifyHTMLContent(t *testing.T, htmlNode *html.Node, containsHTML, notContainsHTML []string) { diff --git a/testdata/issue.json b/testdata/issue.json index 48166f6..852abdd 100644 --- a/testdata/issue.json +++ b/testdata/issue.json @@ -1,14 +1,14 @@ { - "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193", + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122", "repository_url": "https://api.github.com/repos/dictybase-playground/learn-github-action", - "labels_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/labels{/name}", - "comments_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/comments", - "events_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/events", - "html_url": "https://github.com/dictybase-playground/learn-github-action/issues/193", - "id": 1056621442, - "node_id": "I_kwDODtS60c4--sOC", - "number": 193, - "title": "Order ID:37500885 art@vandelayindustries.com", + "labels_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122/labels{/name}", + "comments_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122/comments", + "events_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122/events", + "html_url": "https://github.com/dictybase-playground/learn-github-action/issues/122", + "id": 806469754, + "node_id": "MDU6SXNzdWU4MDY0Njk3NTQ=", + "number": 122, + "title": "Order ID:10283618 kevin.tun@northwestern.edu", "user": { "login": "dictybasebot", "id": 17730815, @@ -39,18 +39,25 @@ "color": "ededed", "default": false, "description": null + }, + { + "id": 2110510985, + "node_id": "MDU6TGFiZWwyMTEwNTEwOTg1", + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/labels/Plasmid", + "name": "Plasmid", + "color": "ededed", + "default": false, + "description": null } ], "state": "open", "locked": false, "assignee": null, - "assignees": [ - - ], + "assignees": [], "milestone": null, "comments": 0, - "created_at": "2021-11-17T20:52:26Z", - "updated_at": "2021-11-17T20:52:26Z", + "created_at": "2021-02-11T14:58:17Z", + "updated_at": "2021-02-11T14:58:17Z", "closed_at": null, "author_association": "MEMBER", "type": null, @@ -66,12 +73,12 @@ "blocking": 0, "total_blocking": 0 }, - "body_html": "

Date: Nov 17, 2021

\n

Order ID: 37500885

\n

Shipping and billing information

\n
Column 1Column 2Value 1Value 2
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Shipping addressBilling address
Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
credit $N/A
\n

Stocks ordered

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ItemQuantityUnit price($)Total($)
Strain23060
Plasmid0150
60
\n

Strain information

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
IDDescriptorName(s)Systematic NameCharacteristics
DBS0351365HL501/X55DL66
DBS0351365HL501/X55DL66
\n

Strain storage

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
NameStored asLocationNo. of vialsColor
HL501/X55cells4-47(46)2Blue
HL501/X55cells4-47(46)2Blue
", - "body_text": "Date: Nov 17, 2021\nOrder ID: 37500885\nShipping and billing information\n\n\n\nShipping address\n\nBilling address\n\n\n\n\nArt Vandelay Vandelay Industries\t 123 Fake St Chicago IL 60601 United States Phone: 867-5309 art@vandelayindustries.com prepaid sending prepaid shipping label\n\nArt Vandelay Vandelay Industries\t 123 Fake St Chicago IL 60601 United States Phone: 867-5309 art@vandelayindustries.com prepaid sending prepaid shipping label credit $N/A\n\n\n\nStocks ordered\n\n\n\nItem\nQuantity\nUnit price($)\nTotal($)\n\n\n\n\nStrain\n2\n30\n60\n\n\nPlasmid\n0\n15\n0\n\n\n\n\n\n60\n\n\n\nStrain information\n\n\n\nID\nDescriptor\nName(s)\nSystematic Name\nCharacteristics\n\n\n\n\nDBS0351365\nHL501/X55\n\nDL66\n\n\n\nDBS0351365\nHL501/X55\n\nDL66\n\n\n\n\nStrain storage\n\n\n\nName\nStored as\nLocation\nNo. of vials\nColor\n\n\n\n\nHL501/X55\ncells\n4-47(46)\n2\nBlue\n\n\nHL501/X55\ncells\n4-47(46)\n2\nBlue", - "body": "**Date:** Nov 17, 2021 \n\n**Order ID:** 37500885 \n\n\n\n# Shipping and billing information \n\n|\tShipping address |\t | Billing address\t |\n| -------------------|----|------------------|\n| Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label | | Art Vandelay
Vandelay Industries\t
123 Fake St

Chicago IL 60601
United States
Phone: 867-5309
art@vandelayindustries.com
prepaid sending prepaid shipping label
credit $N/A |\n\n\n# Stocks ordered\n\n|\tItem\t|\tQuantity \t |\tUnit price($)\t |\tTotal($)\t |\n|-----------|--------------------|--------------------|--------------------|\n|\tStrain\t| 2 | 30 | 60 |\n|\tPlasmid\t| 0 | 15 | 0 |\n|\t\t\t|\t\t\t\t\t |\t\t\t\t\t |\t 60 |\n\n\n# Strain information\n\n|ID\t| Descriptor |\tName(s) |\tSystematic Name |\tCharacteristics |\n|-------|---------------|-----------|--------------------|-----------------|\n| DBS0351365 | HL501/X55 | | DL66 | |\n| DBS0351365 | HL501/X55 | | DL66 | |\n\n\t\n\n\n# Strain storage\n\n|\tName |\tStored as |\tLocation |\tNo. of vials |\tColor |\n|--------|------------|----------|---------------|----------|\n| HL501/X55 | cells | 4-47(46) | 2 | Blue |\n| HL501/X55 | cells | 4-47(46) | 2 | Blue |\n\n\n\n\n\n\n\n", + "body_html": "

Date: Feb 11, 2021

\n

Order ID: 10283618

\n

Shipping and billing information

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Shipping addressBilling address
Kevin Tun
dictybase\t
123 fake st

Chicago IL 00000
United States
Phone: 123456789
kevin.tun@northwestern.edu
UPS 8675309
Kevin Tun
dictybase\t
123 fake st

Chicago IL 00000
United States
Phone: 123456789
kevin.tun@northwestern.edu
UPS 8675309
purchaseOrder
\n

Stocks ordered

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
ItemQuantityUnit price($)Total($)
Strain23060
Plasmid41560
120
\n

Strain information

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
IDDescriptorName(s)Systematic NameCharacteristics
DBS0351362HL16/HL106DL86
DBS0351363HL84/XM101DL28
\n

Strain storage

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
NameStored asLocationNo. of vialsColor
HL16/HL106cells4-46(47)3Red
HL84/XM101cells4-47(17)2Pink
\n

Plasmid information and storage

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
IDNameStored asLocationColor
DBP0001064pDDB_G0279361/lacZbacteria23(9,18)
DBP0001064pDDB_G0279361/lacZbacteria23(9,18)
DBP0001064pDDB_G0279361/lacZbacteria23(9,18)
DBP0001064pDDB_G0279361/lacZbacteria23(9,18)
", + "body_text": "Date: Feb 11, 2021\nOrder ID: 10283618\nShipping and billing information\n\n\n\nShipping address\n\nBilling address\n\n\n\n\nKevin Tun dictybase\t 123 fake st Chicago IL 00000 United States Phone: 123456789 kevin.tun@northwestern.edu UPS 8675309\n\nKevin Tun dictybase\t 123 fake st Chicago IL 00000 United States Phone: 123456789 kevin.tun@northwestern.edu UPS 8675309 purchaseOrder\n\n\n\nStocks ordered\n\n\n\nItem\nQuantity\nUnit price($)\nTotal($)\n\n\n\n\nStrain\n2\n30\n60\n\n\nPlasmid\n4\n15\n60\n\n\n\n\n\n120\n\n\n\nStrain information\n\n\n\nID\nDescriptor\nName(s)\nSystematic Name\nCharacteristics\n\n\n\n\nDBS0351362\nHL16/HL106\n\nDL86\n\n\n\nDBS0351363\nHL84/XM101\n\nDL28\n\n\n\n\nStrain storage\n\n\n\nName\nStored as\nLocation\nNo. of vials\nColor\n\n\n\n\nHL16/HL106\ncells\n4-46(47)\n3\nRed\n\n\nHL84/XM101\ncells\n4-47(17)\n2\nPink\n\n\n\nPlasmid information and storage\n\n\n\nID\nName\nStored as\nLocation\nColor\n\n\n\n\nDBP0001064\npDDB_G0279361/lacZ\nbacteria\n23(9,18)\n\n\n\nDBP0001064\npDDB_G0279361/lacZ\nbacteria\n23(9,18)\n\n\n\nDBP0001064\npDDB_G0279361/lacZ\nbacteria\n23(9,18)\n\n\n\nDBP0001064\npDDB_G0279361/lacZ\nbacteria\n23(9,18)", + "body": "**Date:** Feb 11, 2021 \n\n**Order ID:** 10283618 \n\n\n\n# Shipping and billing information \n\n|\tShipping address |\t | Billing address\t |\n| -------------------|----|------------------|\n| Kevin Tun
dictybase\t
123 fake st

Chicago IL 00000
United States
Phone: 123456789
kevin.tun@northwestern.edu
UPS 8675309 | | Kevin Tun
dictybase\t
123 fake st

Chicago IL 00000
United States
Phone: 123456789
kevin.tun@northwestern.edu
UPS 8675309
purchaseOrder |\n\n\n# Stocks ordered\n\n|\tItem\t|\tQuantity \t |\tUnit price($)\t |\tTotal($)\t |\n|-----------|--------------------|--------------------|--------------------|\n|\tStrain\t| 2 | 30 | 60 |\n|\tPlasmid\t| 4 | 15 | 60 |\n|\t\t\t|\t\t\t\t\t |\t\t\t\t\t |\t 120 |\n\n\n# Strain information\n\n|ID\t| Descriptor |\tName(s) |\tSystematic Name |\tCharacteristics |\n|-------|---------------|-----------|--------------------|-----------------|\n| DBS0351362 | HL16/HL106 | | DL86 | |\n| DBS0351363 | HL84/XM101 | | DL28 | |\n\n\t\n\n\n# Strain storage\n\n|\tName |\tStored as |\tLocation |\tNo. of vials |\tColor |\n|--------|------------|----------|---------------|----------|\n| HL16/HL106 | cells | 4-46(47) | 3 | Red |\n| HL84/XM101 | cells | 4-47(17) | 2 | Pink |\n\n\n\n\n\n# Plasmid information and storage \n\n| ID |\tName |\tStored as |\tLocation |\tColor |\n|-----|-------|-----------|----------|--------|\n| DBP0001064 | pDDB_G0279361/lacZ | bacteria | 23(9,18) | |\n| DBP0001064 | pDDB_G0279361/lacZ | bacteria | 23(9,18) | |\n| DBP0001064 | pDDB_G0279361/lacZ | bacteria | 23(9,18) | |\n| DBP0001064 | pDDB_G0279361/lacZ | bacteria | 23(9,18) | |\n\n\n\n", "closed_by": null, "reactions": { - "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/reactions", + "url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122/reactions", "total_count": 0, "+1": 0, "-1": 0, @@ -82,7 +89,8 @@ "rocket": 0, "eyes": 0 }, - "timeline_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/193/timeline", + "timeline_url": "https://api.github.com/repos/dictybase-playground/learn-github-action/issues/122/timeline", "performed_via_github_app": null, - "state_reason": null + "state_reason": null, + "pinned_comment": null } From 2d8a4d65b603787ccace9b797f8e98f77f21c760 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 09:51:49 -0600 Subject: [PATCH 67/71] feat(email): include stock data in order update emails This commit modifies the email sending process to include stock data (strains and plasmids) in the order update emails. The OrderEmailData struct is updated to include a StockData field, and the email template is modified to display the stock data. The SendOrderUpdateFromTemplate function in email.go now accepts the OrderEmailData struct directly, instead of the recipient email as a separate argument. The email template test has been updated to exclude iteration-scoped fields. The changes allow users to receive detailed information about the strains and plasmids they ordered, improving the user experience. --- internal/app/issue/issue.go | 3 +- internal/email/email.go | 5 +- internal/email/email_test.go | 91 ++++++++++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/internal/app/issue/issue.go b/internal/app/issue/issue.go index 85e9dde..5a2df7f 100644 --- a/internal/app/issue/issue.go +++ b/internal/app/issue/issue.go @@ -219,6 +219,7 @@ func extractAndValidateOrderData(htmlNode *html.Node, label string) (email.Order RecipientEmail: issueData.RecipientEmail, OrderID: issueData.OrderID, Label: label, + StockData: issueData.StockData, }, nil } @@ -298,7 +299,7 @@ func SendIssueLabelEmail(clt *cli.Context) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData.RecipientEmail, emailData); err != nil { + if err := emailClient.SendOrderUpdateFromTemplate(ctx, emailData); err != nil { log.WithFields(map[string]any{ "order_id": emailData.OrderID, "recipient": emailData.RecipientEmail, diff --git a/internal/email/email.go b/internal/email/email.go index 84bd5ae..e9cbc02 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -8,6 +8,7 @@ import ( "html/template" "time" + "github.com/dictyBase-docker/github-actions/internal/parser" "github.com/mailgun/mailgun-go/v5" ) @@ -19,6 +20,7 @@ type OrderEmailData struct { RecipientEmail string OrderID string Label string + StockData parser.StockData } // MailgunConfig holds Mailgun configuration. @@ -94,7 +96,6 @@ func createEmailHTML(data OrderEmailData) (string, error) { // SendOrderUpdateFromTemplate sends an order update email using the template. func (ec *MailgunClient) SendOrderUpdateFromTemplate( ctx context.Context, - recipient string, data OrderEmailData, ) error { html, err := createEmailHTML(data) @@ -107,7 +108,7 @@ func (ec *MailgunClient) SendOrderUpdateFromTemplate( subject := fmt.Sprintf("Dicty Stock Center - Order Update #%s", data.OrderID) // Send the email - if err := ec.SendOrderUpdateEmail(ctx, recipient, subject, html); err != nil { + if err := ec.SendOrderUpdateEmail(ctx, data.RecipientEmail, subject, html); err != nil { return fmt.Errorf("failed to send order update email: %w", err) } diff --git a/internal/email/email_test.go b/internal/email/email_test.go index de67003..e552835 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -6,6 +6,7 @@ import ( "regexp" "testing" + "github.com/dictyBase-docker/github-actions/internal/parser" "github.com/stretchr/testify/require" ) @@ -37,14 +38,22 @@ func TestTemplateFieldsMatchStruct(t *testing.T) { } assert.NoError(err, "should read template file") - // Extract all template field references ({{.FieldName}}) - fieldPatestCaseern := regexp.MustCompile(`\{\{\.(\w+)\}\}`) - matches := fieldPatestCaseern.FindAllStringSubmatch(string(templateContent), -1) + content := string(templateContent) - // Collect unique field names used in template + // 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 } } @@ -61,7 +70,7 @@ func TestTemplateFieldsMatchStruct(t *testing.T) { } } - // Verify every template field exists in the struct + // 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) @@ -121,6 +130,78 @@ func TestCreateEmailHTML(t *testing.T) { "Your order 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 Update # ORD-11111", + "Your order status: 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 Update # ORD-22222", + "Your order status: 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 Update # ORD-33333", + "Strains Ordered", + "DBS0351362", + "Plasmids Ordered", + "DBP0001064", + }, + }, } for _, testCase := range tests { From 6c31b831c8897d131d3f3cd6707eb0c96e8fbc2f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 09:52:47 -0600 Subject: [PATCH 68/71] feat(email): revamp order update email template for improved UX The order update email template was completely rewritten to improve the user experience. The new template includes a modern design, better readability, and more detailed information about the order status and items. The email is now more visually appealing and easier to understand. --- internal/email/order_update.tmpl | 481 ++++++++++++++++++++++++------- 1 file changed, 373 insertions(+), 108 deletions(-) diff --git a/internal/email/order_update.tmpl b/internal/email/order_update.tmpl index c5217ac..c476ed0 100644 --- a/internal/email/order_update.tmpl +++ b/internal/email/order_update.tmpl @@ -1,158 +1,423 @@ - - + + - + - Alerts e.g. approaching your limit - + Order Update - Dicty Stock Center - - - - - - -
- -
- - - - - - + {{range .StockData.PlasmidInfo}} + + + + + {{end}} + +
- Dicty Stock Center -
- - -

- Order Update # {{.OrderID}} -

+ +
+ + - -
+ +
{{.ID}}{{.Name}}
+
+ {{end}} + +
+

+ We wanted to let you know that your order status has been updated. + If you have any questions or concerns about your order, please don't + hesitate to reach out to our team. +

+
+ +
+ +
+
+ Best regards,
+ The Dicty Stock Center Team
+ Northwestern University +
+ Contact Us +
+ + + +
From 33babac712dd6b1b6d1bbc817686649c655ad11b Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 09:53:10 -0600 Subject: [PATCH 69/71] feat(parser): extract strain and plasmid info from order HTML This commit introduces functionality to extract strain and plasmid information from HTML content within an order. It includes new structs for representing strain and plasmid data, functions for parsing tables, and logic to identify and extract relevant information. The ExtractOrderData function is updated to include stock data extraction. Tests are added to verify the extraction process. --- internal/parser/parser.go | 158 ++++++++++++++++++++++++++++++++- internal/parser/parser_test.go | 82 +++++++++++++++++ 2 files changed, 239 insertions(+), 1 deletion(-) diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 5441b07..dc5b2fe 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -20,6 +20,25 @@ type TableData struct { type IssueBodyData struct { RecipientEmail string OrderID string + StockData StockData +} + +// StrainInfo represents information about a strain from the order. +type StrainInfo struct { + ID string + Descriptor string +} + +// PlasmidInfo represents information and storage details for a plasmid. +type PlasmidInfo struct { + ID string + Name string +} + +// StockData represents all stock-related information extracted from an order. +type StockData struct { + StrainInfo []StrainInfo + PlasmidInfo []PlasmidInfo } var ( @@ -222,7 +241,138 @@ func MarkdownToHTML(markdown string) (*html.Node, error) { return doc, nil } -// ExtractOrderData extracts order ID and billing email from HTML and returns structured data. +// containsIgnoreCase performs case-insensitive substring matching. +func containsIgnoreCase(str, substr string) bool { + return strings.Contains(strings.ToLower(str), strings.ToLower(substr)) +} + +// matchesStrainInfoHeaders checks if headers match strain information table. +func matchesStrainInfoHeaders(headers []string) bool { + if len(headers) < 5 { + return false + } + + hasID := false + hasDescriptor := false + hasStored := false + + for _, header := range headers { + if containsIgnoreCase(header, "ID") { + hasID = true + } + if containsIgnoreCase(header, "Descriptor") { + hasDescriptor = true + } + if containsIgnoreCase(header, "Stored") { + hasStored = true + } + } + + // Must have ID and Descriptor, but NOT Stored (to distinguish from plasmid storage) + return hasID && hasDescriptor && !hasStored +} + +// matchesPlasmidInfoHeaders checks if headers match plasmid information table. +func matchesPlasmidInfoHeaders(headers []string) bool { + if len(headers) < 5 { + return false + } + + hasID := false + hasName := false + hasStored := false + + for _, header := range headers { + if containsIgnoreCase(header, "ID") { + hasID = true + } + if containsIgnoreCase(header, "Name") { + hasName = true + } + if containsIgnoreCase(header, "Stored") { + hasStored = true + } + } + + // Must have ID, Name, AND Stored (to distinguish from strain info which lacks Stored) + return hasID && hasName && hasStored +} + +// parseStrainInfoRow converts a table row to StrainInfo struct. +func parseStrainInfoRow(row []string) StrainInfo { + strain := StrainInfo{} + + if len(row) > 0 { + strain.ID = row[0] + } + if len(row) > 1 { + strain.Descriptor = row[1] + } + + return strain +} + +// parsePlasmidInfoRow converts a table row to PlasmidInfo struct. +func parsePlasmidInfoRow(row []string) PlasmidInfo { + plasmid := PlasmidInfo{} + + if len(row) > 0 { + plasmid.ID = row[0] + } + if len(row) > 1 { + plasmid.Name = row[1] + } + return plasmid +} + +// isEmptyRow checks if a row contains only empty strings. +func isEmptyRow(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} + +// ExtractStockData extracts strain and plasmid information from HTML content. +// It searches for tables with matching headers and parses stock-related data. +// Returns StockData with empty slices if no matching tables are found. +func ExtractStockData(doc *html.Node) (StockData, error) { + tables, err := ParseTables(doc) + if err != nil { + return StockData{}, fmt.Errorf("error parsing tables: %w", err) + } + + data := StockData{ + StrainInfo: []StrainInfo{}, + PlasmidInfo: []PlasmidInfo{}, + } + + for _, table := range tables { + // Check if this is a strain information table + if matchesStrainInfoHeaders(table.Headers) { + for _, row := range table.Rows { + if !isEmptyRow(row) { + data.StrainInfo = append(data.StrainInfo, parseStrainInfoRow(row)) + } + } + } + + // Check if this is a plasmid information table + if matchesPlasmidInfoHeaders(table.Headers) { + for _, row := range table.Rows { + if !isEmptyRow(row) { + data.PlasmidInfo = append(data.PlasmidInfo, parsePlasmidInfoRow(row)) + } + } + } + } + + return data, nil +} + +// ExtractOrderData extracts order ID, billing email, and stock data from HTML and returns structured data. func ExtractOrderData(htmlNode *html.Node) (IssueBodyData, error) { orderID, err := ExtractOrderID(htmlNode) if err != nil { @@ -234,8 +384,14 @@ func ExtractOrderData(htmlNode *html.Node) (IssueBodyData, error) { return IssueBodyData{}, fmt.Errorf("failed to extract billing email: %w", err) } + stockData, err := ExtractStockData(htmlNode) + if err != nil { + return IssueBodyData{}, fmt.Errorf("failed to extract stock data: %w", err) + } + return IssueBodyData{ OrderID: orderID, RecipientEmail: billingEmail, + StockData: stockData, }, nil } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 7cd45ba..e42ca10 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -214,3 +214,85 @@ func TestMarkdownToHTML(t *testing.T) { }) } } + +func TestExtractStockData(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 stock data + stockData, err := ExtractStockData(doc) + assert.NoError(err, "should be able to extract stock data") + + // Verify strain information + assert.Len(stockData.StrainInfo, 2, "should extract 2 strain info entries") + + // Verify first strain entry + assert.Equal("DBS0351362", stockData.StrainInfo[0].ID, "first strain should have correct ID") + assert.Equal("HL16/HL106", stockData.StrainInfo[0].Descriptor, "first strain should have correct descriptor") + + // Verify second strain entry + assert.Equal("DBS0351363", stockData.StrainInfo[1].ID, "second strain should have correct ID") + assert.Equal("HL84/XM101", stockData.StrainInfo[1].Descriptor, "second strain should have correct descriptor") + + // Verify plasmid information + assert.Len(stockData.PlasmidInfo, 4, "should extract 4 plasmid info entries") + + // Verify all plasmid entries have the same ID and Name + for i, plasmid := range stockData.PlasmidInfo { + assert.Equal("DBP0001064", plasmid.ID, "plasmid %d should have correct ID", i) + assert.Equal("pDDB_G0279361/lacZ", plasmid.Name, "plasmid %d should have correct name", i) + } +} + +func TestExtractOrderData(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 all order data + orderData, err := ExtractOrderData(doc) + assert.NoError(err, "should be able to extract order data") + + // Verify order ID + assert.Equal("10283618", orderData.OrderID, "should extract correct order ID") + + // Verify recipient email + assert.Equal("kevin.tun@northwestern.edu", orderData.RecipientEmail, "should extract correct recipient email") + + // Verify strain data + assert.Len(orderData.StockData.StrainInfo, 2, "should extract 2 strain info entries") + assert.Equal("DBS0351362", orderData.StockData.StrainInfo[0].ID, "first strain should have correct ID") + assert.Equal("HL16/HL106", orderData.StockData.StrainInfo[0].Descriptor, "first strain should have correct descriptor") + + // Verify plasmid data + assert.Len(orderData.StockData.PlasmidInfo, 4, "should extract 4 plasmid info entries") + assert.Equal("DBP0001064", orderData.StockData.PlasmidInfo[0].ID, "first plasmid should have correct ID") + assert.Equal("pDDB_G0279361/lacZ", orderData.StockData.PlasmidInfo[0].Name, "first plasmid should have correct name") +} From 86ce688e4d5e0084f6703e05057d6200f138c54f Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 10:08:07 -0600 Subject: [PATCH 70/71] feat: remove issue template file The issue template file was removed as it is no longer needed. --- internal/app/issue/issue.tmpl | 63 ----------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 internal/app/issue/issue.tmpl diff --git a/internal/app/issue/issue.tmpl b/internal/app/issue/issue.tmpl deleted file mode 100644 index bd42d23..0000000 --- a/internal/app/issue/issue.tmpl +++ /dev/null @@ -1,63 +0,0 @@ -**Date:** {{.OrderTimestamp}} - -**Order ID:** {{.Order.Data.Id}} - -{{$o := .Order.Data.Attributes}} - -# Shipping and billing information - -| Shipping address | | Billing address | -| -------------------|----|------------------| -| {{with .Shipper.Data.Attributes }} {{.FirstName}} {{.LastName}}
{{.Organization}}
{{.FirstAddress}}
{{.SecondAddress}}
{{.City}} {{.State}} {{.Zipcode}}
{{.Country}}
Phone: {{.Phone}}
{{.Email}}
{{$o.Courier}} {{$o.CourierAccount}} {{- end }} | | {{- with .Payer.Data.Attributes }} {{.FirstName}} {{.LastName}}
{{.Organization}}
{{.FirstAddress}}
{{.SecondAddress}}
{{.City}} {{.State}} {{.Zipcode}}
{{.Country}}
Phone: {{.Phone}}
{{.Email}}
{{$o.Courier}} {{$o.CourierAccount}}
{{$o.Payment}} {{$o.PurchaseOrderNum}} {{- end }} | - -{{if or .StrainInv .PlasmidInv}} -# Stocks ordered - -| Item | Quantity | Unit price($) | Total($) | -|-----------|--------------------|--------------------|--------------------| -| Strain | {{.StrainItems}} | {{.StrainPrice}} | {{.StrainCost}} | -| Plasmid | {{.PlasmidItems}} | {{.PlasmidPrice}} | {{.PlasmidCost}} | -| | | | {{.TotalCost}} | - -{{- end}} - -{{ if .StrainInfo}} -# Strain information - -|ID | Descriptor | Name(s) | Systematic Name | Characteristics | -|-------|---------------|-----------|--------------------|-----------------| -{{- range $idx,$e := .StrainInfo}} -| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | - -{{- end}} -{{end}} - - -{{if .StrainInv}} -# Strain storage - -| Name | Stored as | Location | No. of vials | Color | -|--------|------------|----------|---------------|----------| -{{- range $idx,$e := .StrainInv}} -| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | - -{{- end}} -{{end}} - - - -{{if .PlasmidInv}} -# Plasmid information and storage - -| ID | Name | Stored as | Location | Color | -|-----|-------|-----------|----------|--------| -{{- range $idx,$e := .PlasmidInv}} -| {{index $e 0}} | {{index $e 1}} | {{index $e 2}} | {{index $e 3}} | {{index $e 4}} | - -{{- end}} -{{end}} - -{{if .Order.Data.Attributes.Comments}} -# Comment -{{.Order.Data.Attributes.Comments}} -{{end}} From 98e73ab7b1b122f2e4445df6e30ae2d2a0d78355 Mon Sep 17 00:00:00 2001 From: Kevin Tun Date: Fri, 20 Feb 2026 10:08:21 -0600 Subject: [PATCH 71/71] feat(email): improve order update email template and test coverage The order update email template has been improved with better styling and clearer information presentation. The test coverage has been expanded to include more scenarios and a table-driven approach for easier maintenance and readability. The padding in the email template has been adjusted for better spacing on smaller screens. --- internal/email/email_test.go | 41 +++++++++++++++++++------------- internal/email/order_update.tmpl | 11 +++++++-- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/internal/email/email_test.go b/internal/email/email_test.go index e552835..4c0b19c 100644 --- a/internal/email/email_test.go +++ b/internal/email/email_test.go @@ -80,10 +80,14 @@ func TestTemplateFieldsMatchStruct(t *testing.T) { // as RecipientEmail is used for addressing but not in the email body } -func TestCreateEmailHTML(t *testing.T) { - t.Parallel() - - tests := []struct { +//nolint:funlen // Test cases table +func getEmailHTMLTestCases() []struct { + name string + data OrderEmailData + wantErr bool + contains []string +} { + return []struct { name string data OrderEmailData wantErr bool @@ -98,8 +102,9 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ORD-12345", - "Your order status: shipped", + "Order Number: ORD-12345", + "Current Status", + "shipped", "Dicty Stock Center", "dictystocks@northwestern.edu", }, @@ -113,8 +118,8 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ORD-99999", - "Your order status: processing", + "Order Number: ORD-99999", + "processing", }, }, { @@ -126,8 +131,8 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ", - "Your order status: ", + "Order Number:", + "Current Status", }, }, { @@ -145,8 +150,8 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ORD-11111", - "Your order status: shipped", + "Order Number: ORD-11111", + "shipped", "Strains Ordered", "DBS0351362", "HL16/HL106", @@ -169,8 +174,8 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ORD-22222", - "Your order status: processing", + "Order Number: ORD-22222", + "processing", "Plasmids Ordered", "DBP0001064", "pDDB_G0279361/lacZ", @@ -195,7 +200,7 @@ func TestCreateEmailHTML(t *testing.T) { }, wantErr: false, contains: []string{ - "Order Update # ORD-33333", + "Order Number: ORD-33333", "Strains Ordered", "DBS0351362", "Plasmids Ordered", @@ -203,8 +208,12 @@ func TestCreateEmailHTML(t *testing.T) { }, }, } +} + +func TestCreateEmailHTML(t *testing.T) { + t.Parallel() - for _, testCase := range tests { + for _, testCase := range getEmailHTMLTestCases() { t.Run(testCase.name, func(t *testing.T) { t.Parallel() assert := require.New(t) diff --git a/internal/email/order_update.tmpl b/internal/email/order_update.tmpl index c476ed0..7fb1dbd 100644 --- a/internal/email/order_update.tmpl +++ b/internal/email/order_update.tmpl @@ -64,7 +64,7 @@ } .content { - padding: 48px 40px; + padding: 40px 40px; } .notification-badge { @@ -263,6 +263,13 @@ color: #495057; } + .stock-table th:first-child, + .stock-table td:first-child { + width: 40%; + min-width: 140px; + max-width: 140px; + } + .stock-id { font-weight: 600; color: #1565C0; @@ -286,7 +293,7 @@ } .content { - padding: 32px 24px; + padding: 24px 24px; } .order-title {