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