├── memo ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── Dockerfile ├── .gitignore ├── fs ├── fs_other.go ├── fs_fuse.go └── fs.go ├── README.md ├── go.mod ├── main.go ├── LICENSE ├── backlog └── backlog.go ├── go.sum ├── gitlab └── gitlab.go └── github └── github.go /memo: -------------------------------------------------------------------------------- 1 | https://godoc.org/github.com/hanwen/go-fuse/fuse 2 | https://github.com/hanwen/go-fuse/ 3 | 4 | http://blog.y-yuki.net/entry/2016/10/29/003000 5 | 6 | https://github.com/google/go-github 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: gomod 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | jobs: 9 | test: 10 | uses: lufia/workflows/.github/workflows/go-test.yml@11481ca31250b76912caa52ca686f4cc48284f2c # v0.8.1 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos:7 2 | MAINTAINER lufia 3 | 4 | RUN yum update -y && \ 5 | yum install -y ca-certificates fuse 6 | RUN useradd -u 1000 taskfs && \ 7 | mkdir /mnt/taskfs && \ 8 | chown taskfs:taskfs /mnt/taskfs 9 | ADD taskfs /usr/local/bin 10 | 11 | WORKDIR /home/taskfs 12 | USER taskfs 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | taskfs 27 | -------------------------------------------------------------------------------- /fs/fs_other.go: -------------------------------------------------------------------------------- 1 | //go:build !linux && !darwin 2 | // +build !linux,!darwin 3 | 4 | package fs 5 | 6 | import "errors" 7 | 8 | type Node interface { 9 | MountAndServe(mtpt string, debug bool) error 10 | } 11 | 12 | type node struct{} 13 | 14 | func NewNode() Node { 15 | return &node{} 16 | } 17 | 18 | func (*node) MountAndServe(mtpt string, debug bool) error { 19 | return errors.New("not implement") 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # taskfs 2 | 3 | ## INSTALL 4 | 5 | ``` 6 | $ go install github.com/lufia/taskfs@latest 7 | ``` 8 | 9 | ## USAGE 10 | 11 | ``` 12 | $ mkdir mtpt 13 | $ taskfs mtpt 14 | $ echo add github $github_token >mtpt/ctl 15 | $ ls mtpt/github.com 16 | $ cat mtpt/githubcom/repo@user#1/message 17 | $ fusermount -u mtpt 18 | ``` 19 | 20 | ## DEVELOPMENT 21 | 22 | ``` 23 | $ GOOS=linux go build -o taskfs 24 | $ docker build -t taskfs:latest . 25 | $ docker run -t -i --rm --cap-add SYS_ADMIN --device /dev/fuse taskfs 26 | ``` 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lufia/taskfs 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/google/go-github/v74 v74.0.0 7 | github.com/griffin-stewie/go-backlog v0.0.0-20180115130933-90b046914fbe 8 | github.com/hanwen/go-fuse v1.0.0 9 | github.com/xanzy/go-gitlab v0.115.0 10 | golang.org/x/oauth2 v0.33.0 11 | ) 12 | 13 | require ( 14 | github.com/google/go-querystring v1.1.0 // indirect 15 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 16 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 17 | golang.org/x/sys v0.20.0 // indirect 18 | golang.org/x/time v0.3.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | 7 | "github.com/lufia/taskfs/backlog" 8 | "github.com/lufia/taskfs/fs" 9 | "github.com/lufia/taskfs/github" 10 | "github.com/lufia/taskfs/gitlab" 11 | ) 12 | 13 | var ( 14 | debug = flag.Bool("d", false, "turn on debug print") 15 | 16 | mtpt = "/mnt/taskfs" 17 | ) 18 | 19 | func main() { 20 | flag.Parse() 21 | root := fs.NewRoot() 22 | root.RegisterService("github", func(token, url string) (fs.Service, error) { 23 | return github.NewService(&github.Config{ 24 | BaseURL: url, 25 | Token: token, 26 | }) 27 | }) 28 | root.RegisterService("gitlab", func(token, url string) (fs.Service, error) { 29 | return gitlab.NewService(&gitlab.Config{ 30 | BaseURL: url, 31 | Token: token, 32 | }) 33 | }) 34 | root.RegisterService("backlog", func(token, url string) (fs.Service, error) { 35 | return backlog.NewService(&backlog.Config{ 36 | BaseURL: url, 37 | APIKey: token, 38 | }) 39 | }) 40 | if flag.NArg() > 0 { 41 | mtpt = flag.Arg(0) 42 | } 43 | if err := root.MountAndServe(mtpt, *debug); err != nil { 44 | log.Fatal(err) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016, kadota kyohei 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /backlog/backlog.go: -------------------------------------------------------------------------------- 1 | package backlog 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | backlog "github.com/griffin-stewie/go-backlog" 10 | "github.com/lufia/taskfs/fs" 11 | ) 12 | 13 | type Issue struct { 14 | issue *backlog.Issue 15 | svc *Service 16 | } 17 | 18 | func (p *Issue) Key() string { 19 | return *p.issue.IssueKey 20 | } 21 | 22 | func (p *Issue) Subject() string { 23 | return *p.issue.Summary 24 | } 25 | 26 | func (p *Issue) Message() string { 27 | return *p.issue.Description 28 | } 29 | 30 | func (p *Issue) PermaLink() string { 31 | return fmt.Sprintf("https://%s/view/%s", p.svc.name, *p.issue.IssueKey) 32 | } 33 | 34 | func (p *Issue) Creation() time.Time { 35 | return *p.issue.Created 36 | } 37 | 38 | func (p *Issue) LastMod() time.Time { 39 | return *p.issue.Updated 40 | } 41 | 42 | func (p *Issue) Comments() ([]fs.Comment, error) { 43 | return []fs.Comment{}, nil 44 | } 45 | 46 | type Config struct { 47 | BaseURL string 48 | APIKey string 49 | } 50 | 51 | type Service struct { 52 | c *backlog.Client 53 | name string 54 | userID int 55 | } 56 | 57 | var ( 58 | errMissingURL = errors.New("base url is missing") 59 | ) 60 | 61 | func NewService(config *Config) (*Service, error) { 62 | if config.BaseURL == "" { 63 | return nil, errMissingURL 64 | } 65 | u, err := url.Parse(config.BaseURL) 66 | if err != nil { 67 | return nil, err 68 | } 69 | name := u.Host 70 | 71 | c := backlog.NewClient(u, config.APIKey) 72 | user, err := c.Myself() 73 | if err != nil { 74 | return nil, err 75 | } 76 | return &Service{c: c, name: name, userID: *user.ID}, nil 77 | } 78 | 79 | func (p *Service) Name() string { 80 | return p.name 81 | } 82 | 83 | func (p *Service) List() ([]fs.Task, error) { 84 | l, err := p.c.IssuesWithOption(&backlog.IssuesOption{ 85 | AssigneeIDs: []int{p.userID}, 86 | Statuses: []backlog.IssueStatus{ 87 | backlog.Open, 88 | backlog.InProgress, 89 | backlog.Resolved, 90 | }, 91 | }) 92 | if err != nil { 93 | return nil, err 94 | } 95 | a := make([]fs.Task, len(l)) 96 | for i, v := range l { 97 | a[i] = &Issue{issue: v, svc: p} 98 | } 99 | return a, nil 100 | } 101 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 4 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 5 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 6 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 7 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 8 | github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= 9 | github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= 10 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 11 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 12 | github.com/griffin-stewie/go-backlog v0.0.0-20180115130933-90b046914fbe h1:tF6QVqQ7DlBihn4wWiJX7Cy2/hLlztoetLANnOY8PF0= 13 | github.com/griffin-stewie/go-backlog v0.0.0-20180115130933-90b046914fbe/go.mod h1:2FqL80YFdzCVNlSrxFfPeVomXvoN7ujPDi+HMwvTMK8= 14 | github.com/hanwen/go-fuse v1.0.0 h1:GxS9Zrn6c35/BnfiVsZVWmsG803xwE7eVRDvcf/BEVc= 15 | github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= 16 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 17 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 18 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 19 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 20 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 21 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 22 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 23 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 24 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 25 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 29 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 30 | github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8= 31 | github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M= 32 | golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 33 | golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 34 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 35 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 36 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 37 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 38 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 39 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 40 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 41 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | -------------------------------------------------------------------------------- /gitlab/gitlab.go: -------------------------------------------------------------------------------- 1 | package gitlab 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/lufia/taskfs/fs" 9 | "github.com/xanzy/go-gitlab" 10 | ) 11 | 12 | type Comment struct { 13 | num int 14 | note *gitlab.Note 15 | } 16 | 17 | func (p *Comment) Key() string { 18 | return fmt.Sprintf("%d", p.num) 19 | } 20 | 21 | func (p *Comment) Message() string { 22 | return p.note.Body 23 | } 24 | 25 | func (p *Comment) Creation() time.Time { 26 | return *p.note.CreatedAt 27 | } 28 | 29 | func (p *Comment) LastMod() time.Time { 30 | return *p.note.UpdatedAt 31 | } 32 | 33 | type Issue struct { 34 | issue *gitlab.Issue 35 | proj *gitlab.Project 36 | svc *Service 37 | } 38 | 39 | func (p *Issue) Key() string { 40 | owner := p.proj.Namespace.Name 41 | repo := p.proj.Name 42 | return fmt.Sprintf("%s@%s#%d", owner, repo, p.issue.IID) 43 | } 44 | 45 | func (p *Issue) Subject() string { 46 | return p.issue.Title 47 | } 48 | 49 | func (p *Issue) Message() string { 50 | return p.issue.Description 51 | } 52 | 53 | func (p *Issue) PermaLink() string { 54 | return p.issue.WebURL 55 | } 56 | 57 | func (p *Issue) Creation() time.Time { 58 | return *p.issue.CreatedAt 59 | } 60 | 61 | func (p *Issue) LastMod() time.Time { 62 | return *p.issue.UpdatedAt 63 | } 64 | 65 | func (p *Issue) Comments() (a []fs.Comment, err error) { 66 | var buf []*gitlab.Note 67 | page := 0 68 | for { 69 | var b []*gitlab.Note 70 | b, page, err = p.fetchNotes(page) 71 | if err != nil { 72 | return 73 | } 74 | buf = append(buf, b...) 75 | if page == 0 { 76 | break 77 | } 78 | } 79 | a = make([]fs.Comment, len(buf)) 80 | for i, v := range buf { 81 | a[i] = &Comment{num: i + 1, note: v} 82 | } 83 | return a, nil 84 | } 85 | 86 | func (p *Issue) fetchNotes(page int) ([]*gitlab.Note, int, error) { 87 | pid := p.issue.ProjectID 88 | n := p.issue.ID 89 | var opt gitlab.ListIssueNotesOptions 90 | opt.Page = page 91 | b, resp, err := p.svc.c.Notes.ListIssueNotes(pid, n, &opt) 92 | if err != nil { 93 | return nil, 0, err 94 | } 95 | return b, resp.NextPage, nil 96 | } 97 | 98 | type Config struct { 99 | BaseURL string 100 | Token string 101 | } 102 | 103 | type Service struct { 104 | c *gitlab.Client 105 | name string 106 | projects map[int]*gitlab.Project 107 | } 108 | 109 | func NewService(config *Config) (*Service, error) { 110 | u, err := url.Parse(config.BaseURL) 111 | if err != nil { 112 | return nil, err 113 | } 114 | c, err := gitlab.NewClient(config.Token, gitlab.WithBaseURL(config.BaseURL)) 115 | if err != nil { 116 | return nil, err 117 | } 118 | svc := &Service{ 119 | c: c, 120 | name: u.Host, 121 | projects: make(map[int]*gitlab.Project), 122 | } 123 | return svc, nil 124 | } 125 | 126 | func (p *Service) Name() string { 127 | return p.name 128 | } 129 | 130 | func (p *Service) List() ([]fs.Task, error) { 131 | var a []fs.Task 132 | var opt gitlab.ListIssuesOptions 133 | for { 134 | b, resp, err := p.c.Issues.ListIssues(&opt) 135 | if err != nil { 136 | return nil, err 137 | } 138 | a, err = p.convertAppendIssues(a, b) 139 | if err != nil { 140 | return nil, err 141 | } 142 | if resp.NextPage == 0 { 143 | break 144 | } 145 | opt.Page = resp.NextPage 146 | } 147 | return a, nil 148 | } 149 | 150 | func (p *Service) convertAppendIssues(a []fs.Task, b []*gitlab.Issue) ([]fs.Task, error) { 151 | for _, v := range b { 152 | task, err := p.fetchTask(v) 153 | if err != nil { 154 | return nil, err 155 | } 156 | a = append(a, task) 157 | } 158 | return a, nil 159 | } 160 | 161 | func (p *Service) fetchTask(v *gitlab.Issue) (task fs.Task, err error) { 162 | proj := p.projects[v.ProjectID] 163 | if proj == nil { 164 | proj, _, err = p.c.Projects.GetProject(v.ProjectID, nil) 165 | if err != nil { 166 | return 167 | } 168 | p.projects[v.ProjectID] = proj 169 | } 170 | return &Issue{issue: v, proj: proj, svc: p}, nil 171 | } 172 | -------------------------------------------------------------------------------- /github/github.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/google/go-github/v74/github" 11 | "github.com/lufia/taskfs/fs" 12 | "golang.org/x/oauth2" 13 | ) 14 | 15 | type Comment struct { 16 | seq int 17 | comment *github.IssueComment 18 | } 19 | 20 | func NewComment(seq int, comment *github.IssueComment) *Comment { 21 | return &Comment{ 22 | seq: seq, 23 | comment: comment, 24 | } 25 | } 26 | 27 | func (p *Comment) Key() string { 28 | return fmt.Sprintf("%d", p.seq) 29 | } 30 | 31 | func (p *Comment) Message() string { 32 | return *p.comment.Body 33 | } 34 | 35 | func (p *Comment) Creation() time.Time { 36 | return p.comment.CreatedAt.Time 37 | } 38 | 39 | func (p *Comment) LastMod() time.Time { 40 | return p.comment.UpdatedAt.Time 41 | } 42 | 43 | type Issue struct { 44 | issue *github.Issue 45 | svc *Service 46 | } 47 | 48 | func (p *Issue) Key() string { 49 | owner := p.repositoryOwner() 50 | repo := p.repositoryName() 51 | return fmt.Sprintf("%s@%s#%d", repo, owner, p.Number()) 52 | } 53 | 54 | func (p *Issue) Subject() string { 55 | return *p.issue.Title 56 | } 57 | 58 | func (p *Issue) Message() string { 59 | return *p.issue.Body 60 | } 61 | 62 | func (p *Issue) PermaLink() string { 63 | return *p.issue.HTMLURL 64 | } 65 | 66 | func (p *Issue) Creation() time.Time { 67 | return p.issue.CreatedAt.Time 68 | } 69 | 70 | func (p *Issue) LastMod() time.Time { 71 | return p.issue.UpdatedAt.Time 72 | } 73 | 74 | func (p *Issue) Comments() (a []fs.Comment, err error) { 75 | var buf []*github.IssueComment 76 | page := 0 77 | for { 78 | var b []*github.IssueComment 79 | b, page, err = p.fetchComments(page) 80 | if err != nil { 81 | return 82 | } 83 | buf = append(buf, b...) 84 | if page == 0 { 85 | break 86 | } 87 | } 88 | a = make([]fs.Comment, len(buf)) 89 | for i, v := range buf { 90 | a[i] = NewComment(i+1, v) 91 | } 92 | return a, nil 93 | } 94 | 95 | func (p *Issue) repositoryOwner() string { 96 | owner := *p.issue.Repository.Owner.Login 97 | if org := p.issue.Repository.Organization; org != nil { 98 | owner = *org.Login 99 | } 100 | return owner 101 | } 102 | 103 | func (p *Issue) repositoryName() string { 104 | return *p.issue.Repository.Name 105 | } 106 | 107 | func (p *Issue) Number() int { 108 | return *p.issue.Number 109 | } 110 | 111 | func (p *Issue) fetchComments(page int) ([]*github.IssueComment, int, error) { 112 | ctx := context.Background() 113 | owner := p.repositoryOwner() 114 | repo := p.repositoryName() 115 | n := p.Number() 116 | var opt github.IssueListCommentsOptions 117 | opt.Page = page 118 | b, resp, err := p.svc.c.Issues.ListComments(ctx, owner, repo, n, &opt) 119 | if err != nil { 120 | return nil, 0, err 121 | } 122 | return b, resp.NextPage, nil 123 | } 124 | 125 | type Config struct { 126 | BaseURL string 127 | Token string 128 | } 129 | 130 | func (c *Config) authorizedClient() *http.Client { 131 | if c.Token == "" { 132 | return nil 133 | } 134 | token := &oauth2.Token{ 135 | AccessToken: c.Token, 136 | } 137 | s := oauth2.StaticTokenSource(token) 138 | return oauth2.NewClient(oauth2.NoContext, s) 139 | } 140 | 141 | type Service struct { 142 | c *github.Client 143 | name string 144 | } 145 | 146 | func NewService(config *Config) (*Service, error) { 147 | var client *http.Client 148 | if config.Token != "" { 149 | client = config.authorizedClient() 150 | } 151 | c := github.NewClient(client) 152 | name := "github.com" 153 | if config.BaseURL != "" { 154 | u, err := url.Parse(config.BaseURL) 155 | if err != nil { 156 | return nil, err 157 | } 158 | c.BaseURL = u 159 | name = u.Host 160 | } 161 | return &Service{c: c, name: name}, nil 162 | } 163 | 164 | func (p *Service) Name() string { 165 | return p.name 166 | } 167 | 168 | func (p *Service) List() ([]fs.Task, error) { 169 | var a []fs.Task 170 | var opt github.IssueListOptions 171 | ctx := context.Background() 172 | for { 173 | b, resp, err := p.c.Issues.List(ctx, true, &opt) 174 | if err != nil { 175 | return nil, err 176 | } 177 | a = p.appendIssues(a, b) 178 | if resp.NextPage == 0 { 179 | break 180 | } 181 | opt.ListOptions.Page = resp.NextPage 182 | } 183 | return a, nil 184 | } 185 | 186 | func (p *Service) appendIssues(a []fs.Task, b []*github.Issue) []fs.Task { 187 | for _, v := range b { 188 | a = append(a, &Issue{issue: v, svc: p}) 189 | } 190 | return a 191 | } 192 | -------------------------------------------------------------------------------- /fs/fs_fuse.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package fs 5 | 6 | import ( 7 | "os" 8 | "time" 9 | 10 | "github.com/hanwen/go-fuse/fuse" 11 | "github.com/hanwen/go-fuse/fuse/nodefs" 12 | ) 13 | 14 | type Node interface { 15 | nodefs.Node 16 | } 17 | 18 | func NewNode() Node { 19 | return nodefs.NewDefaultNode() 20 | } 21 | 22 | func (f *FileInfo) FillAttr(out *fuse.Attr) { 23 | perm := uint32(f.Mode & os.ModePerm) 24 | if f.IsDir() { 25 | out.Mode = fuse.S_IFDIR | perm 26 | } else { 27 | out.Mode = fuse.S_IFREG | perm 28 | } 29 | out.Size = uint64(f.Size) 30 | out.Atime = uint64(f.LastMod.Unix()) 31 | out.Mtime = uint64(f.LastMod.Unix()) 32 | } 33 | 34 | func (f *FileInfo) FillDirEntry(out *fuse.DirEntry) { 35 | out.Name = f.Name 36 | out.Mode = uint32(f.Mode & os.ModePerm) 37 | if f.IsDir() { 38 | out.Mode |= fuse.S_IFDIR 39 | } 40 | } 41 | 42 | func (root *Root) MountAndServe(mtpt string, debug bool) error { 43 | opts := nodefs.Options{ 44 | AttrTimeout: time.Second, 45 | EntryTimeout: time.Second, 46 | Debug: debug, 47 | } 48 | s, _, err := nodefs.MountRoot(mtpt, root, &opts) 49 | if err != nil { 50 | return err 51 | } 52 | s.Serve() 53 | return nil 54 | } 55 | 56 | func (root *Root) Lookup(out *fuse.Attr, name string, ctx *fuse.Context) (*nodefs.Inode, fuse.Status) { 57 | return lookupName(root, name, out, ctx) 58 | } 59 | 60 | func (root *Root) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 61 | root.FileInfo.FillAttr(out) 62 | return fuse.OK 63 | } 64 | 65 | func (root *Root) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 66 | return readDir(root) 67 | } 68 | 69 | func (dir *ServiceDir) Lookup(out *fuse.Attr, name string, ctx *fuse.Context) (*nodefs.Inode, fuse.Status) { 70 | return lookupName(dir, name, out, ctx) 71 | } 72 | 73 | func (dir *ServiceDir) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 74 | dir.FileInfo.FillAttr(out) 75 | return fuse.OK 76 | } 77 | 78 | func (dir *ServiceDir) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 79 | return readDir(dir) 80 | } 81 | 82 | func (dir *TaskDir) Lookup(out *fuse.Attr, name string, ctx *fuse.Context) (*nodefs.Inode, fuse.Status) { 83 | return lookupName(dir, name, out, ctx) 84 | } 85 | 86 | func (dir *TaskDir) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 87 | dir.FileInfo.FillAttr(out) 88 | return fuse.OK 89 | } 90 | 91 | func (dir *TaskDir) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 92 | return readDir(dir) 93 | } 94 | 95 | func (t *Text) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 96 | t.FileInfo.FillAttr(out) 97 | return fuse.OK 98 | } 99 | 100 | func (t *Text) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 101 | return nil, fuse.EINVAL 102 | } 103 | 104 | func (t *Text) Open(flags uint32, ctx *fuse.Context) (nodefs.File, fuse.Status) { 105 | if flags&fuse.O_ANYWRITE != 0 { 106 | return nil, fuse.EPERM 107 | } 108 | p, err := t.ReadFile() 109 | if err != nil { 110 | return nil, fuse.EIO 111 | } 112 | return nodefs.NewDataFile(p), fuse.OK 113 | } 114 | 115 | func (t *CommentText) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 116 | t.FileInfo.FillAttr(out) 117 | return fuse.OK 118 | } 119 | 120 | func (t *CommentText) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 121 | return nil, fuse.EINVAL 122 | } 123 | 124 | func (t *CommentText) Open(flags uint32, ctx *fuse.Context) (nodefs.File, fuse.Status) { 125 | if flags&fuse.O_ANYWRITE != 0 { 126 | return nil, fuse.EPERM 127 | } 128 | p, err := t.ReadFile() 129 | if err != nil { 130 | return nil, fuse.EIO 131 | } 132 | return nodefs.NewDataFile(p), fuse.OK 133 | } 134 | 135 | func (ctl *Ctl) GetAttr(out *fuse.Attr, file nodefs.File, ctx *fuse.Context) fuse.Status { 136 | ctl.FileInfo.FillAttr(out) 137 | return fuse.OK 138 | } 139 | 140 | func (ctl *Ctl) OpenDir(ctx *fuse.Context) ([]fuse.DirEntry, fuse.Status) { 141 | return nil, fuse.EINVAL 142 | } 143 | 144 | func (ctl *Ctl) Open(flags uint32, ctx *fuse.Context) (nodefs.File, fuse.Status) { 145 | p, err := ctl.ReadFile() 146 | if err != nil { 147 | return nil, fuse.EIO 148 | } 149 | return nodefs.NewDataFile(p), fuse.OK 150 | } 151 | 152 | func (ctl *Ctl) Truncate(file nodefs.File, size uint64, ctx *fuse.Context) fuse.Status { 153 | return fuse.OK 154 | } 155 | 156 | func (ctl *Ctl) Write(file nodefs.File, data []byte, off int64, ctx *fuse.Context) (uint32, fuse.Status) { 157 | err := ctl.WriteFile(data) 158 | if err != nil { 159 | return 0, fuse.EINVAL 160 | } 161 | return uint32(len(data)), fuse.OK 162 | } 163 | 164 | func lookupName(dir Dir, name string, out *fuse.Attr, ctx *fuse.Context) (*nodefs.Inode, fuse.Status) { 165 | _, status := readDir(dir) 166 | if status != fuse.OK { 167 | return nil, status 168 | } 169 | c := dir.Inode().GetChild(name) 170 | if c == nil { 171 | return nil, fuse.ENOENT 172 | } 173 | status = c.Node().GetAttr(out, nil, ctx) 174 | if status != fuse.OK { 175 | return nil, status 176 | } 177 | return c, fuse.OK 178 | } 179 | 180 | func readDir(dir Dir) ([]fuse.DirEntry, fuse.Status) { 181 | p := dir.Inode() 182 | kids, err := dir.ReadDir() 183 | if err != nil { 184 | return nil, fuse.EIO 185 | } 186 | a := make([]fuse.DirEntry, len(kids)) 187 | for i, kid := range kids { 188 | info := kid.Stat() 189 | if p.GetChild(info.Name) == nil { 190 | p.NewChild(info.Name, info.IsDir(), kid) 191 | } 192 | info.FillDirEntry(&a[i]) 193 | } 194 | return a, fuse.OK 195 | } 196 | -------------------------------------------------------------------------------- /fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | /* 4 | file structure: 5 | 6 | mtpt/ 7 | github/ 8 | 1000111/ 9 | subject 10 | message 11 | 1 12 | 2 13 | 3... 14 | */ 15 | 16 | import ( 17 | "errors" 18 | "os" 19 | "strings" 20 | "time" 21 | ) 22 | 23 | type Comment interface { 24 | Key() string 25 | Message() string 26 | Creation() time.Time 27 | LastMod() time.Time 28 | } 29 | 30 | type Task interface { 31 | Key() string 32 | Subject() string 33 | Message() string 34 | PermaLink() string 35 | Creation() time.Time 36 | LastMod() time.Time 37 | Comments() ([]Comment, error) 38 | } 39 | 40 | type Service interface { 41 | Name() string 42 | List() ([]Task, error) 43 | } 44 | 45 | type FileInfo struct { 46 | Name string 47 | Size int64 48 | Mode os.FileMode 49 | Creation time.Time 50 | LastMod time.Time 51 | } 52 | 53 | func (f *FileInfo) IsDir() bool { 54 | return f.Mode&os.ModeDir != 0 55 | } 56 | 57 | type Dir interface { 58 | Node 59 | Stat() *FileInfo 60 | ReadDir() ([]Dir, error) 61 | ReadFile() ([]byte, error) 62 | } 63 | 64 | var errProtocol = errors.New("protocol botch") 65 | 66 | type Root struct { 67 | Node 68 | FileInfo 69 | registers map[string]func(token, url string) (Service, error) 70 | services map[string]Service 71 | } 72 | 73 | func NewRoot() *Root { 74 | now := time.Now() 75 | return &Root{ 76 | Node: NewNode(), 77 | FileInfo: FileInfo{ 78 | Mode: os.ModeDir | 0755, 79 | Creation: now, 80 | LastMod: now, 81 | }, 82 | registers: make(map[string]func(token, url string) (Service, error)), 83 | services: make(map[string]Service), 84 | } 85 | } 86 | 87 | func (root *Root) RegisterService(kind string, fn func(token, url string) (Service, error)) { 88 | if _, ok := root.registers[kind]; ok { 89 | panic("duplicate service register: " + kind) 90 | } 91 | root.registers[kind] = fn 92 | } 93 | 94 | func (root *Root) Stat() *FileInfo { 95 | return &root.FileInfo 96 | } 97 | 98 | func (root *Root) ReadDir() ([]Dir, error) { 99 | now := time.Now() 100 | dirs := make([]Dir, 0, len(root.services)+1) // +1: ctl file 101 | for name, svc := range root.services { 102 | dir := &ServiceDir{ 103 | Node: NewNode(), 104 | FileInfo: FileInfo{ 105 | Name: name, 106 | Mode: os.ModeDir | 0755, 107 | Creation: now, 108 | LastMod: now, 109 | }, 110 | svc: svc, 111 | } 112 | dirs = append(dirs, dir) 113 | } 114 | dirs = append(dirs, &Ctl{ 115 | Node: NewNode(), 116 | FileInfo: FileInfo{ 117 | Name: "ctl", 118 | Mode: 0644, 119 | Creation: now, 120 | LastMod: now, 121 | }, 122 | Commands: map[string]func(args ...string) error{ 123 | "add": root.addService, 124 | }, 125 | }) 126 | return dirs, nil 127 | } 128 | 129 | func (root *Root) addService(args ...string) error { 130 | var kind, token, url string 131 | switch len(args) { 132 | case 3: 133 | url = args[2] 134 | fallthrough 135 | case 2: 136 | token = args[1] 137 | kind = args[0] 138 | register := root.registers[kind] 139 | if register == nil { 140 | return errors.New("unsupported service type: " + kind) 141 | } 142 | srv, err := register(token, url) 143 | if err != nil { 144 | return err 145 | } 146 | root.services[srv.Name()] = srv 147 | return nil 148 | default: 149 | return errors.New("invalid add command") 150 | } 151 | } 152 | 153 | func (*Root) ReadFile() ([]byte, error) { 154 | return nil, errProtocol 155 | } 156 | 157 | type ServiceDir struct { 158 | Node 159 | FileInfo 160 | svc Service 161 | cache []Dir 162 | } 163 | 164 | func (dir *ServiceDir) Stat() *FileInfo { 165 | return &dir.FileInfo 166 | } 167 | 168 | func (dir *ServiceDir) ReadDir() ([]Dir, error) { 169 | if dir.cache != nil { 170 | return dir.cache, nil 171 | } 172 | a, err := dir.svc.List() 173 | if err != nil { 174 | return nil, err 175 | } 176 | dirs := make([]Dir, len(a)+1) 177 | for i, task := range a { 178 | dirs[i] = &TaskDir{ 179 | Node: NewNode(), 180 | FileInfo: FileInfo{ 181 | Name: task.Key(), 182 | Mode: os.ModeDir | 0755, 183 | Creation: task.Creation(), 184 | LastMod: task.LastMod(), 185 | }, 186 | task: task, 187 | } 188 | } 189 | now := time.Now() 190 | dirs[len(dirs)-1] = &Ctl{ 191 | Node: NewNode(), 192 | FileInfo: FileInfo{ 193 | Name: "ctl", 194 | Mode: 0644, 195 | Creation: now, 196 | LastMod: now, 197 | }, 198 | Commands: map[string]func(args ...string) error{ 199 | "refresh": dir.refreshCache, 200 | }, 201 | } 202 | dir.cache = dirs 203 | return dirs, nil 204 | } 205 | 206 | func (dir *ServiceDir) refreshCache(args ...string) error { 207 | dir.cache = nil 208 | return nil 209 | } 210 | 211 | func (*ServiceDir) ReadFile() ([]byte, error) { 212 | return nil, errProtocol 213 | } 214 | 215 | type TaskDir struct { 216 | Node 217 | FileInfo 218 | task Task 219 | files []Dir 220 | } 221 | 222 | func (dir *TaskDir) Stat() *FileInfo { 223 | return &dir.FileInfo 224 | } 225 | 226 | func (dir *TaskDir) ReadDir() ([]Dir, error) { 227 | if dir.files != nil { 228 | return dir.files, nil 229 | } 230 | a, err := dir.task.Comments() 231 | if err != nil { 232 | return nil, err 233 | } 234 | kids := make([]Dir, 0, len(a)+3) 235 | kids = append(kids, dir.newText("subject", dir.task.Subject())) 236 | kids = append(kids, dir.newText("message", dir.task.Message())) 237 | kids = append(kids, dir.newText("url", dir.task.PermaLink())) 238 | for _, c := range a { 239 | kids = append(kids, NewCommentText(c)) 240 | } 241 | dir.files = kids 242 | return dir.files, nil 243 | } 244 | 245 | func (*TaskDir) ReadFile() ([]byte, error) { 246 | return nil, errProtocol 247 | } 248 | 249 | func (dir *TaskDir) newText(name, s string) *Text { 250 | data := []byte(s) 251 | return &Text{ 252 | Node: NewNode(), 253 | FileInfo: FileInfo{ 254 | Name: name, 255 | Size: int64(len(data)), 256 | Mode: 0644, 257 | Creation: dir.task.Creation(), 258 | LastMod: dir.task.LastMod(), 259 | }, 260 | data: data, 261 | } 262 | } 263 | 264 | type Text struct { 265 | Node 266 | FileInfo 267 | data []byte 268 | } 269 | 270 | func (t *Text) Stat() *FileInfo { 271 | return &t.FileInfo 272 | } 273 | 274 | func (t *Text) ReadDir() ([]Dir, error) { 275 | // this method isn't going to be called. 276 | return nil, errProtocol 277 | } 278 | 279 | func (t *Text) ReadFile() ([]byte, error) { 280 | return t.data, nil 281 | } 282 | 283 | type CommentText struct { 284 | Node 285 | FileInfo 286 | data []byte 287 | } 288 | 289 | func NewCommentText(c Comment) *CommentText { 290 | data := []byte(c.Message()) 291 | return &CommentText{ 292 | Node: NewNode(), 293 | FileInfo: FileInfo{ 294 | Name: c.Key(), 295 | Size: int64(len(data)), 296 | Mode: 0644, 297 | Creation: c.Creation(), 298 | LastMod: c.LastMod(), 299 | }, 300 | data: data, 301 | } 302 | } 303 | 304 | func (t *CommentText) Stat() *FileInfo { 305 | return &t.FileInfo 306 | } 307 | 308 | func (t *CommentText) ReadDir() ([]Dir, error) { 309 | return nil, errProtocol 310 | } 311 | 312 | func (t *CommentText) ReadFile() ([]byte, error) { 313 | return t.data, nil 314 | } 315 | 316 | type Ctl struct { 317 | Node 318 | FileInfo 319 | Commands map[string]func(args ...string) error 320 | } 321 | 322 | func (ctl *Ctl) Stat() *FileInfo { 323 | return &ctl.FileInfo 324 | } 325 | 326 | func (ctl *Ctl) ReadDir() ([]Dir, error) { 327 | return nil, errProtocol 328 | } 329 | 330 | func (ctl *Ctl) ReadFile() ([]byte, error) { 331 | return []byte{}, nil 332 | } 333 | 334 | func (ctl *Ctl) WriteFile(p []byte) error { 335 | s := string(p) 336 | cmds := strings.Split(s, "\n") 337 | for _, cmd := range cmds { 338 | a := strings.Fields(cmd) 339 | if len(a) == 0 { 340 | return nil 341 | } 342 | fn, ok := ctl.Commands[a[0]] 343 | if !ok { 344 | return errors.New("unknown control command") 345 | } 346 | if err := fn(a[1:]...); err != nil { 347 | return err 348 | } 349 | } 350 | return nil 351 | } 352 | --------------------------------------------------------------------------------