commit 4b4a1e1a4e3aba4c9ba625ac563d40248182249f
parent 01ed92f204ee6627ab9a19df16bb548df7dcf6ab
Author: Oliver Lowe <o@olowe.co>
Date: Fri, 17 Jul 2026 22:41:48 +1000
issue: reimplement based on fs.FS interface to GitHub issues
Read-only for now. No searches (yet).
Diffstat:
| M | go.mod | | | 8 | +------- |
| M | go.sum | | | 10 | ---------- |
| D | issue/LICENSE | | | 27 | --------------------------- |
| M | issue/acme.go | | | 617 | ++++++------------------------------------------------------------------------- |
| M | issue/doc.go | | | 249 | ++++++++++--------------------------------------------------------------------- |
| D | issue/edit.go | | | 519 | ------------------------------------------------------------------------------- |
| A | issue/hub/file.go | | | 221 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | issue/hub/fs.go | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | issue/hub/hub.go | | | 83 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- |
| M | issue/issue.go | | | 755 | ++++--------------------------------------------------------------------------- |
10 files changed, 485 insertions(+), 2082 deletions(-)
diff --git a/go.mod b/go.mod
@@ -2,10 +2,4 @@ module olowe.co/issues
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
diff --git a/go.sum b/go.sum
@@ -3,13 +3,6 @@
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=
@@ -27,8 +20,6 @@ golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hM
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=
@@ -41,4 +32,3 @@ golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapK
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=
diff --git a/issue/LICENSE b/issue/LICENSE
@@ -1,27 +0,0 @@
-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.
diff --git a/issue/acme.go b/issue/acme.go
@@ -1,124 +1,15 @@
-// Copyright 2013 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// Originally code.google.com/p/rsc/cmd/issue/acme.go.
-
package main
import (
- "bufio"
- "bytes"
- "context"
- "flag"
- "fmt"
- "log"
- "os"
+ "errors"
+ "io/fs"
"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(proj string) {
- var dummy awin
- dummy.prefix = path.Join(root, proj) + "/"
- 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
@@ -129,510 +20,94 @@ const (
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()
+func (w *awin) pathname() string {
+ b, err := w.ReadAll("tag")
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)
- }
+ w.Errf("read tag: %v", err)
+ return ""
}
- 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
+ name := strings.Fields(string(b))[0]
+ return strings.TrimPrefix(name, "/issue/")
}
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 text == "all" {
- if w.show("all") {
- 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
- }
- 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)
- return true
+ // found an issue, try loading it
}
- 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
- }
+ name := path.Join(w.pathname(), text)
+ stat, err := fs.Stat(fsys, name)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false
+ } else if err != nil {
+ w.Err(err.Error())
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) {
- var buf bytes.Buffer
- id := findMilestone(&buf, w.project(), &milestone)
- if buf.Len() > 0 {
- w.Err(strings.TrimSpace(buf.String()))
- }
- 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)
+ w1 := new(awin)
+ w1.Win, err = acme.New()
if err != nil {
- w.Err(fmt.Sprintf("Error changing issue #%d: %v", n, err))
+ w.Err(err.Error())
+ return false
}
-}
-
-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()
-}
-
-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
+ if stat.IsDir() {
+ w1.Name("/issue/" + name + "/")
+ } else {
+ w1.Name("/issue/" + name)
}
-
- w.Addr("0")
- w.Ctl("dot=addr")
- w.Ctl("show")
+ go w1.load()
+ go w1.EventLoop(w1)
+ w1.Ctl("show")
+ return true
}
-func diff(line, field, old string) *string {
- old = strings.TrimSpace(old)
- line = strings.TrimSpace(strings.TrimPrefix(line, field))
- if old == line {
- return nil
+func (w *awin) load() {
+ name := w.pathname()
+ if name == "" {
+ name = "." // special name for root
}
- 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)
+ defer w.Ctl("clean")
+ if strings.HasSuffix(name, "/") || name == "." {
+ entries, err := fs.ReadDir(fsys, path.Clean(name))
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
+ for _, dir := range entries {
+ if dir.IsDir() {
+ w.PrintTabbed(dir.Name() + "/")
+ } else {
+ w.PrintTabbed(dir.Name())
+ }
}
- w.load()
-
- case modeBulk:
- data, err := w.ReadAll("body")
+ } else {
+ b, err := fs.ReadFile(fsys, name)
if err != nil {
- w.Err(fmt.Sprintf("Put: %v", err))
+ w.Err(err.Error())
return
}
- ids, err := bulkWriteIssue(w.project(), w.github, data, func(s string) { w.Err("Put: " + s) })
+ _, err = w.Write("body", b)
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(err.Error())
+ return
}
- 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 {
switch cmd {
case "Get":
w.load()
return true
- case "Put":
- w.put()
- return true
case "Del":
w.Ctl("del")
return true
- case "New":
- w.createIssue()
- return true
- case "Sort":
- if w.mode != modeQuery {
- w.Err("can only sort issue list windows")
- break
- }
- 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")))
- 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)
-}
diff --git a/issue/doc.go b/issue/doc.go
@@ -1,248 +1,61 @@
-// 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.
+Issue is a client for reading issues in a GitHub repository from the Acme editor.
- usage: issue [-a] [-e] [-p owner/repo] <query>
+ usage: issue [-p owner/repo]
-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.
+$HOME/.config/github/token
+and will use that token to authenticate
+to GitHub when reading or writing issues.
# 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
+Issue serves a synthetic read-only filesystem consisting of issues and any comments made on them as a tree.
- Reported by dsymonds (2014-09-21 23:02:50)
+Navigating the issue tree is similar to navigating regular filesystems in Acme.
- 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.
+The root directory contains references to all issues of the repository,
+one directory per issue named after the issue number.
- Comment by rsc (2015-01-08 05:17:06)
+Each issue directory contains the following files:
- time must not depend on fmt.
+ - issue - holds a text representation of the issue in a format similar to an email message.
+ - comments - a directory containing numbered files for each comment made on the parent issue. Each contains a text representation of the comment in the same format as issue files.
-Executing "Get" reloads the issue data.
+Reading issue 28 from the file "28/issue" will yield something like the below:
-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:
+ Title: scte35: support decoding splice_schedule command type
+ State: open
+ From: ollytom
+ Date: 2024-11-30 02:30:57
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
+ URL: https://github.com/untangledco/streaming/issues/28
-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:
+ We support encoding splice_schedule commands (see
+ `packEvents`), but we don't support decoding them yet. For
+ consistency, we could have two functions, `unpackEvent` and
+ `unpackEvents` in command.go after `packEvent`.
- Milestones: Go1.4.1 Go1.5 Go1.5Maybe
+To load comment 2508795164 made on issue 28, open the file "28/comments/2508795164":
- 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
- ...
+ From: ollytom
+ Date: 2024-11-30 02:30:56 +0000 UTC
-As in any window, right clicking on an issue number opens a window for that issue.
+ > Have you ever seen one in the wild?
-# Search Result Window
+ Haha good question! Never actually now that I think about it.
-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:
+ > I dropped support for Splice Schedule.
- Search author:rsc
+ Not a bad idea. At least could store it as raw byte slice.
- 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
- ...
+ Thanks for reaching out :) Any other tips?
-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.
+Executing "Get" reloads a file's (either regular file or directory listing) backing data via the GitHub API.
*/
package main
diff --git a/issue/edit.go b/issue/edit.go
@@ -1,519 +0,0 @@
-// 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:]
-}
diff --git a/issue/hub/file.go b/issue/hub/file.go
@@ -0,0 +1,221 @@
+package hub
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "io/fs"
+ "strconv"
+ "time"
+)
+
+type ftype uint8
+
+const (
+ ftypeRoot = iota
+ ftypeIssueDir
+ ftypeIssue
+ ftypeCommentDir
+ ftypeComment
+)
+
+type fakeFile struct {
+ *Client
+ owner, repo string
+ typ ftype
+
+ // comment, issue files
+ number int
+ buf *bytes.Buffer
+
+ // comment files need to know which issue they belong to
+ parent int
+
+ // directory files
+ children []fs.DirEntry
+ dirp int
+}
+
+func (f *fakeFile) Name() string {
+ switch f.typ {
+ case ftypeRoot:
+ return "."
+ case ftypeIssueDir:
+ return strconv.Itoa(f.number)
+ case ftypeIssue:
+ return "issue"
+ case ftypeCommentDir:
+ return "comments"
+ case ftypeComment:
+ return strconv.Itoa(f.number)
+ }
+ return "unknown"
+}
+
+func (f *fakeFile) IsDir() bool {
+ switch f.typ {
+ case ftypeRoot, ftypeIssueDir, ftypeCommentDir:
+ return true
+ }
+ return false
+}
+
+func (f *fakeFile) Type() fs.FileMode {
+ if f.IsDir() {
+ return fs.ModeDir
+ }
+ return 0
+}
+
+func (f *fakeFile) Info() (fs.FileInfo, error) { return f.Stat() }
+
+func (f *fakeFile) Stat() (fs.FileInfo, error) {
+ switch f.typ {
+ case ftypeRoot:
+ return stat{name: ".", mode: fs.ModeDir}, nil
+ case ftypeIssueDir, ftypeCommentDir:
+ return stat{name: f.Name(), mode: 0o555 | fs.ModeDir}, nil
+ case ftypeIssue:
+ issue, err := f.Client.LookupIssue(f.owner, f.repo, f.number)
+ if err != nil {
+ return nil, fmt.Errorf("lookup issue %d: %w", f.number, err)
+ }
+ buf := &bytes.Buffer{}
+ if f.buf == nil {
+ f.buf = buf
+ }
+ _ = printIssue(buf, issue)
+ st := stat{
+ name: f.Name(),
+ mtime: issue.Updated,
+ length: int64(buf.Len()),
+ sys: issue,
+ }
+ return st, nil
+ case ftypeComment:
+ comment, err := f.Client.LookupComment(f.owner, f.repo, f.number)
+ if err != nil {
+ return nil, &fs.PathError{"stat", f.Name(), fmt.Errorf("lookup comment %d: %w", f.number, err)}
+ }
+ buf := &bytes.Buffer{}
+ if f.buf == nil {
+ f.buf = buf
+ }
+ _ = printComment(buf, *comment)
+ st := stat{
+ name: f.Name(),
+ mtime: comment.Updated,
+ length: int64(buf.Len()),
+ sys: comment,
+ }
+ return st, nil
+ }
+ return nil, &fs.PathError{"stat", f.Name(), fs.ErrNotExist}
+}
+
+func (f *fakeFile) Read(p []byte) (n int, err error) {
+ switch f.typ {
+ case ftypeRoot, ftypeIssueDir, ftypeCommentDir:
+ return 0, &fs.PathError{"read", f.Name(), fmt.Errorf("is a directory")}
+ }
+
+ if f.buf == nil {
+ if _, err := f.Stat(); err != nil {
+ return 0, &fs.PathError{"read", f.Name(), err}
+ }
+ }
+ return f.buf.Read(p)
+}
+
+func (f *fakeFile) ReadDir(n int) ([]fs.DirEntry, error) {
+ if f.typ == ftypeIssue || f.typ == ftypeComment {
+ return nil, &fs.PathError{"readdir", f.Name(), fmt.Errorf("not a directory")}
+ }
+
+ if f.children == nil {
+ switch f.typ {
+ case ftypeRoot:
+ issues, err := f.Client.Issues(f.owner, f.repo)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.Name(), err}
+ }
+ f.children = make([]fs.DirEntry, len(issues))
+ for i, issue := range issues {
+ f.children[i] = &fakeFile{
+ typ: ftypeIssueDir,
+ number: issue.Number,
+ }
+ }
+ case ftypeIssueDir:
+ f.children = []fs.DirEntry{
+ &fakeFile{
+ typ: ftypeIssue,
+ number: f.number,
+ },
+ &fakeFile{
+ typ: ftypeCommentDir,
+ number: f.number,
+ },
+ }
+ case ftypeCommentDir:
+ comments, err := f.Client.Comments(f.owner, f.repo, f.number)
+ if err != nil {
+ return nil, &fs.PathError{"readdir", f.Name(), err}
+ }
+ f.children = make([]fs.DirEntry, len(comments))
+ for i, c := range comments {
+ f.children[i] = &fakeFile{
+ typ: ftypeComment,
+ number: c.ID,
+ parent: f.parent,
+ }
+ }
+ }
+ }
+
+ if f.dirp >= len(f.children) {
+ if n <= 0 {
+ return nil, nil
+ }
+ return nil, io.EOF
+ }
+ if n <= 0 {
+ f.dirp = len(f.children)
+ return f.children, nil
+ }
+
+ var err error
+ d := f.children[f.dirp:]
+ if len(d) >= n {
+ d = d[:n]
+ } else if len(d) <= n {
+ err = io.EOF
+ }
+ f.dirp += n
+ return d, err
+}
+
+func (f *fakeFile) Close() error {
+ if f.buf != nil {
+ f.buf.Reset()
+ }
+ f.buf = nil
+ f.children = nil
+ f.Client = nil
+ return nil
+}
+
+type stat struct {
+ name string
+ length int64
+ mode fs.FileMode
+ mtime time.Time
+ sys any
+}
+
+func (s stat) Name() string { return s.name }
+func (s stat) Size() int64 { return s.length }
+func (s stat) Mode() fs.FileMode { return s.mode }
+func (s stat) ModTime() time.Time { return s.mtime }
+func (s stat) IsDir() bool { return s.Mode().IsDir() }
+func (s stat) Sys() any { return s.sys }
diff --git a/issue/hub/fs.go b/issue/hub/fs.go
@@ -0,0 +1,78 @@
+package hub
+
+import (
+ "io/fs"
+ "path"
+ "strconv"
+ "strings"
+)
+
+type FS struct {
+ Client *Client
+ Owner string
+ Repo string
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+ if !fs.ValidPath(name) {
+ return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+ }
+ name = path.Clean(name)
+ if strings.Contains(name, "\\") {
+ return nil, fs.ErrNotExist
+ }
+
+ if name == "." {
+ return &fakeFile{
+ Client: fsys.Client,
+ typ: ftypeRoot,
+ owner: fsys.Owner,
+ repo: fsys.Repo,
+ }, nil
+ }
+
+ elems := strings.Split(name, "/")
+ issueNum, err := strconv.Atoi(elems[0])
+ if err != nil {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+ f := &fakeFile{
+ Client: fsys.Client,
+ owner: fsys.Owner,
+ repo: fsys.Repo,
+ number: issueNum,
+ }
+
+ switch len(elems) {
+ case 1:
+ // /123
+ _, err = fsys.Client.CheckIssue(fsys.Owner, fsys.Repo, issueNum)
+ if err != nil {
+ return nil, err
+ }
+ f.typ = ftypeIssueDir
+ case 2:
+ // /123/issue
+ // /123/comments
+ switch elems[1] {
+ case "issue":
+ f.typ = ftypeIssue
+ case "comments":
+ f.typ = ftypeCommentDir
+ default:
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+ case 3:
+ // /123/comments/69
+ num, err := strconv.Atoi(elems[2])
+ if err != nil {
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+ f.typ = ftypeComment
+ f.parent = issueNum
+ f.number = num
+ default:
+ return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+ }
+ return f, nil
+}
diff --git a/issue/hub/hub.go b/issue/hub/hub.go
@@ -3,9 +3,13 @@ package hub
import (
"bytes"
"encoding/json"
+ "errors"
"fmt"
"io"
+ "io/fs"
"net/http"
+ "path"
+ "strconv"
"strings"
"time"
)
@@ -51,12 +55,12 @@ func printIssue(w io.Writer, issue *Issue) error {
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 Comment struct {
+ ID int
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
Body string
@@ -65,6 +69,16 @@ type Comment struct {
}
}
+func printComment(w io.Writer, c Comment) error {
+ buf := &bytes.Buffer{}
+ fmt.Fprintln(buf, "From:", c.User.Name)
+ fmt.Fprintln(buf, "Date:", c.Updated)
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, strings.TrimSpace(c.Body))
+ _, err := io.Copy(w, buf)
+ return err
+}
+
func printComments(w io.Writer, comments []Comment) error {
buf := &bytes.Buffer{}
for _, c := range comments {
@@ -77,27 +91,58 @@ func printComments(w io.Writer, comments []Comment) error {
return err
}
-func (c *Client) LookupIssue(number int) (*Issue, error) {
+func (c *Client) CheckIssue(owner, repo string, number int) (bool, error) {
+ if c.Client == nil {
+ c.Client = http.DefaultClient
+ }
+
+ p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(number))
+ resp, err := c.head(p)
+ if err != nil {
+ return false, err
+ }
+ // TODO: don't reuse ErrNotExist?
+ if resp.StatusCode == http.StatusNotFound {
+ return false, fs.ErrNotExist
+ } else if resp.StatusCode != http.StatusOK {
+ return false, errors.New(resp.Status)
+ }
+ return true, nil
+}
+
+func (c *Client) LookupIssue(owner, repo string, number int) (*Issue, error) {
var issue Issue
- path := fmt.Sprintf("/issues/%d", number)
- err := c.get(path, &issue)
+ p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(number))
+ err := c.get(p, &issue)
return &issue, err
}
func (c *Client) Issues(owner, repo string) ([]Issue, error) {
var issues []Issue
- err := c.get("/issues", &issues)
+ p := path.Join("/repos", owner, repo, "issues")
+ err := c.get(p, &issues)
return issues, err
}
-func (c *Client) Comments(issue int) ([]Comment, error) {
+func (c *Client) Comments(owner, repo string, issue int) ([]Comment, error) {
var comments []Comment
- path := fmt.Sprintf("/issues/%d/comments", issue)
- err := c.get(path, &comments)
+ p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(issue), "comments")
+ err := c.get(p, &comments)
return comments, err
}
+func (c *Client) LookupComment(owner, repo string, number int) (*Comment, error) {
+ p := path.Join("/repos", owner, repo, "issues", "comments", strconv.Itoa(number))
+ var comment Comment
+ err := c.get(p, &comment)
+ return &comment, err
+}
+
func (c *Client) get(path string, v any) error {
+ if c.baseURL == "" {
+ c.baseURL = defaultBaseURL
+ }
+
u := c.baseURL + path
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
@@ -109,7 +154,10 @@ func (c *Client) get(path string, v any) error {
return err
}
defer resp.Body.Close()
- // TODO: check HTTP status, decode any error messages from body
+ if resp.StatusCode != http.StatusOK {
+ // TODO: decode any error messages from body
+ return errors.New(resp.Status)
+ }
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("decode response: %w", err)
@@ -118,6 +166,10 @@ func (c *Client) get(path string, v any) error {
}
func (c *Client) post(path string, body io.Reader) (*http.Response, error) {
+ if c.baseURL == "" {
+ c.baseURL = defaultBaseURL
+ }
+
u := c.baseURL + path
req, err := http.NewRequest(http.MethodPost, u, body)
if err != nil {
@@ -126,6 +178,19 @@ func (c *Client) post(path string, body io.Reader) (*http.Response, error) {
return c.do(req)
}
+func (c *Client) head(path string) (*http.Response, error) {
+ if c.baseURL == "" {
+ c.baseURL = defaultBaseURL
+ }
+
+ u := c.baseURL + path
+ req, err := http.NewRequest(http.MethodHead, u, nil)
+ 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
diff --git a/issue/issue.go b/issue/issue.go
@@ -1,43 +1,30 @@
-// 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 "rsc.io/github/issue"
+package main
import (
"bytes"
- "context"
- "encoding/json"
+ "errors"
"flag"
"fmt"
- "io"
+ "io/fs"
"log"
- "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"
+ "olowe.co/issues/issue/hub"
)
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")
projectFlag = flag.String("p", "", "GitHub owner/repo name")
- rawFlag = flag.Bool("raw", false, "do no processing of markdown")
- logHTTP = flag.Bool("loghttp", false, "log http requests")
)
+var fsys fs.FS
+
var project string = "golang/go"
func usage() {
- fmt.Fprintf(os.Stderr, `usage: issue [-a] [-e] [-p owner/repo] <query>
+ fmt.Fprintf(os.Stderr, `usage: issue [-p owner/repo]
If query is a single number, prints the full history for the issue.
Otherwise, prints a table of matching results.
@@ -46,35 +33,23 @@ Otherwise, prints a table of matching results.
os.Exit(2)
}
-func main() {
+func init() {
flag.Usage = usage
flag.Parse()
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 {
- http.DefaultTransport = newLogger(http.DefaultTransport)
- }
+}
+func main() {
confDir, err := os.UserConfigDir()
if err != nil {
log.Fatalln("find config dir:", err)
}
confDir = filepath.Join(confDir, "github")
- projPath := confDir + "/project"
if *projectFlag != "" {
project = *projectFlag
} else {
+ projPath := confDir + "/project"
if _, err := os.Stat(projPath); err == nil {
b, err := os.ReadFile(projPath)
if err != nil {
@@ -84,377 +59,34 @@ func main() {
}
}
}
- if len(strings.Split(project, "/")) != 2 {
- log.Fatal("invalid form for -p argument: must be owner/repo, like golang/go")
- }
-
- if err := loadAuth(filepath.Join(confDir, "token")); err != nil {
- log.Fatalln("load auth:", err)
- }
-
- if *acmeFlag {
- acmeMode(project)
+ token, err := os.ReadFile(filepath.Join(confDir, "token"))
+ if errors.Is(err, fs.ErrNotExist) {
+ // continue unauthenticated
+ } else if err != nil {
+ log.Fatalf("load github auth token: %v", err)
}
- q := strings.Join(flag.Args(), " ")
-
- if *editFlag && q == "new" {
- editIssue(project, []byte(createTemplate), new(github.Issue))
- return
+ owner, repo, ok := strings.Cut(project, "/")
+ if !ok {
+ log.Fatal("invalid project form: must be owner/repo, like golang/go")
}
- 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
+ fsys = &hub.FS{
+ Client: &hub.Client{
+ Token: string(token),
+ },
+ Owner: owner,
+ Repo: repo,
}
- 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 {
- 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)
+ var root awin
+ root.Win, err = acme.New()
if err != nil {
- return nil, err
+ log.Fatalf("create root acme window: %v", err)
}
- updateIssueCache(project, issue)
- return issue, printIssue(w, project, issue)
-}
-
-func printIssue(w io.Writer, project string, issue *github.Issue) error {
- if *jsonFlag {
- showJSONIssue(w, project, issue)
- return nil
- }
-
- 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))
-
- 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"))
- }
- }
- }
-
- 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
- }
-
- 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
- }
-
- sort.Strings(output)
- for _, s := range output {
- i := strings.Index(s, "\n")
- fmt.Fprintf(w, "%s", s[i+1:])
- }
-
- return nil
-}
-
-func showQuery(w io.Writer, project, q string) error {
- all, err := searchIssues(project, q)
- if err != nil {
- return err
- }
- sort.Sort(issuesByTitle(all))
- if *jsonFlag {
- showJSONList(project, all)
- return nil
- }
- 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
+ root.Name("/issue/")
+ root.load()
+ root.EventLoop(&root)
}
func wrap(t string, prefix string) string {
@@ -481,329 +113,10 @@ func wrap(t string, prefix string) string {
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()
+ dir, err := os.UserConfigDir()
if err != nil {
log.Fatal("find user configuration directory: ", err)
}
- 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}
-}
-
-type loggingTransport struct {
- transport http.RoundTripper
- mu sync.Mutex
- active []byte
-}
-
-func (t *loggingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
- t.mu.Lock()
- index := len(t.active)
- start := time.Now()
- fmt.Fprintf(os.Stderr, "HTTP: %s %s+ %s\n", timeFormat1(start), t.active, r.URL)
- t.active = append(t.active, '|')
- t.mu.Unlock()
-
- resp, err := t.transport.RoundTrip(r)
-
- last := r.URL.Path
- if i := strings.LastIndex(last, "/"); i >= 0 {
- last = last[i:]
- }
- display := last
- if resp != nil {
- display += " " + resp.Status
- }
- if err != nil {
- display += " error: " + err.Error()
- }
- now := time.Now()
-
- t.mu.Lock()
- t.active[index] = '-'
- fmt.Fprintf(os.Stderr, "HTTP: %s %s %s (%.3fs)\n", timeFormat1(now), t.active, display, now.Sub(start).Seconds())
- t.active[index] = ' '
- n := len(t.active)
- for n%4 == 0 && n >= 4 && t.active[n-1] == ' ' && t.active[n-2] == ' ' && t.active[n-3] == ' ' && t.active[n-4] == ' ' {
- t.active = t.active[:n-4]
- n -= 4
- }
- t.mu.Unlock()
-
- return resp, err
-}
-
-func timeFormat1(t time.Time) string {
- return t.Format("15:04:05.000")
+ return dir
}