shithub: jirafs

Download patch

ref: 1d43766c7ccd703adf2015aefc18609748b6f5eb
author: Kenny Levinsen <kl@codesealer.com>
date: Mon Jun 6 08:46:30 EDT 2016

Initial commit

--- /dev/null
+++ b/jira.go
@@ -1,0 +1,405 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+	"sync"
+
+	"github.com/andygrunwald/go-jira"
+	"github.com/joushou/qp"
+	"github.com/joushou/qptools/fileserver/trees"
+)
+
+type jiraWalker interface {
+	Walk(jc *jira.Client, name string) (trees.File, error)
+}
+
+type jiraLister interface {
+	List(jc *jira.Client) ([]qp.Stat, error)
+}
+
+type jiraRemover interface {
+	Remove(jc *jira.Client, name string) error
+}
+
+type CommentView struct {
+	project string
+	issueNo string
+}
+
+func (cw *CommentView) Walk(jc *jira.Client, name string) (trees.File, error) {
+	switch name {
+	case "comment":
+		sf := trees.NewSyntheticFile(name, 0777, "jira", "jira")
+		onClose := func() error {
+			sf.Lock()
+			body := string(sf.Content)
+			sf.Unlock()
+
+			return AddComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), body)
+		}
+		return NewCloseSaver(sf, onClose), nil
+	default:
+		cmt, err := GetComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name)
+		if err != nil {
+			return nil, err
+		}
+		if len(cmt.Body) > 0 && cmt.Body[len(cmt.Body)-1] != '\n' {
+			cmt.Body += "\n"
+		}
+
+		sf := trees.NewSyntheticFile(name, 0777, cmt.Author.Name, "jira")
+		sf.SetContent([]byte(cmt.Body))
+
+		onClose := func() error {
+			sf.Lock()
+			body := string(sf.Content)
+			sf.Unlock()
+
+			return SetComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name, body)
+		}
+
+		return NewCloseSaver(sf, onClose), nil
+	}
+}
+
+func (cw *CommentView) List(jc *jira.Client) ([]qp.Stat, error) {
+	strs, err := GetCommentsForIssue(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo))
+	if err != nil {
+		return nil, err
+	}
+
+	strs = append(strs, "comment")
+
+	return StringsToStats(strs, 0777, "jira", "jira"), nil
+}
+
+func (cw *CommentView) Remove(jc *jira.Client, name string) error {
+	switch name {
+	case "comment":
+		return trees.ErrPermissionDenied
+	default:
+		return RemoveComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name)
+	}
+}
+
+type IssueView struct {
+	project string
+	issueNo string
+
+	issueLock sync.Mutex
+	newIssue  bool
+	values    map[string]string
+}
+
+func (iw *IssueView) normalFiles() (files, dirs []string) {
+	files = []string{"assignee", "creator", "ctl", "description", "issuetype", "key", "reporter", "status", "summary", "labels", "transitions"}
+	dirs = []string{"comments"}
+	return
+}
+
+func (iw *IssueView) newFiles() (files, dirs []string) {
+	files = []string{"ctl", "description", "issuetype", "summary"}
+	return
+}
+
+func (iw *IssueView) newWalk(jc *jira.Client, file string) (trees.File, error) {
+	files, dirs := iw.newFiles()
+	if !StringExistsInSets(file, files, dirs) {
+		return nil, nil
+	}
+
+	switch file {
+	case "ctl":
+		cmds := map[string]func([]string) error{
+			"commit": func(args []string) error {
+				var issuetype, summary, description string
+
+				iw.issueLock.Lock()
+				isNew := iw.newIssue
+				if iw.values != nil {
+					issuetype = strings.Replace(string(iw.values["issuetype"]), "\n", "", -1)
+					summary = strings.Replace(string(iw.values["summary"]), "\n", "", -1)
+					description = strings.Replace(string(iw.values["description"]), "\n", "", -1)
+				}
+				iw.issueLock.Unlock()
+
+				if !isNew {
+					return errors.New("issue already committed")
+				}
+
+				issue := jira.Issue{
+					Fields: &jira.IssueFields{
+						Type: jira.IssueType{
+							Name: issuetype,
+						},
+						Project: jira.Project{
+							Key: iw.project,
+						},
+						Summary:     summary,
+						Description: description,
+					},
+				}
+
+				key, err := CreateIssue(jc, &issue)
+				if err != nil {
+					log.Printf("Create failed: %v", err)
+					return err
+				}
+
+				keys := strings.Split(key, "-")
+				if len(keys) != 2 {
+					log.Printf("Weird key: %s", keys)
+					return errors.New("weird key")
+				}
+				iw.issueLock.Lock()
+				iw.issueNo = keys[1]
+				iw.newIssue = false
+				iw.issueLock.Unlock()
+				return nil
+			},
+		}
+		return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil
+	default:
+		sf := trees.NewSyntheticFile(file, 0777, "jira", "jira")
+		iw.issueLock.Lock()
+		defer iw.issueLock.Unlock()
+
+		if iw.values == nil {
+			iw.values = make(map[string]string)
+		}
+
+		value := iw.values[file]
+
+		sf.SetContent([]byte(value))
+
+		onClose := func() error {
+			iw.issueLock.Lock()
+			defer iw.issueLock.Unlock()
+
+			iw.values[file] = string(sf.Content)
+			return nil
+		}
+
+		return NewCloseSaver(sf, onClose), nil
+	}
+
+}
+
+func (iw *IssueView) normalWalk(jc *jira.Client, file string) (trees.File, error) {
+	files, dirs := iw.normalFiles()
+	if !StringExistsInSets(file, files, dirs) {
+		return nil, nil
+	}
+
+	issue, err := GetIssue(jc, fmt.Sprintf("%s-%s", iw.project, iw.issueNo))
+	if err != nil {
+		return nil, err
+	}
+
+	sf := trees.NewSyntheticFile(file, 0777, "jira", "jira")
+
+	switch file {
+	case "assignee":
+		if issue.Fields != nil && issue.Fields.Assignee != nil {
+			sf.SetContent([]byte(issue.Fields.Assignee.Name + "\n"))
+		}
+	case "reporter":
+		if issue.Fields != nil && issue.Fields.Reporter != nil {
+			sf.SetContent([]byte(issue.Fields.Reporter.Name + "\n"))
+		}
+	case "creator":
+		if issue.Fields != nil && issue.Fields.Creator != nil {
+			sf.SetContent([]byte(issue.Fields.Creator.Name + "\n"))
+		}
+	case "summary":
+		if issue.Fields != nil {
+			sf.SetContent([]byte(issue.Fields.Summary + "\n"))
+		}
+	case "description":
+		if issue.Fields != nil {
+			sf.SetContent([]byte(issue.Fields.Description + "\n"))
+		}
+	case "issuetype":
+		if issue.Fields != nil {
+			sf.SetContent([]byte(issue.Fields.Type.Name + "\n"))
+		}
+	case "status":
+		if issue.Fields != nil && issue.Fields.Status != nil {
+			sf.SetContent([]byte(issue.Fields.Status.Name + "\n"))
+		}
+	case "key":
+		sf.SetContent([]byte(issue.Key + "\n"))
+	case "labels":
+		if issue.Fields != nil {
+			var s string
+			for _, lbl := range issue.Fields.Labels {
+				s += lbl + "\n"
+			}
+			sf.SetContent([]byte(s))
+		}
+	case "transitions":
+		trs, err := GetTransitionsForIssue(jc, issue.Key)
+		if err != nil {
+			log.Printf("Could not get transitions for issue %s: %v", issue.Key, err)
+			return nil, err
+		}
+
+		var s string
+		for _, tr := range trs {
+			s += tr.Name + "\n"
+		}
+		sf.SetContent([]byte(s))
+	case "comments":
+		return NewJiraDir(file,
+			0555|qp.DMDIR,
+			"jira",
+			"jira",
+			jc,
+			&CommentView{project: iw.project, issueNo: iw.issueNo})
+	case "ctl":
+		cmds := map[string]func([]string) error{
+			"delete": func(args []string) error {
+				return DeleteIssue(jc, issue.Key)
+			},
+		}
+		return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil
+
+	}
+
+	onClose := func() error {
+		switch file {
+		case "key":
+			return nil
+		case "status", "transitions":
+			sf.Lock()
+			str := string(sf.Content)
+			sf.Unlock()
+			str = strings.Replace(str, "\n", "", -1)
+
+			return TransitionIssue(jc, issue.Key, str)
+		default:
+			sf.Lock()
+			str := string(sf.Content)
+			sf.Unlock()
+			if file != "description" && file != "labels" {
+				str = strings.Replace(str, "\n", "", -1)
+			}
+			return SetFieldInIssue(jc, issue.Key, file, str)
+		}
+	}
+
+	return NewCloseSaver(sf, onClose), nil
+}
+
+func (iw *IssueView) Walk(jc *jira.Client, file string) (trees.File, error) {
+	iw.issueLock.Lock()
+	isNew := iw.newIssue
+	iw.issueLock.Unlock()
+
+	if isNew {
+		return iw.newWalk(jc, file)
+	} else {
+		return iw.normalWalk(jc, file)
+	}
+}
+
+func (iw *IssueView) List(jc *jira.Client) ([]qp.Stat, error) {
+	iw.issueLock.Lock()
+	isNew := iw.newIssue
+	iw.issueLock.Unlock()
+
+	var files, dirs []string
+	if isNew {
+		files, dirs = iw.newFiles()
+	} else {
+		files, dirs = iw.normalFiles()
+	}
+	var stats []qp.Stat
+
+	stats = append(stats, StringsToStats(files, 0777, "jira", "jira")...)
+	stats = append(stats, StringsToStats(dirs, 0777|qp.DMDIR, "jira", "jira")...)
+
+	return stats, nil
+}
+
+type ProjectView struct {
+	project string
+}
+
+func (pw *ProjectView) Walk(jc *jira.Client, issueNo string) (trees.File, error) {
+	iw := &IssueView{
+		project: pw.project,
+	}
+
+	if issueNo == "new" {
+		iw.newIssue = true
+	} else {
+		// Check if the thing is a valid issue number.
+		if _, err := strconv.ParseUint(issueNo, 10, 64); err != nil {
+			return nil, nil
+		}
+
+		_, err := GetIssue(jc, fmt.Sprintf("%s-%s", pw.project, issueNo))
+		if err != nil {
+			return nil, err
+		}
+		iw.issueNo = issueNo
+	}
+
+	return NewJiraDir(issueNo, 0555|qp.DMDIR, "jira", "jira", jc, iw)
+}
+
+func (pw *ProjectView) List(jc *jira.Client) ([]qp.Stat, error) {
+	keys, err := GetKeysForNIssues(jc, pw.project, 250)
+	if err != nil {
+		return nil, err
+	}
+
+	keys = append(keys, "new")
+	return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil
+}
+
+type JiraView struct{}
+
+func (jw *JiraView) Walk(jc *jira.Client, projectName string) (trees.File, error) {
+	projectName = strings.ToUpper(projectName)
+	projects, err := GetProjects(jc)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, project := range projects {
+		if project.Key == projectName {
+			goto found
+		}
+	}
+
+	return nil, nil
+
+found:
+
+	return NewJiraDir(projectName,
+		0555|qp.DMDIR,
+		"jira",
+		"jira",
+		jc,
+		&ProjectView{project: projectName})
+}
+
+func (jw *JiraView) List(jc *jira.Client) ([]qp.Stat, error) {
+	projects, err := GetProjects(jc)
+	if err != nil {
+		return nil, err
+	}
+
+	var strs []string
+	for _, p := range projects {
+		strs = append(strs, p.Key)
+	}
+
+	return StringsToStats(strs, 0555|qp.DMDIR, "jira", "jira"), nil
+}
--- /dev/null
+++ b/main.go
@@ -1,0 +1,66 @@
+package main
+
+import (
+	"fmt"
+	"net"
+	"os"
+
+	"github.com/andygrunwald/go-jira"
+	"github.com/howeyc/gopass"
+	"github.com/joushou/qp"
+	"github.com/joushou/qptools/fileserver"
+)
+
+func main() {
+	jiraClient, err := jira.NewClient(nil, os.Args[1])
+	if err != nil {
+		fmt.Printf("Could not connect to JIRA: %v\n", err)
+		return
+	}
+
+	var user, password string
+	fmt.Printf("Username: ")
+	_, err = fmt.Scan(&user)
+	if err != nil {
+		fmt.Printf("Could not read username: %v", err)
+		return
+	}
+	fmt.Printf("Password: ")
+	pass, err := gopass.GetPasswdMasked()
+	if err != nil {
+		fmt.Printf("Could not read password: %v", err)
+		return
+	}
+	password = string(pass)
+
+	res, err := jiraClient.Authentication.AcquireSessionCookie(user, password)
+	if err != nil || res == false {
+		fmt.Printf("Could not authenticate to JIRA: %v\n", err)
+		return
+	}
+
+	root, err := NewJiraDir("", 0555|qp.DMDIR, "jira", "jira", jiraClient, &JiraView{})
+	if err != nil {
+		fmt.Printf("Could not create JIRA view")
+		return
+	}
+
+	l, err := net.Listen("tcp", ":30000")
+	if err != nil {
+		fmt.Printf("Could not listen: %v\n", err)
+		return
+	}
+
+	for {
+		conn, err := l.Accept()
+		if err != nil {
+			fmt.Printf("Accept failed: %v\n", err)
+			return
+		}
+
+		f := fileserver.New(conn, root, nil)
+		f.Verbosity = fileserver.Quiet
+		go f.Serve()
+	}
+
+}
--- /dev/null
+++ b/utils.go
@@ -1,0 +1,514 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"net/http/httputil"
+	"strings"
+	"time"
+
+	"github.com/andygrunwald/go-jira"
+	"github.com/joushou/qp"
+	"github.com/joushou/qptools/fileserver/trees"
+)
+
+type SearchResult struct {
+	Issues []jira.Issue `json:"issues"`
+}
+
+func GetProjects(jc *jira.Client) ([]jira.Project, error) {
+	req, err := jc.NewRequest("GET", "/rest/api/2/project", nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var projects []jira.Project
+	if _, err := jc.Do(req, &projects); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return projects, nil
+}
+
+func GetTypesForProject(jc *jira.Client, project string) ([]string, error) {
+	req, err := jc.NewRequest("GET", "/rest/api/2/issuetype", nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var types []jira.IssueType
+	if _, err := jc.Do(req, &types); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	ss := make([]string, len(types))
+	for i, tp := range types {
+		ss[i] = tp.Name
+	}
+
+	return ss, nil
+}
+
+func GetKeysForNIssues(jc *jira.Client, project string, n int) ([]string, error) {
+	cmd := fmt.Sprintf("/rest/api/2/search?fields=key&maxResults=%d&jql=project=%s", n, project)
+
+	req, err := jc.NewRequest("GET", cmd, nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var s SearchResult
+	if _, err := jc.Do(req, &s); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	ss := make([]string, len(s.Issues))
+	for i, issue := range s.Issues {
+		s := strings.Split(issue.Key, "-")
+		if len(s) != 2 {
+			continue
+		}
+		ss[i] = s[1]
+	}
+
+	return ss, nil
+}
+
+func GetIssue(jc *jira.Client, key string) (*jira.Issue, error) {
+	req, err := jc.NewRequest("GET", fmt.Sprintf("/rest/api/2/issue/%s", key), nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var i jira.Issue
+	if _, err = jc.Do(req, &i); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+	return &i, nil
+}
+
+type CreateIssueResult struct {
+	ID  string `json:"id,omitempty"`
+	Key string `json:"key,omitempty"`
+}
+
+func CreateIssue(jc *jira.Client, issue *jira.Issue) (string, error) {
+	req, err := jc.NewRequest("POST", "/rest/api/2/issue", issue)
+	if err != nil {
+		return "", fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var cir CreateIssueResult
+	if _, err = jc.Do(req, &cir); err != nil {
+		return "", fmt.Errorf("could not query JIRA: %v", err)
+	}
+	return cir.Key, nil
+}
+
+func DeleteIssue(jc *jira.Client, issue string) error {
+	req, err := jc.NewRequest("DELETE", fmt.Sprintf("/rest/api/2/issue/%s", issue), nil)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+	return nil
+}
+
+type Transition struct {
+	ID     string            `json:"id,omitempty"`
+	Name   string            `json:"name,omitempty"`
+	Fields *jira.IssueFields `json:"fields,omitempty"`
+}
+
+type TransitionResult struct {
+	Transitions []Transition `json:"transitions,omitempty"`
+}
+
+func GetTransitionsForIssue(jc *jira.Client, issue string) ([]Transition, error) {
+	req, err := jc.NewRequest("GET", fmt.Sprintf("/rest/api/2/issue/%s/transitions", issue), nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var tr TransitionResult
+	if _, err = jc.Do(req, &tr); err != nil {
+		return nil, fmt.Errorf("could no query JIRA: %v", err)
+	}
+
+	return tr.Transitions, nil
+}
+
+func TransitionIssue(jc *jira.Client, issue, transition string) error {
+	transition = strings.Replace(transition, "\n", "", -1)
+	transitions, err := GetTransitionsForIssue(jc, issue)
+	if err != nil {
+		return err
+	}
+	var id string
+	for _, t := range transitions {
+		if transition == t.Name {
+			id = t.ID
+			break
+		}
+	}
+
+	if id == "" {
+		return fmt.Errorf("no such transition")
+	}
+
+	post := map[string]interface{}{
+		"transition": map[string]interface{}{
+			"id": id,
+		},
+	}
+
+	req, err := jc.NewRequest("POST", fmt.Sprintf("/rest/api/2/issue/%s/transitions", issue), post)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return nil
+}
+
+func SetFieldInIssue(jc *jira.Client, issue, field, val string) error {
+	cmd := fmt.Sprintf("/rest/api/2/issue/%s", issue)
+	method := "PUT"
+
+	var value interface{}
+	if val == "" {
+		value = nil
+	} else {
+		value = val
+	}
+
+	fields := make(map[string]interface{})
+	post := map[string]interface{}{
+		"fields": fields,
+	}
+
+	switch field {
+	case "labels":
+		var labels []string
+		if val != "" && val != "\n" {
+			labels := strings.Split(val, "\n")
+			if labels[len(labels)-1] == "" {
+				labels = labels[:len(labels)-1]
+			}
+		}
+		fields[field] = labels
+	case "issuetype", "assignee", "reporter", "creator":
+		fields[field] = map[string]interface{}{
+			"name": value,
+		}
+	default:
+		fields[field] = value
+	}
+	req, err := jc.NewRequest(method, cmd, post)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if b, err := httputil.DumpRequestOut(req, true); err == nil {
+		log.Printf("SetFieldInIssue body: \n%s\n", b)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return nil
+}
+
+type CommentResult struct {
+	Comments []jira.Comment `json:"comments,omitempty"`
+}
+
+func GetCommentsForIssue(jc *jira.Client, issue string) ([]string, error) {
+	req, err := jc.NewRequest("GET", fmt.Sprintf("/rest/api/2/issue/%s/comment?maxResults=1000", issue), nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var cr CommentResult
+	if _, err := jc.Do(req, &cr); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var ss []string
+	for _, c := range cr.Comments {
+		ss = append(ss, c.ID)
+	}
+
+	return ss, nil
+}
+
+func GetComment(jc *jira.Client, issue, id string) (*jira.Comment, error) {
+	req, err := jc.NewRequest("GET", fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id), nil)
+	if err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	var c jira.Comment
+	if _, err := jc.Do(req, &c); err != nil {
+		return nil, fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return &c, nil
+}
+
+func SetComment(jc *jira.Client, issue, id, body string) error {
+	c := jira.Comment{
+		Body: body,
+	}
+
+	req, err := jc.NewRequest("PUT", fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id), c)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return nil
+}
+
+func AddComment(jc *jira.Client, issue, body string) error {
+	c := jira.Comment{
+		Body: body,
+	}
+
+	req, err := jc.NewRequest("POST", fmt.Sprintf("/rest/api/2/issue/%s/comment/", issue), c)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return nil
+}
+
+func RemoveComment(jc *jira.Client, issue, id string) error {
+	req, err := jc.NewRequest("DELETE", fmt.Sprintf("/rest/api/2/issue/%s/comment/%s", issue, id), nil)
+	if err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	if _, err = jc.Do(req, nil); err != nil {
+		return fmt.Errorf("could not query JIRA: %v", err)
+	}
+
+	return nil
+}
+
+type CanOpenAndLister interface {
+	CanOpen(string, qp.OpenMode) bool
+	trees.Lister
+}
+
+func OpenList(l CanOpenAndLister, user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) {
+	if !l.CanOpen(user, mode) {
+		return nil, errors.New("permission denied")
+	}
+
+	return &trees.ListHandle{
+		Dir:  l,
+		User: user,
+	}, nil
+}
+
+func StringsToStats(strs []string, Perm qp.FileMode, user, group string) []qp.Stat {
+	var stats []qp.Stat
+	for _, str := range strs {
+		stat := qp.Stat{
+			Name: str,
+			UID:  user,
+			GID:  group,
+			MUID: user,
+			Mode: Perm,
+		}
+		stats = append(stats, stat)
+	}
+
+	return stats
+}
+
+func StringExistsInSets(str string, sets ...[]string) bool {
+	for _, set := range sets {
+		for _, s := range set {
+			if str == s {
+				return true
+			}
+		}
+	}
+
+	return false
+}
+
+type CloseSaverHandle struct {
+	onClose func() error
+	trees.ReadWriteAtCloser
+}
+
+func (csh *CloseSaverHandle) Close() error {
+	err := csh.ReadWriteAtCloser.Close()
+	if err != nil {
+		return err
+	}
+
+	if csh.onClose != nil {
+		return csh.onClose()
+	}
+
+	return nil
+}
+
+type CloseSaver struct {
+	onClose func() error
+	trees.File
+}
+
+func (cs *CloseSaver) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) {
+	hndl, err := cs.File.Open(user, mode)
+	if err != nil {
+		return nil, err
+	}
+
+	var closer func() error
+
+	switch mode & 3 {
+	case qp.OWRITE, qp.ORDWR:
+		closer = cs.onClose
+	}
+
+	return &CloseSaverHandle{
+		ReadWriteAtCloser: hndl,
+		onClose:           closer,
+	}, nil
+}
+
+func NewCloseSaver(file trees.File, onClose func() error) trees.File {
+	return &CloseSaver{
+		onClose: onClose,
+		File:    file,
+	}
+}
+
+type CommandFile struct {
+	cmds map[string]func([]string) error
+	*trees.SyntheticFile
+}
+
+func (cf *CommandFile) Close() error { return nil }
+func (cf *CommandFile) ReadAt(p []byte, offset int64) (int, error) {
+	return 0, errors.New("cannot read from command file")
+}
+
+func (cf *CommandFile) WriteAt(p []byte, offset int64) (int, error) {
+	args := strings.Split(strings.Trim(string(p), " \n"), " ")
+	cmd := args[0]
+	args = args[1:]
+
+	if f, exists := cf.cmds[cmd]; exists {
+		return len(p), f(args)
+	}
+	return len(p), errors.New("no such command")
+}
+
+func (cf *CommandFile) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) {
+	if !cf.CanOpen(user, mode) {
+		return nil, trees.ErrPermissionDenied
+	}
+
+	return cf, nil
+}
+
+func NewCommandFile(name string, perms qp.FileMode, user, group string, cmds map[string]func([]string) error) *CommandFile {
+	return &CommandFile{
+		cmds:          cmds,
+		SyntheticFile: trees.NewSyntheticFile(name, perms, user, group),
+	}
+}
+
+type JiraDir struct {
+	thing  interface{}
+	client *jira.Client
+	*trees.SyntheticDir
+}
+
+func (jd *JiraDir) Walk(user, name string) (trees.File, error) {
+
+	if f, ok := jd.thing.(jiraWalker); ok {
+		return f.Walk(jd.client, name)
+	}
+	if f, ok := jd.thing.(trees.Dir); ok {
+		return f.Walk(user, name)
+	}
+
+	return nil, trees.ErrPermissionDenied
+}
+
+func (jd *JiraDir) List(user string) ([]qp.Stat, error) {
+	if f, ok := jd.thing.(jiraLister); ok {
+		return f.List(jd.client)
+	}
+	if f, ok := jd.thing.(trees.Lister); ok {
+		return f.List(user)
+	}
+
+	return nil, trees.ErrPermissionDenied
+}
+
+func (jd *JiraDir) Remove(user, name string) error {
+	if f, ok := jd.thing.(jiraRemover); ok {
+		return f.Remove(jd.client, name)
+	}
+	if f, ok := jd.thing.(trees.Dir); ok {
+		return f.Remove(user, name)
+	}
+
+	return trees.ErrPermissionDenied
+}
+
+func (jd *JiraDir) Create(user, name string, perms qp.FileMode) (trees.File, error) {
+	return nil, trees.ErrPermissionDenied
+}
+
+func (jd *JiraDir) Open(user string, mode qp.OpenMode) (trees.ReadWriteAtCloser, error) {
+	if !jd.CanOpen(user, mode) {
+		return nil, errors.New("access denied")
+	}
+
+	jd.Lock()
+	defer jd.Unlock()
+	jd.Atime = time.Now()
+	jd.Opens++
+	return &trees.ListHandle{
+		Dir:  jd,
+		User: user,
+	}, nil
+}
+
+func NewJiraDir(name string, perm qp.FileMode, user, group string, jc *jira.Client, thing interface{}) (*JiraDir, error) {
+	switch thing.(type) {
+	case trees.File, jiraWalker, jiraLister, jiraRemover:
+	default:
+		return nil, fmt.Errorf("unsupported type: %T", thing)
+	}
+
+	return &JiraDir{
+		thing:        thing,
+		client:       jc,
+		SyntheticDir: trees.NewSyntheticDir(name, perm, user, group),
+	}, nil
+}