commit c91e253e7326d185468b13aeb0b9dbc275504ae3 from: Oliver Lowe date: Sat Jan 3 05:53:33 2026 UTC issue: read-only client using plain net/http commit - 432e81a8c21e9e8f2c9bac7b6548fe655e0afc23 commit + c91e253e7326d185468b13aeb0b9dbc275504ae3 blob - 2d59cefc4776cb6b940c7f8a143529d9728f67b2 blob + c7f1fe45da2bf62f66756a2e2ffd958d88ecd73a --- go.mod +++ go.mod @@ -2,10 +2,4 @@ module olowe.co/issues go 1.21 -require ( - 9fans.net/go v0.0.7 - github.com/google/go-github/v63 v63.0.0 - golang.org/x/oauth2 v0.21.0 -) - -require github.com/google/go-querystring v1.1.0 // indirect +require 9fans.net/go v0.0.7 blob - b91297c4880b9fd5445893919fed9b72b3f3996f blob + d81a876d3cb2d296808a432563bf86a43c14bbdc --- go.sum +++ go.sum @@ -3,13 +3,6 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= -github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -27,8 +20,6 @@ golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -41,4 +32,3 @@ golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= blob - 6a66aea5eafe0ca6a688840c47219556c552488e (mode 644) blob + /dev/null --- issue/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. blob - fa156c3c514954664de22413b7eb08441ac19a1e blob + 55c6c6e8ae9ca0b26616cc32a7a5c4cfe0d08e35 --- issue/acme.go +++ issue/acme.go @@ -7,632 +7,130 @@ package main import ( - "bufio" "bytes" - "context" - "flag" "fmt" - "log" "os" "path" - "regexp" "strconv" "strings" - "sync" - "time" "9fans.net/go/acme" - "9fans.net/go/plumb" - "github.com/google/go-github/v63/github" ) -const root = "/issue/" - -func (w *awin) project() string { - p := w.prefix - p = strings.TrimPrefix(p, root) - i := strings.Index(p, "/") - if i >= 0 { - j := strings.Index(p[i+1:], "/") - if j >= 0 { - p = p[:i+1+j] - } - } - return p -} - -func acmeMode() { - var dummy awin - dummy.prefix = path.Join(root, *project) + "/" - 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. - // TODO(rsc): Block this look from doing the multiline selection mode? - for _, arg := range flag.Args() { - if dummy.Look(arg) { - continue - } - if arg == "new" { - dummy.createIssue() - continue - } - dummy.newSearch(dummy.prefix, "search", arg) - } - } else { - dummy.Look("all") - } - - go plumbserve() - - select {} -} - -func plumbserve() { - fid, err := plumb.Open("githubissue", 0) - if err != nil { - acme.Errf(root, "plumb: %v", err) - return - } - r := bufio.NewReader(fid) - for { - var m plumb.Message - if err := m.Recv(r); err != nil { - acme.Errf(root, "plumb recv: %v", err) - return - } - if m.Type != "text" { - acme.Errf(root, "plumb recv: unexpected type: %s", m.Type) - continue - } - if m.Dst != "githubissue" { - acme.Errf(root, "plumb recv: unexpected dst: %s", m.Dst) - continue - } - // TODO use m.Dir - data := string(m.Data) - var project, what string - if strings.HasPrefix(data, root) { - project = data[len(root):] - i := strings.LastIndex(project, "/") - if i < 0 { - acme.Errf(root, "plumb recv: bad text %q", data) - continue - } - project, what = project[:i], project[i+1:] - } else { - i := strings.Index(data, "#") - if i < 0 { - acme.Errf(root, "plumb recv: bad text %q", data) - continue - } - project, what = data[:i], data[i+1:] - } - if strings.Count(project, "/") != 1 { - acme.Errf(root, "plumb recv: bad text %q", data) - continue - } - var plummy awin - plummy.prefix = path.Join(root, project) + "/" - if !plummy.Look(what) { - acme.Errf(root, "plumb recv: can't look %s%s", plummy.prefix, what) - } - } -} - -const ( - modeSingle = 1 + iota - modeQuery - modeCreate - modeMilestone - modeBulk -) - type awin struct { *acme.Win - prefix string - mode int - query string - id int - github *github.Issue - title string - sortByNumber bool // otherwise sort by title } -var all struct { - sync.Mutex - m map[*acme.Win]*awin -} - -func (w *awin) exit() { - all.Lock() - defer all.Unlock() - if all.m[w.Win] == w { - delete(all.m, w.Win) - } - if len(all.m) == 0 { - os.Exit(0) - } -} - -func (w *awin) new(prefix, title string) *awin { - all.Lock() - defer all.Unlock() - if all.m == nil { - all.m = make(map[*acme.Win]*awin) - } - w1 := new(awin) - w1.title = title - var err error - w1.Win, err = acme.New() - if err != nil { - log.Printf("creating acme window: %v", err) - time.Sleep(10 * time.Millisecond) - w1.Win, err = acme.New() - if err != nil { - log.Fatalf("creating acme window again: %v", err) - } - } - w1.prefix = prefix - w1.SetErrorPrefix(w1.prefix) - w1.Name(w1.prefix + title) - all.m[w1.Win] = w1 - return w1 -} - -func (w *awin) show(title string) bool { - return acme.Show(w.prefix+title) != nil -} - -var numRE = regexp.MustCompile(`(?m)^#[0-9]+\t`) -var repoHashRE = regexp.MustCompile(`\A([A-Za-z0-9_]+/[A-Za-z0-9_]+)#(all|[0-9]+)\z`) - -var milecache struct { - sync.Mutex - list map[string][]*github.Milestone -} - -func cachedMilestones(project string) []*github.Milestone { - milecache.Lock() - if milecache.list == nil { - milecache.list = make(map[string][]*github.Milestone) - } - if milecache.list[project] == nil { - milecache.list[project], _ = loadMilestones(project) - } - list := milecache.list[project] - milecache.Unlock() - return list -} - func (w *awin) Look(text string) bool { - ids := readBulkIDs([]byte(text)) - if len(ids) > 0 { - for _, id := range ids { - text := fmt.Sprint(id) - if w.show(text) { - continue - } - w.newIssue(w.prefix, text, id) - } - return true + if *debug { + fmt.Fprintln(os.Stderr, "Look", text) } - if text == "all" { - if w.show("all") { + n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")) + if n > 0 { + win, err := acme.New() + if err != nil { + w.Err(err.Error()) return true } - w.newSearch(w.prefix, "all", "") - return true - } - if text == "Milestone" || text == "Milestones" || text == "milestone" { - if w.show("milestone") { - return true - } - w.newMilestoneList() - return true - } - list := cachedMilestones(w.project()) - for _, m := range list { - if getString(m.Title) == text { - if w.show(text) { - return true + owner, repo := w.repository() + pathname := fmt.Sprintf("/issue/%s/%s/%d", owner, repo, n) + win.Name(pathname) + ww := &awin{win} + go ww.EventLoop(ww) + go func() { + if err := ww.Get(); err != nil { + w.Err(err.Error()) } - w.newSearch(w.prefix, text, "milestone:"+text) - return true - } - } - - if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 1000000 { - text = strings.TrimPrefix(text, "#") - if w.show(text) { - return true - } - w.newIssue(w.prefix, text, n) + ww.Addr("#0") + ww.Ctl("dot=addr") + ww.Ctl("show") + }() return true } - if m := repoHashRE.FindStringSubmatch(text); m != nil { - project := m[1] - what := m[2] - prefix := path.Join(root, project) + "/" - if acme.Show(prefix+what) != nil { - return true - } - if what == "all" { - w.newSearch(prefix, what, "") - return true - } - if n, _ := strconv.Atoi(what); 0 < n && n < 1000000 { - w.newIssue(prefix, what, n) - return true - } - return false - } - - if m := numRE.FindAllString(text, -1); m != nil { - for _, s := range m { - w.Look(strings.TrimSpace(strings.TrimPrefix(s, "#"))) - } - return true - } return false } -func (w *awin) setMilestone(milestone, text string) { +func (w *awin) Get() error { + defer w.Ctl("clean") + if path.Base(w.name()) == "search" { + return fmt.Errorf("TODO search") + } + + owner, repo := w.repository() + n, err := strconv.Atoi(path.Base(w.name())) + if err != nil { + return fmt.Errorf("parse issue number: %w", err) + } + issue, err := client.LookupIssue(owner, repo, n) + if err != nil { + return fmt.Errorf("load %s/%s issue %d: %w", owner, repo, n, err) + } var buf bytes.Buffer - id := findMilestone(&buf, w.project(), &milestone) - if buf.Len() > 0 { - w.Err(strings.TrimSpace(buf.String())) + printIssue(&buf, issue) + w.Clear() + if _, err := w.Write("body", buf.Bytes()); err != nil { + return fmt.Errorf("write body: %w", err) } - if id == nil { - return - } - milestoneID := *id - stop := w.Blink() - defer stop() - if w.mode == modeSingle { - w.setMilestone1(milestoneID, w.id) - w.load() - return - } - if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 100000 { - w.setMilestone1(milestoneID, n) - return - } - if m := numRE.FindAllString(text, -1); m != nil { - for _, s := range m { - n, _ := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(s, "#"))) - if 0 < n && n < 100000 { - w.setMilestone1(milestoneID, n) - } - } - return - } -} - -func (w *awin) setMilestone1(milestoneID, n int) { - var edit github.IssueRequest - edit.Milestone = &milestoneID - - _, _, err := client.Issues.Edit(context.TODO(), projectOwner(w.project()), projectRepo(w.project()), n, &edit) + comments, err := client.Comments(owner, repo, n) if err != nil { - w.Err(fmt.Sprintf("Error changing issue #%d: %v", n, err)) + return fmt.Errorf("load issue %d comments: %w", n, err) } + buf.Reset() + printComments(&buf, comments) + if _, err := w.Write("body", buf.Bytes()); err != nil { + return fmt.Errorf("write body: %w", err) + } + return nil } -func (w *awin) createIssue() { - w = w.new(w.prefix, "new") - w.mode = modeCreate - w.Ctl("cleartag") - w.Fprintf("tag", " Put Search ") - go w.load() - go w.loop() -} - -func (w *awin) newIssue(prefix, title string, id int) { - w = w.new(prefix, title) - w.mode = modeSingle - w.id = id - w.Ctl("cleartag") - w.Fprintf("tag", " Get Put Look ") - go w.load() - go w.loop() -} - -func (w *awin) newBulkEdit(body []byte) { - w = w.new(w.prefix, "bulk-edit/") - w.mode = modeBulk - w.query = "" - w.Ctl("cleartag") - w.Fprintf("tag", " New Get Sort Search ") - w.Write("body", append([]byte("Loading...\n\n"), body...)) - go w.load() - go w.loop() -} - -func (w *awin) newMilestoneList() { - w = w.new(w.prefix, "milestone") - w.mode = modeMilestone - w.query = "" - w.Ctl("cleartag") - w.Fprintf("tag", " New Get Sort Search ") - w.Write("body", []byte("Loading...")) - go w.load() - go w.loop() -} - -func (w *awin) newSearch(prefix, title, query string) { - w = w.new(prefix, title) - w.mode = modeQuery - w.query = query - w.Ctl("cleartag") - w.Fprintf("tag", " New Get Bulk Sort Search ") - w.Write("body", []byte("Loading...")) - go w.load() - go w.loop() -} - -var createTemplate = `Title: +const createTemplate = `Title: Assignee: Labels: Milestone: - - ` -func (w *awin) load() { - switch w.mode { - case modeCreate: - w.Clear() - w.Write("body", []byte(createTemplate)) - w.Ctl("clean") - - case modeSingle: - var buf bytes.Buffer - stop := w.Blink() - issue, err := showIssue(&buf, w.project(), w.id) - stop() - w.Clear() - if err != nil { - w.Write("body", []byte(err.Error())) - break - } - w.Write("body", buf.Bytes()) - w.Ctl("clean") - w.github = issue - - case modeMilestone: - stop := w.Blink() - milestones, err := loadMilestones(w.project()) - milecache.Lock() - if milecache.list == nil { - milecache.list = make(map[string][]*github.Milestone) - } - milecache.list[w.project()] = milestones - milecache.Unlock() - stop() - w.Clear() - if err != nil { - w.Fprintf("body", "Error loading milestones: %v\n", err) - break - } - var buf bytes.Buffer - for _, m := range milestones { - fmt.Fprintf(&buf, "%s\t%s\t%d\n", m.DueOn.Format("2006-01-02"), getString(m.Title), getInt(m.OpenIssues)) - } - w.PrintTabbed(buf.String()) - w.Ctl("clean") - - case modeQuery: - var buf bytes.Buffer - stop := w.Blink() - err := showQuery(&buf, w.project(), w.query) - if w.title == "all" { - cachedMilestones(w.project()) - } - stop() - w.Clear() - if err != nil { - w.Write("body", []byte(err.Error())) - break - } - if w.title == "all" { - var names []string - for _, m := range cachedMilestones(w.project()) { - names = append(names, getString(m.Title)) - } - if len(names) > 0 { - w.Fprintf("body", "Milestones: %s\n\n", strings.Join(names, " ")) - } - } - if w.title == "search" { - w.Fprintf("body", "Search %s\n\n", w.query) - } - w.PrintTabbed(buf.String()) - w.Ctl("clean") - - case modeBulk: - stop := w.Blink() - body, err := w.ReadAll("body") - if err != nil { - w.Err(fmt.Sprintf("%v", err)) - stop() - break - } - base, original, err := bulkEditStartFromText(w.project(), body) - stop() - if err != nil { - w.Err(fmt.Sprintf("%v", err)) - break - } - w.Clear() - w.PrintTabbed(string(original)) - w.Ctl("clean") - w.github = base - } - - w.Addr("0") - w.Ctl("dot=addr") - w.Ctl("show") -} - -func diff(line, field, old string) *string { - old = strings.TrimSpace(old) - line = strings.TrimSpace(strings.TrimPrefix(line, field)) - if old == line { - return nil - } - return &line -} - -func (w *awin) put() { - stop := w.Blink() - defer stop() - switch w.mode { - case modeSingle, modeCreate: - old := w.github - if w.mode == modeCreate { - old = new(github.Issue) - } - data, err := w.ReadAll("body") - if err != nil { - w.Err(fmt.Sprintf("Put: %v", err)) - return - } - issue, _, err := writeIssue(w.project(), old, data, false) - if err != nil { - w.Err(err.Error()) - return - } - if w.mode == modeCreate { - w.mode = modeSingle - w.id = getInt(issue.Number) - w.title = fmt.Sprint(w.id) - w.Name(w.prefix + w.title) - w.github = issue - } - w.load() - - case modeBulk: - data, err := w.ReadAll("body") - if err != nil { - w.Err(fmt.Sprintf("Put: %v", err)) - return - } - ids, err := bulkWriteIssue(w.project(), w.github, data, func(s string) { w.Err("Put: " + s) }) - if err != nil { - errText := strings.Replace(err.Error(), "\n", "\t\n", -1) - if len(ids) > 0 { - w.Err(fmt.Sprintf("updated %d issue%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText)) - break - } - w.Err(fmt.Sprintf("%s", errText)) - break - } - w.Err(fmt.Sprintf("updated %d issue%s", len(ids), suffix(len(ids)))) - - case modeMilestone: - w.Err("cannot Put milestone list") - - case modeQuery: - w.Err("cannot Put issue list") - } -} - -func (w *awin) sort() { - if err := w.Addr("0/^[0-9]/,"); err != nil { - w.Err("nothing to sort") - } - var less func(string, string) bool - if w.sortByNumber { - less = func(x, y string) bool { return lineNumber(x) > lineNumber(y) } - } else { - less = func(x, y string) bool { return skipField(x) < skipField(y) } - } - if err := w.Sort(less); err != nil { - w.Err(err.Error()) - } - w.Addr("0") - w.Ctl("dot=addr") - w.Ctl("show") -} - -func lineNumber(s string) int { - n := 0 - for j := 0; j < len(s) && '0' <= s[j] && s[j] <= '9'; j++ { - n = n*10 + int(s[j]-'0') - } - return n -} - -func skipField(s string) string { - i := strings.Index(s, "\t") - if i < 0 { - return s - } - for i < len(s) && s[i+1] == '\t' { - i++ - } - return s[i:] -} - func (w *awin) Execute(cmd string) bool { + if *debug { + fmt.Fprintln(os.Stderr, "Execute", cmd) + } switch cmd { case "Get": - w.load() + if err := w.Get(); err != nil { + w.Err(err.Error()) + } return true case "Put": - w.put() + w.Err("put tempoarily disabled sorry") return true - case "Del": - w.Ctl("del") - return true - case "New": - w.createIssue() - return true - case "Sort": - if w.mode != modeQuery { - w.Err("can only sort issue list windows") - break - } - w.sortByNumber = !w.sortByNumber - w.sort() - return true - case "Bulk": - // TODO(rsc): If Bulk has an argument, treat as search query and use results? - if w.mode != modeQuery { - w.Err("can only start bulk edit in issue list windows") - return true - } - text := w.Selection() - if text == "" { - data, err := w.ReadAll("body") - if err != nil { - w.Err(fmt.Sprintf("%v", err)) - return true - } - text = string(data) - } - w.newBulkEdit([]byte(text)) - return true } - if strings.HasPrefix(cmd, "Search ") { - w.newSearch(w.prefix, "search", strings.TrimSpace(strings.TrimPrefix(cmd, "Search"))) + w.Err("search temporarily disabled sorry") return true } - if strings.HasPrefix(cmd, "Milestone ") { - text := w.Selection() - w.setMilestone(strings.TrimSpace(strings.TrimPrefix(cmd, "Milestone")), text) - return true - } return false } -func (w *awin) loop() { - defer w.exit() - w.EventLoop(w) +func (w *awin) repository() (owner, repo string) { + dirs := strings.Split(w.name(), "/") + if len(dirs) == 1 { + return dirs[0], "" + } else if len(dirs) == 0 { + return "", "" + } + return dirs[0], dirs[1] } + +func (w *awin) name() string { + b, err := w.ReadAll("tag") + if err != nil { + w.Err(err.Error()) + return "" + } + fields := strings.Fields(string(b)) + return strings.TrimPrefix(fields[0], "/issue/") +} blob - /dev/null blob + 75b6cbf0f0043aac05aec8050229a83797ffce78 (mode 644) --- /dev/null +++ issue/github.go @@ -0,0 +1,223 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" +) + +const defaultBaseURL = "https://api.github.com" + +var client = &Client{ + baseURL: defaultBaseURL, + Client: http.DefaultClient, +} + +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 + Reactions Reactions +} + +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, " ")) + 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)) + _, 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"` + } + Reactions Reactions +} + +func printComments(w io.Writer, comments []Comment) error { + buf := &bytes.Buffer{} + for i, 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) + } + + // print blank line between each comment to tell them apart. + // except the last one. + if i < len(comments)-1 { + fmt.Fprintln(buf) + } + } + _, err := io.Copy(w, buf) + return err +} + +type Reactions struct { + PlusOne int `json:"+1"` + MinusOne int `json:"-1"` + Laugh int + Confused int + Heart int + Hooray int + Rocket int + Eyes int +} + +func (r Reactions) String() string { + var buf bytes.Buffer + add := func(s string, n int) { + if n != 0 { + if buf.Len() != 0 { + buf.WriteString(" ") + } + fmt.Fprintf(&buf, "%s %d", s, n) + } + } + add("πŸ‘", r.PlusOne) + add("πŸ‘Ž", r.MinusOne) + add("πŸ˜†", r.Laugh) + add("πŸ˜•", r.Confused) + add("β™₯", r.Heart) + add("πŸŽ‰", r.Hooray) + add("πŸš€", r.Rocket) + add("πŸ‘€", r.Eyes) + return buf.String() +} +func (c *Client) LookupIssue(owner, repo string, number int) (*Issue, error) { + var issue Issue + p := fmt.Sprintf("/repos/%s/%s/issues/%d", owner, repo, number) + err := c.get(p, &issue) + return &issue, err +} + +func (c *Client) Issues(owner, repo string) ([]Issue, error) { + var issues []Issue + p := path.Join("repos", owner, repo, "issues") + err := c.get(p, &issues) + return issues, err +} + +func (c *Client) SearchIssues(owner, repo, query string) ([]Issue, error) { + u, err := url.Parse(c.baseURL + "/search/issues") + if err != nil { + return nil, err + } + prefix := fmt.Sprintf("type:issue repo:%s/%s", owner, repo) + v := make(url.Values) + v.Set("q", prefix+" "+query) + u.RawQuery = v.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + // TODO: check http status codes etc. + if resp.StatusCode > 399 { + return nil, errors.New(resp.Status) + } + var hits = struct { + Count int `json:"total_count"` + Issues []Issue `json:"items"` + }{} + if err := json.NewDecoder(resp.Body).Decode(&hits); err != nil { + return nil, fmt.Errorf("decode search response: %w", err) + } + return hits.Issues, nil +} + +func (c *Client) Comments(owner, repo string, issue int) ([]Comment, error) { + var comments []Comment + p := fmt.Sprintf("/repos/%s/%s/issues/%d/comments", owner, repo, issue) + err := c.get(p, &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) +} blob - 52eeeb6a32e50fb28a383ced92a674734ba58772 (mode 644) blob + /dev/null --- issue/doc.go +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright 2015 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -/* -Issue is a client for reading and updating issues in a GitHub project issue tracker. - - usage: issue [-a] [-e] [-p owner/repo] - -Issue runs the query against the given project's issue tracker and -prints a table of matching issues, sorted by issue summary. -The default owner/repo is golang/go. - -If multiple arguments are given as the query, issue joins them by -spaces to form a single issue search. These two commands are equivalent: - - issue assignee:rsc author:robpike - issue "assignee:rsc author:robpike" - -Searches are always limited to open issues. - -If the query is a single number, issue prints that issue in detail, -including all comments. - -# Authentication - -Issue expects to find a GitHub "personal access token" in -$HOME/.github-issue-token and will use that token to authenticate -to GitHub when reading or writing issue data. -A token can be created by visiting https://github.com/settings/tokens/new. -The token only needs the 'repo' scope checkbox, and optionally 'private_repo' -if you want to work with issue trackers for private repositories. -It does not need any other permissions. -The -token flag specifies an alternate file from which to read the token. - -# Acme Editor Integration - -If the -a flag is specified, issue runs as a collection of acme windows -instead of a command-line tool. In this mode, the query is optional. -If no query is given, issue uses "state:open". - -This mode requires an existing plumb port called "githubissue", which can -be created with the following plumbing rule: - - plumb to githubissue - -There are three kinds of acme windows: issue, issue creation, issue list, -search result, and milestone list. - -The following text forms can be looked for (right clicked on) -and open a window (or navigate to an existing one). - - nnnn issue #nnnn - #nnnn issue #nnnn - all the issue list - milestone(s) the milestone list - the named milestone (e.g., Go1.5) - -Executing "New" opens an issue creation window. - -Executing "Search " opens a new window showing the -results of that search. - -# Issue Window - -An issue window, opened by loading an issue number, -displays full detail about an issue, a header followed by each comment. -For example: - - Title: time: Duration should implement fmt.Formatter - State: closed - Assignee: robpike - Closed: 2015-01-08 05:20:00 - Labels: release-none repo-main size-m - Milestone: - URL: https://github.com/golang/go/issues/8786 - - Reported by dsymonds (2014-09-21 23:02:50) - - It'd be nice if http://play.golang.org/p/KCnUQOPyol - printed "[+3us]", which would require time.Duration - implementing fmt.Formatter to get the '+' flag. - - Comment by rsc (2015-01-08 05:17:06) - - time must not depend on fmt. - -Executing "Get" reloads the issue data. - -Executing "Put" updates an issue. It saves any changes to the issue header -and, if any text has been entered between the header and the "Reported by" line, -posts that text as a new comment. If both succeed, Put then reloads the issue data. -The "Closed" and "URL" headers cannot be changed. - -# Issue Creation Window - -An issue creation window, opened by executing "New", is like an issue window -but displays only an empty issue template: - - Title: - Assignee: - Labels: - Milestone: - - - -Once the template has been completed (only the title is required), executing "Put" -creates the issue and converts the window into a issue window for the new issue. - -# Issue List Window - -An issue list window displays a list of all open issue numbers and titles. -If the project has any open milestones, they are listed in a header line. -For example: - - Milestones: Go1.4.1 Go1.5 Go1.5Maybe - - 9027 archive/tar: round-trip of Header misses values - 8669 archive/zip: not possible to a start writing zip at offset other than zero - 8359 archive/zip: not possible to specify deflate compression level - ... - -As in any window, right clicking on an issue number opens a window for that issue. - -# Search Result Window - -A search result window, opened by executing "Search ", displays a list of issues -matching a search query. It shows the query in a header line. For example: - - Search author:rsc - - 9131 bench: no documentation - 599 cmd/5c, 5g, 8c, 8g: make 64-bit fields 64-bit aligned - 6699 cmd/5l: use m to store div/mod denominator - 4997 cmd/6a, cmd/8a: MOVL $x-8(SP) and LEAL x-8(SP) are different - ... - -Executing "Sort" in a search result window toggles between sorting by title -and sorting by decreasing issue number. - -# Bulk Edit Window - -Executing "Bulk" in an issue list or search result window opens a new -bulk edit window applying to the displayed issues. If there is a non-empty -text selection in the issue list or search result list, the bulk edit window -is restricted to issues in the selection. - -The bulk edit window consists of a metadata header followed by a list of issues, like: - - State: open - Assignee: - Labels: - Milestone: Go1.4.3 - - 10219 cmd/gc: internal compiler error: agen: unknown op - 9711 net/http: Testing timeout on Go1.4.1 - 9576 runtime: crash in checkdead - 9954 runtime: invalid heap pointer found in bss on openbsd/386 - -The metadata header shows only metadata shared by all the issues. -In the above example, all four issues are open and have milestone Go1.4.3, -but they have no common labels nor a common assignee. - -The bulk edit applies to the issues listed in the window text; adding or removing -issue lines changes the set of issues affected by Get or Put operations. - -Executing "Get" refreshes the metadata header and issue summaries. - -Executing "Put" updates all the listed issues. It applies any changes made to -the metadata header and, if any text has been entered between the header -and the first issue line, posts that text as a comment. If all operations succeed, -Put then refreshes the window as Get does. - -# Milestone List Window - -The milestone list window, opened by loading any of the names -"milestone", "Milestone", or "Milestones", displays the open project -milestones, sorted by due date, along with the number of open issues in each. -For example: - - 2015-01-15 Go1.4.1 1 - 2015-07-31 Go1.5 215 - 2015-07-31 Go1.5Maybe 5 - -Loading one of the listed milestone names opens a search for issues -in that milestone. - -# Alternate Editor Integration - -The -e flag enables basic editing of issues with editors other than acme. -The editor invoked is $VISUAL if set, $EDITOR if set, or else ed. -Issue prepares a textual representation of issue data in a temporary file, -opens that file in the editor, waits for the editor to exit, and then applies any -changes from the file to the actual issues. - -When is a single number, issue -e edits a single issue. -See the β€œIssue Window” section above. - -If the is the text "new", issue -e creates a new issue. -See the β€œIssue Creation Window” section above. - -Otherwise, for general queries, issue -e edits multiple issues in bulk. -See the β€œBulk Edit Window” section above. - -# JSON Output - -The -json flag causes issue to print the results in JSON format -using these data structures: - - type Issue struct { - Number int - Ref string - Title string - State string - Assignee string - Closed time.Time - Labels []string - Milestone string - URL string - Reporter string - Created time.Time - Text string - Comments []*Comment - Reactions Reactions - } - - type Comment struct { - Author string - Time time.Time - Text string - Reactions Reactions - } - - type Reactions struct { - PlusOne int - MinusOne int - Laugh int - Confused int - Heart int - Hooray int - Rocket int - Eyes int - } - -If asked for a specific issue, the output is an Issue with Comments. -Otherwise, the result is an array of Issues without Comments. -*/ -package main blob - /dev/null blob + fb0109b3954d885d4de07e2e544db0ad77599cbf (mode 644) --- /dev/null +++ issue/github_test.go @@ -0,0 +1,33 @@ +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 []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) +} blob - 9b5a4f67b3590cb179e1d122740d82733e2a2e0a (mode 644) blob + /dev/null --- issue/edit.go +++ /dev/null @@ -1,519 +0,0 @@ -// Copyright 2015 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package main - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "log" - "os" - "os/exec" - "strconv" - "strings" - "time" - - "github.com/google/go-github/v63/github" -) - -func editIssue(project string, original []byte, issue *github.Issue) { - updated := editText(original) - if bytes.Equal(original, updated) { - log.Print("no changes made") - return - } - - newIssue, _, err := writeIssue(project, issue, updated, false) - if err != nil { - log.Fatal(err) - } - if newIssue != nil { - issue = newIssue - } - log.Printf("https://github.com/%s/issues/%d updated", project, getInt(issue.Number)) -} - -func editText(original []byte) []byte { - f, err := os.CreateTemp("", "issue-edit-") - if err != nil { - log.Fatal(err) - } - if err := os.WriteFile(f.Name(), original, 0600); err != nil { - log.Fatal(err) - } - if err := runEditor(f.Name()); err != nil { - log.Fatal(err) - } - updated, err := os.ReadFile(f.Name()) - if err != nil { - log.Fatal(err) - } - name := f.Name() - f.Close() - os.Remove(name) - return updated -} - -func runEditor(filename string) error { - ed := os.Getenv("VISUAL") - if ed == "" { - ed = os.Getenv("EDITOR") - } - if ed == "" { - ed = "ed" - } - - // If the editor contains spaces or other magic shell chars, - // invoke it as a shell command. This lets people have - // environment variables like "EDITOR=emacs -nw". - // The magic list of characters and the idea of running - // sh -c this way is taken from git/run-command.c. - var cmd *exec.Cmd - if strings.ContainsAny(ed, "|&;<>()$`\\\"' \t\n*?[#~=%") { - cmd = exec.Command("sh", "-c", ed+` "$@"`, "$EDITOR", filename) - } else { - cmd = exec.Command(ed, filename) - } - - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("invoking editor: %v", err) - } - return nil -} - -const bulkHeader = "\nBulk editing these issues:" - -func writeIssue(project string, old *github.Issue, updated []byte, isBulk bool) (issue *github.Issue, rate *github.Rate, err error) { - var errbuf bytes.Buffer - defer func() { - if errbuf.Len() > 0 { - err = errors.New(strings.TrimSpace(errbuf.String())) - } - }() - - sdata := string(updated) - off := 0 - var edit github.IssueRequest - var addLabels, removeLabels []string - for _, line := range strings.SplitAfter(sdata, "\n") { - off += len(line) - line = strings.TrimSpace(line) - if line == "" { - break - } - switch { - case strings.HasPrefix(line, "#"): - continue - - case strings.HasPrefix(line, "Title:"): - edit.Title = diff(line, "Title:", getString(old.Title)) - - case strings.HasPrefix(line, "State:"): - edit.State = diff(line, "State:", getString(old.State)) - - case strings.HasPrefix(line, "Assignee:"): - edit.Assignee = diff(line, "Assignee:", getUserLogin(old.Assignee)) - - case strings.HasPrefix(line, "Closed:"): - continue - - case strings.HasPrefix(line, "Labels:"): - if isBulk { - addLabels, removeLabels = diffList2(line, "Labels:", getLabelNames(old.Labels)) - } else { - edit.Labels = diffList(line, "Labels:", getLabelNames(old.Labels)) - } - - case strings.HasPrefix(line, "Milestone:"): - edit.Milestone = findMilestone(&errbuf, project, diff(line, "Milestone:", getMilestoneTitle(old.Milestone))) - - case strings.HasPrefix(line, "URL:"): - continue - - case strings.HasPrefix(line, "Reactions:"): - continue - - default: - fmt.Fprintf(&errbuf, "unknown summary line: %s\n", line) - } - } - - if errbuf.Len() > 0 { - return nil, nil, nil - } - - if getInt(old.Number) == 0 { - comment := strings.TrimSpace(sdata[off:]) - edit.Body = &comment - issue, resp, err := client.Issues.Create(context.TODO(), projectOwner(project), projectRepo(project), &edit) - if resp != nil { - rate = &resp.Rate - } - if err != nil { - fmt.Fprintf(&errbuf, "error creating issue: %v\n", err) - return nil, rate, nil - } - return issue, rate, nil - } - - if getInt(old.Number) == -1 { - // Asking to just sanity check the text parsing. - return nil, nil, nil - } - - marker := "\nReported by " - if isBulk { - marker = bulkHeader - } - var comment string - if i := strings.Index(sdata, marker); i >= off { - comment = strings.TrimSpace(sdata[off:i]) - } - - if comment == "" { - comment = "" - } - - var failed bool - var did []string - if comment != "" { - _, resp, err := client.Issues.CreateComment(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), &github.IssueComment{ - Body: &comment, - }) - if resp != nil { - rate = &resp.Rate - } - if err != nil { - fmt.Fprintf(&errbuf, "error saving comment: %v\n", err) - failed = true - } else { - did = append(did, "saved comment") - } - } - - if edit.Title != nil || edit.State != nil || edit.Assignee != nil || edit.Labels != nil || edit.Milestone != nil { - _, resp, err := client.Issues.Edit(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), &edit) - if resp != nil { - rate = &resp.Rate - } - if err != nil { - fmt.Fprintf(&errbuf, "error changing metadata: %v\n", err) - failed = true - } else { - did = append(did, "updated metadata") - } - } - if len(addLabels) > 0 { - _, resp, err := client.Issues.AddLabelsToIssue(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), addLabels) - if resp != nil { - rate = &resp.Rate - } - if err != nil { - fmt.Fprintf(&errbuf, "error adding labels: %v\n", err) - failed = true - } else { - if len(addLabels) == 1 { - did = append(did, "added label "+addLabels[0]) - } else { - did = append(did, "added labels") - } - } - } - if len(removeLabels) > 0 { - for _, label := range removeLabels { - resp, err := client.Issues.RemoveLabelForIssue(context.TODO(), projectOwner(project), projectRepo(project), getInt(old.Number), label) - if resp != nil { - rate = &resp.Rate - } - if err != nil { - fmt.Fprintf(&errbuf, "error removing label %s: %v\n", label, err) - failed = true - } else { - did = append(did, "removed label "+label) - } - } - } - - if failed && len(did) > 0 { - var buf bytes.Buffer - fmt.Fprintf(&buf, "%s", did[0]) - for i := 1; i < len(did)-1; i++ { - fmt.Fprintf(&buf, ", %s", did[i]) - } - if len(did) >= 2 { - if len(did) >= 3 { - fmt.Fprintf(&buf, ",") - } - fmt.Fprintf(&buf, " and %s", did[len(did)-1]) - } - all := buf.Bytes() - all[0] -= 'a' - 'A' - fmt.Fprintf(&errbuf, "(%s successfully.)\n", all) - } - return -} - -func diffList(line, field string, old []string) *[]string { - line = strings.TrimSpace(strings.TrimPrefix(line, field)) - had := make(map[string]bool) - for _, f := range old { - had[f] = true - } - changes := false - for _, f := range strings.Fields(line) { - if !had[f] { - changes = true - } - delete(had, f) - } - if len(had) != 0 { - changes = true - } - if changes { - ret := strings.Fields(line) - if ret == nil { - ret = []string{} - } - return &ret - } - return nil -} - -func diffList2(line, field string, old []string) (added, removed []string) { - line = strings.TrimSpace(strings.TrimPrefix(line, field)) - had := make(map[string]bool) - for _, f := range old { - had[f] = true - } - for _, f := range strings.Fields(line) { - if !had[f] { - added = append(added, f) - } - delete(had, f) - } - if len(had) != 0 { - for _, f := range old { - if had[f] { - removed = append(removed, f) - } - } - } - return -} - -func findMilestone(w io.Writer, project string, name *string) *int { - if name == nil { - return nil - } - - all, err := loadMilestones(project) - if err != nil { - fmt.Fprintf(w, "Error loading milestone list: %v\n\tIgnoring milestone change.\n", err) - return nil - } - - for _, m := range all { - if getString(m.Title) == *name { - return m.Number - } - } - - fmt.Fprintf(w, "Unknown milestone: %s\n", *name) - return nil -} - -func readBulkIDs(text []byte) []int { - var ids []int - for _, line := range strings.Split(string(text), "\n") { - if i := strings.Index(line, "\t"); i >= 0 { - line = line[:i] - } - if i := strings.Index(line, " "); i >= 0 { - line = line[:i] - } - n, err := strconv.Atoi(line) - if err != nil { - continue - } - ids = append(ids, n) - } - return ids -} - -func bulkEditStartFromText(project string, content []byte) (base *github.Issue, original []byte, err error) { - ids := readBulkIDs(content) - if len(ids) == 0 { - return nil, nil, fmt.Errorf("found no issues in selection") - } - issues, err := bulkReadIssuesCached(project, ids) - if err != nil { - return nil, nil, err - } - base, original = bulkEditStart(issues) - return base, original, nil -} - -func suffix(n int) string { - if n == 1 { - return "" - } - return "s" -} - -func bulkEditIssues(project string, issues []*github.Issue) { - base, original := bulkEditStart(issues) - updated := editText(original) - if bytes.Equal(original, updated) { - log.Print("no changes made") - return - } - ids, err := bulkWriteIssue(project, base, updated, func(s string) { log.Print(s) }) - if err != nil { - errText := strings.Replace(err.Error(), "\n", "\t\n", -1) - if len(ids) > 0 { - log.Fatalf("updated %d issue%s with errors:\n\t%v", len(ids), suffix(len(ids)), errText) - } - log.Fatal(errText) - } - log.Printf("updated %d issue%s", len(ids), suffix(len(ids))) -} - -func bulkEditStart(issues []*github.Issue) (*github.Issue, []byte) { - common := new(github.Issue) - for i, issue := range issues { - if i == 0 { - common.State = issue.State - common.Assignee = issue.Assignee - common.Labels = issue.Labels - common.Milestone = issue.Milestone - continue - } - if common.State != nil && getString(common.State) != getString(issue.State) { - common.State = nil - } - if common.Assignee != nil && getUserLogin(common.Assignee) != getUserLogin(issue.Assignee) { - common.Assignee = nil - } - if common.Milestone != nil && getMilestoneTitle(common.Milestone) != getMilestoneTitle(issue.Milestone) { - common.Milestone = nil - } - common.Labels = commonLabels(common.Labels, issue.Labels) - } - - var buf bytes.Buffer - fmt.Fprintf(&buf, "State: %s\n", getString(common.State)) - fmt.Fprintf(&buf, "Assignee: %s\n", getUserLogin(common.Assignee)) - fmt.Fprintf(&buf, "Labels: %s\n", strings.Join(getLabelNames(common.Labels), " ")) - fmt.Fprintf(&buf, "Milestone: %s\n", getMilestoneTitle(common.Milestone)) - fmt.Fprintf(&buf, "\n\n") - fmt.Fprintf(&buf, "%s\n", bulkHeader) - for _, issue := range issues { - fmt.Fprintf(&buf, "%d\t%s\n", getInt(issue.Number), getString(issue.Title)) - } - - return common, buf.Bytes() -} - -func commonString(x, y string) string { - if x != y { - x = "" - } - return x -} - -func commonLabels(x, y []*github.Label) []*github.Label { - if len(x) == 0 || len(y) == 0 { - return nil - } - have := make(map[string]bool) - for _, lab := range y { - have[getString(lab.Name)] = true - } - var out []*github.Label - for _, lab := range x { - if have[getString(lab.Name)] { - out = append(out, lab) - } - } - return out -} - -func bulkWriteIssue(project string, old *github.Issue, updated []byte, status func(string)) (ids []int, err error) { - i := bytes.Index(updated, []byte(bulkHeader)) - if i < 0 { - return nil, fmt.Errorf("cannot find bulk edit issue list") - } - ids = readBulkIDs(updated[i:]) - if len(ids) == 0 { - return nil, fmt.Errorf("found no issues in bulk edit issue list") - } - - // Make a copy of the issue to modify. - x := *old - old = &x - - // Try a write to issue -1, checking for formatting only. - old.Number = new(int) - *old.Number = -1 - _, rate, err := writeIssue(project, old, updated, true) - if err != nil { - return nil, err - } - - // Apply to all issues in list. - suffix := "" - if len(ids) != 1 { - suffix = "s" - } - status(fmt.Sprintf("updating %d issue%s", len(ids), suffix)) - - failed := false - for index, number := range ids { - if index%10 == 0 && index > 0 { - status(fmt.Sprintf("updated %d/%d issues", index, len(ids))) - } - // Check rate limits here (in contrast to everywhere else in this program) - // to avoid needless failure halfway through the loop. - for rate != nil && rate.Limit > 0 && rate.Remaining == 0 { - delta := (rate.Reset.Sub(time.Now())/time.Minute + 2) * time.Minute - if delta < 0 { - delta = 2 * time.Minute - } - status(fmt.Sprintf("updated %d/%d issues; pausing %d minutes to respect GitHub rate limit", index, len(ids), int(delta/time.Minute))) - time.Sleep(delta) - limits, _, err := client.RateLimits(context.TODO()) - if err != nil { - status(fmt.Sprintf("reading rate limit: %v", err)) - } - rate = nil - if limits != nil { - rate = limits.Core - } - } - *old.Number = number - if _, rate, err = writeIssue(project, old, updated, true); err != nil { - status(fmt.Sprintf("writing #%d: %s", number, strings.Replace(err.Error(), "\n", "\n\t", -1))) - failed = true - } - } - - if failed { - return ids, fmt.Errorf("failed to update all issues") - } - return ids, nil -} - -func projectOwner(project string) string { - return project[:strings.Index(project, "/")] -} - -func projectRepo(project string) string { - return project[strings.Index(project, "/")+1:] -} blob - a877ea0e120b13ddf565ac268f6693cb4c1a267b (mode 644) blob + /dev/null --- issue/gh.go +++ /dev/null @@ -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) -} blob - 62986ef887a033a167612c9f9a553c4aea78268d (mode 644) blob + /dev/null --- issue/gh_test.go +++ /dev/null @@ -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) -} blob - 3a9f812b3b00d9f13d54eca7d686b98be485baee blob + b9f3828159d8d2355462b82b8be655c17ddc4abe --- issue/issue.go +++ issue/issue.go @@ -1,13 +1,7 @@ -// Copyright 2015 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. +package main -package main // import "rsc.io/github/issue" - import ( "bytes" - "context" - "encoding/json" "flag" "fmt" "io" @@ -15,32 +9,20 @@ import ( "net/http" "os" "path/filepath" - "sort" - "strconv" "strings" "sync" "time" - "github.com/google/go-github/v63/github" - "golang.org/x/oauth2" + "9fans.net/go/acme" ) 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") + project = flag.String("p", "golang/go", "GitHub owner/repo name") + debug = flag.Bool("d", false, "debug info to standard error") ) func usage() { - fmt.Fprintf(os.Stderr, `usage: issue [-a] [-e] [-p owner/repo] - -If query is a single number, prints the full history for the issue. -Otherwise, prints a table of matching results. -`) + fmt.Fprintln(os.Stderr, "usage: issue [-d] [-p owner/repo]") flag.PrintDefaults() os.Exit(2) } @@ -51,18 +33,7 @@ func main() { log.SetFlags(0) log.SetPrefix("issue: ") - if flag.NArg() == 0 && !*acmeFlag { - usage() - } - - if *jsonFlag && *acmeFlag { - log.Fatal("cannot use -a with -json") - } - if *jsonFlag && *editFlag { - log.Fatal("cannot use -e with -acme") - } - - if *logHTTP { + if *debug { http.DefaultTransport = newLogger(http.DefaultTransport) } @@ -75,379 +46,73 @@ func main() { if err != nil { log.Fatalln("find config dir:", err) } - if err := loadAuth(filepath.Join(confDir, "github", "token")); err != nil { + b, err := os.ReadFile(filepath.Join(confDir, "github", "finetoken")) + if err != nil { log.Fatalln("load auth:", err) } + client.Token = string(b) - if *acmeFlag { - acmeMode() - } - - q := strings.Join(flag.Args(), " ") - - if *editFlag && q == "new" { - editIssue(*project, []byte(createTemplate), new(github.Issue)) - return - } - - n, _ := strconv.Atoi(q) - if n != 0 { - if *editFlag { - var buf bytes.Buffer - issue, err := showIssue(&buf, *project, n) - if err != nil { - log.Fatal(err) - } - editIssue(*project, buf.Bytes(), issue) - return - } - if _, err := showIssue(os.Stdout, *project, n); err != nil { - log.Fatal(err) - } - return - } - - if *editFlag { - all, err := searchIssues(*project, q) - if err != nil { - log.Fatal(err) - } - if len(all) == 0 { - log.Fatal("no issues matched search") - } - sort.Sort(issuesByTitle(all)) - bulkEditIssues(*project, all) - return - } - - if err := showQuery(os.Stdout, *project, q); err != nil { + acme.AutoExit(true) + win, err := acme.New() + if err != nil { log.Fatal(err) } -} -func showIssue(w io.Writer, project string, n int) (*github.Issue, error) { - issue, _, err := client.Issues.Get(context.TODO(), projectOwner(project), projectRepo(project), n) - if err != nil { - return nil, err - } - updateIssueCache(project, issue) - return issue, printIssue(w, project, issue) -} + wname := fmt.Sprintf("/issue/%s/search", *project) + win.Name(wname) -func printIssue(w io.Writer, project string, issue *github.Issue) error { - if *jsonFlag { - showJSONIssue(w, project, issue) - return nil - } + var buf bytes.Buffer + query := "state:open" + showQuery(&buf, *project, query) + win.Fprintf("tag", "Search %s", query) + win.Write("body", buf.Bytes()) - fmt.Fprintf(w, "Title: %s\n", getString(issue.Title)) - fmt.Fprintf(w, "State: %s\n", getString(issue.State)) - fmt.Fprintf(w, "Assignee: %s\n", getUserLogin(issue.Assignee)) - if issue.ClosedAt != nil { - fmt.Fprintf(w, "Closed: %s\n", issue.ClosedAt.Format(time.DateTime)) - } - fmt.Fprintf(w, "Labels: %s\n", strings.Join(getLabelNames(issue.Labels), " ")) - fmt.Fprintf(w, "Milestone: %s\n", getMilestoneTitle(issue.Milestone)) - fmt.Fprintf(w, "URL: %s\n", getString(issue.HTMLURL)) - fmt.Fprintf(w, "Reactions: %v\n", getReactions(issue.Reactions)) + rootwin := &awin{win} + rootwin.Addr("#0") + rootwin.Ctl("dot=addr") + rootwin.Ctl("show") + rootwin.Ctl("clean") + rootwin.EventLoop(rootwin) +} - fmt.Fprintf(w, "\nReported by %s (%s)\n", getUserLogin(issue.User), issue.CreatedAt.Format(time.DateTime)) - if issue.Body != nil { - if *rawFlag { - fmt.Fprintf(w, "\n%s\n\n", *issue.Body) - } else { - text := strings.TrimSpace(*issue.Body) - if text != "" { - fmt.Fprintf(w, "\n\t%s\n", wrap(text, "\t")) - } - } +func showIssue(w io.Writer, project string, n int) error { + owner, repo, _ := strings.Cut(project, "/") + gissue, err := client.LookupIssue(owner, repo, n) + if err != nil { + return err } - - var output []string - - for page := 1; ; { - list, resp, err := client.Issues.ListComments(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.IssueListCommentsOptions{ - ListOptions: github.ListOptions{ - Page: page, - PerPage: 100, - }, - }) - for _, com := range list { - var buf bytes.Buffer - w := &buf - fmt.Fprintf(w, "%s\n", com.CreatedAt.Format(time.RFC3339)) - fmt.Fprintf(w, "\nComment by %s (%s)\n", getUserLogin(com.User), com.CreatedAt.Format(time.DateTime)) - if com.Body != nil { - if *rawFlag { - fmt.Fprintf(w, "\n%s\n\n", *com.Body) - } else { - text := strings.TrimSpace(*com.Body) - if text != "" { - fmt.Fprintf(w, "\n\t%s\n", wrap(text, "\t")) - } - } - } - if r := getReactions(com.Reactions); r != (Reactions{}) { - fmt.Fprintf(w, "\n\t%v\n", r) - } - - output = append(output, buf.String()) - } - if err != nil { - return err - } - if resp.NextPage < page { - break - } - page = resp.NextPage + if err := printIssue(w, gissue); err != nil { + return fmt.Errorf("print g issue: %w", err) } - for page := 1; ; { - list, resp, err := client.Issues.ListIssueEvents(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.ListOptions{ - Page: page, - PerPage: 100, - }) - for _, ev := range list { - var buf bytes.Buffer - w := &buf - fmt.Fprintf(w, "%s\n", ev.CreatedAt.Format(time.RFC3339)) - switch event := getString(ev.Event); event { - case "mentioned", "subscribed", "unsubscribed": - // ignore - default: - fmt.Fprintf(w, "\n* %s %s (%s)\n", getUserLogin(ev.Actor), event, ev.CreatedAt.Format(time.DateTime)) - case "closed", "referenced", "merged": - id := getString(ev.CommitID) - if id != "" { - if len(id) > 7 { - id = id[:7] - } - id = " in commit " + id - } - fmt.Fprintf(w, "\n* %s %s%s (%s)\n", getUserLogin(ev.Actor), event, id, ev.CreatedAt.Format(time.DateTime)) - if id != "" { - commit, _, err := client.Git.GetCommit(context.TODO(), projectOwner(project), projectRepo(project), *ev.CommitID) - if err == nil { - fmt.Fprintf(w, "\n\tAuthor: %s <%s> %s\n\tCommitter: %s <%s> %s\n\n\t%s\n", - getString(commit.Author.Name), getString(commit.Author.Email), commit.Author.Date.Format(time.DateTime), - getString(commit.Committer.Name), getString(commit.Committer.Email), commit.Committer.Date.Format(time.DateTime), - wrap(getString(commit.Message), "\t")) - } - } - case "assigned", "unassigned": - fmt.Fprintf(w, "\n* %s %s %s (%s)\n", getUserLogin(ev.Actor), event, getUserLogin(ev.Assignee), ev.CreatedAt.Format(time.DateTime)) - case "labeled", "unlabeled": - fmt.Fprintf(w, "\n* %s %s %s (%s)\n", getUserLogin(ev.Actor), event, getString(ev.Label.Name), ev.CreatedAt.Format(time.DateTime)) - case "milestoned", "demilestoned": - if event == "milestoned" { - event = "added to milestone" - } else { - event = "removed from milestone" - } - fmt.Fprintf(w, "\n* %s %s %s (%s)\n", getUserLogin(ev.Actor), event, getString(ev.Milestone.Title), ev.CreatedAt.Format(time.DateTime)) - case "renamed": - fmt.Fprintf(w, "\n* %s changed title (%s)\n - %s\n + %s\n", getUserLogin(ev.Actor), ev.CreatedAt.Format(time.DateTime), getString(ev.Rename.From), getString(ev.Rename.To)) - } - output = append(output, buf.String()) - } - if err != nil { - return err - } - if resp.NextPage < page { - break - } - page = resp.NextPage - } + fmt.Fprintf(w, "\n---\n\n") - sort.Strings(output) - for _, s := range output { - i := strings.Index(s, "\n") - fmt.Fprintf(w, "%s", s[i+1:]) + comments, err := client.Comments(owner, repo, n) + if err != nil { + return fmt.Errorf("load issue %d comments: %w", n, err) } - + if err := printComments(w, comments); err != nil { + return fmt.Errorf("print issue %d comments: %w", n, err) + } return nil } func showQuery(w io.Writer, project, q string) error { - all, err := searchIssues(project, q) + owner, repo, _ := strings.Cut(project, "/") + hits, err := client.SearchIssues(owner, repo, q) if err != nil { return err } - sort.Sort(issuesByTitle(all)) - if *jsonFlag { - showJSONList(project, all) - return nil + for _, issue := range hits { + fmt.Fprintf(w, "%d\t%s\n", issue.Number, issue.Title) } - for _, issue := range all { - fmt.Fprintf(w, "%v\t%v\n", getInt(issue.Number), getString(issue.Title)) - } return nil } -type issuesByTitle []*github.Issue - -func (x issuesByTitle) Len() int { return len(x) } -func (x issuesByTitle) Swap(i, j int) { x[i], x[j] = x[j], x[i] } -func (x issuesByTitle) Less(i, j int) bool { - if getString(x[i].Title) != getString(x[j].Title) { - return getString(x[i].Title) < getString(x[j].Title) - } - return getInt(x[i].Number) < getInt(x[j].Number) -} - -func searchIssues(project, q string) ([]*github.Issue, error) { - if opt, ok := queryToListOptions(project, q); ok { - return listRepoIssues(project, opt) - } - - var all []*github.Issue - for page := 1; ; { - // TODO(rsc): Rethink excluding pull requests. - x, resp, err := client.Search.Issues(context.TODO(), "type:issue state:open repo:"+project+" "+q, &github.SearchOptions{ - ListOptions: github.ListOptions{ - Page: page, - PerPage: 100, - }, - }) - for i := range x.Issues { - updateIssueCache(project, x.Issues[i]) - all = append(all, x.Issues[i]) - } - if err != nil { - return all, err - } - if resp.NextPage < page { - break - } - page = resp.NextPage - } - return all, nil -} - -func queryToListOptions(project, q string) (opt github.IssueListByRepoOptions, ok bool) { - if strings.ContainsAny(q, `"'`) { - return - } - for _, f := range strings.Fields(q) { - i := strings.Index(f, ":") - if i < 0 { - return - } - key, val := f[:i], f[i+1:] - switch key { - default: - return - case "milestone": - if opt.Milestone != "" || val == "" { - return - } - id := findMilestone(io.Discard, project, &val) - if id == nil { - return - } - opt.Milestone = fmt.Sprint(*id) - case "state": - if opt.State != "" || val == "" { - return - } - opt.State = val - case "assignee": - if opt.Assignee != "" || val == "" { - return - } - opt.Assignee = val - case "author": - if opt.Creator != "" || val == "" { - return - } - opt.Creator = val - case "mentions": - if opt.Mentioned != "" || val == "" { - return - } - opt.Mentioned = val - case "label": - if opt.Labels != nil || val == "" { - return - } - opt.Labels = strings.Split(val, ",") - case "sort": - if opt.Sort != "" || val == "" { - return - } - opt.Sort = val - case "updated": - if !opt.Since.IsZero() || !strings.HasPrefix(val, ">=") { - return - } - // TODO: Can set Since if we parse val[2:]. - return - case "no": - switch val { - default: - return - case "milestone": - if opt.Milestone != "" { - return - } - opt.Milestone = "none" - } - } - } - return opt, true -} - -func listRepoIssues(project string, opt github.IssueListByRepoOptions) ([]*github.Issue, error) { - var all []*github.Issue - for page := 1; ; { - xopt := opt - xopt.ListOptions = github.ListOptions{ - Page: page, - PerPage: 100, - } - issues, resp, err := client.Issues.ListByRepo(context.TODO(), projectOwner(project), projectRepo(project), &xopt) - for i := range issues { - updateIssueCache(project, issues[i]) - all = append(all, issues[i]) - } - if err != nil { - return all, err - } - if resp.NextPage < page { - break - } - page = resp.NextPage - } - - // Filter out pull requests, since we cannot say type:issue like in searchIssues. - // TODO(rsc): Rethink excluding pull requests. - save := all[:0] - for _, issue := range all { - if issue.PullRequestLinks == nil { - save = append(save, issue) - } - } - return save, nil -} - -func loadMilestones(project string) ([]*github.Milestone, error) { - // NOTE(rsc): There appears to be no paging possible. - all, _, err := client.Issues.ListMilestones(context.TODO(), projectOwner(project), projectRepo(project), &github.MilestoneListOptions{ - State: "open", - }) - if err != nil { - return nil, err - } - if all == nil { - all = []*github.Milestone{} - } - return all, nil -} - func wrap(t string, prefix string) string { out := "" t = strings.Replace(t, "\r\n", "\n", -1) - max := 70 + max := 80 lines := strings.Split(t, "\n") for i, line := range lines { if i > 0 { @@ -468,25 +133,6 @@ func wrap(t string, prefix string) string { return out } -var client *github.Client - -func loadAuth(name string) error { - data, err := os.ReadFile(name) - if err != nil { - return err - } - fi, err := os.Stat(name) - if fi.Mode()&0077 != 0 { - return fmt.Errorf("stat token: mode is %#o, want %#o", fi.Mode()&0777, fi.Mode()&0700) - } - authToken := strings.TrimSpace(string(data)) - t := &oauth2.Transport{ - Source: &tokenSource{AccessToken: authToken}, - } - client = github.NewClient(&http.Client{Transport: t}) - return nil -} - func mustConfigPath() string { confdir, err := os.UserConfigDir() if err != nil { @@ -495,255 +141,6 @@ func mustConfigPath() string { return filepath.Join(confdir, "github/token") } -type tokenSource oauth2.Token - -func (t *tokenSource) Token() (*oauth2.Token, error) { - return (*oauth2.Token)(t), nil -} - -func getInt(x *int) int { - if x == nil { - return 0 - } - return *x -} - -func getString(x *string) string { - if x == nil { - return "" - } - return *x -} - -func getUserLogin(x *github.User) string { - if x == nil || x.Login == nil { - return "" - } - return *x.Login -} - -func getMilestoneTitle(x *github.Milestone) string { - if x == nil || x.Title == nil { - return "" - } - return *x.Title -} - -func getLabelNames(x []*github.Label) []string { - var out []string - for _, lab := range x { - out = append(out, getString(lab.Name)) - } - sort.Strings(out) - return out -} - -type projectAndNumber struct { - project string - number int -} - -var issueCache struct { - sync.Mutex - m map[projectAndNumber]*github.Issue -} - -func updateIssueCache(project string, issue *github.Issue) { - n := getInt(issue.Number) - if n == 0 { - return - } - issueCache.Lock() - if issueCache.m == nil { - issueCache.m = make(map[projectAndNumber]*github.Issue) - } - issueCache.m[projectAndNumber{project, n}] = issue - issueCache.Unlock() -} - -func bulkReadIssuesCached(project string, ids []int) ([]*github.Issue, error) { - var all []*github.Issue - issueCache.Lock() - for _, id := range ids { - all = append(all, issueCache.m[projectAndNumber{project, id}]) - } - issueCache.Unlock() - - var errbuf bytes.Buffer - for i, id := range ids { - if all[i] == nil { - issue, _, err := client.Issues.Get(context.TODO(), projectOwner(project), projectRepo(project), id) - if err != nil { - fmt.Fprintf(&errbuf, "reading #%d: %v\n", id, err) - continue - } - updateIssueCache(project, issue) - all[i] = issue - } - } - var err error - if errbuf.Len() > 0 { - err = fmt.Errorf("%s", strings.TrimSpace(errbuf.String())) - } - return all, err -} - -// JSON output -// If you make changes to the structs, copy them back into the doc comment. - -type Issue struct { - Number int - Ref string - Title string - State string - Assignee string - Closed time.Time - Labels []string - Milestone string - URL string - Reporter string - Created time.Time - Text string - Comments []*Comment - Reactions Reactions -} - -type Comment struct { - Author string - Time time.Time - Text string - Reactions Reactions -} - -type Reactions struct { - PlusOne int `json:"+1"` - MinusOne int `json:"-1"` - Laugh int - Confused int - Heart int - Hooray int - Rocket int - Eyes int -} - -func showJSONIssue(w io.Writer, project string, issue *github.Issue) { - data, err := json.MarshalIndent(toJSONWithComments(project, issue), "", "\t") - if err != nil { - log.Fatal(err) - } - data = append(data, '\n') - w.Write(data) -} - -func showJSONList(project string, all []*github.Issue) { - j := []*Issue{} // non-nil for json - for _, issue := range all { - j = append(j, toJSON(project, issue)) - } - data, err := json.MarshalIndent(j, "", "\t") - if err != nil { - log.Fatal(err) - } - data = append(data, '\n') - os.Stdout.Write(data) -} - -func toJSON(project string, issue *github.Issue) *Issue { - j := &Issue{ - Number: getInt(issue.Number), - Ref: fmt.Sprintf("%s/%s#%d\n", projectOwner(project), projectRepo(project), getInt(issue.Number)), - Title: getString(issue.Title), - State: getString(issue.State), - Assignee: getUserLogin(issue.Assignee), - Closed: issue.ClosedAt.Time, - Labels: getLabelNames(issue.Labels), - Milestone: getMilestoneTitle(issue.Milestone), - URL: fmt.Sprintf("https://github.com/%s/%s/issues/%d\n", projectOwner(project), projectRepo(project), getInt(issue.Number)), - Reporter: getUserLogin(issue.User), - Created: issue.CreatedAt.Time, - Text: getString(issue.Body), - Comments: []*Comment{}, - Reactions: getReactions(issue.Reactions), - } - if j.Labels == nil { - j.Labels = []string{} - } - return j -} - -func toJSONWithComments(project string, issue *github.Issue) *Issue { - j := toJSON(project, issue) - for page := 1; ; { - list, resp, err := client.Issues.ListComments(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.IssueListCommentsOptions{ - ListOptions: github.ListOptions{ - Page: page, - PerPage: 100, - }, - }) - if err != nil { - log.Fatal(err) - } - for _, com := range list { - j.Comments = append(j.Comments, &Comment{ - Reactions: getReactions(com.Reactions), - Author: getUserLogin(com.User), - Time: com.CreatedAt.Time, - Text: getString(com.Body), - }) - } - if resp.NextPage < page { - break - } - page = resp.NextPage - } - return j -} - -func (r Reactions) String() string { - var buf bytes.Buffer - add := func(s string, n int) { - if n != 0 { - if buf.Len() != 0 { - buf.WriteString(" ") - } - fmt.Fprintf(&buf, "%s %d", s, n) - } - } - add("πŸ‘", r.PlusOne) - add("πŸ‘Ž", r.MinusOne) - add("πŸ˜†", r.Laugh) - add("πŸ˜•", r.Confused) - add("β™₯", r.Heart) - add("πŸŽ‰", r.Hooray) - add("πŸš€", r.Rocket) - add("πŸ‘€", r.Eyes) - return buf.String() -} - -func getReactions(r *github.Reactions) Reactions { - if r == nil { - return Reactions{} - } - return Reactions{ - PlusOne: z(r.PlusOne), - MinusOne: z(r.MinusOne), - Laugh: z(r.Laugh), - Confused: z(r.Confused), - Heart: z(r.Heart), - Hooray: z(r.Hooray), - Rocket: z(r.Rocket), - Eyes: z(r.Eyes), - } -} - -func z[T any](x *T) T { - if x == nil { - var zero T - return zero - } - return *x -} - func newLogger(t http.RoundTripper) http.RoundTripper { return &loggingTransport{transport: t} }