mirror of
https://github.com/Threnklyn/jira.git
synced 2026-05-19 12:43:30 +02:00
f7eb04e36d
This adjusts the CmdWatch interface as per discussion in https://github.com/Netflix-Skunkworks/go-jira/pull/26 It also exposes public versions of the c.getOptString and c.getOptBool utility functions, again as discussed. The interface to CmdWatch now includes the user to be watched (rather than depending on the opt[] map. This makes CmdWatch more useful externally. A '--remove' option has been created, to allow for removal of a given watcher. This was deliberately not included in the defaults map, as it is specifically only used for 'watch' command right now. It should be moved up to a default if it becomes a more common option, I guess (as 'remove is false' isn't a bad default)
500 lines
12 KiB
Go
500 lines
12 KiB
Go
package jira
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"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, "/"))
|
|
|
|
transport := &http.Transport{
|
|
TLSClientConfig: &tls.Config{},
|
|
}
|
|
|
|
if project, ok := opts["project"].(string); ok {
|
|
opts["project"] = strings.ToUpper(project)
|
|
}
|
|
|
|
if insecureSkipVerify, ok := opts["insecure"].(bool); ok {
|
|
transport.TLSClientConfig.InsecureSkipVerify = insecureSkipVerify
|
|
}
|
|
|
|
cli := &Cli{
|
|
endpoint: url,
|
|
opts: opts,
|
|
cookieFile: fmt.Sprintf("%s/.jira.d/cookies.js", homedir),
|
|
ua: &http.Client{
|
|
Jar: cookieJar,
|
|
Transport: transport,
|
|
},
|
|
}
|
|
|
|
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) delete(uri string) (*http.Response, error) {
|
|
method := "DELETE"
|
|
req, _ := http.NewRequest(method, uri, nil)
|
|
log.Info("%s %s", req.Method, req.URL.String())
|
|
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, nil)
|
|
return c.makeRequest(req)
|
|
}
|
|
return resp, err
|
|
}
|
|
}
|
|
|
|
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) ViewIssue(issue string) (interface{}, error) {
|
|
uri := fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue)
|
|
data, err := responseToJson(c.get(uri))
|
|
if err != nil {
|
|
return nil, err
|
|
} else {
|
|
return data, 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 {
|
|
return c.getOptString(optName, dflt)
|
|
}
|
|
|
|
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 {
|
|
return c.getOptBool(optName, dflt)
|
|
}
|
|
|
|
func (c *Cli) getOptBool(optName string, dflt bool) bool {
|
|
if val, ok := c.opts[optName].(bool); ok {
|
|
return val
|
|
} else {
|
|
return dflt
|
|
}
|
|
}
|