issues

Github, Jira, Gitlab issues from Plan 9's acme
Log | Files | Refs | README | LICENSE

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:
Mcmd/jiraq/jiraq.go | 3+--
Missue/acme.go | 6+++---
Dissue/gh.go | 146-------------------------------------------------------------------------------
Dissue/gh_test.go | 33---------------------------------
Rissue/comments.json -> issue/hub/comments.json | 0
Aissue/hub/hub.go | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aissue/hub/hub_test.go | 33+++++++++++++++++++++++++++++++++
Rissue/issues.json -> issue/hub/issues.json | 0
Missue/issue.go | 57+++++++++++++++++++++++++++++++++++----------------------
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) } }