Cleared History
This commit is contained in:
commit
e356cc6c4e
4 changed files with 423 additions and 0 deletions
76
README.md
Normal file
76
README.md
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# GitHub Issue Downloader
|
||||||
|
|
||||||
|
This project is a command-line tool that fetches GitHub issue data and reconstructs it in Markdown or HTML format. It includes features such as rendering Markdown in comments, displaying user avatars, and a dark mode toggle in HTML output.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Fetch GitHub issue and comments
|
||||||
|
- Render Markdown in issues and comments
|
||||||
|
- Display user avatars
|
||||||
|
- Dark mode toggle for HTML output
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.23 or later
|
||||||
|
- Internet connection to access the GitHub API
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://f0rk.systems/reso/tools_issue_cli.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Change to the project directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd tools_issue_cli§
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build the project:
|
||||||
|
```sh
|
||||||
|
go build -o tools_issue_cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Fetching and Outputting an Issue
|
||||||
|
|
||||||
|
You can use the tool to fetch a GitHub issue and output it in either Markdown or HTML format.
|
||||||
|
|
||||||
|
#### Fetch and Output as Markdown
|
||||||
|
|
||||||
|
To fetch an issue and output it as Markdown:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools_issue_cli fetch --url https://github.com/owner/repo/issues/1 --output issue.md --format markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fetch and Output as HTML
|
||||||
|
|
||||||
|
To fetch an issue and output it as HTML:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools_issue_cli fetch --url https://github.com/owner/repo/issues/1 --output issue.html --format html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command-Line Options
|
||||||
|
|
||||||
|
- `--url`: The URL of the GitHub issue to fetch.
|
||||||
|
- `--output`: The file path to write the output (optional). If not provided, the output is printed to the console.
|
||||||
|
- `--format`: The output format (`markdown` or `html`). Defaults to `markdown`.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
### Fetch an Issue and Output as Markdown
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools_issue_cli fetch --url https://github.com/dnnyxyz/dnnyxyz.github.io/issues/1 --output issue.md --format markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fetch an Issue and Output as HTML
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./tools_issue_cli fetch --url https://github.com/dnnyxyz/dnnyxyz.github.io/issues/1 --output issue.html --format html
|
||||||
|
```
|
13
go.mod
Normal file
13
go.mod
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
module issue_cli
|
||||||
|
|
||||||
|
go 1.23.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0
|
||||||
|
github.com/urfave/cli/v2 v2.27.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
|
)
|
8
go.sum
Normal file
8
go.sum
Normal file
|
@ -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=
|
326
main.go
Normal file
326
main.go
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
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()
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue