shithub: jirafs

Download patch

ref: 9be022b54341bef76e62871c352adc6e7a77d9a3
parent: ef379bb73ac216792ac1601ec6589cbe09ccfeba
author: Kenny Levinsen <kl@codesealer.com>
date: Thu Jun 9 11:27:57 EDT 2016

More things

--- /dev/null
+++ b/README.md
@@ -1,0 +1,145 @@
+# jirafs
+
+jirafs is a 9P fileserver that presents JIRA as a filesystem. It tries to be feature-complete without getting in the way.
+
+jirafs supports both username/password login, and oauth 1.0 login to JIRA. Note that username/password logins expire at seemingly arbitrary intervals, so it may prove slightly unreliable.
+
+## OAuth
+
+In order to use oauth, you must generate a key pair for jirafs:
+```plain
+openssl genrsa -out private_key.pem 4096
+openssl rsa -pubout -in private_key.pem -out public_key.pem
+```
+
+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).
+
+## Username/password auth
+
+Simply start jirafs with the `-pass` option. If you want to configure the automatic relogin, see the `-loginint` or `-alwayslogin` options.
+
+## Mounting jirafs
+
+On Linux, you can mount jirafs with the following (assuming it is running on localhost:30000):
+```plain
+sudo mount -t 9p -o trans=tcp,port=30000,noextend,sync,dirsync,nosuid,tcp 127.0.0.1 /mnt/jira
+```
+
+## Disclaimer
+
+jirafs comes without any warranties. The jirafs directory structure may change at random until an optimal shape has been reached.
+
+# Structure
+
+```plain
+/
+   ctl
+   projects/
+      ABC/
+         components
+         issuetypes
+         issues/
+            1/ # ABC-1
+               ...
+            ...
+
+      DEF/
+         ...
+      ...
+   issues/
+      new/
+         ctl
+         description
+         project
+         summary
+         type
+      ABC-1/
+         assignee
+         comments/
+            1
+            ...
+            comment
+         components
+         creator
+         ctl
+         description
+         key
+         labels
+         links
+         priority
+         progress
+         project
+         raw
+         reporter
+         resolution
+         status
+         summary
+         transition
+         type
+         worklog/
+            1/
+               comment
+               author
+               time
+               started
+            ...
+      ABC-2/
+         ...
+      ...
+
+```
+
+## Files worthy of note
+
+## ctl
+
+A global control file. It supports the following commands:
+
+* search search_name JQL
+
+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.
+
+* pass-login
+
+Re-issue a username/password login using the initially provided credentials.
+
+* set name val
+
+Sets jirafs variables. Currently, max-listing is the only variable, which expects an integer.
+
+
+## projects/ABC/issues
+
+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/
+
+## issues/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.
+
+### issues/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.
+
+### issues/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.
+
+### issues/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.
+
+### issues/ABC-1/links
+
+Issue links in the form of "INWARD-ISSUE OUTWARD-ISSUE RELATIONSHIP", such as "ABC-1 ABC-2 Blocks". Writable.
+
+### issues/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.
+
+### issues/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.
+
+### issues/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.
--- a/client.go
+++ b/client.go
@@ -23,7 +23,7 @@
 	cookies                 []*http.Cookie
 	alwaysLogin, usingOAuth bool
 
-	maxIssueListing int
+	maxlisting int
 }
 
 type RPCError struct {
@@ -33,7 +33,7 @@
 }
 
 func (rpc *RPCError) Error() string {
-	return fmt.Sprintf("RPCError: %s: status %d,  %s", rpc.Description, rpc.Status, rpc.Body)
+	return fmt.Sprintf("RPCError: %s: status %s, %s", rpc.Description, rpc.Status, rpc.Body)
 }
 
 func (c *Client) RPC(method, path string, body, target interface{}) error {
@@ -43,13 +43,16 @@
 	}
 
 	var b io.Reader
