commit dc015b15c4b3c47440f46310d7ed8c477cda5e91
parent 06a525dc7b1e71d909a79279c4c2698732e8780d
Author: Russ Cox <rsc@golang.org>
Date: Thu, 9 Apr 2015 11:47:35 -0400
issue: add bulk edits, -e option for non-acme editors
Diffstat:
| M | issue/acme.go | | | 287 | ++++++++++++++++++++++++++++++++++++------------------------------------------- |
| A | issue/edit.go | | | 499 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | issue/issue.go | | | 289 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
3 files changed, 903 insertions(+), 172 deletions(-)
diff --git a/issue/acme.go b/issue/acme.go
@@ -28,10 +28,17 @@ func acmeMode() {
var dummy awin
dummy.prefix = "/issue/" + *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.
for _, arg := range flag.Args() {
- if !dummy.look(arg) {
- dummy.newSearch("search", arg)
+ if dummy.look(arg) {
+ continue
+ }
+ if arg == "new" {
+ dummy.createIssue()
+ continue
}
+ dummy.newSearch("search", arg)
}
} else {
dummy.look("all")
@@ -44,6 +51,7 @@ const (
modeQuery
modeCreate
modeMilestone
+ modeBulk
)
type awin struct {
@@ -219,7 +227,11 @@ func (w *awin) look(text string) bool {
}
func (w *awin) setMilestone(milestone, text string) {
- id := findMilestone(w, &milestone)
+ var buf bytes.Buffer
+ id := findMilestone(&buf, &milestone)
+ if buf.Len() > 0 {
+ w.err(strings.TrimSpace(buf.String()))
+ }
if id == nil {
return
}
@@ -276,6 +288,17 @@ func (w *awin) newIssue(title string, id int) {
go w.loop()
}
+func (w *awin) newBulkEdit(body []byte) {
+ w = w.new("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("milestone")
w.mode = modeMilestone
@@ -292,7 +315,7 @@ func (w *awin) newSearch(title, query string) {
w.mode = modeQuery
w.query = query
w.Ctl("cleartag")
- w.Fprintf("tag", " New Get Sort Search ")
+ w.Fprintf("tag", " New Get Bulk Sort Search ")
w.Write("body", []byte("Loading..."))
go w.load()
go w.loop()
@@ -411,6 +434,25 @@ func (w *awin) load() {
}
w.printTabbed(buf.String())
w.Ctl("clean")
+
+ case modeBulk:
+ stop := w.blinker()
+ body, err := w.ReadAll("body")
+ if err != nil {
+ w.err(fmt.Sprintf("%v", err))
+ stop()
+ break
+ }
+ base, original, err := bulkEditStartFromText(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")
@@ -441,32 +483,6 @@ func diff(line, field, old string) *string {
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()
@@ -481,53 +497,12 @@ func (w *awin) put() {
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))
- }
+ issue, err := writeIssue(old, data, false)
+ if err != nil {
+ w.err(err.Error())
+ return
}
-
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)
@@ -536,43 +511,26 @@ func (w *awin) put() {
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])
}
+ w.load()
- 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
- }
+ case modeBulk:
+ data, err := w.ReadAll("body")
+ if err != nil {
+ w.err(fmt.Sprintf("Put: %v", err))
+ return
}
-
- 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")
+ ids, err := bulkWriteIssue(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
}
-
- if !failed {
- w.load()
- }
+ w.err(fmt.Sprintf("updated %d issue%s", len(ids), suffix(len(ids))))
case modeMilestone:
w.err("cannot Put milestone list")
@@ -695,6 +653,24 @@ func (w *awin) loop() {
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
@@ -706,6 +682,7 @@ func (w *awin) loop() {
}
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)
@@ -716,71 +693,69 @@ func (w *awin) loop() {
func (w *awin) printTabbed(text string) {
lines := strings.SplitAfter(text, "\n")
- var rows [][]string
+ var allRows [][]string
for _, line := range lines {
if line == "" {
continue
}
line = strings.TrimSuffix(line, "\n")
- rows = append(rows, strings.Split(line, "\t"))
+ allRows = append(allRows, 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 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
}
- }
- 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
- }
+ i := 0
+ for i < len(allRows) && len(allRows[i]) > 1 {
+ i++
}
- buf.WriteString("\n")
- }
- w.Write("body", buf.Bytes())
-}
+ rows := allRows[:i]
+ allRows = allRows[i:]
-func findMilestone(w *awin, name *string) *int {
- if name == nil {
- return nil
- }
+ var wid []int
- all, err := loadMilestones()
- if err != nil {
- w.err(fmt.Sprintf("Error loading milestone list: %v\n\tIgnoring milestone change.\n", err))
- return nil
- }
+ 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
+ }
+ }
+ }
+ }
- for _, m := range all {
- if getString(m.Title) == *name {
- return m.Number
+ 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.err(fmt.Sprintf("Ignoring unknown milestone: %s\n", *name))
- return nil
+ w.Write("body", buf.Bytes())
}
diff --git a/issue/edit.go b/issue/edit.go
@@ -0,0 +1,499 @@
+// 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"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/go-github/github"
+)
+
+func editIssue(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)
+ 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 := ioutil.TempFile("", "issue-edit-")
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := ioutil.WriteFile(f.Name(), original, 0666); err != nil {
+ log.Fatal(err)
+ }
+ if err := runEditor(f.Name()); err != nil {
+ log.Fatal(err)
+ }
+ updated, err := ioutil.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.Contains(ed, "|&;<>()$`\\\"' \t\n*?[#~=%") {
+ cmd = exec.Command("sh", "-c", ed+` "$@"`, 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(old *github.Issue, updated []byte, isBulk bool) (issue *github.Issue, 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, diff(line, "Milestone:", getMilestoneTitle(old.Milestone)))
+
+ case strings.HasPrefix(line, "URL:"):
+ continue
+
+ default:
+ fmt.Fprintf(&errbuf, "unknown summary line: %s\n", line)
+ }
+ }
+
+ if errbuf.Len() > 0 {
+ return nil, nil
+ }
+
+ if getInt(old.Number) == 0 {
+ comment := strings.TrimSpace(sdata[off:])
+ edit.Body = &comment
+ issue, _, err := client.Issues.Create(projectOwner, projectRepo, &edit)
+ if err != nil {
+ fmt.Fprintf(&errbuf, "error creating issue: %v\n", err)
+ return nil, nil
+ }
+ return issue, nil
+ }
+
+ if getInt(old.Number) == -1 {
+ // Asking to just sanity check the text parsing.
+ return 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 == "<optional comment here>" {
+ comment = ""
+ }
+
+ var failed bool
+ var did []string
+ if comment != "" {
+ _, _, err := client.Issues.CreateComment(projectOwner, projectRepo, getInt(old.Number), &github.IssueComment{
+ Body: &comment,
+ })
+ 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 || len(edit.Labels) > 0 || edit.Milestone != nil {
+ _, _, err := client.Issues.Edit(projectOwner, projectRepo, getInt(old.Number), &edit)
+ if err != nil {
+ fmt.Fprintf(&errbuf, "error changing metadata: %v\n", err)
+ failed = true
+ } else {
+ did = append(did, "updated metadata")
+ }
+ }
+ if edit.Labels != nil && len(edit.Labels) == 0 {
+ // Work around https://github.com/google/go-github/issues/181
+ // Note that if this code is removed, the test for the previous block
+ // should change from len(edit.Labels) > 0 to edit.Labels != nil.
+ _, err := client.Issues.RemoveLabelsForIssue(projectOwner, projectRepo, getInt(old.Number))
+ if err != nil {
+ fmt.Fprintf(&errbuf, "error deleting labels: %v\n", err)
+ failed = true
+ } else {
+ did = append(did, "deleted labels")
+ }
+ }
+ if len(addLabels) > 0 {
+ _, _, err := client.Issues.AddLabelsToIssue(projectOwner, projectRepo, getInt(old.Number), addLabels)
+ 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 {
+ _, err := client.Issues.RemoveLabelForIssue(projectOwner, projectRepo, getInt(old.Number), label)
+ 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, name *string) *int {
+ if name == nil {
+ return nil
+ }
+
+ all, err := loadMilestones()
+ 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, "Ignoring 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(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)
+ 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(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) })
+ if err != nil {
+ errText := strings.Replace(err.Error(), "\n", "\t\n", -1)
+ if len(ids) > 0 {
+ log.Fatal("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)
+}
+
+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<optional comment here>\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(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
+ if _, err := writeIssue(old, updated, true); 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 client.Rate.Limit > 0 && client.Rate.Remaining == 0 {
+ delta := (client.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 {
+ status(fmt.Sprintf("reading rate limit: %v", err))
+ }
+ }
+ *old.Number = number
+ if _, err := writeIssue(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
+}
diff --git a/issue/issue.go b/issue/issue.go
@@ -2,9 +2,9 @@
// 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.
+// Issue is a client for reading and updating issues in a GitHub project issue tracker.
//
-// usage: issue [-a] [-p owner/repo] <query>
+// usage: issue [-a] [-e] [-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.
@@ -31,7 +31,7 @@
// if you want to work with issue trackers for private repositories.
// It does not need any other permissions.
//
-// Acme
+// 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.
@@ -131,6 +131,39 @@
// 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
@@ -145,9 +178,27 @@
// 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 <query> is a single number, issue -e edits a single issue.
+// See the ``Issue Window'' section above.
+//
+// If the <query> 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.
+//
package main // import "rsc.io/github/issue"
import (
+ "bytes"
"flag"
"fmt"
"io"
@@ -159,6 +210,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"time"
"github.com/google/go-github/github"
@@ -166,15 +218,15 @@ import (
)
var (
- acmeFlag = flag.Bool("a", false, "acme")
-
+ acmeFlag = flag.Bool("a", false, "open in new acme window")
+ editFlag = flag.Bool("e", false, "edit in system editor")
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>
+ fmt.Fprintf(os.Stderr, `usage: issue [-a] [-e] [-p owner/repo] <query>
If query is a single number, prints the full history for the issue.
Otherwise, prints a table of matching results.
@@ -191,7 +243,6 @@ func main() {
if flag.NArg() == 0 && !*acmeFlag {
usage()
}
- q := strings.Join(flag.Args(), " ")
f := strings.Split(*project, "/")
if len(f) != 2 {
@@ -206,14 +257,43 @@ func main() {
acmeMode()
}
+ q := strings.Join(flag.Args(), " ")
+
+ if *editFlag && q == "new" {
+ editIssue([]byte(createTemplate), new(github.Issue))
+ return
+ }
+
n, _ := strconv.Atoi(q)
if n != 0 {
+ if *editFlag {
+ var buf bytes.Buffer
+ issue, err := showIssue(&buf, n)
+ if err != nil {
+ log.Fatal(err)
+ }
+ editIssue(buf.Bytes(), issue)
+ return
+ }
if _, err := showIssue(os.Stdout, n); err != nil {
log.Fatal(err)
}
return
}
+ if *editFlag {
+ all, err := searchIssues(q)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if len(all) == 0 {
+ log.Fatal("no issues matched search")
+ }
+ sort.Sort(issuesByTitle(all))
+ bulkEditIssues(all)
+ return
+ }
+
if err := showQuery(os.Stdout, q); err != nil {
log.Fatal(err)
}
@@ -224,6 +304,7 @@ func showIssue(w io.Writer, n int) (*github.Issue, error) {
if err != nil {
return nil, err
}
+ updateIssueCache(issue)
return issue, printIssue(w, issue)
}
@@ -276,31 +357,161 @@ func printIssue(w io.Writer, issue *github.Issue) error {
}
func showQuery(w io.Writer, q string) error {
- var all []string
+ all, err := searchIssues(q)
+ if err != nil {
+ return err
+ }
+ sort.Sort(issuesByTitle(all))
+ 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(q string) ([]*github.Issue, error) {
+ if opt, ok := queryToListOptions(q); ok {
+ return listRepoIssues(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{
ListOptions: github.ListOptions{
Page: page,
PerPage: 100,
},
})
+ for i := range x.Issues {
+ updateIssueCache(&x.Issues[i])
+ all = append(all, &x.Issues[i])
+ }
if err != nil {
- return err
+ return all, 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
+ }
+ return all, nil
+}
+
+func queryToListOptions(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(ioutil.Discard, &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(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(projectOwner, projectRepo, &xopt)
+ for i := range issues {
+ updateIssueCache(&issues[i])
+ all = append(all, &issues[i])
+ }
+ if err != nil {
+ return all, err
}
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])
+
+ // 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 nil
+ return save, nil
}
func loadMilestones() ([]github.Milestone, error) {
@@ -415,5 +626,51 @@ func getLabelNames(x []github.Label) []string {
for _, lab := range x {
out = append(out, getString(lab.Name))
}
+ sort.Strings(out)
return out
}
+
+var issueCache struct {
+ sync.Mutex
+ m map[int]*github.Issue
+}
+
+func updateIssueCache(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[n] = issue
+ issueCache.Unlock()
+}
+
+func bulkReadIssuesCached(ids []int) ([]*github.Issue, error) {
+ var all []*github.Issue
+ issueCache.Lock()
+ for _, id := range ids {
+ all = append(all, issueCache.m[id])
+ }
+ issueCache.Unlock()
+
+ var errbuf bytes.Buffer
+ for i, id := range ids {
+ if all[i] == nil {
+ issue, _, err := client.Issues.Get(projectOwner, projectRepo, id)
+ if err != nil {
+ fmt.Fprintf(&errbuf, "reading #%d: %v\n", id, err)
+ continue
+ }
+ updateIssueCache(issue)
+ all[i] = issue
+ }
+ }
+ var err error
+ if errbuf.Len() > 0 {
+ err = fmt.Errorf("%s", strings.TrimSpace(errbuf.String()))
+ }
+ return all, err
+}