commit 4f7a6856e8d1f7e96a76eb2854e8dc7eff9f9b6d
parent 2fe99c0f775659ffbbd6683ed755964934a8300f
Author: Oliver Lowe <o@olowe.co>
Date: Thu, 3 Oct 2024 10:39:19 +1000
jira: aeroplane hacking
Diffstat:
25 files changed, 1161 insertions(+), 1017 deletions(-)
diff --git a/Jira/acme.go b/Jira/acme.go
@@ -0,0 +1,188 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "os"
+ "path"
+ "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/")
+}
+
+func (w *awin) Look(text string) bool {
+ text = strings.TrimSpace(text)
+ fname := path.Join(path.Dir(w.name()), text)
+ if strings.HasSuffix(text, "/") {
+ fname = path.Join(w.name(), text)
+ }
+ f, err := w.fsys.Open(fname)
+ if errors.Is(err, fs.ErrNotExist) {
+ return false
+ } else if err != nil {
+ w.Err(err.Error())
+ return false
+ }
+ stat, err := f.Stat()
+ if err != nil {
+ w.Err(err.Error())
+ return true
+ }
+
+ win, err := acme.New()
+ if err != nil {
+ w.Err(err.Error())
+ return true
+ }
+
+ wname := path.Join("/jira", fname)
+ if stat.IsDir() {
+ wname += "/"
+ }
+ win.Name(wname)
+ ww := &awin{win, w.fsys}
+ go ww.EventLoop(ww)
+ go func() {
+ if err := ww.Get(); err != nil {
+ w.Err(err.Error())
+ }
+ }()
+ return true
+}
+
+func (w *awin) Execute(cmd string) bool {
+ fields := strings.Fields(strings.TrimSpace(cmd))
+ switch fields[0] {
+ case "Get":
+ if err := w.Get(); err != nil {
+ w.Err(err.Error())
+ return false
+ }
+ case "Search":
+ if len(fields) == 1 {
+ return false
+ }
+ query := strings.Join(fields[1:], " ")
+ go newSearch(w.fsys, APIRoot, query)
+ return true
+ }
+ return false
+}
+
+func (w *awin) Get() error {
+ w.Ctl("dirty")
+ defer w.Ctl("clean")
+ f, err := w.fsys.Open(path.Clean(w.name()))
+ if err != nil {
+ return err
+ }
+ stat, err := f.Stat()
+ if err != nil {
+ return err
+ }
+
+ defer f.Close()
+ buf := &bytes.Buffer{}
+ if stat.IsDir() {
+ dirs, err := f.(fs.ReadDirFile).ReadDir(-1)
+ if err != nil {
+ return err
+ }
+ for _, d := range dirs {
+ if d.IsDir() {
+ fmt.Fprintln(buf, d.Name()+"/")
+ continue
+ }
+ fmt.Fprintln(buf, d.Name())
+ }
+ } else {
+ if _, err := io.Copy(buf, f); err != nil {
+ return fmt.Errorf("copy %s: %w", stat.Name(), err)
+ }
+ }
+ w.Clear()
+ if _, err := w.Write("body", buf.Bytes()); err != nil {
+ return fmt.Errorf("write %s: %w", "body", err)
+ }
+ return nil
+}
+
+func newSearch(fsys fs.FS, apiRoot, query string) {
+ win, err := acme.New()
+ if err != nil {
+ acme.Errf("new window: %v", err.Error())
+ return
+ }
+ win.Name("/jira/search")
+ issues, err := searchIssues(apiRoot, query)
+ if err != nil {
+ win.Errf("search %q: %v", query, err)
+ return
+ }
+ if err := win.Fprintf("body", "Search %s\n\n", query); err != nil {
+ win.Err(err.Error())
+ return
+ }
+ _, err = win.Write("body", []byte(printIssues(issues)))
+ if err != nil {
+ win.Err(err.Error())
+ }
+ w := &awin{win, fsys}
+ go w.EventLoop(w)
+}
+
+var APIRoot = "https://jira.atlassian.com/api/rest/2"
+
+func main() {
+ srv := newFakeServer("testdata")
+ defer srv.Close()
+ APIRoot = srv.URL
+ fsys := &FS{apiRoot: srv.URL}
+
+ acme.AutoExit(true)
+ win, err := acme.New()
+ if err != nil {
+ log.Fatal(err)
+ }
+ root := &awin{win, fsys}
+
+ dirs, err := fs.ReadDir(fsys, ".")
+ if err != nil {
+ log.Fatal(err)
+ }
+ buf := &bytes.Buffer{}
+ for _, d := range dirs {
+ fmt.Fprintln(buf, d.Name()+"/")
+ }
+ if _, err := root.Write("body", buf.Bytes()); err != nil {
+ log.Fatal(err)
+ }
+ root.Name("/jira/")
+ win.Ctl("clean")
+ root.EventLoop(root)
+ os.Exit(0)
+}
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 main
diff --git a/Jira/fake.go b/Jira/fake.go
@@ -0,0 +1,77 @@
+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
diff --git a/Jira/fs.go b/Jira/fs.go
@@ -0,0 +1,343 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "log"
+ "path"
+ "strings"
+ "time"
+)
+
+type FS struct {
+ apiRoot string
+ root *fid
+}
+
+const (
+ ftypeRoot int = iota
+ ftypeProject
+ ftypeIssue
+ ftypeIssueDir
+ ftypeComment
+)
+
+type fid struct {
+ apiRoot string
+ name string
+ typ int
+ rd io.Reader
+ parent *fid
+
+ // May be set but only as an optimisation to skip a Stat().
+ stat *stat
+
+ // 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 debug {
+ log.Println("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 := getProject(f.apiRoot, f.name)
+ if err != nil {
+ return nil, &fs.PathError{"stat", f.name, err}
+ }
+ return p, nil
+ case ftypeIssueDir, ftypeIssue:
+ is, err := getIssue(f.apiRoot, f.issueKey())
+ if err != nil {
+ return nil, &fs.PathError{"stat", f.name, err}
+ }
+ if f.typ == ftypeIssueDir {
+ return is, nil
+ }
+ return &stat{f.name, int64(len(printIssue(is))), 0o444, is.Updated}, nil
+ case ftypeComment:
+ c, err := getComment(f.apiRoot, f.issueKey(), f.name)
+ if err != nil {
+ return nil, &fs.PathError{"stat", f.name, err}
+ }
+ 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.rd == nil {
+ switch f.typ {
+ case ftypeComment:
+ c, err := getComment(f.apiRoot, f.issueKey(), f.name)
+ if err != nil {
+ err = fmt.Errorf("get comment %s: %w", f.issueKey(), err)
+ return 0, &fs.PathError{"read", f.name, err}
+ }
+ f.rd = strings.NewReader(printComment(c))
+ case ftypeIssue:
+ is, err := getIssue(f.apiRoot, f.issueKey())
+ 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
+ return nil
+}
+
+func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) {
+ if !f.IsDir() {
+ return nil, fmt.Errorf("not a directory")
+ }
+ if debug {
+ log.Println("readdir", f.name)
+ }
+ if f.children == nil {
+ switch f.typ {
+ case ftypeRoot:
+ return nil, fmt.Errorf("root initialised incorrectly: no dir entries")
+ case ftypeProject:
+ issues, err := getIssues(f.apiRoot, f.name)
+ if err != nil {
+ return nil, fmt.Errorf("get issues: %w", err)
+ }
+ f.children = make([]fs.DirEntry, len(issues))
+ for i, issue := range issues {
+ f.children[i] = &fid{
+ apiRoot: f.apiRoot,
+ name: issue.Name(),
+ typ: ftypeIssueDir,
+ parent: f,
+ }
+ }
+ case ftypeIssueDir:
+ issue, err := getIssue(f.apiRoot, f.issueKey())
+ if err != nil {
+ return nil, fmt.Errorf("get issue %s: %w", f.name, err)
+ }
+ f.children = make([]fs.DirEntry, len(issue.Comments)+1)
+ for i, c := range issue.Comments {
+ f.children[i] = &fid{
+ apiRoot: f.apiRoot,
+ name: c.ID,
+ typ: ftypeComment,
+ rd: strings.NewReader(printComment(&c)),
+ parent: f,
+ }
+ }
+ f.children[len(f.children)-1] = &fid{
+ name: "issue",
+ apiRoot: f.apiRoot,
+ typ: ftypeIssue,
+ rd: strings.NewReader(issue.Summary),
+ parent: f,
+ stat: &stat{"issue", int64(len(printIssue(issue))), 0o444, issue.Updated},
+ }
+ }
+ }
+
+ if f.dirp >= len(f.children) {
+ if n <= 0 {
+ return nil, nil
+ }
+ return nil, io.EOF
+ }
+ if n <= 0 {
+ f.dirp = len(f.children)
+ return f.children, nil
+ }
+
+ var err error
+ d := f.children[f.dirp:]
+ if len(d) >= n {
+ d = d[:n]
+ } else if len(d) <= n {
+ err = io.EOF
+ }
+ f.dirp += n
+ return d, err
+}
+
+func (f *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.apiRoot)
+ if err != nil {
+ return nil, fmt.Errorf("make root file: %w", err)
+ }
+ }
+ if debug {
+ log.Println("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(apiRoot string) (*fid, error) {
+ projects, err := getProjects(apiRoot)
+ if err != nil {
+ return nil, err
+ }
+ root := &fid{
+ apiRoot: apiRoot,
+ name: ".",
+ typ: ftypeRoot,
+ children: make([]fs.DirEntry, len(projects)),
+ }
+ for i, p := range projects {
+ root.children[i] = &fid{
+ apiRoot: apiRoot,
+ name: p.Key,
+ typ: ftypeProject,
+ }
+ }
+ return root, nil
+}
+
+func find(dir *fid, name string) (*fid, error) {
+ if !dir.IsDir() {
+ return nil, fs.ErrNotExist
+ }
+ child := &fid{apiRoot: dir.apiRoot, 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 := checkIssue(dir.apiRoot, 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
+ }
+ // we may have already loaded the dir entries (comments)
+ // when we loaded the parent (issue).
+ /*
+ for _, d := range dir.children {
+ if d.Name() == name {
+ c, ok := d.(*fid)
+ if !ok {
+ break
+ }
+ return c, nil
+ }
+ }
+ */
+ ok, err := checkComment(dir.apiRoot, dir.issueKey(), name)
+ 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 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
@@ -0,0 +1,230 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+)
+
+const debug = false
+
+type Client struct {
+ *http.Client
+ debug bool
+ apiRoot *url.URL
+}
+
+func (c *Client) Projects() ([]Project, error) {
+ u := *c.apiRoot
+ u.Path += "/project"
+
+ resp, err := c.Get(u.String())
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var p []Project
+
+ var p []Project
+ if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+ return
+ return nil, fmt.Errorf("TODO")
+}
+
+func (c *Client) getDecode(path string, v any) error {
+ u := *c.apiRoot
+ resp, err := c.Get(url)
+ if err != nil {
+ ...
+ }
+ return json.NewDecoder(resp.Body).Decode(&v)
+}
+
+func getProjects(apiRoot string) ([]Project, error) {
+ u := fmt.Sprintf("%s/project", apiRoot)
+ if debug {
+ fmt.Fprintln(os.Stderr, "GET", u)
+ }
+ resp, err := http.Get(u)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-ok status: %s", resp.Status)
+ }
+ 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 getProject(apiRoot, name string) (*Project, error) {
+ u := fmt.Sprintf("%s/project/%s", apiRoot, name)
+ if debug {
+ fmt.Fprintln(os.Stderr, "GET", u)
+ }
+ resp, err := http.Get(u)
+ 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 getIssues(apiRoot, project string) ([]Issue, error) {
+ q := fmt.Sprintf("project = %q", project)
+ return searchIssues(apiRoot, q)
+}
+
+func searchIssues(apiRoot, query string) ([]Issue, error) {
+ u, err := url.Parse(apiRoot + "/search")
+ if err != nil {
+ return nil, err
+ }
+ q := make(url.Values)
+ q.Add("jql", query)
+ u.RawQuery = q.Encode()
+ if debug {
+ fmt.Fprintln(os.Stderr, "GET", u)
+ }
+ resp, err := http.Get(u.String())
+ if err != nil {
+ return nil, err
+ }
+ 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 checkIssue(apiRoot, name string) (bool, error) {
+ u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
+ if debug {
+ log.Println("HEAD", u)
+ }
+ resp, err := http.Head(u)
+ if err != nil {
+ return false, err
+ }
+ if resp.StatusCode == http.StatusOK {
+ return true, nil
+ }
+ return false, nil
+}
+
+func getIssue(apiRoot, name string) (*Issue, error) {
+ u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
+ if debug {
+ log.Println("GET", u)
+ }
+ resp, err := http.Get(u)
+ 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 checkComment(apiRoot, ikey, id string) (bool, error) {
+ u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
+ if debug {
+ log.Println("HEAD", u)
+ }
+ resp, err := http.Head(u)
+ if err != nil {
+ return false, err
+ }
+ if resp.StatusCode == http.StatusOK {
+ return true, nil
+ }
+ return false, nil
+}
+
+func getComment(apiRoot string, ikey, id string) (*Comment, error) {
+ u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
+ if debug {
+ fmt.Fprintln(os.Stderr, "GET", u)
+ }
+ resp, err := http.Get(u)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("non-ok status: %s", resp.Status)
+ }
+ defer resp.Body.Close()
+ var c Comment
+ if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
+ return nil, fmt.Errorf("decode comment: %w", err)
+ }
+ return &c, 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
+}
diff --git a/Jira/http_test.go b/Jira/http_test.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "io"
+ "net/http"
+ "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()
+
+ project := "TEST"
+ issue := "TEST-1"
+ comment := "69"
+ if _, err := getProject(srv.URL, project); err != nil {
+ t.Fatalf("get project %s: %v", project, err)
+ }
+ if _, err := getIssues(srv.URL, project); err != nil {
+ t.Fatalf("get %s issues: %v", project, err)
+ }
+ if _, err := getIssue(srv.URL, issue); err != nil {
+ t.Fatalf("get issue %s: %v", issue, err)
+ }
+ c, err := getComment(srv.URL, 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{apiRoot: srv.URL}
+ 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,116 @@
+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
+ Summary string
+ Status struct {
+ Name string `json:"name"`
+ } `json:"status"`
+ Description string
+ Project Project
+ Created time.Time
+ Updated time.Time
+ Comments []Comment
+}
+
+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"`
+ DisplayName string `json:"displayName"`
+}
+
+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
+ *alias
+ }{
+ alias: (*alias)(issue),
+ }
+ if err := json.Unmarshal(aux.Fields, iaux); err != nil {
+ return err
+ }
+
+ var err error
+ issue.Created, err = time.Parse(timestamp, iaux.Created)
+ if err != nil {
+ return fmt.Errorf("created time: %w", err)
+ }
+ issue.Updated, err = time.Parse(timestamp, iaux.Updated)
+ if err != nil {
+ return fmt.Errorf("updated time: %w", err)
+ }
+
+ if bb, ok := iaux.Comment["comments"]; ok {
+ if err := json.Unmarshal(bb, &issue.Comments); err != nil {
+ return fmt.Errorf("unmarshal comments: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/jira/jira_test.go b/Jira/jira_test.go
diff --git a/Jira/print.go b/Jira/print.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+func printIssues(issues []Issue) string {
+ buf := &strings.Builder{}
+ for _, ii := range issues {
+ name := strings.Replace(ii.Key, "-", "/", 1)
+ fmt.Fprintf(buf, "%s/\t%s\n", name, ii.Summary)
+ }
+ return buf.String()
+}
+
+func printIssue(i *Issue) string {
+ buf := &strings.Builder{}
+ fmt.Fprintln(buf, "From:", i.Reporter.Name)
+ fmt.Fprintln(buf, "URL:", i.URL)
+ fmt.Fprintln(buf, "Date:", i.Updated.Format(time.RFC1123Z))
+ fmt.Fprintln(buf, "Status:", i.Status.Name)
+ fmt.Fprintln(buf, "Subject:", i.Summary)
+ fmt.Fprintln(buf)
+
+ fmt.Fprintln(buf, i.Description)
+ 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.Name)
+ fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z))
+ fmt.Fprintln(buf)
+ fmt.Fprintln(buf, 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
diff --git a/LICENSE b/LICENSE
@@ -1,27 +1,13 @@
-Copyright (c) 2009 The Go Authors. All rights reserved.
+Copyright (c) 2024 Oliver Lowe <o@olowe.co>
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
@@ -1,9 +1,11 @@
-This repo holds programs for interacting with GitHub and GitLab issues from the [Acme] editor.
+This repo holds programs for interacting with issue trackers from the [Acme] editor.
See the documentation for the commands:
- [issue]
- [Gitlab]
+- [Jira]
[Acme]: https://p9f.org/sys/doc/acme/acme.html
[issue]: https://pkg.go.dev/olowe.co/github/issue
[Gitlab]: https://pkg.go.dev/olowe.co/github/Gitlab
+[Jira]: https://pkg.go.dev/olowe.co/github/Jira
diff --git a/LICENSE b/issue/LICENSE
diff --git a/jira/acme.go b/jira/acme.go
@@ -1,159 +0,0 @@
-package main
-
-import (
- "bytes"
- "errors"
- "fmt"
- "io"
- "io/fs"
- "log"
- "os"
- "path"
- "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/")
-}
-
-func (w *awin) Look(text string) bool {
- text = strings.TrimSpace(text)
- fname := path.Join(path.Dir(w.name()), text)
- if strings.HasSuffix(text, "/") {
- fname = path.Join(w.name(), text)
- }
- f, err := w.fsys.Open(fname)
- if errors.Is(err, fs.ErrNotExist) {
- return false
- } else if err != nil {
- w.Err(err.Error())
- return false
- }
- stat, err := f.Stat()
- if err != nil {
- w.Err(err.Error())
- return true
- }
-
- win, err := acme.New()
- if err != nil {
- w.Err(err.Error())
- return true
- }
-
- wname := path.Join("/jira", w.name(), path.Base(fname))
- if stat.IsDir() {
- wname += "/"
- }
- win.Name(wname)
- go func() {
- defer f.Close()
- buf := &bytes.Buffer{}
- if stat.IsDir() {
- dirs, err := f.(fs.ReadDirFile).ReadDir(-1)
- if err != nil {
- win.Err(err.Error())
- return
- }
- for _, d := range dirs {
- if d.IsDir() {
- fmt.Fprintln(buf, d.Name()+"/")
- continue
- }
- fmt.Fprintln(buf, d.Name())
- }
- } else {
- if _, err := io.Copy(buf, f); err != nil {
- win.Err(err.Error())
- return
- }
- }
- w := &awin{win, w.fsys}
- if _, err := w.Write("body", buf.Bytes()); err != nil {
- win.Err(err.Error())
- return
- }
- win.Ctl("clean")
- go w.EventLoop(w)
- }()
- return true
-}
-
-func (w *awin) Execute(cmd string) bool {
- fields := strings.Fields(strings.TrimSpace(cmd))
- switch fields[0] {
- case "Get":
- return false
- case "Search":
- if len(fields) == 1 {
- return false
- }
- query := strings.Join(fields[1:], " ")
- go newSearch("TODO", query)
- return true
- }
- return false
-}
-
-func newSearch(apiRoot, query string) {
- win, err := acme.New()
- if err != nil {
- acme.Errf("new window: %v", err.Error())
- return
- }
- win.Name("/jira/search")
- issues, err := searchIssues(apiRoot, query)
- if err != nil {
- win.Errf("search %q: %v", query, err)
- return
- }
- _, err = win.Write("body", []byte(printIssues(issues)))
- if err != nil {
- win.Err(err.Error())
- }
-}
-
-func main() {
- fsys := &FS{apiRoot: "https://jira.atlassian.com/rest/api/2"}
-
- acme.AutoExit(true)
- win, err := acme.New()
- if err != nil {
- log.Fatal(err)
- }
- root := &awin{win, fsys}
-
- dirs, err := fs.ReadDir(fsys, ".")
- if err != nil {
- log.Fatal(err)
- }
- buf := &bytes.Buffer{}
- for _, d := range dirs {
- fmt.Fprintln(buf, d.Name()+"/")
- }
- if _, err := root.Write("body", buf.Bytes()); err != nil {
- log.Fatal(err)
- }
- root.Name("/jira/")
- win.Ctl("clean")
- root.EventLoop(root)
- os.Exit(0)
-}
diff --git a/jira/fs.go b/jira/fs.go
@@ -1,346 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "io/fs"
- "log"
- "path"
- "strings"
- "time"
-)
-
-type FS struct {
- apiRoot string
- root *fid
-}
-
-// JRASERVER/1234/issue
-// JRASERVER/1234/5678
-
-const (
- ftypeRoot int = iota
- ftypeProject
- ftypeIssue
- ftypeIssueDir
- ftypeComment
-)
-
-type fid struct {
- apiRoot string
- name string
- typ int
- rd io.Reader
- parent *fid
-
- // May not be set.
- stat *stat
-
- // 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 debug {
- log.Println("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 := getProject(f.apiRoot, f.name)
- if err != nil {
- return nil, &fs.PathError{"stat", f.name, err}
- }
- return p, nil
- case ftypeIssueDir, ftypeIssue:
- is, err := getIssue(f.apiRoot, f.issueKey())
- if err != nil {
- return nil, &fs.PathError{"stat", f.name, err}
- }
- if f.typ == ftypeIssueDir {
- return is, nil
- }
- return &stat{f.name, int64(len(printIssue(is))), 0o444, is.Updated}, nil
- case ftypeComment:
- c, err := getComment(f.apiRoot, f.issueKey(), f.name)
- if err != nil {
- return nil, &fs.PathError{"stat", f.name, err}
- }
- 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.rd == nil {
- switch f.typ {
- case ftypeComment:
- c, err := getComment(f.apiRoot, f.issueKey(), f.name)
- if err != nil {
- err = fmt.Errorf("get comment %s: %w", f.issueKey(), err)
- return 0, &fs.PathError{"read", f.name, err}
- }
- f.rd = strings.NewReader(printComment(c))
- case ftypeIssue:
- is, err := getIssue(f.apiRoot, f.issueKey())
- 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
- return nil
-}
-
-func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) {
- if !f.IsDir() {
- return nil, fmt.Errorf("not a directory")
- }
- if debug {
- log.Println("readdir", f.name)
- }
- if f.children == nil {
- switch f.typ {
- case ftypeRoot:
- return nil, fmt.Errorf("root initialised incorrectly: no dir entries")
- case ftypeProject:
- issues, err := getIssues(f.apiRoot, f.name)
- if err != nil {
- return nil, fmt.Errorf("get issues: %w", err)
- }
- f.children = make([]fs.DirEntry, len(issues))
- for i, issue := range issues {
- f.children[i] = &fid{
- apiRoot: f.apiRoot,
- name: issue.Name(),
- typ: ftypeIssueDir,
- parent: f,
- }
- }
- case ftypeIssueDir:
- issue, err := getIssue(f.apiRoot, f.issueKey())
- if err != nil {
- return nil, fmt.Errorf("get issue %s: %w", f.name, err)
- }
- f.children = make([]fs.DirEntry, len(issue.Comments)+1)
- for i, c := range issue.Comments {
- f.children[i] = &fid{
- apiRoot: f.apiRoot,
- name: c.ID,
- typ: ftypeComment,
- rd: strings.NewReader(printComment(&c)),
- parent: f,
- }
- }
- f.children[len(f.children)-1] = &fid{
- name: "issue",
- apiRoot: f.apiRoot,
- typ: ftypeIssue,
- rd: strings.NewReader(issue.Summary),
- parent: f,
- stat: &stat{"issue", int64(len(printIssue(issue))), 0o444, issue.Updated},
- }
- }
- }
-
- if f.dirp >= len(f.children) {
- if n <= 0 {
- return nil, nil
- }
- return nil, io.EOF
- }
- if n <= 0 {
- f.dirp = len(f.children)
- return f.children, nil
- }
-
- var err error
- d := f.children[f.dirp:]
- if len(d) >= n {
- d = d[:n]
- } else if len(d) <= n {
- err = io.EOF
- }
- f.dirp += n
- return d, err
-}
-
-func (f *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
- }
-
- var err error
- if fsys.root == nil {
- fsys.root, err = makeRoot(fsys.apiRoot)
- if err != nil {
- return nil, fmt.Errorf("make root file: %w", err)
- }
- }
- if debug {
- log.Println("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(apiRoot string) (*fid, error) {
- projects, err := getProjects(apiRoot)
- if err != nil {
- return nil, err
- }
- root := &fid{
- apiRoot: apiRoot,
- name: ".",
- typ: ftypeRoot,
- children: make([]fs.DirEntry, len(projects)),
- }
- for i, p := range projects {
- root.children[i] = &fid{
- apiRoot: apiRoot,
- name: p.Key,
- typ: ftypeProject,
- }
- }
- return root, nil
-}
-
-func find(dir *fid, name string) (*fid, error) {
- if !dir.IsDir() {
- return nil, fs.ErrNotExist
- }
- child := &fid{apiRoot: dir.apiRoot, 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 := checkIssue(dir.apiRoot, 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
- }
- // we may have already loaded the dir entries (comments)
- // when we loaded the parent (issue).
- /*
- for _, d := range dir.children {
- if d.Name() == name {
- c, ok := d.(*fid)
- if !ok {
- break
- }
- return c, nil
- }
- }
- */
- ok, err := checkComment(dir.apiRoot, dir.issueKey(), name)
- 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,44 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "io/fs"
- "os"
- "testing"
-)
-
-const atlassianRoot = "https://jira.atlassian.com/rest/api/2"
-
-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,154 +0,0 @@
-package main
-
-import (
- "encoding/json"
- "fmt"
- "log"
- "net/http"
- "net/url"
- "os"
-)
-
-const debug = false
-
-func getProjects(apiRoot string) ([]Project, error) {
- u := fmt.Sprintf("%s/project", apiRoot)
- if debug {
- fmt.Fprintln(os.Stderr, "GET", u)
- }
- resp, err := http.Get(u)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("non-ok status: %s", resp.Status)
- }
- 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 getProject(apiRoot, name string) (*Project, error) {
- u := fmt.Sprintf("%s/project/%s", apiRoot, name)
- if debug {
- fmt.Fprintln(os.Stderr, "GET", u)
- }
- resp, err := http.Get(u)
- 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 getIssues(apiRoot, project string) ([]Issue, error) {
- q := fmt.Sprintf("project = %q", project)
- return searchIssues(apiRoot, q)
-}
-
-func searchIssues(apiRoot, query string) ([]Issue, error) {
- u, err := url.Parse(apiRoot + "/search")
- if err != nil {
- return nil, err
- }
- q := make(url.Values)
- q.Add("jql", query)
- u.RawQuery = q.Encode()
- if debug {
- fmt.Fprintln(os.Stderr, "GET", u)
- }
- resp, err := http.Get(u.String())
- if err != nil {
- return nil, err
- }
- 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 checkIssue(apiRoot, name string) (bool, error) {
- u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
- if debug {
- log.Println("HEAD", u)
- }
- resp, err := http.Head(u)
- if err != nil {
- return false, err
- }
- if resp.StatusCode == http.StatusOK {
- return true, nil
- }
- return false, nil
-}
-
-func getIssue(apiRoot, name string) (*Issue, error) {
- u := fmt.Sprintf("%s/issue/%s", apiRoot, name)
- if debug {
- log.Println("GET", u)
- }
- resp, err := http.Get(u)
- 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 checkComment(apiRoot, ikey, id string) (bool, error) {
- u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
- if debug {
- log.Println("HEAD", u)
- }
- resp, err := http.Head(u)
- if err != nil {
- return false, err
- }
- if resp.StatusCode == http.StatusOK {
- return true, nil
- }
- return false, nil
-}
-
-func getComment(apiRoot string, ikey, id string) (*Comment, error) {
- u := fmt.Sprintf("%s/issue/%s/comment/%s", apiRoot, ikey, id)
- if debug {
- fmt.Fprintln(os.Stderr, "GET", u)
- }
- resp, err := http.Get(u)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("non-ok status: %s", resp.Status)
- }
- defer resp.Body.Close()
- var c Comment
- if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
- return nil, fmt.Errorf("decode comment: %w", err)
- }
- return &c, nil
-}
diff --git a/jira/http_test.go b/jira/http_test.go
@@ -1,111 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "log"
- "net/http"
- "net/http/httptest"
- "os"
- "path"
- "testing"
- "testing/fstest"
-)
-
-func serveFakeList(dir string) http.HandlerFunc {
- return func(w http.ResponseWriter, req *http.Request) {
- var prefix string
- typ := path.Base(dir)
- switch typ {
- case "issue":
- prefix = `{"issues": [`
- case "project":
- prefix = "["
- default:
- http.NotFound(w, req)
- return
- }
- dirs, err := os.ReadDir(dir)
- if err != nil {
- log.Println(err)
- 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()
- }
- f.Close()
- if i == len(dirs)-1 {
- break
- }
- fmt.Fprintln(w, ",")
- }
- fmt.Fprintln(w, "]}")
- }
-}
-
-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) {
- http.HandleFunc("/project", serveFakeList("testdata/project"))
- http.HandleFunc("/search", serveFakeList("testdata/issue"))
- http.HandleFunc("/issue", serveFakeList("testdata/issue"))
- http.HandleFunc("/issue/TEST-1/comment/", handleComment)
- http.Handle("/", http.FileServer(http.Dir("testdata")))
- srv := httptest.NewServer(nil)
- defer srv.Close()
-
- project := "TEST"
- issue := "TEST-1"
- comment := "69"
- if _, err := getProject(srv.URL, project); err != nil {
- t.Fatalf("get project %s: %v", project, err)
- }
- if _, err := getIssues(srv.URL, project); err != nil {
- t.Fatalf("get %s issues: %v", project, err)
- }
- if _, err := getIssue(srv.URL, issue); err != nil {
- t.Fatalf("get issue %s: %v", issue, err)
- }
- c, err := getComment(srv.URL, issue, comment)
- if err != nil {
- t.Fatalf("get comment %s from %s: %v", comment, issue, err)
- }
- if c.ID != "69" {
- t.Fatalf("wanted comment id %s, got %s", "69", c.ID)
- }
-
- fsys := &FS{apiRoot: srv.URL}
- 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,115 +0,0 @@
-// https://developer.atlassian.com/cloud/jira/platform/rest/v2/
-// https://jira.atlassian.com/rest/api/2/issue/JRA-9
-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
- Summary string
- Description string
- Project Project
- Created time.Time
- Updated time.Time
- Comments []Comment
-}
-
-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"`
- DisplayName string `json:"displayName"`
-}
-
-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
- *alias
- }{
- alias: (*alias)(issue),
- }
- if err := json.Unmarshal(aux.Fields, iaux); err != nil {
- return err
- }
-
- var err error
- issue.Created, err = time.Parse(timestamp, iaux.Created)
- if err != nil {
- return fmt.Errorf("created time: %w", err)
- }
- issue.Updated, err = time.Parse(timestamp, iaux.Updated)
- if err != nil {
- return fmt.Errorf("updated time: %w", err)
- }
-
- if bb, ok := iaux.Comment["comments"]; ok {
- if err := json.Unmarshal(bb, &issue.Comments); err != nil {
- return fmt.Errorf("unmarshal comments: %w", err)
- }
- }
- return nil
-}
diff --git a/jira/print.go b/jira/print.go
@@ -1,62 +0,0 @@
-package main
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-func printIssues(issues []Issue) string {
- buf := &strings.Builder{}
- for _, ii := range issues {
- name := strings.TrimPrefix(ii.Key, ii.Project.Key+"-")
- fmt.Fprintf(buf, "%s/\t%s\n", name, ii.Summary)
- }
- return buf.String()
-}
-
-func printIssue(i *Issue) string {
- buf := &strings.Builder{}
- fmt.Fprintln(buf, "From:", i.Reporter.Name)
- fmt.Fprintln(buf, "URL:", i.URL)
- fmt.Fprintln(buf, "Date", i.Updated.Format(time.RFC1123Z))
- fmt.Fprintln(buf, "Subject:", i.Summary)
- fmt.Fprintln(buf)
-
- fmt.Fprintln(buf, i.Description)
- 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.Name)
- fmt.Fprintln(buf, "Date:", date)
- fmt.Fprintln(buf)
- fmt.Fprintln(buf, 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 + "..."
-}