commit 3b6f405c505f7575b0b3d60fbfd596d80cec0a2d Author: devfxx <54813298+devfxx@users.noreply.github.com> Date: Fri Jan 24 13:09:11 2025 +0200 init diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..681eae1 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module issue_cli + +go 1.23.4 + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/urfave/cli/v2 v2.27.5 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c8b6c7e --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5f6b6d2 --- /dev/null +++ b/main.go @@ -0,0 +1,330 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "strings" + "time" + + "github.com/russross/blackfriday/v2" + "github.com/urfave/cli/v2" +) + +type Issue struct { + Title string `json:"title"` + Body string `json:"body"` + User User `json:"user"` + Number int `json:"number"` + State string `json:"state"` + Comments int `json:"comments"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type User struct { + Login string `json:"login"` +} + +type Comment struct { + Body string `json:"body"` + User User `json:"user"` + CreatedAt string `json:"created_at"` +} + +func main() { + app := &cli.App{ + Name: "github-issue-cli", + Usage: "Fetches GitHub issue data and reconstructs it in Markdown or HTML format", + Commands: []*cli.Command{ + { + Name: "fetch", + Usage: "Fetch a GitHub issue", + Action: fetchIssue, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "url", + Usage: "GitHub issue URL", + Required: true, + }, + &cli.StringFlag{ + Name: "output", + Usage: "Output file to write the markdown or html", + }, + &cli.StringFlag{ + Name: "format", + Usage: "Output format (markdown or html)", + Value: "markdown", + }, + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func fetchIssue(c *cli.Context) error { + url := c.String("url") + if !strings.Contains(url, "github.com") || !strings.Contains(url, "/issues/") { + return fmt.Errorf("invalid GitHub issue URL") + } + + // Extract owner, repo, and issue number from the URL + parts := strings.Split(url, "/") + if len(parts) < 5 { + return fmt.Errorf("invalid GitHub issue URL") + } + owner := parts[3] + repo := parts[4] + issueNumber := parts[len(parts)-1] + + // Fetch the issue data from the GitHub API + issue, err := getIssue(owner, repo, issueNumber) + if err != nil { + return err + } + + // Fetch the comments for the issue + comments, err := getComments(owner, repo, issueNumber) + if err != nil { + return err + } + + // Convert the issue data to the specified format + format := c.String("format") + var output string + if format == "html" { + output = convertToHTML(issue, comments) + } else { + output = convertToMarkdown(issue, comments) + } + + // Check if output file path is provided + outputFile := c.String("output") + if outputFile != "" { + // Write the output to the specified file + if err := os.WriteFile(outputFile, []byte(output), 0644); err != nil { + return fmt.Errorf("failed to write to file: %v", err) + } + fmt.Printf("Output written to %s\n", outputFile) + } else { + // Print the output to the console + fmt.Println(output) + } + + return nil +} + +func getIssue(owner, repo, issueNumber string) (Issue, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%s", owner, repo, issueNumber) + resp, err := http.Get(apiURL) + if err != nil { + return Issue{}, fmt.Errorf("failed to fetch issue: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return Issue{}, fmt.Errorf("failed to fetch issue: status code %d", resp.StatusCode) + } + + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return Issue{}, fmt.Errorf("failed to decode JSON response: %v", err) + } + + return issue, nil +} + +func getComments(owner, repo, issueNumber string) ([]Comment, error) { + apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues/%s/comments", owner, repo, issueNumber) + resp, err := http.Get(apiURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch comments: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch comments: status code %d", resp.StatusCode) + } + + var comments []Comment + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return nil, fmt.Errorf("failed to decode JSON response: %v", err) + } + + return comments, nil +} + +func formatDate(isoDate string) (string, error) { + parsedTime, err := time.Parse(time.RFC3339, isoDate) + if err != nil { + return "", err + } + return parsedTime.Format("January 2, 2006 15:04 MST"), nil +} + +func parseMarkdown(md string) string { + // Use blackfriday to convert markdown to HTML + html := blackfriday.Run([]byte(md)) + + // Custom handling for images + re := regexp.MustCompile(`!\[Image\]\((https://github.com/user-attachments/assets/[^\)]+)\)`) + html = re.ReplaceAll(html, []byte(`Image`)) + + // Remove leading and trailing

tags and replace
tags with spaces + re = regexp.MustCompile(`(?m)^

(.*?)

$`) + html = re.ReplaceAll(html, []byte("$1")) + html = bytes.ReplaceAll(html, []byte("
"), []byte(" ")) + + return string(html) +} + +func convertToMarkdown(issue Issue, comments []Comment) string { + var sb strings.Builder + + createdAt, _ := formatDate(issue.CreatedAt) + updatedAt, _ := formatDate(issue.UpdatedAt) + + sb.WriteString(fmt.Sprintf(`# %s + +**Issue Number:** %d +**State:** %s +**Created By:** %s +**Created At:** %s +**Updated At:** %s +**Comments:** %d + +--- + +%s +`, issue.Title, issue.Number, issue.State, issue.User.Login, createdAt, updatedAt, issue.Comments, issue.Body)) + + if len(comments) > 0 { + sb.WriteString("\n## Comments:\n") + for _, comment := range comments { + commentCreatedAt, _ := formatDate(comment.CreatedAt) + sb.WriteString(fmt.Sprintf("\n**%s** commented on %s:\n\n%s\n", comment.User.Login, commentCreatedAt, comment.Body)) + } + } + + return sb.String() +} + +func convertToHTML(issue Issue, comments []Comment) string { + var sb strings.Builder + + createdAt, _ := formatDate(issue.CreatedAt) + updatedAt, _ := formatDate(issue.UpdatedAt) + + // Add CSS styling + sb.WriteString(``) + + // Add the toggle button and JavaScript + sb.WriteString(` +`) + + sb.WriteString(fmt.Sprintf(`
+
+

%s

+

Issue Number: %d | State: %s | Created By: %s | Created At: %s | Updated At: %s | Comments: %d

+
+
+ %s +
+
`, issue.Title, issue.Number, issue.State, issue.User.Login, createdAt, updatedAt, issue.Comments, parseMarkdown(issue.Body))) + + if len(comments) > 0 { + sb.WriteString("

Comments:

") + for _, comment := range comments { + commentCreatedAt, _ := formatDate(comment.CreatedAt) + sb.WriteString(fmt.Sprintf(`
+
%s commented on %s:
+
%s
+
`, comment.User.Login, commentCreatedAt, parseMarkdown(comment.Body))) + } + } + + return sb.String() +}