commit 438e3045216521b085564bac4c1a287a9079c26f from: Oliver Lowe date: Fri Jan 10 01:31:15 2025 UTC Add commands to get jira from the command line commit - 45f610f681a1e110dc2a2a27ffea9c721ec62968 commit + 438e3045216521b085564bac4c1a287a9079c26f blob - 51e41e9049d329dcc4e20a18cdf3ef47f43dc787 (mode 644) blob + /dev/null --- Jira/acme.go +++ /dev/null @@ -1,288 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "flag" - "fmt" - "io" - "io/fs" - "log" - "net/url" - "os" - "path" - "regexp" - "strings" - - "9fans.net/go/acme" -) - -func init() { - log.SetFlags(0) - log.SetPrefix("Jira: ") -} - -type awin struct { - *acme.Win - fsys fs.FS -} - -func (w *awin) name() string { - b, err := w.ReadAll("tag") - if err != nil { - w.Err(err.Error()) - return "" - } - fields := strings.Fields(string(b)) - return strings.TrimPrefix(fields[0], "/jira/") -} - -var issueKeyExp = regexp.MustCompile("[A-Z]+-[0-9]+") - -func (w *awin) Look(text string) bool { - text = strings.TrimSpace(text) - - var pathname string - if issueKeyExp.MatchString(text) { - proj, num, _ := strings.Cut(text, "-") - pathname = path.Join(proj, num, "issue") - } else { - pathname = path.Join(path.Dir(w.name()), text) - if strings.HasSuffix(text, "/") { - pathname = path.Join(w.name(), text) - } - } - f, err := w.fsys.Open(pathname) - if errors.Is(err, fs.ErrNotExist) { - return false - } else if err != nil { - w.Err(err.Error()) - return false - } - - win, err := acme.New() - if err != nil { - w.Err(err.Error()) - return true - } - wname := path.Join("/jira", pathname) - if d, ok := f.(fs.DirEntry); ok { - if d.IsDir() { - wname += "/" - } - } else { - stat, err := f.Stat() - if err != nil { - w.Err(err.Error()) - return true - } - if stat.IsDir() { - wname += "/" - } - } - win.Name(wname) - if path.Base(pathname) == "issue" { - win.Fprintf("tag", "Comment ") - } - ww := &awin{win, w.fsys} - go ww.EventLoop(ww) - go func() { - if err := ww.Get(f); err != nil { - w.Err(err.Error()) - } - ww.Addr("#0") - ww.Ctl("dot=addr") - ww.Ctl("show") - }() - return true -} - -func (w *awin) Execute(cmd string) bool { - fields := strings.Fields(strings.TrimSpace(cmd)) - switch fields[0] { - case "Get": - if err := w.Get(nil); err != nil { - w.Err(err.Error()) - } - return true - case "Search": - if len(fields) == 1 { - return false - } - query := strings.Join(fields[1:], " ") - go newSearch(w.fsys, query) - return true - case "Comment": - if len(fields) > 1 { - return false - } - win, err := acme.New() - if err != nil { - w.Err(err.Error()) - return false - } - dname := path.Dir(w.name()) - win.Name(path.Join("/jira", dname, "new")) - win.Fprintf("tag", "Post ") - a := &awin{win, w.fsys} - go a.EventLoop(a) - return true - case "Post": - if err := w.postComment(); err != nil { - w.Errf("post comment: %s", err.Error()) - } - w.Del(true) - return true - } - return false -} - -func (w *awin) Get(f fs.File) error { - defer w.Ctl("clean") - fname := path.Clean(w.name()) - if fname == "/" { - fname = "." // special name for the root file in io/fs - } - if f == nil { - var err error - f, err = w.fsys.Open(fname) - if err != nil { - return err - } - } - stat, err := f.Stat() - if err != nil { - return err - } - - defer f.Close() - if stat.IsDir() { - dirs, err := f.(fs.ReadDirFile).ReadDir(-1) - if err != nil { - return err - } - buf := &strings.Builder{} - for _, d := range dirs { - if d.IsDir() { - fmt.Fprintln(buf, d.Name()+"/") - continue - } - fmt.Fprintln(buf, d.Name()) - } - w.Clear() - w.PrintTabbed(buf.String()) - return nil - } - b, err := io.ReadAll(f) - if err != nil { - return fmt.Errorf("read %s: %w", stat.Name(), err) - } - w.Clear() - if _, err := w.Write("body", b); err != nil { - return fmt.Errorf("write body: %w", err) - } - return nil -} - -func (w *awin) postComment() error { - defer w.Ctl("clean") - body, err := w.ReadAll("body") - if err != nil { - return fmt.Errorf("read body: %w", err) - } - f, ok := w.fsys.(*FS) - if !ok { - return fmt.Errorf("cannot write comment with filesystem type %T", w.fsys) - } - elems := strings.Split(w.name(), "/") - ikey := fmt.Sprintf("%s-%s", elems[0], elems[1]) - return f.client.PostComment(ikey, bytes.NewReader(body)) -} - -func newSearch(fsys fs.FS, query string) { - win, err := acme.New() - if err != nil { - acme.Errf("new window: %v", err.Error()) - return - } - defer win.Ctl("clean") - win.Name("/jira/search") - f, ok := fsys.(*FS) - if !ok { - win.Errf("cannot search with filesystem type %T", fsys) - } - win.PrintTabbed("Search " + query + "\n\n") - issues, err := f.client.SearchIssues(query) - if err != nil { - win.Errf("search %q: %v", query, err) - return - } - win.PrintTabbed(printIssues(issues)) - w := &awin{win, fsys} - go w.EventLoop(w) -} - -const usage string = "usage: Jira [keyfile]" - -func readCreds(name string) (username, password string, err error) { - b, err := os.ReadFile(name) - if err != nil { - return "", "", err - } - u, p, found := strings.Cut(strings.TrimSpace(string(b)), ":") - if !found { - return "", "", fmt.Errorf("missing userpass field separator %q", ":") - } - return u, p, nil -} - -var hostFlag = flag.String("h", "jira.atlassian.com", "") - -func main() { - flag.Parse() - home, err := os.UserHomeDir() - if err != nil { - log.Fatalf("find user config dir: %v", err) - } - credPath := path.Join(home, ".config/atlassian/jira") - if len(flag.Args()) == 1 { - credPath = flag.Args()[0] - } else if len(flag.Args()) > 2 { - fmt.Fprintln(os.Stderr, usage) - os.Exit(2) - } - user, pass, err := readCreds(credPath) - if err != nil { - log.Fatalf("read credentials: %v", err) - } - - // srv := newFakeServer("testdata") - // defer srv.Close() - - u, err := url.Parse("https://" + *hostFlag + "/rest/api/2") - if err != nil { - log.Fatalf("parse api root url: %v", err) - } - fsys := &FS{ - client: &Client{ - debug: false, - apiRoot: u, - username: user, - password: pass, - }, - } - - acme.AutoExit(true) - win, err := acme.New() - if err != nil { - log.Fatal(err) - } - win.Name("/jira/") - root := &awin{win, fsys} - root.Get(nil) - root.Addr("#0") - root.Ctl("dot=addr") - root.Ctl("show") - win.Ctl("clean") - root.EventLoop(root) -} blob - 448376cd698d232e4ccedf18eb5f1f03bd9d844e (mode 644) blob + /dev/null --- Jira/doc.go +++ /dev/null @@ -1,26 +0,0 @@ -/* -Jira is a program to interact with Jira issues from the Acme editor. -Projects, issues and comments are presented as a virtual read-only filesystem -(using package [io/fs]) -which can be browsed in the usual way Acme handles filesystems served -by the host system. - -The filesystem root holds project directories. -Within each project are the project's issues, one directory entry per issue. -The filepaths for issues TEST-1, TEST-2, and WEB-27 would be: - - TEST/1 - TEST/2 - WEB/27 - -Each issue directory has a file named "issue" -holding a textual representation of the issue and a listing of comments. -For example, TEST/1/issue. - -Comments are available as numbered files alongside the issue file. -Comment 69 of issue TEST-420 can be accessed at TEST/420/69. - -https:developer.atlassian.com/cloud/jira/platform/rest/v2/ -https:jira.atlassian.com/rest/api/2/issue/JRA-9 -*/ -package main blob - 082eff3fee08ddf352518bc38463ff3ae5a67d23 (mode 644) blob + /dev/null --- Jira/fake.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "errors" - "fmt" - "io" - "io/fs" - "log" - "net/http" - "net/http/httptest" - "os" - "path" -) - -// newFakeServer returns a fake JIRA server which serves projects, -// issues, and comments from the filesystem tree rooted at root. -// For an example tree, see the testdata directory. -// -// The server provides a limited read-only subset of the JIRA HTTP API -// intended for testing API clients. -// All search requests return a list of every issue, even if the JQL query is invalid. -// Paginated responses are not supported. -func newFakeServer(root string) *httptest.Server { - mux := http.NewServeMux() - mux.HandleFunc("/project", serveJSONList(path.Join(root, "project"))) - mux.HandleFunc("/search", serveJSONList(path.Join(root, "issue"))) - mux.HandleFunc("/issue", serveJSONList(path.Join(root, "issue"))) - mux.HandleFunc("/issue/", handleIssues(root)) - mux.Handle("/", http.FileServer(http.Dir(root))) - return httptest.NewServer(mux) -} - -func serveJSONList(dir string) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - prefix := "[" - if path.Base(dir) == "issue" { - prefix = `{"issues": [` - } - dirs, err := os.ReadDir(dir) - if errors.Is(err, fs.ErrNotExist) { - http.NotFound(w, req) - return - } else if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - fmt.Fprintln(w, prefix) - for i, d := range dirs { - f, err := os.Open(path.Join(dir, d.Name())) - if err != nil { - log.Println(err) - return - } - if _, err := io.Copy(w, f); err != nil { - log.Printf("copy %s: %v", f.Name(), err) - } - f.Close() - if i == len(dirs)-1 { - break - } - fmt.Fprintln(w, ",") - } - fmt.Fprintln(w, "]}") - } -} - -func handleIssues(dir string) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - if match, _ := path.Match("/issue/*/comment/*", req.URL.Path); match { - // ignore error; we know pattern is ok. - file := path.Base(req.URL.Path) - http.ServeFile(w, req, path.Join(dir, "comment", file)) - return - } - http.FileServerFS(os.DirFS(dir)).ServeHTTP(w, req) - } -} blob - 392894548760bc2cc46ea7f1e8c014a30882d6d0 (mode 644) blob + /dev/null --- Jira/fileinfo.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "io/fs" - "strings" - "time" -) - -func (is *Issue) Name() string { - _, number, found := strings.Cut(is.Key, "-") - if !found { - return is.Key - } - return number -} - -func (is *Issue) Size() int64 { return int64(len(printIssue(is))) } -func (is *Issue) Mode() fs.FileMode { return 0o444 | fs.ModeDir } -func (is *Issue) ModTime() time.Time { return is.Updated } -func (is *Issue) IsDir() bool { return is.Mode().IsDir() } -func (is *Issue) Sys() any { return nil } - -func (c *Comment) Name() string { return c.ID } -func (c *Comment) Size() int64 { return int64(len(printComment(c))) } -func (c *Comment) Mode() fs.FileMode { return 0o444 } -func (c *Comment) ModTime() time.Time { return c.Updated } -func (c *Comment) IsDir() bool { return c.Mode().IsDir() } -func (c *Comment) Sys() any { return nil } - -func (p *Project) Name() string { return p.Key } -func (p *Project) Size() int64 { return -1 } -func (p *Project) Mode() fs.FileMode { return 0o444 | fs.ModeDir } -func (p *Project) ModTime() time.Time { return time.Time{} } -func (p *Project) IsDir() bool { return p.Mode().IsDir() } -func (p *Project) Sys() any { return nil } - -type stat struct { - name string - size int64 - mode fs.FileMode - mtime time.Time -} - -func (s stat) Name() string { return s.name } -func (s stat) Size() int64 { return s.size } -func (s stat) Mode() fs.FileMode { return s.mode } -func (s stat) ModTime() time.Time { return s.mtime } -func (s stat) IsDir() bool { return s.Mode().IsDir() } -func (s stat) Sys() any { return nil } blob - 3373c54419e45908db4065222ec9a8cdf92ca650 (mode 644) blob + /dev/null --- Jira/fs.go +++ /dev/null @@ -1,346 +0,0 @@ -package main - -import ( - "fmt" - "io" - "io/fs" - "os" - "path" - "strings" - "time" -) - -type FS struct { - client *Client - root *fid -} - -const ( - ftypeRoot int = iota - ftypeProject - ftypeIssue - ftypeIssueDir - ftypeComment -) - -type fid struct { - *Client - name string - typ int - rd io.Reader - parent *fid - - // May be set but only as an optimisation to skip a Stat(). - stat fs.FileInfo - - // directories only - children []fs.DirEntry - dirp int -} - -func (f *fid) Name() string { return f.name } -func (f *fid) IsDir() bool { return f.Type().IsDir() } - -func (f *fid) Type() fs.FileMode { - switch f.typ { - case ftypeRoot, ftypeProject, ftypeIssueDir: - return fs.ModeDir - } - return 0 -} - -func (f *fid) Info() (fs.FileInfo, error) { return f.Stat() } - -func (f *fid) Stat() (fs.FileInfo, error) { - if f.Client.debug { - fmt.Fprintln(os.Stderr, "stat", f.Name()) - } - if f.stat != nil { - return f.stat, nil - } - - switch f.typ { - case ftypeRoot: - return &stat{".", int64(len(f.children)), 0o444 | fs.ModeDir, time.Time{}}, nil - case ftypeProject: - p, err := f.Project(f.name) - if err != nil { - return nil, &fs.PathError{"stat", f.name, err} - } - return p, nil - case ftypeIssueDir, ftypeIssue: - is, err := f.Issue(f.issueKey()) - if err != nil { - return nil, &fs.PathError{"stat", f.name, err} - } - if f.typ == ftypeIssueDir { - f.children = issueChildren(f, is) - return is, nil - } - // optimisation: we might read the file soon so load the contents. - f.rd = strings.NewReader(printIssue(is)) - return &stat{f.name, int64(len(printIssue(is))), 0o444, is.Updated}, nil - case ftypeComment: - c, err := f.Comment(f.issueKey(), f.name) - if err != nil { - return nil, &fs.PathError{"stat", f.name, err} - } - // optimisation: we might read the file soon so load the contents. - f.rd = strings.NewReader(printComment(c)) - return c, nil - } - err := fmt.Errorf("unexpected fid type %d", f.typ) - return nil, &fs.PathError{"stat", f.name, err} -} - -func (f *fid) Read(p []byte) (n int, err error) { - if f.Client.debug { - fmt.Fprintln(os.Stderr, "read", f.Name()) - } - if f.rd == nil { - switch f.typ { - case ftypeComment: - c, err := f.Comment(f.issueKey(), f.name) - if err != nil { - err = fmt.Errorf("get comment %s: %w", f.issueKey(), err) - return 0, &fs.PathError{"read", f.name, err} - } - f.rd = strings.NewReader(printComment(c)) - case ftypeIssue: - is, err := f.Issue(f.issueKey()) - if err != nil { - err = fmt.Errorf("get issue %s: %w", f.issueKey(), err) - return 0, &fs.PathError{"read", f.name, err} - } - f.rd = strings.NewReader(printIssue(is)) - default: - var err error - if f.children == nil { - f.children, err = f.ReadDir(-1) - if err != nil { - return 0, &fs.PathError{"read", f.name, err} - } - } - buf := &strings.Builder{} - for _, d := range f.children { - fmt.Fprintln(buf, fs.FormatDirEntry(d)) - } - f.rd = strings.NewReader(buf.String()) - } - } - return f.rd.Read(p) -} - -func (f *fid) Close() error { - f.rd = nil - f.stat = nil - return nil -} - -func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) { - if f.Client.debug { - fmt.Fprintln(os.Stderr, "readdir", f.Name()) - } - if !f.IsDir() { - return nil, fmt.Errorf("not a directory") - } - if f.children == nil { - switch f.typ { - case ftypeRoot: - return nil, fmt.Errorf("root initialised incorrectly: no dir entries") - case ftypeProject: - issues, err := f.Issues(f.name) - if err != nil { - return nil, fmt.Errorf("get issues: %w", err) - } - f.children = make([]fs.DirEntry, len(issues)) - for i, issue := range issues { - f.children[i] = &fid{ - Client: f.Client, - name: issue.Name(), - typ: ftypeIssueDir, - parent: f, - } - } - case ftypeIssueDir: - issue, err := f.Issue(f.issueKey()) - if err != nil { - return nil, fmt.Errorf("get issue %s: %w", f.name, err) - } - f.children = issueChildren(f, issue) - } - } - - if f.dirp >= len(f.children) { - if n <= 0 { - return nil, nil - } - return nil, io.EOF - } - if n <= 0 { - f.dirp = len(f.children) - return f.children, nil - } - - var err error - d := f.children[f.dirp:] - if len(d) >= n { - d = d[:n] - } else if len(d) <= n { - err = io.EOF - } - f.dirp += n - return d, err -} - -func issueChildren(parent *fid, is *Issue) []fs.DirEntry { - kids := make([]fs.DirEntry, len(is.Comments)+1) - for i, c := range is.Comments { - kids[i] = &fid{ - Client: parent.Client, - name: c.ID, - typ: ftypeComment, - rd: strings.NewReader(printComment(&c)), - parent: parent, - stat: &c, - } - } - kids[len(kids)-1] = &fid{ - name: "issue", - Client: parent.Client, - typ: ftypeIssue, - rd: strings.NewReader(is.Summary), - parent: parent, - stat: &stat{"issue", int64(len(printIssue(is))), 0o444, is.Updated}, - } - return kids -} - -func (f *fid) issueKey() string { - // to make the issue key e.g. "EXAMPLE-42" - // we need the name of the issue (parent name, "42") - // and the name of the project (the issue's parent's name, "EXAMPLE") - var project, issueNumber string - switch f.typ { - default: - return "" - case ftypeComment, ftypeIssue: - project = f.parent.parent.name - issueNumber = f.parent.name - case ftypeIssueDir: - project = f.parent.name - issueNumber = f.name - } - return project + "-" + issueNumber -} - -func (fsys *FS) Open(name string) (fs.File, error) { - if !fs.ValidPath(name) { - return nil, &fs.PathError{"open", name, fs.ErrInvalid} - } - name = path.Clean(name) - if strings.Contains(name, "\\") { - return nil, fs.ErrNotExist - } - - if fsys.root == nil { - var err error - fsys.root, err = makeRoot(fsys.client) - if err != nil { - return nil, fmt.Errorf("make root file: %w", err) - } - } - if fsys.client.debug { - fmt.Fprintln(os.Stderr, "open", name) - } - - if name == "." { - f := *fsys.root - return &f, nil - } - - elems := strings.Split(name, "/") - if elems[0] == "." && len(elems) > 1 { - elems = elems[1:] - } - - f := fsys.root - for _, elem := range elems { - dir, err := find(f, elem) - if err != nil { - return nil, &fs.PathError{"open", name, err} - } - f = dir - } - g := *f - return &g, nil -} - -func makeRoot(client *Client) (*fid, error) { - projects, err := client.Projects() - if err != nil { - return nil, err - } - root := &fid{ - Client: client, - name: ".", - typ: ftypeRoot, - children: make([]fs.DirEntry, len(projects)), - } - for i, p := range projects { - root.children[i] = &fid{ - Client: client, - name: p.Key, - typ: ftypeProject, - } - } - return root, nil -} - -func find(dir *fid, name string) (*fid, error) { - if !dir.IsDir() { - return nil, fs.ErrNotExist - } - child := &fid{Client: dir.Client, parent: dir} - switch dir.typ { - case ftypeRoot: - for _, d := range dir.children { - if d.Name() == name { - child, ok := d.(*fid) - if !ok { - return nil, fmt.Errorf("unexpected dir entry type %T", d) - } - return child, nil - } - } - return nil, fs.ErrNotExist - case ftypeProject: - key := fmt.Sprintf("%s-%s", dir.name, name) - ok, err := dir.CheckIssue(key) - if err != nil { - return nil, err - } - if !ok { - return nil, fs.ErrNotExist - } - child.name = name - child.typ = ftypeIssueDir - return child, nil - case ftypeIssueDir: - if name == "issue" { - child.name = name - child.typ = ftypeIssue - return child, nil - } - ok, err := dir.checkComment(dir.issueKey(), name) - if err != nil { - return nil, err - } else if !ok { - return nil, fs.ErrNotExist - } - child.name = name - child.typ = ftypeComment - return child, nil - } - return nil, fs.ErrNotExist -} blob - 229b9aa02f40c9244943922f279aa08774d31d81 (mode 644) blob + /dev/null --- Jira/fs_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package main - -import ( - "encoding/json" - "io/fs" - "os" - "testing" -) - -func TestIssueName(t *testing.T) { - f, err := os.Open("testdata/issue/TEST-1") - if err != nil { - t.Fatal(err) - } - defer f.Close() - var issue Issue - if err := json.NewDecoder(f).Decode(&issue); err != nil { - t.Fatal(err) - } - want := "1" - if issue.Name() != want { - t.Errorf("issue.Name() = %q, want %q", issue.Name(), want) - } -} - -func TestIssueKey(t *testing.T) { - comment := &fid{name: "69", typ: ftypeComment} - issue := &fid{name: "issue", typ: ftypeIssue} - issueDir := &fid{name: "1", typ: ftypeIssueDir, children: []fs.DirEntry{issue, comment}} - project := &fid{name: "TEST", typ: ftypeProject, children: []fs.DirEntry{issueDir}} - - comment.parent = issueDir - issue.parent = issueDir - issueDir.parent = project - - want := "TEST-1" - for _, f := range []*fid{comment, issue, issueDir} { - if f.issueKey() != want { - t.Errorf("fid %s issueKey = %q, want %q", f.name, f.issueKey(), want) - } - } -} blob - 6da5f7e1c86a298071c9ab46ff50ed7525f4b0a6 (mode 644) blob + /dev/null --- Jira/http.go +++ /dev/null @@ -1,241 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" -) - -type Client struct { - *http.Client - debug bool - username, password string - apiRoot *url.URL -} - -func (c *Client) Projects() ([]Project, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "project") - resp, err := c.get(u.String()) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-ok status: %s", resp.Status) - } - defer resp.Body.Close() - var p []Project - if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { - return nil, fmt.Errorf("decode project: %w", err) - } - return p, nil -} - -func (c *Client) Project(name string) (*Project, error) { - u := fmt.Sprintf("%s/project/%s", c.apiRoot, name) - resp, err := c.get(u) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-ok status: %s", resp.Status) - } - defer resp.Body.Close() - var p Project - if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { - return nil, fmt.Errorf("decode project: %w", err) - } - return &p, nil -} - -func (c *Client) Issues(project string) ([]Issue, error) { - q := fmt.Sprintf("project = %q", project) - return c.SearchIssues(q) -} - -func (c *Client) SearchIssues(query string) ([]Issue, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "search") - q := make(url.Values) - q.Add("jql", query) - u.RawQuery = q.Encode() - resp, err := c.get(u.String()) - if err != nil { - return nil, err - } - if resp.StatusCode == http.StatusBadRequest { - return nil, fmt.Errorf("bad query") - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-ok status: %s", resp.Status) - } - t := struct { - Issues []Issue - }{} - if err := json.NewDecoder(resp.Body).Decode(&t); err != nil { - return nil, fmt.Errorf("decode issues: %w", err) - } - return t.Issues, nil -} - -func (c *Client) CheckIssue(name string) (bool, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "issue", name) - req, err := http.NewRequest(http.MethodHead, u.String(), nil) - if err != nil { - return false, err - } - resp, err := c.do(req) - if err != nil { - return false, err - } - if resp.StatusCode == http.StatusOK { - return true, nil - } - return false, nil -} - -func (c *Client) Issue(name string) (*Issue, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "issue", name) - resp, err := c.get(u.String()) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-ok status: %s", resp.Status) - } - defer resp.Body.Close() - var is Issue - if err := json.NewDecoder(resp.Body).Decode(&is); err != nil { - return nil, fmt.Errorf("decode issue: %w", err) - } - return &is, nil -} - -func (c *Client) checkComment(ikey, id string) (bool, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "issue", ikey, "comment", id) - req, err := http.NewRequest(http.MethodHead, u.String(), nil) - if err != nil { - return false, err - } - resp, err := c.do(req) - if err != nil { - return false, err - } - if resp.StatusCode == http.StatusOK { - return true, nil - } - return false, nil -} - -func (c *Client) Comment(ikey, id string) (*Comment, error) { - u := *c.apiRoot - u.Path = path.Join(u.Path, "issue", ikey, "comment", id) - resp, err := c.get(u.String()) - if err != nil { - return nil, err - } - defer resp.Body.Close() - var com Comment - if err := json.NewDecoder(resp.Body).Decode(&com); err != nil { - return nil, fmt.Errorf("decode comment: %w", err) - } - return &com, nil -} - -func (c *Client) PostComment(issueKey string, body io.Reader) error { - b, err := io.ReadAll(body) - if err != nil { - return fmt.Errorf("read body: %w", err) - } - cm := Comment{Body: string(b)} - hbody, err := json.Marshal(&cm) - if err != nil { - return fmt.Errorf("to json: %w", err) - } - u := fmt.Sprintf("%s/issue/%s/comment", c.apiRoot, issueKey) - req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(hbody)) - if err != nil { - return err - } - req.Header.Add("Content-Type", "application/json") - resp, err := c.do(req) - if err != nil { - return err - } - if resp.StatusCode >= http.StatusBadRequest { - return fmt.Errorf("non-ok status: %s", resp.Status) - } - return nil - -} - -func Create(apiRoot string, issue Issue) (*Issue, error) { - b, err := json.Marshal(&issue) - if err != nil { - return nil, fmt.Errorf("to json: %w", err) - } - u := apiRoot + "/issue" - resp, err := http.Post(u, "application/json", bytes.NewReader(b)) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-ok status %s", resp.Status) - } - var i Issue - if err := json.NewDecoder(resp.Body).Decode(&i); err != nil { - return nil, fmt.Errorf("decode created issue: %w", err) - } - return &i, nil -} - -func CreateComment(apiRoot, issueKey string, body io.Reader) error { - b, err := io.ReadAll(body) - if err != nil { - return fmt.Errorf("read body: %w", err) - } - c := Comment{Body: string(b)} - hbody, err := json.Marshal(&c) - if err != nil { - return fmt.Errorf("to json: %w", err) - } - u := fmt.Sprintf("%s/issue/%s/comment", apiRoot, issueKey) - resp, err := http.Post(u, "application/json", bytes.NewReader(hbody)) - if err != nil { - return err - } - if resp.StatusCode >= http.StatusBadRequest { - return fmt.Errorf("non-ok status: %s", resp.Status) - } - return nil -} - -func (c *Client) get(url string) (*http.Response, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return nil, err - } - return c.do(req) -} - -func (c *Client) do(req *http.Request) (*http.Response, error) { - if c.Client == nil { - c.Client = http.DefaultClient - } - if c.username != "" && c.password != "" { - req.SetBasicAuth(c.username, c.password) - } - if c.debug { - fmt.Fprintln(os.Stderr, req.Method, req.URL) - } - return c.Do(req) -} blob - b26a6877fd34f772e184733902ee038377bab5e7 (mode 644) blob + /dev/null --- Jira/http_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "io" - "net/http" - "net/url" - "path" - "testing" - "testing/fstest" -) - -func handleComment(w http.ResponseWriter, req *http.Request) { - id := path.Base(req.URL.Path) - http.ServeFile(w, req, "testdata/comment/"+id) -} - -func TestGet(t *testing.T) { - srv := newFakeServer("testdata") - defer srv.Close() - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatal(err) - } - client := &Client{apiRoot: u} - - project := "TEST" - issue := "TEST-1" - comment := "69" - if _, err := client.Project(project); err != nil { - t.Fatalf("get project %s: %v", project, err) - } - if _, err := client.Issues(project); err != nil { - t.Fatalf("get %s issues: %v", project, err) - } - if _, err := client.Issue(issue); err != nil { - t.Fatalf("get issue %s: %v", issue, err) - } - c, err := client.Comment(issue, comment) - if err != nil { - t.Fatalf("get comment %s from %s: %v", comment, issue, err) - } - if c.ID != comment { - t.Fatalf("wanted comment id %s, got %s", comment, c.ID) - } - - fsys := &FS{client: client} - f, err := fsys.Open("TEST/1/69") - if err != nil { - t.Fatal(err) - } - if _, err := f.Stat(); err != nil { - t.Fatal(err) - } - if _, err := io.Copy(io.Discard, f); err != nil { - t.Fatal(err) - } - f.Close() - - expected := []string{ - "TEST", - "TEST/1", - "TEST/1/issue", - "TEST/1/69", - } - if err := fstest.TestFS(fsys, expected...); err != nil { - t.Error(err) - } -} blob - 9f2ca6d5ec44a9f54ca4f17c2e40776f3e915c97 (mode 644) blob + /dev/null --- Jira/jira.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "time" -) - -const timestamp = "2006-01-02T15:04:05.999-0700" - -type Issue struct { - ID string // TODO(otl): int? - URL string - Key string - Reporter User - Assignee User - Summary string - Status struct { - Name string `json:"name"` - } `json:"status"` - Description string - Project Project - Created time.Time - Updated time.Time - Comments []Comment - Links []Issue - Subtasks []Issue -} - -type Project struct { - ID string `json:"id"` // TODO(otl): int? - // Name string `json:"name"` - Key string `json:"key"` - URL string `json:"self"` -} - -type Comment struct { - ID string `json:"id"` // TODO(otl): int? - URL string `json:"self"` - Body string `json:"body"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` - Author User `json:"author"` - UpdateAuthor User `json:"updateAuthor"` -} - -func (c *Comment) UnmarshalJSON(b []byte) error { - type alias Comment - aux := &struct { - Created string `json:"created"` - Updated string `json:"updated"` - *alias - }{ - alias: (*alias)(c), - } - if err := json.Unmarshal(b, aux); err != nil { - return err - } - var err error - c.Created, err = time.Parse(timestamp, aux.Created) - if err != nil { - return fmt.Errorf("parse created time: %w", err) - } - c.Updated, err = time.Parse(timestamp, aux.Updated) - if err != nil { - return fmt.Errorf("parse updated time: %w", err) - } - return nil -} - -type User struct { - Name string `json:"name"` - Email string `json:"emailAddress"` - DisplayName string `json:"displayName"` -} - -func (u User) String() string { - if u.DisplayName == "" { - return u.Email - } - return fmt.Sprintf("%s <%s>", u.DisplayName, u.Email) -} - -func (issue *Issue) UnmarshalJSON(b []byte) error { - aux := &struct { - ID string - Self string - Key string - Fields json.RawMessage - }{} - if err := json.Unmarshal(b, aux); err != nil { - return err - } - issue.ID = aux.ID - issue.URL = aux.Self - issue.Key = aux.Key - - type alias Issue - iaux := &struct { - Created string - Updated string - Comment map[string]json.RawMessage - IssueLinks []struct { - InwardIssue *Issue - OutwardIssue *Issue - } - *alias - }{ - alias: (*alias)(issue), - } - if err := json.Unmarshal(aux.Fields, iaux); err != nil { - return err - } - - var err error - if iaux.Created != "" { - issue.Created, err = time.Parse(timestamp, iaux.Created) - if err != nil { - return fmt.Errorf("created time: %w", err) - } - } - if iaux.Updated != "" { - issue.Updated, err = time.Parse(timestamp, iaux.Updated) - if err != nil { - return fmt.Errorf("updated time: %w", err) - } - } - if bb, ok := iaux.Comment["comments"]; ok { - if err := json.Unmarshal(bb, &issue.Comments); err != nil { - return fmt.Errorf("unmarshal comments: %w", err) - } - } - for _, l := range iaux.IssueLinks { - if l.InwardIssue != nil { - issue.Links = append(issue.Links, *l.InwardIssue) - } - if l.OutwardIssue != nil { - issue.Links = append(issue.Links, *l.OutwardIssue) - } - } - return nil -} blob - 9d7a88427bd487c8a301a98162ef2b36961e2ee8 (mode 644) blob + /dev/null --- Jira/jira_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "testing" -) - -func TestDecode(t *testing.T) { - dents, err := os.ReadDir("testdata/issue") - if err != nil { - t.Fatal(err) - } - for _, d := range dents { - f, err := os.Open("testdata/issue/" + d.Name()) - if err != nil { - t.Fatal(err) - } - var i Issue - if err := json.NewDecoder(f).Decode(&i); err != nil { - t.Errorf("decode %s: %v", f.Name(), err) - } - f.Close() - } -} - -/* -func TestSubtasks(t *testing.T) { - f, err := os.Open("testdata/subtasks") - if err != nil { - t.Fatal(err) - } - defer f.Close() - var is Issue - if err := json.NewDecoder(f).Decode(&is); err != nil { - t.Fatal(err) - } - fmt.Println(is.Subtasks) -} -*/ blob - 2883317a3ad95736195ab1c971d64ba2af462f85 (mode 644) blob + /dev/null --- Jira/print.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "fmt" - "net/url" - "path" - "strings" - "time" -) - -func printIssues(issues []Issue) string { - buf := &strings.Builder{} - for _, ii := range issues { - name := strings.Replace(ii.Key, "-", "/", 1) - fmt.Fprintf(buf, "%s/issue\t%s\n", name, ii.Summary) - } - return buf.String() -} - -func printIssue(i *Issue) string { - buf := &strings.Builder{} - fmt.Fprintln(buf, "From:", i.Reporter) - fmt.Fprintln(buf, "Date:", i.Created.Format(time.RFC1123Z)) - if i.Assignee.String() != "" { - fmt.Fprintln(buf, "Assignee:", i.Assignee) - } - if u, err := url.Parse(i.URL); err == nil { - u.Path = path.Join("browse", i.Key) - fmt.Fprintf(buf, "Archived-At: <%s>\n", u) - } - fmt.Fprintf(buf, "Archived-At: <%s>\n", i.URL) - fmt.Fprintln(buf, "Status:", i.Status.Name) - if len(i.Links) > 0 { - s := make([]string, len(i.Links)) - for j := range i.Links { - s[j] = i.Links[j].Key - } - fmt.Fprintln(buf, "References:", strings.Join(s, ", ")) - } - if len(i.Subtasks) > 0 { - s := make([]string, len(i.Subtasks)) - for j := range i.Subtasks { - s[j] = i.Subtasks[j].Key - } - fmt.Fprintln(buf, "Subtasks:", strings.Join(s, ", ")) - } - fmt.Fprintln(buf, "Subject:", i.Summary) - fmt.Fprintln(buf) - - if i.Description != "" { - fmt.Fprintln(buf, strings.ReplaceAll(i.Description, "\r", "")) - } - if len(i.Comments) == 0 { - return buf.String() - } - fmt.Fprintln(buf) - for _, c := range i.Comments { - date := c.Created - if !c.Updated.IsZero() { - date = c.Updated - } - fmt.Fprintf(buf, "%s\t%s\t%s (%s)\n", c.ID, summarise(c.Body, 36), c.Author.Name, date.Format(time.DateTime)) - } - return buf.String() -} - -func printComment(c *Comment) string { - buf := &strings.Builder{} - date := c.Created - if !c.Updated.IsZero() { - date = c.Updated - } - fmt.Fprintln(buf, "From:", c.Author) - fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z)) - fmt.Fprintln(buf) - fmt.Fprintln(buf, strings.TrimSpace(c.Body)) - return buf.String() -} - -func summarise(body string, length int) string { - if len(body) < length { - body = strings.ReplaceAll(body, "\n", " ") - return strings.TrimSpace(body) - } - body = body[:length] - body = strings.ReplaceAll(body, "\r", "") - body = strings.ReplaceAll(body, "\n", " ") - body = strings.TrimSpace(body) - body = strings.ReplaceAll(body, " ", " ") - return body + "..." -} blob - 30254dd4d27efe5f9765de36b1994068c770b813 (mode 644) blob + /dev/null --- Jira/project.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "expand": "description,lead,issueTypes,url,projectKeys", - "self": "http://www.example.com/jira/rest/api/2/project/EX", - "id": "10000", - "key": "EX", - "description": "This project was created as an example for REST.", - "lead": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "components": [ - { - "self": "http://www.example.com/jira/rest/api/2/component/10000", - "id": "10000", - "name": "Component 1", - "description": "This is a JIRA component", - "lead": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F.User", - "active": false - }, - "assigneeType": "PROJECT_LEAD", - "assignee": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "realAssigneeType": "PROJECT_LEAD", - "realAssignee": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "isAssigneeTypeValid": false, - "project": "HSP", - "projectId": 10000 - } - ], - "issueTypes": [ - { - "self": "http://localhost:8090/jira/rest/api/2.0/issueType/3", - "id": "3", - "description": "A task that needs to be done.", - "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/task.png", - "name": "Task", - "subtask": false, - "avatarId": 1 - }, - { - "self": "http://localhost:8090/jira/rest/api/2.0/issueType/1", - "id": "1", - "description": "A problem with the software.", - "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/bug.png", - "name": "Bug", - "subtask": false, - "avatarId": 10002 - } - ], - "url": "http://www.example.com/jira/browse/EX", - "email": "from-jira@example.com", - "assigneeType": "PROJECT_LEAD", - "versions": [], - "name": "Example", - "roles": { - "Developers": "http://www.example.com/jira/rest/api/2/project/EX/role/10000" - }, - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", - "24x24": "http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", - "16x16": "http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", - "32x32": "http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" - }, - "projectCategory": { - "self": "http://www.example.com/jira/rest/api/2/projectCategory/10000", - "id": "10000", - "name": "FIRST", - "description": "First Project Category" - } -} blob - afa177739515fe9b83c8948093f5d7596b0ef6f5 (mode 644) blob + /dev/null --- Jira/testdata/comment/69 +++ /dev/null @@ -1,35 +0,0 @@ -{ - "self": "https://jira.atlassian.com/rest/api/2/issue/10148/comment/17800", - "id": "69", - "author": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", - "name": "owen@atlassian.com", - "key": "owen@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" - }, - "displayName": "Owen Fellows", - "active": true, - "timeZone": "UTC" - }, - "body": "This is due to users not having a timezone set, see linked Issue.", - "updateAuthor": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", - "name": "owen@atlassian.com", - "key": "owen@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" - }, - "displayName": "Owen Fellows", - "active": true, - "timeZone": "UTC" - }, - "created": "2003-11-17T01:55:10.760+0000", - "updated": "2003-11-17T01:55:10.760+0000" -} blob - 72af386aea83edb5619ec766b813cdd8b9929e9a (mode 644) blob + /dev/null --- Jira/testdata/issue/TEST-1 +++ /dev/null @@ -1,877 +0,0 @@ -{ - "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", - "id": "10148", - "self": "https://jira.atlassian.com/rest/api/latest/issue/10148", - "key": "TEST-1", - "fields": { - "fixVersions": [ - { - "self": "https://jira.atlassian.com/rest/api/2/version/15918", - "id": "15918", - "description": "", - "name": "4.4", - "archived": false, - "released": true, - "releaseDate": "2011-08-02" - } - ], - "resolution": { - "self": "https://jira.atlassian.com/rest/api/2/resolution/1", - "id": "1", - "description": "A fix for this issue is checked into the tree and tested.", - "name": "Fixed" - }, - "labels": [ - "affects-cloud", - "affects-server" - ], - "aggregatetimeoriginalestimate": 2160000, - "timeestimate": 2160000, - "issuelinks": [ - { - "id": "50308", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/50308", - "type": { - "id": "10020", - "name": "Blocker", - "inward": "is blocked by", - "outward": "blocks", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10020" - }, - "outwardIssue": { - "id": "126823", - "key": "JSWSERVER-2855", - "self": "https://jira.atlassian.com/rest/api/2/issue/126823", - "fields": { - "summary": "GreenHopper Hourly Burndown Gadget - Update Every 15 Minutes or as Scheduled", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "37511", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/37511", - "type": { - "id": "10050", - "name": "Cause", - "inward": "causes", - "outward": "is caused by", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10050" - }, - "inwardIssue": { - "id": "85626", - "key": "JRASERVER-17359", - "self": "https://jira.atlassian.com/rest/api/2/issue/85626", - "fields": { - "summary": "Add timezone support to JQL dates.", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "28680", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/28680", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "42259", - "key": "JRASERVER-11253", - "self": "https://jira.atlassian.com/rest/api/2/issue/42259", - "fields": { - "summary": "Date stamp does not compensate for time zone differences", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "priority": { - "self": "https://jira.atlassian.com/rest/api/2/priority/4", - "iconUrl": "https://jira.atlassian.com/images/icons/priorities/low.svg", - "name": "Low", - "id": "4" - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/1", - "id": "1", - "description": "A problem which impairs or prevents the functions of the product.", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51493&avatarType=issuetype", - "name": "Bug", - "subtask": false, - "avatarId": 51493 - } - } - } - }, - { - "id": "10942", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/10942", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "15168", - "key": "JRASERVER-2677", - "self": "https://jira.atlassian.com/rest/api/2/issue/15168", - "fields": { - "summary": "time on jira.atlassian.com?", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "11779", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/11779", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "16922", - "key": "JRASERVER-3316", - "self": "https://jira.atlassian.com/rest/api/2/issue/16922", - "fields": { - "summary": "Created/Updated times are shown in server time zone, not the client's one", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "12982", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/12982", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "22426", - "key": "JRASERVER-5539", - "self": "https://jira.atlassian.com/rest/api/2/issue/22426", - "fields": { - "summary": "TimeZone preference", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "22245", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/22245", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "46356", - "key": "JRASERVER-11930", - "self": "https://jira.atlassian.com/rest/api/2/issue/46356", - "fields": { - "summary": "Display Time in Current User TZ", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "31127", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/31127", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "70205", - "key": "JRASERVER-15149", - "self": "https://jira.atlassian.com/rest/api/2/issue/70205", - "fields": { - "summary": "Date/Times to reflect local time zone", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "32100", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/32100", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "73554", - "key": "JRASERVER-15528", - "self": "https://jira.atlassian.com/rest/api/2/issue/73554", - "fields": { - "summary": "JIRA enterprise does not honour system TZ nor seems to provide option to override, all stamps are and keep on being 1h out of sync after migration to other JIRA instance", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "41981", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/41981", - "type": { - "id": "10001", - "name": "Duplicate", - "inward": "is duplicated by", - "outward": "duplicates", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" - }, - "inwardIssue": { - "id": "104230", - "key": "JRASERVER-20802", - "self": "https://jira.atlassian.com/rest/api/2/issue/104230", - "fields": { - "summary": "User Profiles > Time Zones - the ability to select a time zone per user profile", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "11606", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/11606", - "type": { - "id": "10000", - "name": "Reference", - "inward": "is related to", - "outward": "relates to", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" - }, - "outwardIssue": { - "id": "13031", - "key": "JRASERVER-1519", - "self": "https://jira.atlassian.com/rest/api/2/issue/13031", - "fields": { - "summary": "Allow arbitrary fields (company, phone#, etc) in user profile", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/11772", - "description": "This suggestion needs more unique domain votes and comments before being reviewed by our team.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", - "name": "Gathering Interest", - "id": "11772", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", - "id": 2, - "key": "new", - "colorName": "default", - "name": "To Do" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "27174", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/27174", - "type": { - "id": "10000", - "name": "Reference", - "inward": "is related to", - "outward": "relates to", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" - }, - "outwardIssue": { - "id": "39309", - "key": "JRASERVER-10613", - "self": "https://jira.atlassian.com/rest/api/2/issue/39309", - "fields": { - "summary": "Capability to customize date/time format per user", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/11772", - "description": "This suggestion needs more unique domain votes and comments before being reviewed by our team.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", - "name": "Gathering Interest", - "id": "11772", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", - "id": 2, - "key": "new", - "colorName": "default", - "name": "To Do" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - }, - { - "id": "71195", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/71195", - "type": { - "id": "10000", - "name": "Reference", - "inward": "is related to", - "outward": "relates to", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" - }, - "inwardIssue": { - "id": "192343", - "key": "JRASERVER-28939", - "self": "https://jira.atlassian.com/rest/api/2/issue/192343", - "fields": { - "summary": "Add time-zone support for rendering date fields", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/12072", - "description": "This issue has been reviewed, but needs more supporting information to gauge how pervasive the problem is.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", - "name": "Gathering Impact", - "id": "12072", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", - "id": 2, - "key": "new", - "colorName": "default", - "name": "To Do" - } - }, - "priority": { - "self": "https://jira.atlassian.com/rest/api/2/priority/4", - "iconUrl": "https://jira.atlassian.com/images/icons/priorities/low.svg", - "name": "Low", - "id": "4" - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/1", - "id": "1", - "description": "A problem which impairs or prevents the functions of the product.", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51493&avatarType=issuetype", - "name": "Bug", - "subtask": false, - "avatarId": 51493 - } - } - } - }, - { - "id": "57958", - "self": "https://jira.atlassian.com/rest/api/2/issueLink/57958", - "type": { - "id": "10000", - "name": "Reference", - "inward": "is related to", - "outward": "relates to", - "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" - }, - "inwardIssue": { - "id": "144072", - "key": "JRASERVER-25303", - "self": "https://jira.atlassian.com/rest/api/2/issue/144072", - "fields": { - "summary": "Administer Timezone settings", - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - } - } - } - } - ], - "assignee": null, - "status": { - "self": "https://jira.atlassian.com/rest/api/2/status/6", - "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", - "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", - "name": "Closed", - "id": "6", - "statusCategory": { - "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", - "id": 3, - "key": "done", - "colorName": "success", - "name": "Done" - } - }, - "components": [ - { - "self": "https://jira.atlassian.com/rest/api/2/component/10125", - "id": "10125", - "name": "User Management - Others" - } - ], - "archiveddate": null, - "aggregatetimeestimate": 2160000, - "creator": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=mike%40atlassian.com", - "name": "mike@atlassian.com", - "key": "mike@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=32" - }, - "displayName": "Mike Cannon-Brookes", - "active": true, - "timeZone": "Australia/Sydney" - }, - "subtasks": [], - "reporter": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=mike%40atlassian.com", - "name": "mike@atlassian.com", - "key": "mike@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=32" - }, - "displayName": "Mike Cannon-Brookes", - "active": true, - "timeZone": "Australia/Sydney" - }, - "aggregateprogress": { - "progress": 1500, - "total": 2161500, - "percent": 0 - }, - "progress": { - "progress": 1500, - "total": 2161500, - "percent": 0 - }, - "votes": { - "self": "https://jira.atlassian.com/rest/api/2/issue/JRASERVER-9/votes", - "votes": 454, - "hasVoted": false - }, - "worklog": { - "startAt": 0, - "maxResults": 20, - "total": 1, - "worklogs": [ - { - "self": "https://jira.atlassian.com/rest/api/2/issue/10148/worklog/96804", - "author": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=tim%40atlassian.com", - "name": "tim@atlassian.com", - "key": "tim@atlassian.com", - "avatarUrls": { - "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=tim%40atlassian.com&avatarId=2465661", - "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=tim%40atlassian.com&avatarId=2465661", - "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=tim%40atlassian.com&avatarId=2465661", - "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=tim%40atlassian.com&avatarId=2465661" - }, - "displayName": "TimP", - "active": true, - "timeZone": "America/Los_Angeles" - }, - "updateAuthor": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=tim%40atlassian.com", - "name": "tim@atlassian.com", - "key": "tim@atlassian.com", - "avatarUrls": { - "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=tim%40atlassian.com&avatarId=2465661", - "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=tim%40atlassian.com&avatarId=2465661", - "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=tim%40atlassian.com&avatarId=2465661", - "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=tim%40atlassian.com&avatarId=2465661" - }, - "displayName": "TimP", - "active": true, - "timeZone": "America/Los_Angeles" - }, - "comment": "Time submitted by matt for review CR-2", - "created": "2011-05-20T05:55:22.952+0000", - "updated": "2011-05-20T05:55:22.952+0000", - "started": "2011-05-20T05:55:18.528+0000", - "timeSpent": "25m", - "timeSpentSeconds": 1500, - "id": "96804", - "issueId": "10148" - } - ] - }, - "archivedby": null, - "issuetype": { - "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", - "id": "10000", - "description": "", - "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", - "name": "Suggestion", - "subtask": false, - "avatarId": 51505 - }, - "timespent": 1500, - "project": { - "self": "https://jira.atlassian.com/rest/api/2/project/10240", - "id": "10240", - "key": "JRASERVER", - "name": "Jira Data Center", - "projectTypeKey": "software", - "avatarUrls": { - "48x48": "https://jira.atlassian.com/secure/projectavatar?pid=10240&avatarId=105190", - "24x24": "https://jira.atlassian.com/secure/projectavatar?size=small&pid=10240&avatarId=105190", - "16x16": "https://jira.atlassian.com/secure/projectavatar?size=xsmall&pid=10240&avatarId=105190", - "32x32": "https://jira.atlassian.com/secure/projectavatar?size=medium&pid=10240&avatarId=105190" - }, - "projectCategory": { - "self": "https://jira.atlassian.com/rest/api/2/projectCategory/10031", - "id": "10031", - "description": "", - "name": "Atlassian Products" - } - }, - "aggregatetimespent": 1500, - "resolutiondate": "2011-06-07T15:31:26.782+0000", - "workratio": 0, - "watches": { - "self": "https://jira.atlassian.com/rest/api/2/issue/JRASERVER-9/watchers", - "watchCount": 213, - "isWatching": false - }, - "created": "2002-02-08T05:08:00.000+0000", - "updated": "2022-05-18T18:35:49.533+0000", - "timeoriginalestimate": 2160000, - "description": "Add time zones to user profile. That way the dates displayed to a user are always contiguous with their local time zone, rather than the server's time zone.", - "timetracking": { - "originalEstimate": "600h", - "remainingEstimate": "600h", - "timeSpent": "25m", - "originalEstimateSeconds": 2160000, - "remainingEstimateSeconds": 2160000, - "timeSpentSeconds": 1500 - }, - "attachment": [ - { - "self": "https://jira.atlassian.com/rest/api/2/attachment/45565", - "id": "45565", - "filename": "log_110314_115720______.csv", - "created": "2011-03-26T15:38:11.072+0000", - "size": 24576, - "mimeType": "text/csv", - "content": "https://jira.atlassian.com/secure/attachment/45565/log_110314_115720______.csv" - }, - { - "self": "https://jira.atlassian.com/rest/api/2/attachment/40875", - "id": "40875", - "filename": "times.png", - "author": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=rkrishna", - "name": "rkrishna", - "key": "rkrishna", - "avatarUrls": { - "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=rkrishna&avatarId=2478834", - "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=rkrishna&avatarId=2478834", - "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=rkrishna&avatarId=2478834", - "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=rkrishna&avatarId=2478834" - }, - "displayName": "Roy Krishna", - "active": false, - "timeZone": "Australia/Sydney" - }, - "created": "2010-09-21T05:31:32.198+0000", - "size": 39226, - "mimeType": "image/png", - "content": "https://jira.atlassian.com/secure/attachment/40875/times.png", - "thumbnail": "https://jira.atlassian.com/secure/thumbnail/40875/_thumb_40875.png" - } - ], - "summary": "User Preference: User Time Zones", - "environment": null, - "duedate": null, - "comment": { - "comments": [ - { - "self": "https://jira.atlassian.com/rest/api/2/issue/10148/comment/17800", - "id": "69", - "author": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", - "name": "owen@atlassian.com", - "key": "owen@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" - }, - "displayName": "Owen Fellows", - "active": true, - "timeZone": "UTC" - }, - "body": "This is due to users not having a timezone set, see linked Issue.", - "updateAuthor": { - "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", - "name": "owen@atlassian.com", - "key": "owen@atlassian.com", - "avatarUrls": { - "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", - "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", - "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", - "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" - }, - "displayName": "Owen Fellows", - "active": true, - "timeZone": "UTC" - }, - "created": "2003-11-17T01:55:10.760+0000", - "updated": "2003-11-17T01:55:10.760+0000" - } - ], - "maxResults": 143, - "total": 143, - "startAt": 0 - } - } -} blob - b7a01c69a5f5e79a2d8b2c6395e5df33c2a157e2 (mode 644) blob + /dev/null --- Jira/testdata/project/TEST +++ /dev/null @@ -1,116 +0,0 @@ -{ - "expand": "description,lead,issueTypes,url,projectKeys", - "self": "http://www.example.com/jira/rest/api/2/project/EX", - "id": "10000", - "key": "TEST", - "description": "This project was created as an example for REST.", - "lead": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "components": [ - { - "self": "http://www.example.com/jira/rest/api/2/component/10000", - "id": "10000", - "name": "Component 1", - "description": "This is a JIRA component", - "lead": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F.User", - "active": false - }, - "assigneeType": "PROJECT_LEAD", - "assignee": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "realAssigneeType": "PROJECT_LEAD", - "realAssignee": { - "self": "http://www.example.com/jira/rest/api/2/user?username=fred", - "key": "fred", - "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", - "name": "fred", - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", - "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", - "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", - "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" - }, - "displayName": "Fred F. User", - "active": false - }, - "isAssigneeTypeValid": false, - "project": "HSP", - "projectId": 10000 - } - ], - "issueTypes": [ - { - "self": "http://localhost:8090/jira/rest/api/2.0/issueType/3", - "id": "3", - "description": "A task that needs to be done.", - "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/task.png", - "name": "Task", - "subtask": false, - "avatarId": 1 - }, - { - "self": "http://localhost:8090/jira/rest/api/2.0/issueType/1", - "id": "1", - "description": "A problem with the software.", - "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/bug.png", - "name": "Bug", - "subtask": false, - "avatarId": 10002 - } - ], - "url": "http://www.example.com/jira/browse/EX", - "email": "from-jira@example.com", - "assigneeType": "PROJECT_LEAD", - "versions": [], - "name": "Example", - "roles": { - "Developers": "http://www.example.com/jira/rest/api/2/project/EX/role/10000" - }, - "avatarUrls": { - "48x48": "http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", - "24x24": "http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", - "16x16": "http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", - "32x32": "http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" - }, - "projectCategory": { - "self": "http://www.example.com/jira/rest/api/2/projectCategory/10000", - "id": "10000", - "name": "FIRST", - "description": "First Project Category" - } -} blob - 9a6334d9be3ea7bb0b68832223e9105365ebf1bd blob + 6d2557b1fec60dd74f42ad2eedf2706a4753e476 --- README.md +++ README.md @@ -1,11 +1,16 @@ -This repo holds programs for interacting with issue trackers from the [Acme] editor. +This repo holds programs for interacting with issue trackers from the [Acme] editor +and via a command line. See the documentation for the commands: - [issue] - [Gitlab] - [Jira] +- [jiraexport] +- [jiraq] [Acme]: https://p9f.org/sys/doc/acme/acme.html [issue]: https://pkg.go.dev/olowe.co/issues/issue [Gitlab]: https://pkg.go.dev/olowe.co/issues/Gitlab -[Jira]: https://pkg.go.dev/olowe.co/issues/Jira +[Jira]: https://pkg.go.dev/olowe.co/issues/cmd/Jira +[jiraexport]: https://pkg.go.dev/olowe.co/issues/cmd/jiraexport +[jiraq]: https://pkg.go.dev/olowe.co/issues/cmd/jiraq blob - /dev/null blob + defa8f4ac66fc7ab6e793dffdccdf927198e404d (mode 644) --- /dev/null +++ cmd/Jira/Jira.go @@ -0,0 +1,298 @@ +package jira + +import ( + "bytes" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/url" + "os" + "path" + "regexp" + "strings" + + "9fans.net/go/acme" + "olowe.co/issues/jira" +) + +func init() { + log.SetFlags(0) + log.SetPrefix("Jira: ") +} + +type awin struct { + *acme.Win + fsys fs.FS +} + +func (w *awin) name() string { + b, err := w.ReadAll("tag") + if err != nil { + w.Err(err.Error()) + return "" + } + fields := strings.Fields(string(b)) + return strings.TrimPrefix(fields[0], "/jira/") +} + +var issueKeyExp = regexp.MustCompile("[A-Z]+-[0-9]+") + +func (w *awin) Look(text string) bool { + text = strings.TrimSpace(text) + + var pathname string + if issueKeyExp.MatchString(text) { + proj, num, _ := strings.Cut(text, "-") + pathname = path.Join(proj, num, "issue") + } else { + pathname = path.Join(path.Dir(w.name()), text) + if strings.HasSuffix(text, "/") { + pathname = path.Join(w.name(), text) + } + } + f, err := w.fsys.Open(pathname) + if errors.Is(err, fs.ErrNotExist) { + return false + } else if err != nil { + w.Err(err.Error()) + return false + } + + win, err := acme.New() + if err != nil { + w.Err(err.Error()) + return true + } + wname := path.Join("/jira", pathname) + if d, ok := f.(fs.DirEntry); ok { + if d.IsDir() { + wname += "/" + } + } else { + stat, err := f.Stat() + if err != nil { + w.Err(err.Error()) + return true + } + if stat.IsDir() { + wname += "/" + } + } + win.Name(wname) + if path.Base(pathname) == "issue" { + win.Fprintf("tag", "Comment ") + } + ww := &awin{win, w.fsys} + go ww.EventLoop(ww) + go func() { + if err := ww.Get(f); err != nil { + w.Err(err.Error()) + } + ww.Addr("#0") + ww.Ctl("dot=addr") + ww.Ctl("show") + }() + return true +} + +func (w *awin) Execute(cmd string) bool { + fields := strings.Fields(strings.TrimSpace(cmd)) + switch fields[0] { + case "Get": + if err := w.Get(nil); err != nil { + w.Err(err.Error()) + } + return true + case "Search": + if len(fields) == 1 { + return false + } + query := strings.Join(fields[1:], " ") + go newSearch(w.fsys, query) + return true + case "Comment": + if len(fields) > 1 { + return false + } + win, err := acme.New() + if err != nil { + w.Err(err.Error()) + return false + } + dname := path.Dir(w.name()) + win.Name(path.Join("/jira", dname, "new")) + win.Fprintf("tag", "Post ") + a := &awin{win, w.fsys} + go a.EventLoop(a) + return true + case "Post": + if err := w.postComment(); err != nil { + w.Errf("post comment: %s", err.Error()) + } + w.Del(true) + return true + } + return false +} + +func (w *awin) Get(f fs.File) error { + defer w.Ctl("clean") + fname := path.Clean(w.name()) + if fname == "/" { + fname = "." // special name for the root file in io/fs + } + if f == nil { + var err error + f, err = w.fsys.Open(fname) + if err != nil { + return err + } + } + stat, err := f.Stat() + if err != nil { + return err + } + + defer f.Close() + if stat.IsDir() { + dirs, err := f.(fs.ReadDirFile).ReadDir(-1) + if err != nil { + return err + } + buf := &strings.Builder{} + for _, d := range dirs { + if d.IsDir() { + fmt.Fprintln(buf, d.Name()+"/") + continue + } + fmt.Fprintln(buf, d.Name()) + } + w.Clear() + w.PrintTabbed(buf.String()) + return nil + } + b, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("read %s: %w", stat.Name(), err) + } + w.Clear() + if _, err := w.Write("body", b); err != nil { + return fmt.Errorf("write body: %w", err) + } + return nil +} + +func (w *awin) postComment() error { + defer w.Ctl("clean") + body, err := w.ReadAll("body") + if err != nil { + return fmt.Errorf("read body: %w", err) + } + f, ok := w.fsys.(*jira.FS) + if !ok { + return fmt.Errorf("cannot write comment with filesystem type %T", w.fsys) + } + elems := strings.Split(w.name(), "/") + ikey := fmt.Sprintf("%s-%s", elems[0], elems[1]) + return f.Client.PostComment(ikey, bytes.NewReader(body)) +} + +func newSearch(fsys fs.FS, query string) { + win, err := acme.New() + if err != nil { + acme.Errf("new window: %v", err.Error()) + return + } + defer win.Ctl("clean") + win.Name("/jira/search") + f, ok := fsys.(*jira.FS) + if !ok { + win.Errf("cannot search with filesystem type %T", fsys) + } + win.PrintTabbed("Search " + query + "\n\n") + issues, err := f.Client.SearchIssues(query) + if err != nil { + win.Errf("search %q: %v", query, err) + return + } + win.PrintTabbed(printIssues(issues)) + w := &awin{win, fsys} + go w.EventLoop(w) +} + +func printIssues(issues []jira.Issue) string { + buf := &strings.Builder{} + for _, ii := range issues { + name := strings.Replace(ii.Key, "-", "/", 1) + fmt.Fprintf(buf, "%s/issue\t%s\n", name, ii.Summary) + } + return buf.String() +} + +const usage string = "usage: Jira [keyfile]" + +func readCreds(name string) (username, password string, err error) { + b, err := os.ReadFile(name) + if err != nil { + return "", "", err + } + u, p, found := strings.Cut(strings.TrimSpace(string(b)), ":") + if !found { + return "", "", fmt.Errorf("missing userpass field separator %q", ":") + } + return u, p, nil +} + +var hostFlag = flag.String("h", "jira.atlassian.com", "") + +func main() { + flag.Parse() + home, err := os.UserHomeDir() + if err != nil { + log.Fatalf("find user config dir: %v", err) + } + credPath := path.Join(home, ".config/atlassian/jira") + if len(flag.Args()) == 1 { + credPath = flag.Args()[0] + } else if len(flag.Args()) > 2 { + fmt.Fprintln(os.Stderr, usage) + os.Exit(2) + } + user, pass, err := readCreds(credPath) + if err != nil { + log.Fatalf("read credentials: %v", err) + } + + // srv := newFakeServer("testdata") + // defer srv.Close() + + u, err := url.Parse("https://" + *hostFlag + "/rest/api/3") + if err != nil { + log.Fatalf("parse api root url: %v", err) + } + fsys := &jira.FS{ + Client: &jira.Client{ + Debug: false, + APIRoot: u, + Username: user, + Password: pass, + }, + } + + acme.AutoExit(true) + win, err := acme.New() + if err != nil { + log.Fatal(err) + } + win.Name("/jira/") + root := &awin{win, fsys} + root.Get(nil) + root.Addr("#0") + root.Ctl("dot=addr") + root.Ctl("show") + win.Ctl("clean") + root.EventLoop(root) +} blob - /dev/null blob + aa6995ef1121bf8982368fc0b0917c12feab532c (mode 644) --- /dev/null +++ cmd/jiraexport/jiraexport.go @@ -0,0 +1,159 @@ +// Command jiraexport prints the named Jira issues, and their comments, +// RFC 5322 mail message format (email). +// +// Usage: +// jiraexport [ -d duration ] issue [ ... ] +// +// The options are: +// +// -d duration +// Exclude any issues and comments unmodified since duration. +// Duration may be given in the format accepted by time.ParseDuration. +// For example, 24h (24 hours). The default is 7 days. +// +// # Example +// +// Print the last day's updates to tickets SRE-1234 and SRE-5678: +// +// jiraexport -d 24h SRE-1234 SRE-5678 +// +package main + +import ( + "bytes" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/mail" + "net/url" + "os" + "path" + "strings" + "time" + + "olowe.co/issues/jira" +) + +func readJiraAuth() (user, pass string, err error) { + confDir, err := os.UserConfigDir() + if err != nil { + return "", "", err + } + b, err := os.ReadFile(path.Join(confDir, "atlassian/jira")) + if err != nil { + return "", "", err + } + b = bytes.TrimSpace(b) + u, p, ok := strings.Cut(string(b), ":") + if !ok { + return "", "", fmt.Errorf(`missing ":" between username and password`) + } + return u, p, nil +} + +const usage string = "jiraexport [-d duration] [-u url] issue [...]" + +var since = flag.Duration("d", 7*24*time.Hour, "exclude activity older than this duration") +var apiRoot = flag.String("u", "http://[::1]:8080", "base URL for the JIRA API") + +func init() { + log.SetFlags(0) + log.SetPrefix("") + flag.Parse() +} + +func main() { + if len(flag.Args()) == 0 { + log.Fatal(usage) + } + + user, pass, err := readJiraAuth() + if err != nil { + log.Fatalf("read jira auth credentials: %v", err) + } + u, err := url.Parse(*apiRoot) + if err != nil { + log.Fatalln("parse api url:", err) + } + u.Path = path.Join(u.Path, "rest/api/2") + jclient := &jira.Client{ + APIRoot: u, + Username: user, + Password: pass, + Debug: false, + } + fsys := &jira.FS{Client: jclient} + + for _, arg := range flag.Args() { + proj, num, ok := strings.Cut(arg, "-") + if !ok { + log.Println("bad issue name: missing - separator") + continue + } + dir := path.Join(proj, num) + f, err := fsys.Open(path.Join(dir, "issue")) + if err != nil { + log.Println(err) + continue + } + info, err := f.Stat() + if err != nil { + f.Close() + log.Println(err) + continue + } + if time.Since(info.ModTime()) >= *since { + f.Close() + continue + } + msg, err := mail.ReadMessage(f) + if err != nil { + f.Close() + log.Println(err) + continue + } + // fmt.Println("From nobody", info.ModTime().Format(time.ANSIC)) + fmt.Println("Subject:", msg.Header.Get("Subject")) + /* + if _, err := io.Copy(os.Stdout, f); err != nil { + f.Close() + log.Println(err) + continue + } + */ + fmt.Println() + f.Close() + + dents, err := fs.ReadDir(fsys, dir) + if err != nil { + log.Println(err) + continue + } + for _, d := range dents { + if d.Name() == "issue" { + continue // already done + } + info, err := d.Info() + if err != nil { + log.Println(err) + continue + } + if time.Since(info.ModTime()) >= *since { + continue + } + f, err := fsys.Open(path.Join(dir, d.Name())) + if err != nil { + log.Println(err) + continue + } + fmt.Println("From nobody", info.ModTime().Format(time.ANSIC)) + if _, err := io.Copy(os.Stdout, f); err != nil { + log.Println(err) + } + f.Close() + fmt.Println() + } + } +} blob - /dev/null blob + c0cf2f7316e4f8104c39eaa0c3a0d25b1b134670 (mode 644) --- /dev/null +++ cmd/jiraq/jiraq.go @@ -0,0 +1,99 @@ +// Command jiraq lists Jira issues matching the provided Jira query. +// Queries must be provided as a single quoted argument in JQL format, +// such as "project = EXAMPLE and status = Done". +// +// Its usage is: +// +// jiraq [ -u url ] query +// +// The flags are: +// +// -u url +// The URL pointing to the root of the JIRA REST API. +// +// +// # Examples +// +// Print an overview of all open tickets in the project "SRE": +// +// jiraq -u https://company.example.net 'project = SRE and status != done' +// +// Subsequent examples omit the "-u" flag for brevity. +// List all open tickets assigned to yourself in the project "SRE": +// +// jiraq 'project = SRE and status != done and assignee = currentuser()' +// +// Print issues updated since yesterday: +// +// query='project = SRE and status != done and updated >= -24h' +// jiraexport `jiraq "$query" | awk '{print $1}'` +package main + +import ( + "bytes" + "flag" + "fmt" + "log" + "net/url" + "os" + "path" + "strings" + + "olowe.co/issues/jira" +) + +func readJiraAuth() (user, pass string, err error) { + confDir, err := os.UserConfigDir() + if err != nil { + return "", "", err + } + b, err := os.ReadFile(path.Join(confDir, "atlassian/jira")) + if err != nil { + return "", "", err + } + b = bytes.TrimSpace(b) + u, p, ok := strings.Cut(string(b), ":") + if !ok { + return "", "", fmt.Errorf(`missing ":" between username and password`) + } + return u, p, nil +} + +var apiRoot = flag.String("u", "http://[::1]:8080", "base URL for the JIRA API") + +const usage = "usage: jiraq [-u url] query" + +func init() { + log.SetPrefix("jiraq: ") + log.SetFlags(0) + flag.Parse() +} + +func main() { + if len(os.Args) == 1 { + log.Fatal(usage) + } + + user, pass, err := readJiraAuth() + if err != nil { + log.Fatalf("read jira auth credentials: %v", err) + } + u, err := url.Parse(*apiRoot) + if err != nil { + log.Fatalln("parse api url:", err) + } + u.Path = path.Join(u.Path, "rest/api/2") + client := &jira.Client{ + APIRoot: u, + Username: user, + Password: pass, + } + + issues, err := client.SearchIssues(strings.Join(flag.Args(), " ")) + if err != nil { + log.Fatal(err) + } + for _, is := range issues { + fmt.Printf("%s-%s\t%s\n", is.Project.Name(), is.Name(), is.Summary) + } +} blob - /dev/null blob + 6b95e28795fc9e3e8f2bb4621dcd97a290462062 (mode 644) --- /dev/null +++ jira/doc.go @@ -0,0 +1,26 @@ +/* +Jira is a program to interact with Jira issues from the Acme editor. +Projects, issues and comments are presented as a virtual read-only filesystem +(using package [io/fs]) +which can be browsed in the usual way Acme handles filesystems served +by the host system. + +The filesystem root holds project directories. +Within each project are the project's issues, one directory entry per issue. +The filepaths for issues TEST-1, TEST-2, and WEB-27 would be: + + TEST/1 + TEST/2 + WEB/27 + +Each issue directory has a file named "issue" +holding a textual representation of the issue and a listing of comments. +For example, TEST/1/issue. + +Comments are available as numbered files alongside the issue file. +Comment 69 of issue TEST-420 can be accessed at TEST/420/69. + +https:developer.atlassian.com/cloud/jira/platform/rest/v2/ +https:jira.atlassian.com/rest/api/2/issue/JRA-9 +*/ +package jira blob - /dev/null blob + a4ad0cece6e03850de18798a6c5c00ac7e0d6caf (mode 644) --- /dev/null +++ jira/fake.go @@ -0,0 +1,77 @@ +package jira + +import ( + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "net/http/httptest" + "os" + "path" +) + +// newFakeServer returns a fake JIRA server which serves projects, +// issues, and comments from the filesystem tree rooted at root. +// For an example tree, see the testdata directory. +// +// The server provides a limited read-only subset of the JIRA HTTP API +// intended for testing API clients. +// All search requests return a list of every issue, even if the JQL query is invalid. +// Paginated responses are not supported. +func newFakeServer(root string) *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/project", serveJSONList(path.Join(root, "project"))) + mux.HandleFunc("/search", serveJSONList(path.Join(root, "issue"))) + mux.HandleFunc("/issue", serveJSONList(path.Join(root, "issue"))) + mux.HandleFunc("/issue/", handleIssues(root)) + mux.Handle("/", http.FileServer(http.Dir(root))) + return httptest.NewServer(mux) +} + +func serveJSONList(dir string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + prefix := "[" + if path.Base(dir) == "issue" { + prefix = `{"issues": [` + } + dirs, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + http.NotFound(w, req) + return + } else if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintln(w, prefix) + for i, d := range dirs { + f, err := os.Open(path.Join(dir, d.Name())) + if err != nil { + log.Println(err) + return + } + if _, err := io.Copy(w, f); err != nil { + log.Printf("copy %s: %v", f.Name(), err) + } + f.Close() + if i == len(dirs)-1 { + break + } + fmt.Fprintln(w, ",") + } + fmt.Fprintln(w, "]}") + } +} + +func handleIssues(dir string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if match, _ := path.Match("/issue/*/comment/*", req.URL.Path); match { + // ignore error; we know pattern is ok. + file := path.Base(req.URL.Path) + http.ServeFile(w, req, path.Join(dir, "comment", file)) + return + } + http.FileServerFS(os.DirFS(dir)).ServeHTTP(w, req) + } +} blob - /dev/null blob + e9fd41ba5c289341a73e4e2eb080fe34d358ca47 (mode 644) --- /dev/null +++ jira/fileinfo.go @@ -0,0 +1,49 @@ +package jira + +import ( + "io/fs" + "strings" + "time" +) + +func (is *Issue) Name() string { + _, number, found := strings.Cut(is.Key, "-") + if !found { + return is.Key + } + return number +} + +func (is *Issue) Size() int64 { return int64(len(printIssue(is))) } +func (is *Issue) Mode() fs.FileMode { return 0o444 | fs.ModeDir } +func (is *Issue) ModTime() time.Time { return is.Updated } +func (is *Issue) IsDir() bool { return is.Mode().IsDir() } +func (is *Issue) Sys() any { return nil } + +func (c *Comment) Name() string { return c.ID } +func (c *Comment) Size() int64 { return int64(len(printComment(c))) } +func (c *Comment) Mode() fs.FileMode { return 0o444 } +func (c *Comment) ModTime() time.Time { return c.Updated } +func (c *Comment) IsDir() bool { return c.Mode().IsDir() } +func (c *Comment) Sys() any { return nil } + +func (p *Project) Name() string { return p.Key } +func (p *Project) Size() int64 { return -1 } +func (p *Project) Mode() fs.FileMode { return 0o444 | fs.ModeDir } +func (p *Project) ModTime() time.Time { return time.Time{} } +func (p *Project) IsDir() bool { return p.Mode().IsDir() } +func (p *Project) Sys() any { return nil } + +type stat struct { + name string + size int64 + mode fs.FileMode + mtime time.Time +} + +func (s stat) Name() string { return s.name } +func (s stat) Size() int64 { return s.size } +func (s stat) Mode() fs.FileMode { return s.mode } +func (s stat) ModTime() time.Time { return s.mtime } +func (s stat) IsDir() bool { return s.Mode().IsDir() } +func (s stat) Sys() any { return nil } blob - /dev/null blob + 3a531ebc795d1e6953d190f8568f80d575231fb7 (mode 644) --- /dev/null +++ jira/fs.go @@ -0,0 +1,346 @@ +package jira + +import ( + "fmt" + "io" + "io/fs" + "os" + "path" + "strings" + "time" +) + +type FS struct { + Client *Client + root *fid +} + +const ( + ftypeRoot int = iota + ftypeProject + ftypeIssue + ftypeIssueDir + ftypeComment +) + +type fid struct { + *Client + name string + typ int + rd io.Reader + parent *fid + + // May be set but only as an optimisation to skip a Stat(). + stat fs.FileInfo + + // directories only + children []fs.DirEntry + dirp int +} + +func (f *fid) Name() string { return f.name } +func (f *fid) IsDir() bool { return f.Type().IsDir() } + +func (f *fid) Type() fs.FileMode { + switch f.typ { + case ftypeRoot, ftypeProject, ftypeIssueDir: + return fs.ModeDir + } + return 0 +} + +func (f *fid) Info() (fs.FileInfo, error) { return f.Stat() } + +func (f *fid) Stat() (fs.FileInfo, error) { + if f.Client.Debug { + fmt.Fprintln(os.Stderr, "stat", f.Name()) + } + if f.stat != nil { + return f.stat, nil + } + + switch f.typ { + case ftypeRoot: + return &stat{".", int64(len(f.children)), 0o444 | fs.ModeDir, time.Time{}}, nil + case ftypeProject: + p, err := f.Project(f.name) + if err != nil { + return nil, &fs.PathError{"stat", f.name, err} + } + return p, nil + case ftypeIssueDir, ftypeIssue: + is, err := f.Issue(f.issueKey()) + if err != nil { + return nil, &fs.PathError{"stat", f.name, err} + } + if f.typ == ftypeIssueDir { + f.children = issueChildren(f, is) + return is, nil + } + // optimisation: we might read the file soon so load the contents. + f.rd = strings.NewReader(printIssue(is)) + return &stat{f.name, int64(len(printIssue(is))), 0o444, is.Updated}, nil + case ftypeComment: + c, err := f.Comment(f.issueKey(), f.name) + if err != nil { + return nil, &fs.PathError{"stat", f.name, err} + } + // optimisation: we might read the file soon so load the contents. + f.rd = strings.NewReader(printComment(c)) + return c, nil + } + err := fmt.Errorf("unexpected fid type %d", f.typ) + return nil, &fs.PathError{"stat", f.name, err} +} + +func (f *fid) Read(p []byte) (n int, err error) { + if f.Client.Debug { + fmt.Fprintln(os.Stderr, "read", f.Name()) + } + if f.rd == nil { + switch f.typ { + case ftypeComment: + c, err := f.Comment(f.issueKey(), f.name) + if err != nil { + err = fmt.Errorf("get comment %s: %w", f.issueKey(), err) + return 0, &fs.PathError{"read", f.name, err} + } + f.rd = strings.NewReader(printComment(c)) + case ftypeIssue: + is, err := f.Issue(f.issueKey()) + if err != nil { + err = fmt.Errorf("get issue %s: %w", f.issueKey(), err) + return 0, &fs.PathError{"read", f.name, err} + } + f.rd = strings.NewReader(printIssue(is)) + default: + var err error + if f.children == nil { + f.children, err = f.ReadDir(-1) + if err != nil { + return 0, &fs.PathError{"read", f.name, err} + } + } + buf := &strings.Builder{} + for _, d := range f.children { + fmt.Fprintln(buf, fs.FormatDirEntry(d)) + } + f.rd = strings.NewReader(buf.String()) + } + } + return f.rd.Read(p) +} + +func (f *fid) Close() error { + f.rd = nil + f.stat = nil + return nil +} + +func (f *fid) ReadDir(n int) ([]fs.DirEntry, error) { + if f.Client.Debug { + fmt.Fprintln(os.Stderr, "readdir", f.Name()) + } + if !f.IsDir() { + return nil, fmt.Errorf("not a directory") + } + if f.children == nil { + switch f.typ { + case ftypeRoot: + return nil, fmt.Errorf("root initialised incorrectly: no dir entries") + case ftypeProject: + issues, err := f.Issues(f.name) + if err != nil { + return nil, fmt.Errorf("get issues: %w", err) + } + f.children = make([]fs.DirEntry, len(issues)) + for i, issue := range issues { + f.children[i] = &fid{ + Client: f.Client, + name: issue.Name(), + typ: ftypeIssueDir, + parent: f, + } + } + case ftypeIssueDir: + issue, err := f.Issue(f.issueKey()) + if err != nil { + return nil, fmt.Errorf("get issue %s: %w", f.name, err) + } + f.children = issueChildren(f, issue) + } + } + + if f.dirp >= len(f.children) { + if n <= 0 { + return nil, nil + } + return nil, io.EOF + } + if n <= 0 { + f.dirp = len(f.children) + return f.children, nil + } + + var err error + d := f.children[f.dirp:] + if len(d) >= n { + d = d[:n] + } else if len(d) <= n { + err = io.EOF + } + f.dirp += n + return d, err +} + +func issueChildren(parent *fid, is *Issue) []fs.DirEntry { + kids := make([]fs.DirEntry, len(is.Comments)+1) + for i, c := range is.Comments { + kids[i] = &fid{ + Client: parent.Client, + name: c.ID, + typ: ftypeComment, + rd: strings.NewReader(printComment(&is.Comments[i])), + parent: parent, + stat: &is.Comments[i], + } + } + kids[len(kids)-1] = &fid{ + name: "issue", + Client: parent.Client, + typ: ftypeIssue, + rd: strings.NewReader(is.Summary), + parent: parent, + stat: &stat{"issue", int64(len(printIssue(is))), 0o444, is.Updated}, + } + return kids +} + +func (f *fid) issueKey() string { + // to make the issue key e.g. "EXAMPLE-42" + // we need the name of the issue (parent name, "42") + // and the name of the project (the issue's parent's name, "EXAMPLE") + var project, issueNumber string + switch f.typ { + default: + return "" + case ftypeComment, ftypeIssue: + project = f.parent.parent.name + issueNumber = f.parent.name + case ftypeIssueDir: + project = f.parent.name + issueNumber = f.name + } + return project + "-" + issueNumber +} + +func (fsys *FS) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{"open", name, fs.ErrInvalid} + } + name = path.Clean(name) + if strings.Contains(name, "\\") { + return nil, fs.ErrNotExist + } + + if fsys.root == nil { + var err error + fsys.root, err = makeRoot(fsys.Client) + if err != nil { + return nil, fmt.Errorf("make root file: %w", err) + } + } + if fsys.Client.Debug { + fmt.Fprintln(os.Stderr, "open", name) + } + + if name == "." { + f := *fsys.root + return &f, nil + } + + elems := strings.Split(name, "/") + if elems[0] == "." && len(elems) > 1 { + elems = elems[1:] + } + + f := fsys.root + for _, elem := range elems { + dir, err := find(f, elem) + if err != nil { + return nil, &fs.PathError{"open", name, err} + } + f = dir + } + g := *f + return &g, nil +} + +func makeRoot(client *Client) (*fid, error) { + projects, err := client.Projects() + if err != nil { + return nil, err + } + root := &fid{ + Client: client, + name: ".", + typ: ftypeRoot, + children: make([]fs.DirEntry, len(projects)), + } + for i, p := range projects { + root.children[i] = &fid{ + Client: client, + name: p.Key, + typ: ftypeProject, + } + } + return root, nil +} + +func find(dir *fid, name string) (*fid, error) { + if !dir.IsDir() { + return nil, fs.ErrNotExist + } + child := &fid{Client: dir.Client, parent: dir} + switch dir.typ { + case ftypeRoot: + for _, d := range dir.children { + if d.Name() == name { + child, ok := d.(*fid) + if !ok { + return nil, fmt.Errorf("unexpected dir entry type %T", d) + } + return child, nil + } + } + return nil, fs.ErrNotExist + case ftypeProject: + key := fmt.Sprintf("%s-%s", dir.name, name) + ok, err := dir.CheckIssue(key) + if err != nil { + return nil, err + } + if !ok { + return nil, fs.ErrNotExist + } + child.name = name + child.typ = ftypeIssueDir + return child, nil + case ftypeIssueDir: + if name == "issue" { + child.name = name + child.typ = ftypeIssue + return child, nil + } + ok, err := dir.checkComment(dir.issueKey(), name) + if err != nil { + return nil, err + } else if !ok { + return nil, fs.ErrNotExist + } + child.name = name + child.typ = ftypeComment + return child, nil + } + return nil, fs.ErrNotExist +} blob - /dev/null blob + 0b32c34870100a0afb6be9a08aced1f8ac03a8d9 (mode 644) --- /dev/null +++ jira/fs_test.go @@ -0,0 +1,42 @@ +package jira + +import ( + "encoding/json" + "io/fs" + "os" + "testing" +) + +func TestIssueName(t *testing.T) { + f, err := os.Open("testdata/issue/TEST-1") + if err != nil { + t.Fatal(err) + } + defer f.Close() + var issue Issue + if err := json.NewDecoder(f).Decode(&issue); err != nil { + t.Fatal(err) + } + want := "1" + if issue.Name() != want { + t.Errorf("issue.Name() = %q, want %q", issue.Name(), want) + } +} + +func TestIssueKey(t *testing.T) { + comment := &fid{name: "69", typ: ftypeComment} + issue := &fid{name: "issue", typ: ftypeIssue} + issueDir := &fid{name: "1", typ: ftypeIssueDir, children: []fs.DirEntry{issue, comment}} + project := &fid{name: "TEST", typ: ftypeProject, children: []fs.DirEntry{issueDir}} + + comment.parent = issueDir + issue.parent = issueDir + issueDir.parent = project + + want := "TEST-1" + for _, f := range []*fid{comment, issue, issueDir} { + if f.issueKey() != want { + t.Errorf("fid %s issueKey = %q, want %q", f.name, f.issueKey(), want) + } + } +} blob - /dev/null blob + bf513862428129dca85813e77ec2022e3cdf8478 (mode 644) --- /dev/null +++ jira/http.go @@ -0,0 +1,241 @@ +package jira + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" +) + +type Client struct { + *http.Client + Debug bool + Username, Password string + APIRoot *url.URL +} + +func (c *Client) Projects() ([]Project, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "project") + resp, err := c.get(u.String()) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status: %s", resp.Status) + } + defer resp.Body.Close() + var p []Project + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + return nil, fmt.Errorf("decode project: %w", err) + } + return p, nil +} + +func (c *Client) Project(name string) (*Project, error) { + u := fmt.Sprintf("%s/project/%s", c.APIRoot, name) + resp, err := c.get(u) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status: %s", resp.Status) + } + defer resp.Body.Close() + var p Project + if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { + return nil, fmt.Errorf("decode project: %w", err) + } + return &p, nil +} + +func (c *Client) Issues(project string) ([]Issue, error) { + q := fmt.Sprintf("project = %q", project) + return c.SearchIssues(q) +} + +func (c *Client) SearchIssues(query string) ([]Issue, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "search") + q := make(url.Values) + q.Add("jql", query) + u.RawQuery = q.Encode() + resp, err := c.get(u.String()) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusBadRequest { + return nil, fmt.Errorf("bad query") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status: %s", resp.Status) + } + t := struct { + Issues []Issue + }{} + if err := json.NewDecoder(resp.Body).Decode(&t); err != nil { + return nil, fmt.Errorf("decode issues: %w", err) + } + return t.Issues, nil +} + +func (c *Client) CheckIssue(name string) (bool, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "issue", name) + req, err := http.NewRequest(http.MethodHead, u.String(), nil) + if err != nil { + return false, err + } + resp, err := c.do(req) + if err != nil { + return false, err + } + if resp.StatusCode == http.StatusOK { + return true, nil + } + return false, nil +} + +func (c *Client) Issue(name string) (*Issue, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "issue", name) + resp, err := c.get(u.String()) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status: %s", resp.Status) + } + defer resp.Body.Close() + var is Issue + if err := json.NewDecoder(resp.Body).Decode(&is); err != nil { + return nil, fmt.Errorf("decode issue: %w", err) + } + return &is, nil +} + +func (c *Client) checkComment(ikey, id string) (bool, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "issue", ikey, "comment", id) + req, err := http.NewRequest(http.MethodHead, u.String(), nil) + if err != nil { + return false, err + } + resp, err := c.do(req) + if err != nil { + return false, err + } + if resp.StatusCode == http.StatusOK { + return true, nil + } + return false, nil +} + +func (c *Client) Comment(ikey, id string) (*Comment, error) { + u := *c.APIRoot + u.Path = path.Join(u.Path, "issue", ikey, "comment", id) + resp, err := c.get(u.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var com Comment + if err := json.NewDecoder(resp.Body).Decode(&com); err != nil { + return nil, fmt.Errorf("decode comment: %w", err) + } + return &com, nil +} + +func (c *Client) PostComment(issueKey string, body io.Reader) error { + b, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + cm := Comment{Body: string(b)} + hbody, err := json.Marshal(&cm) + if err != nil { + return fmt.Errorf("to json: %w", err) + } + u := fmt.Sprintf("%s/issue/%s/comment", c.APIRoot, issueKey) + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(hbody)) + if err != nil { + return err + } + req.Header.Add("Content-Type", "application/json") + resp, err := c.do(req) + if err != nil { + return err + } + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("non-ok status: %s", resp.Status) + } + return nil + +} + +func Create(APIRoot string, issue Issue) (*Issue, error) { + b, err := json.Marshal(&issue) + if err != nil { + return nil, fmt.Errorf("to json: %w", err) + } + u := APIRoot + "/issue" + resp, err := http.Post(u, "application/json", bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok status %s", resp.Status) + } + var i Issue + if err := json.NewDecoder(resp.Body).Decode(&i); err != nil { + return nil, fmt.Errorf("decode created issue: %w", err) + } + return &i, nil +} + +func CreateComment(APIRoot, issueKey string, body io.Reader) error { + b, err := io.ReadAll(body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + c := Comment{Body: string(b)} + hbody, err := json.Marshal(&c) + if err != nil { + return fmt.Errorf("to json: %w", err) + } + u := fmt.Sprintf("%s/issue/%s/comment", APIRoot, issueKey) + resp, err := http.Post(u, "application/json", bytes.NewReader(hbody)) + if err != nil { + return err + } + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("non-ok status: %s", resp.Status) + } + return nil +} + +func (c *Client) get(url string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + return c.do(req) +} + +func (c *Client) do(req *http.Request) (*http.Response, error) { + if c.Client == nil { + c.Client = http.DefaultClient + } + if c.Username != "" && c.Password != "" { + req.SetBasicAuth(c.Username, c.Password) + } + if c.Debug { + fmt.Fprintln(os.Stderr, req.Method, req.URL) + } + return c.Do(req) +} blob - /dev/null blob + ae442b174cd7d4cc9a3c5e09c9d63d883d5681c5 (mode 644) --- /dev/null +++ jira/http_test.go @@ -0,0 +1,68 @@ +package jira + +import ( + "io" + "net/http" + "net/url" + "path" + "testing" + "testing/fstest" +) + +func handleComment(w http.ResponseWriter, req *http.Request) { + id := path.Base(req.URL.Path) + http.ServeFile(w, req, "testdata/comment/"+id) +} + +func TestGet(t *testing.T) { + srv := newFakeServer("testdata") + defer srv.Close() + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + client := &Client{APIRoot: u} + + project := "TEST" + issue := "TEST-1" + comment := "69" + if _, err := client.Project(project); err != nil { + t.Fatalf("get project %s: %v", project, err) + } + if _, err := client.Issues(project); err != nil { + t.Fatalf("get %s issues: %v", project, err) + } + if _, err := client.Issue(issue); err != nil { + t.Fatalf("get issue %s: %v", issue, err) + } + c, err := client.Comment(issue, comment) + if err != nil { + t.Fatalf("get comment %s from %s: %v", comment, issue, err) + } + if c.ID != comment { + t.Fatalf("wanted comment id %s, got %s", comment, c.ID) + } + + fsys := &FS{Client: client} + f, err := fsys.Open("TEST/1/69") + if err != nil { + t.Fatal(err) + } + if _, err := f.Stat(); err != nil { + t.Fatal(err) + } + if _, err := io.Copy(io.Discard, f); err != nil { + t.Fatal(err) + } + f.Close() + + expected := []string{ + "TEST", + "TEST/1", + "TEST/1/issue", + "TEST/1/69", + } + if err := fstest.TestFS(fsys, expected...); err != nil { + t.Error(err) + } +} blob - /dev/null blob + a4339567b02f6a29f302567eda5fbba16a3fd198 (mode 644) --- /dev/null +++ jira/jira.go @@ -0,0 +1,142 @@ +package jira + +import ( + "encoding/json" + "fmt" + "time" +) + +const timestamp = "2006-01-02T15:04:05.999-0700" + +type Issue struct { + ID string // TODO(otl): int? + URL string + Key string + Reporter User + Assignee User + Summary string + Status struct { + Name string `json:"name"` + } `json:"status"` + Description string + Project Project + Created time.Time + Updated time.Time + Comments []Comment + Links []Issue + Subtasks []Issue +} + +type Project struct { + ID string `json:"id"` // TODO(otl): int? + // Name string `json:"name"` + Key string `json:"key"` + URL string `json:"self"` +} + +type Comment struct { + ID string `json:"id"` // TODO(otl): int? + URL string `json:"self"` + Body string `json:"body"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Author User `json:"author"` + UpdateAuthor User `json:"updateAuthor"` +} + +func (c *Comment) UnmarshalJSON(b []byte) error { + type alias Comment + aux := &struct { + Created string `json:"created"` + Updated string `json:"updated"` + *alias + }{ + alias: (*alias)(c), + } + if err := json.Unmarshal(b, aux); err != nil { + return err + } + var err error + c.Created, err = time.Parse(timestamp, aux.Created) + if err != nil { + return fmt.Errorf("parse created time: %w", err) + } + c.Updated, err = time.Parse(timestamp, aux.Updated) + if err != nil { + return fmt.Errorf("parse updated time: %w", err) + } + return nil +} + +type User struct { + Name string `json:"name"` + Email string `json:"emailAddress"` + DisplayName string `json:"displayName"` +} + +func (u User) String() string { + if u.DisplayName == "" { + return u.Email + } + return fmt.Sprintf("%s <%s>", u.DisplayName, u.Email) +} + +func (issue *Issue) UnmarshalJSON(b []byte) error { + aux := &struct { + ID string + Self string + Key string + Fields json.RawMessage + }{} + if err := json.Unmarshal(b, aux); err != nil { + return err + } + issue.ID = aux.ID + issue.URL = aux.Self + issue.Key = aux.Key + + type alias Issue + iaux := &struct { + Created string + Updated string + Comment map[string]json.RawMessage + IssueLinks []struct { + InwardIssue *Issue + OutwardIssue *Issue + } + *alias + }{ + alias: (*alias)(issue), + } + if err := json.Unmarshal(aux.Fields, iaux); err != nil { + return err + } + + var err error + if iaux.Created != "" { + issue.Created, err = time.Parse(timestamp, iaux.Created) + if err != nil { + return fmt.Errorf("created time: %w", err) + } + } + if iaux.Updated != "" { + issue.Updated, err = time.Parse(timestamp, iaux.Updated) + if err != nil { + return fmt.Errorf("updated time: %w", err) + } + } + if bb, ok := iaux.Comment["comments"]; ok { + if err := json.Unmarshal(bb, &issue.Comments); err != nil { + return fmt.Errorf("unmarshal comments: %w", err) + } + } + for _, l := range iaux.IssueLinks { + if l.InwardIssue != nil { + issue.Links = append(issue.Links, *l.InwardIssue) + } + if l.OutwardIssue != nil { + issue.Links = append(issue.Links, *l.OutwardIssue) + } + } + return nil +} blob - /dev/null blob + 569d9044a8d842febb0dddae295bc66bd98a58ca (mode 644) --- /dev/null +++ jira/jira_test.go @@ -0,0 +1,40 @@ +package jira + +import ( + "encoding/json" + "os" + "testing" +) + +func TestDecode(t *testing.T) { + dents, err := os.ReadDir("testdata/issue") + if err != nil { + t.Fatal(err) + } + for _, d := range dents { + f, err := os.Open("testdata/issue/" + d.Name()) + if err != nil { + t.Fatal(err) + } + var i Issue + if err := json.NewDecoder(f).Decode(&i); err != nil { + t.Errorf("decode %s: %v", f.Name(), err) + } + f.Close() + } +} + +/* +func TestSubtasks(t *testing.T) { + f, err := os.Open("testdata/subtasks") + if err != nil { + t.Fatal(err) + } + defer f.Close() + var is Issue + if err := json.NewDecoder(f).Decode(&is); err != nil { + t.Fatal(err) + } + fmt.Println(is.Subtasks) +} +*/ blob - /dev/null blob + 2af937f6316d5c2ea8d6b78d866af66a24992d70 (mode 644) --- /dev/null +++ jira/print.go @@ -0,0 +1,91 @@ +package jira + +import ( + "fmt" + "net/url" + "path" + "strings" + "time" +) + +func printIssues(issues []Issue) string { + buf := &strings.Builder{} + for _, ii := range issues { + name := strings.Replace(ii.Key, "-", "/", 1) + fmt.Fprintf(buf, "%s/issue\t%s\n", name, ii.Summary) + } + return buf.String() +} + +func printIssue(i *Issue) string { + buf := &strings.Builder{} + fmt.Fprintln(buf, "From:", i.Reporter) + fmt.Fprintln(buf, "Date:", i.Created.Format(time.RFC1123Z)) + if i.Assignee.String() != "" { + fmt.Fprintln(buf, "Assignee:", i.Assignee) + } + if u, err := url.Parse(i.URL); err == nil { + u.Path = path.Join("browse", i.Key) + fmt.Fprintf(buf, "Archived-At: <%s>\n", u) + } + fmt.Fprintf(buf, "Archived-At: <%s>\n", i.URL) + fmt.Fprintln(buf, "Status:", i.Status.Name) + if len(i.Links) > 0 { + s := make([]string, len(i.Links)) + for j := range i.Links { + s[j] = i.Links[j].Key + } + fmt.Fprintln(buf, "References:", strings.Join(s, ", ")) + } + if len(i.Subtasks) > 0 { + s := make([]string, len(i.Subtasks)) + for j := range i.Subtasks { + s[j] = i.Subtasks[j].Key + } + fmt.Fprintln(buf, "Subtasks:", strings.Join(s, ", ")) + } + fmt.Fprintln(buf, "Subject:", i.Summary) + fmt.Fprintln(buf) + + if i.Description != "" { + fmt.Fprintln(buf, strings.ReplaceAll(i.Description, "\r", "")) + } + if len(i.Comments) == 0 { + return buf.String() + } + fmt.Fprintln(buf) + for _, c := range i.Comments { + date := c.Created + if !c.Updated.IsZero() { + date = c.Updated + } + fmt.Fprintf(buf, "%s\t%s\t%s (%s)\n", c.ID, summarise(c.Body, 36), c.Author.Name, date.Format(time.DateTime)) + } + return buf.String() +} + +func printComment(c *Comment) string { + buf := &strings.Builder{} + date := c.Created + if !c.Updated.IsZero() { + date = c.Updated + } + fmt.Fprintln(buf, "From:", c.Author) + fmt.Fprintln(buf, "Date:", date.Format(time.RFC1123Z)) + fmt.Fprintln(buf) + fmt.Fprintln(buf, strings.TrimSpace(c.Body)) + return buf.String() +} + +func summarise(body string, length int) string { + if len(body) < length { + body = strings.ReplaceAll(body, "\n", " ") + return strings.TrimSpace(body) + } + body = body[:length] + body = strings.ReplaceAll(body, "\r", "") + body = strings.ReplaceAll(body, "\n", " ") + body = strings.TrimSpace(body) + body = strings.ReplaceAll(body, " ", " ") + return body + "..." +} blob - /dev/null blob + 30254dd4d27efe5f9765de36b1994068c770b813 (mode 644) --- /dev/null +++ jira/project.json @@ -0,0 +1,116 @@ +{ + "expand": "description,lead,issueTypes,url,projectKeys", + "self": "http://www.example.com/jira/rest/api/2/project/EX", + "id": "10000", + "key": "EX", + "description": "This project was created as an example for REST.", + "lead": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "components": [ + { + "self": "http://www.example.com/jira/rest/api/2/component/10000", + "id": "10000", + "name": "Component 1", + "description": "This is a JIRA component", + "lead": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F.User", + "active": false + }, + "assigneeType": "PROJECT_LEAD", + "assignee": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "realAssigneeType": "PROJECT_LEAD", + "realAssignee": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "isAssigneeTypeValid": false, + "project": "HSP", + "projectId": 10000 + } + ], + "issueTypes": [ + { + "self": "http://localhost:8090/jira/rest/api/2.0/issueType/3", + "id": "3", + "description": "A task that needs to be done.", + "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/task.png", + "name": "Task", + "subtask": false, + "avatarId": 1 + }, + { + "self": "http://localhost:8090/jira/rest/api/2.0/issueType/1", + "id": "1", + "description": "A problem with the software.", + "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/bug.png", + "name": "Bug", + "subtask": false, + "avatarId": 10002 + } + ], + "url": "http://www.example.com/jira/browse/EX", + "email": "from-jira@example.com", + "assigneeType": "PROJECT_LEAD", + "versions": [], + "name": "Example", + "roles": { + "Developers": "http://www.example.com/jira/rest/api/2/project/EX/role/10000" + }, + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", + "24x24": "http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", + "16x16": "http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", + "32x32": "http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory": { + "self": "http://www.example.com/jira/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + } +} blob - /dev/null blob + afa177739515fe9b83c8948093f5d7596b0ef6f5 (mode 644) --- /dev/null +++ jira/testdata/comment/69 @@ -0,0 +1,35 @@ +{ + "self": "https://jira.atlassian.com/rest/api/2/issue/10148/comment/17800", + "id": "69", + "author": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", + "name": "owen@atlassian.com", + "key": "owen@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" + }, + "displayName": "Owen Fellows", + "active": true, + "timeZone": "UTC" + }, + "body": "This is due to users not having a timezone set, see linked Issue.", + "updateAuthor": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", + "name": "owen@atlassian.com", + "key": "owen@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" + }, + "displayName": "Owen Fellows", + "active": true, + "timeZone": "UTC" + }, + "created": "2003-11-17T01:55:10.760+0000", + "updated": "2003-11-17T01:55:10.760+0000" +} blob - /dev/null blob + 72af386aea83edb5619ec766b813cdd8b9929e9a (mode 644) --- /dev/null +++ jira/testdata/issue/TEST-1 @@ -0,0 +1,877 @@ +{ + "expand": "renderedFields,names,schema,operations,editmeta,changelog,versionedRepresentations", + "id": "10148", + "self": "https://jira.atlassian.com/rest/api/latest/issue/10148", + "key": "TEST-1", + "fields": { + "fixVersions": [ + { + "self": "https://jira.atlassian.com/rest/api/2/version/15918", + "id": "15918", + "description": "", + "name": "4.4", + "archived": false, + "released": true, + "releaseDate": "2011-08-02" + } + ], + "resolution": { + "self": "https://jira.atlassian.com/rest/api/2/resolution/1", + "id": "1", + "description": "A fix for this issue is checked into the tree and tested.", + "name": "Fixed" + }, + "labels": [ + "affects-cloud", + "affects-server" + ], + "aggregatetimeoriginalestimate": 2160000, + "timeestimate": 2160000, + "issuelinks": [ + { + "id": "50308", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/50308", + "type": { + "id": "10020", + "name": "Blocker", + "inward": "is blocked by", + "outward": "blocks", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10020" + }, + "outwardIssue": { + "id": "126823", + "key": "JSWSERVER-2855", + "self": "https://jira.atlassian.com/rest/api/2/issue/126823", + "fields": { + "summary": "GreenHopper Hourly Burndown Gadget - Update Every 15 Minutes or as Scheduled", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "37511", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/37511", + "type": { + "id": "10050", + "name": "Cause", + "inward": "causes", + "outward": "is caused by", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10050" + }, + "inwardIssue": { + "id": "85626", + "key": "JRASERVER-17359", + "self": "https://jira.atlassian.com/rest/api/2/issue/85626", + "fields": { + "summary": "Add timezone support to JQL dates.", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "28680", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/28680", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "42259", + "key": "JRASERVER-11253", + "self": "https://jira.atlassian.com/rest/api/2/issue/42259", + "fields": { + "summary": "Date stamp does not compensate for time zone differences", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "priority": { + "self": "https://jira.atlassian.com/rest/api/2/priority/4", + "iconUrl": "https://jira.atlassian.com/images/icons/priorities/low.svg", + "name": "Low", + "id": "4" + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/1", + "id": "1", + "description": "A problem which impairs or prevents the functions of the product.", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51493&avatarType=issuetype", + "name": "Bug", + "subtask": false, + "avatarId": 51493 + } + } + } + }, + { + "id": "10942", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/10942", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "15168", + "key": "JRASERVER-2677", + "self": "https://jira.atlassian.com/rest/api/2/issue/15168", + "fields": { + "summary": "time on jira.atlassian.com?", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "11779", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/11779", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "16922", + "key": "JRASERVER-3316", + "self": "https://jira.atlassian.com/rest/api/2/issue/16922", + "fields": { + "summary": "Created/Updated times are shown in server time zone, not the client's one", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "12982", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/12982", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "22426", + "key": "JRASERVER-5539", + "self": "https://jira.atlassian.com/rest/api/2/issue/22426", + "fields": { + "summary": "TimeZone preference", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "22245", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/22245", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "46356", + "key": "JRASERVER-11930", + "self": "https://jira.atlassian.com/rest/api/2/issue/46356", + "fields": { + "summary": "Display Time in Current User TZ", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "31127", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/31127", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "70205", + "key": "JRASERVER-15149", + "self": "https://jira.atlassian.com/rest/api/2/issue/70205", + "fields": { + "summary": "Date/Times to reflect local time zone", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "32100", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/32100", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "73554", + "key": "JRASERVER-15528", + "self": "https://jira.atlassian.com/rest/api/2/issue/73554", + "fields": { + "summary": "JIRA enterprise does not honour system TZ nor seems to provide option to override, all stamps are and keep on being 1h out of sync after migration to other JIRA instance", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "41981", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/41981", + "type": { + "id": "10001", + "name": "Duplicate", + "inward": "is duplicated by", + "outward": "duplicates", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10001" + }, + "inwardIssue": { + "id": "104230", + "key": "JRASERVER-20802", + "self": "https://jira.atlassian.com/rest/api/2/issue/104230", + "fields": { + "summary": "User Profiles > Time Zones - the ability to select a time zone per user profile", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "11606", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/11606", + "type": { + "id": "10000", + "name": "Reference", + "inward": "is related to", + "outward": "relates to", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" + }, + "outwardIssue": { + "id": "13031", + "key": "JRASERVER-1519", + "self": "https://jira.atlassian.com/rest/api/2/issue/13031", + "fields": { + "summary": "Allow arbitrary fields (company, phone#, etc) in user profile", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/11772", + "description": "This suggestion needs more unique domain votes and comments before being reviewed by our team.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", + "name": "Gathering Interest", + "id": "11772", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "default", + "name": "To Do" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "27174", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/27174", + "type": { + "id": "10000", + "name": "Reference", + "inward": "is related to", + "outward": "relates to", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" + }, + "outwardIssue": { + "id": "39309", + "key": "JRASERVER-10613", + "self": "https://jira.atlassian.com/rest/api/2/issue/39309", + "fields": { + "summary": "Capability to customize date/time format per user", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/11772", + "description": "This suggestion needs more unique domain votes and comments before being reviewed by our team.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", + "name": "Gathering Interest", + "id": "11772", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "default", + "name": "To Do" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + }, + { + "id": "71195", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/71195", + "type": { + "id": "10000", + "name": "Reference", + "inward": "is related to", + "outward": "relates to", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" + }, + "inwardIssue": { + "id": "192343", + "key": "JRASERVER-28939", + "self": "https://jira.atlassian.com/rest/api/2/issue/192343", + "fields": { + "summary": "Add time-zone support for rendering date fields", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/12072", + "description": "This issue has been reviewed, but needs more supporting information to gauge how pervasive the problem is.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/generic.png", + "name": "Gathering Impact", + "id": "12072", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "default", + "name": "To Do" + } + }, + "priority": { + "self": "https://jira.atlassian.com/rest/api/2/priority/4", + "iconUrl": "https://jira.atlassian.com/images/icons/priorities/low.svg", + "name": "Low", + "id": "4" + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/1", + "id": "1", + "description": "A problem which impairs or prevents the functions of the product.", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51493&avatarType=issuetype", + "name": "Bug", + "subtask": false, + "avatarId": 51493 + } + } + } + }, + { + "id": "57958", + "self": "https://jira.atlassian.com/rest/api/2/issueLink/57958", + "type": { + "id": "10000", + "name": "Reference", + "inward": "is related to", + "outward": "relates to", + "self": "https://jira.atlassian.com/rest/api/2/issueLinkType/10000" + }, + "inwardIssue": { + "id": "144072", + "key": "JRASERVER-25303", + "self": "https://jira.atlassian.com/rest/api/2/issue/144072", + "fields": { + "summary": "Administer Timezone settings", + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + } + } + } + } + ], + "assignee": null, + "status": { + "self": "https://jira.atlassian.com/rest/api/2/status/6", + "description": "Work on this issue is complete.\r\n\r\nIf it\u2019s fixed in a Server product, the resolution will be \u2018Fixed\u2019 and the Fix Version field will indicate the product version that contains the fix.\r\n\r\nIf no code changes were required, the resolution will be \u2018Duplicate', 'Won't fix', 'Handled by support', 'Timed out', or similar.", + "iconUrl": "https://jira.atlassian.com/images/icons/statuses/closed.png", + "name": "Closed", + "id": "6", + "statusCategory": { + "self": "https://jira.atlassian.com/rest/api/2/statuscategory/3", + "id": 3, + "key": "done", + "colorName": "success", + "name": "Done" + } + }, + "components": [ + { + "self": "https://jira.atlassian.com/rest/api/2/component/10125", + "id": "10125", + "name": "User Management - Others" + } + ], + "archiveddate": null, + "aggregatetimeestimate": 2160000, + "creator": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=mike%40atlassian.com", + "name": "mike@atlassian.com", + "key": "mike@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=32" + }, + "displayName": "Mike Cannon-Brookes", + "active": true, + "timeZone": "Australia/Sydney" + }, + "subtasks": [], + "reporter": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=mike%40atlassian.com", + "name": "mike@atlassian.com", + "key": "mike@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/b1a3871fb560dc486b6636467e9802fe?d=mm&s=32" + }, + "displayName": "Mike Cannon-Brookes", + "active": true, + "timeZone": "Australia/Sydney" + }, + "aggregateprogress": { + "progress": 1500, + "total": 2161500, + "percent": 0 + }, + "progress": { + "progress": 1500, + "total": 2161500, + "percent": 0 + }, + "votes": { + "self": "https://jira.atlassian.com/rest/api/2/issue/JRASERVER-9/votes", + "votes": 454, + "hasVoted": false + }, + "worklog": { + "startAt": 0, + "maxResults": 20, + "total": 1, + "worklogs": [ + { + "self": "https://jira.atlassian.com/rest/api/2/issue/10148/worklog/96804", + "author": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=tim%40atlassian.com", + "name": "tim@atlassian.com", + "key": "tim@atlassian.com", + "avatarUrls": { + "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=tim%40atlassian.com&avatarId=2465661", + "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=tim%40atlassian.com&avatarId=2465661", + "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=tim%40atlassian.com&avatarId=2465661", + "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=tim%40atlassian.com&avatarId=2465661" + }, + "displayName": "TimP", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "updateAuthor": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=tim%40atlassian.com", + "name": "tim@atlassian.com", + "key": "tim@atlassian.com", + "avatarUrls": { + "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=tim%40atlassian.com&avatarId=2465661", + "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=tim%40atlassian.com&avatarId=2465661", + "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=tim%40atlassian.com&avatarId=2465661", + "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=tim%40atlassian.com&avatarId=2465661" + }, + "displayName": "TimP", + "active": true, + "timeZone": "America/Los_Angeles" + }, + "comment": "Time submitted by matt for review CR-2", + "created": "2011-05-20T05:55:22.952+0000", + "updated": "2011-05-20T05:55:22.952+0000", + "started": "2011-05-20T05:55:18.528+0000", + "timeSpent": "25m", + "timeSpentSeconds": 1500, + "id": "96804", + "issueId": "10148" + } + ] + }, + "archivedby": null, + "issuetype": { + "self": "https://jira.atlassian.com/rest/api/2/issuetype/10000", + "id": "10000", + "description": "", + "iconUrl": "https://jira.atlassian.com/secure/viewavatar?size=xsmall&avatarId=51505&avatarType=issuetype", + "name": "Suggestion", + "subtask": false, + "avatarId": 51505 + }, + "timespent": 1500, + "project": { + "self": "https://jira.atlassian.com/rest/api/2/project/10240", + "id": "10240", + "key": "JRASERVER", + "name": "Jira Data Center", + "projectTypeKey": "software", + "avatarUrls": { + "48x48": "https://jira.atlassian.com/secure/projectavatar?pid=10240&avatarId=105190", + "24x24": "https://jira.atlassian.com/secure/projectavatar?size=small&pid=10240&avatarId=105190", + "16x16": "https://jira.atlassian.com/secure/projectavatar?size=xsmall&pid=10240&avatarId=105190", + "32x32": "https://jira.atlassian.com/secure/projectavatar?size=medium&pid=10240&avatarId=105190" + }, + "projectCategory": { + "self": "https://jira.atlassian.com/rest/api/2/projectCategory/10031", + "id": "10031", + "description": "", + "name": "Atlassian Products" + } + }, + "aggregatetimespent": 1500, + "resolutiondate": "2011-06-07T15:31:26.782+0000", + "workratio": 0, + "watches": { + "self": "https://jira.atlassian.com/rest/api/2/issue/JRASERVER-9/watchers", + "watchCount": 213, + "isWatching": false + }, + "created": "2002-02-08T05:08:00.000+0000", + "updated": "2022-05-18T18:35:49.533+0000", + "timeoriginalestimate": 2160000, + "description": "Add time zones to user profile. That way the dates displayed to a user are always contiguous with their local time zone, rather than the server's time zone.", + "timetracking": { + "originalEstimate": "600h", + "remainingEstimate": "600h", + "timeSpent": "25m", + "originalEstimateSeconds": 2160000, + "remainingEstimateSeconds": 2160000, + "timeSpentSeconds": 1500 + }, + "attachment": [ + { + "self": "https://jira.atlassian.com/rest/api/2/attachment/45565", + "id": "45565", + "filename": "log_110314_115720______.csv", + "created": "2011-03-26T15:38:11.072+0000", + "size": 24576, + "mimeType": "text/csv", + "content": "https://jira.atlassian.com/secure/attachment/45565/log_110314_115720______.csv" + }, + { + "self": "https://jira.atlassian.com/rest/api/2/attachment/40875", + "id": "40875", + "filename": "times.png", + "author": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=rkrishna", + "name": "rkrishna", + "key": "rkrishna", + "avatarUrls": { + "48x48": "https://jira.atlassian.com/secure/useravatar?ownerId=rkrishna&avatarId=2478834", + "24x24": "https://jira.atlassian.com/secure/useravatar?size=small&ownerId=rkrishna&avatarId=2478834", + "16x16": "https://jira.atlassian.com/secure/useravatar?size=xsmall&ownerId=rkrishna&avatarId=2478834", + "32x32": "https://jira.atlassian.com/secure/useravatar?size=medium&ownerId=rkrishna&avatarId=2478834" + }, + "displayName": "Roy Krishna", + "active": false, + "timeZone": "Australia/Sydney" + }, + "created": "2010-09-21T05:31:32.198+0000", + "size": 39226, + "mimeType": "image/png", + "content": "https://jira.atlassian.com/secure/attachment/40875/times.png", + "thumbnail": "https://jira.atlassian.com/secure/thumbnail/40875/_thumb_40875.png" + } + ], + "summary": "User Preference: User Time Zones", + "environment": null, + "duedate": null, + "comment": { + "comments": [ + { + "self": "https://jira.atlassian.com/rest/api/2/issue/10148/comment/17800", + "id": "69", + "author": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", + "name": "owen@atlassian.com", + "key": "owen@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" + }, + "displayName": "Owen Fellows", + "active": true, + "timeZone": "UTC" + }, + "body": "This is due to users not having a timezone set, see linked Issue.", + "updateAuthor": { + "self": "https://jira.atlassian.com/rest/api/2/user?username=owen%40atlassian.com", + "name": "owen@atlassian.com", + "key": "owen@atlassian.com", + "avatarUrls": { + "48x48": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=48", + "24x24": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=24", + "16x16": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=16", + "32x32": "https://avatar-cdn.atlassian.com/5aa96f52989b58639779e66325f190a0?d=mm&s=32" + }, + "displayName": "Owen Fellows", + "active": true, + "timeZone": "UTC" + }, + "created": "2003-11-17T01:55:10.760+0000", + "updated": "2003-11-17T01:55:10.760+0000" + } + ], + "maxResults": 143, + "total": 143, + "startAt": 0 + } + } +} blob - /dev/null blob + b7a01c69a5f5e79a2d8b2c6395e5df33c2a157e2 (mode 644) --- /dev/null +++ jira/testdata/project/TEST @@ -0,0 +1,116 @@ +{ + "expand": "description,lead,issueTypes,url,projectKeys", + "self": "http://www.example.com/jira/rest/api/2/project/EX", + "id": "10000", + "key": "TEST", + "description": "This project was created as an example for REST.", + "lead": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "components": [ + { + "self": "http://www.example.com/jira/rest/api/2/component/10000", + "id": "10000", + "name": "Component 1", + "description": "This is a JIRA component", + "lead": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F.User", + "active": false + }, + "assigneeType": "PROJECT_LEAD", + "assignee": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "realAssigneeType": "PROJECT_LEAD", + "realAssignee": { + "self": "http://www.example.com/jira/rest/api/2/user?username=fred", + "key": "fred", + "accountId": "99:27935d01-92a7-4687-8272-a9b8d3b2ae2e", + "name": "fred", + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/useravatar?size=large&ownerId=fred", + "24x24": "http://www.example.com/jira/secure/useravatar?size=small&ownerId=fred", + "16x16": "http://www.example.com/jira/secure/useravatar?size=xsmall&ownerId=fred", + "32x32": "http://www.example.com/jira/secure/useravatar?size=medium&ownerId=fred" + }, + "displayName": "Fred F. User", + "active": false + }, + "isAssigneeTypeValid": false, + "project": "HSP", + "projectId": 10000 + } + ], + "issueTypes": [ + { + "self": "http://localhost:8090/jira/rest/api/2.0/issueType/3", + "id": "3", + "description": "A task that needs to be done.", + "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/task.png", + "name": "Task", + "subtask": false, + "avatarId": 1 + }, + { + "self": "http://localhost:8090/jira/rest/api/2.0/issueType/1", + "id": "1", + "description": "A problem with the software.", + "iconUrl": "http://localhost:8090/jira/images/icons/issuetypes/bug.png", + "name": "Bug", + "subtask": false, + "avatarId": 10002 + } + ], + "url": "http://www.example.com/jira/browse/EX", + "email": "from-jira@example.com", + "assigneeType": "PROJECT_LEAD", + "versions": [], + "name": "Example", + "roles": { + "Developers": "http://www.example.com/jira/rest/api/2/project/EX/role/10000" + }, + "avatarUrls": { + "48x48": "http://www.example.com/jira/secure/projectavatar?size=large&pid=10000", + "24x24": "http://www.example.com/jira/secure/projectavatar?size=small&pid=10000", + "16x16": "http://www.example.com/jira/secure/projectavatar?size=xsmall&pid=10000", + "32x32": "http://www.example.com/jira/secure/projectavatar?size=medium&pid=10000" + }, + "projectCategory": { + "self": "http://www.example.com/jira/rest/api/2/projectCategory/10000", + "id": "10000", + "name": "FIRST", + "description": "First Project Category" + } +}