Commit Diff


commit - 432e81a8c21e9e8f2c9bac7b6548fe655e0afc23
commit + c91e253e7326d185468b13aeb0b9dbc275504ae3
blob - 2d59cefc4776cb6b940c7f8a143529d9728f67b2
blob + c7f1fe45da2bf62f66756a2e2ffd958d88ecd73a
--- go.mod
+++ 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
blob - b91297c4880b9fd5445893919fed9b72b3f3996f
blob + d81a876d3cb2d296808a432563bf86a43c14bbdc
--- go.sum
+++ 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/
 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/
 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
@@ -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.
blob - fa156c3c514954664de22413b7eb08441ac19a1e
blob + 55c6c6e8ae9ca0b26616cc32a7a5c4cfe0d08e35
--- issue/acme.go
+++ issue/acme.go
@@ -7,632 +7,130 @@
 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
@@ -0,0 +1,223 @@
+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
@@ -1,248 +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.
-
-/*
-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
@@ -0,0 +1,33 @@
+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
@@ -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:]
-}
blob - a877ea0e120b13ddf565ac268f6693cb4c1a267b (mode 644)
blob + /dev/null
--- issue/gh.go
+++ /dev/null
@@ -1,146 +0,0 @@
-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
@@ -1,33 +0,0 @@
-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
@@ -1,13 +1,7 @@
-// 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"
@@ -15,32 +9,20 @@ import (
 	"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)
 }
@@ -51,18 +33,7 @@ func main() {
 	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)
 	}
 
@@ -75,379 +46,73 @@ func main() {
 	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 {
@@ -468,25 +133,6 @@ 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()
 	if err != nil {
@@ -495,255 +141,6 @@ func mustConfigPath() string {
 	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}
 }