-	if body != nil {
+	switch x := body.(type) {
+	case nil:
+	case []byte:
+		b = bytes.NewReader(x)
+	default:
 		buf, err := json.Marshal(body)
 		if err != nil {
 			return err
 		}
 		b = bytes.NewReader(buf)
-
 	}
 
 	req, err := http.NewRequest(method, u.String(), b)
--- a/files.go
+++ b/files.go
@@ -117,23 +117,27 @@
 
 // CloseSaver calls a callback on save if the file was opened for writing.
 type CloseSaver struct {
-	onClose func() error
+	onClose    func() error
+	forceTrunc bool
 	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:
+		if cs.forceTrunc {
+			mode |= qp.OTRUNC
+		}
 		closer = cs.onClose
 	}
 
+	hndl, err := cs.File.Open(user, mode)
+	if err != nil {
+		return nil, err
+	}
+
 	return &CloseSaverHandle{
 		ReadWriteAtCloser: hndl,
 		onClose:           closer,
@@ -140,7 +144,7 @@
 	}, nil
 }
 
-func NewCloseSaver(file trees.File, onClose func() error) trees.File {
+func NewCloseSaver(file trees.File, onClose func() error) *CloseSaver {
 	return &CloseSaver{
 		onClose: onClose,
 		File:    file,
--- a/jira.go
+++ b/jira.go
@@ -15,8 +15,78 @@
 	"github.com/joushou/qptools/fileserver/trees"
 )
 
+type WorklogView struct {
+	issueNo string
+	worklog string
+}
+
+func (wv *WorklogView) Walk(jc *Client, file string) (trees.File, error) {
+	w, err := GetSpecificWorklogForIssue(jc, wv.issueNo, wv.worklog)
+	if err != nil {
+		return nil, err
+	}
+
+	sf := trees.NewSyntheticFile(file, 0555, "jira", "jira")
+	switch file {
+	case "comment":
+		sf.SetContent([]byte(w.Comment + "\n"))
+	case "author":
+		sf.SetContent([]byte(w.Author.Name + "\n"))
+	case "time":
+		t := time.Duration(w.TimeSpentSeconds) * time.Second
+		sf.SetContent([]byte(t.String() + "\n"))
+	case "started":
+		sf.SetContent([]byte(time.Time(w.Started).String() + "\n"))
+	default:
+		return nil, nil
+	}
+
+	return sf, nil
+}
+
+func (wv *WorklogView) List(jc *Client) ([]qp.Stat, error) {
+	return StringsToStats([]string{"comment", "author", "time", "started"}, 0555, "jira", "jira"), nil
+}
+
+type IssueWorklogView struct {
+	issueNo string
+}
+
+func (iwv *IssueWorklogView) Walk(jc *Client, file string) (trees.File, error) {
+	w, err := GetWorklogForIssue(jc, iwv.issueNo)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, wr := range w.Worklogs {
+		if wr.ID == file {
+			return NewJiraDir(file,
+				0555|qp.DMDIR,
+				"jira",
+				"jira",
+				jc,
+				&WorklogView{issueNo: iwv.issueNo, worklog: file})
+		}
+	}
+
+	return nil, nil
+}
+
+func (iwv *IssueWorklogView) List(jc *Client) ([]qp.Stat, error) {
+	w, err := GetWorklogForIssue(jc, iwv.issueNo)
+	if err != nil {
+		return nil, err
+	}
+
+	var s []string
+	for _, wr := range w.Worklogs {
+		s = append(s, wr.ID)
+	}
+
+	return StringsToStats(s, 0555, "jira", "jira"), nil
+}
+
 type CommentView struct {
-	project string
 	issueNo string
 }
 
@@ -29,11 +99,11 @@
 			body := string(sf.Content)
 			sf.Unlock()
 
-			return AddComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), body)
+			return AddComment(jc, cw.issueNo, body)
 		}
 		return NewCloseSaver(sf, onClose), nil
 	default:
-		cmt, err := GetComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name)
+		cmt, err := GetComment(jc, cw.issueNo, name)
 		if err != nil {
 			return nil, err
 		}
@@ -49,7 +119,7 @@
 			body := string(sf.Content)
 			sf.Unlock()
 
-			return SetComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name, body)
+			return SetComment(jc, cw.issueNo, name, body)
 		}
 
 		return NewCloseSaver(sf, onClose), nil
