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"` AvatarURL string `json:"avatar_url"` } 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") } 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] issue, err := getIssue(owner, repo, issueNumber) if err != nil { return err } comments, err := getComments(owner, repo, issueNumber) if err != nil { return err } format := c.String("format") var output string if format == "html" { output = convertToHTML(issue, comments) } else { output = convertToMarkdown(issue, comments) } outputFile := c.String("output") if outputFile != "" { 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 { 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 { html := blackfriday.Run([]byte(md)) re := regexp.MustCompile(`!\[Image\]\((https://github.com/user-attachments/assets/[^\)]+)\)`) html = re.ReplaceAll(html, []byte(`Image`)) 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) sb.WriteString(``) sb.WriteString(` `) sb.WriteString(fmt.Sprintf(`

%s

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

%s
`, issue.Title, issue.User.AvatarURL, issue.User.Login, issue.Number, issue.State, 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(`
Avatar%s commented on %s:
%s
`, comment.User.AvatarURL, comment.User.Login, commentCreatedAt, parseMarkdown(comment.Body))) } } return sb.String() }