diff --git a/Godeps/Godeps.json b/Godeps/Godeps.json deleted file mode 100644 index c1fa090..0000000 --- a/Godeps/Godeps.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "ImportPath": "github.com/avanier/jkl", - "GoVersion": "go1.8", - "GodepVersion": "v79", - "Deps": [ - { - "ImportPath": "github.com/joho/godotenv", - "Comment": "v1.1-23-gc9360df", - "Rev": "c9360df4d16dc0e391ea2f28da2d31a9ede2e26f" - } - ] -} diff --git a/Godeps/Readme b/Godeps/Readme deleted file mode 100644 index 4cdaa53..0000000 --- a/Godeps/Readme +++ /dev/null @@ -1,5 +0,0 @@ -This directory tree is generated automatically by godep. - -Please do not edit. - -See https://github.com/tools/godep for more information. diff --git a/README.md b/README.md index eb6d017..199076b 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,34 @@ # jkl - -TODO: Write a project description +jkl is a library for programmatically interacting with a JIRA installation. It +comes with a command line program (also called `jkl`) which allows you to +interact with JIRA via the command line. ## Installation +To use the library, simply import it into your application: +`import "github.com/otremblay/jkl"` -TODO: Describe the installation process +To install the command line application: +First, make sure you have a working go environment: +https://golang.org/doc/install + +Then, execute the following command from your shell: + +`$ go get github.com/otremblay/jkl/cmd/jkl` ## Usage -TODO: Write usage instructions +Make sure you create a `~/.jklrc` file in your home directory, it should contain +at a minimum: + +``` +JIRA_ROOT="https://jira.example.com/" +JIRA_USER="myusername" +JIRA_PASSWORD="mypassword" +JIRA_PROJECT="DPK" +``` +Those values are for example only, your setup will be different. + +TODO: Finish writing usage instructions ## Contributing diff --git a/cmd/jkl/assign.go b/cmd/jkl/assign.go new file mode 100644 index 0000000..d498b63 --- /dev/null +++ b/cmd/jkl/assign.go @@ -0,0 +1,36 @@ +package main + +import ( + "errors" + "flag" + + "otremblay.com/jkl" +) + +type AssignCmd struct { + args []string + assignee string + issueKey string +} + +func NewAssignCmd(args []string) (*AssignCmd, error) { + ccmd := &AssignCmd{} + f := flag.NewFlagSet("assign", flag.ExitOnError) + f.Parse(args) + if len(f.Args()) < 2 { + return nil, ErrAssignNotEnoughArgs + } + ccmd.issueKey = f.Arg(0) + ccmd.assignee = f.Arg(1) + return ccmd, nil +} + +var ErrAssignNotEnoughArgs = errors.New("Not enough arguments, need issue key + assignee") + +func (ccmd *AssignCmd) Assign() error { + return jkl.Assign(ccmd.issueKey, ccmd.assignee) +} + +func (ccmd *AssignCmd) Run() error { + return ccmd.Assign() +} diff --git a/cmd/jkl/attach.go b/cmd/jkl/attach.go new file mode 100644 index 0000000..c62f0a1 --- /dev/null +++ b/cmd/jkl/attach.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + + "otremblay.com/jkl" +) + +type AttachCmd struct { + args []string + file string + taskKey string +} + +func NewAttachCmd(args []string) (*AttachCmd, error) { + ccmd := &AttachCmd{} + f := flag.NewFlagSet("x", flag.ExitOnError) + f.Parse(args) + if len(f.Args()) < 2 { + return nil, ErrNotEnoughArgs + } + ccmd.taskKey = f.Arg(0) + ccmd.file = f.Arg(1) + return ccmd, nil +} + +func (ecmd *AttachCmd) Attach() error { + return jkl.Attach(ecmd.taskKey, ecmd.file) +} + +func (ecmd *AttachCmd) Run() error { + return ecmd.Attach() +} diff --git a/cmd/jkl/create.go b/cmd/jkl/create.go index 69c37ec..0306cb8 100644 --- a/cmd/jkl/create.go +++ b/cmd/jkl/create.go @@ -4,18 +4,19 @@ import ( "bytes" "errors" "flag" + "fmt" "io" "os" - "fmt" + "strings" "text/template" "otremblay.com/jkl" ) type CreateCmd struct { - args []string - project string - file string + args []string + project string + file string issuetype string } @@ -43,7 +44,7 @@ func (ccmd *CreateCmd) Create() error { } } - + if ccmd.project == "" { return ErrCcmdJiraProjectRequired } @@ -55,13 +56,22 @@ func (ccmd *CreateCmd) Create() error { if err != nil { fmt.Fprintln(os.Stderr, fmt.Sprintf("Error getting the CreateMeta for project [%s] and issue types [%s]", ccmd.project, isstype), err) } - + if !readfile { createTemplate.Execute(b, cm) } var iss *jkl.JiraIssue // TODO: Evil badbad don't do this. - em := &jkl.EditMeta{Fields: cm.Projects[0].IssueTypes[0].Fields} + var isst = cm.Projects[0].IssueTypes[0].Fields + for _, v := range cm.Projects[0].IssueTypes { + if strings.ToLower(isstype) == strings.ToLower(v.Name) { + isst = v.Fields + break + } + } + + em := &jkl.EditMeta{Fields: isst} + if ccmd.file != "" { iss, err = GetIssueFromFile(ccmd.file, b, em) if err != nil { diff --git a/cmd/jkl/edit.go b/cmd/jkl/edit.go index e06ec37..0e3ff3b 100644 --- a/cmd/jkl/edit.go +++ b/cmd/jkl/edit.go @@ -21,9 +21,9 @@ func NewEditCmd(args []string) (*EditCmd, error) { ccmd := &EditCmd{project: os.Getenv("JIRA_PROJECT")} f := flag.NewFlagSet("x", flag.ExitOnError) f.StringVar(&ccmd.project, "p", "", "Jira project key") - f.StringVar(&ccmd.file, "f", "filename", "File to get issue description from") + f.StringVar(&ccmd.file, "f", "", "File to get issue description from") f.Parse(args) - ccmd.taskKey = flag.Arg(0) + ccmd.taskKey = f.Arg(0) return ccmd, nil } diff --git a/cmd/jkl/editor.go b/cmd/jkl/editor.go index fedfc6e..9c59ea5 100644 --- a/cmd/jkl/editor.go +++ b/cmd/jkl/editor.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "io/ioutil" - "log" "os" "os/exec" "regexp" @@ -17,20 +16,26 @@ import ( "otremblay.com/jkl" ) + // def get_editor do // [System.get_env("EDITOR"), "nano", "vim", "vi"] // |> Enum.find(nil, fn (ed) -> System.find_executable(ed) != nil end) // end -var editors = []string{os.Getenv("EDITOR"), "nano", "vim", "vi"} +var editors = []string{"nano", "vim", "vi"} // GetEditor returns the path to an editor, taking $EDITOR in account func GetEditor() string { + if ed := os.Getenv("EDITOR"); ed != "" { + return ed + } + if ed := os.Getenv("VISUAL"); ed != "" { + return ed + } for _, ed := range editors { if p, err := exec.LookPath(ed); err == nil { return p } } - log.Fatal("No editor available; use flags.") return "" } @@ -104,7 +109,7 @@ func GetIssueFromFile(filename string, initial io.Reader, editMeta *jkl.EditMeta var spacex = regexp.MustCompile(`\s`) func IssueFromReader(f io.Reader, editMeta *jkl.EditMeta) *jkl.JiraIssue { - iss := &jkl.JiraIssue{Fields: &jkl.Fields{}} + iss := &jkl.JiraIssue{Fields: &jkl.Fields{ExtraFields: map[string]interface{}{}}} riss := reflect.ValueOf(iss).Elem() fieldsField := riss.FieldByName("Fields").Elem() currentField := reflect.Value{} @@ -154,14 +159,40 @@ func IssueFromReader(f io.Reader, editMeta *jkl.EditMeta) *jkl.JiraIssue { } else { currentField = newfield } - } else if editMeta != nil { + } else if editMeta != nil { // If it's not valid, throw it at the createmeta. It will probably end up in ExtraFields. - + val := strings.TrimSpace(strings.Join(parts[1:], ":")) + for fieldname, m := range editMeta.Fields { + var something interface{} = val + if strings.ToLower(m.Name) == strings.ToLower(potentialField) { + name := fieldname + for _, av := range m.AllowedValues { + if strings.ToLower(av.Name) == strings.ToLower(val) { + something = av + break + } + } + if m.Schema.CustomId > 0 { + name = fmt.Sprintf("custom_%d", m.Schema.CustomId) + } + iss.Fields.ExtraFields[name] = something + + break + } + } + } if currentField.IsValid() { - currentField.SetString(strings.TrimSpace(currentField.String() + "\n" + strings.Join(parts, ":"))) + newpart := strings.Join(parts, ":") + newvalue := currentField.String() + "\n" + newpart + if strings.TrimSpace(newpart) != "" { + newvalue = strings.TrimSpace(newvalue) + } + currentField.SetString(newvalue) } } + + iss.EditMeta = editMeta return iss } diff --git a/cmd/jkl/editor_test.go b/cmd/jkl/editor_test.go index 35aa64f..b9a9745 100644 --- a/cmd/jkl/editor_test.go +++ b/cmd/jkl/editor_test.go @@ -12,7 +12,7 @@ hell Issue Type: Sometype this is ignored Summary: Dookienator -also ignored`, "\n")) +also ignored`, "\n"), nil) AssertEqual(t, `Cowboys from hell`, iss.Fields.Description) diff --git a/cmd/jkl/flag.go b/cmd/jkl/flag.go new file mode 100644 index 0000000..d49e1b5 --- /dev/null +++ b/cmd/jkl/flag.go @@ -0,0 +1,34 @@ +package main + +import ( + "errors" + "flag" + + "otremblay.com/jkl" +) + +type FlagCmd struct { + args []string + flg bool +} + +func NewFlagCmd(args []string, flg bool) (*FlagCmd, error) { + ccmd := &FlagCmd{flg: flg} + f := flag.NewFlagSet("flag", flag.ExitOnError) + f.Parse(args) + if len(f.Args()) < 1 { + return nil, ErrFlagNotEnoughArgs + } + ccmd.args = f.Args() + return ccmd, nil +} + +var ErrFlagNotEnoughArgs = errors.New("Not enough arguments, need at least one issue key") + +func (ccmd *FlagCmd) Flag() error { + return jkl.FlagIssue(ccmd.args, ccmd.flg) +} + +func (ccmd *FlagCmd) Run() error { + return ccmd.Flag() +} diff --git a/cmd/jkl/jkl.go b/cmd/jkl/jkl.go index 45da524..68065bc 100644 --- a/cmd/jkl/jkl.go +++ b/cmd/jkl/jkl.go @@ -55,12 +55,22 @@ func getCmd(args []string, depth int) (Runner, error) { case "edit": return NewEditCmd(args[1:]) case "comment": - if strings.Contains(strings.Join(args,""),jkl.CommentIdSeparator){ + if strings.Contains(strings.Join(args, ""), jkl.CommentIdSeparator) { return NewEditCommentCmd(args[1:]) } return NewCommentCmd(args[1:]) case "edit-comment": return NewEditCommentCmd(args[1:]) + case "assign": + return NewAssignCmd(args[1:]) + case "flag": + return NewFlagCmd(args[1:], true) + case "unflag": + return NewFlagCmd(args[1:], false) + case "link": + return NewLinkCmd(args[1:]) + case "attach": + return NewAttachCmd(args[1:]) default: // Think about this real hard. // I want `jkl JIRA-1234 done` to move it to done. @@ -92,9 +102,10 @@ func getCmd(args []string, depth int) (Runner, error) { return nil, ErrTaskSubCommandNotFound } -var verbs = []string{"list", "create", "task", "edit", "comment","edit-comment"} -func init(){ -sort.Strings(verbs) +var verbs = []string{"list", "create", "task", "edit", "comment", "edit-comment", "attach"} + +func init() { + sort.Strings(verbs) } const usage = `Usage: diff --git a/cmd/jkl/link.go b/cmd/jkl/link.go new file mode 100644 index 0000000..76b7d00 --- /dev/null +++ b/cmd/jkl/link.go @@ -0,0 +1,30 @@ +package main + +import ( + "errors" + "flag" + + "otremblay.com/jkl" +) + +type LinkCmd struct { + args []string +} + +func NewLinkCmd(args []string) (*LinkCmd, error) { + ccmd := &LinkCmd{} + f := flag.NewFlagSet("Link", flag.ExitOnError) + f.Parse(args) + ccmd.args = f.Args() + return ccmd, nil +} + +var ErrLinkNotEnoughArgs = errors.New("Not enough arguments, need at least two issue keys and a reason") + +func (ccmd *LinkCmd) Link() error { + return jkl.LinkIssue(ccmd.args) +} + +func (ccmd *LinkCmd) Run() error { + return ccmd.Link() +} \ No newline at end of file diff --git a/cmd/jkl/list.go b/cmd/jkl/list.go index 592cf80..18e193e 100644 --- a/cmd/jkl/list.go +++ b/cmd/jkl/list.go @@ -21,6 +21,9 @@ func (l *listissue) URL() string { } func (l *listissue) Color() string { + if l.Fields == nil || l.Fields.Status == nil { + return "" + } if os.Getenv("JKLNOCOLOR") == "true" || !terminal.IsTerminal(int(os.Stdout.Fd())) { return "" } @@ -52,7 +55,7 @@ func NewListCmd(args []string) (*ListCmd, error) { if *verbose { fmt.Println(&ccmd.tmplstr) } - f.StringVar(&ccmd.tmplstr, "listTemplate", "{{.Color}}{{.Key}}{{if .Color}}\x1b[39m{{end}}\t({{.Fields.IssueType.Name}}{{if .Fields.Parent}} of {{.Fields.Parent.Key}}{{end}})\t{{.Fields.Summary}}\t{{if .Fields.Assignee}}[{{.Fields.Assignee.Name}}]{{end}}\n", "Go template used in list command") + f.StringVar(&ccmd.tmplstr, "listTemplate", "{{.Color}}{{.Key}}{{if .Color}}\x1b[39m{{end}}\t({{if .Fields.IssueType}}{{.Fields.IssueType.Name}}{{end}}{{if .Fields.Parent}} of {{.Fields.Parent.Key}}{{end}})\t{{.Fields.Summary}}\t{{if .Fields.Assignee}}[{{.Fields.Assignee.Name}}]{{end}}\n", "Go template used in list command") f.Parse(args) ccmd.args = f.Args() if len(ccmd.args) == 0 { diff --git a/cmd/jkl/task.go b/cmd/jkl/task.go index b9279a3..a24ccaa 100644 --- a/cmd/jkl/task.go +++ b/cmd/jkl/task.go @@ -19,11 +19,11 @@ func (t *TaskCmd) Handle() error { return t.Get(t.args[0]) } if c == 2 { - fmt.Println(t.args) + // fmt.Println(t.args) err := t.Transition(t.args[0], t.args[1]) if err != nil { - fmt.Println(err) - return t.Log(t.args[0], strings.Join(t.args[1:]," ")) + //fmt.Println(err) + return t.Log(t.args[0], strings.Join(t.args[1:], " ")) } } return ErrTaskSubCommandNotFound @@ -45,7 +45,7 @@ func (t *TaskCmd) Transition(taskKey, transition string) error { } func (t *TaskCmd) Log(taskKey, time string) error { -return jkl.LogWork(taskKey, time) + return jkl.LogWork(taskKey, time) } func (t *TaskCmd) Run() error { diff --git a/cmd/jklfs/jklfile.go b/cmd/jklfs/jklfile.go index ad5bbc3..e34d3fc 100644 --- a/cmd/jklfs/jklfile.go +++ b/cmd/jklfs/jklfile.go @@ -32,7 +32,7 @@ func (f *jklfile) String() string { func (f *jklfile) Write(data []byte, off int64) (uint32, fuse.Status) { n, err := f.File.WriteAt(data, off) if err != nil { - return uint32(0),fuse.EACCES + return uint32(0), fuse.EACCES } return uint32(n), fuse.OK } @@ -76,3 +76,7 @@ func (f *jklfile) SetInode(i *nodefs.Inode) {} func (f *jklfile) Utimens(atime *time.Time, mtime *time.Time) fuse.Status { return fuse.EPERM } + +func (f *jklfile) Flock(flags int) fuse.Status { + return fuse.ENOSYS +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7da4219 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/otremblay/jkl + +go 1.13 + +require ( + github.com/hanwen/go-fuse v0.0.0-20170609101909-5690be47d614 + github.com/joho/godotenv v1.2.0 + golang.org/x/crypto v0.0.0-20171019172325-541b9d50ad47 + golang.org/x/sys v0.0.0-20171017063910-8dbc5d05d6ed +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ef396af --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/hanwen/go-fuse v0.0.0-20170609101909-5690be47d614/go.mod h1:4ZJ05v9yt5k/mcFkGvSPKJB5T8G/6nuumL63ZqlrPvI= +github.com/joho/godotenv v1.2.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +golang.org/x/crypto v0.0.0-20171019172325-541b9d50ad47/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/sys v0.0.0-20171017063910-8dbc5d05d6ed/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/issue.go b/issue.go index 6ca89e0..ba51508 100644 --- a/issue.go +++ b/issue.go @@ -19,9 +19,9 @@ type Search struct { } type IssueType struct { - Name string `json:"name"` + Name string `json:"name"` IconURL string `json:",omitempty"` - Fields map[string]*FieldSpec + Fields map[string]*FieldSpec } func (it *IssueType) RangeFieldSpecs() string { @@ -38,12 +38,13 @@ func (it *IssueType) RangeFieldSpecs() string { } type AllowedValue struct { - Id string - Self string - Value string + Id string `json:"id"` + Self string `json:"self"` + Value string `json:"value"` + Name string `json:"name"` } -func (a *AllowedValue) String() string{ +func (a *AllowedValue) String() string { return a.Value } @@ -51,24 +52,22 @@ type FieldSpec struct { Name string Required bool Schema struct { - Type string - Custom string + Type string + Custom string CustomId int - Items string + Items string } - Operations []string + Operations []string AllowedValues []*AllowedValue - } - type CreateMeta struct { Projects []*Project } type Project struct { Key string `json:"key,omitempty"` - Name string + Name string IssueTypes []*IssueType } @@ -82,9 +81,9 @@ func (a *Author) String() string { } type Comment struct { - Id string `json:"id"` + Id string `json:"id"` Author *Author `json:"author"` - Body string `json:"body"` + Body string `json:"body"` } type CommentColl struct { @@ -92,7 +91,7 @@ type CommentColl struct { } type Status struct { - Name string + Name string IconURL string `json:",omitempty"` } @@ -107,14 +106,14 @@ func (a *Attachment) String() string { type Attachment struct { Filename string - Author *Author - Content string + Author *Author + Content string } type LinkType struct { - Id string - Name string - Inward string + Id string + Name string + Inward string Outward string } @@ -129,8 +128,8 @@ func (i *IssueLink) String() string { } type IssueLink struct { - LinkType *LinkType `json:"type"` - InwardIssue *JiraIssue + LinkType *LinkType `json:"type"` + InwardIssue *JiraIssue OutwardIssue *JiraIssue } @@ -139,36 +138,48 @@ func (w *Worklog) String() string { } type Worklog struct { - Author *Author - Comment string - TimeSpent string + Author *Author + Comment string + TimeSpent string TimeSpentSeconds int - Started string + Started string } type Worklogs struct { Worklogs []*Worklog `json:",omitempty"` } -func (f *Fields) UnmarshalJSON(b []byte) error{ +func (f *Fields) UnmarshalJSON(b []byte) error { err := json.Unmarshal(b, &f.rawFields) if err != nil { fmt.Println("splosion") return err } f.rawExtraFields = map[string]json.RawMessage{} - vf := reflect.ValueOf(f).Elem() + if reflect.ValueOf(f) == reflect.Zero(reflect.TypeOf(f)) { + fmt.Println("wtf") + } + v := reflect.ValueOf(f) + if !v.IsValid() { + fmt.Println("What all the fucks") + } + vf := v.Elem() for key, mess := range f.rawFields { - field := vf.FieldByNameFunc(func(s string)bool{return strings.ToLower(key) == strings.ToLower(s)}) + field := vf.FieldByNameFunc(func(s string) bool { return strings.ToLower(key) == strings.ToLower(s) }) if field.IsValid() { - objType := field.Type() - obj := reflect.New(objType).Interface() + objType := field.Type() + obj := reflect.New(objType).Interface() err := json.Unmarshal(mess, &obj) if err != nil { fmt.Fprintln(os.Stderr, objType, obj, string(mess)) - fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s [%s]: %s","Error allocating field",key, err))) + fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s [%s]: %s", "Error allocating field", key, err))) + } + val := reflect.ValueOf(obj) + if val == reflect.Zero(reflect.TypeOf(val)) || !val.IsValid() { + field.Set(reflect.Zero(objType)) + } else { + field.Set(val.Elem()) } - field.Set(reflect.ValueOf(obj).Elem()) } else { f.rawExtraFields[key] = mess } @@ -182,22 +193,22 @@ type Priority struct { } type Fields struct { - *IssueType `json:"issuetype,omitempty"` - Assignee *Author `json:",omitempty"` - Project *Project `json:"project,omitempty"` - Summary string `json:"summary,omitempty"` - Description string `json:"description,omitempty"` - Comment *CommentColl `json:"comment,omitempty"` - Parent *JiraIssue `json:",omitempty"` - Status *Status `json:",omitempty"` - TimeTracking *TimeTracking `json:"timetracking,omitempty"` - Attachment []*Attachment `json:"attachment,omitempty"` - IssueLinks []*IssueLink `json:"issueLinks,omitempty"` - Priority *Priority `json:",omitempty"` - Worklog *Worklogs `json:"worklog,omitempty"` - rawFields map[string]json.RawMessage + *IssueType `json:"issuetype,omitempty"` + Assignee *Author `json:",omitempty"` + Project *Project `json:"project,omitempty"` + Summary string `json:"summary,omitempty"` + Description string `json:"description,omitempty"` + Comment *CommentColl `json:"comment,omitempty"` + Parent *JiraIssue `json:",omitempty"` + Status *Status `json:",omitempty"` + TimeTracking *TimeTracking `json:"timetracking,omitempty"` + Attachment []*Attachment `json:"attachment,omitempty"` + IssueLinks []*IssueLink `json:"issueLinks,omitempty"` + Priority *Priority `json:",omitempty"` + Worklog *Worklogs `json:"worklog,omitempty"` + rawFields map[string]json.RawMessage rawExtraFields map[string]json.RawMessage - ExtraFields map[string]interface{} `json:"-"` + ExtraFields map[string]interface{} `json:"-"` } func (f *Fields) PrettyRemaining() string { @@ -218,55 +229,142 @@ func PrettySeconds(seconds int) string { return fmt.Sprintf("%dd %2dh %2dm %2ds", days, hours, minutes, seconds) } -type Transition struct{ - Id string `json:"id"` +type Transition struct { + Id string `json:"id"` Name string `json:"name"` } - type Schema struct { - System string - Custom string + System string + Custom string CustomId int } - type EditMeta struct { Fields map[string]*FieldSpec } - - type JiraIssue struct { - Key string `json:"key,omitempty"` - Fields *Fields `json:"fields,omitempty"` + Key string `json:"key,omitempty"` + Fields *Fields `json:"fields,omitempty"` Transitions []*Transition `json:"transitions,omitempty"` - EditMeta *EditMeta `json:"editmeta,omitempty"` + EditMeta *EditMeta `json:"editmeta,omitempty"` } +var sprintRegexp = regexp.MustCompile(`name=([^,]+),`) + +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.Array, reflect.Map, reflect.Slice, reflect.String: + return v.Len() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr: + return v.IsNil() || isEmptyValue(reflect.Indirect(v)) + } + return false +} + +func (i *JiraIssue) MarshalJSON() ([]byte, error) { + fields := map[string]interface{}{} + vf := reflect.ValueOf(*(i.Fields)) + if i.EditMeta != nil && i.EditMeta.Fields != nil { + for k, f := range i.EditMeta.Fields { + name := k + if f.Schema.CustomId > 0 { + name = fmt.Sprintf("custom_%d", f.Schema.CustomId) + } + if val, ok := i.Fields.ExtraFields[name]; ok && val != nil { + if f.Schema.Type == "array" { + fields[name] = []interface{}{val} + continue + } + fields[name] = val + } else if val == nil { + delete(fields, name) + } + } + } + + for i := 0; i < vf.NumField(); i++ { + ft := vf.Type().Field(i) + fv := vf.Field(i) + + if ft.Name != "ExtraFields" && (fv.CanSet() || fv.CanInterface() || fv.CanAddr()) && fv.IsValid() { + name := strings.ToLower(ft.Name) + if alias, ok := ft.Tag.Lookup("json"); ok { + if alias != "" { + name = strings.Split(alias, ",")[0] + } + } + value := fv.Interface() + if value != nil { + fields[name] = value + + } else { + delete(fields, name) + } + } + } + fmt.Println(fields) + for k, v := range fields { + v2 := reflect.ValueOf(v) + for { + if v2.Kind() != reflect.Ptr && v2.Kind() != reflect.Interface { + break + } + v2 = v2.Elem() + } + if !v2.IsValid() || (v2.Kind() == reflect.Slice && v2.Len() == 0) { + delete(fields, k) + } + } + em := i.EditMeta + i.EditMeta = nil + defer func() { i.EditMeta = em }() + type Alias JiraIssue + return json.Marshal(&struct { + *Alias + Fields map[string]interface{} `json:"fields,omitempty"` + }{ + Fields: fields, + Alias: (*Alias)(i), + }) +} -var sprintRegexp = regexp.MustCompile(`name=([^,]+),`) func (i *JiraIssue) UnmarshalJSON(b []byte) error { tmp := map[string]json.RawMessage{} if len(b) == 0 { return nil } i.Fields = &Fields{} - i.EditMeta = &EditMeta{Fields:map[string]*FieldSpec{}} + i.EditMeta = &EditMeta{Fields: map[string]*FieldSpec{}} err := json.Unmarshal(b, &tmp) if err != nil && *Verbose { fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s: %s", "Error unpacking raw json", err))) } + if _, ok := tmp["fields"]; !ok { + fmt.Fprintln(os.Stderr, "Received no fields? wtf?") + fmt.Fprintln(os.Stderr, string(b)) + os.Exit(1) + } err = json.Unmarshal(tmp["fields"], &i.Fields) if err != nil && *Verbose { + fmt.Println(string(tmp["fields"])) fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s: %s", "Error unpacking fields", err))) } err = json.Unmarshal(tmp["transitions"], &i.Transitions) if err != nil && *Verbose { - fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s: %s", "Error unpacking transitions", err))) + fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s: %s", "Error unpacking transitions", err))) } err = json.Unmarshal(tmp["editmeta"], &i.EditMeta) - if err != nil && *Verbose{ + if err != nil && *Verbose { fmt.Fprintln(os.Stderr, errors.New(fmt.Sprintf("%s: %s", "Error unpacking EditMeta", err))) } err = json.Unmarshal(tmp["key"], &i.Key) @@ -282,34 +380,39 @@ func (i *JiraIssue) UnmarshalJSON(b []byte) error { if len(results) == 2 { i.Fields.ExtraFields[k] = results[1] } - } else {switch f.Schema.Type { - case "user": - a := &Author{} - json.Unmarshal(v, &a) - i.Fields.ExtraFields[k] = a - case "option": - val := &AllowedValue{} - err = json.Unmarshal(v, &val) - if err != nil {panic(err)} - i.Fields.ExtraFields[k] = val - case "array": - if f.Schema.Items == "option" { - val := []*AllowedValue{} - err = json.Unmarshal(v, &val) - if err != nil {panic(err)} - i.Fields.ExtraFields[k] = val - continue - } - fallthrough - default: - if string(v) != "null" { - i.Fields.ExtraFields[k] = string(v) + } else { + switch f.Schema.Type { + case "user": + a := &Author{} + json.Unmarshal(v, &a) + i.Fields.ExtraFields[k] = a + case "option": + val := &AllowedValue{} + err = json.Unmarshal(v, &val) + if err != nil { + panic(err) + } + i.Fields.ExtraFields[k] = val + case "array": + if f.Schema.Items == "option" { + val := []*AllowedValue{} + err = json.Unmarshal(v, &val) + if err != nil { + panic(err) + } + i.Fields.ExtraFields[k] = val + continue + } + fallthrough + default: + if string(v) != "null" { + i.Fields.ExtraFields[k] = strings.Replace(string(v), "\\r\\n", "\n", -1) + } } } - } } } - + return nil } @@ -323,6 +426,9 @@ func (i *JiraIssue) String() string { if os.Getenv("JKLNOCOLOR") == "true" { tmpl = issueTmplNoColor } + if customTmpl := os.Getenv("JKL_ISSUE_TMPL"); customTmpl != "" { + tmpl = template.Must(template.New("customIssueTmpl").Parse(customTmpl)) + } err := tmpl.Execute(b, i) if err != nil { log.Fatalln(err) @@ -331,7 +437,7 @@ func (i *JiraIssue) String() string { return b.String() } -func (i *JiraIssue) PrintExtraFields() string{ +func (i *JiraIssue) PrintExtraFields() string { sorter := map[string]string{} b := bytes.NewBuffer(nil) for k, v := range i.Fields.ExtraFields { @@ -350,7 +456,7 @@ func (i *JiraIssue) PrintExtraFields() string{ return b.String() } -var commentTemplate = `{{if .Fields.Comment }}{{$k := .Key}}{{range .Fields.Comment.Comments}}{{.Author.DisplayName}} [~{{.Author.Name}}] ({{$k}}`+CommentIdSeparator+`{{.Id}}): +var commentTemplate = `{{if .Fields.Comment }}{{$k := .Key}}{{range .Fields.Comment.Comments}}{{.Author.DisplayName}} [~{{.Author.Name}}] ({{$k}}` + CommentIdSeparator + `{{.Id}}): ----------------- {{.Body}} ----------------- @@ -358,30 +464,30 @@ var commentTemplate = `{{if .Fields.Comment }}{{$k := .Key}}{{range .Fields.Comm {{end}}{{end}}` var issueTmplTxt = "\x1b[1m{{.Key}}\x1b[0m\t{{if .Fields.IssueType}}[{{.Fields.IssueType.Name}}]{{end}}\t{{.Fields.Summary}}\n\n" + - "\x1b[1mURL\x1b[0m: {{.URL}}\n\n" + + "\x1b[1mURL\x1b[0m: {{.URL}}\n\n" + "{{if .Fields.Status}}\x1b[1mStatus\x1b[0m:\t {{.Fields.Status.Name}}\n{{end}}" + "{{if .Fields.Priority}}\x1b[1mStatus\x1b[0m:\t {{.Fields.Priority.Name}}\n{{end}}" + - "\x1b[1mTransitions\x1b[0m: {{range .Transitions}}[{{.Name}}] {{end}}\n"+ + "\x1b[1mTransitions\x1b[0m: {{range .Transitions}}[{{.Name}}] {{end}}\n" + "{{if .Fields.Assignee}}\x1b[1mAssignee:\x1b[0m\t{{.Fields.Assignee.Name}}\n{{end}}\n" + "\x1b[1mTime Remaining/Original Estimate:\x1b[0m\t{{.Fields.PrettyRemaining}} / {{.Fields.PrettyOriginalEstimate}}\n\n" + - "{{$i := .}}{{range $k, $v := .Fields.ExtraFields}}{{with index $i.EditMeta.Fields $k}}\x1b[1m{{.Name}}\x1b[0m{{end}}: {{$v}}\n{{end}}\n\n"+ + "{{$i := .}}{{range $k, $v := .Fields.ExtraFields}}{{with index $i.EditMeta.Fields $k}}\x1b[1m{{.Name}}\x1b[0m{{end}}: {{$v}}\n{{end}}\n\n" + "\x1b[1mDescription:\x1b[0m {{.Fields.Description}} \n\n" + - "\x1b[1mIssue Links\x1b[0m: \n{{range .Fields.IssueLinks}}\t{{.}}\n{{end}}\n\n" + + "\x1b[1mIssue Links\x1b[0m: \n{{range .Fields.IssueLinks}}\t{{.}}\n{{end}}\n\n" + "\x1b[1mComments:\x1b[0m\n\n" + commentTemplate + "Worklog:\n{{range .Fields.Worklog.Worklogs}}\t{{.}}\n{{end}}" var issueTmplNoColorTxt = "{{.Key}}\t{{if .Fields.IssueType}}[{{.Fields.IssueType.Name}}]{{end}}\t{{.Fields.Summary}}\n\n" + - "URL: {{.URL}}\n\n" + + "URL: {{.URL}}\n\n" + "{{if .Fields.Status}}Status:\t {{.Fields.Status.Name}}\n{{end}}" + - "Transitions: {{range .Transitions}}[{{.Name}}] {{end}}\n"+ + "Transitions: {{range .Transitions}}[{{.Name}}] {{end}}\n" + "{{if .Fields.Assignee}}Assignee:\t{{.Fields.Assignee.Name}}\n{{end}}\n" + "Time Remaining/Original Estimate:\t{{.Fields.PrettyRemaining}} / {{.Fields.PrettyOriginalEstimate}}\n\n" + - "{{.PrintExtraFields}}\n\n"+ + "{{.PrintExtraFields}}\n\n" + "Description: {{.Fields.Description}} \n\n" + - "Issue Links: \n{{range .Fields.IssueLinks}}\t{{.}}\n{{end}}\n\n" + - "Comments:\n\n" + commentTemplate+ + "Issue Links: \n{{range .Fields.IssueLinks}}\t{{.}}\n{{end}}\n\n" + + "Comments:\n\n" + commentTemplate + "Worklog:\n{{range .Fields.Worklog.Worklogs}}\t{{.}}\n{{end}}" var CommentIdSeparator = "~" var issueTmpl = template.Must(template.New("issueTmpl").Parse(issueTmplTxt)) -var issueTmplNoColor = template.Must(template.New("issueTmplNoColor").Parse(issueTmplNoColorTxt)) \ No newline at end of file +var issueTmplNoColor = template.Must(template.New("issueTmplNoColor").Parse(issueTmplNoColorTxt)) diff --git a/jiraclient.go b/jiraclient.go index ea9a3ed..434dba8 100644 --- a/jiraclient.go +++ b/jiraclient.go @@ -1,6 +1,8 @@ package jkl import ( + "bytes" + "encoding/json" "errors" "fmt" "io" @@ -9,6 +11,8 @@ import ( "net/http/cookiejar" "net/url" "os" + "strings" + "time" ) var j, _ = cookiejar.New(nil) @@ -20,39 +24,84 @@ type JiraClient struct { jiraRoot string } -func init(){ +func init() { x := false Verbose = &x } func NewJiraClient(jiraRoot string) *JiraClient { - j := &JiraClient{ + jc := &JiraClient{ &http.Client{ Jar: j, }, jiraRoot, } - if j.jiraRoot == "" { - j.jiraRoot = os.Getenv("JIRA_ROOT") + if jc.jiraRoot == "" { + jc.jiraRoot = os.Getenv("JIRA_ROOT") } + if cookiefile := os.Getenv("JIRA_COOKIEFILE"); cookiefile != "" { + makeNewFile := false + f, err := os.Open(cookiefile) + server := jc.jiraRoot + "rest/gadget/1.0/login" + u, _ := url.Parse(server) + if err != nil { + makeNewFile = true + } else { + if stat, err := f.Stat(); err == nil { + if time.Now().Sub(stat.ModTime()).Minutes() > 60 { + makeNewFile = true + } else { + var cookies []*http.Cookie + dec := json.NewDecoder(f) + dec.Decode(&cookies) + u, _ = url.Parse(jc.jiraRoot) + jc.Jar.SetCookies(u, cookies) + } + } + f.Close() + } + if makeNewFile { + f, err = os.Create(cookiefile) + if err != nil { + panic(err) + } + + http.DefaultClient.Jar = j + form := url.Values{} + form.Add("os_username", os.Getenv("JIRA_USER")) + form.Add("os_password", os.Getenv("JIRA_PASSWORD")) + req, _ := http.NewRequest("POST", server, strings.NewReader(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode >= 400 { + fmt.Println(resp.Header) + fmt.Println(resp.Status) + fmt.Println(err) + } + b := bytes.NewBuffer(nil) + enc := json.NewEncoder(b) + enc.Encode(j.Cookies(u)) + io.Copy(f, b) + f.Close() + } + } + if *Verbose { - fmt.Println("Jira root:", j.jiraRoot) + fmt.Println("Jira root:", jc.jiraRoot) } - return j + return jc } -func (j *JiraClient) Do(req *http.Request) (*http.Response, error) { - var err error - req.SetBasicAuth(os.Getenv("JIRA_USER"), os.Getenv("JIRA_PASSWORD")) - if *Verbose { - fmt.Println("Jira User: ", os.Getenv("JIRA_USER")) - fmt.Println("Jira Password: ", os.Getenv("JIRA_PASSWORD")) - } +func (j *JiraClient) DoLess(req *http.Request) (*http.Response, error) { req.Header.Add("Content-Type", "application/json") req.Header.Add("Accept", "application/json, text/plain, text/html") - req.URL, err = url.Parse(j.jiraRoot + "rest/" + req.URL.RequestURI()) - if err != nil { - return nil, err + return j.DoEvenLess(req) +} + +func (j *JiraClient) DoEvenLess(req *http.Request) (*http.Response, error) { + var err error + if os.Getenv("JIRA_COOKIEFILE") == "" { + req.SetBasicAuth(os.Getenv("JIRA_USER"), os.Getenv("JIRA_PASSWORD")) } resp, err := j.Client.Do(req) if err != nil { @@ -75,6 +124,15 @@ func (j *JiraClient) Do(req *http.Request) (*http.Response, error) { return resp, nil } +func (j *JiraClient) Do(req *http.Request) (*http.Response, error) { + var err error + req.URL, err = url.Parse(j.jiraRoot + "rest/" + req.URL.RequestURI()) + if err != nil { + return nil, err + } + return j.DoLess(req) +} + func (j *JiraClient) Put(path string, payload io.Reader) (*http.Response, error) { req, err := http.NewRequest("PUT", path, payload) if err != nil { diff --git a/jkl.go b/jkl.go index 7823de3..6d53e76 100644 --- a/jkl.go +++ b/jkl.go @@ -8,6 +8,8 @@ import ( "io" "io/ioutil" "log" + "mime/multipart" + "net/http" "net/url" "os" "strings" @@ -32,6 +34,7 @@ func bootHttpClient() { func Create(issue *JiraIssue) (*JiraIssue, error) { bootHttpClient() payload, err := formatPayload(issue) + if err != nil { return nil, err } @@ -216,7 +219,7 @@ func DoTransition(taskKey string, transitionName string) error { return err } var t *Transition - fmt.Println(iss.Transitions) + //fmt.Println(iss.Transitions) for _, transition := range iss.Transitions { if strings.ToLower(transition.Name) == strings.ToLower(transitionName) { t = transition @@ -251,6 +254,120 @@ func LogWork(taskKey string, workAmount string) error { return nil } +func Assign(taskKey string, user string) error { + bootHttpClient() + payload, err := serializePayload(map[string]interface{}{"name": user}) + resp, err := httpClient.Put("api/2/issue/"+taskKey+"/assignee", payload) + if err != nil { + fmt.Println(resp.StatusCode) + return err + } + if resp.StatusCode >= 400 { + io.Copy(os.Stderr, resp.Body) + } + return nil +} + +func FlagIssue(taskKeys []string, flg bool) error { + bootHttpClient() + payload, err := serializePayload(map[string]interface{}{"issueKeys": taskKeys, "flag": flg}) + req, err := http.NewRequest("POST", "", payload) + + if err != nil { + return err + } + req.URL, err = url.Parse(httpClient.jiraRoot + "rest/" + "greenhopper/1.0/xboard/issue/flag/flag.json") + if err != nil { + return err + } + resp, err := httpClient.DoLess(req) + if err != nil { + fmt.Println(resp.StatusCode) + return err + } + if resp.StatusCode >= 400 { + io.Copy(os.Stderr, resp.Body) + } + return nil +} + +type msi map[string]interface{} + +func LinkIssue(params []string) error { + bootHttpClient() + if len(params) == 0 { + resp, err := httpClient.Get("api/2/issueLinkType") + if err != nil { + if resp != nil { + fmt.Println(resp.StatusCode) + } + return err + } + io.Copy(os.Stdout, resp.Body) + return nil + } + payload, err := serializePayload(msi{ + "type": msi{"name": strings.Join(params[1:len(params)-1], " ")}, + "inwardIssue": msi{"key": params[len(params)-1]}, + "outwardIssue": msi{"key": params[0]}, + }) + resp, err := httpClient.Post("api/2/issueLink", payload) + if err != nil { + if resp != nil { + fmt.Println(resp.StatusCode) + } + return err + } + if resp.StatusCode >= 400 { + io.Copy(os.Stderr, resp.Body) + } + return nil +} + +func Attach(issueKey string, filename string) error { + bootHttpClient() + + // Prepare a form that you will submit to that URL. + var b bytes.Buffer + w := multipart.NewWriter(&b) + // Add your image file + f, err := os.Open(filename) + if err != nil { + return err + } + fi, err := os.Lstat(filename) + fw, err := w.CreateFormFile("file", fi.Name()) + if err != nil { + return err + } + if _, err = io.Copy(fw, f); err != nil { + return err + } + // Don't forget to close the multipart writer. + // If you don't close it, your request will be missing the terminating boundary. + w.Close() + + req, err := http.NewRequest("POST", "", &b) + + if err != nil { + return err + } + req.URL, err = url.Parse(httpClient.jiraRoot + "rest/" + fmt.Sprintf("api/2/issue/%s/attachments", issueKey)) + if err != nil { + return err + } + req.Header.Add("X-Atlassian-Token", "no-check") + req.Header.Add("Content-Type", w.FormDataContentType()) + res, err := httpClient.DoEvenLess(req) + + if err != nil { + s, _ := ioutil.ReadAll(res.Body) + fmt.Println(string(s)) + return err + } + return nil +} + func formatPayload(issue *JiraIssue) (io.Reader, error) { if issue.Fields != nil && issue.Fields.Project != nil && @@ -265,7 +382,7 @@ func serializePayload(i interface{}) (io.Reader, error) { payload := bytes.NewBuffer(b) enc := json.NewEncoder(payload) err := enc.Encode(i) - fmt.Println(payload.String()) + //fmt.Println("payload: ", payload.String()) if err != nil { fmt.Println(err) return nil, err diff --git a/jkl_test.go b/jkl_test.go index 70f796c..d1665bd 100644 --- a/jkl_test.go +++ b/jkl_test.go @@ -1,34 +1,11 @@ package jkl import ( - "encoding/json" - "fmt" "os" "testing" "text/template" ) -func TestUnmarshalProjects(t *testing.T) { - f, err := os.Open("projects.json") - if err != nil { - t.Error(err) - } - dec := json.NewDecoder(f) - x := struct{ Projects []Project }{} - - err = dec.Decode(&x) - if err != nil { - t.Error(err) - } - for _, p := range x.Projects { - for _, it := range p.IssueTypes { - for sn, f := range it.Fields { - fmt.Println(it.Name, sn, f.Name, f.Required, f.Schema.Type) - } - } - } -} - type TestType struct { Field string }