issues

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

commit 4f7a6856e8d1f7e96a76eb2854e8dc7eff9f9b6d
parent 2fe99c0f775659ffbbd6683ed755964934a8300f
Author: Oliver Lowe <o@olowe.co>
Date:   Thu,  3 Oct 2024 10:39:19 +1000

jira: aeroplane hacking

Diffstat:
AJira/acme.go | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AJira/doc.go | 26++++++++++++++++++++++++++
AJira/fake.go | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rjira/fileinfo.go -> Jira/fileinfo.go | 0
AJira/fs.go | 343+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AJira/fs_test.go | 42++++++++++++++++++++++++++++++++++++++++++
AJira/http.go | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AJira/http_test.go | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AJira/jira.go | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rjira/jira_test.go -> Jira/jira_test.go | 0
AJira/print.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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
MLICENSE | 36+++++++++++-------------------------
MREADME.md | 4+++-
CLICENSE -> issue/LICENSE | 0
Djira/acme.go | 159-------------------------------------------------------------------------------
Djira/fs.go | 346-------------------------------------------------------------------------------
Djira/fs_test.go | 44--------------------------------------------
Djira/http.go | 154-------------------------------------------------------------------------------
Djira/http_test.go | 111-------------------------------------------------------------------------------
Djira/jira.go | 115-------------------------------------------------------------------------------
Djira/print.go | 62--------------------------------------------------------------
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 + "..." -}