commit 69060eece77e6760a189194ea5be56b6439bb23a
parent cb9983f05919d07d481b139146d3d0eebca29172
Author: Russ Cox <rsc@golang.org>
Date: Thu, 8 Jan 2015 00:42:56 -0500
issue: new command for reading/writing GitHub project issues
Diffstat:
| A | issue/acme.go | | | 714 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | issue/issue.go | | | 406 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
2 files changed, 1120 insertions(+), 0 deletions(-)
diff --git a/issue/acme.go b/issue/acme.go
@@ -0,0 +1,714 @@
+// Copyright 2013 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.
+
+// Originally code.google.com/p/rsc/cmd/issue/acme.go.
+
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/go-github/github"
+
+ "code.google.com/p/goplan9/draw"
+ "code.google.com/p/goplan9/plan9/acme"
+)
+
+func acmeMode() {
+ var dummy awin
+ dummy.prefix = "/issue/" + *project + "/"
+ if flag.NArg() > 0 {
+ for _, arg := range flag.Args() {
+ if !dummy.look(arg) {
+ dummy.newSearch("search", arg)
+ }
+ }
+ } else {
+ dummy.look("all")
+ }
+ select {}
+}
+
+const (
+ modeSingle = 1 + iota
+ modeQuery
+ modeCreate
+ modeMilestone
+)
+
+type awin struct {
+ *acme.Win
+ prefix string
+ mode int
+ query string
+ id int
+ github *github.Issue
+ tab int
+ font *draw.Font
+ fontName string
+ title string
+}
+
+var all struct {
+ sync.Mutex
+ m map[string]*awin
+ f map[string]*draw.Font
+ numwin int
+}
+
+func (w *awin) exit() {
+ all.Lock()
+ defer all.Unlock()
+ if all.m[w.title] == w {
+ delete(all.m, w.title)
+ }
+ if all.numwin--; all.numwin == 0 {
+ os.Exit(0)
+ }
+}
+
+func (w *awin) new(title string) *awin {
+ all.Lock()
+ defer all.Unlock()
+ all.numwin++
+ if all.m == nil {
+ all.m = make(map[string]*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 = w.prefix
+ w1.Name(w1.prefix + title)
+ if title != "new" {
+ all.m[title] = 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
+}
+
+var numRE = regexp.MustCompile(`(?m)^#[0-9]+\t`)
+
+var milecache struct {
+ sync.Mutex
+ list []github.Milestone
+}
+
+func cachedMilestones() []github.Milestone {
+ milecache.Lock()
+ if milecache.list == nil {
+ milecache.list, _ = loadMilestones()
+ }
+ list := milecache.list
+ milecache.Unlock()
+ return list
+}
+
+func (w *awin) look(text string) bool {
+ if text == "all" {
+ if w.show("all") != nil {
+ return true
+ }
+ w.newSearch("all", "")
+ return true
+ }
+ if text == "Milestone" || text == "Milestones" || text == "milestone" {
+ if w.show("milestone") != nil {
+ return true
+ }
+ w.newMilestoneList()
+ return true
+ }
+ milecache.Lock()
+ if milecache.list == nil {
+ milecache.list, _ = loadMilestones()
+ }
+ list := milecache.list
+ milecache.Unlock()
+ for _, m := range list {
+ if getString(m.Title) == text {
+ if w.show(text) != nil {
+ return true
+ }
+ w.newSearch(text, "milestone:"+text)
+ return true
+ }
+ }
+
+ if n, _ := strconv.Atoi(strings.TrimPrefix(text, "#")); 0 < n && n < 100000 {
+ text = strings.TrimPrefix(text, "#")
+ if w.show(text) != nil {
+ return true
+ }
+ w.newIssue(text, n)
+ return true
+ }
+ 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) {
+ id := findMilestone(w, &milestone)
+ if id == nil {
+ return
+ }
+ milestoneID := *id
+
+ stop := w.blinker()
+ 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(projectOwner, projectRepo, n, &edit)
+ if err != nil {
+ w.err(fmt.Sprintf("Error changing issue #%d: %v", n, err))
+ }
+}
+
+func (w *awin) createIssue() {
+ w = w.new("new")
+ w.mode = modeCreate
+ w.Ctl("cleartag")
+ w.Fprintf("tag", " Put Search ")
+ go w.load()
+ go w.loop()
+}
+
+func (w *awin) newIssue(title string, id int) {
+ w = w.new(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) newMilestoneList() {
+ w = w.new("milestone")
+ w.mode = modeMilestone
+ w.query = ""
+ w.Ctl("cleartag")
+ w.Fprintf("tag", " New Get Search ")
+ w.Write("body", []byte("Loading..."))
+ go w.load()
+ go w.loop()
+}
+
+func (w *awin) newSearch(title, query string) {
+ w = w.new(title)
+ w.mode = modeQuery
+ w.query = query
+ w.Ctl("cleartag")
+ w.Fprintf("tag", " New Get Search ")
+ w.Write("body", []byte("Loading..."))
+ go w.load()
+ go w.loop()
+}
+
+func (w *awin) blinker() func() {
+ c := make(chan struct{})
+ go func() {
+ t := time.NewTicker(300 * 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:
+
+<describe issue here>
+
+`
+
+func (w *awin) load() {
+ w.fixfont()
+
+ switch w.mode {
+ case modeCreate:
+ 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.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.blinker()
+ milestones, err := loadMilestones()
+ milecache.Lock()
+ milecache.list = 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", getTime(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.blinker()
+ err := showQuery(&buf, w.query)
+ if w.title == "all" {
+ cachedMilestones()
+ }
+ stop()
+ w.clear()
+ if err != nil {
+ w.Write("body", []byte(err.Error()))
+ break
+ }
+ if w.title == "all" {
+ var names []string
+ for _, m := range cachedMilestones() {
+ 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")
+ }
+
+ w.Addr("0")
+ w.Ctl("dot=addr")
+ 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))
+ if old == line {
+ return nil
+ }
+ return &line
+}
+
+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 (w *awin) put() {
+ stop := w.blinker()
+ 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
+ }
+ sdata := string(data)
+ off := 0
+ var edit github.IssueRequest
+ 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:"):
+ edit.Labels = diffList(line, "Labels:", getLabelNames(old.Labels))
+
+ case strings.HasPrefix(line, "Milestone:"):
+ edit.Milestone = findMilestone(w, diff(line, "Milestone:", getMilestoneTitle(old.Milestone)))
+
+ case strings.HasPrefix(line, "URL:"):
+ continue
+
+ default:
+ w.err(fmt.Sprintf("Put: unknown summary line: %s", line))
+ }
+ }
+
+ if w.mode == modeCreate {
+ comment := strings.TrimSpace(sdata[off:])
+ edit.Body = &comment
+ issue, _, err := client.Issues.Create(projectOwner, projectRepo, &edit)
+ if err != nil {
+ w.err(fmt.Sprintf("Error creating issue: %v", err))
+ return
+ }
+ w.mode = modeSingle
+ 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()
+ return
+ }
+
+ var comment string
+ i := strings.Index(sdata, "\nReported by ")
+ if i >= off {
+ comment = strings.TrimSpace(sdata[off:i])
+ }
+
+ failed := false
+ if comment != "" {
+ _, _, err := client.Issues.CreateComment(projectOwner, projectRepo, getInt(old.Number), &github.IssueComment{
+ Body: &comment,
+ })
+ if err != nil {
+ w.err(fmt.Sprintf("Error saving comment: %v", err))
+ failed = true
+ }
+ }
+
+ 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)
+ if err != nil {
+ w.err(fmt.Sprintf("Error changing issue: %v", err))
+ if !failed {
+ w.err("(Comment saved; only metadata failed to update.)\n")
+ }
+ failed = true
+ } else if failed {
+ w.err("(Metadata changes made; only comment failed to save.)\n")
+ }
+ }
+
+ if !failed {
+ w.load()
+ }
+
+ case modeMilestone:
+ 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())
+ }
+ return string(data)
+}
+
+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 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
+ w.loadText(e)
+ if !w.look(string(e.Text)) {
+ w.WriteEvent(e)
+ }
+ }
+ }
+}
+
+func (w *awin) printTabbed(text string) {
+ lines := strings.SplitAfter(text, "\n")
+ var rows [][]string
+ for _, line := range lines {
+ if line == "" {
+ continue
+ }
+ line = strings.TrimSuffix(line, "\n")
+ rows = append(rows, strings.Split(line, "\t"))
+ }
+
+ 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
+ }
+ }
+ }
+ }
+
+ var buf bytes.Buffer
+ 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")
+ }
+
+ w.Write("body", buf.Bytes())
+}
+
+func findMilestone(w *awin, name *string) *int {
+ if name == nil {
+ return nil
+ }
+
+ all, err := loadMilestones()
+ if err != nil {
+ w.err(fmt.Sprintf("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
+ }
+ }
+
+ w.err(fmt.Sprintf("Ignoring unknown milestone: %s\n", *name))
+ return nil
+}
diff --git a/issue/issue.go b/issue/issue.go
@@ -0,0 +1,406 @@
+// 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 GitHub project issues.
+//
+// usage: issue [-a] [-p owner/repo] <query>
+//
+// 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.
+//
+// Acme
+//
+// 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".
+//
+// 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
+// <milestone-name> the named milestone (e.g., Go1.5)
+//
+// Executing "New" opens an issue creation window.
+//
+// Executing "Search <query>" 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:
+//
+// <describe issue here>
+//
+// Once the template has been completed, 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 <query>", 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
+// ...
+//
+// 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.
+//
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/go-github/github"
+ "golang.org/x/oauth2"
+)
+
+var (
+ acmeFlag = flag.Bool("a", false, "acme")
+
+ project = flag.String("p", "golang/go", "GitHub owner/repo name")
+ projectOwner = ""
+ projectRepo = ""
+)
+
+func usage() {
+ fmt.Fprintf(os.Stderr, `usage: issue [-a] [-p owner/repo] <query>
+
+If query is a single number, prints the full history for the issue.
+Otherwise, prints a table of matching results.
+`)
+ os.Exit(2)
+}
+
+func main() {
+ flag.Usage = usage
+ flag.Parse()
+ log.SetFlags(0)
+ log.SetPrefix("issue: ")
+
+ if flag.NArg() == 0 && !*acmeFlag {
+ usage()
+ }
+ q := strings.Join(flag.Args(), " ")
+
+ 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()
+
+ if *acmeFlag {
+ acmeMode()
+ }
+
+ n, _ := strconv.Atoi(q)
+ if n != 0 {
+ if _, err := showIssue(os.Stdout, n); err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+
+ if err := showQuery(os.Stdout, q); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func showIssue(w io.Writer, n int) (*github.Issue, error) {
+ issue, _, err := client.Issues.Get(projectOwner, projectRepo, n)
+ if err != nil {
+ return nil, err
+ }
+ return issue, printIssue(w, issue)
+}
+
+const timeFormat = "2006-01-02 15:04:05"
+
+func printIssue(w io.Writer, issue *github.Issue) error {
+ 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", getTime(issue.ClosedAt).Format(timeFormat))
+ }
+ 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, "\nReported by %s (%s)\n", getUserLogin(issue.User), getTime(issue.CreatedAt).Format(timeFormat))
+ if issue.Body != nil {
+ text := strings.TrimSpace(*issue.Body)
+ if text != "" {
+ fmt.Fprintf(w, "\n\t%s\n", wrap(text, "\t"))
+ }
+ }
+
+ for page := 1; ; {
+ list, resp, err := client.Issues.ListComments(projectOwner, projectRepo, getInt(issue.Number), &github.IssueListCommentsOptions{
+ ListOptions: github.ListOptions{
+ Page: page,
+ PerPage: 100,
+ },
+ })
+ for _, com := range list {
+ fmt.Fprintf(w, "\nComment by %s (%s)\n", getUserLogin(com.User), getTime(com.CreatedAt).Format(timeFormat))
+ if com.Body != nil {
+ text := strings.TrimSpace(*com.Body)
+ if text != "" {
+ fmt.Fprintf(w, "\n\t%s\n", wrap(text, "\t"))
+ }
+ }
+ }
+ if err != nil {
+ return err
+ }
+ if resp.NextPage < page {
+ break
+ }
+ page = resp.NextPage
+ }
+ return nil
+}
+
+func showQuery(w io.Writer, q string) error {
+ var all []string
+ for page := 1; ; {
+ x, resp, err := client.Search.Issues("type:issue state:open repo:"+*project+" "+q, &github.SearchOptions{
+ ListOptions: github.ListOptions{
+ Page: page,
+ PerPage: 100,
+ },
+ })
+ if err != nil {
+ return err
+ }
+ for _, issue := range x.Issues {
+ all = append(all, fmt.Sprintf("%s\t%d", getString(issue.Title), getInt(issue.Number)))
+ }
+ if resp.NextPage < page {
+ break
+ }
+ page = resp.NextPage
+ }
+ sort.Strings(all)
+ for _, s := range all {
+ i := strings.LastIndex(s, "\t")
+ fmt.Fprintf(w, "%s\t%s\n", s[i+1:], s[:i])
+ }
+ return nil
+}
+
+func loadMilestones() ([]github.Milestone, error) {
+ // NOTE(rsc): There appears to be no paging possible.
+ all, _, err := client.Issues.ListMilestones(projectOwner, projectRepo, &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)
+ lines := strings.Split(t, "\n")
+ for i, line := range lines {
+ if i > 0 {
+ out += "\n" + prefix
+ }
+ s := line
+ for len(s) > 70 {
+ i := strings.LastIndex(s[:70], " ")
+ if i < 0 {
+ i = 69
+ }
+ i++
+ out += s[:i] + "\n" + prefix
+ s = s[i:]
+ }
+ out += s
+ }
+ return out
+}
+
+var client *github.Client
+
+// GitHub personal access token, from https://github.com/settings/applications.
+var authToken string
+
+func loadAuth() {
+ const short = ".github-issue-token"
+ filename := filepath.Clean(os.Getenv("HOME") + "/" + short)
+ data, err := ioutil.ReadFile(filename)
+ if err != nil {
+ log.Fatal("reading token: ", err, "\n\n"+
+ "Please create a personal access token at https://github.com/settings/tokens/new\n"+
+ "and write it to ", filepath.Clean("$HOME/"+short), " to use this program.\n"+
+ "The token only needs the repo scope, or private_repo if you want to\n"+
+ "view or edit issues for private repositories.\n"+
+ "The benefit of using a personal access token over using your GitHub\n"+
+ "password directly is that you can limit its use and revoke it at any time.\n\n")
+ }
+ fi, err := os.Stat(filename)
+ if fi.Mode()&0077 != 0 {
+ log.Fatalf("reading token: %s mode is %#o, want %#o", filepath.Clean("$HOME/"+short), 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})
+}
+
+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 getTime(x *time.Time) time.Time {
+ if x == nil {
+ return time.Time{}
+ }
+ return *x
+}
+
+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))
+ }
+ return out
+}