@@ -57,7 +127,7 @@
 }
 
 func (cw *CommentView) List(jc *Client) ([]qp.Stat, error) {
-	strs, err := GetCommentsForIssue(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo))
+	strs, err := GetCommentsForIssue(jc, cw.issueNo)
 	if err != nil {
 		return nil, err
 	}
@@ -72,7 +142,7 @@
 	case "comment":
 		return trees.ErrPermissionDenied
 	default:
-		return RemoveComment(jc, fmt.Sprintf("%s-%s", cw.project, cw.issueNo), name)
+		return RemoveComment(jc, cw.issueNo, name)
 	}
 }
 
@@ -87,13 +157,14 @@
 
 func (iw *IssueView) normalFiles() (files, dirs []string) {
 	files = []string{"assignee", "creator", "ctl", "description", "type", "key", "reporter", "status",
-		"summary", "labels", "transition", "priority", "resolution", "raw", "progress", "links", "components"}
-	dirs = []string{"comments"}
+		"summary", "labels", "transition", "priority", "resolution", "raw", "progress", "links", "components",
+		"project"}
+	dirs = []string{"comments", "worklog"}
 	return
 }
 
 func (iw *IssueView) newFiles() (files, dirs []string) {
-	files = []string{"ctl", "description", "type", "summary"}
+	files = []string{"ctl", "description", "type", "summary", "project"}
 	return
 }
 
@@ -107,7 +178,7 @@
 	case "ctl":
 		cmds := map[string]func([]string) error{
 			"commit": func(args []string) error {
-				var issuetype, summary, description string
+				var issuetype, summary, description, project string
 
 				iw.issueLock.Lock()
 				isNew := iw.newIssue
@@ -115,9 +186,14 @@
 					issuetype = strings.Replace(string(iw.values["type"]), "\n", "", -1)
 					summary = strings.Replace(string(iw.values["summary"]), "\n", "", -1)
 					description = strings.Replace(string(iw.values["description"]), "\n", "", -1)
+					project = strings.Replace(string(iw.values["project"]), "\n", "", -1)
 				}
 				iw.issueLock.Unlock()
 
+				if project == "" && iw.project != "" {
+					project = iw.project
+				}
+
 				if !isNew {
 					return errors.New("issue already committed")
 				}
@@ -128,7 +204,7 @@
 							Name: issuetype,
 						},
 						Project: jira.Project{
-							Key: iw.project,
+							Key: project,
 						},
 						Summary:     summary,
 						Description: description,
@@ -141,13 +217,9 @@
 					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.issueNo = key
+				iw.project = project
 				iw.newIssue = false
 				iw.issueLock.Unlock()
 				return nil
@@ -197,49 +269,53 @@
 		return nil, nil
 	}
 
-	issue, err := GetIssue(jc, fmt.Sprintf("%s-%s", iw.project, iw.issueNo))
+	issue, err := GetIssue(jc, iw.issueNo)
 	if err != nil {
 		return nil, err
 	}
 
-	sf := trees.NewSyntheticFile(file, 0777, "jira", "jira")
+	forceTrunc := true
+	writable := true
 
