Compare commits

...

50 Commits

Author SHA1 Message Date
Cory Bennett 03ad7ee9b2 bump version 2015-02-18 15:02:46 -08:00
Cory Bennett c1681b632c adding simple Makefile to make cross compiling a bit easier 2015-02-18 15:01:47 -08:00
Cory Bennett af4952621b adding missing --override arguments to various transition commands
add browse command
2015-02-18 15:01:12 -08:00
coryb 74178e917b Update README.md 2015-02-18 14:43:20 -08:00
coryb 56c610718d Update README.md 2015-02-18 14:38:47 -08:00
coryb 9d7272c45e Update README.md 2015-02-18 13:38:46 -08:00
coryb 94af4c46af Update README.md 2015-02-18 13:36:28 -08:00
coryb 8464ff1b41 Update README.md 2015-02-18 13:35:53 -08:00
Cory Bennett 87758ecc3b add download link 2015-02-18 13:29:20 -08:00
Cory Bennett 1cf15318a1 update usage in readme 2015-02-18 11:38:12 -08:00
Cory Bennett 6e2ee2b865 fill in issuetype before running the "create" template 2015-02-18 11:34:24 -08:00
Cory Bennett 6f0d1f5e44 minor tweak to prevent exceptions in some templates
set default for "commit" command to use editor
2015-02-17 16:03:32 -08:00
Cory Bennett 4524d89435 added created field to "view" template 2015-02-17 11:09:24 -08:00
Cory Bennett 1ac6929e56 fix view template for empty description 2015-02-17 10:40:11 -08:00
Cory Bennett 45fb06f6bf add "transition" editable template so you can modify other fields during transitions
add --noedit/--edit options for various commands
sanitize the yaml from the editor so we dont send empty strings when unedited
2015-02-17 10:19:54 -08:00
Cory Bennett 9bf7533fc2 add --watcher and --reporter options to "list" 2015-02-16 15:27:47 -08:00
Cory Bennett e9c866d38b dont write response to log unless request, otherwise response body will have
been read and unavailable to parse later in client code
2015-02-16 15:19:25 -08:00
Cory Bennett a7399c7f48 [issue #4] make sure ~/.jira.d/tmp exists before we write to it during create/edit 2015-02-16 14:31:39 -08:00
Cory Bennett 9d6fdf73e5 add --queryfields option to restrict data returned from jira search api. This is for performance
as the jira issue data can be quite large and we use almost none of it in the "list" template
2015-02-16 14:06:36 -08:00
Cory Bennett b5f62fb092 dont default issuetype unless it is a require param, otherwise "ls" is restricted 2015-02-16 13:21:15 -08:00
Cory Bennett b616e640dd adding --browse option to most command which should open the issue in your favorite browser 2015-02-16 10:34:04 -08:00
Cory Bennett 6a9afae5b4 Merge branch 'jaybuff-nil-assignee' 2015-02-16 09:51:43 -08:00
Cory Bennett 14a0ae1cc3 fix inner assignee template 2015-02-16 09:51:22 -08:00
Cory Bennett 25539efedd Merge branch 'nil-assignee' of https://github.com/jaybuff/go-jira into jaybuff-nil-assignee 2015-02-16 09:50:57 -08:00
Cory Bennett 421473140a fix indent for override coments in edit template 2015-02-16 08:53:37 -08:00
coryb 9fb23d6e00 Merge pull request #2 from jaybuff/clean-up
Clean up
2015-02-16 08:50:36 -08:00
Jay Buffington d5ac5e677e default view template shouldn't fail when assignee is nil
The default view template was failing with this error whenever I tried to
view a jira that had no assignee:
assignee: 2015-02-16T08:31:58.564-08:00 ERROR [util.go:109] Failed to execute template: template: template:7:20: executing "template" at <.fields.assignee.nam...>: nil pointer evaluating interface {}.name
2015-02-16 08:48:10 -08:00
Jay Buffington 306d66dba2 clean up README (mostly spelling fixes) 2015-02-16 08:44:12 -08:00
Jay Buffington eedbd94b98 add .gitignore 2015-02-16 08:32:18 -08:00
Cory Bennett 798016de78 [issue #1] trim trailing / on uri 2015-02-15 21:02:28 -08:00
Cory Bennett 025c5edc6d . 2015-02-13 16:58:57 -08:00
Cory Bennett 3caf524d3e update for golang minver 2015-02-13 16:58:26 -08:00
Cory Bennett 0806aa3202 udpate usage 2015-02-13 16:48:57 -08:00
Cory Bennett e1ee1fc29a tweak usage ordering
fix looking for generic "create" template
2015-02-13 16:48:10 -08:00
Cory Bennett 2cdb1d3cf7 udpate Editor section 2015-02-13 16:48:03 -08:00
Cory Bennett f95baf7c1c update 2015-02-13 16:38:20 -08:00
Cory Bennett 5c3c02f8d8 dont default endpoint make users specify it on command line or in config 2015-02-13 16:38:01 -08:00
Cory Bennett 76ec33e7e3 tweak getTemplate routine 2015-02-13 16:03:03 -08:00
Cory Bennett 97ad931f79 go fmt 2015-02-13 15:44:07 -08:00
Cory Bennett 4c1e0ec93e adding export-templates command
updated edit template to allow for -o overrides
2015-02-13 14:07:20 -08:00
Cory Bennett 7697594fe2 sort usage options, update README 2015-02-13 13:19:10 -08:00
Cory Bennett 60e4925fe4 add options to ls to allow for dynamically creating some simple JQL 2015-02-13 13:16:18 -08:00
Cory Bennett 48ee9ae8ba add take/give commands 2015-02-13 12:52:35 -08:00
Cory Bennett 0010215242 adding "comment" command 2015-02-13 12:33:47 -08:00
Cory Bennett 44bc16b02e handle various issue state transitions 2015-02-13 12:07:03 -08:00
Cory Bennett 18f10fd125 adding commands:
* create
* dups
* blocks
* watch
2015-02-12 23:41:39 -08:00
Cory Bennett acbc24b209 work in progress, minor refactor. Added commands:
* login
* editmeta ISSUE
* edit ISSUE
* issuetypes [-p PROJECT]
* createmeta [-p PROJECT] [-i ISSUETYPE]
* transitions ISSUE

make --template argumetn work
2015-02-12 15:50:08 -08:00
Cory Bennett 1d96b5549e need to set GOBIN for install to work 2015-02-10 16:31:48 -08:00
Cory Bennett bb6b7ee6af update readme, change config location 2015-02-10 16:27:16 -08:00
Cory Bennett 6936b27ea1 initial checkin, work in progress 2015-02-10 16:17:13 -08:00
8 changed files with 1779 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
bin/
pkg/
src/code.google.com/
src/github.com/docopt/
src/github.com/mgutz/
src/github.com/op/
src/gopkg.in/
+30
View File
@@ -0,0 +1,30 @@
PLATFORMS= \
freebsd-386 \
freebsd-amd64 \
freebsd-arm \
linux-386 \
linux-amd64 \
linux-arm \
openbsd-386 \
openbsd-amd64 \
windows-386 \
windows-amd64 \
darwin-386 \
darwin-amd64 \
$(NULL)
DIST=$(shell pwd)/dist
export GOPATH=$(shell pwd)
build:
cd src/github.com/Netflix-Skunkworks/go-jira/jira; \
go install -v
all:
mkdir -p $(DIST); \
cd src/github.com/Netflix-Skunkworks/go-jira/jira; \
go get -d; \
for p in $(PLATFORMS); do \
echo "Building for $$p"; \
GOOS=$${p/-*/} GOARCH=$${p/*-/} go build -v -o $(DIST)/jira-$$p; \
done
+184
View File
@@ -1,2 +1,186 @@
# go-jira
simple jira command line client in Go
## Synopsis
```bash
jira ls -p GOJIRA # list all unresolved issues for project GOJRIA
jira ls -p GOJIRA -a mothra # as above also assigned to user mothra
jira ls -p GOJIRA -w mothra # lists GOJIRA unresolved issues watched by user mothra
jira ls -p GOJIRA -r mothra # list GOJIRA unresolved issues reported by user mothra
jira view GOJIRA-321 # print Issue using "view" template
jira GOJIRA-321 # same as above
jira edit GOJIRA-321 # open up the issue in an editor, when you exit the editor
# the issue will post the updates to the server
# edit the issue, using the overirdes on the command line, skip the interactive editor:
jira edit GOJIRA-321 --noedit -o assignee=mothra -o comment="mothra, please take care of this." -o priority=Major
jira create -p GOJIRA # create new "Bug" type issue for project GOJIRA
jira create -p GOJIRA -i Task # create new Task type issue
jira trans close GOJIRA-321 # close issue, with interactive editor to be able to set other fields
jira close GOJIRA-321 --edit # same as above
# close the issue, set the resolution, and skip interactive editor:
jira trans close GOJIRA-321 -o resolution="Won't Fix" --noedit
# same as above
jira close GOJIRA-321 -o resolution="Won't Fix"
jira repopen GOJIRA-321 -m "reopening" # reopen issue
jira watch GOJIRA-321 # add self as watcher to the issue
jira comment GOJIRA-321 -m "done yet?" # add comment to the issue
jira take GOJIRA-321 # assign issue to self
jira give GOJIRA-321 mothra # assign issue to user mothra
# create local project config to set defaults
mkdir .jira.d
echo "project: GOJIRA" > .jira.d/config.yml
jira ls # list all unresolved issues for project GOJRIA
jira ls -a mothra # as above also assigned to user mothra
jira ls -w mothra # lists GOJIRA unresolved issues watched by user mothra
jira ls -r mothra # list GOJIRA unresolved issues reported by user mothra
jira create # create new "Bug" type issue for project GOJIRA
jira create -i Task # create new Task type issue
```
## Download
You can download one of the pre-built binaries for **go-jira** [here](https://github.com/Netflix-Skunkworks/go-jira/releases).
## Build
* **NOTE** You will need **`go-1.4.1`** minimum
* If you do not have a **GOPATH** setup, these are simple build steps:
```bash
git clone git@github.com:Netflix-Skunkworks/go-jira.git
cd go-jira
export GOPATH=$(pwd)
export GOBIN=$GOPATH/bin
export PATH=$GOBIN:$PATH
cd src/github.com/Netflix-Skunkworks/go-jira/jira
go get -v
```
* If you do have a **GOPATH** setup, these are the standard steps to build:
```
cd $GOPATH
git clone git@github.com:Netflix-Skunkworks/go-jira.git src/github.com/Netflix-Skunkworks/go-jira
cd src/github.com/Netflix-Skunkworks/go-jira/jira
go get -v
```
## Configuration
**go-jira** uses a configuration hierarchy. When loading the configuration from disk it will recursively look through
all parent directories in your current path looking for a **.jira.d** directory. If your current directory is not
a child directory of your homedir, then your homedir will also be inspected for a **.jira.d** directory. From all of **.jira.d** directories
discovered **go-jira** will load a **config.yml** if found. The configuration properties found in a file closests to your current working directory
will have precedence. Properties overriden with command line options will have final precedence.
The complicated configuration heirarchy is used because **go-jira** attempts to be context aware. For example, if you are working on a "foo" project and
you `cd` into your project workspace, wouldn't it be nice if `jira ls` automatically knew to list only issues related to the "foo" project? Likewise when you
`cd` to the "bar" project then `jira ls` should only list issues related to "bar" project. You can do this with by creating a configuration under your project
workspace at **./.jira.d/config.yml** that looks like:
```
project: foo
```
You will need to specify your local jira endpoint first, typically in your homedir like:
```bash
mkdir ~/.jira.d
cat <<EOM >~/.jira.d/config.yml
endpoint: https://jira.mycompany.com
EOM
```
### Editing
When you run command like `jira edit` it will open up your favorite editor with the templatized output so you can quickly edit. When the editor
closes **go-jira** will submit the completed form. The order which **go-jira** attempts to determine your prefered editor is:
* **editor** property in any config.yml file
* **JIRA_EDITOR** environment variable
* **EDITOR** environment variable
* vim
### Templates
**go-jira** has the ability to customize most output (and editor input) via templates. There are default templates available for all operations,
which may or may not work for your actual jira implementation. Jira is endlessly customizable, so it is hard to provide default templates
that will work for all issue types.
When running a command like `jira edit` it will look through the current directory hierarchy trying to find a file that matches **.jira.d/templates/edit**,
if found it will use that file as the template, otherwise it will use the default **edit** template hard-coded into **go-jira**. You can export the default
hard-coded templates with `jira export-templates` which will write them to **~/.jira.d/templates/**.
## Usage
```
Usage:
Usage:
jira [-v ...] [-u USER] [-e URI] [-t FILE] (ls|list) ( [-q JQL] | [-p PROJECT] [-c COMPONENT] [-a ASSIGNEE] [-i ISSUETYPE] [-w WATCHER] [-r REPORTER])
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] view ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] edit ISSUE [--noedit] [-m COMMENT] [-o KEY=VAL]...
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] create [--noedit] [-p PROJECT] [-i ISSUETYPE] [-o KEY=VAL]...
jira [-v ...] [-u USER] [-e URI] [-b] DUPLICATE dups ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] BLOCKER blocks ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] watch ISSUE [-w WATCHER]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] (trans|transition) TRANSITION ISSUE [-m COMMENT] [--noedit]
jira [-v ...] [-u USER] [-e URI] [-b] ack ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] close ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] resolve ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] reopen ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] start ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] stop ISSUE [-m COMMENT] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] comment ISSUE [-m COMMENT]
jira [-v ...] [-u USER] [-e URI] [-b] take ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] (assign|give) ISSUE ASSIGNEE
jira [-v ...] [-u USER] [-e URI] [-t FILE] fields
jira [-v ...] [-u USER] [-e URI] [-t FILE] issuelinktypes
jira [-v ...] [-u USER] [-e URI] [-b][-t FILE] transmeta ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] editmeta ISSUE
jira [-v ...] [-u USER] [-e URI] [-t FILE] issuetypes [-p PROJECT]
jira [-v ...] [-u USER] [-e URI] [-t FILE] createmeta [-p PROJECT] [-i ISSUETYPE]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] transitions ISSUE
jira [-v ...] export-templates [-d DIR]
jira [-v ...] [-u USER] [-e URI] [-t FILE] login
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] ISSUE
General Options:
-e --endpoint=URI URI to use for jira
-h --help Show this usage
-t --template=FILE Template file to use for output/editing
-u --user=USER Username to use for authenticaion (default: cbennett)
-v --verbose Increase output logging
--version Show this version
Command Options:
-a --assignee=USER Username assigned the issue
-b --browse Open your browser to the Jira issue
-c --component=COMPONENT Component to Search for
-d --directory=DIR Directory to export templates to (default: /Users/cbennett/.jira.d/templates)
-f --queryfields Fields that are used in "list" template: (default: summary)
-i --issuetype=ISSUETYPE Jira Issue Type (default: Bug)
-m --comment=COMMENT Comment message for transition
-o --override=KEY:VAL Set custom key/value pairs
-p --project=PROJECT Project to Search for
-q --query=JQL Jira Query Language expression for the search
-r --reporter=USER Reporter to search for
-w --watcher=USER Watcher to add to issue (default: cbennett)
or Watcher to search for
```
+322
View File
@@ -0,0 +1,322 @@
package cli
import (
"bytes"
"encoding/json"
"fmt"
"github.com/op/go-logging"
"gopkg.in/yaml.v2"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"time"
)
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(strings.TrimRight(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 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 {
if override, ok := c.opts["template"]; 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)
}
}
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
}
err = runTemplate(template, templateData, fh)
if err != nil {
return err
}
fh.Close()
editor, ok := c.opts["editor"]
if !ok {
editor = os.Getenv("JIRA_EDITOR")
if editor == "" {
editor = os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
}
}
}
editing := true
if val, ok := c.opts["edit"]; ok && val == "false" {
editing = false
}
for true {
if editing {
log.Debug("Running: %s %s", editor, tmpFileName)
cmd := exec.Command(editor, tmpFileName)
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
}
}
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 _, 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"]; ok && val == "true" {
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
}
+569
View File
@@ -0,0 +1,569 @@
package cli
import (
"bytes"
"code.google.com/p/gopass"
"fmt"
"net/http"
"os"
"strings"
// "github.com/kr/pretty"
)
func (c *Cli) CmdLogin() error {
uri := fmt.Sprintf("%s/rest/auth/1/session", c.endpoint)
for true {
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)
if resp, err := c.makeRequest(req); err != nil {
return err
} else {
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)
return fmt.Errorf("Authenticaion Failed: %s", reason)
}
log.Error("Authentication Failead: Unknown")
return fmt.Errorf("Authentication Failead")
}
if resp.StatusCode != 200 {
log.Warning("Login failed")
continue
}
}
return nil
}
return nil
}
func (c *Cli) CmdFields() error {
log.Debug("fields called")
uri := fmt.Sprintf("%s/rest/api/2/field", c.endpoint)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("fields"), data, nil)
}
func (c *Cli) CmdList() error {
log.Debug("list called")
var query string
var ok bool
// project = BAKERY and status not in (Resolved, Closed)
if query, ok = c.opts["query"]; !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 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))
}
query = qbuff.String()
}
fields := make([]string,0)
if qf, ok := c.opts["queryfields"]; ok {
fields = strings.Split(qf,",")
} else {
fields = append(fields, "summary")
}
json, err := jsonEncode(map[string]interface{}{
"jql": query,
"startAt": "0",
"maxResults": "500",
"fields": fields,
})
if err != nil {
return err
}
uri := fmt.Sprintf("%s/rest/api/2/search", c.endpoint)
data, err := responseToJson(c.post(uri, json))
if err != nil {
return err
}
return runTemplate(c.getTemplate("list"), data, nil)
}
func (c *Cli) CmdView(issue string) error {
log.Debug("view called")
c.Browse(issue)
uri := fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("view"), data, nil)
}
func (c *Cli) CmdEdit(issue string) error {
log.Debug("edit called")
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/editmeta", c.endpoint, issue)
editmeta, err := responseToJson(c.get(uri))
if err != nil {
return err
}
uri = fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue)
var issueData map[string]interface{}
if data, err := responseToJson(c.get(uri)); err != nil {
return err
} else {
issueData = data.(map[string]interface{})
}
issueData["meta"] = editmeta.(map[string]interface{})
issueData["overrides"] = c.opts
return c.editTemplate(
c.getTemplate("edit"),
fmt.Sprintf("%s-edit-", issue),
issueData,
func(json string) error {
resp, err := c.put(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 204 {
c.Browse(issueData["key"].(string))
fmt.Printf("OK %s %s/browse/%s\n", issueData["key"], c.endpoint, issueData["key"])
return nil
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From PUT")
log.Error("%s:\n%s", err, logBuffer)
return err
}
},
)
}
func (c *Cli) CmdEditMeta(issue string) error {
log.Debug("editMeta called")
c.Browse(issue)
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/editmeta", c.endpoint, issue)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("editmeta"), data, nil)
}
func (c *Cli) CmdTransitionMeta(issue string) error {
log.Debug("tranisionMeta called")
c.Browse(issue)
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/transitions?expand=transitions.fields", c.endpoint, issue)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("transmeta"), data, nil)
}
func (c *Cli) CmdIssueTypes(project string) error {
log.Debug("issueTypes called")
uri := fmt.Sprintf("%s/rest/api/2/issue/createmeta?projectKeys=%s", c.endpoint, project)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("issuetypes"), data, nil)
}
func (c *Cli) CmdCreateMeta(project string, issuetype string) error {
log.Debug("createMeta called")
uri := fmt.Sprintf("%s/rest/api/2/issue/createmeta?projectKeys=%s&issuetypeNames=%s&expand=projects.issuetypes.fields", c.endpoint, project, issuetype)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
if val, ok := data.(map[string]interface{})["projects"]; ok {
if val, ok = val.([]interface{})[0].(map[string]interface{})["issuetypes"]; ok {
data = val.([]interface{})[0]
}
}
return runTemplate(c.getTemplate("createmeta"), data, nil)
}
func (c *Cli) CmdTransitions(issue string) error {
log.Debug("Transitions called")
c.Browse(issue)
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/transitions", c.endpoint, issue)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("transitions"), data, nil)
}
func (c *Cli) CmdCreate(project string, issuetype string) error {
log.Debug("create called")
uri := fmt.Sprintf("%s/rest/api/2/issue/createmeta?projectKeys=%s&issuetypeNames=%s&expand=projects.issuetypes.fields", c.endpoint, project, issuetype)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
issueData := make(map[string]interface{})
issueData["overrides"] = c.opts
issueData["overrides"].(map[string]string)["issuetype"] = issuetype
if val, ok := data.(map[string]interface{})["projects"]; ok {
if val, ok = val.([]interface{})[0].(map[string]interface{})["issuetypes"]; ok {
issueData["meta"] = val.([]interface{})[0]
}
}
sanitizedType := strings.ToLower(strings.Replace(issuetype, " ", "", -1))
return c.editTemplate(
c.getTemplate(fmt.Sprintf("create-%s", sanitizedType)),
fmt.Sprintf("create-%s-", sanitizedType),
issueData,
func(json string) error {
log.Debug("JSON: %s", json)
uri := fmt.Sprintf("%s/rest/api/2/issue", c.endpoint)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 201 {
// response: {"id":"410836","key":"PROJ-238","self":"https://jira/rest/api/2/issue/410836"}
if json, err := responseToJson(resp, nil); err != nil {
return err
} else {
key := json.(map[string]interface{})["key"]
c.Browse(key.(string))
fmt.Printf("OK %s %s/browse/%s\n", key, c.endpoint, key)
}
return nil
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
},
)
return nil
}
func (c *Cli) CmdIssueLinkTypes() error {
log.Debug("Transitions called")
uri := fmt.Sprintf("%s/rest/api/2/issueLinkType", c.endpoint)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
return runTemplate(c.getTemplate("issuelinktypes"), data, nil)
}
func (c *Cli) CmdBlocks(blocker string, issue string) error {
log.Debug("blocks called")
json, err := jsonEncode(map[string]interface{}{
"type": map[string]string{
"name": "Depends", // TODO This is probably not constant across Jira installs
},
"inwardIssue": map[string]string{
"key": issue,
},
"outwardIssue": map[string]string{
"key": blocker,
},
})
if err != nil {
return err
}
uri := fmt.Sprintf("%s/rest/api/2/issueLink", c.endpoint)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 201 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
return nil
}
func (c *Cli) CmdDups(duplicate string, issue string) error {
log.Debug("dups called")
json, err := jsonEncode(map[string]interface{}{
"type": map[string]string{
"name": "Duplicate", // TODO This is probably not constant across Jira installs
},
"inwardIssue": map[string]string{
"key": duplicate,
},
"outwardIssue": map[string]string{
"key": issue,
},
})
if err != nil {
return err
}
uri := fmt.Sprintf("%s/rest/api/2/issueLink", c.endpoint)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 201 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
return nil
}
func (c *Cli) CmdWatch(issue string, watcher string) error {
log.Debug("watch called")
json, err := jsonEncode(watcher)
if err != nil {
return err
}
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/watchers", c.endpoint, issue)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 204 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
return nil
}
func (c *Cli) CmdTransition(issue string, trans string) error {
log.Debug("transition called")
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/transitions?expand=transitions.fields", c.endpoint, issue)
data, err := responseToJson(c.get(uri))
if err != nil {
return err
}
transitions := data.(map[string]interface{})["transitions"].([]interface{})
var transId, transName string
var transMeta map[string]interface{}
found := make([]string, 0, len(transitions))
for _, transition := range transitions {
name := transition.(map[string]interface{})["name"].(string)
id := transition.(map[string]interface{})["id"].(string)
found = append(found, name)
if strings.Contains(strings.ToLower(name), trans) {
transName = name
transId = id
transMeta = transition.(map[string]interface{})
}
}
if transId == "" {
err := fmt.Errorf("Invalid Transition '%s', Available: %s", trans, strings.Join(found, ", "))
log.Error("%s", err)
return err
}
handlePost := func(json string) error {
log.Debug("POST: %s", json)
// os.Exit(0)
uri = fmt.Sprintf("%s/rest/api/2/issue/%s/transitions", c.endpoint, issue)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 204 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
return nil
}
uri = fmt.Sprintf("%s/rest/api/2/issue/%s", c.endpoint, issue)
var issueData map[string]interface{}
if data, err := responseToJson(c.get(uri)); err != nil {
return err
} else {
issueData = data.(map[string]interface{})
}
issueData["meta"] = transMeta
issueData["overrides"] = c.opts
issueData["transition"] = map[string]interface{}{
"name": transName,
"id": transId,
};
return c.editTemplate(
c.getTemplate("transition"),
fmt.Sprintf("%s-trans-%s-", issue, trans),
issueData,
handlePost,
)
}
func (c *Cli) CmdComment(issue string) error {
log.Debug("comment called")
handlePost := func(json string) error {
log.Debug("JSON: %s", json)
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/comment", c.endpoint, issue)
resp, err := c.post(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 201 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
return nil
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From POST")
log.Error("%s:\n%s", err, logBuffer)
return err
}
}
if comment, ok := c.opts["comment"]; ok && comment != "" {
json, err := jsonEncode(map[string]interface{}{
"body": comment,
})
if err != nil {
return err
}
return handlePost(json)
} else {
return c.editTemplate(
c.getTemplate("comment"),
fmt.Sprintf("%s-create-", issue),
map[string]interface{}{},
handlePost,
)
}
return nil
}
func (c *Cli) CmdAssign(issue string, user string) error {
log.Debug("assign called")
json, err := jsonEncode(map[string]interface{}{
"name": user,
})
if err != nil {
return err
}
uri := fmt.Sprintf("%s/rest/api/2/issue/%s/assignee", c.endpoint, issue)
resp, err := c.put(uri, json)
if err != nil {
return err
}
if resp.StatusCode == 204 {
c.Browse(issue)
fmt.Printf("OK %s %s/browse/%s\n", issue, c.endpoint, issue)
} else {
logBuffer := bytes.NewBuffer(make([]byte, 0))
resp.Write(logBuffer)
err := fmt.Errorf("Unexpected Response From PUT")
log.Error("%s:\n%s", err, logBuffer)
return err
}
return nil
}
func (c *Cli) CmdExportTemplates() error {
dir := c.opts["directory"]
if err := mkdir(dir); err != nil {
return err
}
for name, template := range all_templates {
templateFile := fmt.Sprintf("%s/%s", dir, name)
if _, err := os.Stat(templateFile); err == nil {
log.Warning("Skipping %s, already exists", templateFile)
continue
}
if fh, err := os.OpenFile(templateFile, os.O_WRONLY|os.O_CREATE, 0644); err != nil {
log.Error("Failed to open %s for writing: %s", templateFile, err)
return err
} else {
defer fh.Close()
log.Notice("Creating %s", templateFile)
fh.Write([]byte(template))
}
}
return nil
}
+132
View File
@@ -0,0 +1,132 @@
package cli
var all_templates = map[string]string{
"debug": default_debug_template,
"fields": default_debug_template,
"editmeta": default_debug_template,
"transmeta": default_debug_template,
"createmeta": default_debug_template,
"issuelinktypes": default_debug_template,
"list": default_list_template,
"view": default_view_template,
"edit": default_edit_template,
"transitions": default_transitions_template,
"issuetypes": default_issuetypes_template,
"create": default_create_template,
"comment": default_comment_template,
"transition": default_transition_template,
}
const default_debug_template = "{{ . | toJson}}\n"
const default_list_template = "{{ range .issues }}{{ .key | append \":\" | printf \"%-12s\"}} {{ .fields.summary }}\n{{ end }}"
const default_view_template = `issue: {{ .key }}
created: {{ .fields.created }}
status: {{ .fields.status.name }}
summary: {{ .fields.summary }}
project: {{ .fields.project.key }}
components: {{ range .fields.components }}{{ .name }} {{end}}
issuetype: {{ .fields.issuetype.name }}
assignee: {{ if .fields.assignee }}{{ .fields.assignee.name }}{{end}}
reporter: {{ .fields.reporter.name }}
watchers: {{ range .fields.customfield_10110 }}{{ .name }} {{end}}
blockers: {{ range .fields.issuelinks }}{{if .outwardIssue}}{{ .outwardIssue.key }}[{{.outwardIssue.fields.status.name}}]{{end}}{{end}}
depends: {{ range .fields.issuelinks }}{{if .inwardIssue}}{{ .inwardIssue.key }}[{{.inwardIssue.fields.status.name}}]{{end}}{{end}}
priority: {{ .fields.priority.name }}
description: |
{{ or .fields.description "" | indent 2 }}
comments:
{{ range .fields.comment.comments }} - | # {{.author.name}} at {{.created}}
{{ or .body "" | indent 4}}
{{end}}
`
const default_edit_template = `update:
comment:
- add:
body: |
{{ or .overrides.comment "" | indent 10 }}
fields:
summary: {{ or .overrides.summary .fields.summary }}
components: # Values: {{ range .meta.fields.components.allowedValues }}{{.name}}, {{end}}{{if .overrides.components }}{{ range (split "," .overrides.components)}}
- name: {{.}}{{end}}{{else}}{{ range .fields.components }}
- name: {{ .name }}{{end}}{{end}}
assignee:
name: {{ if .overrides.assignee }}{{.overrides.assignee}}{{else}}{{if .fields.assignee }}{{ .fields.assignee.name }}{{end}}{{end}}
reporter:
name: {{ or .overrides.reporter .fields.reporter.name }}
# watchers
customfield_10110: {{ range .fields.customfield_10110 }}
- name: {{ .name }}{{end}}{{if .overrides.watcher}}
- name: {{ .overrides.watcher}}{{end}}
priority: # Values: {{ range .meta.fields.priority.allowedValues }}{{.name}}, {{end}}
name: {{ or .overrides.priority .fields.priority.name }}
description: |
{{ or .overrides.description (or .fields.description "") | indent 4 }}
`
const default_transitions_template = `{{ range .transitions }}{{.id }}: {{.name}}
{{end}}`
const default_issuetypes_template = `{{ range .projects }}{{ range .issuetypes }}{{color "+bh"}}{{.name | append ":" | printf "%-13s" }}{{color "reset"}} {{.description}}
{{end}}{{end}}`
const default_create_template = `fields:
project:
key: {{ .overrides.project }}
issuetype:
name: {{ .overrides.issuetype }}
summary: {{ or .overrides.summary "" }}
priority: # Values: {{ range .meta.fields.priority.allowedValues }}{{.name}}, {{end}}
name: {{ or .overrides.priority "unassigned" }}
components: # Values: {{ range .meta.fields.components.allowedValues }}{{.name}}, {{end}}{{ range split "," (or .overrides.components "")}}
- name: {{ . }}{{end}}
description: |
{{ or .overrides.description "" | indent 4 }}
assignee:
name: {{ or .overrides.assignee "" }}
reporter:
name: {{ or .overrides.reporter .overrides.user }}
# watchers
customfield_10110:
- name:
`
const default_comment_template = `body: |
{{ or .overrides.comment "" | indent 2 }}
`
const default_transition_template = `update:
comment:
- add:
body: |
{{ or .overrides.comment "" | indent 10 }}
fields:{{if .meta.fields.assignee}}
assignee:
name: {{if .overrides.assignee}}{{.overrides.assignee}}{{else}}{{if .fields.assignee}}{{.fields.assignee.name}}{{end}}{{end}}{{end}}{{if .meta.fields.components}}
components: # Values: {{ range .meta.fields.components.allowedValues }}{{.name}}, {{end}}{{if .overrides.components }}{{ range (split "," .overrides.components)}}
- name: {{.}}{{end}}{{else}}{{ range .fields.components }}
- name: {{ .name }}{{end}}{{end}}{{end}}{{if .meta.fields.description}}
description: {{or .overrides.description .fields.description }}{{end}}{{if .meta.fields.fixVersions}}{{if .meta.fields.fixVersions.allowedValues}}
fixVersions: # Values: {{ range .meta.fields.fixVersions.allowedValues }}{{.name}}, {{end}}{{if .overrides.fixVersions}}{{ range (split "," .overrides.fixVersions)}}
- name: {{.}}{{end}}{{else}}{{range .fields.fixVersions}}
- name: {{.}}{{end}}{{end}}{{end}}{{end}}{{if .meta.fields.issuetype}}
issuetype: # Values: {{ range .meta.fields.issuetype.allowedValues }}{{.name}}, {{end}}
name: {{if .overrides.issuetype}}{{.overrides.issuetype}}{{else}}{{if .fields.issuetype}}{{.fields.issuetype.name}}{{end}}{{end}}{{end}}{{if .meta.fields.labels}}
labels: {{range .fields.labels}}
- {{.}}{{end}}{{if .overrides.labels}}{{range (split "," .overrides.labels)}}
- {{.}}{{end}}{{end}}{{end}}{{if .meta.fields.priority}}
priority: # Values: {{ range .meta.fields.priority.allowedValues }}{{.name}}, {{end}}
name: {{ or .overrides.priority "unassigned" }}{{end}}{{if .meta.fields.reporter}}
reporter:
name: {{if .overrides.reporter}}{{.overrides.reporter}}{{else}}{{if .fields.reporter}}{{.fields.reporter.name}}{{end}}{{end}}{{end}}{{if .meta.fields.resolution}}
resolution: # Values: {{ range .meta.fields.resolution.allowedValues }}{{.name}}, {{end}}
name: {{if .overrides.resolution}}{{.overrides.resolution}}{{else if .fields.resolution}}{{.fields.resolution.name}}{{else}}Fixed{{end}}{{end}}{{if .meta.fields.summary}}
summary: {{or .overrides.summary .fields.summary}}{{end}}{{if .meta.fields.versions.allowedValues}}
versions: # Values: {{ range .meta.fields.versions.allowedValues }}{{.name}}, {{end}}{{if .overrides.versions}}{{ range (split "," .overrides.versions)}}
- name: {{.}}{{end}}{{else}}{{range .fields.versions}}
- name: {{.}}{{end}}{{end}}{{end}}
transition:
id: {{ .transition.id }}
name: {{ .transition.name }}
`
+241
View File
@@ -0,0 +1,241 @@
package cli
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/mgutz/ansi"
"io"
"io/ioutil"
"net/http"
"os"
"strings"
"text/template"
)
func FindParentPaths(fileName string) []string {
cwd, _ := os.Getwd()
paths := make([]string, 0)
// special case if homedir is not in current path then check there anyway
homedir := os.Getenv("HOME")
if !strings.HasPrefix(cwd, homedir) {
file := fmt.Sprintf("%s/%s", homedir, fileName)
if _, err := os.Stat(file); err == nil {
paths = append(paths, file)
}
}
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(templateContent string, data interface{}, out io.Writer) error {
if out == nil {
out = os.Stdout
}
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)
},
"split": func(sep string, content string) []string {
return strings.Split(content, sep)
},
}
if tmpl, err := template.New("template").Funcs(funcs).Parse(templateContent); err != nil {
log.Error("Failed to parse template: %s", err)
return err
} else {
if err := tmpl.Execute(out, data); err != nil {
log.Error("Failed to execute template: %s", err)
return err
}
}
return nil
}
func responseToJson(resp *http.Response, err error) (interface{}, error) {
if err != nil {
return nil, err
} else {
return jsonDecode(resp.Body), nil
}
}
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 jsonEncode(data interface{}) (string, error) {
buffer := bytes.NewBuffer(make([]byte, 0))
enc := json.NewEncoder(buffer)
err := enc.Encode(data)
if err != nil {
log.Error("Failed to encode data %s: %s", data, err)
return "", err
}
return buffer.String(), nil
}
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)
}
func promptYN(prompt string, yes bool) bool {
reader := bufio.NewReader(os.Stdin)
if !yes {
prompt = fmt.Sprintf("%s [y/N]: ", prompt)
} else {
prompt = fmt.Sprintf("%s [Y/n]: ", prompt)
}
fmt.Printf("%s", prompt)
text, _ := reader.ReadString('\n')
ans := strings.ToLower(strings.TrimRight(text, "\n"))
if ans == "" {
return yes
}
if strings.HasPrefix(ans, "y") {
return true
}
return false
}
func yamlFixup(data interface{}) (interface{}, error) {
switch d := data.(type) {
case map[interface{}]interface{}:
// need to copy this map into a string map so json can encode it
copy := make(map[string]interface{})
for key, val := range d {
switch k := key.(type) {
case string:
if fixed, err := yamlFixup(val); err != nil {
return nil, err
} else if fixed != nil {
copy[k] = fixed
}
default:
err := fmt.Errorf("YAML: key %s is type '%T', require 'string'", key, k)
log.Error("%s", err)
return nil, err
}
}
return copy, nil
case map[string]interface{}:
for k, v := range d {
if fixed, err := yamlFixup(v); err != nil {
return nil, err
} else if fixed != nil {
d[k] = fixed
}
}
return d, nil
case []interface{}:
for i, val := range d {
if fixed, err := yamlFixup(val); err != nil {
return nil, err
} else if fixed != nil {
d[i] = fixed
}
}
return data, nil
case string:
if d == "" {
return nil, nil
}
return d, nil
default:
return d, nil
}
}
func mkdir(dir string) error {
if stat, err := os.Stat(dir); err != nil && !os.IsNotExist(err) {
log.Error("Failed to stat %s: %s", dir, err)
return err
} else if err == nil && !stat.IsDir() {
err := fmt.Errorf("%s exists and is not a directory!", dir)
log.Error("%s", err)
return err
} else {
// dir does not exist, so try to create it
if err := os.MkdirAll(dir, 0755); err != nil {
log.Error("Failed to mkdir -p %s: %s", dir, err)
return err
}
}
return nil
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"fmt"
"github.com/Netflix-Skunkworks/go-jira/jira/cli"
"github.com/docopt/docopt-go"
"github.com/op/go-logging"
"gopkg.in/yaml.v2"
"io/ioutil"
"os"
"strings"
)
var log = logging.MustGetLogger("jira")
var format = "%{color}%{time:2006-01-02T15:04:05.000Z07:00} %{level:-5s} [%{shortfile}]%{color:reset} %{message}"
func main() {
user := os.Getenv("USER")
home := os.Getenv("HOME")
usage := fmt.Sprintf(`
Usage:
jira [-v ...] [-u USER] [-e URI] [-t FILE] (ls|list) ( [-q JQL] | [-p PROJECT] [-c COMPONENT] [-a ASSIGNEE] [-i ISSUETYPE] [-w WATCHER] [-r REPORTER])
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] view ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] edit ISSUE [--noedit] [-m COMMENT] [-o KEY=VAL]...
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] create [--noedit] [-p PROJECT] [-i ISSUETYPE] [-o KEY=VAL]...
jira [-v ...] [-u USER] [-e URI] [-b] DUPLICATE dups ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] BLOCKER blocks ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] watch ISSUE [-w WATCHER]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] (trans|transition) TRANSITION ISSUE [-m COMMENT] [-o KEY=VAL] [--noedit]
jira [-v ...] [-u USER] [-e URI] [-b] ack ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] close ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] resolve ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] reopen ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] start ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] stop ISSUE [-m COMMENT] [-o KEY=VAL] [--edit]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] comment ISSUE [-m COMMENT]
jira [-v ...] [-u USER] [-e URI] [-b] take ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] (assign|give) ISSUE ASSIGNEE
jira [-v ...] [-u USER] [-e URI] [-t FILE] fields
jira [-v ...] [-u USER] [-e URI] [-t FILE] issuelinktypes
jira [-v ...] [-u USER] [-e URI] [-b][-t FILE] transmeta ISSUE
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] editmeta ISSUE
jira [-v ...] [-u USER] [-e URI] [-t FILE] issuetypes [-p PROJECT]
jira [-v ...] [-u USER] [-e URI] [-t FILE] createmeta [-p PROJECT] [-i ISSUETYPE]
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] transitions ISSUE
jira [-v ...] export-templates [-d DIR]
jira [-v ...] [-u USER] [-e URI] (b|browse) ISSUE
jira [-v ...] [-u USER] [-e URI] [-t FILE] login
jira [-v ...] [-u USER] [-e URI] [-b] [-t FILE] ISSUE
General Options:
-e --endpoint=URI URI to use for jira
-h --help Show this usage
-t --template=FILE Template file to use for output/editing
-u --user=USER Username to use for authenticaion (default: %s)
-v --verbose Increase output logging
--version Show this version
Command Options:
-a --assignee=USER Username assigned the issue
-b --browse Open your browser to the Jira issue
-c --component=COMPONENT Component to Search for
-d --directory=DIR Directory to export templates to (default: %s)
-f --queryfields Fields that are used in "list" template: (default: summary)
-i --issuetype=ISSUETYPE Jira Issue Type (default: Bug)
-m --comment=COMMENT Comment message for transition
-o --override=KEY:VAL Set custom key/value pairs
-p --project=PROJECT Project to Search for
-q --query=JQL Jira Query Language expression for the search
-r --reporter=USER Reporter to search for
-w --watcher=USER Watcher to add to issue (default: %s)
or Watcher to search for
`, user, fmt.Sprintf("%s/.jira.d/templates", home), user)
args, err := docopt.Parse(usage, nil, true, "0.0.2", false, false)
if err != nil {
log.Error("Failed to parse options: %s", err)
os.Exit(1)
}
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)
// strip the "--" off the command line options
// and populate the opts that we pass to the cli ctor
for key, val := range args {
if val != nil && strings.HasPrefix(key, "--") {
opt := key[2:]
if opt == "override" {
for _, v := range val.([]string) {
if strings.Contains(v, "=") {
kv := strings.SplitN(v, "=", 2)
opts[kv[0]] = kv[1]
} else {
log.Error("Malformed override, expected KEY=VALUE, got %s", v)
os.Exit(1)
}
}
} else {
switch v := val.(type) {
case string:
opts[opt] = v
case int:
opts[opt] = fmt.Sprintf("%d", v)
case bool:
opts[opt] = fmt.Sprintf("%t", v)
}
}
}
}
// cant use proper [default:x] syntax in docopt
// because only want to default if the option is not
// already specified in some .jira.d/config.yml file
if _, ok := opts["user"]; !ok {
opts["user"] = user
}
if _, ok := opts["queryfields"]; !ok {
opts["queryfields"] = "summary"
}
if _, ok := opts["directory"]; !ok {
opts["directory"] = fmt.Sprintf("%s/.jira.d/templates", home)
}
if _, ok := opts["endpoint"]; !ok {
log.Error("endpoint option required. Either use --endpoint or set a enpoint option in your ~/.jira.d/config.yml file")
os.Exit(1)
}
c := cli.New(opts)
log.Debug("opts: %s", opts)
validCommand := func(cmd string) bool {
if val, ok := args[cmd]; ok && val.(bool) {
return true
}
return false
}
validOpt := func(opt string, dflt interface{}) interface{} {
if val, ok := opts[opt]; ok {
return val
}
if dflt == nil {
log.Error("Missing required option --%s or \"%s\" property override in the config file", opt, opt)
os.Exit(1)
}
return dflt
}
setEditing := func(dflt bool) {
if dflt {
if val, ok := opts["noedit"]; ok && val == "true" {
opts["edit"] = "false"
} else {
opts["edit"] = "true"
}
} else {
if val, ok := opts["edit"]; ok && val == "true" {
opts["edit"] = "true"
} else {
opts["edit"] = "false"
}
}
}
if validCommand("login") {
err = c.CmdLogin()
} else if validCommand("fields") {
err = c.CmdFields()
} else if validCommand("ls") || validCommand("list") {
err = c.CmdList()
} else if validCommand("edit") {
setEditing(true)
err = c.CmdEdit(args["ISSUE"].(string))
} else if validCommand("editmeta") {
err = c.CmdEditMeta(args["ISSUE"].(string))
} else if validCommand("transmeta") {
err = c.CmdTransitionMeta(args["ISSUE"].(string))
} else if validCommand("issuelinktypes") {
err = c.CmdIssueLinkTypes()
} else if validCommand("issuetypes") {
err = c.CmdIssueTypes(validOpt("project", nil).(string))
} else if validCommand("createmeta") {
err = c.CmdCreateMeta(
validOpt("project", nil).(string),
validOpt("issuetype", "Bug").(string),
)
} else if validCommand("create") {
setEditing(true)
err = c.CmdCreate(
validOpt("project", nil).(string),
validOpt("issuetype", "Bug").(string),
)
} else if validCommand("transitions") {
err = c.CmdTransitions(args["ISSUE"].(string))
} else if validCommand("blocks") {
err = c.CmdBlocks(
args["BLOCKER"].(string),
args["ISSUE"].(string),
)
} else if validCommand("dups") {
err = c.CmdDups(
args["DUPLICATE"].(string),
args["ISSUE"].(string),
)
} else if validCommand("watch") {
err = c.CmdWatch(
args["ISSUE"].(string),
validOpt("watcher", user).(string),
)
} else if validCommand("trans") || validCommand("transition") {
setEditing(true)
err = c.CmdTransition(
args["ISSUE"].(string),
args["TRANSITION"].(string),
)
} else if validCommand("close") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "close")
} else if validCommand("ack") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "acknowledge")
} else if validCommand("reopen") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "reopen")
} else if validCommand("resolve") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "resolve")
} else if validCommand("start") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "start")
} else if validCommand("stop") {
setEditing(false)
err = c.CmdTransition(args["ISSUE"].(string), "stop")
} else if validCommand("comment") {
setEditing(true)
err = c.CmdComment(args["ISSUE"].(string))
} else if validCommand("take") {
err = c.CmdAssign(args["ISSUE"].(string), user)
} else if validCommand("browse") || validCommand("b") {
opts["browse"] = "true"
err = c.Browse(args["ISSUE"].(string))
} else if validCommand("export-templates") {
err = c.CmdExportTemplates()
} else if validCommand("assign") || validCommand("give") {
err = c.CmdAssign(
args["ISSUE"].(string),
args["ASSIGNEE"].(string),
)
} else if val, ok := args["ISSUE"]; ok {
err = c.CmdView(val.(string))
}
if err != nil {
os.Exit(1)
}
os.Exit(0)
}
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.d/config.yml")
// prepend
paths = append([]string{"/etc/jira-cli.yml"}, paths...)
for _, file := range paths {
parseYaml(file, opts)
}
}