Commit Diff


commit - 4f7a6856e8d1f7e96a76eb2854e8dc7eff9f9b6d
commit + 4d0a5834991f4debd135280b03d29f26735457c0
blob - 5f847009d1cec60ffb47873b705c95ee783962d3
blob + ebe4369672e9aef95dc2e4b1ed23a552f83e7e53
--- Jira/acme.go
+++ Jira/acme.go
@@ -7,8 +7,10 @@ import (
 	"io"
 	"io/fs"
 	"log"
+	"net/url"
 	"os"
 	"path"
+	"regexp"
 	"strings"
 
 	"9fans.net/go/acme"
@@ -34,42 +36,59 @@ func (w *awin) name() string {
 	return strings.TrimPrefix(fields[0], "/jira/")
 }
 
+var issueKeyExp = regexp.MustCompile("[A-Z]+-[0-9]+")
+
 func (w *awin) Look(text string) bool {
 	text = strings.TrimSpace(text)
-	fname := path.Join(path.Dir(w.name()), text)
-	if strings.HasSuffix(text, "/") {
-		fname = path.Join(w.name(), text)
+
+	var pathname string
+	if issueKeyExp.MatchString(text) {
+		proj, num, _ := strings.Cut(text, "-")
+		pathname = path.Join(proj, num, "issue")
+	} else {
+		pathname = path.Join(path.Dir(w.name()), text)
+		if strings.HasSuffix(text, "/") {
+			pathname = path.Join(w.name(), text)
+		}
 	}
-	f, err := w.fsys.Open(fname)
+	f, err := w.fsys.Open(pathname)
 	if errors.Is(err, fs.ErrNotExist) {
 		return false
 	} else if err != nil {
 		w.Err(err.Error())
 		return false
 	}
-	stat, err := f.Stat()
-	if err != nil {
-		w.Err(err.Error())
-		return true
-	}
 
 	win, err := acme.New()
 	if err != nil {
 		w.Err(err.Error())
 		return true
 	}
-
-	wname := path.Join("/jira", fname)
-	if stat.IsDir() {
-		wname += "/"
+	wname := path.Join("/jira", pathname)
+	if d, ok := f.(fs.DirEntry); ok {
+		if d.IsDir() {
+			wname += "/"
+		}
+	} else {
+		stat, err := f.Stat()
+		if err != nil {
+			w.Err(err.Error())
+			return true
+		}
+		if stat.IsDir() {
+			wname += "/"
+		}
 	}
 	win.Name(wname)
 	ww := &awin{win, w.fsys}
 	go ww.EventLoop(ww)
 	go func() {
-		if err := ww.Get(); err != nil {
+		if err := ww.Get(f); err != nil {
 			w.Err(err.Error())
 		}
+		ww.Addr("#0")
+		ww.Ctl("dot=addr")
+		ww.Ctl("show")
 	}()
 	return true
 }
@@ -78,40 +97,67 @@ func (w *awin) Execute(cmd string) bool {
 	fields := strings.Fields(strings.TrimSpace(cmd))
 	switch fields[0] {
 	case "Get":
-		if err := w.Get(); err != nil {
+		if err := w.Get(nil); err != nil {
 			w.Err(err.Error())
-			return false
 		}
+		return true
 	case "Search":
 		if len(fields) == 1 {
 			return false
 		}
 		query := strings.Join(fields[1:], " ")
-		go newSearch(w.fsys, APIRoot, query)
+		go newSearch(w.fsys, query)
 		return true
+	case "Comment":
+		if len(fields) > 1 {
+			return false
+		}
+		win, err := acme.New()
+		if err != nil {
+			w.Err(err.Error())
+			return false
+		}
+		dname := path.Dir(w.name())
+		win.Name(path.Join("/jira", dname, "new"))
+		win.Fprintf("tag", "Post ")
+		a := &awin{win, w.fsys}
+		go a.EventLoop(a)
+		return true
+	case "Post":
+		if err := w.postComment(); err != nil {
+			w.Errf("post comment: %s", err.Error())
+		}
+		w.Del(true)
+		return true
 	}
 	return false
 }
 
-func (w *awin) Get() error {
-	w.Ctl("dirty")
+func (w *awin) Get(f fs.File) error {
 	defer w.Ctl("clean")
-	f, err := w.fsys.Open(path.Clean(w.name()))
-	if err != nil {
-		return err
+	fname := path.Clean(w.name())
+	if fname == "/" {
+		fname = "." // special name for the root file in io/fs
 	}
+	if f == nil {
+		var err error
+		f, err = w.fsys.Open(fname)
+		if err != nil {
+			return err
+		}
+	}
 	stat, err := f.Stat()
 	if err != nil {
 		return err
 	}
 
 	defer f.Close()
-	buf := &bytes.Buffer{}
 	if stat.IsDir() {
 		dirs, err := f.(fs.ReadDirFile).ReadDir(-1)
 		if err != nil {
 			return err
 		}
+		buf := &strings.Builder{}
 		for _, d := range dirs {
 			if d.IsDir() {
 				fmt.Fprintln(buf, d.Name()+"/")
@@ -119,70 +165,120 @@ func (w *awin) Get() error {
 			}
 			fmt.Fprintln(buf, d.Name())
 		}
-	} else {
-		if _, err := io.Copy(buf, f); err != nil {
-			return fmt.Errorf("copy %s: %w", stat.Name(), err)
-		}
+		w.Clear()
+		w.PrintTabbed(buf.String())
+		return nil
 	}
+	b, err := io.ReadAll(f)
+	if err != nil {
+		return fmt.Errorf("read %s: %w", stat.Name(), err)
+	}
 	w.Clear()
-	if _, err := w.Write("body", buf.Bytes()); err != nil {
-		return fmt.Errorf("write %s: %w", "body", err)
+	if _, err := w.Write("body", b); err != nil {
+		return fmt.Errorf("write body: %w", err)
 	}
 	return nil
 }
 
-func newSearch(fsys fs.FS, apiRoot, query string) {
+func (w *awin) postComment() error {
+	defer w.Ctl("clean")
+	body, err := w.ReadAll("body")
+	if err != nil {
+		return fmt.Errorf("read body: %w", err)
+	}
+	f, ok := w.fsys.(*FS)
+	if !ok {
+		return fmt.Errorf("cannot write comment with filesystem type %T", w.fsys)
+	}
+	elems := strings.Split(w.name(), "/")
+	ikey := fmt.Sprintf("%s-%s", elems[0], elems[1])
+	return f.client.PostComment(ikey, bytes.NewReader(body))
+}
+
+func newSearch(fsys fs.FS, query string) {
 	win, err := acme.New()
 	if err != nil {
 		acme.Errf("new window: %v", err.Error())
 		return
 	}
+	defer win.Ctl("clean")
 	win.Name("/jira/search")
-	issues, err := searchIssues(apiRoot, query)
+	f, ok := fsys.(*FS)
+	if !ok {
+		win.Errf("cannot search with filesystem type %T", fsys)
+	}
+	win.PrintTabbed("Search "+query+"\n\n")
+	issues, err := f.client.SearchIssues(query)
 	if err != nil {
 		win.Errf("search %q: %v", query, err)
 		return
 	}
-	if err := win.Fprintf("body", "Search %s\n\n", query); err != nil {
-		win.Err(err.Error())
-		return
-	}
-	_, err = win.Write("body", []byte(printIssues(issues)))
-	if err != nil {
-		win.Err(err.Error())
-	}
+	win.PrintTabbed(printIssues(issues))
 	w := &awin{win, fsys}
 	go w.EventLoop(w)
 }
 
-var APIRoot = "https://jira.atlassian.com/api/rest/2"
+const usage string = "usage: Jira [keyfile]"
 
-func main() {
-	srv := newFakeServer("testdata")
-	defer srv.Close()
-	APIRoot = srv.URL
-	fsys := &FS{apiRoot: srv.URL}
+func readCreds(name string) (username, password string, err error) {
+	b, err := os.ReadFile(name)
+	if err != nil {
+		return "", "", err
+	}
+	u, p, found := strings.Cut(strings.TrimSpace(string(b)), ":")
+	if !found {
+		return "", "", fmt.Errorf("missing userpass field separator %q", ":")
+	}
+	return u, p, nil
+}
 
-	acme.AutoExit(true)
-	win, err := acme.New()
+// TODO(otl): don't hardcode lol
+const host string = "audinate.atlassian.net"
+
+func main() {
+	home, err := os.UserHomeDir()
 	if err != nil {
-		log.Fatal(err)
+		log.Fatalf("find user config dir: %v", err)
 	}
-	root := &awin{win, fsys}
+	credPath := path.Join(home, ".config/atlassian/jira")
+	if len(os.Args) == 2 {
+		credPath = os.Args[1]
+	} else if len(os.Args) > 2 {
+		fmt.Fprintln(os.Stderr, usage)
+		os.Exit(2)
+	}
+	user, pass, err := readCreds(credPath)
+	if err != nil {
+		log.Fatalf("read credentials: %v", err)
+	}
 
-	dirs, err := fs.ReadDir(fsys, ".")
+	// srv := newFakeServer("testdata")
+	// defer srv.Close()
+
+	u, err := url.Parse("https://" + host + "/rest/api/2")
 	if err != nil {
-		log.Fatal(err)
+		log.Fatalf("parse api root url: %v", err)
 	}
-	buf := &bytes.Buffer{}
-	for _, d := range dirs {
-		fmt.Fprintln(buf, d.Name()+"/")
+	fsys := &FS{
+		client: &Client{
+			debug:    false,
+			apiRoot:  u,
+			username: user,
+			password: pass,
+		},
 	}
-	if _, err := root.Write("body", buf.Bytes()); err != nil {
+
+	acme.AutoExit(true)
+	win, err := acme.New()
+	if err != nil {
 		log.Fatal(err)
 	}
-	root.Name("/jira/")
+	win.Name("/jira/")
+	root := &awin{win, fsys}
+	root.Get(nil)
+	root.Addr("#0")
+	root.Ctl("dot=addr")
+	root.Ctl("show")
 	win.Ctl("clean")
 	root.EventLoop(root)
-	os.Exit(0)
 }
blob - e1767fe65ba9fbacec7ce0d72e8025bc3d0c5971
blob + 3373c54419e45908db4065222ec9a8cdf92ca650
--- Jira/fs.go
+++ Jira/fs.go
@@ -4,15 +4,15 @@ import (
 	"fmt"
 	"io"
 	"io/fs"
-	"log"
+	"os"
 	"path"
 	"strings"
 	"time"
 )
 
 type FS struct {
-	apiRoot string
-	root    *fid
+	client *Client
+	root   *fid
 }
 
 const (
@@ -24,14 +24,14 @@ const (
 )
 
 type fid struct {
-	apiRoot string
-	name    string
-	typ     int
-	rd      io.Reader
-	parent  *fid
+	*Client
+	name   string
+	typ    int
+	rd     io.Reader
+	parent *fid
 
 	// May be set but only as an optimisation to skip a Stat().
-	stat *stat
+	stat fs.FileInfo
 
 	// directories only
 	children []fs.DirEntry
@@ -52,8 +52,8 @@ func (f *fid) Type() fs.FileMode {
 func (f *fid) Info() (fs.FileInfo, error) { return f.Stat() }
 
 func (f *fid) Stat() (fs.FileInfo, error) {
-	if debug {
-		log.Println("stat", f.name)
+	if f.Client.debug {
+		fmt.Fprintln(os.Stderr, "stat", f.Name())
 	}
 	if f.stat != nil {
 		return f.stat, nil
@@ -63,25 +63,30 @@ func (f *fid) Stat() (fs.FileInfo, error) {
 	case ftypeRoot:
 		return &stat{".", int64(len(f.children)), 0o444 | fs.ModeDir, time.Time{}}, nil
 	case ftypeProject:
-		p, err := getProject(f.apiRoot, f.name)
+		p, err := f.Project(f.name)
 		if err != nil {
 			return nil, &fs.PathError{"stat", f.name, err}
 		}
 		return p, nil
 	case ftypeIssueDir, ftypeIssue:
-		is, err := getIssue(f.apiRoot, f.issueKey())
+		is, err := f.Issue(f.issueKey())
 		if err != nil {
 			return nil, &fs.PathError{"stat", f.name, err}
 		}
 		if f.typ == ftypeIssueDir {
+			f.children = issueChildren(f, is)
 			return is, nil
 		}
+		// optimisation: we might read the file soon so load the contents.
+		f.rd = strings.NewReader(printIssue(is))
 		return &stat{f.name, int64(len(printIssue(is))), 0o444, is.Updated}, nil
 	case ftypeComment:
-		c, err := getComment(f.apiRoot, f.issueKey(), f.name)
+		c, err := f.Comment(f.issueKey(), f.name)
 		if err != nil {
 			return nil, &fs.PathError{"stat", f.name, err}
 		}
+		// optimisation: we might read the file soon so load the contents.
+		f.rd = strings.NewReader(printComment(c))
 		return c, nil
 	}
 	err := fmt.Errorf("unexpected fid type %d", f.typ)
@@ -89,17 +94,20 @@ func (f *fid) Stat() (fs.FileInfo, error) {
 }
 
 func (f *fid) Read(p []byte) (n int, err error) {
+	if f.Client.debug {
+		fmt.Fprintln(os.Stderr, "read", f.Name())
+	}
 	if f.rd == nil {
 		switch f.typ {
 		case ftypeComment:
-			c, err := getComment(f.apiRoot, f.issueKey(), f.name)
+			c, err := f.Comment(f.issueKey(), f.name)
 			if err != nil {
 				err = fmt.Errorf("get comment %s: %w", f.issueKey(), err)
 				return 0, &fs.PathError{"read", f.name, err}
 			}
 			f.rd = strings.NewReader(printComment(c))
 		case ftypeIssue:
-			is, err := getIssue(f.apiRoot, f.issueKey())
+			is, err := f.Issue(f.issueKey())
 			if err != nil {
 				err = fmt.Errorf("get issue %s: %w", f.issueKey(), err)
 				return 0, &fs.PathError{"read", f.name, err}
@@ -125,57 +133,41 @@ func (f *fid) Read(p []byte) (n int, err error) {
 
 func (f *fid) Close() error {
 	f.rd = nil
+	f.stat = nil
 	return nil
 }
 
 func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) {
+	if f.Client.debug {
+		fmt.Fprintln(os.Stderr, "readdir", f.Name())
+	}
 	if !f.IsDir() {
 		return nil, fmt.Errorf("not a directory")
 	}
-	if debug {
-		log.Println("readdir", f.name)
-	}
 	if f.children == nil {
 		switch f.typ {
 		case ftypeRoot:
 			return nil, fmt.Errorf("root initialised incorrectly: no dir entries")
 		case ftypeProject:
-			issues, err := getIssues(f.apiRoot, f.name)
+			issues, err := f.Issues(f.name)
 			if err != nil {
 				return nil, fmt.Errorf("get issues: %w", err)
 			}
 			f.children = make([]fs.DirEntry, len(issues))
 			for i, issue := range issues {
 				f.children[i] = &fid{
-					apiRoot: f.apiRoot,
-					name:    issue.Name(),
-					typ:     ftypeIssueDir,
-					parent:  f,
+					Client: f.Client,
+					name:   issue.Name(),
+					typ:    ftypeIssueDir,
+					parent: f,
 				}
 			}
 		case ftypeIssueDir:
-			issue, err := getIssue(f.apiRoot, f.issueKey())
+			issue, err := f.Issue(f.issueKey())
 			if err != nil {
 				return nil, fmt.Errorf("get issue %s: %w", f.name, err)
 			}
-			f.children = make([]fs.DirEntry, len(issue.Comments)+1)
-			for i, c := range issue.Comments {
-				f.children[i] = &fid{
-					apiRoot: f.apiRoot,
-					name:    c.ID,
-					typ:     ftypeComment,
-					rd:      strings.NewReader(printComment(&c)),
-					parent:  f,
-				}
-			}
-			f.children[len(f.children)-1] = &fid{
-				name:    "issue",
-				apiRoot: f.apiRoot,
-				typ:     ftypeIssue,
-				rd:      strings.NewReader(issue.Summary),
-				parent:  f,
-				stat:    &stat{"issue", int64(len(printIssue(issue))), 0o444, issue.Updated},
-			}
+			f.children = issueChildren(f, issue)
 		}
 	}
 
@@ -201,6 +193,29 @@ func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) {
 	return d, err
 }
 
+func issueChildren(parent *fid, is *Issue) []fs.DirEntry {
+	kids := make([]fs.DirEntry, len(is.Comments)+1)
+	for i, c := range is.Comments {
+		kids[i] = &fid{
+			Client: parent.Client,
+			name:   c.ID,
+			typ:    ftypeComment,
+			rd:     strings.NewReader(printComment(&c)),
+			parent: parent,
+			stat:   &c,
+		}
+	}
+	kids[len(kids)-1] = &fid{
+		name:   "issue",
+		Client: parent.Client,
+		typ:    ftypeIssue,
+		rd:     strings.NewReader(is.Summary),
+		parent: parent,
+		stat:   &stat{"issue", int64(len(printIssue(is))), 0o444, is.Updated},
+	}
+	return kids
+}
+
 func (f *fid) issueKey() string {
 	// to make the issue key e.g. "EXAMPLE-42"
 	// we need the name of the issue (parent name, "42")
@@ -230,14 +245,15 @@ func (fsys *FS) Open(name string) (fs.File, error) {
 
 	if fsys.root == nil {
 		var err error
-		fsys.root, err = makeRoot(fsys.apiRoot)
+		fsys.root, err = makeRoot(fsys.client)
 		if err != nil {
 			return nil, fmt.Errorf("make root file: %w", err)
 		}
 	}
-	if debug {
-		log.Println("open", name)
+	if fsys.client.debug {
+		fmt.Fprintln(os.Stderr, "open", name)
 	}
+
 	if name == "." {
 		f := *fsys.root
 		return &f, nil
@@ -260,22 +276,22 @@ func (fsys *FS) Open(name string) (fs.File, error) {
 	return &g, nil
 }
 
-func makeRoot(apiRoot string) (*fid, error) {
-	projects, err := getProjects(apiRoot)
+func makeRoot(client *Client) (*fid, error) {
+	projects, err := client.Projects()
 	if err != nil {
 		return nil, err
 	}
 	root := &fid{
-		apiRoot:  apiRoot,
+		Client:   client,
 		name:     ".",
 		typ:      ftypeRoot,
 		children: make([]fs.DirEntry, len(projects)),
 	}
 	for i, p := range projects {
 		root.children[i] = &fid{
-			apiRoot: apiRoot,
-			name:    p.Key,
-			typ:     ftypeProject,
+			Client: client,
+			name:   p.Key,
+			typ:    ftypeProject,
 		}
 	}
 	return root, nil
@@ -285,7 +301,7 @@ func find(dir *fid, name string) (*fid, error) {
 	if !dir.IsDir() {
 		return nil, fs.ErrNotExist
 	}
-	child := &fid{apiRoot: dir.apiRoot, parent: dir}
+	child := &fid{Client: dir.Client, parent: dir}
 	switch dir.typ {
 	case ftypeRoot:
 		for _, d := range dir.children {
@@ -300,7 +316,7 @@ func find(dir *fid, name string) (*fid, error) {
 		return nil, fs.ErrNotExist
 	case ftypeProject:
 		key := fmt.Sprintf("%s-%s", dir.name, name)
-		ok, err := checkIssue(dir.apiRoot, key)
+		ok, err := dir.CheckIssue(key)
 		if err != nil {
 			return nil, err
 		}
@@ -316,20 +332,7 @@ func find(dir *fid, name string) (*fid, error) {
 			child.typ = ftypeIssue
 			return child, nil
 		}
-		// we may have already loaded the dir entries (comments)
-		// when we loaded the parent (issue).
-		/*
-			for _, d := range dir.children {
-				if d.Name() == name {
-					c, ok := d.(*fid)
-					if !ok {
-						break
-					}
-					return c, nil
-				}
-			}
-		*/
-		ok, err := checkComment(dir.apiRoot, dir.issueKey(), name)
+		ok, err := dir.checkComment(dir.issueKey(), name)
 		if err != nil {
 			return nil, err
 		} else if !ok {
blob - dd6c3711753ffb72d15fa94ccc47c14c11a3d2b1
blob + 6da5f7e1c86a298071c9ab46ff50ed7525f4b0a6
--- Jira/http.go
+++ Jira/http.go
@@ -5,55 +5,26 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"log"
 	"net/http"
 	"net/url"
 	"os"
+	"path"
 )
 
-const debug = false
-
 type Client struct {
 	*http.Client
-	debug   bool
-	apiRoot *url.URL
+	debug              bool
+	username, password string
+	apiRoot            *url.URL
 }
 
 func (c *Client) Projects() ([]Project, error) {
 	u := *c.apiRoot
-	u.Path += "/project"
-
-	resp, err := c.Get(u.String())
+	u.Path = path.Join(u.Path, "project")
+	resp, err := c.get(u.String())
 	if err != nil {
 		return nil, err
 	}
-	defer resp.Body.Close()
-	var p []Project
-
-	var p []Project
-	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
-		return
-	return nil, fmt.Errorf("TODO")
-}
-
-func (c *Client) getDecode(path string, v any) error {
-	u := *c.apiRoot
-	resp, err := c.Get(url)
-	if err != nil {
-		...
-	}
-	return json.NewDecoder(resp.Body).Decode(&v)
-}
-
-func getProjects(apiRoot string) ([]Project, error) {
-	u := fmt.Sprintf("%s/project", apiRoot)
-	if debug {
-		fmt.Fprintln(os.Stderr, "GET", u)
-	}
-	resp, err := http.Get(u)
-	if err != nil {
-		return nil, err
-	}
 	if resp.StatusCode != http.StatusOK {
 		return nil, fmt.Errorf("non-ok status: %s", resp.Status)
 	}
@@ -65,12 +36,9 @@ func getProjects(apiRoot string) ([]Project, error) {
 	return p, nil
 }
 
-func getProject(apiRoot, name string) (*Project, error) {
-	u := fmt.Sprintf("%s/project/%s", apiRoot, name)
-	if debug {
-		fmt.Fprintln(os.Stderr, "GET", u)
-	}
-	resp, err := http.Get(u)
+func (c *Client) Project(name string) (*Project, error) {
+	u := fmt.Sprintf("%s/project/%s", c.apiRoot, name)
+	resp, err := c.get(u)
 	if err != nil {
 		return nil, err
 	}
@@ -85,26 +53,24 @@ func getProject(apiRoot, name string) (*Project, error
 	return &p, nil
 }
 
-func getIssues(apiRoot, project string) ([]Issue, error) {
+func (c *Client) Issues(project string) ([]Issue, error) {
 	q := fmt.Sprintf("project = %q", project)
-	return searchIssues(apiRoot, q)
+	return c.SearchIssues(q)
 }
 
-func searchIssues(apiRoot, query string) ([]Issue, error) {
-	u, err := url.Parse(apiRoot + "/search")
-	if err != nil {
-		return nil, err
-	}
+func (c *Client) SearchIssues(query string) ([]Issue, error) {
+	u := *c.apiRoot
+	u.Path = path.Join(u.Path, "search")
 	q := make(url.Values)
 	q.Add("jql", query)
 	u.RawQuery = q.Encode()
-	if debug {
-		fmt.Fprintln(os.Stderr, "GET", u)
-	}
-	resp, err := http.Get(u.String())
+	resp, err := c.get(u.String())
 	if err != nil {
 		return nil, err
 	}
+	if resp.StatusCode == http.StatusBadRequest {
+		return nil, fmt.Errorf("bad query")
+	}
 	if resp.StatusCode != http.StatusOK {
 		return nil, fmt.Errorf("non-ok status: %s", resp.Status)
 	}
@@ -117,12 +83,14 @@ func searchIssues(apiRoot, query string) ([]Issue, err
 	return t.Issues, nil
 }
 
-func checkIssue(apiRoot, name string) (bool, error) {
-	u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
-	if debug {
-		log.Println("HEAD", u)
+func (c *Client) CheckIssue(name string) (bool, error) {
+	u := *c.apiRoot
+	u.Path = path.Join(u.Path, "issue", name)
+	req, err := http.NewRequest(http.MethodHead, u.String(), nil)
+	if err != nil {
+		return false, err
 	}
-	resp, err := http.Head(u)
+	resp, err := c.do(req)
 	if err != nil {
 		return false, err
 	}
@@ -132,12 +100,10 @@ func checkIssue(apiRoot, name string) (bool, error) {
 	return false, nil
 }
 
-func getIssue(apiRoot, name string) (*Issue, error) {
-	u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
-	if debug {
-		log.Println("GET", u)
-	}
-	resp, err := http.Get(u)
+func (c *Client) Issue(name string) (*Issue, error) {
+	u := *c.apiRoot
+	u.Path = path.Join(u.Path, "issue", name)
+	resp, err := c.get(u.String())
 	if err != nil {
 		return nil, err
 	}
@@ -152,12 +118,14 @@ func getIssue(apiRoot, name string) (*Issue, error) {
 	return &is, nil
 }
 
-func checkComment(apiRoot, ikey, id string) (bool, error) {
-	u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
-	if debug {
-		log.Println("HEAD", u)
+func (c *Client) checkComment(ikey, id string) (bool, error) {
+	u := *c.apiRoot
+	u.Path = path.Join(u.Path, "issue", ikey, "comment", id)
+	req, err := http.NewRequest(http.MethodHead, u.String(), nil)
+	if err != nil {
+		return false, err
 	}
-	resp, err := http.Head(u)
+	resp, err := c.do(req)
 	if err != nil {
 		return false, err
 	}
@@ -167,26 +135,48 @@ func checkComment(apiRoot, ikey, id string) (bool, err
 	return false, nil
 }
 
-func getComment(apiRoot string, ikey, id string) (*Comment, error) {
-	u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
-	if debug {
-		fmt.Fprintln(os.Stderr, "GET", u)
-	}
-	resp, err := http.Get(u)
+func (c *Client) Comment(ikey, id string) (*Comment, error) {
+	u := *c.apiRoot
+	u.Path = path.Join(u.Path, "issue", ikey, "comment", id)
+	resp, err := c.get(u.String())
 	if err != nil {
 		return nil, err
 	}
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("non-ok status: %s", resp.Status)
-	}
 	defer resp.Body.Close()
-	var c Comment
-	if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
+	var com Comment
+	if err := json.NewDecoder(resp.Body).Decode(&com); err != nil {
 		return nil, fmt.Errorf("decode comment: %w", err)
 	}
-	return &c, nil
+	return &com, nil
 }
 
+func (c *Client) PostComment(issueKey string, body io.Reader) error {
+	b, err := io.ReadAll(body)
+	if err != nil {
+		return fmt.Errorf("read body: %w", err)
+	}
+	cm := Comment{Body: string(b)}
+	hbody, err := json.Marshal(&cm)
+	if err != nil {
+		return fmt.Errorf("to json: %w", err)
+	}
+	u := fmt.Sprintf("%s/issue/%s/comment", c.apiRoot, issueKey)
+	req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(hbody))
+	if err != nil {
+		return err
+	}
+	req.Header.Add("Content-Type", "application/json")
+	resp, err := c.do(req)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode >= http.StatusBadRequest {
+		return fmt.Errorf("non-ok status: %s", resp.Status)
+	}
+	return nil
+
+}
+
 func Create(apiRoot string, issue Issue) (*Issue, error) {
 	b, err := json.Marshal(&issue)
 	if err != nil {
@@ -228,3 +218,24 @@ func CreateComment(apiRoot, issueKey string, body io.R
 	}
 	return nil
 }
+
+func (c *Client) get(url string) (*http.Response, error) {
+	req, err := http.NewRequest(http.MethodGet, url, 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
+	}
+	if c.username != "" && c.password != "" {
+		req.SetBasicAuth(c.username, c.password)
+	}
+	if c.debug {
+		fmt.Fprintln(os.Stderr, req.Method, req.URL)
+	}
+	return c.Do(req)
+}
blob - 5862e9bfab6fba3ccd22c069ebbd9a5cdc2ef35b
blob + b26a6877fd34f772e184733902ee038377bab5e7
--- Jira/http_test.go
+++ Jira/http_test.go
@@ -3,6 +3,7 @@ package main
 import (
 	"io"
 	"net/http"
+	"net/url"
 	"path"
 	"testing"
 	"testing/fstest"
@@ -16,20 +17,25 @@ func handleComment(w http.ResponseWriter, req *http.Re
 func TestGet(t *testing.T) {
 	srv := newFakeServer("testdata")
 	defer srv.Close()
+	u, err := url.Parse(srv.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	client := &Client{apiRoot: u}
 
 	project := "TEST"
 	issue := "TEST-1"
 	comment := "69"
-	if _, err := getProject(srv.URL, project); err != nil {
+	if _, err := client.Project(project); err != nil {
 		t.Fatalf("get project %s: %v", project, err)
 	}
-	if _, err := getIssues(srv.URL, project); err != nil {
+	if _, err := client.Issues(project); err != nil {
 		t.Fatalf("get %s issues: %v", project, err)
 	}
-	if _, err := getIssue(srv.URL, issue); err != nil {
+	if _, err := client.Issue(issue); err != nil {
 		t.Fatalf("get issue %s: %v", issue, err)
 	}
-	c, err := getComment(srv.URL, issue, comment)
+	c, err := client.Comment(issue, comment)
 	if err != nil {
 		t.Fatalf("get comment %s from %s: %v", comment, issue, err)
 	}
@@ -37,7 +43,7 @@ func TestGet(t *testing.T) {
 		t.Fatalf("wanted comment id %s, got %s", comment, c.ID)
 	}
 
-	fsys := &FS{apiRoot: srv.URL}
+	fsys := &FS{client: client}
 	f, err := fsys.Open("TEST/1/69")
 	if err != nil {
 		t.Fatal(err)
blob - 247e98f04e533ede40968a90d5982ff4a6bb0c2d
blob + 7d5e3e863c132c13bf3adc9b01b52763e98c655c
--- Jira/jira.go
+++ Jira/jira.go
@@ -13,6 +13,7 @@ type Issue struct {
 	URL      string
 	Key      string
 	Reporter User
+	Assignee User
 	Summary  string
 	Status   struct {
 		Name string `json:"name"`
@@ -22,6 +23,7 @@ type Issue struct {
 	Created     time.Time
 	Updated     time.Time
 	Comments    []Comment
+	Subtasks []Issue
 }
 
 type Project struct {
@@ -67,9 +69,17 @@ func (c *Comment) UnmarshalJSON(b []byte) error {
 
 type User struct {
 	Name        string `json:"name"`
+	Email string `json:"emailAddress"`
 	DisplayName string `json:"displayName"`
 }
 
+func (u User) String() string {
+	if u.DisplayName == "" {
+		return u.Email
+	}
+	return fmt.Sprintf("%s <%s>", u.DisplayName, u.Email)
+}
+
 func (issue *Issue) UnmarshalJSON(b []byte) error {
 	aux := &struct {
 		ID     string
@@ -98,15 +108,18 @@ func (issue *Issue) UnmarshalJSON(b []byte) error {
 	}
 
 	var err error
-	issue.Created, err = time.Parse(timestamp, iaux.Created)
-	if err != nil {
-		return fmt.Errorf("created time: %w", err)
+	if iaux.Created != "" {
+		issue.Created, err = time.Parse(timestamp, iaux.Created)
+		if err != nil {
+			return fmt.Errorf("created time: %w", err)
+		}
 	}
-	issue.Updated, err = time.Parse(timestamp, iaux.Updated)
-	if err != nil {
-		return fmt.Errorf("updated time: %w", err)
+	if iaux.Updated != "" {
+		issue.Updated, err = time.Parse(timestamp, iaux.Updated)
+		if err != nil {
+			return fmt.Errorf("updated time: %w", err)
+		}
 	}
-
 	if bb, ok := iaux.Comment["comments"]; ok {
 		if err := json.Unmarshal(bb, &issue.Comments); err != nil {
 			return fmt.Errorf("unmarshal comments: %w", err)
blob - 170637a16a565d81a410b6fe2dedcf1f2e298c52
blob + ca18c7050d18fc42f8b01338c381089a8dccba07
--- Jira/jira_test.go
+++ Jira/jira_test.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"encoding/json"
+	"fmt"
 	"os"
 	"testing"
 )
@@ -12,7 +13,7 @@ func TestDecode(t *testing.T) {
 		t.Fatal(err)
 	}
 	for _, d := range dents {
-		f, err := os.Open("testdata/issue/"+d.Name())
+		f, err := os.Open("testdata/issue/" + d.Name())
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -23,3 +24,16 @@ func TestDecode(t *testing.T) {
 		f.Close()
 	}
 }
+
+func TestSubtasks(t *testing.T) {
+	f, err := os.Open("testdata/subtasks")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	var is Issue
+	if err := json.NewDecoder(f).Decode(&is); err != nil {
+		t.Fatal(err)
+	}
+	fmt.Println(is.Subtasks)
+}
blob - 506f626996dafb604cf86e6b9b81afec6c7bacac
blob + 516fb01ba5686ddaf74cba85cb90712b7da5aba6
--- Jira/print.go
+++ Jira/print.go
@@ -2,6 +2,8 @@ package main
 
 import (
 	"fmt"
+	"net/url"
+	"path"
 	"strings"
 	"time"
 )
@@ -10,21 +12,40 @@ func printIssues(issues []Issue) string {
 	buf := &strings.Builder{}
 	for _, ii := range issues {
 		name := strings.Replace(ii.Key, "-", "/", 1)
-		fmt.Fprintf(buf, "%s/\t%s\n", name, ii.Summary)
+		fmt.Fprintf(buf, "%s/issue\t%s\n", name, ii.Summary)
 	}
 	return buf.String()
 }
 
 func printIssue(i *Issue) string {
 	buf := &strings.Builder{}
-	fmt.Fprintln(buf, "From:", i.Reporter.Name)
-	fmt.Fprintln(buf, "URL:", i.URL)
+	fmt.Fprintln(buf, "From:", i.Reporter)
+	if i.Assignee.String() != "" {
+		fmt.Fprintln(buf, "Assignee:", i.Assignee)
+	}
+	if u, err := url.Parse(i.URL); err == nil {
+		u.Path = path.Join("browse", i.Key)
+		fmt.Fprintf(buf, "Archived-At: <%s>\n", u)
+	}
+	fmt.Fprintf(buf, "Archived-At: <%s>\n", i.URL)
 	fmt.Fprintln(buf, "Date:", i.Updated.Format(time.RFC1123Z))
 	fmt.Fprintln(buf, "Status:", i.Status.Name)
+	if len(i.Subtasks) > 0 {
+		s := make([]string, len(i.Subtasks))
+		for j := range i.Subtasks {
+			s[j] = i.Subtasks[j].Key
+		}
+		fmt.Fprintln(buf, "Subtasks:", strings.Join(s, ", "))
+	}
 	fmt.Fprintln(buf, "Subject:", i.Summary)
 	fmt.Fprintln(buf)
 
-	fmt.Fprintln(buf, i.Description)
+	if i.Description != "" {
+		fmt.Fprintln(buf, strings.ReplaceAll(i.Description, "\r", ""))
+	}
+	if len(i.Comments) == 0 {
+		return buf.String()
+	}
 	fmt.Fprintln(buf)
 	for _, c := range i.Comments {
 		date := c.Created
@@ -42,10 +63,10 @@ func printComment(c *Comment) string {
 	if !c.Updated.IsZero() {
 		date = c.Updated
 	}
-	fmt.Fprintln(buf, "From:", c.Author.Name)
+	fmt.Fprintln(buf, "From:", c.Author)
 	fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z))
 	fmt.Fprintln(buf)
-	fmt.Fprintln(buf, c.Body)
+	fmt.Fprintln(buf, strings.TrimSpace(c.Body))
 	return buf.String()
 }