issues

Github, Jira, Gitlab issues from Plan 9's acme
Log | Files | Refs | README | LICENSE

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:
DJira/acme.go | 288-------------------------------------------------------------------------------
DJira/doc.go | 26--------------------------
DJira/fake.go | 77-----------------------------------------------------------------------------
DJira/fileinfo.go | 49-------------------------------------------------
DJira/fs.go | 346-------------------------------------------------------------------------------
DJira/fs_test.go | 42------------------------------------------
DJira/http.go | 241-------------------------------------------------------------------------------
DJira/http_test.go | 68--------------------------------------------------------------------
DJira/jira.go | 142-------------------------------------------------------------------------------
DJira/jira_test.go | 40----------------------------------------
DJira/print.go | 91-------------------------------------------------------------------------------
MREADME.md | 9+++++++--
Acmd/Jira/Jira.go | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/jiraexport/jiraexport.go | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/jiraq/jiraq.go | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/doc.go | 26++++++++++++++++++++++++++
Ajira/fake.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/fileinfo.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/fs.go | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/fs_test.go | 42++++++++++++++++++++++++++++++++++++++++++
Ajira/http.go | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/http_test.go | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/jira.go | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ajira/jira_test.go | 40++++++++++++++++++++++++++++++++++++++++
Ajira/print.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
RJira/project.json -> jira/project.json | 0
RJira/testdata/comment/69 -> jira/testdata/comment/69 | 0
RJira/testdata/issue/TEST-1 -> jira/testdata/issue/TEST-1 | 0
RJira/testdata/project/TEST -> jira/testdata/project/TEST | 0
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