commit - 4f7a6856e8d1f7e96a76eb2854e8dc7eff9f9b6d
commit + 4d0a5834991f4debd135280b03d29f26735457c0
blob - 5f847009d1cec60ffb47873b705c95ee783962d3
blob + ebe4369672e9aef95dc2e4b1ed23a552f83e7e53
--- Jira/acme.go
+++ Jira/acme.go
"io"
"io/fs"
"log"
+ "net/url"
"os"
"path"
+ "regexp"
"strings"
"9fans.net/go/acme"
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
}
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()+"/")
}
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
"fmt"
"io"
"io/fs"
- "log"
+ "os"
"path"
"strings"
"time"
)
type FS struct {
- apiRoot string
- root *fid
+ client *Client
+ root *fid
}
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
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
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)
}
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}
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)
}
}
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")
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
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
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 {
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
}
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
"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)
}
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
}
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)
}
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
}
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
}
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
}
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 {
}
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
import (
"io"
"net/http"
+ "net/url"
"path"
"testing"
"testing/fstest"
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)
}
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
URL string
Key string
Reporter User
+ Assignee User
Summary string
Status struct {
Name string `json:"name"`
Created time.Time
Updated time.Time
Comments []Comment
+ Subtasks []Issue
}
type Project struct {
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
}
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
import (
"encoding/json"
+ "fmt"
"os"
"testing"
)
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)
}
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
import (
"fmt"
+ "net/url"
+ "path"
"strings"
"time"
)
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
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()
}