+	var cnt []byte
 	switch file {
 	case "assignee":
 		if issue.Fields != nil && issue.Fields.Assignee != nil {
-			sf.SetContent([]byte(issue.Fields.Assignee.Name + "\n"))
+			cnt = []byte(issue.Fields.Assignee.Name + "\n")
 		}
 	case "reporter":
 		if issue.Fields != nil && issue.Fields.Reporter != nil {
-			sf.SetContent([]byte(issue.Fields.Reporter.Name + "\n"))
+			cnt = []byte(issue.Fields.Reporter.Name + "\n")
 		}
 	case "creator":
 		if issue.Fields != nil && issue.Fields.Creator != nil {
-			sf.SetContent([]byte(issue.Fields.Creator.Name + "\n"))
+			cnt = []byte(issue.Fields.Creator.Name + "\n")
 		}
 	case "summary":
 		if issue.Fields != nil {
-			sf.SetContent([]byte(issue.Fields.Summary + "\n"))
+			cnt = []byte(issue.Fields.Summary + "\n")
 		}
+		forceTrunc = false
 	case "description":
 		if issue.Fields != nil {
-			sf.SetContent([]byte(issue.Fields.Description + "\n"))
+			cnt = []byte(issue.Fields.Description + "\n")
 		}
+		forceTrunc = false
 	case "type":
 		if issue.Fields != nil {
-			sf.SetContent([]byte(issue.Fields.Type.Name + "\n"))
+			cnt = []byte(issue.Fields.Type.Name + "\n")
 		}
 	case "status":
 		if issue.Fields != nil && issue.Fields.Status != nil {
-			sf.SetContent([]byte(issue.Fields.Status.Name + "\n"))
+			cnt = []byte(issue.Fields.Status.Name + "\n")
 		}
 	case "priority":
 		if issue.Fields != nil && issue.Fields.Priority != nil {
-			sf.SetContent([]byte(issue.Fields.Priority.Name + "\n"))
+			cnt = []byte(issue.Fields.Priority.Name + "\n")
 		}
 	case "resolution":
 		if issue.Fields != nil && issue.Fields.Resolution != nil {
-			sf.SetContent([]byte(issue.Fields.Resolution.Name + "\n"))
+			cnt = []byte(issue.Fields.Resolution.Name + "\n")
 		}
 	case "progress":
 		if issue.Fields != nil && issue.Fields.Progress != nil {
@@ -246,10 +322,17 @@
 			p := time.Duration(issue.Fields.Progress.Progress) * time.Second
 			t := time.Duration(issue.Fields.Progress.Total) * time.Second
 			r := t - p
-			sf.SetContent([]byte(fmt.Sprintf("Progress: %v, Remaining: %v, Total: %v\n", p, r, t)))
+			cnt = []byte(fmt.Sprintf("Progress: %v, Remaining: %v, Total: %v\n", p, r, t))
 		}
+		writable = false
+	case "project":
+		if issue.Fields != nil {
+			cnt = []byte(issue.Fields.Project.Key + "\n")
+		}
+		writable = false
 	case "key":
-		sf.SetContent([]byte(issue.Key + "\n"))
+		cnt = []byte(issue.Key + "\n")
+		writable = false
 	case "components":
 		if issue.Fields != nil {
 			var s string
@@ -256,8 +339,9 @@
 			for _, comp := range issue.Fields.Components {
 				s += comp.Name + "\n"
 			}
-			sf.SetContent([]byte(s))
+			cnt = []byte(s)
 		}
+		forceTrunc = false
 	case "labels":
 		if issue.Fields != nil {
 			var s string
@@ -264,8 +348,9 @@
 			for _, lbl := range issue.Fields.Labels {
 				s += lbl + "\n"
 			}
-			sf.SetContent([]byte(s))
+			cnt = []byte(s)
 		}
+		forceTrunc = false
 	case "transition":
 		trs, err := GetTransitionsForIssue(jc, issue.Key)
 		if err != nil {
@@ -277,7 +362,7 @@
 		for _, tr := range trs {
 			s += tr.Name + "\n"
 		}
-		sf.SetContent([]byte(s))
+		cnt = []byte(s)
 	case "links":
 		var s string
 		if issue.Fields != nil {
@@ -284,8 +369,9 @@
 			for _, l := range issue.Fields.IssueLinks {
 				s += renderIssueLink(l, issue.Key) + "\n"
 			}
+			cnt = []byte(s)
 		}
-		sf.SetContent([]byte(s))
+		forceTrunc = false
 	case "comments":
 		return NewJiraDir(file,
 			0555|qp.DMDIR,
@@ -292,13 +378,20 @@
 			"jira",
 			"jira",
 			jc,
-			&CommentView{project: iw.project, issueNo: iw.issueNo})
+			&CommentView{issueNo: iw.issueNo})
+	case "worklog":
+		return NewJiraDir(file,
+			0555|qp.DMDIR,
+			"jira",
+			"jira",
+			jc,
+			&IssueWorklogView{issueNo: iw.issueNo})
 	case "raw":
 		b, err := json.MarshalIndent(issue, "", "   ")
 		if err != nil {
 			return nil, err
 		}
-		sf.SetContent(b)
+		cnt = b
 	case "ctl":
 		cmds := map[string]func([]string) error{
 			"delete": func(args []string) error {
@@ -308,11 +401,22 @@
 		return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil
 	}
 
+	var perm qp.FileMode
+	if writable {
+		perm = 0777
+	} else {
+		perm = 0555
+	}
+
+	sf := trees.NewSyntheticFile(file, perm, "jira", "jira")
+	sf.SetContent(cnt)
+
 	onClose := func() error {
 		switch file {
-		case "key", "raw", "progress":
-			return nil
-
+		case "raw":
+			sf.RLock()
+			defer sf.RUnlock()
+			return SetIssueRaw(jc, issue.Key, sf.Content)
 		case "links":
 			cur := make(map[string]string)
 			for _, l := range issue.Fields.IssueLinks {
@@ -319,9 +423,9 @@
 				cur[renderIssueLink(l, issue.Key)] = l.ID
 			}
 
-			sf.Lock()
+			sf.RLock()
 			str := string(sf.Content)
-			sf.Unlock()
+			sf.RUnlock()
 
 			// Figure out which issue links are new, and which are old.
 			var new []string
@@ -361,20 +465,20 @@
 
 			return nil
 		case "transition":
-			sf.Lock()
+			sf.RLock()
 			str := string(sf.Content)
-			sf.Unlock()
+			sf.RUnlock()
 			str = strings.Replace(str, "\n", "", -1)
 
 			return TransitionIssue(jc, issue.Key, str)
 
 		case "status":
-			sf.Lock()
+			sf.RLock()
 			str := string(sf.Content)
-			sf.Unlock()
+			sf.RUnlock()
 			str = strings.Replace(str, "\n", "", -1)
 
-			issue, err := GetIssue(jc, fmt.Sprintf("%s-%s", iw.project, iw.issueNo))
+			issue, err := GetIssue(jc, iw.issueNo)
 			if err != nil {
 				log.Printf("Could not fetch issue: %v", err)
 				return err
@@ -414,9 +518,9 @@
 			return nil
 
 		default:
-			sf.Lock()
+			sf.RLock()
 			str := string(sf.Content)
-			sf.Unlock()
+			sf.RUnlock()
 			switch file {
 			case "description", "labels", "components":
 			default:
@@ -426,7 +530,13 @@
 		}
 	}
 
-	return NewCloseSaver(sf, onClose), nil
+	if writable {
+		cs := NewCloseSaver(sf, onClose)
+		cs.forceTrunc = forceTrunc
+		return cs, nil
+	}
+
+	return sf, nil
 }
 
 func (iw *IssueView) Walk(jc *Client, file string) (trees.File, error) {
@@ -467,7 +577,7 @@
 }
 
 func (sw *SearchView) search(jc *Client) error {
-	keys, err := GetKeysForSearch(jc, sw.query, jc.maxIssueListing)
+	keys, err := GetKeysForSearch(jc, sw.query, jc.maxlisting)
 	if err != nil {
 		return err
 	}
@@ -496,15 +606,9 @@
 		return nil, errors.New("nil fields in issue")
 	}
 
-	s := strings.Split(issue.Key, "-")
-	if len(s) != 2 {
-		return nil, errors.New("funky issue key")
-	}
-	issueNo := s[1]
-
 	iw := &IssueView{
 		project: issue.Fields.Project.Key,
-		issueNo: issueNo,
+		issueNo: issue.Key,
 	}
 
 	return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, iw)
@@ -522,13 +626,13 @@
 	return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil
 }
 
-type ProjectView struct {
+type ProjectIssuesView struct {
 	project string
 }
 
-func (pw *ProjectView) Walk(jc *Client, issueNo string) (trees.File, error) {
+func (piw *ProjectIssuesView) Walk(jc *Client, issueNo string) (trees.File, error) {
 	iw := &IssueView{
-		project: pw.project,
+		project: piw.project,
 	}
 
 	if issueNo == "new" {
@@ -539,19 +643,20 @@
 			return nil, nil
 		}
 
-		_, err := GetIssue(jc, fmt.Sprintf("%s-%s", pw.project, issueNo))
+		issueKey := fmt.Sprintf("%s-%s", piw.project, issueNo)
+		_, err := GetIssue(jc, issueKey)
 		if err != nil {
 			log.Printf("Could not get issue details: %v", err)
 			return nil, err
 		}
-		iw.issueNo = issueNo
+		iw.issueNo = issueKey
 	}
 
 	return NewJiraDir(issueNo, 0555|qp.DMDIR, "jira", "jira", jc, iw)
 }
 
-func (pw *ProjectView) List(jc *Client) ([]qp.Stat, error) {
-	keys, err := GetKeysForNIssues(jc, pw.project, jc.maxIssueListing)
+func (piw *ProjectIssuesView) List(jc *Client) ([]qp.Stat, error) {
+	keys, err := GetKeysForNIssuesInProject(jc, piw.project, jc.maxlisting)
 	if err != nil {
 		log.Printf("Could not generate issue list: %v", err)
 		return nil, err
@@ -561,6 +666,63 @@
 	return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil
 }
 
+type ProjectView struct {
+	project string
+}
+
+func (pw *ProjectView) Walk(jc *Client, file string) (trees.File, error) {
+	switch file {
+	case "issues":
+		piw := &ProjectIssuesView{project: pw.project}
+		return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, piw)
+	case "components":
+		project, err := GetProject(jc, pw.project)
+		if err != nil {
+			return nil, err
+		}
+
+		var components string
+		for _, c := range project.Components {
+			components += c.Name + "\n"
+		}
+
+		sf := trees.NewSyntheticFile(file, 0555, "jira", "jira")
+		sf.SetContent([]byte(components))
+		return sf, nil
+	case "issuetypes":
+		project, err := GetProject(jc, pw.project)
+		if err != nil {
+			return nil, err
+		}
+
+		var issuetypes string
+		for _, tp := range project.IssueTypes {
+			issuetypes += tp.Name + "\n"
+		}
+		sf := trees.NewSyntheticFile(file, 0555, "jira", "jira")
+		sf.SetContent([]byte(issuetypes))
+		return sf, nil
+	case "raw":
+		project, err := GetProject(jc, pw.project)
+		if err != nil {
+			return nil, err
+		}
+		b, err := json.MarshalIndent(project, "", "   ")
+		if err != nil {
+			return nil, err
+		}
+		sf := trees.NewSyntheticFile(file, 0555, "jira", "jira")
+		sf.SetContent([]byte(b))
+		return sf, nil
+	default:
+		return nil, nil
+	}
+}
+
+func (pw *ProjectView) List(jc *Client) ([]qp.Stat, error) {
+	return StringsToStats([]string{"issues", "issuetypes", "components", "raw"}, 0555|qp.DMDIR, "jira", "jira"), nil
+}
+
 type AllProjectsView struct{}
 
 func (apw *AllProjectsView) Walk(jc *Client, projectName string) (trees.File, error) {
@@ -597,6 +759,50 @@
 	return StringsToStats(strs, 0555|qp.DMDIR, "jira", "jira"), nil
 }
 
+type AllIssuesView struct{}
+
+func (aiv *AllIssuesView) Walk(jc *Client, issueKey string) (trees.File, error) {
+	iw := &IssueView{}
+
+	if issueKey == "new" {
+		iw.newIssue = true
+	} else {
+		s := strings.Split(strings.ToUpper(issueKey), "-")
+		if len(s) != 2 {
+			return nil, nil
+		}
+
+		if _, err := strconv.ParseUint(s[1], 10, 64); err != nil {
+			return nil, nil
+		}
+
+		issue, err := GetIssue(jc, issueKey)
+		if err != nil {
+			log.Printf("Could not get issue details: %v", err)
+			return nil, err
+		}
+		if issue.Fields == nil {
+			return nil, errors.New("no fields")
+		}
+
+		iw.issueNo = issueKey
+		iw.project = issue.Fields.Project.Key
+	}
+
+	return NewJiraDir(issueKey, 0555|qp.DMDIR, "jira", "jira", jc, iw)
+}
+
+func (aiv *AllIssuesView) List(jc *Client) ([]qp.Stat, error) {
+	keys, err := GetKeysForSearch(jc, "", jc.maxlisting)
+	if err != nil {
+		log.Printf("Could not generate issue list: %v", err)
+		return nil, err
+	}
+
+	keys = append(keys, "new")
+	return StringsToStats(keys, 0555|qp.DMDIR, "jira", "jira"), nil
+}
+
 type JiraView struct {
 	searchLock sync.Mutex
 	searches   map[string]*SearchView
@@ -640,12 +846,12 @@
 					return errors.New("invalid arguments")
 				}
 				switch args[0] {
-				case "max-issues":
+				case "max-listing":
 					mi, err := strconv.ParseInt(args[1], 10, 64)
 					if err != nil {
 						return err
 					}
-					jc.maxIssueListing = int(mi)
+					jc.maxlisting = int(mi)
 					return nil
 				default:
 					return errors.New("unknown variable")
@@ -655,6 +861,8 @@
 		return NewCommandFile("ctl", 0777, "jira", "jira", cmds), nil
 	case "projects":
 		return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, &AllProjectsView{})
+	case "issues":
+		return NewJiraDir(file, 0555|qp.DMDIR, "jira", "jira", jc, &AllIssuesView{})
 	default:
 		search, exists := jw.searches[file]
 
@@ -678,7 +886,7 @@
 		strs = append(strs, k)
 	}
 
-	a := StringsToStats([]string{"projects"}, 0555|qp.DMDIR, "jira", "jira")
+	a := StringsToStats([]string{"projects", "issues"}, 0555|qp.DMDIR, "jira", "jira")
 	b := StringsToStats([]string{"ctl"}, 0777, "jira", "jira")
 	c := StringsToStats(strs, 0777|qp.DMDIR, "jira", "jira")
 	return append(append(a, b...), c...), nil
@@ -686,7 +894,7 @@
 
 func (jw *JiraView) Remove(jc *Client, file string) error {
 	switch file {
-	case "ctl", "projects":
+	case "ctl", "projects", "issues":
 		return trees.ErrPermissionDenied
 	default:
 		jw.searchLock.Lock()
--- a/main.go
+++ b/main.go
@@ -14,6 +14,7 @@
 )
 
 var (
+	address     = flag.String("address", "localhost:30000", "address to bind on")
 	usingOAuth  = flag.Bool("oauth", false, "use OAuth 1.0 for authorization")
 	ckey        = flag.String("ckey", "", "consumer key for OAuth")
 	pkey        = flag.String("pkey", "", "private key file for OAuth")
@@ -21,7 +22,7 @@
 	jiraURLStr  = flag.String("url", "", "jira URL")
 	loginInt    = flag.Int("loginint", 5, "login interval in minutes - 0 disables automatic relogin (password auth only)")
 	alwaysLogin = flag.Bool("alwayslogin", false, "log in on all requests (password auth only)")
-	maxIssues   = flag.Int("maxissues", 100, "max issue listing")
+	maxlisting  = flag.Int("maxlisting", 100, "max directory listing length")
 )
 
 func main() {
@@ -34,11 +35,11 @@
 	}
 
 	client := &Client{
-		Client:          &http.Client{},
-		alwaysLogin:     *alwaysLogin,
-		usingOAuth:      *usingOAuth,
-		jiraURL:         jiraURL,
-		maxIssueListing: *maxIssues,
+		Client:      &http.Client{},
+		alwaysLogin: *alwaysLogin,
+		usingOAuth:  *usingOAuth,
+		jiraURL:     jiraURL,
+		maxlisting:  *maxlisting,
 	}
 
 	switch {
@@ -84,7 +85,7 @@
 		return
 	}
 
-	l, err := net.Listen("tcp", ":30000")
+	l, err := net.Listen("tcp", *address)
 	if err != nil {
 		fmt.Printf("Could not listen: %v\n", err)
 		return
--- a/utils.go
+++ b/utils.go
@@ -13,6 +13,15 @@
 	Issues []jira.Issue `json:"issues"`
 }
 
+func GetProject(jc *Client, projectKey string) (*jira.Project, error) {
+	var project jira.Project
+	url := fmt.Sprintf("/rest/api/2/project/%s", projectKey)
+	if err := jc.RPC("GET", url, nil, &project); err != nil {
+		return nil, fmt.Errorf("could not query projects: %v", err)
+	}
+	return &project, nil
+}
+
 func GetProjects(jc *Client) ([]jira.Project, error) {
 	var projects []jira.Project
 	if err := jc.RPC("GET", "/rest/api/2/project", nil, &projects); err != nil {
@@ -21,17 +30,16 @@
 	return projects, nil
 }
 
-func GetTypesForProject(jc *Client, project string) ([]string, error) {
-	var types []jira.IssueType
-	if err := jc.RPC("GET", "/rest/api/2/issuetype", nil, &types); err != nil {
-		return nil, fmt.Errorf("could not query issue types: %v", err)
+func GetTypesForProject(jc *Client, projectKey string) ([]string, error) {
+	p, err := GetProject(jc, projectKey)
+	if err != nil {
+		return nil, err
 	}
 
-	ss := make([]string, len(types))
-	for i, tp := range types {
+	ss := make([]string, len(p.IssueTypes))
+	for i, tp := range p.IssueTypes {
 		ss[i] = tp.Name
 	}
-
 	return ss, nil
 }
 
@@ -50,7 +58,7 @@
 	return ss, nil
 }
 
-func GetKeysForNIssues(jc *Client, project string, max int) ([]string, error) {
+func GetKeysForNIssuesInProject(jc *Client, project string, max int) ([]string, error) {
 	var s SearchResult
 	url := fmt.Sprintf("/rest/api/2/search?fields=key&maxResults=%d&jql=project=%s", max, project)
 	if err := jc.RPC("GET", url, nil, &s); err != nil {
@@ -71,8 +79,8 @@
 
 func GetIssue(jc *Client, key string) (*jira.Issue, error) {
 	var i jira.Issue
-	url := fmt.Sprintf("/rest/api/2/issue/%s", key)
-	if err := jc.RPC("GET", url, nil, &i); err != nil {
+	u := fmt.Sprintf("/rest/api/2/issue/%s", key)
+	if err := jc.RPC("GET", u, nil, &i); err != nil {
 		return nil, fmt.Errorf("could not query issue: %v", err)
 	}
 	return &i, nil
@@ -126,6 +134,24 @@
 	return nil
 }
 
+func GetWorklogForIssue(jc *Client, issue string) (*jira.Worklog, error) {
+	var w jira.Worklog
+	url := fmt.Sprintf("/rest/api/2/issue/%s/worklog", issue)
+	if err := jc.RPC("GET", url, nil, &w); err != nil {
+		return nil, fmt.Errorf("could not get worklog: %v", err)
+	}
+	return &w, nil
+}
+
+func GetSpecificWorklogForIssue(jc *Client, issue, worklog string) (*jira.WorklogRecord, error) {
+	var w jira.WorklogRecord
+	url := fmt.Sprintf("/rest/api/2/issue/%s/worklog/%s", issue, worklog)
+	if err := jc.RPC("GET", url, nil, &w); err != nil {
+		return nil, fmt.Errorf("could not get worklog: %v", err)
+	}
+	return &w, nil
+}
+
 type Transition struct {
 	ID     string            `json:"id,omitempty"`
 	Name   string            `json:"name,omitempty"`
@@ -171,6 +197,14 @@
 	url := fmt.Sprintf("/rest/api/2/issue/%s/transitions", issue)
 	if err := jc.RPC("POST", url, post, nil); err != nil {
 		return fmt.Errorf("could not transition issue: %v", err)
+	}
+	return nil
+}
+
+func SetIssueRaw(jc *Client, issueNo string, b []byte) error {
+	url := fmt.Sprintf("/rest/api/2/issue/%s", issueNo)
+	if err := jc.RPC("PUT", url, b, nil); err != nil {
+		return fmt.Errorf("could not set issue: %v", err)
 	}
 	return nil
 }