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