mirror of
https://github.com/Threnklyn/jira.git
synced 2026-06-06 21:20:48 +02:00
initial checkin, work in progress
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
"io"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logging.MustGetLogger("jira.cli")
|
||||||
|
|
||||||
|
type Cli struct {
|
||||||
|
endpoint *url.URL
|
||||||
|
opts map[string]string
|
||||||
|
cookieFile string
|
||||||
|
ua *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(opts map[string]string) *Cli {
|
||||||
|
homedir := os.Getenv("HOME")
|
||||||
|
cookieJar, _ := cookiejar.New(nil)
|
||||||
|
endpoint, _ := opts["endpoint"]
|
||||||
|
url, _ := url.Parse(endpoint)
|
||||||
|
|
||||||
|
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 io.Reader) *http.Response {
|
||||||
|
req, _ := http.NewRequest("POST", uri, content)
|
||||||
|
return c.makeRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) get(uri string) *http.Response {
|
||||||
|
req, _ := http.NewRequest("GET", uri, nil)
|
||||||
|
return c.makeRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) makeRequest(req *http.Request) *http.Response {
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.ua.Do(req)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
log.Error("response status: %s", resp.Status)
|
||||||
|
resp.Write(os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.SetFinalizer(resp, func(r *http.Response) {
|
||||||
|
r.Body.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, ok := resp.Header["Set-Cookie"]; ok {
|
||||||
|
c.saveCookies(resp.Cookies())
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"code.google.com/p/gopass"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Cli) CmdLogin() {
|
||||||
|
uri := fmt.Sprintf("%s/rest/auth/1/session", c.endpoint)
|
||||||
|
resp := c.get(uri)
|
||||||
|
for ; resp.StatusCode != 200 ; {
|
||||||
|
req, _ := http.NewRequest("GET", uri, nil)
|
||||||
|
user, _ := c.opts["user"]
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf("Enter Password for %s: ", user)
|
||||||
|
passwd, _ := gopass.GetPass(prompt);
|
||||||
|
|
||||||
|
req.SetBasicAuth(user, passwd)
|
||||||
|
resp = c.makeRequest(req)
|
||||||
|
if resp.StatusCode == 403 {
|
||||||
|
// probably got this, need to redirect the user to login manually
|
||||||
|
// X-Authentication-Denied-Reason: CAPTCHA_CHALLENGE; login-url=https://jira/login.jsp
|
||||||
|
if reason := resp.Header.Get("X-Authentication-Denied-Reason"); reason != "" {
|
||||||
|
log.Error("Authentication Failed: %s", reason)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Error("Authentication Failead: Unknown")
|
||||||
|
os.Exit(1)
|
||||||
|
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
log.Error("Login failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) CmdFields() {
|
||||||
|
log.Debug("fields called")
|
||||||
|
resp := c.get(fmt.Sprintf("%s/rest/api/2/field", c.endpoint))
|
||||||
|
data := jsonDecode(resp.Body)
|
||||||
|
|
||||||
|
if templateFile, err := FindClosestParentPath(".jira.d/templates/fields"); err != nil {
|
||||||
|
runTemplate(default_fields_template, data)
|
||||||
|
} else {
|
||||||
|
log.Debug("Using Template: %s", templateFile)
|
||||||
|
runTemplate(readFile(templateFile), data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) CmdList() {
|
||||||
|
log.Debug("list called")
|
||||||
|
|
||||||
|
if query, ok := c.opts["query"]; !ok {
|
||||||
|
log.Error("No query argument found, either use --query or set query attribute in .jira file")
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
buffer := bytes.NewBuffer(make([]byte, 0, len(query)))
|
||||||
|
enc := json.NewEncoder(buffer)
|
||||||
|
|
||||||
|
enc.Encode(map[string]string{
|
||||||
|
"jql": query,
|
||||||
|
"startAt": "0",
|
||||||
|
"maxResults": "500",
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := c.post(fmt.Sprintf("%s/rest/api/2/search", c.endpoint), buffer)
|
||||||
|
data := jsonDecode(resp.Body)
|
||||||
|
|
||||||
|
if templateFile, err := FindClosestParentPath(".jira.d/templates/list"); err != nil {
|
||||||
|
runTemplate(default_list_template, data)
|
||||||
|
} else {
|
||||||
|
log.Debug("Using Template: %s", templateFile)
|
||||||
|
runTemplate(readFile(templateFile), data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cli) CmdView(issue string) {
|
||||||
|
log.Debug("view called")
|
||||||
|
resp := c.get(fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue))
|
||||||
|
data := jsonDecode(resp.Body)
|
||||||
|
if templateFile, err := FindClosestParentPath(".jira.d/templates/view"); err != nil {
|
||||||
|
runTemplate(default_view_template, data)
|
||||||
|
} else {
|
||||||
|
log.Debug("Using Template: %s", templateFile)
|
||||||
|
runTemplate(readFile(templateFile), data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
const default_fields_template = "{{ . | toJson}}\n"
|
||||||
|
|
||||||
|
const default_list_template = "{{ range .issues }}{{ .key | append \":\" | printf \"%-12s\"}} {{ .fields.summary }}\n{{ end }}"
|
||||||
|
|
||||||
|
const default_view_template = `issue: {{ .key }}
|
||||||
|
summary: {{ .fields.summary }}
|
||||||
|
project: {{ .fields.project.key }}
|
||||||
|
components: {{ range .fields.components }}{{ .name }} {{end}}
|
||||||
|
issuetype: {{ .fields.issuetype.name }}
|
||||||
|
assignee: {{ .fields.assignee.name }}
|
||||||
|
reporter: {{ .fields.reporter.name }}
|
||||||
|
watchers: {{ range .fields.customfield_10110 }}{{ .name }} {{end}}
|
||||||
|
blockers: {{ range .fields.issuelinks }}{{if .outwardIssue}}{{ .outwardIssue.key }}{{end}}{{end}}
|
||||||
|
depends: {{ range .fields.issuelinks }}{{if .inwardIssue}}{{ .inwardIssue.key }}{{end}}{{end}}
|
||||||
|
priority: {{ .fields.priority.name }}
|
||||||
|
description: |
|
||||||
|
{{ .fields.description | indent 2 }}
|
||||||
|
|
||||||
|
comments:
|
||||||
|
{{ range .fields.comment.comments }} - | # {{.author.name}} at {{.created}}
|
||||||
|
{{ .body | indent 4}}{{end}}
|
||||||
|
`
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"fmt"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"text/template"
|
||||||
|
"io"
|
||||||
|
"github.com/mgutz/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FindParentPaths(fileName string) []string {
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
|
||||||
|
paths := make([]string,0)
|
||||||
|
|
||||||
|
var dir string
|
||||||
|
for _, part := range strings.Split(cwd, string(os.PathSeparator)) {
|
||||||
|
if dir == "/" {
|
||||||
|
dir = fmt.Sprintf("/%s", part)
|
||||||
|
} else {
|
||||||
|
dir = fmt.Sprintf("%s/%s", dir, part)
|
||||||
|
}
|
||||||
|
file := fmt.Sprintf("%s/%s", dir, fileName)
|
||||||
|
if _, err := os.Stat(file); err == nil {
|
||||||
|
paths = append(paths, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindClosestParentPath(fileName string) (string,error) {
|
||||||
|
paths := FindParentPaths(fileName)
|
||||||
|
if len(paths) > 0 {
|
||||||
|
return paths[len(paths)-1], nil
|
||||||
|
}
|
||||||
|
return "", errors.New(fmt.Sprintf("%s not found in parent directory hierarchy", fileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(file string) string {
|
||||||
|
var bytes []byte
|
||||||
|
var err error
|
||||||
|
if bytes, err = ioutil.ReadFile(file); err != nil {
|
||||||
|
log.Error("Failed to read file %s: %s", file, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return string(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTemplate(text string, data interface{}) {
|
||||||
|
funcs := map[string]interface{}{
|
||||||
|
"toJson": func(content interface{}) (string, error) {
|
||||||
|
if bytes, err := json.MarshalIndent(content, "", " "); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else {
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"append": func(more string, content interface{}) (string, error) {
|
||||||
|
switch value := content.(type) {
|
||||||
|
case string: return string(append([]byte(content.(string)), []byte(more)...)), nil
|
||||||
|
case []byte: return string(append(content.([]byte), []byte(more)...)), nil
|
||||||
|
default: return "", errors.New(fmt.Sprintf("Unknown type: %s", value))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indent": func(spaces int, content string) string {
|
||||||
|
indent := make([]byte, spaces + 1, spaces +1)
|
||||||
|
indent[0] = '\n'
|
||||||
|
for i := 1; i < spaces + 1; i += 1 {
|
||||||
|
indent[i] = ' '
|
||||||
|
}
|
||||||
|
return strings.Replace(content, "\n", string(indent), -1)
|
||||||
|
},
|
||||||
|
"color": func(color string) string {
|
||||||
|
return ansi.ColorCode(color)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if tmpl, err := template.New("template").Funcs(funcs).Parse(text); err != nil {
|
||||||
|
log.Error("Failed to parse template: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
if err := tmpl.Execute(os.Stdout, data); err != nil {
|
||||||
|
log.Error("Failed to execute template: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonDecode(io io.Reader) interface{} {
|
||||||
|
content, err := ioutil.ReadAll(io)
|
||||||
|
var data interface{}
|
||||||
|
err = json.Unmarshal(content, &data)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("JSON Parse Error: %s from %s", err, content)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonWrite(file string, data interface{}) {
|
||||||
|
fh, err := os.OpenFile(file, os.O_WRONLY | os.O_CREATE | os.O_TRUNC, 0600)
|
||||||
|
defer fh.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to open %s: %s", file, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(fh)
|
||||||
|
enc.Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/docopt/docopt-go"
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"io/ioutil"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
"github.com/Netflix-Skunkworks/go-jira/jira/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = logging.MustGetLogger("jira")
|
||||||
|
var format = "%{color}%{time:2006-01-02T15:04:05.000Z07:00} %{level:-5s} [%{shortfile}]%{color:reset} %{message}"
|
||||||
|
|
||||||
|
func parseYaml(file string, opts map[string]string) {
|
||||||
|
if fh, err := ioutil.ReadFile(file); err == nil {
|
||||||
|
log.Debug("Found Config file: %s", file)
|
||||||
|
yaml.Unmarshal(fh, &opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigs(opts map[string]string) {
|
||||||
|
paths := cli.FindParentPaths(".jira")
|
||||||
|
// prepend
|
||||||
|
paths = append([]string{"/etc/jira-cli.yml"}, paths...)
|
||||||
|
|
||||||
|
for _, file := range(paths) {
|
||||||
|
parseYaml(file, opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
user := os.Getenv("USER")
|
||||||
|
usage := fmt.Sprintf(`
|
||||||
|
Usage:
|
||||||
|
jira [-v ...] [-u USER] [-e URI] [-t FILE] fields
|
||||||
|
jira [-v ...] [-u USER] [-e URI] [-t FILE] ls [--query=JQL]
|
||||||
|
jira [-v ...] [-u USER] [-e URI] [-t FILE] view ISSUE
|
||||||
|
jira [-v ...] [-u USER] [-e URI] [-t FILE] ISSUE
|
||||||
|
|
||||||
|
|
||||||
|
General Options:
|
||||||
|
-h --help Show this usage
|
||||||
|
--version Show this version
|
||||||
|
-v --verbose Increase output logging
|
||||||
|
-u --user=USER Username to use for authenticaion (default: %s)
|
||||||
|
-e --endpoint=URI URI to use for jira (default: https://jira)
|
||||||
|
-t --template=FILE Template file to use for output
|
||||||
|
|
||||||
|
List options:
|
||||||
|
-q --query=FILE Template to use for output
|
||||||
|
`, user)
|
||||||
|
|
||||||
|
args, _ := docopt.Parse(usage, nil, true, "0.0.1", false, false)
|
||||||
|
logBackend := logging.NewLogBackend(os.Stderr, "", 0)
|
||||||
|
logging.SetBackend(
|
||||||
|
logging.NewBackendFormatter(
|
||||||
|
logBackend,
|
||||||
|
logging.MustStringFormatter(format),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
logging.SetLevel(logging.NOTICE, "")
|
||||||
|
if verbose, ok := args["--verbose"]; ok {
|
||||||
|
if verbose.(int) > 1 {
|
||||||
|
logging.SetLevel(logging.DEBUG, "")
|
||||||
|
} else if verbose.(int) > 0 {
|
||||||
|
logging.SetLevel(logging.INFO, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Args: %v", args)
|
||||||
|
|
||||||
|
|
||||||
|
opts := make(map[string]string)
|
||||||
|
loadConfigs(opts)
|
||||||
|
|
||||||
|
for key,val := range args {
|
||||||
|
if val != nil && strings.HasPrefix(key, "--") {
|
||||||
|
opt := key[2:]
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
opts[opt] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := opts["endpoint"]; !ok {
|
||||||
|
opts["endpoint"] = "https://jira"
|
||||||
|
}
|
||||||
|
if _, ok := opts["user"]; !ok {
|
||||||
|
opts["user"] = user
|
||||||
|
}
|
||||||
|
|
||||||
|
c := cli.New(opts)
|
||||||
|
|
||||||
|
log.Debug("opts: %s", opts);
|
||||||
|
|
||||||
|
c.CmdLogin()
|
||||||
|
|
||||||
|
if val, ok := args["fields"]; ok && val.(bool) {
|
||||||
|
c.CmdFields()
|
||||||
|
} else if val, ok := args["ls"]; ok && val.(bool) {
|
||||||
|
c.CmdList()
|
||||||
|
} else if val, ok := args["ISSUE"]; ok {
|
||||||
|
c.CmdView(val.(string))
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user