mirror of
https://github.com/Threnklyn/jira.git
synced 2026-05-18 20:23:28 +02:00
587 lines
15 KiB
Go
587 lines
15 KiB
Go
package jira
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/kballard/go-shellquote"
|
|
"gopkg.in/coryb/yaml.v2"
|
|
"gopkg.in/op/go-logging.v1"
|
|
"gopkg.in/Netflix-Skunkworks/go-jira.v1/data"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const VERSION = "0.1.7"
|
|
|
|
var log = logging.MustGetLogger("jira")
|
|
|
|
// Cli is go-jira client object
|
|
type Cli struct {
|
|
endpoint *url.URL
|
|
opts map[string]interface{}
|
|
cookieFile string
|
|
ua *http.Client
|
|
}
|
|
|
|
// New creates go-jira client object
|
|
func New(opts map[string]interface{}) *Cli {
|
|
homedir := homedir()
|
|
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: filepath.Join(homedir, ".jira.d", "cookies.js"),
|
|
ua: &http.Client{
|
|
Jar: cookieJar,
|
|
Transport: transport,
|
|
},
|
|
}
|
|
|
|
cli.ua.Jar.SetCookies(url, cli.loadCookies())
|
|
|
|
return cli
|
|
}
|
|
|
|
func (c *Cli) saveCookies(resp *http.Response) {
|
|
if _, ok := resp.Header["Set-Cookie"]; !ok {
|
|
return
|
|
}
|
|
|
|
cookies := resp.Cookies()
|
|
for _, cookie := range cookies {
|
|
if cookie.Domain == "" {
|
|
// if it is host:port then we need to split off port
|
|
parts := strings.Split(resp.Request.URL.Host, ":")
|
|
host := parts[0]
|
|
log.Debugf("Setting DOMAIN to %s for Cookie: %s", host, cookie)
|
|
cookie.Domain = host
|
|
}
|
|
}
|
|
|
|
// 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.Domain] = cookie
|
|
}
|
|
|
|
for _, cookie := range cookies {
|
|
currentCookiesByName[cookie.Name+cookie.Domain] = cookie
|
|
}
|
|
|
|
mergedCookies := make([]*http.Cookie, 0, len(currentCookiesByName))
|
|
for _, v := range currentCookiesByName {
|
|
mergedCookies = append(mergedCookies, v)
|
|
}
|
|
jsonWrite(c.cookieFile, mergedCookies)
|
|
} else {
|
|
mkdir(path.Dir(c.cookieFile))
|
|
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.Errorf("Failed to open %s: %s", c.cookieFile, err)
|
|
panic(err)
|
|
}
|
|
cookies := []*http.Cookie{}
|
|
err = json.Unmarshal(bytes, &cookies)
|
|
if err != nil {
|
|
log.Errorf("Failed to parse json from file %s: %s", c.cookieFile, err)
|
|
}
|
|
|
|
if os.Getenv("LOG_TRACE") != "" && log.IsEnabledFor(logging.DEBUG) {
|
|
log.Debugf("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) (resp *http.Response, err error) {
|
|
method := "DELETE"
|
|
req, _ := http.NewRequest(method, uri, nil)
|
|
log.Infof("%s %s", req.Method, req.URL.String())
|
|
if resp, err = c.makeRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
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) (resp *http.Response, err error) {
|
|
buffer := bytes.NewBufferString(content)
|
|
req, _ := http.NewRequest(method, uri, buffer)
|
|
|
|
log.Infof("%s %s", req.Method, req.URL.String())
|
|
if resp, err = c.makeRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
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) (resp *http.Response, err error) {
|
|
req, _ := http.NewRequest("GET", uri, nil)
|
|
log.Infof("%s %s", req.Method, req.URL.String())
|
|
if log.IsEnabledFor(logging.DEBUG) {
|
|
logBuffer := bytes.NewBuffer(make([]byte, 0))
|
|
req.Write(logBuffer)
|
|
log.Debugf("%s", logBuffer)
|
|
}
|
|
|
|
if resp, err = c.makeRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
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("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// this is actually done in http.send but doing it
|
|
// here so we can log it in DumpRequest for debugging
|
|
for _, cookie := range c.ua.Jar.Cookies(req.URL) {
|
|
req.AddCookie(cookie)
|
|
}
|
|
|
|
if log.IsEnabledFor(logging.DEBUG) {
|
|
out, _ := httputil.DumpRequest(req, true)
|
|
log.Debugf("Request: %s", out)
|
|
}
|
|
|
|
if resp, err = c.ua.Do(req); err != nil {
|
|
log.Errorf("Failed to %s %s: %s", req.Method, req.URL.String(), err)
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 && resp.StatusCode != 401 {
|
|
log.Errorf("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)
|
|
}
|
|
if log.IsEnabledFor(logging.DEBUG) {
|
|
out, _ := httputil.DumpResponse(resp, true)
|
|
log.Debugf("Response: %s", out)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// GetTemplate will return the text/template for the given command name
|
|
func (c *Cli) GetTemplate(name string) string {
|
|
return c.getTemplate(name)
|
|
}
|
|
|
|
func getLookedUpTemplate(name string, dflt string) string {
|
|
if file, err := FindClosestParentPath(filepath.Join(".jira.d", "templates", name)); err == nil {
|
|
return readFile(file)
|
|
}
|
|
if _, err := os.Stat(fmt.Sprintf("/etc/go-jira/templates/%s", name)); err == nil {
|
|
file := fmt.Sprintf("/etc/go-jira/templates/%s", name)
|
|
return readFile(file)
|
|
}
|
|
return dflt
|
|
}
|
|
|
|
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)
|
|
}
|
|
if t := getLookedUpTemplate(override, allTemplates[override]); t != "" {
|
|
return t
|
|
}
|
|
}
|
|
// create-bug etc are special, if we dont find it in the path
|
|
// then just return the create template
|
|
if strings.HasPrefix(name, "create-") {
|
|
return getLookedUpTemplate(name, c.getTemplate("create"))
|
|
}
|
|
return getLookedUpTemplate(name, allTemplates[name])
|
|
}
|
|
|
|
// NoChangesFound is an error returned from when editing templates
|
|
// and no modifications were made while editing
|
|
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 := filepath.Join(homedir(), ".jira.d", "tmp")
|
|
if err := mkdir(tmpdir); err != nil {
|
|
return err
|
|
}
|
|
|
|
fh, err := ioutil.TempFile(tmpdir, tmpFilePrefix)
|
|
if err != nil {
|
|
log.Errorf("Failed to make temp file in %s: %s", tmpdir, err)
|
|
return err
|
|
}
|
|
|
|
oldFileName := fh.Name()
|
|
tmpFileName := fmt.Sprintf("%s.yml", oldFileName)
|
|
|
|
// close tmpfile so we can rename on windows
|
|
fh.Close()
|
|
|
|
if err := os.Rename(oldFileName, tmpFileName); err != nil {
|
|
log.Errorf("Failed to rename %s to %s: %s", oldFileName, tmpFileName, err)
|
|
return err
|
|
}
|
|
|
|
fh, err = os.OpenFile(tmpFileName, os.O_RDWR|os.O_EXCL, 0600)
|
|
if err != nil {
|
|
log.Errorf("Failed to reopen temp file file in %s: %s", tmpFileName, err)
|
|
return err
|
|
}
|
|
|
|
defer fh.Close()
|
|
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.Debugf("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.Errorf("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{})
|
|
var data []byte
|
|
if data, err = ioutil.ReadFile(tmpFileName); err != nil {
|
|
log.Errorf("Failed to read tmpfile %s: %s", tmpFileName, err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
if err := yaml.Unmarshal(data, &edited); err != nil {
|
|
log.Errorf("Failed to parse YAML: %s", err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
|
|
var fixed interface{}
|
|
if fixed, err = yamlFixup(edited); err != nil {
|
|
return err
|
|
}
|
|
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.Infof("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.Errorf("%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.Errorf("%s", err)
|
|
if editing && promptYN("edit again?", true) {
|
|
continue
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Browse will open up your default browser to the provided issue
|
|
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
|
|
}
|
|
|
|
// SaveData will write out the yaml formated --saveFile file with provided data
|
|
func (c *Cli) SaveData(data interface{}) error {
|
|
if val, ok := c.opts["saveFile"].(string); ok && val != "" {
|
|
yamlWrite(val, data)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ViewIssueWorkLogs gets the worklog data for the given issue
|
|
func (c *Cli) ViewIssueWorkLogs(issue string) (interface{}, error) {
|
|
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/worklog", c.endpoint, issue)
|
|
data, err := responseToJSON(c.get(uri))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// ViewIssue will return the details for the given issue id
|
|
func (c *Cli) ViewIssue(issue string) (interface{}, error) {
|
|
uri := fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue)
|
|
if x := c.expansions(); len(x) > 0 {
|
|
uri = fmt.Sprintf("%s?expand=%s", uri, strings.Join(x, ","))
|
|
}
|
|
|
|
data, err := responseToJSON(c.get(uri))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type QueryOptions struct {
|
|
Assignee string
|
|
}
|
|
|
|
// FindIssues will return a list of issues that match the given options.
|
|
// If the "query" option is undefined it will generate a JQL query
|
|
// using any/all of the provide options: project, component, assignee,
|
|
// issuetype, watcher, reporter, sort
|
|
// Further it will restrict the fields being extracted from the jira
|
|
// response with the 'queryfields' option
|
|
func (c *Cli) FindIssues(sp SearchProvider) (*jiradata.SearchResults, error) {
|
|
req := sp.SearchRequest()
|
|
encoded, err := jsonEncode(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
uri := fmt.Sprintf("%s/reest/api/2/search", c.endpoint)
|
|
resp, err := c.post(uri, encoded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
results := &jiradata.SearchResults{}
|
|
content, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = json.Unmarshal(content, results)
|
|
if err != nil {
|
|
log.Errorf("JSON Parse Error: %s from %s", err, content)
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// RankOrder type used to specify before/after ranking arguments to RankIssue
|
|
type RankOrder int
|
|
|
|
const (
|
|
// RANKBEFORE should be used to rank issue before the target issue
|
|
RANKBEFORE RankOrder = iota
|
|
// RANKAFTER should be used to rank issue after the target issue
|
|
RANKAFTER RankOrder = iota
|
|
)
|
|
|
|
// RankIssue will modify issue to have rank before or after the target issue
|
|
func (c *Cli) RankIssue(issue, target string, order RankOrder) error {
|
|
type RankRequest struct {
|
|
Issues []string `json:"issues"`
|
|
Before string `json:"rankBeforeIssue,omitempty"`
|
|
After string `json:"rankAfterIssue,omitempty"`
|
|
}
|
|
req := &RankRequest{
|
|
Issues: []string{
|
|
issue,
|
|
},
|
|
}
|
|
if order == RANKBEFORE {
|
|
req.Before = target
|
|
} else {
|
|
req.After = target
|
|
}
|
|
|
|
json, err := jsonEncode(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
uri := fmt.Sprintf("%s/rest/agile/1.0/issue/rank", c.endpoint)
|
|
if c.getOptBool("dryrun", false) {
|
|
log.Debugf("PUT: %s", json)
|
|
log.Debugf("Dryrun mode, skipping PUT")
|
|
return nil
|
|
}
|
|
resp, err := c.put(uri, json)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != 204 {
|
|
return fmt.Errorf("failed to modify issue rank: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetOptString will extract the string from the Cli object options
|
|
// otherwise return the provided default
|
|
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
|
|
}
|
|
return dflt
|
|
}
|
|
|
|
// GetOptBool will extract the boolean value from the Client object options
|
|
// otherwise return the provided default\
|
|
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
|
|
}
|
|
return dflt
|
|
}
|
|
|
|
// expansions returns a comma-separated list of values for field expansion
|
|
func (c *Cli) expansions() []string {
|
|
var expansions []string
|
|
if x, ok := c.opts["expand"].(string); ok {
|
|
expansions = strings.Split(x, ",")
|
|
}
|
|
return expansions
|
|
}
|