issues

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

commit d1235f3a5e08fd4516f6c854113dfa3e02cba2f3
parent 543888eeaf89c38d330d82a92d8336bfac1b5b1d
Author: Russ Cox <rsc@golang.org>
Date:   Wed, 19 Jun 2019 15:54:09 -0400

issue: various updates

Updates from a long period of time.
Too much to describe in full.
Works better now. :-)

Diffstat:
Missue/acme.go | 605+++++++++++++++++++++++++++++++------------------------------------------------
Missue/edit.go | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Missue/issue.go | 178++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
3 files changed, 407 insertions(+), 458 deletions(-)

diff --git a/issue/acme.go b/issue/acme.go @@ -7,23 +7,37 @@ package main import ( + "bufio" "bytes" + "context" "flag" "fmt" "log" "os" "regexp" - "sort" "strconv" "strings" "sync" "time" "9fans.net/go/acme" - "9fans.net/go/draw" + "9fans.net/go/plumb" "github.com/google/go-github/github" ) +func (w *awin) project() string { + p := w.prefix + p = strings.TrimPrefix(p, "/issue/") + 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 = "/issue/" + *project + "/" @@ -32,21 +46,76 @@ func acmeMode() { // 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) { + if dummy.Look(arg) { continue } if arg == "new" { dummy.createIssue() continue } - dummy.newSearch("search", arg) + dummy.newSearch(dummy.prefix, "search", arg) } } else { - dummy.look("all") + dummy.Look("all") } + + go dummy.plumbserve() + select {} } +func (w *awin) plumbserve() { + fid, err := plumb.Open("githubissue", 0) + if err != nil { + w.Err(fmt.Sprintf("plumb: %v", err)) + return + } + r := bufio.NewReader(fid) + for { + var m plumb.Message + if err := m.Recv(r); err != nil { + w.Err(fmt.Sprintf("plumb recv: %v", err)) + return + } + if m.Type != "text" { + w.Err(fmt.Sprintf("plumb recv: unexpected type: %s\n", m.Type)) + continue + } + if m.Dst != "githubissue" { + w.Err(fmt.Sprintf("plumb recv: unexpected dst: %s\n", m.Dst)) + continue + } + // TODO use m.Dir + data := string(m.Data) + var project, what string + if strings.HasPrefix(data, "/issue/") { + project = data[len("/issue/"):] + i := strings.LastIndex(project, "/") + if i < 0 { + w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) + continue + } + project, what = project[:i], project[i+1:] + } else { + i := strings.Index(data, "#") + if i < 0 { + w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) + continue + } + project, what = data[:i], data[i+1:] + } + if strings.Count(project, "/") != 1 { + w.Err(fmt.Sprintf("plumb recv: bad text %q", data)) + continue + } + var plummy awin + plummy.prefix = "/issue/" + project + "/" + if !plummy.Look(what) { + w.Err(fmt.Sprintf("plumb recv: can't look %s%s", plummy.prefix, what)) + } + } +} + const ( modeSingle = 1 + iota modeQuery @@ -62,37 +131,31 @@ type awin struct { query string id int github *github.Issue - tab int - font *draw.Font - fontName string title string sortByNumber bool // otherwise sort by title } var all struct { sync.Mutex - m map[string]*awin - f map[string]*draw.Font - numwin int + m map[*acme.Win]*awin } func (w *awin) exit() { all.Lock() defer all.Unlock() - if all.m[w.title] == w { - delete(all.m, w.title) + if all.m[w.Win] == w { + delete(all.m, w.Win) } - if all.numwin--; all.numwin == 0 { + if len(all.m) == 0 { os.Exit(0) } } -func (w *awin) new(title string) *awin { +func (w *awin) new(prefix, title string) *awin { all.Lock() defer all.Unlock() - all.numwin++ if all.m == nil { - all.m = make(map[string]*awin) + all.m = make(map[*acme.Win]*awin) } w1 := new(awin) w1.title = title @@ -106,133 +169,106 @@ func (w *awin) new(title string) *awin { log.Fatalf("creating acme window again: %v", err) } } - w1.prefix = w.prefix + w1.prefix = prefix + w1.SetErrorPrefix(w1.prefix) w1.Name(w1.prefix + title) - if title != "new" { - all.m[title] = w1 - } + all.m[w1.Win] = w1 return w1 } -func (w *awin) show(title string) *awin { - all.Lock() - defer all.Unlock() - if w1 := all.m[title]; w1 != nil { - w.Ctl("show") - return w1 - } - return nil -} - -func (w *awin) fixfont() { - ctl := make([]byte, 1000) - w.Seek("ctl", 0, 0) - n, err := w.Read("ctl", ctl) - if err != nil { - return - } - f := strings.Fields(string(ctl[:n])) - if len(f) < 8 { - return - } - w.tab, _ = strconv.Atoi(f[7]) - if w.tab == 0 { - return - } - name := f[6] - if w.fontName == name { - return - } - all.Lock() - defer all.Unlock() - if font := all.f[name]; font != nil { - w.font = font - w.fontName = name - return - } - var disp *draw.Display = nil - font, err := disp.OpenFont(name) - if err != nil { - return - } - if all.f == nil { - all.f = make(map[string]*draw.Font) - } - all.f[name] = font - w.font = font +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 []*github.Milestone + list map[string][]*github.Milestone } -func cachedMilestones() []*github.Milestone { +func cachedMilestones(project string) []*github.Milestone { milecache.Lock() if milecache.list == nil { - milecache.list, _ = loadMilestones() + milecache.list = make(map[string][]*github.Milestone) + } + if milecache.list[project] == nil { + milecache.list[project], _ = loadMilestones(project) } - list := milecache.list + list := milecache.list[project] milecache.Unlock() return list } -func (w *awin) look(text string) bool { +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) != nil { + if w.show(text) { continue } - w.newIssue(text, id) + w.newIssue(w.prefix, text, id) } return true } if text == "all" { - if w.show("all") != nil { + if w.show("all") { return true } - w.newSearch("all", "") + w.newSearch(w.prefix, "all", "") return true } if text == "Milestone" || text == "Milestones" || text == "milestone" { - if w.show("milestone") != nil { + if w.show("milestone") { return true } w.newMilestoneList() return true } - milecache.Lock() - if milecache.list == nil { - milecache.list, _ = loadMilestones() - } - list := milecache.list - milecache.Unlock() + list := cachedMilestones(w.project()) for _, m := range list { if getString(m.Title) == text { - if w.show(text) != nil { + if w.show(text) { return true } - w.newSearch(text, "milestone:"+text) + w.newSearch(w.prefix, text, "milestone:"+text) return true } } - if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 100000 { + if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 1000000 { text = strings.TrimPrefix(text, "#") - if w.show(text) != nil { + if w.show(text) { return true } - w.newIssue(text, n) + w.newIssue(w.prefix, text, n) return true } + + if m := repoHashRE.FindStringSubmatch(text); m != nil { + project := m[1] + what := m[2] + prefix := "/issue/" + 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, "#"))) + w.Look(strings.TrimSpace(strings.TrimPrefix(s, "#"))) } return true } @@ -241,16 +277,16 @@ func (w *awin) look(text string) bool { func (w *awin) setMilestone(milestone, text string) { var buf bytes.Buffer - id := findMilestone(&buf, &milestone) + id := findMilestone(&buf, w.project(), &milestone) if buf.Len() > 0 { - w.err(strings.TrimSpace(buf.String())) + w.Err(strings.TrimSpace(buf.String())) } if id == nil { return } milestoneID := *id - stop := w.blinker() + stop := w.Blink() defer stop() if w.mode == modeSingle { w.setMilestone1(milestoneID, w.id) @@ -276,14 +312,14 @@ func (w *awin) setMilestone1(milestoneID, n int) { var edit github.IssueRequest edit.Milestone = &milestoneID - _, _, err := client.Issues.Edit(projectOwner, projectRepo, n, &edit) + _, _, err := client.Issues.Edit(context.TODO(), projectOwner(w.project()), projectRepo(w.project()), n, &edit) if err != nil { - w.err(fmt.Sprintf("Error changing issue #%d: %v", n, err)) + w.Err(fmt.Sprintf("Error changing issue #%d: %v", n, err)) } } func (w *awin) createIssue() { - w = w.new("new") + w = w.new(w.prefix, "new") w.mode = modeCreate w.Ctl("cleartag") w.Fprintf("tag", " Put Search ") @@ -291,8 +327,8 @@ func (w *awin) createIssue() { go w.loop() } -func (w *awin) newIssue(title string, id int) { - w = w.new(title) +func (w *awin) newIssue(prefix, title string, id int) { + w = w.new(prefix, title) w.mode = modeSingle w.id = id w.Ctl("cleartag") @@ -302,7 +338,7 @@ func (w *awin) newIssue(title string, id int) { } func (w *awin) newBulkEdit(body []byte) { - w = w.new("bulk-edit/") + w = w.new(w.prefix, "bulk-edit/") w.mode = modeBulk w.query = "" w.Ctl("cleartag") @@ -313,7 +349,7 @@ func (w *awin) newBulkEdit(body []byte) { } func (w *awin) newMilestoneList() { - w = w.new("milestone") + w = w.new(w.prefix, "milestone") w.mode = modeMilestone w.query = "" w.Ctl("cleartag") @@ -323,8 +359,8 @@ func (w *awin) newMilestoneList() { go w.loop() } -func (w *awin) newSearch(title, query string) { - w = w.new(title) +func (w *awin) newSearch(prefix, title, query string) { + w = w.new(prefix, title) w.mode = modeQuery w.query = query w.Ctl("cleartag") @@ -334,65 +370,28 @@ func (w *awin) newSearch(title, query string) { go w.loop() } -func (w *awin) blinker() func() { - c := make(chan struct{}) - go func() { - t := time.NewTicker(1000 * time.Millisecond) - defer t.Stop() - dirty := false - for { - select { - case <-t.C: - dirty = !dirty - if dirty { - w.Ctl("dirty") - } else { - w.Ctl("clean") - } - case <-c: - if dirty { - w.Ctl("clean") - } - c <- struct{}{} - return - } - } - }() - return func() { - c <- struct{}{} - <-c - } -} - -func (w *awin) clear() { - w.Addr(",") - w.Write("data", nil) -} - -var createTemplate = `Title: -Assignee: -Labels: -Milestone: +var createTemplate = `Title: +Assignee: +Labels: +Milestone: <describe issue here> ` func (w *awin) load() { - w.fixfont() - switch w.mode { case modeCreate: - w.clear() + w.Clear() w.Write("body", []byte(createTemplate)) w.Ctl("clean") case modeSingle: var buf bytes.Buffer - stop := w.blinker() - issue, err := showIssue(&buf, w.id) + stop := w.Blink() + issue, err := showIssue(&buf, w.project(), w.id) stop() - w.clear() + w.Clear() if err != nil { w.Write("body", []byte(err.Error())) break @@ -402,13 +401,16 @@ func (w *awin) load() { w.github = issue case modeMilestone: - stop := w.blinker() - milestones, err := loadMilestones() + stop := w.Blink() + milestones, err := loadMilestones(w.project()) milecache.Lock() - milecache.list = milestones + if milecache.list == nil { + milecache.list = make(map[string][]*github.Milestone) + } + milecache.list[w.project()] = milestones milecache.Unlock() stop() - w.clear() + w.Clear() if err != nil { w.Fprintf("body", "Error loading milestones: %v\n", err) break @@ -417,25 +419,25 @@ func (w *awin) load() { for _, m := range milestones { fmt.Fprintf(&buf, "%s\t%s\t%d\n", getTime(m.DueOn).Format("2006-01-02"), getString(m.Title), getInt(m.OpenIssues)) } - w.printTabbed(buf.String()) + w.PrintTabbed(buf.String()) w.Ctl("clean") case modeQuery: var buf bytes.Buffer - stop := w.blinker() - err := showQuery(&buf, w.query) + stop := w.Blink() + err := showQuery(&buf, w.project(), w.query) if w.title == "all" { - cachedMilestones() + cachedMilestones(w.project()) } stop() - w.clear() + w.Clear() if err != nil { w.Write("body", []byte(err.Error())) break } if w.title == "all" { var names []string - for _, m := range cachedMilestones() { + for _, m := range cachedMilestones(w.project()) { names = append(names, getString(m.Title)) } if len(names) > 0 { @@ -445,25 +447,25 @@ func (w *awin) load() { if w.title == "search" { w.Fprintf("body", "Search %s\n\n", w.query) } - w.printTabbed(buf.String()) + w.PrintTabbed(buf.String()) w.Ctl("clean") case modeBulk: - stop := w.blinker() + stop := w.Blink() body, err := w.ReadAll("body") if err != nil { - w.err(fmt.Sprintf("%v", err)) + w.Err(fmt.Sprintf("%v", err)) stop() break } - base, original, err := bulkEditStartFromText(body) + base, original, err := bulkEditStartFromText(w.project(), body) stop() if err != nil { - w.err(fmt.Sprintf("%v", err)) + w.Err(fmt.Sprintf("%v", err)) break } - w.clear() - w.printTabbed(string(original)) + w.Clear() + w.PrintTabbed(string(original)) w.Ctl("clean") w.github = base } @@ -473,20 +475,6 @@ func (w *awin) load() { w.Ctl("show") } -func (w *awin) err(s string) { - if !strings.HasSuffix(s, "\n") { - s = s + "\n" - } - w1 := w.show("+Errors") - if w1 == nil { - w1 = w.new("+Errors") - } - w1.Fprintf("body", "%s", s) - w1.Addr("$") - w1.Ctl("dot=addr") - w1.Ctl("show") -} - func diff(line, field, old string) *string { old = strings.TrimSpace(old) line = strings.TrimSpace(strings.TrimPrefix(line, field)) @@ -497,7 +485,7 @@ func diff(line, field, old string) *string { } func (w *awin) put() { - stop := w.blinker() + stop := w.Blink() defer stop() switch w.mode { case modeSingle, modeCreate: @@ -507,12 +495,12 @@ func (w *awin) put() { } data, err := w.ReadAll("body") if err != nil { - w.err(fmt.Sprintf("Put: %v", err)) + w.Err(fmt.Sprintf("Put: %v", err)) return } - issue, err := writeIssue(old, data, false) + issue, _, err := writeIssue(w.project(), old, data, false) if err != nil { - w.err(err.Error()) + w.Err(err.Error()) return } if w.mode == modeCreate { @@ -520,9 +508,6 @@ func (w *awin) put() { w.id = getInt(issue.Number) w.title = fmt.Sprint(w.id) w.Name(w.prefix + w.title) - all.Lock() - all.m[w.title] = w - all.Unlock() w.github = issue } w.load() @@ -530,84 +515,47 @@ func (w *awin) put() { case modeBulk: data, err := w.ReadAll("body") if err != nil { - w.err(fmt.Sprintf("Put: %v", err)) + w.Err(fmt.Sprintf("Put: %v", err)) return } - ids, err := bulkWriteIssue(w.github, data, func(s string) { w.err("Put: " + s) }) + 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)) + 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)) + w.Err(fmt.Sprintf("%s", errText)) break } - w.err(fmt.Sprintf("updated %d issue%s", len(ids), suffix(len(ids)))) + w.Err(fmt.Sprintf("updated %d issue%s", len(ids), suffix(len(ids)))) case modeMilestone: - w.err("cannot Put milestone list") + w.Err("cannot Put milestone list") case modeQuery: - w.err("cannot Put issue list") - } -} - -func (w *awin) loadText(e *acme.Event) { - if len(e.Text) == 0 && e.Q0 < e.Q1 { - w.Addr("#%d,#%d", e.Q0, e.Q1) - data, err := w.ReadAll("xdata") - if err != nil { - w.err(err.Error()) - } - e.Text = data - } -} - -func (w *awin) selection() string { - w.Ctl("addr=dot") - data, err := w.ReadAll("xdata") - if err != nil { - w.err(err.Error()) + w.Err("cannot Put issue list") } - return string(data) } func (w *awin) sort() { if err := w.Addr("0/^[0-9]/,"); err != nil { - w.err("nothing to sort") - } - data, err := w.ReadAll("xdata") - if err != nil { - w.err(err.Error()) - return - } - suffix := "" - lines := strings.Split(string(data), "\n") - if lines[len(lines)-1] == "" { - suffix = "\n" - lines = lines[:len(lines)-1] + w.Err("nothing to sort") } + var less func(string, string) bool if w.sortByNumber { - sort.Stable(byNumber(lines)) + less = func(x, y string) bool { return lineNumber(x) > lineNumber(y) } } else { - sort.Stable(bySecondField(lines)) + 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/^[0-9]/,") - w.Write("data", []byte(strings.Join(lines, "\n")+suffix)) w.Addr("0") w.Ctl("dot=addr") w.Ctl("show") } -type byNumber []string - -func (x byNumber) Len() int { return len(x) } -func (x byNumber) Swap(i, j int) { x[i], x[j] = x[j], x[i] } -func (x byNumber) Less(i, j int) bool { - return lineNumber(x[i]) > lineNumber(x[j]) -} - func lineNumber(s string) int { n := 0 for j := 0; j < len(s) && '0' <= s[j] && s[j] <= '9'; j++ { @@ -616,14 +564,6 @@ func lineNumber(s string) int { return n } -type bySecondField []string - -func (x bySecondField) Len() int { return len(x) } -func (x bySecondField) Swap(i, j int) { x[i], x[j] = x[j], x[i] } -func (x bySecondField) Less(i, j int) bool { - return skipField(x[i]) < skipField(x[j]) -} - func skipField(s string) string { i := strings.Index(s, "\t") if i < 0 { @@ -635,140 +575,61 @@ func skipField(s string) string { return s[i:] } -func (w *awin) loop() { - defer w.exit() - for e := range w.EventChan() { - switch e.C2 { - case 'x', 'X': // execute - cmd := strings.TrimSpace(string(e.Text)) - if cmd == "Get" { - w.load() - break - } - if cmd == "Put" { - w.put() - break - } - if cmd == "Del" { - w.Ctl("del") - break - } - if cmd == "New" { - w.createIssue() - break - } - if cmd == "Sort" { - if w.mode != modeQuery { - w.err("can only sort issue list windows") - break - } - w.sortByNumber = !w.sortByNumber - w.sort() - break - } - if cmd == "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") - break - } - text := w.selection() - if text == "" { - data, err := w.ReadAll("body") - if err != nil { - w.err(fmt.Sprintf("%v", err)) - break - } - text = string(data) - } - w.newBulkEdit([]byte(text)) - break - } - if strings.HasPrefix(cmd, "Search ") { - w.newSearch("search", strings.TrimSpace(strings.TrimPrefix(cmd, "Search"))) - break - } - if strings.HasPrefix(cmd, "Milestone ") { - text := w.selection() - w.setMilestone(strings.TrimSpace(strings.TrimPrefix(cmd, "Milestone")), text) - break - } - w.WriteEvent(e) - case 'l', 'L': // look - // TODO(rsc): Expand selection, especially for URLs. - w.loadText(e) - if !w.look(string(e.Text)) { - w.WriteEvent(e) - } - } - } -} - -func (w *awin) printTabbed(text string) { - lines := strings.SplitAfter(text, "\n") - var allRows [][]string - for _, line := range lines { - if line == "" { - continue - } - line = strings.TrimSuffix(line, "\n") - allRows = append(allRows, strings.Split(line, "\t")) - } - - var buf bytes.Buffer - for len(allRows) > 0 { - if row := allRows[0]; len(row) <= 1 { - if len(row) > 0 { - buf.WriteString(row[0]) - } - buf.WriteString("\n") - allRows = allRows[1:] - continue +func (w *awin) Execute(cmd string) bool { + switch cmd { + case "Get": + w.load() + return true + case "Put": + w.put() + 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 } - - i := 0 - for i < len(allRows) && len(allRows[i]) > 1 { - i++ + 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 } - - rows := allRows[:i] - allRows = allRows[i:] - - var wid []int - - if w.font != nil { - for _, row := range rows { - for len(wid) < len(row) { - wid = append(wid, 0) - } - for i, col := range row { - n := w.font.StringWidth(col) - if wid[i] < n { - wid[i] = n - } - } + 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 + } - for _, row := range rows { - for i, col := range row { - buf.WriteString(col) - if i == len(row)-1 { - break - } - if w.font == nil || w.tab == 0 { - buf.WriteString("\t") - continue - } - pos := w.font.StringWidth(col) - for pos <= wid[i] { - buf.WriteString("\t") - pos += w.tab - pos%w.tab - } - } - buf.WriteString("\n") - } + if strings.HasPrefix(cmd, "Search ") { + w.newSearch(w.prefix, "search", strings.TrimSpace(strings.TrimPrefix(cmd, "Search"))) + return true + } + if strings.HasPrefix(cmd, "Milestone ") { + text := w.Selection() + w.setMilestone(strings.TrimSpace(strings.TrimPrefix(cmd, "Milestone")), text) + return true } - w.Write("body", buf.Bytes()) + return false +} + +func (w *awin) loop() { + defer w.exit() + w.EventLoop(w) } diff --git a/issue/edit.go b/issue/edit.go @@ -6,6 +6,7 @@ package main import ( "bytes" + "context" "errors" "fmt" "io" @@ -20,21 +21,21 @@ import ( "github.com/google/go-github/github" ) -func editIssue(original []byte, issue *github.Issue) { +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(issue, updated, false) + 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)) + log.Printf("https://github.com/%s/issues/%d updated", project, getInt(issue.Number)) } func editText(original []byte) []byte { @@ -90,7 +91,7 @@ func runEditor(filename string) error { const bulkHeader = "\nBulk editing these issues:" -func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.Issue, err error) { +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 { @@ -132,7 +133,7 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I } case strings.HasPrefix(line, "Milestone:"): - edit.Milestone = findMilestone(&errbuf, diff(line, "Milestone:", getMilestoneTitle(old.Milestone))) + edit.Milestone = findMilestone(&errbuf, project, diff(line, "Milestone:", getMilestoneTitle(old.Milestone))) case strings.HasPrefix(line, "URL:"): continue @@ -143,23 +144,26 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I } if errbuf.Len() > 0 { - return nil, nil + return nil, nil, nil } if getInt(old.Number) == 0 { comment := strings.TrimSpace(sdata[off:]) edit.Body = &comment - issue, _, err := client.Issues.Create(projectOwner, projectRepo, &edit) + 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, nil + return nil, rate, nil } - return issue, nil + return issue, rate, nil } if getInt(old.Number) == -1 { // Asking to just sanity check the text parsing. - return nil, nil + return nil, nil, nil } marker := "\nReported by " @@ -178,9 +182,12 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I var failed bool var did []string if comment != "" { - _, _, err := client.Issues.CreateComment(projectOwner, projectRepo, getInt(old.Number), &github.IssueComment{ + _, 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 @@ -190,7 +197,10 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I } if edit.Title != nil || edit.State != nil || edit.Assignee != nil || edit.Labels != nil || edit.Milestone != nil { - _, _, err := client.Issues.Edit(projectOwner, projectRepo, getInt(old.Number), &edit) + _, 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 @@ -199,7 +209,10 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I } } if len(addLabels) > 0 { - _, _, err := client.Issues.AddLabelsToIssue(projectOwner, projectRepo, getInt(old.Number), addLabels) + _, 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 @@ -213,7 +226,10 @@ func writeIssue(old *github.Issue, updated []byte, isBulk bool) (issue *github.I } if len(removeLabels) > 0 { for _, label := range removeLabels { - _, err := client.Issues.RemoveLabelForIssue(projectOwner, projectRepo, getInt(old.Number), label) + 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 @@ -290,12 +306,12 @@ func diffList2(line, field string, old []string) (added, removed []string) { return } -func findMilestone(w io.Writer, name *string) *int { +func findMilestone(w io.Writer, project string, name *string) *int { if name == nil { return nil } - all, err := loadMilestones() + all, err := loadMilestones(project) if err != nil { fmt.Fprintf(w, "Error loading milestone list: %v\n\tIgnoring milestone change.\n", err) return nil @@ -329,12 +345,12 @@ func readBulkIDs(text []byte) []int { return ids } -func bulkEditStartFromText(content []byte) (base *github.Issue, original []byte, err error) { +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(ids) + issues, err := bulkReadIssuesCached(project, ids) if err != nil { return nil, nil, err } @@ -349,14 +365,14 @@ func suffix(n int) string { return "s" } -func bulkEditIssues(issues []*github.Issue) { +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(base, updated, func(s string) { log.Print(s) }) + 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 { @@ -427,7 +443,7 @@ func commonLabels(x, y []github.Label) []github.Label { return out } -func bulkWriteIssue(old *github.Issue, updated []byte, status func(string)) (ids []int, err error) { +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") @@ -444,7 +460,8 @@ func bulkWriteIssue(old *github.Issue, updated []byte, status func(string)) (ids // Try a write to issue -1, checking for formatting only. old.Number = new(int) *old.Number = -1 - if _, err := writeIssue(old, updated, true); err != nil { + _, rate, err := writeIssue(project, old, updated, true) + if err != nil { return nil, err } @@ -462,19 +479,24 @@ func bulkWriteIssue(old *github.Issue, updated []byte, status func(string)) (ids } // Check rate limits here (in contrast to everywhere else in this program) // to avoid needless failure halfway through the loop. - for client.Rate().Limit > 0 && client.Rate().Remaining == 0 { - delta := (client.Rate().Reset.Sub(time.Now())/time.Minute + 2) * time.Minute + 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) - if _, _, err := client.RateLimit(); err != nil { + 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 _, err := writeIssue(old, updated, true); err != nil { + 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 } @@ -485,3 +507,11 @@ func bulkWriteIssue(old *github.Issue, updated []byte, status func(string)) (ids } return ids, nil } + +func projectOwner(project string) string { + return project[:strings.Index(project, "/")] +} + +func projectRepo(project string) string { + return project[strings.Index(project, "/")+1:] +} diff --git a/issue/issue.go b/issue/issue.go @@ -231,6 +231,7 @@ package main // import "rsc.io/github/issue" import ( "bytes" + "context" "encoding/json" "flag" "fmt" @@ -251,14 +252,13 @@ 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", "", "read GitHub token personal access token from `file` (default $HOME/.github-issue-token)") - projectOwner = "" - projectRepo = "" + 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", "", "read GitHub token personal access token from `file` (default $HOME/.github-issue-token)") + logHTTP = flag.Bool("loghttp", false, "log http requests") ) func usage() { @@ -288,12 +288,14 @@ func main() { log.Fatal("cannot use -e with -acme") } + if *logHTTP { + 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") } - projectOwner = f[0] - projectRepo = f[1] loadAuth() @@ -304,7 +306,7 @@ func main() { q := strings.Join(flag.Args(), " ") if *editFlag && q == "new" { - editIssue([]byte(createTemplate), new(github.Issue)) + editIssue(*project, []byte(createTemplate), new(github.Issue)) return } @@ -312,21 +314,21 @@ func main() { if n != 0 { if *editFlag { var buf bytes.Buffer - issue, err := showIssue(&buf, n) + issue, err := showIssue(&buf, *project, n) if err != nil { log.Fatal(err) } - editIssue(buf.Bytes(), issue) + editIssue(*project, buf.Bytes(), issue) return } - if _, err := showIssue(os.Stdout, n); err != nil { + if _, err := showIssue(os.Stdout, *project, n); err != nil { log.Fatal(err) } return } if *editFlag { - all, err := searchIssues(q) + all, err := searchIssues(*project, q) if err != nil { log.Fatal(err) } @@ -334,29 +336,29 @@ func main() { log.Fatal("no issues matched search") } sort.Sort(issuesByTitle(all)) - bulkEditIssues(all) + bulkEditIssues(*project, all) return } - if err := showQuery(os.Stdout, q); err != nil { + if err := showQuery(os.Stdout, *project, q); err != nil { log.Fatal(err) } } -func showIssue(w io.Writer, n int) (*github.Issue, error) { - issue, _, err := client.Issues.Get(projectOwner, projectRepo, n) +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(issue) - return issue, printIssue(w, issue) + updateIssueCache(project, issue) + return issue, printIssue(w, project, issue) } const timeFormat = "2006-01-02 15:04:05" -func printIssue(w io.Writer, issue *github.Issue) error { +func printIssue(w io.Writer, project string, issue *github.Issue) error { if *jsonFlag { - showJSONIssue(w, issue) + showJSONIssue(w, project, issue) return nil } @@ -368,7 +370,7 @@ func printIssue(w io.Writer, issue *github.Issue) error { } fmt.Fprintf(w, "Labels: %s\n", strings.Join(getLabelNames(issue.Labels), " ")) fmt.Fprintf(w, "Milestone: %s\n", getMilestoneTitle(issue.Milestone)) - fmt.Fprintf(w, "URL: https://github.com/%s/%s/issues/%d\n", projectOwner, projectRepo, getInt(issue.Number)) + fmt.Fprintf(w, "URL: https://github.com/%s/%s/issues/%d\n", projectOwner(project), projectRepo(project), getInt(issue.Number)) fmt.Fprintf(w, "\nReported by %s (%s)\n", getUserLogin(issue.User), getTime(issue.CreatedAt).Format(timeFormat)) if issue.Body != nil { @@ -385,7 +387,7 @@ func printIssue(w io.Writer, issue *github.Issue) error { var output []string for page := 1; ; { - list, resp, err := client.Issues.ListComments(projectOwner, projectRepo, getInt(issue.Number), &github.IssueListCommentsOptions{ + list, resp, err := client.Issues.ListComments(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ Page: page, PerPage: 100, @@ -418,7 +420,7 @@ func printIssue(w io.Writer, issue *github.Issue) error { } for page := 1; ; { - list, resp, err := client.Issues.ListIssueEvents(projectOwner, projectRepo, getInt(issue.Number), &github.ListOptions{ + list, resp, err := client.Issues.ListIssueEvents(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.ListOptions{ Page: page, PerPage: 100, }) @@ -441,7 +443,7 @@ func printIssue(w io.Writer, issue *github.Issue) error { } fmt.Fprintf(w, "\n* %s %s%s (%s)\n", getUserLogin(ev.Actor), event, id, getTime(ev.CreatedAt).Format(timeFormat)) if id != "" { - commit, _, err := client.Git.GetCommit(projectOwner, projectRepo, *ev.CommitID) + 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), getTime(commit.Author.Date).Format(timeFormat), @@ -483,14 +485,14 @@ func printIssue(w io.Writer, issue *github.Issue) error { return nil } -func showQuery(w io.Writer, q string) error { - all, err := searchIssues(q) +func showQuery(w io.Writer, project, q string) error { + all, err := searchIssues(project, q) if err != nil { return err } sort.Sort(issuesByTitle(all)) if *jsonFlag { - showJSONList(all) + showJSONList(project, all) return nil } for _, issue := range all { @@ -510,22 +512,22 @@ func (x issuesByTitle) Less(i, j int) bool { return getInt(x[i].Number) < getInt(x[j].Number) } -func searchIssues(q string) ([]*github.Issue, error) { - if opt, ok := queryToListOptions(q); ok { - return listRepoIssues(opt) +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("type:issue state:open repo:"+*project+" "+q, &github.SearchOptions{ + 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(&x.Issues[i]) + updateIssueCache(project, &x.Issues[i]) all = append(all, &x.Issues[i]) } if err != nil { @@ -539,7 +541,7 @@ func searchIssues(q string) ([]*github.Issue, error) { return all, nil } -func queryToListOptions(q string) (opt github.IssueListByRepoOptions, ok bool) { +func queryToListOptions(project, q string) (opt github.IssueListByRepoOptions, ok bool) { if strings.ContainsAny(q, `"'`) { return } @@ -556,7 +558,7 @@ func queryToListOptions(q string) (opt github.IssueListByRepoOptions, ok bool) { if opt.Milestone != "" || val == "" { return } - id := findMilestone(ioutil.Discard, &val) + id := findMilestone(ioutil.Discard, project, &val) if id == nil { return } @@ -612,7 +614,7 @@ func queryToListOptions(q string) (opt github.IssueListByRepoOptions, ok bool) { return opt, true } -func listRepoIssues(opt github.IssueListByRepoOptions) ([]*github.Issue, error) { +func listRepoIssues(project string, opt github.IssueListByRepoOptions) ([]*github.Issue, error) { var all []*github.Issue for page := 1; ; { xopt := opt @@ -620,9 +622,9 @@ func listRepoIssues(opt github.IssueListByRepoOptions) ([]*github.Issue, error) Page: page, PerPage: 100, } - issues, resp, err := client.Issues.ListByRepo(projectOwner, projectRepo, &xopt) + issues, resp, err := client.Issues.ListByRepo(context.TODO(), projectOwner(project), projectRepo(project), &xopt) for i := range issues { - updateIssueCache(issues[i]) + updateIssueCache(project, issues[i]) all = append(all, issues[i]) } if err != nil { @@ -645,9 +647,9 @@ func listRepoIssues(opt github.IssueListByRepoOptions) ([]*github.Issue, error) return save, nil } -func loadMilestones() ([]*github.Milestone, error) { +func loadMilestones(project string) ([]*github.Milestone, error) { // NOTE(rsc): There appears to be no paging possible. - all, _, err := client.Issues.ListMilestones(projectOwner, projectRepo, &github.MilestoneListOptions{ + all, _, err := client.Issues.ListMilestones(context.TODO(), projectOwner(project), projectRepo(project), &github.MilestoneListOptions{ State: "open", }) if err != nil { @@ -770,41 +772,46 @@ func getLabelNames(x []github.Label) []string { return out } +type projectAndNumber struct { + project string + number int +} + var issueCache struct { sync.Mutex - m map[int]*github.Issue + m map[projectAndNumber]*github.Issue } -func updateIssueCache(issue *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[int]*github.Issue) + issueCache.m = make(map[projectAndNumber]*github.Issue) } - issueCache.m[n] = issue + issueCache.m[projectAndNumber{project, n}] = issue issueCache.Unlock() } -func bulkReadIssuesCached(ids []int) ([]*github.Issue, error) { +func bulkReadIssuesCached(project string, ids []int) ([]*github.Issue, error) { var all []*github.Issue issueCache.Lock() for _, id := range ids { - all = append(all, issueCache.m[id]) + 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(projectOwner, projectRepo, id) + 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(issue) + updateIssueCache(project, issue) all[i] = issue } } @@ -840,8 +847,8 @@ type Comment struct { Text string } -func showJSONIssue(w io.Writer, issue *github.Issue) { - data, err := json.MarshalIndent(toJSONWithComments(issue), "", "\t") +func showJSONIssue(w io.Writer, project string, issue *github.Issue) { + data, err := json.MarshalIndent(toJSONWithComments(project, issue), "", "\t") if err != nil { log.Fatal(err) } @@ -849,10 +856,10 @@ func showJSONIssue(w io.Writer, issue *github.Issue) { w.Write(data) } -func showJSONList(all []*github.Issue) { +func showJSONList(project string, all []*github.Issue) { j := []*Issue{} // non-nil for json for _, issue := range all { - j = append(j, toJSON(issue)) + j = append(j, toJSON(project, issue)) } data, err := json.MarshalIndent(j, "", "\t") if err != nil { @@ -862,17 +869,17 @@ func showJSONList(all []*github.Issue) { os.Stdout.Write(data) } -func toJSON(issue *github.Issue) *Issue { +func toJSON(project string, issue *github.Issue) *Issue { j := &Issue{ Number: getInt(issue.Number), - Ref: fmt.Sprintf("%s/%s#%d\n", projectOwner, projectRepo, 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: getTime(issue.ClosedAt), Labels: getLabelNames(issue.Labels), Milestone: getMilestoneTitle(issue.Milestone), - URL: fmt.Sprintf("https://github.com/%s/%s/issues/%d\n", projectOwner, projectRepo, getInt(issue.Number)), + URL: fmt.Sprintf("https://github.com/%s/%s/issues/%d\n", projectOwner(project), projectRepo(project), getInt(issue.Number)), Reporter: getUserLogin(issue.User), Created: getTime(issue.CreatedAt), Text: getString(issue.Body), @@ -884,10 +891,10 @@ func toJSON(issue *github.Issue) *Issue { return j } -func toJSONWithComments(issue *github.Issue) *Issue { - j := toJSON(issue) +func toJSONWithComments(project string, issue *github.Issue) *Issue { + j := toJSON(project, issue) for page := 1; ; { - list, resp, err := client.Issues.ListComments(projectOwner, projectRepo, getInt(issue.Number), &github.IssueListCommentsOptions{ + list, resp, err := client.Issues.ListComments(context.TODO(), projectOwner(project), projectRepo(project), getInt(issue.Number), &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ Page: page, PerPage: 100, @@ -910,3 +917,54 @@ func toJSONWithComments(issue *github.Issue) *Issue { } return j } + +func newLogger(t http.RoundTripper) http.RoundTripper { + return &loggingTransport{transport: t} +} + +type loggingTransport struct { + transport http.RoundTripper + mu sync.Mutex + active []byte +} + +func (t *loggingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + t.mu.Lock() + index := len(t.active) + start := time.Now() + fmt.Fprintf(os.Stderr, "HTTP: %s %s+ %s\n", timeFormat1(start), t.active, r.URL) + t.active = append(t.active, '|') + t.mu.Unlock() + + resp, err := t.transport.RoundTrip(r) + + last := r.URL.Path + if i := strings.LastIndex(last, "/"); i >= 0 { + last = last[i:] + } + display := last + if resp != nil { + display += " " + resp.Status + } + if err != nil { + display += " error: " + err.Error() + } + now := time.Now() + + t.mu.Lock() + t.active[index] = '-' + fmt.Fprintf(os.Stderr, "HTTP: %s %s %s (%.3fs)\n", timeFormat1(now), t.active, display, now.Sub(start).Seconds()) + t.active[index] = ' ' + n := len(t.active) + for n%4 == 0 && n >= 4 && t.active[n-1] == ' ' && t.active[n-2] == ' ' && t.active[n-3] == ' ' && t.active[n-4] == ' ' { + t.active = t.active[:n-4] + n -= 4 + } + t.mu.Unlock() + + return resp, err +} + +func timeFormat1(t time.Time) string { + return t.Format("15:04:05.000") +}