commit 01ed92f204ee6627ab9a19df16bb548df7dcf6ab
parent 432e81a8c21e9e8f2c9bac7b6548fe655e0afc23
Author: Oliver Lowe <o@olowe.co>
Date: Thu, 16 Jul 2026 15:34:00 +1000
hub: new package to replace big 3rd party github
Diffstat:
9 files changed, 209 insertions(+), 206 deletions(-)
diff --git a/cmd/jiraq/jiraq.go b/cmd/jiraq/jiraq.go
@@ -11,7 +11,6 @@
// -u url
// The URL pointing to the root of the JIRA REST API.
//
-//
// # Examples
//
// Print an overview of all open tickets in the project "SRE":
@@ -26,7 +25,7 @@
// Print issues updated since yesterday:
//
// query='project = SRE and status != done and updated >= -24h'
-// jiraexport `jiraq "$query" | awk '{print $1}'`
+// jiraexport `jiraq "$query" | awk '{print $1}'`
package main
import (
diff --git a/issue/acme.go b/issue/acme.go
@@ -41,9 +41,9 @@ func (w *awin) project() string {
return p
}
-func acmeMode() {
+func acmeMode(proj string) {
var dummy awin
- dummy.prefix = path.Join(root, *project) + "/"
+ dummy.prefix = path.Join(root, proj) + "/"
if flag.NArg() > 0 {
// TODO(rsc): Without -a flag, the query is conatenated into one query.
// Decide which behavior should be used, and use it consistently.
@@ -373,7 +373,7 @@ func (w *awin) newSearch(prefix, title, query string) {
go w.loop()
}
-var createTemplate = `Title:
+const createTemplate = `Title:
Assignee:
Labels:
Milestone:
diff --git a/issue/gh.go b/issue/gh.go
@@ -1,146 +0,0 @@
-package main
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
-)
-
-const defaultBaseURL = "https://api.github.com"
-
-type Client struct {
- baseURL string
- Token string
- *http.Client
-}
-
-type GIssue struct {
- Number int
- Title string
- Creator struct {
- Name string `json:"login"`
- } `json:"user"`
- Assignee struct {
- Name string `json:"login"`
- }
- Labels []string
- State string
- Created time.Time `json:"created_at"`
- Updated time.Time `json:"updated_at"`
- Closed time.Time `json:"closed_at"`
- HTMLURL string `json:"html_url"`
- Body string
- Comments int
- Reactions Reactions
-}
-
-func printGIssue(w io.Writer, issue *GIssue) error {
- buf := &bytes.Buffer{}
- fmt.Fprintln(buf, "Title:", issue.Title)
- fmt.Fprintln(buf, "State:", issue.State)
- fmt.Fprintln(buf, "From:", issue.Creator.Name)
- fmt.Fprintln(buf, "Date:", issue.Updated.Format(time.DateTime))
- if !issue.Closed.IsZero() {
- fmt.Fprintln(buf, "Closed:", issue.Closed.Format(time.DateTime))
- }
- fmt.Fprintln(buf, "Assignee:", issue.Assignee.Name)
- fmt.Fprintln(buf, "Labels:", strings.Join(issue.Labels, " "))
- if issue.Reactions.String() != "" {
- fmt.Fprintln(buf, "Reactions:", issue.Reactions)
- }
- fmt.Fprintln(buf, "URL:", issue.HTMLURL)
- fmt.Fprintln(buf)
- fmt.Fprintln(buf, strings.TrimSpace(issue.Body))
- fmt.Fprintln(buf)
- _, err := io.Copy(w, buf)
- return err
-}
-
-type GComment struct {
- Created time.Time `json:"created_at"`
- Updated time.Time `json:"updated_at"`
- Body string
- User struct {
- Name string `json:"login"`
- }
- Reactions Reactions
-}
-
-func printComments(w io.Writer, comments []GComment) error {
- buf := &bytes.Buffer{}
- for _, c := range comments {
- fmt.Fprintf(buf, "Comment by %s (%s)\n", c.User.Name, c.Updated.Format(time.DateTime))
- fmt.Fprintln(buf)
- fmt.Fprintln(buf, strings.TrimSpace(c.Body))
- if c.Reactions.String() != "" {
- fmt.Fprintln(buf)
- fmt.Fprintln(buf, "Reactions:", c.Reactions)
- }
- fmt.Fprintln(buf)
- }
- _, err := io.Copy(w, buf)
- return err
-}
-
-func (c *Client) LookupIssue(number int) (*Issue, error) {
- var issue Issue
- path := fmt.Sprintf("/issues/%d", number)
- err := c.get(path, &issue)
- return &issue, err
-}
-
-func (c *Client) Issues(owner, repo string) ([]Issue, error) {
- var issues []Issue
- err := c.get("/issues", &issues)
- return issues, err
-}
-
-func (c *Client) Comments(issue int) ([]Comment, error) {
- var comments []Comment
- path := fmt.Sprintf("/issues/%d/comments", issue)
- err := c.get(path, &comments)
- return comments, err
-}
-
-func (c *Client) get(path string, v any) error {
- u := c.baseURL + path
- req, err := http.NewRequest(http.MethodGet, u, nil)
- if err != nil {
- return err
- }
-
- resp, err := c.do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- // TODO: check HTTP status, decode any error messages from body
-
- if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
- return fmt.Errorf("decode response: %w", err)
- }
- return nil
-}
-
-func (c *Client) post(path string, body io.Reader) (*http.Response, error) {
- u := c.baseURL + path
- req, err := http.NewRequest(http.MethodPost, u, body)
- if err != nil {
- return nil, err
- }
- return c.do(req)
-}
-
-func (c *Client) do(req *http.Request) (*http.Response, error) {
- if c.Client == nil {
- c.Client = http.DefaultClient
- }
- if c.Token != "" {
- req.Header.Set("Authorization", "Bearer "+c.Token)
- }
- return c.Do(req)
-}
diff --git a/issue/gh_test.go b/issue/gh_test.go
@@ -1,33 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "io"
- "os"
- "testing"
-)
-
-func TestReadIssues(t *testing.T) {
- f, err := os.Open("issues.json")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
- var issues []GIssue
- if err := json.NewDecoder(f).Decode(&issues); err != nil {
- t.Fatal(err)
- }
- for _, is := range issues {
- printGIssue(io.Discard, &is)
- }
-
- b, err := os.ReadFile("comments.json")
- if err != nil {
- t.Fatal(err)
- }
- var comments []GComment
- if err := json.Unmarshal(b, &comments); err != nil {
- t.Fatal(err)
- }
- printComments(io.Discard, comments)
-}
diff --git a/issue/comments.json b/issue/hub/comments.json
diff --git a/issue/hub/hub.go b/issue/hub/hub.go
@@ -0,0 +1,137 @@
+package hub
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const defaultBaseURL = "https://api.github.com"
+
+type Client struct {
+ baseURL string
+ Token string
+ *http.Client
+}
+
+type Issue struct {
+ Number int
+ Title string
+ Creator struct {
+ Name string `json:"login"`
+ } `json:"user"`
+ Assignee struct {
+ Name string `json:"login"`
+ }
+ Labels []string
+ State string
+ Created time.Time `json:"created_at"`
+ Updated time.Time `json:"updated_at"`
+ Closed time.Time `json:"closed_at"`
+ HTMLURL string `json:"html_url"`
+ Body string
+ Comments int
+}
+
+func printIssue(w io.Writer, issue *Issue) error {
+ buf := &bytes.Buffer{}
+ fmt.Fprintln(buf, "Title:", issue.Title)
+ fmt.Fprintln(buf, "State:", issue.State)
+ fmt.Fprintln(buf, "From:", issue.Creator.Name)
+ fmt.Fprintln(buf, "Date:", issue.Updated.Format(time.DateTime))
+ if !issue.Closed.IsZero() {
+ fmt.Fprintln(buf, "Closed:", issue.Closed.Format(time.DateTime))
+ }
+ fmt.Fprintln(buf, "Assignee:", issue.Assignee.Name)
+ fmt.Fprintln(buf, "Labels:", strings.Join(issue.Labels, " "))
+ fmt.Fprintln(buf, "URL:", issue.HTMLURL)
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, strings.TrimSpace(issue.Body))
+ fmt.Fprintln(buf)
+ _, err := io.Copy(w, buf)
+ return err
+}
+
+type Comment struct {
+ Created time.Time `json:"created_at"`
+ Updated time.Time `json:"updated_at"`
+ Body string
+ User struct {
+ Name string `json:"login"`
+ }
+}
+
+func printComments(w io.Writer, comments []Comment) error {
+ buf := &bytes.Buffer{}
+ for _, c := range comments {
+ fmt.Fprintf(buf, "Comment by %s (%s)\n", c.User.Name, c.Updated.Format(time.DateTime))
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, strings.TrimSpace(c.Body))
+ fmt.Fprintln(buf)
+ }
+ _, err := io.Copy(w, buf)
+ return err
+}
+
+func (c *Client) LookupIssue(number int) (*Issue, error) {
+ var issue Issue
+ path := fmt.Sprintf("/issues/%d", number)
+ err := c.get(path, &issue)
+ return &issue, err
+}
+
+func (c *Client) Issues(owner, repo string) ([]Issue, error) {
+ var issues []Issue
+ err := c.get("/issues", &issues)
+ return issues, err
+}
+
+func (c *Client) Comments(issue int) ([]Comment, error) {
+ var comments []Comment
+ path := fmt.Sprintf("/issues/%d/comments", issue)
+ err := c.get(path, &comments)
+ return comments, err
+}
+
+func (c *Client) get(path string, v any) error {
+ u := c.baseURL + path
+ req, err := http.NewRequest(http.MethodGet, u, nil)
+ if err != nil {
+ return err
+ }
+
+ resp, err := c.do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ // TODO: check HTTP status, decode any error messages from body
+
+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+ return fmt.Errorf("decode response: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) post(path string, body io.Reader) (*http.Response, error) {
+ u := c.baseURL + path
+ req, err := http.NewRequest(http.MethodPost, u, body)
+ if err != nil {
+ return nil, err
+ }
+ return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) (*http.Response, error) {
+ if c.Client == nil {
+ c.Client = http.DefaultClient
+ }
+ if c.Token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.Token)
+ }
+ return c.Do(req)
+}
diff --git a/issue/hub/hub_test.go b/issue/hub/hub_test.go
@@ -0,0 +1,33 @@
+package hub
+
+import (
+ "encoding/json"
+ "io"
+ "os"
+ "testing"
+)
+
+func TestReadIssues(t *testing.T) {
+ f, err := os.Open("issues.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ var issues []Issue
+ if err := json.NewDecoder(f).Decode(&issues); err != nil {
+ t.Fatal(err)
+ }
+ for _, is := range issues {
+ printIssue(io.Discard, &is)
+ }
+
+ b, err := os.ReadFile("comments.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var comments []Comment
+ if err := json.Unmarshal(b, &comments); err != nil {
+ t.Fatal(err)
+ }
+ printComments(io.Discard, comments)
+}
diff --git a/issue/issues.json b/issue/hub/issues.json
diff --git a/issue/issue.go b/issue/issue.go
@@ -26,15 +26,16 @@ import (
)
var (
- acmeFlag = flag.Bool("a", false, "open in new acme window")
- editFlag = flag.Bool("e", false, "edit in system editor")
- jsonFlag = flag.Bool("json", false, "write JSON output")
- project = flag.String("p", "golang/go", "GitHub owner/repo name")
- rawFlag = flag.Bool("raw", false, "do no processing of markdown")
- tokenFile = flag.String("token", mustConfigPath(), "read GitHub token personal access token from `file`")
- logHTTP = flag.Bool("loghttp", false, "log http requests")
+ acmeFlag = flag.Bool("a", false, "open in new acme window")
+ editFlag = flag.Bool("e", false, "edit in system editor")
+ jsonFlag = flag.Bool("json", false, "write JSON output")
+ projectFlag = flag.String("p", "", "GitHub owner/repo name")
+ rawFlag = flag.Bool("raw", false, "do no processing of markdown")
+ logHTTP = flag.Bool("loghttp", false, "log http requests")
)
+var project string = "golang/go"
+
func usage() {
fmt.Fprintf(os.Stderr, `usage: issue [-a] [-e] [-p owner/repo] <query>
@@ -50,7 +51,6 @@ func main() {
flag.Parse()
log.SetFlags(0)
log.SetPrefix("issue: ")
-
if flag.NArg() == 0 && !*acmeFlag {
usage()
}
@@ -66,27 +66,40 @@ func main() {
http.DefaultTransport = newLogger(http.DefaultTransport)
}
- f := strings.Split(*project, "/")
- if len(f) != 2 {
- log.Fatal("invalid form for -p argument: must be owner/repo, like golang/go")
- }
-
confDir, err := os.UserConfigDir()
if err != nil {
log.Fatalln("find config dir:", err)
}
- if err := loadAuth(filepath.Join(confDir, "github", "token")); err != nil {
+ confDir = filepath.Join(confDir, "github")
+ projPath := confDir + "/project"
+ if *projectFlag != "" {
+ project = *projectFlag
+ } else {
+ if _, err := os.Stat(projPath); err == nil {
+ b, err := os.ReadFile(projPath)
+ if err != nil {
+ log.Println("read default project file:", err)
+ } else {
+ project = string(bytes.TrimSpace(b))
+ }
+ }
+ }
+ if len(strings.Split(project, "/")) != 2 {
+ log.Fatal("invalid form for -p argument: must be owner/repo, like golang/go")
+ }
+
+ if err := loadAuth(filepath.Join(confDir, "token")); err != nil {
log.Fatalln("load auth:", err)
}
if *acmeFlag {
- acmeMode()
+ acmeMode(project)
}
q := strings.Join(flag.Args(), " ")
if *editFlag && q == "new" {
- editIssue(*project, []byte(createTemplate), new(github.Issue))
+ editIssue(project, []byte(createTemplate), new(github.Issue))
return
}
@@ -94,21 +107,21 @@ func main() {
if n != 0 {
if *editFlag {
var buf bytes.Buffer
- issue, err := showIssue(&buf, *project, n)
+ issue, err := showIssue(&buf, project, n)
if err != nil {
log.Fatal(err)
}
- editIssue(*project, buf.Bytes(), issue)
+ editIssue(project, buf.Bytes(), issue)
return
}
- if _, err := showIssue(os.Stdout, *project, n); err != nil {
+ if _, err := showIssue(os.Stdout, project, n); err != nil {
log.Fatal(err)
}
return
}
if *editFlag {
- all, err := searchIssues(*project, q)
+ all, err := searchIssues(project, q)
if err != nil {
log.Fatal(err)
}
@@ -116,11 +129,11 @@ func main() {
log.Fatal("no issues matched search")
}
sort.Sort(issuesByTitle(all))
- bulkEditIssues(*project, all)
+ bulkEditIssues(project, all)
return
}
- if err := showQuery(os.Stdout, *project, q); err != nil {
+ if err := showQuery(os.Stdout, project, q); err != nil {
log.Fatal(err)
}
}