commit 438e3045216521b085564bac4c1a287a9079c26f
parent 45f610f681a1e110dc2a2a27ffea9c721ec62968
Author: Oliver Lowe <o@olowe.co>
Date: Mon, 30 Dec 2024 19:26:21 +1100
Add commands to get jira from the command line
Diffstat:
29 files changed, 1685 insertions(+), 1412 deletions(-)
diff --git a/Jira/acme.go b/Jira/acme.go
@@ -1,288 +0,0 @@
-package main
-
-import (
- "bytes"
- "errors"
- "flag"
- "fmt"
- "io"
- "io/fs"
- "log"
- "net/url"
- "os"
- "path"
- "regexp"
- "strings"
-
- "9fans.net/go/acme"
-)
-
-func init() {
- log.SetFlags(0)
- log.SetPrefix("Jira: ")
-}
-
-type awin struct {
- *acme.Win
- fsys fs.FS
-}
-
-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], "/jira/")
-}
-
-var issueKeyExp = regexp.MustCompile("[A-Z]+-[0-9]+")
-
-func (w *awin) Look(text string) bool {
- text = strings.TrimSpace(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(pathname)
- if errors.Is(err, fs.ErrNotExist) {
- return false
- } else if err != nil {
- w.Err(err.Error())
- return false
- }
-
- win, err := acme.New()
- if err != nil {
- w.Err(err.Error())
- return true
- }
- 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)
- if path.Base(pathname) == "issue" {
- win.Fprintf("tag", "Comment ")
- }
- ww := &awin{win, w.fsys}
- go ww.EventLoop(ww)
- go func() {
- if err := ww.Get(f); err != nil {
- w.Err(err.Error())
- }
- ww.Addr("#0")
- ww.Ctl("dot=addr")
- ww.Ctl("show")
- }()
- return true
-}
-
-func (w *awin) Execute(cmd string) bool {
- fields := strings.Fields(strings.TrimSpace(cmd))
- switch fields[0] {
- case "Get":
- if err := w.Get(nil); err != nil {
- w.Err(err.Error())
- }
- return true
- case "Search":
- if len(fields) == 1 {
- return false
- }
- query := strings.Join(fields[1:], " ")
- 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(f fs.File) error {
- defer w.Ctl("clean")
- 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()
- 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()+"/")
- continue
- }
- fmt.Fprintln(buf, d.Name())
- }
- 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", b); err != nil {
- return fmt.Errorf("write body: %w", err)
- }
- return nil
-}
-
-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")
- 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
- }
- win.PrintTabbed(printIssues(issues))
- w := &awin{win, fsys}
- go w.EventLoop(w)
-}
-
-const usage string = "usage: Jira [keyfile]"
-
-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
-}
-
-var hostFlag = flag.String("h", "jira.atlassian.com", "")
-
-func main() {
- flag.Parse()
- home, err := os.UserHomeDir()
- if err != nil {
- log.Fatalf("find user config dir: %v", err)
- }
- credPath := path.Join(home, ".config/atlassian/jira")
- if len(flag.Args()) == 1 {
- credPath = flag.Args()[0]
- } else if len(flag.Args()) > 2 {
- fmt.Fprintln(os.Stderr, usage)
- os.Exit(2)
- }
- user, pass, err := readCreds(credPath)
- if err != nil {
- log.Fatalf("read credentials: %v", err)
- }
-
- // srv := newFakeServer("testdata")
- // defer srv.Close()
-
- u, err := url.Parse("https://" + *hostFlag + "/rest/api/2")
- if err != nil {
- log.Fatalf("parse api root url: %v", err)
- }
- fsys := &FS{
- client: &Client{
- debug: false,
- apiRoot: u,
- username: user,
- password: pass,
- },
- }
-
- acme.AutoExit(true)
- win, err := acme.New()
- if err != nil {
- log.Fatal(err)
- }
- 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)
-}
diff --git a/Jira/doc.go b/Jira/doc.go
@@ -1,26 +0,0 @@
-/*
-Jira is a program to interact with Jira issues from the Acme editor.
-Projects, issues and comments are presented as a virtual read-only filesystem
-(using package [io/fs])
-which can be browsed in the usual way Acme handles filesystems served
-by the host system.
-
-The filesystem root holds project directories.
-Within each project are the project's issues, one directory entry per issue.
-The filepaths for issues TEST-1, TEST-2, and WEB-27 would be:
-
- TEST/1
- TEST/2
- WEB/27
-
-Each issue directory has a file named "issue"
-holding a textual representation of the issue and a listing of comments.
-For example, TEST/1/issue.
-
-Comments are available as numbered files alongside the issue file.
-Comment 69 of issue TEST-420 can be accessed at TEST/420/69.
-
-https:developer.atlassian.com/cloud/jira/platform/rest/v2/
-https:jira.atlassian.com/rest/api/2/issue/JRA-9
-*/
-package main
diff --git a/Jira/fake.go b/Jira/fake.go
@@ -1,77 +0,0 @@
-package main
-
-import (
- "errors"
- "fmt"
- "io"
- "io/fs"
- "log"
- "net/http"
- "net/http/httptest"
- "os"
- "path"
-)
-
-// newFakeServer returns a fake JIRA server which serves projects,
-// issues, and comments from the filesystem tree rooted at root.
-// For an example tree, see the testdata directory.
-//
-// The server provides a limited read-only subset of the JIRA HTTP API
-// intended for testing API clients.
-// All search requests return a list of every issue, even if the JQL query is invalid.
-// Paginated responses are not supported.
-func newFakeServer(root string) *httptest.Server {
- mux := http.NewServeMux()
- mux.HandleFunc("/project", serveJSONList(path.Join(root, "project")))
- mux.HandleFunc("/search", serveJSONList(path.Join(root, "issue")))
- mux.HandleFunc("/issue", serveJSONList(path.Join(root, "issue")))
- mux.HandleFunc("/issue/", handleIssues(root))
- mux.Handle("/", http.FileServer(http.Dir(root)))
- return httptest.NewServer(mux)
-}
-
-func serveJSONList(dir string) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- prefix := "["
- if path.Base(dir) == "issue" {
- prefix = `{"issues": [`
- }
- dirs, err := os.ReadDir(dir)
- if errors.Is(err, fs.ErrNotExist) {
- http.NotFound(w, req)
- return
- } else if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- fmt.Fprintln(w, prefix)
- for i, d := range dirs {
- f, err := os.Open(path.Join(dir, d.Name()))
- if err != nil {
- log.Println(err)
- return
- }
- if _, err := io.Copy(w, f); err != nil {
- log.Printf("copy %s: %v", f.Name(), err)
- }
- f.Close()
- if i == len(dirs)-1 {
- break
- }
- fmt.Fprintln(w, ",")
- }
- fmt.Fprintln(w, "]}")
- }
-}
-
-func handleIssues(dir string) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- if match, _ := path.Match("/issue/*/comment/*", req.URL.Path); match {
- // ignore error; we know pattern is ok.
- file := path.Base(req.URL.Path)
- http.ServeFile(w, req, path.Join(dir, "comment", file))
- return
- }
- http.FileServerFS(os.DirFS(dir)).ServeHTTP(w, req)
- }
-}
diff --git a/Jira/fileinfo.go b/Jira/fileinfo.go
@@ -1,49 +0,0 @@
-package main
-
-import (
- "io/fs"
- "strings"
- "time"
-)
-
-func (is *Issue) Name() string {
- _, number, found := strings.Cut(is.Key, "-")
- if !found {
- return is.Key
- }
- return number
-}
-
-func (is *Issue) Size() int64 { return int64(len(printIssue(is))) }
-func (is *Issue) Mode() fs.FileMode { return 0o444 | fs.ModeDir }
-func (is *Issue) ModTime() time.Time { return is.Updated }
-func (is *Issue) IsDir() bool { return is.Mode().IsDir() }
-func (is *Issue) Sys() any { return nil }
-
-func (c *Comment) Name() string { return c.ID }
-func (c *Comment) Size() int64 { return int64(len(printComment(c))) }
-func (c *Comment) Mode() fs.FileMode { return 0o444 }
-func (c *Comment) ModTime() time.Time { return c.Updated }
-func (c *Comment) IsDir() bool { return c.Mode().IsDir() }
-func (c *Comment) Sys() any { return nil }
-
-func (p *Project) Name() string { return p.Key }
-func (p *Project) Size() int64 { return -1 }
-func (p *Project) Mode() fs.FileMode { return 0o444 | fs.ModeDir }
-func (p *Project) ModTime() time.Time { return time.Time{} }
-func (p *Project) IsDir() bool { return p.Mode().IsDir() }
-func (p *Project) Sys() any { return nil }
-
-type stat struct {
- name string
- size int64
- mode fs.FileMode
- mtime time.Time
-}
-
-func (s stat) Name() string { return s.name }
-func (s stat) Size() int64 { return s.size }
-func (s stat) Mode() fs.FileMode { return s.mode }
-func (s stat) ModTime() time.Time { return s.mtime }
-func (s stat) IsDir() bool { return s.Mode().IsDir() }
-func (s stat) Sys() any { return nil }
diff --git a/Jira/fs.go b/Jira/fs.go
@@ -1,346 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "io/fs"
- "os"
- "path"
- "strings"
- "time"
-)
-
-type FS struct {
- client *Client
- root *fid
-}
-
-const (
- ftypeRoot int = iota
- ftypeProject
- ftypeIssue
- ftypeIssueDir
- ftypeComment
-)
-
-type fid struct {
- *Client
- name string
- typ int
- rd io.Reader
- parent *fid
-
- // May be set but only as an optimisation to skip a Stat().
- stat fs.FileInfo
-
- // directories only
- children []fs.DirEntry
- dirp int
-}
-
-func (f *fid) Name() string { return f.name }
-func (f *fid) IsDir() bool { return f.Type().IsDir() }
-
-func (f *fid) Type() fs.FileMode {
- switch f.typ {
- case ftypeRoot, ftypeProject, ftypeIssueDir:
- return fs.ModeDir
- }
- return 0
-}
-
-func (f *fid) Info() (fs.FileInfo, error) { return f.Stat() }
-
-func (f *fid) Stat() (fs.FileInfo, error) {
- if f.Client.debug {
- fmt.Fprintln(os.Stderr, "stat", f.Name())
- }
- if f.stat != nil {
- return f.stat, nil
- }
-
- switch f.typ {
- case ftypeRoot:
- return &stat{".", int64(len(f.children)), 0o444 | fs.ModeDir, time.Time{}}, nil
- case ftypeProject:
- 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 := 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 := 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)
- return nil, &fs.PathError{"stat", f.name, err}
-}
-
-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 := 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 := 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}
- }
- f.rd = strings.NewReader(printIssue(is))
- default:
- var err error
- if f.children == nil {
- f.children, err = f.ReadDir(-1)
- if err != nil {
- return 0, &fs.PathError{"read", f.name, err}
- }
- }
- buf := &strings.Builder{}
- for _, d := range f.children {
- fmt.Fprintln(buf, fs.FormatDirEntry(d))
- }
- f.rd = strings.NewReader(buf.String())
- }
- }
- return f.rd.Read(p)
-}
-
-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 f.children == nil {
- switch f.typ {
- case ftypeRoot:
- return nil, fmt.Errorf("root initialised incorrectly: no dir entries")
- case ftypeProject:
- 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{
- Client: f.Client,
- name: issue.Name(),
- typ: ftypeIssueDir,
- parent: f,
- }
- }
- case ftypeIssueDir:
- issue, err := f.Issue(f.issueKey())
- if err != nil {
- return nil, fmt.Errorf("get issue %s: %w", f.name, err)
- }
- f.children = issueChildren(f, issue)
- }
- }
-
- if f.dirp >= len(f.children) {
- if n <= 0 {
- return nil, nil
- }
- return nil, io.EOF
- }
- if n <= 0 {
- f.dirp = len(f.children)
- return f.children, nil
- }
-
- var err error
- d := f.children[f.dirp:]
- if len(d) >= n {
- d = d[:n]
- } else if len(d) <= n {
- err = io.EOF
- }
- f.dirp += n
- return d, err
-}
-
-func 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")
- // and the name of the project (the issue's parent's name, "EXAMPLE")
- var project, issueNumber string
- switch f.typ {
- default:
- return ""
- case ftypeComment, ftypeIssue:
- project = f.parent.parent.name
- issueNumber = f.parent.name
- case ftypeIssueDir:
- project = f.parent.name
- issueNumber = f.name
- }
- return project + "-" + issueNumber
-}
-
-func (fsys *FS) Open(name string) (fs.File, error) {
- if !fs.ValidPath(name) {
- return nil, &fs.PathError{"open", name, fs.ErrInvalid}
- }
- name = path.Clean(name)
- if strings.Contains(name, "\\") {
- return nil, fs.ErrNotExist
- }
-
- if fsys.root == nil {
- var err error
- fsys.root, err = makeRoot(fsys.client)
- if err != nil {
- return nil, fmt.Errorf("make root file: %w", err)
- }
- }
- if fsys.client.debug {
- fmt.Fprintln(os.Stderr, "open", name)
- }
-
- if name == "." {
- f := *fsys.root
- return &f, nil
- }
-
- elems := strings.Split(name, "/")
- if elems[0] == "." && len(elems) > 1 {
- elems = elems[1:]
- }
-
- f := fsys.root
- for _, elem := range elems {
- dir, err := find(f, elem)
- if err != nil {
- return nil, &fs.PathError{"open", name, err}
- }
- f = dir
- }
- g := *f
- return &g, nil
-}
-
-func makeRoot(client *Client) (*fid, error) {
- projects, err := client.Projects()
- if err != nil {
- return nil, err
- }
- root := &fid{
- Client: client,
- name: ".",
- typ: ftypeRoot,
- children: make([]fs.DirEntry, len(projects)),
- }
- for i, p := range projects {
- root.children[i] = &fid{
- Client: client,
- name: p.Key,
- typ: ftypeProject,
- }
- }
- return root, nil
-}
-
-func find(dir *fid, name string) (*fid, error) {
- if !dir.IsDir() {
- return nil, fs.ErrNotExist
- }
- child := &fid{Client: dir.Client, parent: dir}
- switch dir.typ {
- case ftypeRoot:
- for _, d := range dir.children {
- if d.Name() == name {
- child, ok := d.(*fid)
- if !ok {
- return nil, fmt.Errorf("unexpected dir entry type %T", d)
- }
- return child, nil
- }
- }
- return nil, fs.ErrNotExist
- case ftypeProject:
- key := fmt.Sprintf("%s-%s", dir.name, name)
- ok, err := dir.CheckIssue(key)
- if err != nil {
- return nil, err
- }
- if !ok {
- return nil, fs.ErrNotExist
- }
- child.name = name
- child.typ = ftypeIssueDir
- return child, nil
- case ftypeIssueDir:
- if name == "issue" {
- child.name = name
- child.typ = ftypeIssue
- return child, nil
- }
- ok, err := dir.checkComment(dir.issueKey(), name)
- if err != nil {
- return nil, err
- } else if !ok {
- return nil, fs.ErrNotExist
- }
- child.name = name
- child.typ = ftypeComment
- return child, nil
- }
- return nil, fs.ErrNotExist
-}
diff --git a/Jira/fs_test.go b/Jira/fs_test.go
@@ -1,42 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "io/fs"
- "os"
- "testing"
-)
-
-func TestIssueName(t *testing.T) {
- f, err := os.Open("testdata/issue/TEST-1")
- if err != nil {
- t.Fatal(err)
- }
- defer f.Close()
- var issue Issue
- if err := json.NewDecoder(f).Decode(&issue); err != nil {
- t.Fatal(err)
- }
- want := "1"
- if issue.Name() != want {
- t.Errorf("issue.Name() = %q, want %q", issue.Name(), want)
- }
-}
-
-func TestIssueKey(t *testing.T) {
- comment := &fid{name: "69", typ: ftypeComment}
- issue := &fid{name: "issue", typ: ftypeIssue}
- issueDir := &fid{name: "1", typ: ftypeIssueDir, children: []fs.DirEntry{issue, comment}}
- project := &fid{name: "TEST", typ: ftypeProject, children: []fs.DirEntry{issueDir}}
-
- comment.parent = issueDir
- issue.parent = issueDir
- issueDir.parent = project
-
- want := "TEST-1"
- for _, f := range []*fid{comment, issue, issueDir} {
- if f.issueKey() != want {
- t.Errorf("fid %s issueKey = %q, want %q", f.name, f.issueKey(), want)
- }
- }
-}
diff --git a/Jira/http.go b/Jira/http.go
@@ -1,241 +0,0 @@
-package main
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "os"
- "path"
-)
-
-type Client struct {
- *http.Client
- debug bool
- username, password string
- apiRoot *url.URL
-}
-
-func (c *Client) Projects() ([]Project, error) {
- u := *c.apiRoot
- u.Path = path.Join(u.Path, "project")
- 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 p []Project
- if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
- return nil, fmt.Errorf("decode project: %w", err)
- }
- return p, nil
-}
-
-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
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("non-ok status: %s", resp.Status)
- }
- defer resp.Body.Close()
- var p Project
- if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
- return nil, fmt.Errorf("decode project: %w", err)
- }
- return &p, nil
-}
-
-func (c *Client) Issues(project string) ([]Issue, error) {
- q := fmt.Sprintf("project = %q", project)
- return c.SearchIssues(q)
-}
-
-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()
- 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)
- }
- t := struct {
- Issues []Issue
- }{}
- if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
- return nil, fmt.Errorf("decode issues: %w", err)
- }
- return t.Issues, nil
-}
-
-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 := c.do(req)
- if err != nil {
- return false, err
- }
- if resp.StatusCode == http.StatusOK {
- return true, nil
- }
- return false, nil
-}
-
-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
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("non-ok status: %s", resp.Status)
- }
- defer resp.Body.Close()
- var is Issue
- if err := json.NewDecoder(resp.Body).Decode(&is); err != nil {
- return nil, fmt.Errorf("decode issue: %w", err)
- }
- return &is, nil
-}
-
-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 := c.do(req)
- if err != nil {
- return false, err
- }
- if resp.StatusCode == http.StatusOK {
- return true, nil
- }
- return false, nil
-}
-
-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
- }
- defer resp.Body.Close()
- var com Comment
- if err := json.NewDecoder(resp.Body).Decode(&com); err != nil {
- return nil, fmt.Errorf("decode comment: %w", err)
- }
- 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 {
- return nil, fmt.Errorf("to json: %w", err)
- }
- u := apiRoot + "/issue"
- resp, err := http.Post(u, "application/json", bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("non-ok status %s", resp.Status)
- }
- var i Issue
- if err := json.NewDecoder(resp.Body).Decode(&i); err != nil {
- return nil, fmt.Errorf("decode created issue: %w", err)
- }
- return &i, nil
-}
-
-func CreateComment(apiRoot, issueKey string, body io.Reader) error {
- b, err := io.ReadAll(body)
- if err != nil {
- return fmt.Errorf("read body: %w", err)
- }
- c := Comment{Body: string(b)}
- hbody, err := json.Marshal(&c)
- if err != nil {
- return fmt.Errorf("to json: %w", err)
- }
- u := fmt.Sprintf("%s/issue/%s/comment", apiRoot, issueKey)
- resp, err := http.Post(u, "application/json", bytes.NewReader(hbody))
- if err != nil {
- return err
- }
- if resp.StatusCode >= http.StatusBadRequest {
- return fmt.Errorf("non-ok status: %s", resp.Status)
- }
- 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)
-}
diff --git a/Jira/http_test.go b/Jira/http_test.go
@@ -1,68 +0,0 @@
-package main
-
-import (
- "io"
- "net/http"
- "net/url"
- "path"
- "testing"
- "testing/fstest"
-)
-
-func handleComment(w http.ResponseWriter, req *http.Request) {
- id := path.Base(req.URL.Path)
- http.ServeFile(w, req, "testdata/comment/"+id)
-}
-
-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 := client.Project(project); err != nil {
- t.Fatalf("get project %s: %v", project, err)
- }
- if _, err := client.Issues(project); err != nil {
- t.Fatalf("get %s issues: %v", project, err)
- }
- if _, err := client.Issue(issue); err != nil {
- t.Fatalf("get issue %s: %v", issue, err)
- }
- c, err := client.Comment(issue, comment)
- if err != nil {
- t.Fatalf("get comment %s from %s: %v", comment, issue, err)
- }
- if c.ID != comment {
- t.Fatalf("wanted comment id %s, got %s", comment, c.ID)
- }
-
- fsys := &FS{client: client}
- f, err := fsys.Open("TEST/1/69")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := f.Stat(); err != nil {
- t.Fatal(err)
- }
- if _, err := io.Copy(io.Discard, f); err != nil {
- t.Fatal(err)
- }
- f.Close()
-
- expected := []string{
- "TEST",
- "TEST/1",
- "TEST/1/issue",
- "TEST/1/69",
- }
- if err := fstest.TestFS(fsys, expected...); err != nil {
- t.Error(err)
- }
-}
diff --git a/Jira/jira.go b/Jira/jira.go
@@ -1,142 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "time"
-)
-
-const timestamp = "2006-01-02T15:04:05.999-0700"
-
-type Issue struct {
- ID string // TODO(otl): int?
- URL string
- Key string
- Reporter User
- Assignee User
- Summary string
- Status struct {
- Name string `json:"name"`
- } `json:"status"`
- Description string
- Project Project
- Created time.Time
- Updated time.Time
- Comments []Comment
- Links []Issue
- Subtasks []Issue
-}
-
-type Project struct {
- ID string `json:"id"` // TODO(otl): int?
- // Name string `json:"name"`
- Key string `json:"key"`
- URL string `json:"self"`
-}
-
-type Comment struct {
- ID string `json:"id"` // TODO(otl): int?
- URL string `json:"self"`
- Body string `json:"body"`
- Created time.Time `json:"created"`
- Updated time.Time `json:"updated"`
- Author User `json:"author"`
- UpdateAuthor User `json:"updateAuthor"`
-}
-
-func (c *Comment) UnmarshalJSON(b []byte) error {
- type alias Comment
- aux := &struct {
- Created string `json:"created"`
- Updated string `json:"updated"`
- *alias
- }{
- alias: (*alias)(c),
- }
- if err := json.Unmarshal(b, aux); err != nil {
- return err
- }
- var err error
- c.Created, err = time.Parse(timestamp, aux.Created)
- if err != nil {
- return fmt.Errorf("parse created time: %w", err)
- }
- c.Updated, err = time.Parse(timestamp, aux.Updated)
- if err != nil {
- return fmt.Errorf("parse updated time: %w", err)
- }
- return nil
-}
-
-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
- Self string
- Key string
- Fields json.RawMessage
- }{}
- if err := json.Unmarshal(b, aux); err != nil {
- return err
- }
- issue.ID = aux.ID
- issue.URL = aux.Self
- issue.Key = aux.Key
-
- type alias Issue
- iaux := &struct {
- Created string
- Updated string
- Comment map[string]json.RawMessage
- IssueLinks []struct {
- InwardIssue *Issue
- OutwardIssue *Issue
- }
- *alias
- }{
- alias: (*alias)(issue),
- }
- if err := json.Unmarshal(aux.Fields, iaux); err != nil {
- return err
- }
-
- var err error
- if iaux.Created != "" {
- issue.Created, err = time.Parse(timestamp, iaux.Created)
- if err != nil {
- return fmt.Errorf("created 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)
- }
- }
- for _, l := range iaux.IssueLinks {
- if l.InwardIssue != nil {
- issue.Links = append(issue.Links, *l.InwardIssue)
- }
- if l.OutwardIssue != nil {
- issue.Links = append(issue.Links, *l.OutwardIssue)
- }
- }
- return nil
-}
diff --git a/Jira/jira_test.go b/Jira/jira_test.go
@@ -1,40 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "os"
- "testing"
-)
-
-func TestDecode(t *testing.T) {
- dents, err := os.ReadDir("testdata/issue")
- if err != nil {
- t.Fatal(err)
- }
- for _, d := range dents {
- f, err := os.Open("testdata/issue/" + d.Name())
- if err != nil {
- t.Fatal(err)
- }
- var i Issue
- if err := json.NewDecoder(f).Decode(&i); err != nil {
- t.Errorf("decode %s: %v", f.Name(), err)
- }
- 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)
-}
-*/
diff --git a/Jira/print.go b/Jira/print.go
@@ -1,91 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/url"
- "path"
- "strings"
- "time"
-)
-
-func printIssues(issues []Issue) string {
- buf := &strings.Builder{}
- for _, ii := range issues {
- name := strings.Replace(ii.Key, "-", "/", 1)
- 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)
- fmt.Fprintln(buf, "Date:", i.Created.Format(time.RFC1123Z))
- 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, "Status:", i.Status.Name)
- if len(i.Links) > 0 {
- s := make([]string, len(i.Links))
- for j := range i.Links {
- s[j] = i.Links[j].Key
- }
- fmt.Fprintln(buf, "References:", strings.Join(s, ", "))
- }
- 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)
-
- 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
- if !c.Updated.IsZero() {
- date = c.Updated
- }
- fmt.Fprintf(buf, "%s\t%s\t%s (%s)\n", c.ID, summarise(c.Body, 36), c.Author.Name, date.Format(time.DateTime))
- }
- return buf.String()
-}
-
-func printComment(c *Comment) string {
- buf := &strings.Builder{}
- date := c.Created
- if !c.Updated.IsZero() {
- date = c.Updated
- }
- fmt.Fprintln(buf, "From:", c.Author)
- fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z))
- fmt.Fprintln(buf)
- fmt.Fprintln(buf, strings.TrimSpace(c.Body))
- return buf.String()
-}
-
-func summarise(body string, length int) string {
- if len(body) < length {
- body = strings.ReplaceAll(body, "\n", " ")
- return strings.TrimSpace(body)
- }
- body = body[:length]
- body = strings.ReplaceAll(body, "\r", "")
- body = strings.ReplaceAll(body, "\n", " ")
- body = strings.TrimSpace(body)
- body = strings.ReplaceAll(body, " ", " ")
- return body + "..."
-}
diff --git a/README.md b/README.md
@@ -1,11 +1,16 @@
-This repo holds programs for interacting with issue trackers from the [Acme] editor.
+This repo holds programs for interacting with issue trackers from the [Acme] editor
+and via a command line.
See the documentation for the commands:
- [issue]
- [Gitlab]
- [Jira]
+- [jiraexport]
+- [jiraq]
[Acme]: https://p9f.org/sys/doc/acme/acme.html
[issue]: https://pkg.go.dev/olowe.co/issues/issue
[Gitlab]: https://pkg.go.dev/olowe.co/issues/Gitlab
-[Jira]: https://pkg.go.dev/olowe.co/issues/Jira
+[Jira]: https://pkg.go.dev/olowe.co/issues/cmd/Jira
+[jiraexport]: https://pkg.go.dev/olowe.co/issues/cmd/jiraexport
+[jiraq]: https://pkg.go.dev/olowe.co/issues/cmd/jiraq
diff --git a/cmd/Jira/Jira.go b/cmd/Jira/Jira.go
@@ -0,0 +1,298 @@
+package jira
+
+import (
+ "bytes"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/url"
+ "os"
+ "path"
+ "regexp"
+ "strings"
+
+ "9fans.net/go/acme"
+ "olowe.co/issues/jira"
+)
+
+func init() {
+ log.SetFlags(0)
+ log.SetPrefix("Jira: ")
+}
+
+type awin struct {
+ *acme.Win
+ fsys fs.FS
+}
+
+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], "/jira/")
+}
+
+var issueKeyExp = regexp.MustCompile("[A-Z]+-[0-9]+")
+
+func (w *awin) Look(text string) bool {
+ text = strings.TrimSpace(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(pathname)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false
+ } else if err != nil {
+ w.Err(err.Error())
+ return false
+ }
+
+ win, err := acme.New()
+ if err != nil {
+ w.Err(err.Error())
+ return true
+ }
+ 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)
+ if path.Base(pathname) == "issue" {
+ win.Fprintf("tag", "Comment ")
+ }
+ ww := &awin{win, w.fsys}
+ go ww.EventLoop(ww)
+ go func() {
+ if err := ww.Get(f); err != nil {
+ w.Err(err.Error())
+ }
+ ww.Addr("#0")
+ ww.Ctl("dot=addr")
+ ww.Ctl("show")
+ }()
+ return true
+}
+
+func (w *awin) Execute(cmd string) bool {
+ fields := strings.Fields(strings.TrimSpace(cmd))
+ switch fields[0] {
+ case "Get":
+ if err := w.Get(nil); err != nil {
+ w.Err(err.Error())
+ }
+ return true
+ case "Search":
+ if len(fields) == 1 {
+ return false
+ }
+ query := strings.Join(fields[1:], " ")
+ 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(f fs.File) error {
+ defer w.Ctl("clean")
+ 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()
+ 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()+"/")
+ continue
+ }
+ fmt.Fprintln(buf, d.Name())
+ }
+ 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", b); err != nil {
+ return fmt.Errorf("write body: %w", err)
+ }
+ return nil
+}
+
+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.(*jira.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")
+ f, ok := fsys.(*jira.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
+ }
+ win.PrintTabbed(printIssues(issues))
+ w := &awin{win, fsys}
+ go w.EventLoop(w)
+}
+
+func printIssues(issues []jira.Issue) string {
+ buf := &strings.Builder{}
+ for _, ii := range issues {
+ name := strings.Replace(ii.Key, "-", "/", 1)
+ fmt.Fprintf(buf, "%s/issue\t%s\n", name, ii.Summary)
+ }
+ return buf.String()
+}
+
+const usage string = "usage: Jira [keyfile]"
+
+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
+}
+
+var hostFlag = flag.String("h", "jira.atlassian.com", "")
+
+func main() {
+ flag.Parse()
+ home, err := os.UserHomeDir()
+ if err != nil {
+ log.Fatalf("find user config dir: %v", err)
+ }
+ credPath := path.Join(home, ".config/atlassian/jira")
+ if len(flag.Args()) == 1 {
+ credPath = flag.Args()[0]
+ } else if len(flag.Args()) > 2 {
+ fmt.Fprintln(os.Stderr, usage)
+ os.Exit(2)
+ }
+ user, pass, err := readCreds(credPath)
+ if err != nil {
+ log.Fatalf("read credentials: %v", err)
+ }
+
+ // srv := newFakeServer("testdata")
+ // defer srv.Close()
+
+ u, err := url.Parse("https://" + *hostFlag + "/rest/api/3")
+ if err != nil {
+ log.Fatalf("parse api root url: %v", err)
+ }
+ fsys := &jira.FS{
+ Client: &jira.Client{
+ Debug: false,
+ APIRoot: u,
+ Username: user,
+ Password: pass,
+ },
+ }
+
+ acme.AutoExit(true)
+ win, err := acme.New()
+ if err != nil {
+ log.Fatal(err)
+ }
+ 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)
+}
diff --git a/cmd/jiraexport/jiraexport.go b/cmd/jiraexport/jiraexport.go
@@ -0,0 +1,159 @@
+// Command jiraexport prints the named Jira issues, and their comments,
+// RFC 5322 mail message format (email).
+//
+// Usage:
+// jiraexport [ -d duration ] issue [ ... ]
+//
+// The options are:
+//
+// -d duration
+// Exclude any issues and comments unmodified since duration.
+// Duration may be given in the format accepted by time.ParseDuration.
+// For example, 24h (24 hours). The default is 7 days.
+//
+// # Example
+//
+// Print the last day's updates to tickets SRE-1234 and SRE-5678:
+//
+// jiraexport -d 24h SRE-1234 SRE-5678
+//
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/mail"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "olowe.co/issues/jira"
+)
+
+func readJiraAuth() (user, pass string, err error) {
+ confDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", "", err
+ }
+ b, err := os.ReadFile(path.Join(confDir, "atlassian/jira"))
+ if err != nil {
+ return "", "", err
+ }
+ b = bytes.TrimSpace(b)
+ u, p, ok := strings.Cut(string(b), ":")
+ if !ok {
+ return "", "", fmt.Errorf(`missing ":" between username and password`)
+ }
+ return u, p, nil
+}
+
+const usage string = "jiraexport [-d duration] [-u url] issue [...]"
+
+var since = flag.Duration("d", 7*24*time.Hour, "exclude activity older than this duration")
+var apiRoot = flag.String("u", "http://[::1]:8080", "base URL for the JIRA API")
+
+func init() {
+ log.SetFlags(0)
+ log.SetPrefix("")
+ flag.Parse()
+}
+
+func main() {
+ if len(flag.Args()) == 0 {
+ log.Fatal(usage)
+ }
+
+ user, pass, err := readJiraAuth()
+ if err != nil {
+ log.Fatalf("read jira auth credentials: %v", err)
+ }
+ u, err := url.Parse(*apiRoot)
+ if err != nil {
+ log.Fatalln("parse api url:", err)
+ }
+ u.Path = path.Join(u.Path, "rest/api/2")
+ jclient := &jira.Client{
+ APIRoot: u,
+ Username: user,
+ Password: pass,
+ Debug: false,
+ }
+ fsys := &jira.FS{Client: jclient}
+
+ for _, arg := range flag.Args() {
+ proj, num, ok := strings.Cut(arg, "-")
+ if !ok {
+ log.Println("bad issue name: missing - separator")
+ continue
+ }
+ dir := path.Join(proj, num)
+ f, err := fsys.Open(path.Join(dir, "issue"))
+ if err != nil {
+ log.Println(err)
+ continue
+ }
+ info, err := f.Stat()
+ if err != nil {
+ f.Close()
+ log.Println(err)
+ continue
+ }
+ if time.Since(info.ModTime()) >= *since {
+ f.Close()
+ continue
+ }
+ msg, err := mail.ReadMessage(f)
+ if err != nil {
+ f.Close()
+ log.Println(err)
+ continue
+ }
+ // fmt.Println("From nobody", info.ModTime().Format(time.ANSIC))
+ fmt.Println("Subject:", msg.Header.Get("Subject"))
+ /*
+ if _, err := io.Copy(os.Stdout, f); err != nil {
+ f.Close()
+ log.Println(err)
+ continue
+ }
+ */
+ fmt.Println()
+ f.Close()
+
+ dents, err := fs.ReadDir(fsys, dir)
+ if err != nil {
+ log.Println(err)
+ continue
+ }
+ for _, d := range dents {
+ if d.Name() == "issue" {
+ continue // already done
+ }
+ info, err := d.Info()
+ if err != nil {
+ log.Println(err)
+ continue
+ }
+ if time.Since(info.ModTime()) >= *since {
+ continue
+ }
+ f, err := fsys.Open(path.Join(dir, d.Name()))
+ if err != nil {
+ log.Println(err)
+ continue
+ }
+ fmt.Println("From nobody", info.ModTime().Format(time.ANSIC))
+ if _, err := io.Copy(os.Stdout, f); err != nil {
+ log.Println(err)
+ }
+ f.Close()
+ fmt.Println()
+ }
+ }
+}
diff --git a/cmd/jiraq/jiraq.go b/cmd/jiraq/jiraq.go
@@ -0,0 +1,99 @@
+// Command jiraq lists Jira issues matching the provided Jira query.
+// Queries must be provided as a single quoted argument in JQL format,
+// such as "project = EXAMPLE and status = Done".
+//
+// Its usage is:
+//
+// jiraq [ -u url ] query
+//
+// The flags are:
+//
+// -u url
+// The URL pointing to the root of the JIRA REST API.
+//
+//
+// # Examples
+//
+// Print an overview of all open tickets in the project "SRE":
+//
+// jiraq -u https://company.example.net 'project = SRE and status != done'
+//
+// Subsequent examples omit the "-u" flag for brevity.
+// List all open tickets assigned to yourself in the project "SRE":
+//
+// jiraq 'project = SRE and status != done and assignee = currentuser()'
+//
+// Print issues updated since yesterday:
+//
+// query='project = SRE and status != done and updated >= -24h'
+// jiraexport `jiraq "$query" | awk '{print $1}'`
+package main
+
+import (
+ "bytes"
+ "flag"
+ "fmt"
+ "log"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+
+ "olowe.co/issues/jira"
+)
+
+func readJiraAuth() (user, pass string, err error) {
+ confDir, err := os.UserConfigDir()
+ if err != nil {
+ return "", "", err
+ }
+ b, err := os.ReadFile(path.Join(confDir, "atlassian/jira"))
+ if err != nil {
+ return "", "", err
+ }
+ b = bytes.TrimSpace(b)
+ u, p, ok := strings.Cut(string(b), ":")
+ if !ok {
+ return "", "", fmt.Errorf(`missing ":" between username and password`)
+ }
+ return u, p, nil
+}
+
+var apiRoot = flag.String("u", "http://[::1]:8080", "base URL for the JIRA API")
+
+const usage = "usage: jiraq [-u url] query"
+
+func init() {
+ log.SetPrefix("jiraq: ")
+ log.SetFlags(0)
+ flag.Parse()
+}
+
+func main() {
+ if len(os.Args) == 1 {
+ log.Fatal(usage)
+ }
+
+ user, pass, err := readJiraAuth()
+ if err != nil {
+ log.Fatalf("read jira auth credentials: %v", err)
+ }
+ u, err := url.Parse(*apiRoot)
+ if err != nil {
+ log.Fatalln("parse api url:", err)
+ }
+ u.Path = path.Join(u.Path, "rest/api/2")
+ client := &jira.Client{
+ APIRoot: u,
+ Username: user,
+ Password: pass,
+ }
+
+ issues, err := client.SearchIssues(strings.Join(flag.Args(), " "))
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, is := range issues {
+ fmt.Printf("%s-%s\t%s\n", is.Project.Name(), is.Name(), is.Summary)
+ }
+}
diff --git a/jira/doc.go b/jira/doc.go
@@ -0,0 +1,26 @@
+/*
+Jira is a program to interact with Jira issues from the Acme editor.
+Projects, issues and comments are presented as a virtual read-only filesystem
+(using package [io/fs])
+which can be browsed in the usual way Acme handles filesystems served
+by the host system.
+
+The filesystem root holds project directories.
+Within each project are the project's issues, one directory entry per issue.
+The filepaths for issues TEST-1, TEST-2, and WEB-27 would be:
+
+ TEST/1
+ TEST/2
+ WEB/27
+
+Each issue directory has a file named "issue"
+holding a textual representation of the issue and a listing of comments.
+For example, TEST/1/issue.
+
+Comments are available as numbered files alongside the issue file.
+Comment 69 of issue TEST-420 can be accessed at TEST/420/69.
+
+https:developer.atlassian.com/cloud/jira/platform/rest/v2/
+https:jira.atlassian.com/rest/api/2/issue/JRA-9
+*/
+package jira
diff --git a/jira/fake.go b/jira/fake.go
@@ -0,0 +1,77 @@
+package jira
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path"
+)
+
+// newFakeServer returns a fake JIRA server which serves projects,
+// issues, and comments from the filesystem tree rooted at root.
+// For an example tree, see the testdata directory.
+//
+// The server provides a limited read-only subset of the JIRA HTTP API
+// intended for testing API clients.
+// All search requests return a list of every issue, even if the JQL query is invalid.
+// Paginated responses are not supported.
+func newFakeServer(root string) *httptest.Server {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/project", serveJSONList(path.Join(root, "project")))
+ mux.HandleFunc("/search", serveJSONList(path.Join(root, "issue")))
+ mux.HandleFunc("/issue", serveJSONList(path.Join(root, "issue")))
+ mux.HandleFunc("/issue/", handleIssues(root))
+ mux.Handle("/", http.FileServer(http.Dir(root)))
+ return httptest.NewServer(mux)
+}
+
+func serveJSONList(dir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ prefix := "["
+ if path.Base(dir) == "issue" {
+ prefix = `{"issues": [`
+ }
+ dirs, err := os.ReadDir(dir)
+ if errors.Is(err, fs.ErrNotExist) {
+ http.NotFound(w, req)
+ return
+ } else if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ fmt.Fprintln(w, prefix)
+ for i, d := range dirs {
+ f, err := os.Open(path.Join(dir, d.Name()))
+ if err != nil {
+ log.Println(err)
+ return
+ }
+ if _, err := io.Copy(w, f); err != nil {
+ log.Printf("copy %s: %v", f.Name(), err)
+ }
+ f.Close()
+ if i == len(dirs)-1 {
+ break
+ }
+ fmt.Fprintln(w, ",")
+ }
+ fmt.Fprintln(w, "]}")
+ }
+}
+
+func handleIssues(dir string) http.HandlerFunc {
+ return func(w http.ResponseWriter, req *http.Request) {
+ if match, _ := path.Match("/issue/*/comment/*", req.URL.Path); match {
+ // ignore error; we know pattern is ok.
+ file := path.Base(req.URL.Path)
+ http.ServeFile(w, req, path.Join(dir, "comment", file))
+ return
+ }
+ http.FileServerFS(os.DirFS(dir)).ServeHTTP(w, req)
+ }
+}
diff --git a/jira/fileinfo.go b/jira/fileinfo.go
@@ -0,0 +1,49 @@
+package jira
+
+import (
+ "io/fs"
+ "strings"
+ "time"
+)
+
+func (is *Issue) Name() string {
+ _, number, found := strings.Cut(is.Key, "-")
+ if !found {
+ return is.Key
+ }
+ return number
+}
+
+func (is *Issue) Size() int64 { return int64(len(printIssue(is))) }
+func (is *Issue) Mode() fs.FileMode { return 0o444 | fs.ModeDir }
+func (is *Issue) ModTime() time.Time { return is.Updated }
+func (is *Issue) IsDir() bool { return is.Mode().IsDir() }
+func (is *Issue) Sys() any { return nil }
+
+func (c *Comment) Name() string { return c.ID }
+func (c *Comment) Size() int64 { return int64(len(printComment(c))) }
+func (c *Comment) Mode() fs.FileMode { return 0o444 }
+func (c *Comment) ModTime() time.Time { return c.Updated }
+func (c *Comment) IsDir() bool { return c.Mode().IsDir() }
+func (c *Comment) Sys() any { return nil }
+
+func (p *Project) Name() string { return p.Key }
+func (p *Project) Size() int64 { return -1 }
+func (p *Project) Mode() fs.FileMode { return 0o444 | fs.ModeDir }
+func (p *Project) ModTime() time.Time { return time.Time{} }
+func (p *Project) IsDir() bool { return p.Mode().IsDir() }
+func (p *Project) Sys() any { return nil }
+
+type stat struct {
+ name string
+ size int64
+ mode fs.FileMode
+ mtime time.Time
+}
+
+func (s stat) Name() string { return s.name }
+func (s stat) Size() int64 { return s.size }
+func (s stat) Mode() fs.FileMode { return s.mode }
+func (s stat) ModTime() time.Time { return s.mtime }
+func (s stat) IsDir() bool { return s.Mode().IsDir() }
+func (s stat) Sys() any { return nil }
diff --git a/jira/fs.go b/jira/fs.go
@@ -0,0 +1,346 @@
+package jira
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "strings"
+ "time"
+)
+
+type FS struct {
+ Client *Client
+ root *fid
+}
+
+const (
+ ftypeRoot int = iota
+ ftypeProject
+ ftypeIssue
+ ftypeIssueDir
+ ftypeComment
+)
+
+type fid struct {
+ *Client
+ name string
+ typ int
+ rd io.Reader
+ parent *fid
+
+ // May be set but only as an optimisation to skip a Stat().
+ stat fs.FileInfo
+
+ // directories only
+ children []fs.DirEntry
+ dirp int
+}
+
+func (f *fid) Name() string { return f.name }
+func (f *fid) IsDir() bool { return f.Type().IsDir() }
+
+func (f *fid) Type() fs.FileMode {
+ switch f.typ {
+ case ftypeRoot, ftypeProject, ftypeIssueDir:
+ return fs.ModeDir
+ }
+ return 0
+}
+
+func (f *fid) Info() (fs.FileInfo, error) { return f.Stat() }
+
+func (f *fid) Stat() (fs.FileInfo, error) {
+ if f.Client.Debug {
+ fmt.Fprintln(os.Stderr, "stat", f.Name())
+ }
+ if f.stat != nil {
+ return f.stat, nil
+ }
+
+ switch f.typ {
+ case ftypeRoot:
+ return &stat{".", int64(len(f.children)), 0o444 | fs.ModeDir, time.Time{}}, nil
+ case ftypeProject:
+ 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 := 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 := 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)
+ return nil, &fs.PathError{"stat", f.name, err}
+}
+
+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 := 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 := 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}
+ }
+ f.rd = strings.NewReader(printIssue(is))
+ default:
+ var err error
+ if f.children == nil {
+ f.children, err = f.ReadDir(-1)
+ if err != nil {
+ return 0, &fs.PathError{"read", f.name, err}
+ }
+ }
+ buf := &strings.Builder{}
+ for _, d := range f.children {
+ fmt.Fprintln(buf, fs.FormatDirEntry(d))
+ }
+ f.rd = strings.NewReader(buf.String())
+ }
+ }
+ return f.rd.Read(p)
+}
+
+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 f.children == nil {
+ switch f.typ {
+ case ftypeRoot:
+ return nil, fmt.Errorf("root initialised incorrectly: no dir entries")
+ case ftypeProject:
+ 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{
+ Client: f.Client,
+ name: issue.Name(),
+ typ: ftypeIssueDir,
+ parent: f,
+ }
+ }
+ case ftypeIssueDir:
+ issue, err := f.Issue(f.issueKey())
+ if err != nil {
+ return nil, fmt.Errorf("get issue %s: %w", f.name, err)
+ }
+ f.children = issueChildren(f, issue)
+ }
+ }
+
+ if f.dirp >= len(f.children) {
+ if n <= 0 {
+ return nil, nil
+ }
+ return nil, io.EOF
+ }
+ if n <= 0 {
+ f.dirp = len(f.children)
+ return f.children, nil
+ }
+
+ var err error
+ d := f.children[f.dirp:]
+ if len(d) >= n {
+ d = d[:n]
+ } else if len(d) <= n {
+ err = io.EOF
+ }
+ f.dirp += n
+ return d, err
+}
+
+func 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(&is.Comments[i])),
+ parent: parent,
+ stat: &is.Comments[i],
+ }
+ }
+ 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")
+ // and the name of the project (the issue's parent's name, "EXAMPLE")
+ var project, issueNumber string
+ switch f.typ {
+ default:
+ return ""
+ case ftypeComment, ftypeIssue:
+ project = f.parent.parent.name
+ issueNumber = f.parent.name
+ case ftypeIssueDir:
+ project = f.parent.name
+ issueNumber = f.name
+ }
+ return project + "-" + issueNumber
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+ if !fs.ValidPath(name) {
+ return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+ }
+ name = path.Clean(name)
+ if strings.Contains(name, "\\") {
+ return nil, fs.ErrNotExist
+ }
+
+ if fsys.root == nil {
+ var err error
+ fsys.root, err = makeRoot(fsys.Client)
+ if err != nil {
+ return nil, fmt.Errorf("make root file: %w", err)
+ }
+ }
+ if fsys.Client.Debug {
+ fmt.Fprintln(os.Stderr, "open", name)
+ }
+
+ if name == "." {
+ f := *fsys.root
+ return &f, nil
+ }
+
+ elems := strings.Split(name, "/")
+ if elems[0] == "." && len(elems) > 1 {
+ elems = elems[1:]
+ }
+
+ f := fsys.root
+ for _, elem := range elems {
+ dir, err := find(f, elem)
+ if err != nil {
+ return nil, &fs.PathError{"open", name, err}
+ }
+ f = dir
+ }
+ g := *f
+ return &g, nil
+}
+
+func makeRoot(client *Client) (*fid, error) {
+ projects, err := client.Projects()
+ if err != nil {
+ return nil, err
+ }
+ root := &fid{
+ Client: client,
+ name: ".",
+ typ: ftypeRoot,
+ children: make([]fs.DirEntry, len(projects)),
+ }
+ for i, p := range projects {
+ root.children[i] = &fid{
+ Client: client,
+ name: p.Key,
+ typ: ftypeProject,
+ }
+ }
+ return root, nil
+}
+
+func find(dir *fid, name string) (*fid, error) {
+ if !dir.IsDir() {
+ return nil, fs.ErrNotExist
+ }
+ child := &fid{Client: dir.Client, parent: dir}
+ switch dir.typ {
+ case ftypeRoot:
+ for _, d := range dir.children {
+ if d.Name() == name {
+ child, ok := d.(*fid)
+ if !ok {
+ return nil, fmt.Errorf("unexpected dir entry type %T", d)
+ }
+ return child, nil
+ }
+ }
+ return nil, fs.ErrNotExist
+ case ftypeProject:
+ key := fmt.Sprintf("%s-%s", dir.name, name)
+ ok, err := dir.CheckIssue(key)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ return nil, fs.ErrNotExist
+ }
+ child.name = name
+ child.typ = ftypeIssueDir
+ return child, nil
+ case ftypeIssueDir:
+ if name == "issue" {
+ child.name = name
+ child.typ = ftypeIssue
+ return child, nil
+ }
+ ok, err := dir.checkComment(dir.issueKey(), name)
+ if err != nil {
+ return nil, err
+ } else if !ok {
+ return nil, fs.ErrNotExist
+ }
+ child.name = name
+ child.typ = ftypeComment
+ return child, nil
+ }
+ return nil, fs.ErrNotExist
+}
diff --git a/jira/fs_test.go b/jira/fs_test.go
@@ -0,0 +1,42 @@
+package jira
+
+import (
+ "encoding/json"
+ "io/fs"
+ "os"
+ "testing"
+)
+
+func TestIssueName(t *testing.T) {
+ f, err := os.Open("testdata/issue/TEST-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ var issue Issue
+ if err := json.NewDecoder(f).Decode(&issue); err != nil {
+ t.Fatal(err)
+ }
+ want := "1"
+ if issue.Name() != want {
+ t.Errorf("issue.Name() = %q, want %q", issue.Name(), want)
+ }
+}
+
+func TestIssueKey(t *testing.T) {
+ comment := &fid{name: "69", typ: ftypeComment}
+ issue := &fid{name: "issue", typ: ftypeIssue}
+ issueDir := &fid{name: "1", typ: ftypeIssueDir, children: []fs.DirEntry{issue, comment}}
+ project := &fid{name: "TEST", typ: ftypeProject, children: []fs.DirEntry{issueDir}}
+
+ comment.parent = issueDir
+ issue.parent = issueDir
+ issueDir.parent = project
+
+ want := "TEST-1"
+ for _, f := range []*fid{comment, issue, issueDir} {
+ if f.issueKey() != want {
+ t.Errorf("fid %s issueKey = %q, want %q", f.name, f.issueKey(), want)
+ }
+ }
+}
diff --git a/jira/http.go b/jira/http.go
@@ -0,0 +1,241 @@
+package jira
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+)
+
+type Client struct {
+ *http.Client
+ Debug bool
+ Username, Password string
+ APIRoot *url.URL
+}
+
+func (c *Client) Projects() ([]Project, error) {
+ u := *c.APIRoot
+ u.Path = path.Join(u.Path, "project")
+ 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 p []Project
+ if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+ return nil, fmt.Errorf("decode project: %w", err)
+ }
+ return p, nil
+}
+
+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
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-ok status: %s", resp.Status)
+ }
+ defer resp.Body.Close()
+ var p Project
+ if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+ return nil, fmt.Errorf("decode project: %w", err)
+ }
+ return &p, nil
+}
+
+func (c *Client) Issues(project string) ([]Issue, error) {
+ q := fmt.Sprintf("project = %q", project)
+ return c.SearchIssues(q)
+}
+
+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()
+ 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)
+ }
+ t := struct {
+ Issues []Issue
+ }{}
+ if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
+ return nil, fmt.Errorf("decode issues: %w", err)
+ }
+ return t.Issues, nil
+}
+
+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 := c.do(req)
+ if err != nil {
+ return false, err
+ }
+ if resp.StatusCode == http.StatusOK {
+ return true, nil
+ }
+ return false, nil
+}
+
+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
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-ok status: %s", resp.Status)
+ }
+ defer resp.Body.Close()
+ var is Issue
+ if err := json.NewDecoder(resp.Body).Decode(&is); err != nil {
+ return nil, fmt.Errorf("decode issue: %w", err)
+ }
+ return &is, nil
+}
+
+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 := c.do(req)
+ if err != nil {
+ return false, err
+ }
+ if resp.StatusCode == http.StatusOK {
+ return true, nil
+ }
+ return false, nil
+}
+
+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
+ }
+ defer resp.Body.Close()
+ var com Comment
+ if err := json.NewDecoder(resp.Body).Decode(&com); err != nil {
+ return nil, fmt.Errorf("decode comment: %w", err)
+ }
+ 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 {
+ return nil, fmt.Errorf("to json: %w", err)
+ }
+ u := APIRoot + "/issue"
+ resp, err := http.Post(u, "application/json", bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-ok status %s", resp.Status)
+ }
+ var i Issue
+ if err := json.NewDecoder(resp.Body).Decode(&i); err != nil {
+ return nil, fmt.Errorf("decode created issue: %w", err)
+ }
+ return &i, nil
+}
+
+func CreateComment(APIRoot, issueKey string, body io.Reader) error {
+ b, err := io.ReadAll(body)
+ if err != nil {
+ return fmt.Errorf("read body: %w", err)
+ }
+ c := Comment{Body: string(b)}
+ hbody, err := json.Marshal(&c)
+ if err != nil {
+ return fmt.Errorf("to json: %w", err)
+ }
+ u := fmt.Sprintf("%s/issue/%s/comment", APIRoot, issueKey)
+ resp, err := http.Post(u, "application/json", bytes.NewReader(hbody))
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode >= http.StatusBadRequest {
+ return fmt.Errorf("non-ok status: %s", resp.Status)
+ }
+ 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)
+}
diff --git a/jira/http_test.go b/jira/http_test.go
@@ -0,0 +1,68 @@
+package jira
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "path"
+ "testing"
+ "testing/fstest"
+)
+
+func handleComment(w http.ResponseWriter, req *http.Request) {
+ id := path.Base(req.URL.Path)
+ http.ServeFile(w, req, "testdata/comment/"+id)
+}
+
+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 := client.Project(project); err != nil {
+ t.Fatalf("get project %s: %v", project, err)
+ }
+ if _, err := client.Issues(project); err != nil {
+ t.Fatalf("get %s issues: %v", project, err)
+ }
+ if _, err := client.Issue(issue); err != nil {
+ t.Fatalf("get issue %s: %v", issue, err)
+ }
+ c, err := client.Comment(issue, comment)
+ if err != nil {
+ t.Fatalf("get comment %s from %s: %v", comment, issue, err)
+ }
+ if c.ID != comment {
+ t.Fatalf("wanted comment id %s, got %s", comment, c.ID)
+ }
+
+ fsys := &FS{Client: client}
+ f, err := fsys.Open("TEST/1/69")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.Stat(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := io.Copy(io.Discard, f); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+
+ expected := []string{
+ "TEST",
+ "TEST/1",
+ "TEST/1/issue",
+ "TEST/1/69",
+ }
+ if err := fstest.TestFS(fsys, expected...); err != nil {
+ t.Error(err)
+ }
+}
diff --git a/jira/jira.go b/jira/jira.go
@@ -0,0 +1,142 @@
+package jira
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+const timestamp = "2006-01-02T15:04:05.999-0700"
+
+type Issue struct {
+ ID string // TODO(otl): int?
+ URL string
+ Key string
+ Reporter User
+ Assignee User
+ Summary string
+ Status struct {
+ Name string `json:"name"`
+ } `json:"status"`
+ Description string
+ Project Project
+ Created time.Time
+ Updated time.Time
+ Comments []Comment
+ Links []Issue
+ Subtasks []Issue
+}
+
+type Project struct {
+ ID string `json:"id"` // TODO(otl): int?
+ // Name string `json:"name"`
+ Key string `json:"key"`
+ URL string `json:"self"`
+}
+
+type Comment struct {
+ ID string `json:"id"` // TODO(otl): int?
+ URL string `json:"self"`
+ Body string `json:"body"`
+ Created time.Time `json:"created"`
+ Updated time.Time `json:"updated"`
+ Author User `json:"author"`
+ UpdateAuthor User `json:"updateAuthor"`
+}
+
+func (c *Comment) UnmarshalJSON(b []byte) error {
+ type alias Comment
+ aux := &struct {
+ Created string `json:"created"`
+ Updated string `json:"updated"`
+ *alias
+ }{
+ alias: (*alias)(c),
+ }
+ if err := json.Unmarshal(b, aux); err != nil {
+ return err
+ }
+ var err error
+ c.Created, err = time.Parse(timestamp, aux.Created)
+ if err != nil {
+ return fmt.Errorf("parse created time: %w", err)
+ }
+ c.Updated, err = time.Parse(timestamp, aux.Updated)
+ if err != nil {
+ return fmt.Errorf("parse updated time: %w", err)
+ }
+ return nil
+}
+
+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
+ Self string
+ Key string
+ Fields json.RawMessage
+ }{}
+ if err := json.Unmarshal(b, aux); err != nil {
+ return err
+ }
+ issue.ID = aux.ID
+ issue.URL = aux.Self
+ issue.Key = aux.Key
+
+ type alias Issue
+ iaux := &struct {
+ Created string
+ Updated string
+ Comment map[string]json.RawMessage
+ IssueLinks []struct {
+ InwardIssue *Issue
+ OutwardIssue *Issue
+ }
+ *alias
+ }{
+ alias: (*alias)(issue),
+ }
+ if err := json.Unmarshal(aux.Fields, iaux); err != nil {
+ return err
+ }
+
+ var err error
+ if iaux.Created != "" {
+ issue.Created, err = time.Parse(timestamp, iaux.Created)
+ if err != nil {
+ return fmt.Errorf("created 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)
+ }
+ }
+ for _, l := range iaux.IssueLinks {
+ if l.InwardIssue != nil {
+ issue.Links = append(issue.Links, *l.InwardIssue)
+ }
+ if l.OutwardIssue != nil {
+ issue.Links = append(issue.Links, *l.OutwardIssue)
+ }
+ }
+ return nil
+}
diff --git a/jira/jira_test.go b/jira/jira_test.go
@@ -0,0 +1,40 @@
+package jira
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+func TestDecode(t *testing.T) {
+ dents, err := os.ReadDir("testdata/issue")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range dents {
+ f, err := os.Open("testdata/issue/" + d.Name())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var i Issue
+ if err := json.NewDecoder(f).Decode(&i); err != nil {
+ t.Errorf("decode %s: %v", f.Name(), err)
+ }
+ 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)
+}
+*/
diff --git a/jira/print.go b/jira/print.go
@@ -0,0 +1,91 @@
+package jira
+
+import (
+ "fmt"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+)
+
+func printIssues(issues []Issue) string {
+ buf := &strings.Builder{}
+ for _, ii := range issues {
+ name := strings.Replace(ii.Key, "-", "/", 1)
+ 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)
+ fmt.Fprintln(buf, "Date:", i.Created.Format(time.RFC1123Z))
+ 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, "Status:", i.Status.Name)
+ if len(i.Links) > 0 {
+ s := make([]string, len(i.Links))
+ for j := range i.Links {
+ s[j] = i.Links[j].Key
+ }
+ fmt.Fprintln(buf, "References:", strings.Join(s, ", "))
+ }
+ 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)
+
+ 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
+ if !c.Updated.IsZero() {
+ date = c.Updated
+ }
+ fmt.Fprintf(buf, "%s\t%s\t%s (%s)\n", c.ID, summarise(c.Body, 36), c.Author.Name, date.Format(time.DateTime))
+ }
+ return buf.String()
+}
+
+func printComment(c *Comment) string {
+ buf := &strings.Builder{}
+ date := c.Created
+ if !c.Updated.IsZero() {
+ date = c.Updated
+ }
+ fmt.Fprintln(buf, "From:", c.Author)
+ fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z))
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, strings.TrimSpace(c.Body))
+ return buf.String()
+}
+
+func summarise(body string, length int) string {
+ if len(body) < length {
+ body = strings.ReplaceAll(body, "\n", " ")
+ return strings.TrimSpace(body)
+ }
+ body = body[:length]
+ body = strings.ReplaceAll(body, "\r", "")
+ body = strings.ReplaceAll(body, "\n", " ")
+ body = strings.TrimSpace(body)
+ body = strings.ReplaceAll(body, " ", " ")
+ return body + "..."
+}
diff --git a/Jira/project.json b/jira/project.json
diff --git a/Jira/testdata/comment/69 b/jira/testdata/comment/69
diff --git a/Jira/testdata/issue/TEST-1 b/jira/testdata/issue/TEST-1
diff --git a/Jira/testdata/project/TEST b/jira/testdata/project/TEST