tools_issue_cli/main.go

327 lines
8 KiB
Go
Raw Normal View History

2025-01-24 13:17:55 +02:00
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(`<img src="$1" alt="Image" style="max-width:100%;height:auto;">`))
re = regexp.MustCompile(`(?m)^<p>(.*?)</p>$`)
html = re.ReplaceAll(html, []byte("$1"))
html = bytes.ReplaceAll(html, []byte("<br>"), []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(`<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
background-color: #0d1117;
color: #c9d1d9;
padding: 20px;
}
body.light-mode {
background-color: #ffffff;
color: #24292e;
}
.issue-container {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 15px;
margin-bottom: 20px;
}
body.light-mode .issue-container {
background-color: #ffffff;
border-color: #d0d7de;
}
.issue-header {
border-bottom: 1px solid #30363d;
padding-bottom: 10px;
margin-bottom: 10px;
}
body.light-mode .issue-header {
border-color: #d0d7de;
}
.issue-title {
font-size: 24px;
margin: 0;
}
.issue-meta {
color: #8b949e;
font-size: 14px;
}
.comment-container {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 10px;
margin-top: 10px;
}
body.light-mode .comment-container {
background-color: #f6f8fa;
border-color: #d0d7de;
}
.comment-meta {
color: #8b949e;
font-size: 14px;
margin-bottom: 5px;
}
.comment-body {
white-space: pre-wrap;
}
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 10px;
vertical-align: middle;
}
#toggle-button {
background-color: #444c56;
color: #c9d1d9;
border: none;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
border-radius: 5px;
margin-bottom: 20px;
}
body.light-mode #toggle-button {
background-color: #e1e4e8;
color: #24292e;
}
</style>`)
sb.WriteString(`<button id="toggle-button">Toggle Light/Dark Mode</button>
<script>
document.getElementById('toggle-button').addEventListener('click', function() {
document.body.classList.toggle('light-mode');
});
</script>`)
sb.WriteString(fmt.Sprintf(`<div class="issue-container">
<div class="issue-header">
<h1 class="issue-title">%s</h1>
<p class="issue-meta"><img src="%s" alt="Avatar" class="avatar">%s | Issue Number: %d | State: %s | Created At: %s | Updated At: %s | Comments: %d</p>
</div>
<div class="issue-body">
%s
</div>
</div>`, 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("<h2>Comments:</h2>")
for _, comment := range comments {
commentCreatedAt, _ := formatDate(comment.CreatedAt)
sb.WriteString(fmt.Sprintf(`<div class="comment-container">
<div class="comment-meta"><img src="%s" alt="Avatar" class="avatar">%s commented on %s:</div>
<div class="comment-body">%s</div>
</div>`, comment.User.AvatarURL, comment.User.Login, commentCreatedAt, parseMarkdown(comment.Body)))
}
}
return sb.String()
}