mirror of
https://github.com/Threnklyn/jira.git
synced 2026-05-19 04:33:28 +02:00
a92a93b282
GetTemplate and RunTemplate allow external users to access the template system, and in particular allow them to provide their own Buffer to write the output to. I've implemented exposed versions calling the private functions, rather than breaking the internal API. If this isn't a concern, we should remove getTemplate and runTemplate in a future commit.
452 lines
11 KiB
Go
452 lines
11 KiB
Go
package jira
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/kballard/go-shellquote"
|
|
"github.com/op/go-logging"
|
|
"gopkg.in/coryb/yaml.v2"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
log = logging.MustGetLogger("jira")
|
|
VERSION string
|
|
)
|
|
|
|
type Cli struct {
|
|
endpoint *url.URL
|
|
opts map[string]interface{}
|
|
cookieFile string
|
|
ua *http.Client
|
|
}
|
|
|
|
func New(opts map[string]interface{}) *Cli {
|
|
homedir := os.Getenv("HOME")
|
|
cookieJar, _ := cookiejar.New(nil)
|
|
endpoint, _ := opts["endpoint"].(string)
|
|
url, _ := url.Parse(strings.TrimRight(endpoint, "/"))
|
|
|
|
if project, ok := opts["project"].(string); ok {
|
|
opts["project"] = strings.ToUpper(project)
|
|
}
|
|
|
|
cli := &Cli{
|
|
endpoint: url,
|
|
opts: opts,
|
|
cookieFile: fmt.Sprintf("%s/.jira.d/cookies.js", homedir),
|
|
ua: &http.Client{Jar: cookieJar},
|
|
}
|
|
|
|
cli.ua.Jar.SetCookies(url, cli.loadCookies())
|
|
|
|
return cli
|
|
}
|
|
|
|
func (c *Cli) saveCookies(cookies []*http.Cookie) {
|
|
// expiry in one week from now
|
|
expiry := time.Now().Add(24 * 7 * time.Hour)
|
|
for _, cookie := range cookies {
|
|
cookie.Expires = expiry
|
|
}
|
|
|
|
if currentCookies := c.loadCookies(); currentCookies != nil {
|
|
currentCookiesByName := make(map[string]*http.Cookie)
|
|
for _, cookie := range currentCookies {
|
|
currentCookiesByName[cookie.Name] = cookie
|
|
}
|
|
|
|
for _, cookie := range cookies {
|
|
currentCookiesByName[cookie.Name] = cookie
|
|
}
|
|
|
|
mergedCookies := make([]*http.Cookie, 0, len(currentCookiesByName))
|
|
for _, v := range currentCookiesByName {
|
|
mergedCookies = append(mergedCookies, v)
|
|
}
|
|
jsonWrite(c.cookieFile, mergedCookies)
|
|
} else {
|
|
jsonWrite(c.cookieFile, cookies)
|
|
}
|
|
}
|
|
|
|
func (c *Cli) loadCookies() []*http.Cookie {
|
|
bytes, err := ioutil.ReadFile(c.cookieFile)
|
|
if err != nil && os.IsNotExist(err) {
|
|
// dont load cookies if the file does not exist
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
log.Error("Failed to open %s: %s", c.cookieFile, err)
|
|
os.Exit(1)
|
|
}
|
|
cookies := make([]*http.Cookie, 0)
|
|
err = json.Unmarshal(bytes, &cookies)
|
|
if err != nil {
|
|
log.Error("Failed to parse json from file %s: %s", c.cookieFile, err)
|
|
}
|
|
log.Debug("Loading Cookies: %s", cookies)
|
|
return cookies
|
|
}
|
|
|
|
func (c *Cli) post(uri string, content string) (*http.Response, error) {
|
|
return c.makeRequestWithContent("POST", uri, content)
|
|
}
|
|
|
|
func (c *Cli) put(uri string, content string) (*http.Response, error) {
|
|
return c.makeRequestWithContent("PUT", uri, content)
|
|
}
|
|
|
|
func (c *Cli) makeRequestWithContent(method string, uri string, content string) (*http.Response, error) {
|
|
buffer := bytes.NewBufferString(content)
|
|
req, _ := http.NewRequest(method, uri, buffer)
|
|
|
|
log.Info("%s %s", req.Method, req.URL.String())
|
|
if log.IsEnabledFor(logging.DEBUG) {
|
|
logBuffer := bytes.NewBuffer(make([]byte, 0, len(content)))
|
|
req.Write(logBuffer)
|
|
log.Debug("%s", logBuffer)
|
|
// need to recreate the buffer since the offset is now at the end
|
|
// need to be able to rewind the buffer offset, dont know how yet
|
|
req, _ = http.NewRequest(method, uri, bytes.NewBufferString(content))
|
|
}
|
|
|
|
if resp, err := c.makeRequest(req); err != nil {
|
|
return nil, err
|
|
} else {
|
|
if resp.StatusCode == 401 {
|
|
if err := c.CmdLogin(); err != nil {
|
|
return nil, err
|
|
}
|
|
req, _ = http.NewRequest(method, uri, bytes.NewBufferString(content))
|
|
return c.makeRequest(req)
|
|
}
|
|
return resp, err
|
|
}
|
|
}
|
|
|
|
func (c *Cli) get(uri string) (*http.Response, error) {
|
|
req, _ := http.NewRequest("GET", uri, nil)
|
|
log.Info("%s %s", req.Method, req.URL.String())
|
|
if log.IsEnabledFor(logging.DEBUG) {
|
|
logBuffer := bytes.NewBuffer(make([]byte, 0))
|
|
req.Write(logBuffer)
|
|
log.Debug("%s", logBuffer)
|
|
}
|
|
|
|
if resp, err := c.makeRequest(req); err != nil {
|
|
return nil, err
|
|
} else {
|
|
if resp.StatusCode == 401 {
|
|
if err := c.CmdLogin(); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.makeRequest(req)
|
|
}
|
|
return resp, err
|
|
}
|
|
}
|
|
|
|
func (c *Cli) makeRequest(req *http.Request) (resp *http.Response, err error) {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if resp, err = c.ua.Do(req); err != nil {
|
|
log.Error("Failed to %s %s: %s", req.Method, req.URL.String(), err)
|
|
return nil, err
|
|
} else {
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 401 {
|
|
log.Error("response status: %s", resp.Status)
|
|
}
|
|
|
|
runtime.SetFinalizer(resp, func(r *http.Response) {
|
|
r.Body.Close()
|
|
})
|
|
|
|
if _, ok := resp.Header["Set-Cookie"]; ok {
|
|
c.saveCookies(resp.Cookies())
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (c *Cli) GetTemplate(name string) string {
|
|
return c.getTemplate(name)
|
|
}
|
|
|
|
func (c *Cli) getTemplate(name string) string {
|
|
if override, ok := c.opts["template"].(string); ok {
|
|
if _, err := os.Stat(override); err == nil {
|
|
return readFile(override)
|
|
} else {
|
|
if file, err := FindClosestParentPath(fmt.Sprintf(".jira.d/templates/%s", override)); err == nil {
|
|
return readFile(file)
|
|
}
|
|
if dflt, ok := all_templates[override]; ok {
|
|
return dflt
|
|
}
|
|
}
|
|
}
|
|
if file, err := FindClosestParentPath(fmt.Sprintf(".jira.d/templates/%s", name)); err != nil {
|
|
// create-bug etc are special, if we dont find it in the path
|
|
// then just return a generic create template
|
|
if strings.HasPrefix(name, "create-") {
|
|
if file, err := FindClosestParentPath(".jira.d/templates/create"); err != nil {
|
|
return all_templates["create"]
|
|
} else {
|
|
return readFile(file)
|
|
}
|
|
}
|
|
return all_templates[name]
|
|
} else {
|
|
return readFile(file)
|
|
}
|
|
}
|
|
|
|
type NoChangesFound struct{}
|
|
|
|
func (f NoChangesFound) Error() string {
|
|
return "No changes found, aborting"
|
|
}
|
|
|
|
func (c *Cli) editTemplate(template string, tmpFilePrefix string, templateData map[string]interface{}, templateProcessor func(string) error) error {
|
|
|
|
tmpdir := fmt.Sprintf("%s/.jira.d/tmp", os.Getenv("HOME"))
|
|
if err := mkdir(tmpdir); err != nil {
|
|
return err
|
|
}
|
|
|
|
fh, err := ioutil.TempFile(tmpdir, tmpFilePrefix)
|
|
if err != nil {
|
|
log.Error("Failed to make temp file in %s: %s", tmpdir, err)
|
|
return err
|
|
}
|
|
defer fh.Close()
|
|
|
|
tmpFileName := fmt.Sprintf("%s.yml", fh.Name())
|
|
if err := os.Rename(fh.Name(), tmpFileName); err != nil {
|
|
log.Error("Failed to rename %s to %s: %s", fh.Name(), fmt.Sprintf("%s.yml", fh.Name()), err)
|
|
return err
|
|
}
|
|
defer func() {
|
|
os.Remove(tmpFileName)
|
|
}()
|
|
|
|
err = runTemplate(template, templateData, fh)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fh.Close()
|
|
|
|
editor, ok := c.opts["editor"].(string)
|
|
if !ok {
|
|
editor = os.Getenv("JIRA_EDITOR")
|
|
if editor == "" {
|
|
editor = os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vim"
|
|
}
|
|
}
|
|
}
|
|
|
|
editing := c.getOptBool("edit", true)
|
|
|
|
tmpFileNameOrig := fmt.Sprintf("%s.orig", tmpFileName)
|
|
copyFile(tmpFileName, tmpFileNameOrig)
|
|
defer func() {
|
|
os.Remove(tmpFileNameOrig)
|
|
}()
|
|
|
|
for true {
|
|
if editing {
|
|
shell, _ := shellquote.Split(editor)
|
|
shell = append(shell, tmpFileName)
|
|
log.Debug("Running: %#v", shell)
|
|
cmd := exec.Command(shell[0], shell[1:]...)
|
|
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
|
|
if err := cmd.Run(); err != nil {
|
|
log.Error("Failed to edit template with %s: %s", editor, err)
|
|
if promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
diff := exec.Command("diff", "-q", tmpFileNameOrig, tmpFileName)
|
|
// if err == nil then diff found no changes
|
|
if err := diff.Run(); err == nil {
|
|
return NoChangesFound{}
|
|
}
|
|
}
|
|
|
|
edited := make(map[string]interface{})
|
|
if fh, err := ioutil.ReadFile(tmpFileName); err != nil {
|
|
log.Error("Failed to read tmpfile %s: %s", tmpFileName, err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
} else {
|
|
if err := yaml.Unmarshal(fh, &edited); err != nil {
|
|
log.Error("Failed to parse YAML: %s", err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
if fixed, err := yamlFixup(edited); err != nil {
|
|
return err
|
|
} else {
|
|
edited = fixed.(map[string]interface{})
|
|
}
|
|
|
|
// if you want to abort editing a jira issue then
|
|
// you can add the "abort: true" flag to the document
|
|
// and we will abort now
|
|
if val, ok := edited["abort"].(bool); ok && val {
|
|
log.Info("abort flag found in template, quiting")
|
|
return fmt.Errorf("abort flag found in template, quiting")
|
|
}
|
|
|
|
if _, ok := templateData["meta"]; ok {
|
|
mf := templateData["meta"].(map[string]interface{})["fields"]
|
|
if f, ok := edited["fields"].(map[string]interface{}); ok {
|
|
for k := range f {
|
|
if _, ok := mf.(map[string]interface{})[k]; !ok {
|
|
err := fmt.Errorf("Field %s is not editable", k)
|
|
log.Error("%s", err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
json, err := jsonEncode(edited)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := templateProcessor(json); err != nil {
|
|
log.Error("%s", err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Cli) Browse(issue string) error {
|
|
if val, ok := c.opts["browse"].(bool); ok && val {
|
|
if runtime.GOOS == "darwin" {
|
|
return exec.Command("open", fmt.Sprintf("%s/browse/%s", c.endpoint, issue)).Run()
|
|
} else if runtime.GOOS == "linux" {
|
|
return exec.Command("xdg-open", fmt.Sprintf("%s/browse/%s", c.endpoint, issue)).Run()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Cli) SaveData(data interface{}) error {
|
|
if val, ok := c.opts["saveFile"].(string); ok && val != "" {
|
|
yamlWrite(val, data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Cli) FindIssues() (interface{}, error) {
|
|
var query string
|
|
var ok bool
|
|
// project = BAKERY and status not in (Resolved, Closed)
|
|
if query, ok = c.opts["query"].(string); !ok {
|
|
qbuff := bytes.NewBufferString("resolution = unresolved")
|
|
if project, ok := c.opts["project"]; !ok {
|
|
err := fmt.Errorf("Missing required arguments, either 'query' or 'project' are required")
|
|
log.Error("%s", err)
|
|
return nil, err
|
|
} else {
|
|
qbuff.WriteString(fmt.Sprintf(" AND project = '%s'", project))
|
|
}
|
|
|
|
if component, ok := c.opts["component"]; ok {
|
|
qbuff.WriteString(fmt.Sprintf(" AND component = '%s'", component))
|
|
}
|
|
|
|
if assignee, ok := c.opts["assignee"]; ok {
|
|
qbuff.WriteString(fmt.Sprintf(" AND assignee = '%s'", assignee))
|
|
}
|
|
|
|
if issuetype, ok := c.opts["issuetype"]; ok {
|
|
qbuff.WriteString(fmt.Sprintf(" AND issuetype = '%s'", issuetype))
|
|
}
|
|
|
|
if watcher, ok := c.opts["watcher"]; ok {
|
|
qbuff.WriteString(fmt.Sprintf(" AND watcher = '%s'", watcher))
|
|
}
|
|
|
|
if reporter, ok := c.opts["reporter"]; ok {
|
|
qbuff.WriteString(fmt.Sprintf(" AND reporter = '%s'", reporter))
|
|
}
|
|
|
|
if sort, ok := c.opts["sort"]; ok && sort != "" {
|
|
qbuff.WriteString(fmt.Sprintf(" ORDER BY %s", sort))
|
|
}
|
|
|
|
query = qbuff.String()
|
|
}
|
|
|
|
fields := make([]string, 0)
|
|
if qf, ok := c.opts["queryfields"].(string); ok {
|
|
fields = strings.Split(qf, ",")
|
|
} else {
|
|
fields = append(fields, "summary")
|
|
}
|
|
|
|
json, err := jsonEncode(map[string]interface{}{
|
|
"jql": query,
|
|
"startAt": "0",
|
|
"maxResults": c.opts["max_results"],
|
|
"fields": fields,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
uri := fmt.Sprintf("%s/rest/api/2/search", c.endpoint)
|
|
if data, err := responseToJson(c.post(uri, json)); err != nil {
|
|
return nil, err
|
|
} else {
|
|
return data, nil
|
|
}
|
|
}
|
|
|
|
func (c *Cli) getOptString(optName string, dflt string) string {
|
|
if val, ok := c.opts[optName].(string); ok {
|
|
return val
|
|
} else {
|
|
return dflt
|
|
}
|
|
}
|
|
|
|
func (c *Cli) getOptBool(optName string, dflt bool) bool {
|
|
if val, ok := c.opts[optName].(bool); ok {
|
|
return val
|
|
} else {
|
|
return dflt
|
|
}
|
|
}
|