issues

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

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:
Missue/acme.go | 287++++++++++++++++++++++++++++++++++++-------------------------------------------
Aissue/edit.go | 499+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Missue/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 +}