├── LICENSE ├── README.md ├── TODO ├── client.go ├── files.go ├── jira.go ├── main.go ├── utils.go └── workflow.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Kenny Levinsen 4 | Portions Copyright (c) 2019 David Romano 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jirafs 2 | 3 | jirafs is a 9P fileserver that presents JIRA as a filesystem. It tries to be feature-complete without getting in the way. 4 | 5 | jirafs supports both username/password (basic authentication) login, and oauth 1.0 login to JIRA. 6 | 7 | ## OAuth 8 | 9 | In order to use oauth, you must generate a key pair for jirafs: 10 | ```plain 11 | openssl genrsa -out private_key.pem 4096 12 | openssl rsa -pubout -in private_key.pem -out public_key.pem 13 | ``` 14 | 15 | After setting this up, you will have to set up a generic application link in JIRA, entering arbitrary URL's (they don't matter), a consumer key and the public key generated above. Once done, starting jirafs with `-oath -ckey consumer_key -pkey private_key.pem` should work, requesting that you go through the OAuth verification step (note that -ckey is the literal key, not a path to a key file). 16 | 17 | ## Username/password (basic) auth 18 | 19 | Simply start jirafs with the `-pass` option. 20 | 21 | ## Mounting jirafs 22 | 23 | On Linux, you can mount jirafs with the following (assuming it is running on localhost:30000): 24 | ```plain 25 | sudo mount -t 9p -o trans=tcp,port=30000,noextend,sync,dirsync,nosuid,tcp 127.0.0.1 /mnt/jira 26 | ``` 27 | 28 | Beware that v9fs, the 9P kernel support for Linux, has a few bugs. One is that it does not feed through the OTRUNC opening option properly, meaning that some "echo wee > file" becomes "echo wee >> file" instead. Another is that it does not handle large directory listings well, so keep maxlisting to about 100. The patches to fix these issues are on their way, but it will probably take a while before you'll get the update. 29 | 30 | These issues are due to v9fs not getting much use as a "normal" 9P client, but let's change that! 31 | 32 | On MacOSX, there are two options: 33 | * You can use plan9port which provides 9pfuse. First install [FUSE for macOS](https://osxfuse.github.io/). Then install [plan9port](https://9fans.github.io/plan9port/). You can then use 9pfuse to mount. Beware, however, that stock 'ls' on MacOSX won't work to show the available directory files--you have to use '9 ls', '9 lc' etc. 34 | ```plain 35 | cd jirafs; go build; ./jirafs -pass -url=https://jira.example.com 36 | # then after entering credentials, open another terminal in the parent directory you want for accessing JIRA: 37 | 9pfuse 'tcp!localhost!30000' my-jira; cd my-jira; 9 lc projects 38 | ``` 39 | * You can use [Mac9P](https://github.com/kennylevinsen/mac9p). Follow the install instructions. 40 | 41 | ## Disclaimer 42 | 43 | jirafs comes without any warranties. The jirafs directory structure may change at random until an optimal shape has been reached. 44 | 45 | # Structure 46 | 47 | ```plain 48 | / 49 | ctl 50 | projects/ 51 | ABC/ 52 | components 53 | issuetypes 54 | issues/ 55 | 1/ # ABC-1 56 | ... 57 | ... 58 | 59 | DEF/ 60 | ... 61 | ... 62 | issues/ 63 | new/ 64 | ctl 65 | description 66 | project 67 | summary 68 | type 69 | ABC-1/ 70 | assignee 71 | comments/ 72 | 1/ 73 | author 74 | updated 75 | created 76 | comment 77 | 2/ 78 | ... 79 | ... 80 | comment 81 | components 82 | creator 83 | ctl 84 | description 85 | key 86 | labels 87 | links 88 | priority 89 | progress 90 | project 91 | raw 92 | reporter 93 | resolution 94 | status 95 | summary 96 | transition 97 | type 98 | worklog/ 99 | 1/ 100 | author 101 | started 102 | time 103 | comment 104 | ... 105 | ABC-2/ 106 | ... 107 | ... 108 | 109 | ``` 110 | 111 | ## Files worthy of note 112 | 113 | ## ctl 114 | 115 | A global control file. It supports the following commands: 116 | 117 | * search search_name JQL 118 | 119 | If successful, a folder named search_name will appear at the jirafs root. `ls`'ing in the folder updates the search. The search does not update when simply trying to access an issue in order to avoid significant performance issues. 120 | 121 | * pass-login 122 | 123 | Re-issue a username/password login using the initially provided credentials. 124 | 125 | * set name val 126 | 127 | Sets jirafs variables. Currently, max-listing is the only variable, which expects an integer. 128 | 129 | 130 | ## projects/ABC/issues 131 | 132 | A convenience view of only the issues present in the project. They are listed without their project key. Their structure is similar to that of an issue in issues/ 133 | 134 | ## issues/new 135 | 136 | New is a folder that creates a new skeleton issue when entered. It only contains a minimal set of files necessary to create the issue. Once all fields have been filled out, writing "commit" to the ctl file will cause the issue to be created. The issue folder will change to be that of a created issue, with all files available. Read the "key" file to figure out what issue key your issue received. 137 | 138 | ### issues/ABC-1/comments 139 | 140 | A folder containing comments for the issue. Writing to the comment file creates a new comment. Writing to an existing comment changes it. This structure may change in the future. 141 | 142 | ### issues/ABC-1/components 143 | 144 | A list of components this issue applies to. Writable. Note that the component names are case sensitive, and must be match an existing component for the project. 145 | 146 | ### issues/ABC-1/ctl 147 | 148 | A command file. On a new issue, the only accepted command is "commit", which creates the issue with the provided parameters. For existing issues, the only accepted command is "delete". In the future, more commands may be made available for things that map poorly to files. 149 | 150 | ### issues/ABC-1/links 151 | 152 | Issue links in the form of "INWARD-ISSUE OUTWARD-ISSUE RELATIONSHIP", such as "ABC-1 ABC-2 Blocks". Writable. 153 | 154 | ### issues/ABC-1/raw 155 | 156 | The raw JSON issue object. Writable. Expects the written data to be JSON, and the write will be pushed as an issue update. 157 | 158 | ### issues/ABC-1/status 159 | 160 | When writing to the status file, jirafs will fetch the relevant workflow graph and trace the shortest path from the current status to the requested status, issuing the necessary transitions in order. 161 | 162 | ### issues/ABC-1/transition 163 | 164 | A list of currently possible transitions. Writing to the file executes the transition. See `status` for a more convenient way of changing issue status. 165 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Support saving of queries across mounts/unmounts, per username 2 | * Return directory listing of saved queries by ordering in JQL 3 | * Support orderBy as control for each listing 4 | * Support startAt,total pagination control 5 | * ?Use https://docs.atlassian.com/software/jira/docs/api/REST/7.6.1/jira-rest-plugin.wadl in some way? 6 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "encoding/pem" 9 | "fmt" 10 | "io" 11 | "io/ioutil" 12 | "net/http" 13 | "net/url" 14 | 15 | "github.com/mrjones/oauth" 16 | ) 17 | 18 | type Client struct { 19 | *http.Client 20 | 21 | user, pass string 22 | jiraURL *url.URL 23 | usingOAuth bool 24 | 25 | maxlisting int 26 | } 27 | 28 | type RPCError struct { 29 | Status string 30 | Body []byte 31 | Description string 32 | } 33 | 34 | func (rpc *RPCError) Error() string { 35 | return fmt.Sprintf("RPCError: %s: status %s, %s", rpc.Description, rpc.Status, rpc.Body) 36 | } 37 | 38 | func (c *Client) RPC(method, path string, body, target interface{}) error { 39 | u, err := c.jiraURL.Parse(path) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | var b io.Reader 45 | switch x := body.(type) { 46 | case nil: 47 | case []byte: 48 | b = bytes.NewReader(x) 49 | default: 50 | buf, err := json.Marshal(body) 51 | if err != nil { 52 | return err 53 | } 54 | b = bytes.NewReader(buf) 55 | } 56 | 57 | req, err := http.NewRequest(method, u.String(), b) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | if body != nil { 63 | req.Header.Set("Content-Type", "application/json") 64 | } 65 | req.Header.Set("X-Atlassian-Token", "nocheck") 66 | 67 | if !c.usingOAuth { 68 | req.SetBasicAuth(c.user, c.pass) 69 | } 70 | 71 | resp, err := c.Client.Do(req) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | respBody, err := ioutil.ReadAll(resp.Body) 77 | if err != nil { 78 | return err 79 | } 80 | resp.Body.Close() 81 | 82 | if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) { 83 | err = &RPCError{ 84 | Description: "request failed", 85 | Status: resp.Status, 86 | Body: respBody, 87 | } 88 | return err 89 | } 90 | 91 | if target != nil { 92 | if err := json.Unmarshal(respBody, target); err != nil { 93 | return err 94 | } 95 | } 96 | 97 | return nil 98 | 99 | } 100 | 101 | func (c *Client) oauth(consumerKey, privateKeyFile string) error { 102 | pvf, err := ioutil.ReadFile(privateKeyFile) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | block, _ := pem.Decode(pvf) 108 | privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | url1, _ := c.jiraURL.Parse("/plugins/servlet/oauth/request-token") 114 | url2, _ := c.jiraURL.Parse("/plugins/servlet/oauth/authorize") 115 | url3, _ := c.jiraURL.Parse("/plugins/servlet/oauth/access-token") 116 | 117 | t := oauth.NewRSAConsumer( 118 | consumerKey, 119 | privateKey, 120 | oauth.ServiceProvider{ 121 | RequestTokenUrl: url1.String(), 122 | AuthorizeTokenUrl: url2.String(), 123 | AccessTokenUrl: url3.String(), 124 | HttpMethod: "POST", 125 | }, 126 | ) 127 | 128 | t.HttpClient = &http.Client{ 129 | Transport: &http.Transport{ 130 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 131 | }, 132 | } 133 | 134 | requestToken, url, err := t.GetRequestTokenAndUrl("oob") 135 | if err != nil { 136 | return err 137 | } 138 | 139 | fmt.Printf("OAuth token requested. Please to go the following URL:\n\t%s\n\nEnter verification code: ", url) 140 | var verificationCode string 141 | fmt.Scanln(&verificationCode) 142 | accessToken, err := t.AuthorizeToken(requestToken, verificationCode) 143 | if err != nil { 144 | return err 145 | } 146 | fmt.Printf("OAuth token authorized.\n") 147 | 148 | client, err := t.MakeHttpClient(accessToken) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | c.Client = client 154 | return nil 155 | } 156 | -------------------------------------------------------------------------------- /files.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "strings" 8 | "time" 9 | 10 | "github.com/kennylevinsen/qp" 11 | "github.com/kennylevinsen/qptools/fileserver/trees" 12 | ) 13 | 14 | type jiraWalker interface { 15 | Walk(jc *Client, name string) (trees.File, error) 16 | } 17 | 18 | type jiraLister interface { 19 | List(jc *Client) ([]qp.Stat, error) 20 | } 21 | 22 | type jiraRemover interface { 23 | Remove(jc *Client, name string) error 24 | } 25 | 26 | // JiraDir is a convenience wrapper for dynamic directory hooks. 27 | type JiraDir struct { 28 | thing interface{} 29 | client *Client 30 | *trees.SyntheticDir 31 | } 32 | 33 | func (jd *JiraDir) Walk(user, name string) (trees.File, error) { 34 | 35 | if f, ok := jd.thing.(jiraWalker); ok { 36 | return f.Walk(jd.client, name) 37 | } 38 | if f, ok := jd.thing.(trees.Dir); ok { 39 | return f.Walk(user, name) 40 | } 41 | 42 | return nil, trees.ErrPermissionDenied 43 | } 44 | 45 | func (jd *JiraDir) List(user string) ([]qp.Stat, error) { 46 | if f, ok := jd.thing.(jiraLister); ok { 47 | return f.List(jd.client) 48 | } 49 | if f, ok := jd.thing.(trees.Lister); ok { 50 | return f.List(user) 51 | } 52 | 53 | return nil, trees.ErrPermissionDenied 54 | } 55 | 56 | func (jd *JiraDir) Remove(user, name string) error { 57 | if f, ok := jd.thing.(jiraRemover); ok { 58 | return f.Remove(jd.client, name) 59 | } 60 | if f, ok := jd.thing.(trees.Dir); ok { 61 | return f.Remove(user, name) 62 | } 63 | 64 | return trees.ErrPermissionDenied 65 | } 66 | 67 | func (jd *JiraDir) Create(user, name string, perms qp.FileMode) (trees.File, error) { 68 | return nil, trees.ErrPermissionDenied 69 | } 70 | 71 | func (jd *JiraDir) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) { 72 | if !jd.CanOpen(user, mode) { 73 | return nil, errors.New("access denied") 74 | } 75 | 76 | jd.Lock() 77 | defer jd.Unlock() 78 | jd.Atime = time.Now() 79 | jd.Opens++ 80 | return &trees.ListHandle{ 81 | Dir: jd, 82 | User: user, 83 | }, nil 84 | } 85 | 86 | func NewJiraDir(name string, perm qp.FileMode, user, group string, jc *Client, thing interface{}) (*JiraDir, error) { 87 | switch thing.(type) { 88 | case trees.Dir, jiraWalker, jiraLister, jiraRemover: 89 | default: 90 | return nil, fmt.Errorf("unsupported type: %T", thing) 91 | } 92 | 93 | return &JiraDir{ 94 | thing: thing, 95 | client: jc, 96 | SyntheticDir: trees.NewSyntheticDir(name, perm, user, group), 97 | }, nil 98 | } 99 | 100 | type CloseSaverHandle struct { 101 | onClose func() error 102 | trees.ReadWriteAtCloser 103 | } 104 | 105 | func (csh *CloseSaverHandle) Close() error { 106 | err := csh.ReadWriteAtCloser.Close() 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if csh.onClose != nil { 112 | return csh.onClose() 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // CloseSaver calls a callback on save if the file was opened for writing. 119 | type CloseSaver struct { 120 | onClose func() error 121 | forceTrunc bool 122 | trees.File 123 | } 124 | 125 | func (cs *CloseSaver) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) { 126 | var closer func() error 127 | 128 | switch mode & 3 { 129 | case qp.OWRITE, qp.ORDWR: 130 | if cs.forceTrunc { 131 | mode |= qp.OTRUNC 132 | } 133 | closer = cs.onClose 134 | } 135 | 136 | hndl, err := cs.File.Open(user, mode) 137 | if err != nil { 138 | return nil, err 139 | } 140 | 141 | return &CloseSaverHandle{ 142 | ReadWriteAtCloser: hndl, 143 | onClose: closer, 144 | }, nil 145 | } 146 | 147 | func NewCloseSaver(file trees.File, onClose func() error) *CloseSaver { 148 | return &CloseSaver{ 149 | onClose: onClose, 150 | File: file, 151 | } 152 | } 153 | 154 | // CommandFile calls commands on write. 155 | type CommandFile struct { 156 | cmds map[string]func([]string) error 157 | *trees.SyntheticFile 158 | } 159 | 160 | func (cf *CommandFile) Close() error { return nil } 161 | func (cf *CommandFile) ReadAt(p []byte, offset int64) (int, error) { 162 | return 0, errors.New("cannot read from command file") 163 | } 164 | 165 | func (cf *CommandFile) WriteAt(p []byte, offset int64) (int, error) { 166 | args := strings.Split(strings.Trim(string(p), " \n"), " ") 167 | cmd := args[0] 168 | args = args[1:] 169 | 170 | if f, exists := cf.cmds[cmd]; exists { 171 | err := f(args) 172 | if err != nil { 173 | log.Printf("Command %s failed: %v", cmd, err) 174 | } 175 | return len(p), err 176 | } 177 | return len(p), errors.New("no such command") 178 | } 179 | 180 | func (cf *CommandFile) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) { 181 | if !cf.CanOpen(user, mode) { 182 | return nil, trees.ErrPermissionDenied 183 | } 184 | 185 | return cf, nil 186 | } 187 | 188 | func NewCommandFile(name string, perms qp.FileMode, user, group string, cmds map[string]func([]string) error) *CommandFile { 189 | return &CommandFile{ 190 | cmds: cmds, 191 | SyntheticFile: trees.NewSyntheticFile(name, perms, user, group), 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /jira.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/andygrunwald/go-jira" 14 | "github.com/kennylevinsen/qp" 15 | "github.com/kennylevinsen/qptools/fileserver/trees" 16 | ) 17 | 18 | type WorklogView struct { 19 | issueNo string 20 | worklog string 21 | } 22 | 23 | func (wv *WorklogView) Walk(jc *Client, file string) (trees.File, error) { 24 | w, err := GetSpecificWorklogForIssue(jc, wv.issueNo, wv.worklog) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 30 | switch file { 31 | case "comment": 32 | sf.SetContent([]byte(w.Comment + "\n")) 33 | case "author": 34 | sf.SetContent([]byte(w.Author.Name + "\n")) 35 | case "time": 36 | t := time.Duration(w.TimeSpentSeconds) * time.Second 37 | sf.SetContent([]byte(t.String() + "\n")) 38 | case "started": 39 | sf.SetContent([]byte(time.Time(*w.Started).String() + "\n")) 40 | default: 41 | return nil, nil 42 | } 43 | 44 | return sf, nil 45 | } 46 | 47 | func (wv *WorklogView) List(jc *Client) ([]qp.Stat, error) { 48 | return StringsToStats([]string{"comment", "author", "time", "started"}, 0555, "jira", "jira"), nil 49 | } 50 | 51 | type IssueWorklogView struct { 52 | issueNo string 53 | } 54 | 55 | func (iwv *IssueWorklogView) Walk(jc *Client, file string) (trees.File, error) { 56 | w, err := GetWorklogForIssue(jc, iwv.issueNo) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | for _, wr := range w.Worklogs { 62 | if wr.ID == file { 63 | return NewJiraDir(file, 64 | 0555|qp.DMDIR, 65 | "jira", 66 | "jira", 67 | jc, 68 | &WorklogView{issueNo: iwv.issueNo, worklog: file}) 69 | } 70 | } 71 | 72 | return nil, nil 73 | } 74 | 75 | func (iwv *IssueWorklogView) List(jc *Client) ([]qp.Stat, error) { 76 | w, err := GetWorklogForIssue(jc, iwv.issueNo) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | var s []string 82 | for _, wr := range w.Worklogs { 83 | s = append(s, wr.ID) 84 | } 85 | 86 | return StringsToStats(s, 0555|qp.DMDIR, "jira", "jira"), nil 87 | } 88 | 89 | type CommentView struct { 90 | issueNo string 91 | comment string 92 | } 93 | 94 | func (cw *CommentView) Walk(jc *Client, file string) (trees.File, error) { 95 | if !StringExistsInSets(file, []string{"author", "comment", "updated", "created"}) { 96 | return nil, nil 97 | } 98 | 99 | cmt, err := GetComment(jc, cw.issueNo, cw.comment) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | var cnt []byte 105 | writable := false 106 | forceTrunc := true 107 | switch file { 108 | case "author": 109 | cnt = []byte(cmt.Author.Name + "\n") 110 | case "comment": 111 | cnt = []byte(cmt.Body) 112 | forceTrunc = false 113 | writable = true 114 | case "updated": 115 | cnt = []byte(cmt.Updated + "\n") 116 | case "created": 117 | cnt = []byte(cmt.Created + "\n") 118 | } 119 | var perm qp.FileMode 120 | if writable { 121 | perm = 0777 122 | } else { 123 | perm = 0555 124 | } 125 | sf := trees.NewSyntheticFile(file, perm, "jira", "jira") 126 | sf.SetContent(cnt) 127 | 128 | onClose := func() error { 129 | sf.RLock() 130 | str := string(sf.Content) 131 | sf.RUnlock() 132 | 133 | switch file { 134 | case "comment": 135 | return SetComment(jc, cw.issueNo, cw.comment, str) 136 | } 137 | return nil 138 | } 139 | 140 | if writable { 141 | cs := NewCloseSaver(sf, onClose) 142 | cs.forceTrunc = forceTrunc 143 | return cs, nil 144 | } 145 | 146 | return sf, nil 147 | } 148 | 149 | func (cw *CommentView) List(jc *Client) ([]qp.Stat, error) { 150 | a := StringsToStats([]string{"comment"}, 0777, "jira", "jira") 151 | b := StringsToStats([]string{"author", "updated", "created"}, 0555, "jira", "jira") 152 | return append(a, b...), nil 153 | } 154 | 155 | type IssueCommentView struct { 156 | issueNo string 157 | } 158 | 159 | func (icv *IssueCommentView) Walk(jc *Client, file string) (trees.File, error) { 160 | switch file { 161 | case "comment": 162 | sf := trees.NewSyntheticFile(file, 0777, "jira", "jira") 163 | onClose := func() error { 164 | sf.Lock() 165 | body := string(sf.Content) 166 | sf.Unlock() 167 | 168 | return AddComment(jc, icv.issueNo, body) 169 | } 170 | return NewCloseSaver(sf, onClose), nil 171 | default: 172 | _, err := GetComment(jc, icv.issueNo, file) 173 | if err != nil { 174 | return nil, err 175 | } 176 | cv := &CommentView{issueNo: icv.issueNo, comment: file} 177 | return NewJiraDir(file, 0777|qp.DMDIR, "jira", "jira", jc, cv) 178 | } 179 | } 180 | 181 | func (icv *IssueCommentView) List(jc *Client) ([]qp.Stat, error) { 182 | strs, err := GetCommentsForIssue(jc, icv.issueNo) 183 | if err != nil { 184 | return nil, err 185 | } 186 | 187 | a := StringsToStats(strs, 0777|qp.DMDIR, "jira", "jira") 188 | b := StringsToStats([]string{"comment"}, 0777, "jira", "jira") 189 | 190 | return append(a, b...), nil 191 | } 192 | 193 | func (icv *IssueCommentView) Remove(jc *Client, name string) error { 194 | switch name { 195 | case "comment": 196 | return trees.ErrPermissionDenied 197 | default: 198 | return RemoveComment(jc, icv.issueNo, name) 199 | } 200 | } 201 | 202 | type IssueView struct { 203 | project string 204 | issueNo string 205 | 206 | issueLock sync.Mutex 207 | newIssue bool 208 | values map[string]string 209 | } 210 | 211 | func (iw *IssueView) normalFiles() (files, dirs []string) { 212 | files = []string{"assignee", "creator", "ctl", "description", "type", "key", "reporter", "status", 213 | "summary", "labels", "transition", "priority", "resolution", "raw", "progress", "links", "components", 214 | "project"} 215 | dirs = []string{"comments", "worklog"} 216 | return 217 | } 218 | 219 | func (iw *IssueView) newFiles() (files, dirs []string) { 220 | files = []string{"ctl", "description", "type", "summary", "project"} 221 | return 222 | } 223 | 224 | func (iw *IssueView) newWalk(jc *Client, file string) (trees.File, error) { 225 | files, dirs := iw.newFiles() 226 | if !StringExistsInSets(file, files, dirs) { 227 | return nil, nil 228 | } 229 | 230 | switch file { 231 | case "ctl": 232 | cmds := map[string]func([]string) error{ 233 | "commit": func(args []string) error { 234 | var issuetype, summary, description, project string 235 | 236 | iw.issueLock.Lock() 237 | isNew := iw.newIssue 238 | if iw.values != nil { 239 | issuetype = strings.Replace(string(iw.values["type"]), "\n", "", -1) 240 | summary = strings.Replace(string(iw.values["summary"]), "\n", "", -1) 241 | description = string(iw.values["description"]) 242 | project = strings.Replace(string(iw.values["project"]), "\n", "", -1) 243 | } 244 | iw.issueLock.Unlock() 245 | 246 | if project == "" && iw.project != "" { 247 | project = iw.project 248 | } 249 | 250 | if !isNew { 251 | return errors.New("issue already committed") 252 | } 253 | 254 | issue := jira.Issue{ 255 | Fields: &jira.IssueFields{ 256 | Type: jira.IssueType{ 257 | Name: issuetype, 258 | }, 259 | Project: jira.Project{ 260 | Key: project, 261 | }, 262 | Summary: summary, 263 | Description: description, 264 | }, 265 | } 266 | 267 | key, err := CreateIssue(jc, &issue) 268 | if err != nil { 269 | log.Printf("Create failed: %v", err) 270 | return err 271 | } 272 | 273 | iw.issueLock.Lock() 274 | iw.issueNo = key 275 | iw.project = project 276 | iw.newIssue = false 277 | iw.issueLock.Unlock() 278 | return nil 279 | }, 280 | } 281 | return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil 282 | default: 283 | sf := trees.NewSyntheticFile(file, 0777, "jira", "jira") 284 | iw.issueLock.Lock() 285 | defer iw.issueLock.Unlock() 286 | 287 | if iw.values == nil { 288 | iw.values = make(map[string]string) 289 | } 290 | 291 | value := iw.values[file] 292 | 293 | sf.SetContent([]byte(value)) 294 | 295 | onClose := func() error { 296 | iw.issueLock.Lock() 297 | defer iw.issueLock.Unlock() 298 | 299 | iw.values[file] = string(sf.Content) 300 | return nil 301 | } 302 | 303 | return NewCloseSaver(sf, onClose), nil 304 | } 305 | 306 | } 307 | 308 | func renderIssueLink(l *jira.IssueLink, key string) string { 309 | switch { 310 | case l.OutwardIssue != nil: 311 | return fmt.Sprintf("%s %s %s", key, l.OutwardIssue.Key, l.Type.Name) 312 | case l.InwardIssue != nil: 313 | return fmt.Sprintf("%s %s %s", l.InwardIssue.Key, key, l.Type.Name) 314 | default: 315 | return "" 316 | } 317 | } 318 | 319 | func (iw *IssueView) normalWalk(jc *Client, file string) (trees.File, error) { 320 | files, dirs := iw.normalFiles() 321 | if !StringExistsInSets(file, files, dirs) { 322 | return nil, nil 323 | } 324 | 325 | issue, err := GetIssue(jc, iw.issueNo) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | forceTrunc := true 331 | writable := true 332 | 333 | var cnt []byte 334 | switch file { 335 | case "assignee": 336 | if issue.Fields != nil && issue.Fields.Assignee != nil { 337 | cnt = []byte(issue.Fields.Assignee.Name + "\n") 338 | } 339 | case "reporter": 340 | if issue.Fields != nil && issue.Fields.Reporter != nil { 341 | cnt = []byte(issue.Fields.Reporter.Name + "\n") 342 | } 343 | case "creator": 344 | if issue.Fields != nil && issue.Fields.Creator != nil { 345 | cnt = []byte(issue.Fields.Creator.Name + "\n") 346 | } 347 | case "summary": 348 | if issue.Fields != nil { 349 | cnt = []byte(issue.Fields.Summary + "\n") 350 | } 351 | forceTrunc = false 352 | case "description": 353 | if issue.Fields != nil { 354 | cnt = []byte(issue.Fields.Description + "\n") 355 | } 356 | forceTrunc = false 357 | case "type": 358 | if issue.Fields != nil { 359 | cnt = []byte(issue.Fields.Type.Name + "\n") 360 | } 361 | case "status": 362 | if issue.Fields != nil && issue.Fields.Status != nil { 363 | cnt = []byte(issue.Fields.Status.Name + "\n") 364 | } 365 | case "priority": 366 | if issue.Fields != nil && issue.Fields.Priority != nil { 367 | cnt = []byte(issue.Fields.Priority.Name + "\n") 368 | } 369 | case "resolution": 370 | if issue.Fields != nil && issue.Fields.Resolution != nil { 371 | cnt = []byte(issue.Fields.Resolution.Name + "\n") 372 | } 373 | case "progress": 374 | if issue.Fields != nil && issue.Fields.Progress != nil { 375 | p := time.Duration(issue.Fields.Progress.Progress) * time.Second 376 | t := time.Duration(issue.Fields.Progress.Total) * time.Second 377 | r := t - p 378 | cnt = []byte(fmt.Sprintf("Progress: %v, Remaining: %v, Total: %v\n", p, r, t)) 379 | } 380 | writable = false 381 | case "project": 382 | if issue.Fields != nil { 383 | cnt = []byte(issue.Fields.Project.Key + "\n") 384 | } 385 | writable = false 386 | case "key": 387 | cnt = []byte(issue.Key + "\n") 388 | writable = false 389 | case "components": 390 | if issue.Fields != nil { 391 | var s string 392 | for _, comp := range issue.Fields.Components { 393 | s += comp.Name + "\n" 394 | } 395 | cnt = []byte(s) 396 | } 397 | forceTrunc = false 398 | case "labels": 399 | if issue.Fields != nil { 400 | var s string 401 | for _, lbl := range issue.Fields.Labels { 402 | s += lbl + "\n" 403 | } 404 | cnt = []byte(s) 405 | } 406 | forceTrunc = false 407 | case "transition": 408 | trs, err := GetTransitionsForIssue(jc, issue.Key) 409 | if err != nil { 410 | log.Printf("Could not get transitions for issue %s: %v", issue.Key, err) 411 | return nil, err 412 | } 413 | 414 | var s string 415 | for _, tr := range trs { 416 | s += tr.Name + "\n" 417 | } 418 | cnt = []byte(s) 419 | case "links": 420 | var s string 421 | if issue.Fields != nil { 422 | for _, l := range issue.Fields.IssueLinks { 423 | s += renderIssueLink(l, issue.Key) + "\n" 424 | } 425 | cnt = []byte(s) 426 | } 427 | forceTrunc = false 428 | case "comments": 429 | return NewJiraDir(file, 430 | 0555|qp.DMDIR, 431 | "jira", 432 | "jira", 433 | jc, 434 | &IssueCommentView{issueNo: iw.issueNo}) 435 | case "worklog": 436 | return NewJiraDir(file, 437 | 0555|qp.DMDIR, 438 | "jira", 439 | "jira", 440 | jc, 441 | &IssueWorklogView{issueNo: iw.issueNo}) 442 | case "raw": 443 | b, err := json.MarshalIndent(issue, "", " ") 444 | if err != nil { 445 | return nil, err 446 | } 447 | cnt = b 448 | case "ctl": 449 | cmds := map[string]func([]string) error{ 450 | "delete": func(args []string) error { 451 | return DeleteIssue(jc, issue.Key) 452 | }, 453 | } 454 | return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil 455 | } 456 | 457 | var perm qp.FileMode 458 | if writable { 459 | perm = 0777 460 | } else { 461 | perm = 0555 462 | } 463 | 464 | sf := trees.NewSyntheticFile(file, perm, "jira", "jira") 465 | sf.SetContent(cnt) 466 | 467 | onClose := func() error { 468 | switch file { 469 | case "raw": 470 | sf.RLock() 471 | defer sf.RUnlock() 472 | return SetIssueRaw(jc, issue.Key, sf.Content) 473 | case "links": 474 | cur := make(map[string]string) 475 | for _, l := range issue.Fields.IssueLinks { 476 | cur[renderIssueLink(l, issue.Key)] = l.ID 477 | } 478 | 479 | sf.RLock() 480 | str := string(sf.Content) 481 | sf.RUnlock() 482 | 483 | // Figure out which issue links are new, and which are old. 484 | var new []string 485 | input := strings.Split(str, "\n") 486 | for _, s := range input { 487 | if s == "" { 488 | continue 489 | } 490 | if _, exists := cur[s]; !exists { 491 | new = append(new, s) 492 | } else { 493 | delete(cur, s) 494 | } 495 | } 496 | 497 | // Delete the remaining old issue links 498 | for k, v := range cur { 499 | err := DeleteIssueLink(jc, v) 500 | if err != nil { 501 | log.Printf("Could not delete issue link %s (%s): %v", v, k, err) 502 | } 503 | } 504 | 505 | for _, k := range new { 506 | args := strings.Split(k, " ") 507 | if len(args) != 3 { 508 | continue 509 | } 510 | if args[0] != issue.Key && args[1] != issue.Key { 511 | continue 512 | } 513 | err := LinkIssues(jc, args[0], args[1], args[2]) 514 | if err != nil { 515 | log.Printf("Could not create issue link (%s): %v", k, err) 516 | } 517 | } 518 | 519 | return nil 520 | case "transition": 521 | sf.RLock() 522 | str := string(sf.Content) 523 | sf.RUnlock() 524 | str = strings.Replace(str, "\n", "", -1) 525 | 526 | return TransitionIssue(jc, issue.Key, str) 527 | 528 | case "status": 529 | sf.RLock() 530 | str := string(sf.Content) 531 | sf.RUnlock() 532 | str = strings.Replace(str, "\n", "", -1) 533 | 534 | issue, err := GetIssue(jc, iw.issueNo) 535 | if err != nil { 536 | log.Printf("Could not fetch issue: %v", err) 537 | return err 538 | } 539 | if issue.Fields == nil { 540 | log.Printf("Issue missing fields") 541 | return errors.New("oops") 542 | } 543 | if issue.Fields.Status == nil { 544 | log.Printf("Issue missing status") 545 | return errors.New("oops2") 546 | } 547 | 548 | wg, err := BuildWorkflow2(jc, iw.project, issue.Fields.Type.ID) 549 | if err != nil { 550 | log.Printf("Could not build workflow: %v", err) 551 | return err 552 | } 553 | 554 | p, err := wg.Path(issue.Fields.Status.Name, str, 500) 555 | if err != nil { 556 | log.Printf("Could not find path: %v", err) 557 | log.Printf("Workflow: \n%s\n", wg.Dump()) 558 | return err 559 | } 560 | 561 | log.Printf("Workflow path: %s", strings.Join(p, ", ")) 562 | 563 | for _, s := range p { 564 | err = TransitionIssue(jc, issue.Key, s) 565 | if err != nil { 566 | log.Printf("Could not transition issue: %v", err) 567 | return err 568 | } 569 | } 570 | 571 | return nil 572 | 573 | default: 574 | sf.RLock() 575 | str := string(sf.Content) 576 | sf.RUnlock() 577 | switch file { 578 | case "description", "labels", "components": 579 | default: 580 | str = strings.Replace(str, "\n", "", -1) 581 | } 582 | return SetFieldInIssue(jc, issue.Key, file, str) 583 | } 584 | } 585 | 586 | if writable { 587 | cs := NewCloseSaver(sf, onClose) 588 | cs.forceTrunc = forceTrunc 589 | return cs, nil 590 | } 591 | 592 | return sf, nil 593 | } 594 | 595 | func (iw *IssueView) Walk(jc *Client, file string) (trees.File, error) { 596 | iw.issueLock.Lock() 597 | isNew := iw.newIssue 598 | iw.issueLock.Unlock() 599 | 600 | if isNew { 601 | return iw.newWalk(jc, file) 602 | } else { 603 | return iw.normalWalk(jc, file) 604 | } 605 | } 606 | 607 | func (iw *IssueView) List(jc *Client) ([]qp.Stat, error) { 608 | iw.issueLock.Lock() 609 | isNew := iw.newIssue 610 | iw.issueLock.Unlock() 611 | 612 | var files, dirs []string 613 | if isNew { 614 | files, dirs = iw.newFiles() 615 | } else { 616 | files, dirs = iw.normalFiles() 617 | } 618 | var stats []qp.Stat 619 | 620 | stats = append(stats, StringsToStats(files, 0777, "jira", "jira")...) 621 | stats = append(stats, StringsToStats(dirs, 0777|qp.DMDIR, "jira", "jira")...) 622 | 623 | return stats, nil 624 | } 625 | 626 | type SearchView struct { 627 | query string 628 | resultLock sync.Mutex 629 | results []string 630 | } 631 | 632 | func (sw *SearchView) search(jc *Client) error { 633 | keys, err := GetKeysForSearch(jc, sw.query, jc.maxlisting) 634 | if err != nil { 635 | return err 636 | } 637 | 638 | sw.resultLock.Lock() 639 | sw.results = keys 640 | sw.resultLock.Unlock() 641 | return nil 642 | } 643 | 644 | func (sw *SearchView) Walk(jc *Client, file string) (trees.File, error) { 645 | sw.resultLock.Lock() 646 | keys := sw.results 647 | sw.resultLock.Unlock() 648 | 649 | if !StringExistsInSets(file, keys) { 650 | return nil, trees.ErrNoSuchFile 651 | } 652 | 653 | issue, err := GetIssue(jc, file) 654 | if err != nil { 655 | return nil, err 656 | } 657 | 658 | if issue.Fields == nil { 659 | return nil, errors.New("nil fields in issue") 660 | } 661 | 662 | iw := &IssueView{ 663 | project: issue.Fields.Project.Key, 664 | issueNo: issue.Key, 665 | } 666 | 667 | return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, iw) 668 | } 669 | 670 | func (sw *SearchView) List(jc *Client) ([]qp.Stat, error) { 671 | if err := sw.search(jc); err != nil { 672 | return nil, err 673 | } 674 | 675 | sw.resultLock.Lock() 676 | keys := sw.results 677 | sw.resultLock.Unlock() 678 | 679 | return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil 680 | } 681 | 682 | type ProjectIssuesView struct { 683 | project string 684 | } 685 | 686 | func (piw *ProjectIssuesView) Walk(jc *Client, issueNo string) (trees.File, error) { 687 | iw := &IssueView{ 688 | project: piw.project, 689 | } 690 | 691 | if issueNo == "new" { 692 | iw.newIssue = true 693 | } else { 694 | // Check if the thing is a valid issue number. 695 | if _, err := strconv.ParseUint(issueNo, 10, 64); err != nil { 696 | return nil, nil 697 | } 698 | 699 | issueKey := fmt.Sprintf("%s-%s", piw.project, issueNo) 700 | _, err := GetIssue(jc, issueKey) 701 | if err != nil { 702 | log.Printf("Could not get issue details: %v", err) 703 | return nil, err 704 | } 705 | iw.issueNo = issueKey 706 | } 707 | 708 | return NewJiraDir(issueNo, 0555|qp.DMDIR, "jira", "jira", jc, iw) 709 | } 710 | 711 | func (piw *ProjectIssuesView) List(jc *Client) ([]qp.Stat, error) { 712 | keys, err := GetKeysForNIssuesInProject(jc, piw.project, jc.maxlisting) 713 | if err != nil { 714 | log.Printf("Could not generate issue list: %v", err) 715 | return nil, err 716 | } 717 | 718 | keys = append(keys, "new") 719 | return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil 720 | } 721 | 722 | type ProjectView struct { 723 | project string 724 | } 725 | 726 | func (pw *ProjectView) Walk(jc *Client, file string) (trees.File, error) { 727 | switch file { 728 | case "issues": 729 | piw := &ProjectIssuesView{project: pw.project} 730 | return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, piw) 731 | case "components": 732 | project, err := GetProject(jc, pw.project) 733 | if err != nil { 734 | return nil, err 735 | } 736 | 737 | var components string 738 | for _, c := range project.Components { 739 | components += c.Name + "\n" 740 | } 741 | 742 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 743 | sf.SetContent([]byte(components)) 744 | return sf, nil 745 | case "issuetypes": 746 | project, err := GetProject(jc, pw.project) 747 | if err != nil { 748 | return nil, err 749 | } 750 | 751 | var issuetypes string 752 | for _, tp := range project.IssueTypes { 753 | issuetypes += tp.Name + "\n" 754 | } 755 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 756 | sf.SetContent([]byte(issuetypes)) 757 | return sf, nil 758 | case "raw": 759 | project, err := GetProject(jc, pw.project) 760 | if err != nil { 761 | return nil, err 762 | } 763 | b, err := json.MarshalIndent(project, "", " ") 764 | if err != nil { 765 | return nil, err 766 | } 767 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 768 | sf.SetContent([]byte(b)) 769 | return sf, nil 770 | default: 771 | return nil, nil 772 | } 773 | } 774 | 775 | func (pw *ProjectView) List(jc *Client) ([]qp.Stat, error) { 776 | return StringsToStats([]string{"issues", "issuetypes", "components", "raw"}, 0555|qp.DMDIR, "jira", "jira"), nil 777 | } 778 | 779 | type AllProjectsView struct{} 780 | 781 | func (apw *AllProjectsView) Walk(jc *Client, projectName string) (trees.File, error) { 782 | projectName = strings.ToUpper(projectName) 783 | projects, err := GetProjects(jc) 784 | if err != nil { 785 | log.Printf("Could not generate project list: %v", err) 786 | return nil, err 787 | } 788 | 789 | pw := &ProjectView{project: projectName} 790 | 791 | for _, project := range projects { 792 | if project.Key == projectName { 793 | return NewJiraDir(projectName, 0555|qp.DMDIR, "jira", "jira", jc, pw) 794 | } 795 | } 796 | 797 | return nil, nil 798 | } 799 | 800 | func (apw *AllProjectsView) List(jc *Client) ([]qp.Stat, error) { 801 | projects, err := GetProjects(jc) 802 | if err != nil { 803 | log.Printf("Could not generate project list: %v", err) 804 | return nil, err 805 | } 806 | 807 | var strs []string 808 | for _, p := range projects { 809 | strs = append(strs, p.Key) 810 | } 811 | 812 | return StringsToStats(strs, 0555|qp.DMDIR, "jira", "jira"), nil 813 | } 814 | 815 | type AllIssuesView struct{} 816 | 817 | func (aiv *AllIssuesView) Walk(jc *Client, issueKey string) (trees.File, error) { 818 | iw := &IssueView{} 819 | 820 | if issueKey == "new" { 821 | iw.newIssue = true 822 | } else if issueKey == "help" { 823 | message := `new/: New is a folder that creates a new skeleton issue when entered. It only contains a minimal set of files necessary to create the issue. Once all fields have been filled out, writing "commit" to the ctl file will cause the issue to be created. The issue folder will change to be that of a created issue, with all files available. Read the "key" file to figure out what issue key your issue received. 824 | ABC-1/: A folder containing information for ticket '1' in project 'ABC'. 825 | ABC-1/comments/: A folder containing comments for the issue. Writing to the comment file creates a new comment. Writing to an existing comment changes it. This structure may change in the future. 826 | ABC-1/components: A list of components this issue applies to. Writable. Note that the component names are case sensitive, and must be match an existing component for the project. 827 | ABC-1/ctl: A command file. On a new issue, the only accepted command is "commit", which creates the issue with the provided parameters. For existing issues, the only accepted command is "delete". In the future, more commands may be made available for things that map poorly to files. 828 | ABC-1/links: Issue links in the form of "INWARD-ISSUE OUTWARD-ISSUE RELATIONSHIP", such as "ABC-1 ABC-2 Blocks". Writable. 829 | ABC-1/raw: The raw JSON issue object. Writable. Expects the written data to be JSON, and the write will be pushed as an issue update. 830 | ABC-1/status: When writing to the status file, jirafs will fetch the relevant workflow graph and trace the shortest path from the current status to the requested status, issuing the necessary transitions in order. 831 | ABC-1/transition: A list of currently possible transitions. Writing to the file executes the transition. See status for a more convenient way of changing issue status. 832 | 833 | For deeper structural representation under this hierarchy, cat 'structure'. 834 | ` 835 | sf := trees.NewSyntheticFile(issueKey, 0555, "jira", "jira") 836 | sf.SetContent([]byte(message)) 837 | return sf, nil 838 | } else if issueKey == "structure" { 839 | message := `new/ 840 | ctl 841 | description 842 | project 843 | summary 844 | type 845 | ABC-1/ 846 | assignee 847 | comments/ 848 | 1/ 849 | author 850 | updated 851 | created 852 | comment 853 | 2/ 854 | ... 855 | ... 856 | comment 857 | components 858 | creator 859 | ctl 860 | description 861 | key 862 | labels 863 | links 864 | priority 865 | progress 866 | project 867 | raw 868 | reporter 869 | resolution 870 | status 871 | summary 872 | transition 873 | type 874 | worklog/ 875 | 1/ 876 | author 877 | started 878 | time 879 | comment 880 | ... 881 | ABC-2/ 882 | ... 883 | ... 884 | ` 885 | sf := trees.NewSyntheticFile(issueKey, 0555, "jira", "jira") 886 | sf.SetContent([]byte(message)) 887 | return sf, nil 888 | } else { 889 | s := strings.Split(strings.ToUpper(issueKey), "-") 890 | if len(s) != 2 { 891 | return nil, nil 892 | } 893 | 894 | if _, err := strconv.ParseUint(s[1], 10, 64); err != nil { 895 | return nil, nil 896 | } 897 | 898 | issue, err := GetIssue(jc, issueKey) 899 | if err != nil { 900 | log.Printf("Could not get issue details: %v", err) 901 | return nil, err 902 | } 903 | if issue.Fields == nil { 904 | return nil, errors.New("no fields") 905 | } 906 | 907 | iw.issueNo = issueKey 908 | iw.project = issue.Fields.Project.Key 909 | } 910 | 911 | return NewJiraDir(issueKey, 0555|qp.DMDIR, "jira", "jira", jc, iw) 912 | } 913 | 914 | func (aiv *AllIssuesView) List(jc *Client) ([]qp.Stat, error) { 915 | keys, err := GetKeysForSearch(jc, "", jc.maxlisting) 916 | if err != nil { 917 | log.Printf("Could not generate issue list: %v", err) 918 | return nil, err 919 | } 920 | 921 | keys = append(keys, "new") 922 | issues := StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira") 923 | help := StringsToStats([]string{"help", "structure"}, 055, "jira", "jira") 924 | return append(issues, help...), nil 925 | } 926 | 927 | type JiraView struct { 928 | searchLock sync.Mutex 929 | searches map[string]*SearchView 930 | } 931 | 932 | func (jw *JiraView) Walk(jc *Client, file string) (trees.File, error) { 933 | jw.searchLock.Lock() 934 | defer jw.searchLock.Unlock() 935 | if jw.searches == nil { 936 | jw.searches = make(map[string]*SearchView) 937 | } 938 | 939 | switch file { 940 | case "ctl": 941 | cmds := map[string]func([]string) error{ 942 | "search": func(args []string) error { 943 | if len(args) < 2 { 944 | return errors.New("query missing") 945 | } 946 | 947 | sw := &SearchView{query: strings.Join(args[1:], " ")} 948 | if err := sw.search(jc); err != nil { 949 | return err 950 | } 951 | 952 | jw.searchLock.Lock() 953 | jw.searches[args[0]] = sw 954 | jw.searchLock.Unlock() 955 | return nil 956 | }, 957 | "pass-login": func(args []string) error { 958 | if len(args) == 2 { 959 | jc.user = args[0] 960 | jc.pass = args[1] 961 | } 962 | return nil 963 | }, 964 | "set": func(args []string) error { 965 | if len(args) != 2 { 966 | return errors.New("invalid arguments") 967 | } 968 | switch args[0] { 969 | case "max-listing": 970 | mi, err := strconv.ParseInt(args[1], 10, 64) 971 | if err != nil { 972 | return err 973 | } 974 | jc.maxlisting = int(mi) 975 | return nil 976 | default: 977 | return errors.New("unknown variable") 978 | } 979 | }, 980 | } 981 | return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil 982 | case "projects": 983 | return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, &AllProjectsView{}) 984 | case "issues": 985 | return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, &AllIssuesView{}) 986 | case "structure": 987 | message := ` 988 | / 989 | ctl 990 | projects/ 991 | ABC/ 992 | components 993 | issuetypes 994 | issues/ 995 | 1/ # ABC-1 996 | ... 997 | ... 998 | 999 | DEF/ 1000 | ... 1001 | ... 1002 | issues/ 1003 | new/ 1004 | ctl 1005 | description 1006 | project 1007 | summary 1008 | type 1009 | ABC-1/ 1010 | assignee 1011 | comments/ 1012 | 1/ 1013 | author 1014 | updated 1015 | created 1016 | comment 1017 | 2/ 1018 | ... 1019 | ... 1020 | comment 1021 | components 1022 | creator 1023 | ctl 1024 | description 1025 | key 1026 | labels 1027 | links 1028 | priority 1029 | progress 1030 | project 1031 | raw 1032 | reporter 1033 | resolution 1034 | status 1035 | summary 1036 | transition 1037 | type 1038 | worklog/ 1039 | 1/ 1040 | author 1041 | started 1042 | time 1043 | comment 1044 | ... 1045 | ABC-2/ 1046 | ... 1047 | ... 1048 | ` 1049 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 1050 | sf.SetContent([]byte(message)) 1051 | return sf, nil 1052 | case "help": 1053 | message := `ctl: A global control file. It supports the following commands: 1054 | * search search_name JQL 1055 | If successful, a folder named search_name will appear at the jirafs root. ls'ing in the folder updates the search. The search does not update when simply trying to access an issue in order to avoid significant performance issues. 1056 | * pass-login 1057 | Re-issue a username/password login using the initially provided credentials. 1058 | * set name val 1059 | Sets jirafs variables. Currently, max-listing is the only variable, which expects an integer. 1060 | projects/: Directory listing of projects. 1061 | issues/: Directory listing of issues 1062 | 1063 | For deeper structural representation, cat 'structure' 1064 | ` 1065 | sf := trees.NewSyntheticFile(file, 0555, "jira", "jira") 1066 | sf.SetContent([]byte(message)) 1067 | return sf, nil 1068 | default: 1069 | search, exists := jw.searches[file] 1070 | 1071 | if !exists { 1072 | return nil, nil 1073 | } 1074 | 1075 | return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, search) 1076 | } 1077 | } 1078 | 1079 | func (jw *JiraView) List(jc *Client) ([]qp.Stat, error) { 1080 | jw.searchLock.Lock() 1081 | defer jw.searchLock.Unlock() 1082 | if jw.searches == nil { 1083 | jw.searches = make(map[string]*SearchView) 1084 | } 1085 | 1086 | var strs []string 1087 | for k := range jw.searches { 1088 | strs = append(strs, k) 1089 | } 1090 | 1091 | a := StringsToStats([]string{"projects", "issues"}, 0555|qp.DMDIR, "jira", "jira") 1092 | b := StringsToStats([]string{"ctl"}, 0777, "jira", "jira") 1093 | c := StringsToStats([]string{"help", "structure"}, 0555, "jira", "jira") 1094 | d := StringsToStats(strs, 0777|qp.DMDIR, "jira", "jira") 1095 | return append(append(append(a, b...), c...), d...), nil 1096 | } 1097 | 1098 | func (jw *JiraView) Remove(jc *Client, file string) error { 1099 | switch file { 1100 | case "ctl", "projects", "issues", "structure", "help": 1101 | return trees.ErrPermissionDenied 1102 | default: 1103 | jw.searchLock.Lock() 1104 | defer jw.searchLock.Unlock() 1105 | if jw.searches == nil { 1106 | jw.searches = make(map[string]*SearchView) 1107 | } 1108 | 1109 | if _, exists := jw.searches[file]; exists { 1110 | delete(jw.searches, file) 1111 | return nil 1112 | } 1113 | 1114 | return trees.ErrNoSuchFile 1115 | } 1116 | } 1117 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/howeyc/gopass" 11 | "github.com/kennylevinsen/qp" 12 | "github.com/kennylevinsen/qptools/fileserver" 13 | ) 14 | 15 | var ( 16 | address = flag.String("address", "localhost:30000", "address to bind on") 17 | usingOAuth = flag.Bool("oauth", false, "use OAuth 1.0 for authorization") 18 | ckey = flag.String("ckey", "", "consumer key for OAuth") 19 | pkey = flag.String("pkey", "", "private key file for OAuth") 20 | pass = flag.Bool("pass", false, "use password for authorization") 21 | jiraURLStr = flag.String("url", "", "jira URL") 22 | maxlisting = flag.Int("maxlisting", 100, "max directory listing length") 23 | ) 24 | 25 | func main() { 26 | flag.Parse() 27 | 28 | jiraURL, err := url.Parse(*jiraURLStr) 29 | if err != nil { 30 | fmt.Printf("Could not parse JIRA URL: %v\n", err) 31 | return 32 | } 33 | 34 | client := &Client{ 35 | Client: &http.Client{}, 36 | usingOAuth: *usingOAuth, 37 | jiraURL: jiraURL, 38 | maxlisting: *maxlisting, 39 | } 40 | 41 | switch { 42 | case *pass: 43 | var username string 44 | fmt.Printf("Username: ") 45 | _, err = fmt.Scanln(&username) 46 | if err == nil { 47 | fmt.Printf("Password: ") 48 | password, err := gopass.GetPasswdMasked() 49 | if err != nil { 50 | fmt.Printf("Could not read password: %v\n", err) 51 | return 52 | } 53 | 54 | client.user = username 55 | client.pass = string(password) 56 | } else { 57 | fmt.Printf("Continuing without authentication.\n") 58 | } 59 | case *usingOAuth: 60 | if err := client.oauth(*ckey, *pkey); err != nil { 61 | fmt.Printf("Could not complete oauth handshake: %v\n", err) 62 | return 63 | } 64 | default: 65 | fmt.Printf("Continuing without authentication\n") 66 | } 67 | 68 | root, err := NewJiraDir("", 0555|qp.DMDIR, "jira", "jira", client, &JiraView{}) 69 | if err != nil { 70 | fmt.Printf("Could not create JIRA view\n") 71 | return 72 | } 73 | 74 | l, err := net.Listen("tcp", *address) 75 | if err != nil { 76 | fmt.Printf("Could not listen: %v\n", err) 77 | return 78 | } 79 | 80 | for { 81 | conn, err := l.Accept() 82 | if err != nil { 83 | fmt.Printf("Accept failed: %v\n", err) 84 | return 85 | } 86 | 87 | f := fileserver.New(conn, root, nil) 88 | f.Verbosity = fileserver.Quiet 89 | go f.Serve() 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/andygrunwald/go-jira" 9 | "github.com/kennylevinsen/qp" 10 | ) 11 | 12 | type SearchResult struct { 13 | Issues []jira.Issue `json:"issues"` 14 | } 15 | 16 | func GetProject(jc *Client, projectKey string) (*jira.Project, error) { 17 | var project jira.Project 18 | url := fmt.Sprintf("/rest/api/2/project/%s", projectKey) 19 | if err := jc.RPC("GET", url, nil, &project); err != nil { 20 | return nil, fmt.Errorf("could not query projects: %v", err) 21 | } 22 | return &project, nil 23 | } 24 | 25 | func GetProjects(jc *Client) ([]jira.Project, error) { 26 | var projects []jira.Project 27 | if err := jc.RPC("GET", "/rest/api/2/project", nil, &projects); err != nil { 28 | return nil, fmt.Errorf("could not query projects: %v", err) 29 | } 30 | return projects, nil 31 | } 32 | 33 | func GetTypesForProject(jc *Client, projectKey string) ([]string, error) { 34 | p, err := GetProject(jc, projectKey) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | ss := make([]string, len(p.IssueTypes)) 40 | for i, tp := range p.IssueTypes { 41 | ss[i] = tp.Name 42 | } 43 | return ss, nil 44 | } 45 | 46 | func GetKeysForSearch(jc *Client, query string, max int) ([]string, error) { 47 | var s SearchResult 48 | url := fmt.Sprintf("/rest/api/2/search?fields=key&maxResults=%d&jql=%s", max, url.QueryEscape(query)) 49 | if err := jc.RPC("GET", url, nil, &s); err != nil { 50 | return nil, fmt.Errorf("could not execute search: %v", err) 51 | } 52 | 53 | ss := make([]string, len(s.Issues)) 54 | for i, issue := range s.Issues { 55 | ss[i] = issue.Key 56 | } 57 | 58 | return ss, nil 59 | } 60 | 61 | func GetKeysForNIssuesInProject(jc *Client, project string, max int) ([]string, error) { 62 | var s SearchResult 63 | url := fmt.Sprintf("/rest/api/2/search?fields=key&maxResults=%d&jql=project=%s", max, project) 64 | if err := jc.RPC("GET", url, nil, &s); err != nil { 65 | return nil, fmt.Errorf("could not execute search: %v", err) 66 | } 67 | 68 | ss := make([]string, len(s.Issues)) 69 | for i, issue := range s.Issues { 70 | s := strings.Split(issue.Key, "-") 71 | if len(s) != 2 { 72 | continue 73 | } 74 | ss[i] = s[1] 75 | } 76 | 77 | return ss, nil 78 | } 79 | 80 | func GetIssue(jc *Client, key string) (*jira.Issue, error) { 81 | var i jira.Issue 82 | u := fmt.Sprintf("/rest/api/2/issue/%s", key) 83 | if err := jc.RPC("GET", u, nil, &i); err != nil { 84 | return nil, fmt.Errorf("could not query issue: %v", err) 85 | } 86 | return &i, nil 87 | } 88 | 89 | type CreateIssueResult struct { 90 | ID string `json:"id,omitempty"` 91 | Key string `json:"key,omitempty"` 92 | } 93 | 94 | func CreateIssue(jc *Client, issue *jira.Issue) (string, error) { 95 | var cir CreateIssueResult 96 | if err := jc.RPC("POST", "/rest/api/2/issue", issue, &cir); err != nil { 97 | return "", fmt.Errorf("could not create issue: %v", err) 98 | } 99 | return cir.Key, nil 100 | } 101 | 102 | func DeleteIssue(jc *Client, issue string) error { 103 | url := fmt.Sprintf("/rest/api/2/issue/%s", issue) 104 | if err := jc.RPC("DELETE", url, nil, nil); err != nil { 105 | return fmt.Errorf("could not delete issue: %v", err) 106 | } 107 | return nil 108 | } 109 | 110 | func DeleteIssueLink(jc *Client, issueLinkID string) error { 111 | url := fmt.Sprintf("/rest/api/2/issueLink/%s", issueLinkID) 112 | if err := jc.RPC("DELETE", url, nil, nil); err != nil { 113 | return fmt.Errorf("could not delete issue link: %v", err) 114 | } 115 | return nil 116 | } 117 | 118 | func LinkIssues(jc *Client, inwardKey, outwardKey, relation string) error { 119 | issueLink := &jira.IssueLink{ 120 | Type: jira.IssueLinkType{ 121 | Name: relation, 122 | }, 123 | InwardIssue: &jira.Issue{ 124 | Key: inwardKey, 125 | }, 126 | OutwardIssue: &jira.Issue{ 127 | Key: outwardKey, 128 | }, 129 | } 130 | 131 | if err := jc.RPC("POST", "/rest/api/2/issueLink", issueLink, nil); err != nil { 132 | return fmt.Errorf("could not create issue link: %v", err) 133 | } 134 | return nil 135 | } 136 | 137 | func GetWorklogForIssue(jc *Client, issue string) (*jira.Worklog, error) { 138 | var w jira.Worklog 139 | url := fmt.Sprintf("/rest/api/2/issue/%s/worklog", issue) 140 | if err := jc.RPC("GET", url, nil, &w); err != nil { 141 | return nil, fmt.Errorf("could not get worklog: %v", err) 142 | } 143 | return &w, nil 144 | } 145 | 146 | func GetSpecificWorklogForIssue(jc *Client, issue, worklog string) (*jira.WorklogRecord, error) { 147 | var w jira.WorklogRecord 148 | url := fmt.Sprintf("/rest/api/2/issue/%s/worklog/%s", issue, worklog) 149 | if err := jc.RPC("GET", url, nil, &w); err != nil { 150 | return nil, fmt.Errorf("could not get worklog: %v", err) 151 | } 152 | return &w, nil 153 | } 154 | 155 | type Transition struct { 156 | ID string `json:"id,omitempty"` 157 | Name string `json:"name,omitempty"` 158 | Fields *jira.IssueFields `json:"fields,omitempty"` 159 | } 160 | 161 | type TransitionResult struct { 162 | Transitions []Transition `json:"transitions,omitempty"` 163 | } 164 | 165 | func GetTransitionsForIssue(jc *Client, issue string) ([]Transition, error) { 166 | var tr TransitionResult 167 | url := fmt.Sprintf("/rest/api/2/issue/%s/transitions", issue) 168 | if err := jc.RPC("GET", url, nil, &tr); err != nil { 169 | return nil, fmt.Errorf("could not get transitions: %v", err) 170 | } 171 | return tr.Transitions, nil 172 | } 173 | 174 | func TransitionIssue(jc *Client, issue, transition string) error { 175 | transition = strings.Replace(transition, "\n", "", -1) 176 | transitions, err := GetTransitionsForIssue(jc, issue) 177 | if err != nil { 178 | return err 179 | } 180 | var id string 181 | for _, t := range transitions { 182 | if transition == t.Name { 183 | id = t.ID 184 | break 185 | } 186 | } 187 | 188 | if id == "" { 189 | return fmt.Errorf("no such transition") 190 | } 191 | 192 | post := map[string]interface{}{ 193 | "transition": map[string]interface{}{ 194 | "id": id, 195 | }, 196 | } 197 | url := fmt.Sprintf("/rest/api/2/issue/%s/transitions", issue) 198 | if err := jc.RPC("POST", url, post, nil); err != nil { 199 | return fmt.Errorf("could not transition issue: %v", err) 200 | } 201 | return nil 202 | } 203 | 204 | func SetIssueRaw(jc *Client, issueNo string, b []byte) error { 205 | url := fmt.Sprintf("/rest/api/2/issue/%s", issueNo) 206 | if err := jc.RPC("PUT", url, b, nil); err != nil { 207 | return fmt.Errorf("could not set issue: %v", err) 208 | } 209 | return nil 210 | } 211 | 212 | func SetFieldInIssue(jc *Client, issue, field, val string) error { 213 | switch field { 214 | case "type": 215 | field = "issuetype" 216 | } 217 | 218 | url := fmt.Sprintf("/rest/api/2/issue/%s", issue) 219 | method := "PUT" 220 | 221 | var value interface{} 222 | if val == "" { 223 | value = nil 224 | } else { 225 | value = val 226 | } 227 | 228 | fields := make(map[string]interface{}) 229 | post := map[string]interface{}{ 230 | "fields": fields, 231 | } 232 | 233 | switch field { 234 | case "labels": 235 | var labels []string 236 | if val != "" && val != "\n" { 237 | labels = strings.Split(val, "\n") 238 | if labels[len(labels)-1] == "" { 239 | labels = labels[:len(labels)-1] 240 | } 241 | } 242 | fields[field] = labels 243 | case "components": 244 | componentThing := []map[string]string{} 245 | components := strings.Split(val, "\n") 246 | for _, s := range components { 247 | if s == "" || s == "\n" { 248 | continue 249 | } 250 | thing := map[string]string{ 251 | "name": s, 252 | } 253 | componentThing = append(componentThing, thing) 254 | } 255 | fields[field] = componentThing 256 | case "issuetype", "assignee", "reporter", "creator", "priority", "resolution": 257 | fields[field] = map[string]interface{}{ 258 | "name": value, 259 | } 260 | default: 261 | fields[field] = value 262 | } 263 | 264 | if err := jc.RPC(method, url, post, nil); err != nil { 265 | return fmt.Errorf("could not set field for issue: %v", err) 266 | } 267 | return nil 268 | } 269 | 270 | type CommentResult struct { 271 | Comments []jira.Comment `json:"comments,omitempty"` 272 | } 273 | 274 | func GetCommentsForIssue(jc *Client, issue string) ([]string, error) { 275 | var cr CommentResult 276 | url := fmt.Sprintf("/rest/api/2/issue/%s/comment?maxResults=1000", issue) 277 | if err := jc.RPC("GET", url, nil, &cr); err != nil { 278 | return nil, fmt.Errorf("could not get comments: %v", err) 279 | } 280 | 281 | var ss []string 282 | for _, c := range cr.Comments { 283 | ss = append(ss, c.ID) 284 | } 285 | 286 | return ss, nil 287 | } 288 | 289 | func GetComment(jc *Client, issue, id string) (*jira.Comment, error) { 290 | var c jira.Comment 291 | url := fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id) 292 | if err := jc.RPC("GET", url, nil, &c); err != nil { 293 | return nil, fmt.Errorf("could not get comment: %v", err) 294 | } 295 | return &c, nil 296 | } 297 | 298 | func SetComment(jc *Client, issue, id, body string) error { 299 | c := jira.Comment{ 300 | Body: body, 301 | } 302 | url := fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id) 303 | if err := jc.RPC("PUT", url, c, nil); err != nil { 304 | return fmt.Errorf("could not set comment: %v", err) 305 | } 306 | return nil 307 | } 308 | 309 | func AddComment(jc *Client, issue, body string) error { 310 | c := jira.Comment{ 311 | Body: body, 312 | } 313 | url := fmt.Sprintf("/rest/api/2/issue/%s/comment/", issue) 314 | if err := jc.RPC("POST", url, c, nil); err != nil { 315 | return fmt.Errorf("could not add comment: %v", err) 316 | } 317 | return nil 318 | } 319 | 320 | func RemoveComment(jc *Client, issue, id string) error { 321 | url := fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id) 322 | if err := jc.RPC("DELETE", url, nil, nil); err != nil { 323 | return fmt.Errorf("could not delete comment: %v", err) 324 | } 325 | return nil 326 | } 327 | 328 | func StringsToStats(strs []string, Perm qp.FileMode, user, group string) []qp.Stat { 329 | var stats []qp.Stat 330 | for _, str := range strs { 331 | stat := qp.Stat{ 332 | Name: str, 333 | UID: user, 334 | GID: group, 335 | MUID: user, 336 | Mode: Perm, 337 | } 338 | stats = append(stats, stat) 339 | } 340 | 341 | return stats 342 | } 343 | 344 | func StringExistsInSets(str string, sets ...[]string) bool { 345 | for _, set := range sets { 346 | for _, s := range set { 347 | if str == s { 348 | return true 349 | } 350 | } 351 | } 352 | 353 | return false 354 | } 355 | -------------------------------------------------------------------------------- /workflow.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | ) 9 | 10 | type thing struct { 11 | Name string `json:"name"` 12 | } 13 | 14 | func BuildWorkflow1(jc *Client, project, issueTypeNo string) (*WorkflowGraph, error) { 15 | var t thing 16 | u := fmt.Sprintf("/rest/projectconfig/latest/issuetype/%s/%s/workflow", project, issueTypeNo) 17 | if err := jc.RPC("GET", u, nil, &t); err != nil { 18 | return nil, fmt.Errorf("could not query workflow for issue: %v", err) 19 | } 20 | 21 | var wr WorkflowResponse1 22 | u = fmt.Sprintf("/rest/projectconfig/latest/workflow?workflowName=%s", url.QueryEscape(t.Name)) 23 | if err := jc.RPC("GET", u, nil, &wr); err != nil { 24 | return nil, fmt.Errorf("could not query workflow graph: %v", err) 25 | } 26 | 27 | var wg WorkflowGraph 28 | wg.Build1(&wr) 29 | return &wg, nil 30 | } 31 | 32 | func BuildWorkflow2(jc *Client, project, issueTypeNo string) (*WorkflowGraph, error) { 33 | var t thing 34 | u := fmt.Sprintf("/rest/projectconfig/latest/issuetype/%s/%s/workflow", project, issueTypeNo) 35 | if err := jc.RPC("GET", u, nil, &t); err != nil { 36 | return nil, fmt.Errorf("could not query workflow for issue: %v", err) 37 | } 38 | 39 | var wr WorkflowResponse2 40 | u = fmt.Sprintf("/rest/workflowDesigner/latest/workflows?name=%s", url.QueryEscape(t.Name)) 41 | if err := jc.RPC("GET", u, nil, &wr); err != nil { 42 | return nil, fmt.Errorf("could not query workflow graph: %v", err) 43 | } 44 | 45 | var wg WorkflowGraph 46 | wg.Build2(&wr) 47 | return &wg, nil 48 | } 49 | 50 | type WorkflowResponse2 struct { 51 | Layout struct { 52 | Statuses []struct { 53 | ID string `json:"statusId"` 54 | TransitionID string `json:"id"` 55 | Name string `json:"name"` 56 | Description string `json:"description"` 57 | Initial bool `json:"initial"` 58 | } `json:"statuses"` 59 | Transitions []struct { 60 | Name string `json:"name"` 61 | Description string `json:"description"` 62 | SourceID string `json:"sourceId"` 63 | TargetID string `json:"targetId"` 64 | ActionID int `json:"actionId"` 65 | Global bool `json:"globalTransition"` 66 | Looped bool `json:"loopedTransition"` 67 | } `json:"transitions"` 68 | } `json:"layout"` 69 | } 70 | 71 | type WorkflowResponse1 struct { 72 | Name string `json:"name"` 73 | Description string `json:"description"` 74 | ID int `json:"id"` 75 | DisplayName string `json:"displayName"` 76 | Admin bool `json:"admin"` 77 | Sources []struct { 78 | FromStatus WorkflowStatus `json:"fromStatus"` 79 | Targets []struct { 80 | ToStatus WorkflowStatus `json:"toStatus"` 81 | TransitionName string 82 | } `json:"targets"` 83 | } `json:"sources"` 84 | } 85 | 86 | type WorkflowStatus struct { 87 | StatusCategory struct { 88 | Sequence int `json:"sequence"` 89 | PrimaryAlias string `json:"primaryAlias"` 90 | TranslatedName string `json:"translatedName"` 91 | ColorName string `json:"colorName"` 92 | Aliases []string `json:"aliases"` 93 | Name string `json:"name"` 94 | Key string `json:"key"` 95 | ID int `json:"id"` 96 | } `json:"statusCategory"` 97 | IconURL string `json:"iconUrl"` 98 | Description string `json:"description"` 99 | Name string `json:"name"` 100 | ID string `json:"id"` 101 | } 102 | 103 | func (wf *WorkflowStatus) Status() *Status { 104 | return &Status{ 105 | Name: wf.Name, 106 | ID: wf.ID, 107 | Description: wf.Description, 108 | } 109 | } 110 | 111 | type Status struct { 112 | Name string 113 | Description string 114 | ID string 115 | Edges []StatusEdge 116 | } 117 | 118 | type StatusEdge struct { 119 | Name string 120 | Status *Status 121 | } 122 | 123 | type WorkflowGraph struct { 124 | // verteces is a map of lower-cased status named to their status struct. 125 | verteces map[string]*Status 126 | } 127 | 128 | func (wg *WorkflowGraph) Build2(wr *WorkflowResponse2) { 129 | if wg.verteces == nil { 130 | wg.verteces = make(map[string]*Status) 131 | } 132 | 133 | local := make(map[string]*Status) 134 | layout := wr.Layout 135 | 136 | for _, s := range layout.Statuses { 137 | l := &Status{ 138 | Name: s.Name, 139 | Description: s.Description, 140 | ID: s.ID, 141 | } 142 | 143 | wg.verteces[strings.ToLower(s.Name)] = l 144 | local[s.TransitionID] = l 145 | } 146 | 147 | for _, t := range layout.Transitions { 148 | a := local[t.SourceID] 149 | b := local[t.TargetID] 150 | edge := StatusEdge{ 151 | Name: t.Name, 152 | Status: b, 153 | } 154 | if t.Global { 155 | for _, v := range local { 156 | v.Edges = append(v.Edges, edge) 157 | } 158 | } else { 159 | a.Edges = append(a.Edges, edge) 160 | } 161 | } 162 | } 163 | 164 | func (wg *WorkflowGraph) Build1(wr *WorkflowResponse1) { 165 | if wg.verteces == nil { 166 | wg.verteces = make(map[string]*Status) 167 | } 168 | for _, elem := range wr.Sources { 169 | name := strings.ToLower(elem.FromStatus.Name) 170 | fromStatus, exists := wg.verteces[name] 171 | if !exists { 172 | fromStatus = elem.FromStatus.Status() 173 | wg.verteces[name] = fromStatus 174 | } 175 | 176 | for _, target := range elem.Targets { 177 | targetName := strings.ToLower(target.ToStatus.Name) 178 | targetStatus, exists := wg.verteces[targetName] 179 | if !exists { 180 | targetStatus = target.ToStatus.Status() 181 | wg.verteces[name] = targetStatus 182 | } 183 | targetEdge := StatusEdge{ 184 | Name: target.TransitionName, 185 | Status: targetStatus, 186 | } 187 | 188 | fromStatus.Edges = append(fromStatus.Edges, targetEdge) 189 | } 190 | } 191 | } 192 | 193 | func (wg *WorkflowGraph) Dump() string { 194 | var ss string 195 | for _, v := range wg.verteces { 196 | var s string 197 | for _, e := range v.Edges { 198 | s += fmt.Sprintf("%s (%s), ", e.Status.Name, e.Name) 199 | } 200 | ss += fmt.Sprintf("Status: %s, edges: %s\n", v.Name, s) 201 | } 202 | 203 | return ss 204 | } 205 | 206 | type path struct { 207 | from *path 208 | edge StatusEdge 209 | } 210 | 211 | // Path finds the shortest path in the workflow graph from A to B, searching at 212 | // most limit verteces. A negative limit results in path executing without a 213 | // limit. Cycles are detected and terminated, so the limit is just to avoid high 214 | // searching times in *very* large graphs. A and B are case insensitive for 215 | // convenience. 216 | func (wg *WorkflowGraph) Path(A, B string, limit int) ([]string, error) { 217 | statusA := wg.verteces[strings.ToLower(A)] 218 | statusB := wg.verteces[strings.ToLower(B)] 219 | 220 | if statusA == nil || statusB == nil { 221 | return nil, errors.New("no such status") 222 | } 223 | 224 | visited := make(map[string]bool) 225 | 226 | var search []path 227 | for _, edge := range statusA.Edges { 228 | search = append(search, path{edge: edge}) 229 | } 230 | 231 | for len(search) > 0 { 232 | limit-- 233 | if limit == 0 { 234 | break 235 | } 236 | p := search[0] 237 | search = search[1:] 238 | 239 | // FOUND! 240 | if p.edge.Status == statusB { 241 | var s []string 242 | start := &p 243 | 244 | for { 245 | s = append([]string{start.edge.Name}, s...) 246 | if start.from == nil { 247 | break 248 | } 249 | 250 | start = start.from 251 | } 252 | return s, nil 253 | } 254 | 255 | if visited[p.edge.Status.ID] { 256 | // We have already walked all edges of this vertice. 257 | continue 258 | } 259 | visited[p.edge.Status.ID] = true 260 | 261 | // Add the edges to the search. 262 | for _, edge := range p.edge.Status.Edges { 263 | search = append(search, path{from: &p, edge: edge}) 264 | } 265 | } 266 | 267 | return nil, errors.New("path not found") 268 | } 269 | --------------------------------------------------------------------------------