├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── browse-tests.feature ├── docs └── screenshot-acme.png ├── dynamic ├── debug.go ├── dir.go ├── server.go └── staticfile.go ├── github.go ├── go.mod ├── go.sum ├── issues.go ├── markform ├── doc.go ├── marshal.go ├── marshal_test.go ├── unmarshal.go └── unmarshal_test.go └── repos.go /.gitignore: -------------------------------------------------------------------------------- 1 | ghfs 2 | 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.14.x" 5 | - master 6 | 7 | go_import_path: github.com/sirnewton01/ghfs 8 | 9 | script: 10 | - pwd 11 | - diff -u <(echo -n) <(gofmt -d ./) 12 | - go test -v ./... 13 | - GOOS=darwin go build 14 | - GOOS=plan9 go build 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Chris McGee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | ==================== 24 | 25 | dynamic/debug.go copies from Harvey-OS/ninep with the following license: 26 | 27 | Copyright (c) 2012 The Ninep Authors. All rights reserved. 28 | 29 | Redistribution and use in source and binary forms, with or without 30 | modification, are permitted provided that the following conditions are 31 | met: 32 | 33 | * Redistributions of source code must retain the above copyright 34 | notice, this list of conditions and the following disclaimer. 35 | * Redistributions in binary form must reproduce the above 36 | copyright notice, this list of conditions and the following disclaimer 37 | in the documentation and/or other materials provided with the 38 | distribution. 39 | * The names of Ninep's contributors may not be used to endorse 40 | or promote products derived from this software without specific prior 41 | written permission. 42 | 43 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 44 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 45 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 46 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 47 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 48 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 49 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 50 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 51 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 52 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 53 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub File System 2 | 3 | ![travis ci](https://api.travis-ci.org/sirnewton01/ghfs.svg?branch=master) 4 | 5 | With this filesystem you can use GitHub from your favorite shell and text editor. 6 | In particular, you can use this to mount GitHub onto a Plan 9 system and use it to collaborate 7 | on projects with other Plan 9 users. Content is presented either in plain text or rich markdown 8 | for ease of viewing the content with few distractions. In some cases the files can be modified 9 | and saved to activate new funcionality. If you want to save, copy, snapshot or otherwise work 10 | with the data you can use the standard OS tools like ls, cp, and even the Finder or Explorer. 11 | You can experiment to find combinations of commands that suit your need. 12 | 13 | ## Current feature set 14 | * Browse repositories by owner (user or organization) 15 | * Read issues 16 | * Filter issues based on milestone, labels, assignee and creator 17 | * Vew user, organization and project metadata 18 | * Edit project metadata 19 | * Star/unstar projects 20 | * Follow/unfollow users 21 | * Create/edit issues (EXPERIMENTAL) 22 | 23 | ## Examples 24 | 25 | ``` 26 | $ ls /github/repos/sirnewton01 27 | 28 | 9p-mdns godev plan9adapter 29 | Rest.ServiceProxy godev-oracle projectcreator 30 | dgit gojazz rpi-9front 31 | eclipse-filesystem-example mdns rtcdocker 32 | gdblib ninep society-tests 33 | ghfs orion.client ttf2plan9 34 | git orion.server xinu 35 | go p9-tutorial 36 | godbg plan9-font-hack 37 | 38 | $ cat /github/repos/sirnewton01/ghfs/repo.md 39 | 40 | # sirnewton01/ghfs 41 | 42 | Description = 9p GitHub filesystem written in Go for use with Plan 9/p9p___ 43 | 44 | Starred = [x] 45 | 46 | Notifications = () not watching (x) watching () ignoring 47 | 48 | Created: 2018-08-05T22:21:28Z 49 | Watchers: 10 50 | Stars: 10 51 | Forks: 1 52 | Default branch: master 53 | Pushed: 2018-10-01T20:46:19Z 54 | Commit: 079a0fa100e6b1704bed9373f1032fd3dbad4566 2018-10-01T20:46:13Z 55 | 56 | git clone https://github.com/sirnewton01/ghfs.git 57 | 58 | $ cat /github/repos/sirnewton01/ghfs/issues/13.md 59 | 60 | # Title = Show last modified time and creation time on issues___ 61 | 62 | * State = () open (x) closed 63 | * OpenedBy: [sirnewton01](../../../sirnewton01) 64 | * CreatedAt: 2018-08-10T15:32:24Z 65 | * Assignee = ___ 66 | * Labels = ,, enhancement ,, ___ 67 | 68 | Body = 69 | ''' 70 | This becomes really useful when you are looking at issues and want to view/sort them according to how old they are or if there is recent activity. 71 | 72 | You can do a simple ```ls -l``` to browse them yourself or even sort them using ```ls -lt``` or ```ls -Ult``` 73 | ''' 74 | ___ 75 | 76 | 77 | ## Comment 78 | 79 | * User: [sirnewton01](../../../sirnewton01) 80 | * CreatedAt: 2018-08-10T16:47:23Z 81 | 82 | Body = 83 | ''' 84 | Also, it would be useful to have the issues owned by a particular user, except that would only be visible on Plan 9, since the FUSE filesystems generally set the owners of everything to a specific user. 85 | ''' 86 | ___ 87 | 88 | 89 | ## Comment 90 | 91 | * User: [sirnewton01](../../../sirnewton01) 92 | * CreatedAt: 2018-09-04T02:37:59Z 93 | 94 | Body = 95 | ''' 96 | The last modification time has been added. 97 | ''' 98 | ___ 99 | 100 | 101 | ## Comment 102 | 103 | * User: [sirnewton01](../../../sirnewton01) 104 | * CreatedAt: 2018-09-04T02:43:16Z 105 | 106 | Body = 107 | ''' 108 | There's no way to expose the creation time in a filesystem. 109 | ''' 110 | ___ 111 | 112 | ``` 113 | 114 | Here is how ghfs can look if you are using the Acme editor. 115 | ![acme-screenshot](docs/screenshot-acme.png) 116 | 117 | ## End Goal 118 | Once in a stable state it should be possible to use the GitHub filesystem to manage all of 119 | your Plan 9 projects, create new ones, track issues and collaborate with other users. It 120 | should be possible to use this in conjunction with a tool such as [dgit](https://github.com/driusan/dgit) 121 | or a git filesystem to update/merge/patch/push changes to GitHub while keeping track 122 | of the progress of the project. 123 | 124 | ## Get Started 125 | 126 | ### Plan 9 Port 127 | Install the latest plan9port. Run ghfs. Mount the filesystem with ```9 mount localhost:5640 ``` 128 | assuming the default tcp port 5640. 129 | 130 | ### Plan 9 131 | 132 | Run ghfs. Post the service with `srv tcp!$yourhostname!5640 ghfs`. You can now mount the service somewhere with `mount /srv/ghfs $mountpoint`. 133 | 134 | ## Authentication 135 | The filesystem uses no authentication with GitHub by default. The rate limit is much lower in this mode. 136 | You can generate a Personal Access Token in your Settings > Develper Settings screen. With a token you 137 | can provide it in the command-line with the ```-apitoken``` flag. 138 | 139 | If you plan to make modifications to projects (change descriptions, star/unstar projects) you will need to add 140 | project permissions to your API token. Otherwise, changes will be silently ignored by the GitHub REST API. 141 | Also, be sure to set the follow/unfollow users permission if you want to be able to do that within ghfs. 142 | 143 | ## Useful tricks 144 | You can navigate to any user or organization you want, not just the ones you follow. Open the /repos 145 | directory, type in the name you want and right-click on it. It will open a new directory with the repos 146 | and metadata for the name you selected. If the name doesn't exist it shows an error. 147 | 148 | From the acme editor, you can navigate some of the hyperlinks to other users or repos by selecting 149 | the relative link and middle-click. On Mac with Plan 9 Port you can middle click by holding down 150 | control, alt and clicking on the text. 151 | 152 | -------------------------------------------------------------------------------- /browse-tests.feature: -------------------------------------------------------------------------------- 1 | Feature: Repository browsing 2 | 3 | Scenario: Browse an arbitrary repo 4 | Given ghfs is running and mounted 5 | When the user cd (chdir) to the repos/someuser/somerepo 6 | Then the chdir is successful and the user can see the repo.md file with the metadata 7 | 8 | Scenario: Browse a repo that is owned by a followee 9 | Given ghfs is running, mounted and authenticated as the current user 10 | When the user cd (chdir) to the repos/someuser 11 | Then the chdir is successful and the user can see all of the repos for that user 12 | 13 | Scenario: Browse a repo that is starred 14 | Given ghfs is running, mounted and authenticated as the current user 15 | And the user has starred a repo 16 | When the user opens the repos/stars.md file 17 | Then the user sees the starred repo in the list of starred repos 18 | 19 | Scenario: Browse a repo that was the origin of a forked repo 20 | Given ghfs is runing and mounted 21 | When the user opens an arbitrary repo that has been forked from another repo 22 | And the user opens the path shown in the repo.md to the original repo 23 | Then the user is able to open and read the repo.md file of the original repo 24 | 25 | -------------------------------------------------------------------------------- /docs/screenshot-acme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirnewton01/ghfs/6c8fb3a0347c99b09b0b5fdbb8b282735414d68e/docs/screenshot-acme.png -------------------------------------------------------------------------------- /dynamic/debug.go: -------------------------------------------------------------------------------- 1 | // Copyright 2009 The Ninep Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package dynamic 6 | 7 | import ( 8 | "bytes" 9 | "log" 10 | 11 | "github.com/Harvey-OS/ninep/protocol" 12 | ) 13 | 14 | type debugServer struct { 15 | *Server 16 | } 17 | 18 | func (e *debugServer) Rversion(msize protocol.MaxSize, version string) (protocol.MaxSize, string, error) { 19 | log.Printf(">>> Tversion %v %v\n", msize, version) 20 | msize, version, err := e.Server.Rversion(msize, version) 21 | if err == nil { 22 | log.Printf("<<< Rversion %v %v\n", msize, version) 23 | } else { 24 | log.Printf("<<< Error %v\n", err) 25 | } 26 | return msize, version, err 27 | } 28 | 29 | func (e *debugServer) Rattach(fid protocol.FID, afid protocol.FID, uname string, aname string) (protocol.QID, error) { 30 | log.Printf(">>> Tattach fid %v, afid %v, uname %v, aname %v\n", fid, afid, 31 | uname, aname) 32 | qid, err := e.Server.Rattach(fid, afid, uname, aname) 33 | if err == nil { 34 | log.Printf("<<< Rattach %v\n", qid) 35 | } else { 36 | log.Printf("<<< Error %v\n", err) 37 | } 38 | return qid, err 39 | } 40 | 41 | func (e *debugServer) Rflush(o protocol.Tag) error { 42 | log.Printf(">>> Tflush tag %v\n", o) 43 | err := e.Server.Rflush(o) 44 | if err == nil { 45 | log.Printf("<<< Rflush\n") 46 | } else { 47 | log.Printf("<<< Error %v\n", err) 48 | } 49 | return err 50 | } 51 | 52 | func (e *debugServer) Rwalk(fid protocol.FID, newfid protocol.FID, paths []string) ([]protocol.QID, error) { 53 | log.Printf(">>> Twalk fid %v, newfid %v, paths %v\n", fid, newfid, paths) 54 | qid, err := e.Server.Rwalk(fid, newfid, paths) 55 | if err == nil { 56 | log.Printf("<<< Rwalk %v\n", qid) 57 | } else { 58 | log.Printf("<<< Error %v\n", err) 59 | } 60 | return qid, err 61 | } 62 | 63 | func (e *debugServer) Ropen(fid protocol.FID, mode protocol.Mode) (protocol.QID, protocol.MaxSize, error) { 64 | log.Printf(">>> Topen fid %v, mode %v\n", fid, mode) 65 | qid, iounit, err := e.Server.Ropen(fid, mode) 66 | if err == nil { 67 | log.Printf("<<< Ropen %v %v\n", qid, iounit) 68 | } else { 69 | log.Printf("<<< Error %v\n", err) 70 | } 71 | return qid, iounit, err 72 | } 73 | 74 | func (e *debugServer) Rcreate(fid protocol.FID, name string, perm protocol.Perm, mode protocol.Mode) (protocol.QID, protocol.MaxSize, error) { 75 | log.Printf(">>> Tcreate fid %v, name %v, perm %v, mode %v\n", fid, name, 76 | perm, mode) 77 | qid, iounit, err := e.Server.Rcreate(fid, name, perm, mode) 78 | if err == nil { 79 | log.Printf("<<< Rcreate %v %v\n", qid, iounit) 80 | } else { 81 | log.Printf("<<< Error %v\n", err) 82 | } 83 | return qid, iounit, err 84 | } 85 | 86 | func (e *debugServer) Rclunk(fid protocol.FID) error { 87 | log.Printf(">>> Tclunk fid %v\n", fid) 88 | err := e.Server.Rclunk(fid) 89 | if err == nil { 90 | log.Printf("<<< Rclunk\n") 91 | } else { 92 | log.Printf("<<< Error %v\n", err) 93 | } 94 | return err 95 | } 96 | 97 | func (e *debugServer) Rstat(fid protocol.FID) ([]byte, error) { 98 | log.Printf(">>> Tstat fid %v\n", fid) 99 | b, err := e.Server.Rstat(fid) 100 | if err == nil { 101 | dir, _ := protocol.Unmarshaldir(bytes.NewBuffer(b)) 102 | log.Printf("<<< Rstat %v\n", dir) 103 | } else { 104 | log.Printf("<<< Error %v\n", err) 105 | } 106 | return b, err 107 | } 108 | 109 | func (e *debugServer) Rwstat(fid protocol.FID, b []byte) error { 110 | dir, _ := protocol.Unmarshaldir(bytes.NewBuffer(b)) 111 | log.Printf(">>> Twstat fid %v, %v\n", fid, dir) 112 | err := e.Server.Rwstat(fid, b) 113 | if err == nil { 114 | log.Printf("<<< Rwstat\n") 115 | } else { 116 | log.Printf("<<< Error %v\n", err) 117 | } 118 | return err 119 | } 120 | 121 | func (e *debugServer) Rremove(fid protocol.FID) error { 122 | log.Printf(">>> Tremove fid %v\n", fid) 123 | err := e.Server.Rremove(fid) 124 | if err == nil { 125 | log.Printf("<<< Rremove\n") 126 | } else { 127 | log.Printf("<<< Error %v\n", err) 128 | } 129 | return err 130 | } 131 | 132 | func (e *debugServer) Rread(fid protocol.FID, o protocol.Offset, c protocol.Count) ([]byte, error) { 133 | log.Printf(">>> Tread fid %v, off %v, count %v\n", fid, o, c) 134 | b, err := e.Server.Rread(fid, o, c) 135 | if err == nil { 136 | log.Printf("<<< Rread %v\n", len(b)) 137 | } else { 138 | log.Printf("<<< Error %v\n", err) 139 | } 140 | return b, err 141 | } 142 | 143 | func (e *debugServer) Rwrite(fid protocol.FID, o protocol.Offset, b []byte) (protocol.Count, error) { 144 | log.Printf(">>> Twrite fid %v, off %v, count %v\n", fid, o, len(b)) 145 | c, err := e.Server.Rwrite(fid, o, b) 146 | if err == nil { 147 | log.Printf("<<< Rwrite %v\n", c) 148 | } else { 149 | log.Printf("<<< Error %v\n", err) 150 | } 151 | return c, err 152 | } 153 | -------------------------------------------------------------------------------- /dynamic/dir.go: -------------------------------------------------------------------------------- 1 | package dynamic 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "path" 7 | "strings" 8 | 9 | "github.com/Harvey-OS/ninep/protocol" 10 | ) 11 | 12 | // A basic directory handler interprets the file 13 | // entries to show its children. The handler 14 | // does not support the creation of any children files. 15 | type BasicDirHandler struct { 16 | S *Server 17 | Filter func(name string) bool 18 | } 19 | 20 | func (b *BasicDirHandler) WalkChild(name string, child string) (int, error) { 21 | if name == "" { 22 | name = "/" 23 | } 24 | idx := b.S.MatchFile(func(f *FileEntry) bool { return f.Name == path.Join(name, child) }) 25 | if idx == -1 { 26 | return idx, fmt.Errorf("File not found: %v\n", child) 27 | } 28 | 29 | return idx, nil 30 | } 31 | 32 | func (b *BasicDirHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 33 | return nil 34 | } 35 | 36 | func (b *BasicDirHandler) CreateChild(name string, child string) (int, error) { 37 | return -1, fmt.Errorf("Creation is not supported") 38 | } 39 | 40 | func (b *BasicDirHandler) Stat(name string) (protocol.Dir, error) { 41 | contents, err := b.getDir(name, -1) 42 | if err != nil { 43 | return protocol.Dir{}, err 44 | } 45 | 46 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTDIR}, Length: uint64(len(contents))}, nil 47 | } 48 | 49 | func (b *BasicDirHandler) getDir(name string, max int64) ([]byte, error) { 50 | matches := b.S.MatchFiles(func(f *FileEntry) bool { 51 | ischild := strings.HasPrefix(f.Name, name+"/") && strings.Count(name, "/") == strings.Count(f.Name, "/")-1 52 | if !ischild { 53 | return false 54 | } 55 | 56 | if b.Filter != nil { 57 | return b.Filter(f.Name) 58 | } 59 | 60 | return true 61 | }) 62 | 63 | var bb bytes.Buffer 64 | 65 | for _, idx := range matches { 66 | match := &b.S.files[idx] 67 | 68 | var b bytes.Buffer 69 | dir := protocol.Dir{} 70 | dir, err := match.Handler.Stat(match.Name) 71 | if err != nil { 72 | return []byte{}, err 73 | } 74 | dir.QID.Path = uint64(idx) 75 | 76 | m := uint32(0755) 77 | if dir.QID.Type&protocol.QTDIR != 0 { 78 | m = m | protocol.DMDIR 79 | } 80 | dir.Mode = m 81 | dir.Name = path.Base(match.Name) 82 | 83 | protocol.Marshaldir(&b, dir) 84 | bb.Write(b.Bytes()) 85 | if max != -1 && int64(bb.Len())+int64(b.Len()) > max { 86 | break 87 | } 88 | } 89 | 90 | return bb.Bytes(), nil 91 | } 92 | 93 | func (b *BasicDirHandler) Wstat(name string, dir protocol.Dir) error { 94 | return fmt.Errorf("Wstat is not supported") 95 | } 96 | 97 | func (b *BasicDirHandler) Remove(name string) error { 98 | return fmt.Errorf("Remove is not supported") 99 | } 100 | 101 | func (b *BasicDirHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 102 | content, err := b.getDir(name, offset+count) 103 | if err != nil { 104 | return []byte{}, err 105 | } 106 | 107 | if offset >= int64(len(content)) { 108 | return []byte{}, nil // TODO should an error be returned? 109 | } 110 | 111 | if offset+count >= int64(len(content)) { 112 | return content[offset:], nil 113 | } 114 | 115 | return content[offset : offset+count], nil 116 | } 117 | 118 | func (b *BasicDirHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 119 | return 0, fmt.Errorf("Write is not supported") 120 | } 121 | 122 | func (b *BasicDirHandler) Clunk(name string, fid protocol.FID) error { 123 | return nil 124 | } 125 | -------------------------------------------------------------------------------- /dynamic/server.go: -------------------------------------------------------------------------------- 1 | package dynamic 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "path" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/Harvey-OS/ninep/protocol" 12 | ) 13 | 14 | var ( 15 | debug = flag.Bool("debug", false, "Enable 9P debugging") 16 | ) 17 | 18 | // A file handler defines the behaviour of one or more file entries 19 | type FileHandler interface { 20 | WalkChild(name string, child string) (int, error) 21 | Open(name string, fid protocol.FID, mode protocol.Mode) error 22 | CreateChild(name string, child string) (int, error) 23 | Stat(name string) (protocol.Dir, error) 24 | Wstat(name string, dir protocol.Dir) error 25 | Remove(name string) error 26 | Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) 27 | Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) 28 | Clunk(name string, fid protocol.FID) error 29 | } 30 | 31 | // A file entry is a location in the filesystem tree with a handler 32 | // that handles the file operations for it. The server keeps track 33 | // of the QID and FID's of the entries. 34 | type FileEntry struct { 35 | Name string 36 | fids []protocol.FID 37 | Handler FileHandler 38 | m sync.Mutex 39 | } 40 | 41 | func NewFileEntry(name string, handler FileHandler) FileEntry { 42 | return FileEntry{Name: name, Handler: handler} 43 | } 44 | 45 | func (fe *FileEntry) addFid(fid protocol.FID) { 46 | fe.m.Lock() 47 | defer fe.m.Unlock() 48 | 49 | fe.fids = append(fe.fids, fid) 50 | } 51 | 52 | func (fe *FileEntry) removeFid(fid protocol.FID) { 53 | fe.m.Lock() 54 | defer fe.m.Unlock() 55 | 56 | for idx, f := range fe.fids { 57 | if f == fid { 58 | fe.fids = append(fe.fids[:idx], fe.fids[idx+1:]...) 59 | return 60 | } 61 | } 62 | } 63 | 64 | func (fe *FileEntry) hasFid(fid protocol.FID) bool { 65 | fe.m.Lock() 66 | defer fe.m.Unlock() 67 | 68 | for _, f := range fe.fids { 69 | if f == fid { 70 | return true 71 | } 72 | } 73 | 74 | return false 75 | } 76 | 77 | // A server 78 | type Server struct { 79 | files []FileEntry 80 | iounit int 81 | m sync.Mutex 82 | } 83 | 84 | func (s *Server) Rversion(msize protocol.MaxSize, version string) (protocol.MaxSize, string, error) { 85 | if version != "9P2000" { 86 | return 0, "", fmt.Errorf("%v not supported; only 9P2000", version) 87 | } 88 | return msize, version, nil 89 | } 90 | 91 | func (s *Server) MatchFile(matcher func(f *FileEntry) bool) int { 92 | s.m.Lock() 93 | defer s.m.Unlock() 94 | 95 | for idx := range s.files { 96 | if matcher(&s.files[idx]) { 97 | return idx 98 | } 99 | } 100 | 101 | return -1 102 | } 103 | 104 | func (s *Server) MatchFiles(matcher func(f *FileEntry) bool) []int { 105 | s.m.Lock() 106 | defer s.m.Unlock() 107 | 108 | files := []int{} 109 | 110 | for idx := range s.files { 111 | if matcher(&s.files[idx]) { 112 | files = append(files, idx) 113 | } 114 | } 115 | 116 | return files 117 | } 118 | 119 | func (s *Server) AddFileEntry(name string, handler FileHandler) int { 120 | s.m.Lock() 121 | defer s.m.Unlock() 122 | newEntry := NewFileEntry(name, handler) 123 | 124 | for idx := range s.files { 125 | if s.files[idx].Name == newEntry.Name { 126 | //s.files[idx].Handler = newEntry.Handler 127 | return idx 128 | } 129 | } 130 | 131 | s.files = append(s.files, newEntry) 132 | return len(s.files) - 1 133 | 134 | } 135 | 136 | func (s *Server) HasChildren(name string) bool { 137 | s.m.Lock() 138 | defer s.m.Unlock() 139 | 140 | for idx := range s.files { 141 | if strings.HasPrefix(s.files[idx].Name, name+"/") { 142 | return true 143 | } 144 | } 145 | 146 | return false 147 | } 148 | 149 | func (s *Server) Rattach(fid protocol.FID, afid protocol.FID, uname string, aname string) (protocol.QID, error) { 150 | if afid != protocol.NOFID { 151 | return protocol.QID{}, fmt.Errorf("We don't do auth attach") 152 | } 153 | 154 | idx := s.MatchFile(func(f *FileEntry) bool { return f.Name == aname }) 155 | if idx == -1 { 156 | return protocol.QID{}, fmt.Errorf("File not found: %v\n", aname) 157 | } 158 | 159 | // Register this new FID for this entry 160 | s.files[idx].addFid(fid) 161 | 162 | dir, err := s.files[idx].Handler.Stat(aname) 163 | if err != nil { 164 | return protocol.QID{}, err 165 | } 166 | 167 | // Handler doesn't specify the path, we can fill it in 168 | dir.QID.Path = uint64(idx) 169 | 170 | return dir.QID, nil 171 | } 172 | 173 | func (s *Server) Rflush(o protocol.Tag) error { 174 | return nil 175 | } 176 | 177 | func (s *Server) Rwalk(fid protocol.FID, newfid protocol.FID, paths []string) ([]protocol.QID, error) { 178 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 179 | if idx == -1 { 180 | return []protocol.QID{}, fmt.Errorf("File not found") 181 | } 182 | 183 | parent := &s.files[idx] 184 | if len(paths) == 0 { 185 | parent.addFid(newfid) 186 | return []protocol.QID{}, nil 187 | } 188 | 189 | p := parent.Name 190 | if p == "" { 191 | p = "/" 192 | } 193 | q := make([]protocol.QID, len(paths)) 194 | 195 | for idx = range paths { 196 | idx2, err := parent.Handler.WalkChild(parent.Name, paths[idx]) 197 | if err != nil { 198 | return []protocol.QID{}, err 199 | } 200 | 201 | parent = &s.files[idx2] 202 | dir, err := parent.Handler.Stat(parent.Name) 203 | if err != nil { 204 | return []protocol.QID{}, err 205 | } 206 | 207 | q[idx] = dir.QID 208 | 209 | // Assign the new FID to the last file 210 | if idx == len(paths)-1 { 211 | parent.addFid(newfid) 212 | } 213 | } 214 | 215 | return q, nil 216 | } 217 | 218 | func (s *Server) Ropen(fid protocol.FID, mode protocol.Mode) (protocol.QID, protocol.MaxSize, error) { 219 | 220 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 221 | if idx == -1 { 222 | return protocol.QID{}, 0, fmt.Errorf("File not found") 223 | } 224 | 225 | f := s.files[idx] 226 | dir, err := f.Handler.Stat(f.Name) 227 | 228 | if err != nil { 229 | return protocol.QID{}, 0, err 230 | } 231 | dir.QID.Path = uint64(idx) 232 | 233 | err = f.Handler.Open(f.Name, fid, mode) 234 | if err != nil { 235 | return protocol.QID{}, 0, err 236 | } 237 | 238 | return dir.QID, protocol.MaxSize(s.iounit), nil 239 | } 240 | 241 | func (s *Server) Rcreate(fid protocol.FID, name string, perm protocol.Perm, mode protocol.Mode) (protocol.QID, protocol.MaxSize, error) { 242 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 243 | if idx == -1 { 244 | return protocol.QID{}, 0, fmt.Errorf("File not found") 245 | } 246 | 247 | parent := s.files[idx] 248 | idx, err := parent.Handler.CreateChild(parent.Name, name) 249 | if err != nil { 250 | return protocol.QID{}, 0, err 251 | } 252 | 253 | child := s.files[idx] 254 | dir, err := child.Handler.Stat(child.Name) 255 | if err != nil { 256 | return protocol.QID{}, 0, err 257 | } 258 | dir.QID.Path = uint64(idx) 259 | return dir.QID, protocol.MaxSize(s.iounit), nil 260 | } 261 | 262 | func (s *Server) Rclunk(fid protocol.FID) error { 263 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 264 | if idx == -1 { 265 | return fmt.Errorf("File not found") 266 | } 267 | 268 | f := &s.files[idx] 269 | err := f.Handler.Clunk(f.Name, fid) 270 | if err != nil { 271 | return err 272 | } 273 | f.removeFid(fid) 274 | 275 | return nil 276 | } 277 | 278 | func (s *Server) Rstat(fid protocol.FID) ([]byte, error) { 279 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 280 | if idx == -1 { 281 | return []byte{}, fmt.Errorf("File not found") 282 | } 283 | 284 | f := s.files[idx] 285 | dir, err := f.Handler.Stat(f.Name) 286 | if err != nil { 287 | return []byte{}, fmt.Errorf("File not found") 288 | } 289 | dir.QID.Path = uint64(idx) 290 | 291 | dir.Mode = 0755 292 | if dir.QID.Type&protocol.QTDIR != 0 { 293 | dir.Mode = dir.Mode | protocol.DMDIR 294 | } 295 | 296 | dir.Name = path.Base(f.Name) 297 | if f.Name == "" { 298 | dir.Name = "/" 299 | } 300 | 301 | var b bytes.Buffer 302 | protocol.Marshaldir(&b, dir) 303 | return b.Bytes(), nil 304 | } 305 | 306 | func (s *Server) Rwstat(fid protocol.FID, b []byte) error { 307 | buf := bytes.NewBuffer(b) 308 | dir, err := protocol.Unmarshaldir(buf) 309 | if err != nil { 310 | return err 311 | } 312 | 313 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 314 | if idx == -1 { 315 | return fmt.Errorf("File not found") 316 | } 317 | 318 | f := s.files[idx] 319 | return f.Handler.Wstat(f.Name, dir) 320 | } 321 | 322 | func (s *Server) Rremove(fid protocol.FID) error { 323 | /*idx := s.MatchFile(func (f *FileEntry) bool { return f.hasFid(fid) }) 324 | if idx == -1 { 325 | return fmt.Errorf("File not found") 326 | } 327 | 328 | f := s.files[idx] 329 | return f.Handler.Remove(f.Name)*/ 330 | 331 | return fmt.Errorf("Remove is not supported since it would invalidate the existing QID's") 332 | } 333 | 334 | func (s *Server) Rread(fid protocol.FID, o protocol.Offset, c protocol.Count) ([]byte, error) { 335 | if int(c) == 0 { 336 | return []byte{}, nil 337 | } 338 | 339 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 340 | if idx == -1 { 341 | return []byte{}, fmt.Errorf("File not found") 342 | } 343 | 344 | f := s.files[idx] 345 | return f.Handler.Read(f.Name, fid, int64(o), int64(c)) 346 | } 347 | 348 | func (s *Server) Rwrite(fid protocol.FID, o protocol.Offset, b []byte) (protocol.Count, error) { 349 | idx := s.MatchFile(func(f *FileEntry) bool { return f.hasFid(fid) }) 350 | if idx == -1 { 351 | return 0, fmt.Errorf("File not found") 352 | } 353 | 354 | f := s.files[idx] 355 | c, err := f.Handler.Write(f.Name, fid, int64(o), b) 356 | return protocol.Count(c), err 357 | } 358 | 359 | type ServerOpt func(*protocol.Server) error 360 | 361 | func NewServer(files []FileEntry, opts ...protocol.ServerOpt) (*protocol.Server, *Server, error) { 362 | f := &Server{} 363 | f.files = files 364 | f.files = append([]FileEntry{NewFileEntry("", &BasicDirHandler{f, nil})}, f.files...) 365 | 366 | var d protocol.NineServer = f 367 | if *debug { 368 | d = &debugServer{f} 369 | } 370 | s, err := protocol.NewServer(d, opts...) 371 | if err != nil { 372 | return nil, nil, err 373 | } 374 | f.iounit = 8192 375 | return s, f, nil 376 | } 377 | -------------------------------------------------------------------------------- /dynamic/staticfile.go: -------------------------------------------------------------------------------- 1 | package dynamic 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/Harvey-OS/ninep/protocol" 7 | ) 8 | 9 | // Static file handler has a static contents that 10 | // is initiated at startup and cannot be modified. 11 | // This is useful for README files and other helpful 12 | // documentation for your filesystem. 13 | type StaticFileHandler struct { 14 | Content []byte 15 | } 16 | 17 | func (f *StaticFileHandler) WalkChild(name string, child string) (int, error) { 18 | return -1, fmt.Errorf("Children are not supported") 19 | } 20 | 21 | func (f *StaticFileHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 22 | return nil 23 | } 24 | 25 | func (f *StaticFileHandler) CreateChild(name string, child string) (int, error) { 26 | return -1, fmt.Errorf("Creation is not supported") 27 | } 28 | 29 | func (f *StaticFileHandler) Stat(name string) (protocol.Dir, error) { 30 | // There's only one version and it is always a file 31 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTFILE}, Length: uint64(len(f.Content))}, nil 32 | } 33 | 34 | func (f *StaticFileHandler) Wstat(name string, qid protocol.Dir) error { 35 | return fmt.Errorf("Wstat is not supported") 36 | } 37 | 38 | func (f *StaticFileHandler) Remove(name string) error { 39 | return fmt.Errorf("Remove is not supported") 40 | } 41 | 42 | func (f *StaticFileHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 43 | if offset >= int64(len(f.Content)) { 44 | return []byte{}, nil // TODO should an error be returned? 45 | } 46 | 47 | if offset+count >= int64(len(f.Content)) { 48 | return f.Content[offset:], nil 49 | } 50 | 51 | return f.Content[offset : offset+count], nil 52 | } 53 | 54 | func (f *StaticFileHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 55 | return 0, fmt.Errorf("Write is not supported") 56 | } 57 | 58 | func (f *StaticFileHandler) Clunk(name string, fid protocol.FID) error { 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /github.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "io/ioutil" 7 | "log" 8 | "net" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/google/go-github/github" 13 | "github.com/gregjones/httpcache" 14 | "github.com/sirnewton01/ghfs/dynamic" 15 | "github.com/sirnewton01/ghfs/markform" 16 | "golang.org/x/oauth2" 17 | ) 18 | 19 | var ( 20 | client *github.Client 21 | uncachedClient *github.Client 22 | funcMap = map[string]interface{}{"markdown": markdown, "markform": markform.Marshal} 23 | currentUser string 24 | ntype = flag.String("ntype", "tcp4", "Default network type") 25 | naddr = flag.String("addr", ":5640", "Network address") 26 | apitoken = flag.String("apitoken", "", "Personal API Token for authentication") 27 | lognet = flag.Bool("lognet", false, "Log network requests") 28 | server *dynamic.Server 29 | ) 30 | 31 | func markdown(content string) string { 32 | return " " + strings.Replace(content, "\n", "\n ", -1) 33 | } 34 | 35 | func main() { 36 | log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) 37 | flag.Parse() 38 | 39 | if *apitoken != "" { 40 | log.Printf("Using Personal API Token for authentication. Caching is enabled.\n") 41 | authTs := oauth2.Transport{Source: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: *apitoken})} 42 | cacheTs := httpcache.NewMemoryCacheTransport() 43 | cacheTs.Transport = &authTs 44 | 45 | client = github.NewClient(&http.Client{Transport: cacheTs}) 46 | 47 | tc := oauth2.NewClient(context.Background(), authTs.Source) 48 | uncachedClient = github.NewClient(tc) 49 | 50 | cu, _, err := client.Users.Get(context.Background(), "") 51 | if err != nil { 52 | panic(err) 53 | } 54 | currentUser = *cu.Login 55 | } else { 56 | log.Printf("Using no authentication. Note that rate limits will apply. Caching is enabled.\n") 57 | client = github.NewClient(httpcache.NewMemoryCacheTransport().Client()) 58 | uncachedClient = github.NewClient(nil) 59 | } 60 | 61 | if !*lognet { 62 | log.SetOutput(ioutil.Discard) 63 | } 64 | 65 | ln, err := net.Listen(*ntype, *naddr) 66 | if err != nil { 67 | return 68 | } 69 | 70 | s, d, err := dynamic.NewServer( 71 | []dynamic.FileEntry{ 72 | dynamic.NewFileEntry("/0intro.md", &dynamic.StaticFileHandler{[]byte(` 73 | # GitHub File System 74 | 75 | Welcome to a file system view of GitHub. Using the site is easy once you learn a few tricks. Since GitHub is a very large site parts of the system are hidden and load on-demand. In particular, the repos directory is empty until you attempt to access something inside. You can "cd _ghfsdir_/repos/sirnewton01" or even "cd _ghfsdir_/repos/sirnewton01/ghfs". From there you will see start to see parts of the filesystem fill in. 76 | 77 | Files are rendered in Markdown or even simple text so that you can interact with it using simple text editors. 78 | 79 | For each repo the open issues are shown under "_ghfs_/repos/_owner_/_repo_/issues". In that directory there is a 80 | filter.md file that you can modify to change the issue filters. When you refresh the directory listing only the 81 | issues matching the filter are shown. 82 | 83 | ## Markform 84 | 85 | Various files are modifiable using "markform", which is a format built on top of markdown for highlighting 86 | portions of a file that you can modify to perform certain actions, such as making a comment, changing the owner of 87 | an issues filter, etc. When you make the change and save the file the system takes the necessary actions based 88 | on what you entered or changed in the highlighted regions. 89 | 90 | Markform has a number of different controls, such as text, checkbox, radio and list. Here is an example of a 91 | text field. 92 | 93 | Description = ___ 94 | 95 | The presence of a paragraph with an equal sign indicates that this is a form control. The three underscores 96 | signify that the type of control is text. You can start writing the description by putting your cursor before 97 | the underscores and type out your description. You do not need to remove the underscores. In fact, the underscores 98 | tell the system where your description ends. Also, markform is optimized for editing and tries to avoid any 99 | excess typing, such as the delete key, or extra cursor tricks. You can just place your cursor and type! 100 | 101 | Description = Here is my excellent description!___ 102 | 103 | This is a simple check box example. 104 | 105 | Student = [] 106 | 107 | Just put a lower case x inside the square braces and that's it! 108 | 109 | Student = [x] 110 | 111 | Radios are much the same except that there are labels for each option. The default option is sometimes 112 | pre-checked with an "x." 113 | 114 | Education = (x) elementary () high school () post-secondary 115 | 116 | Delete the x from the default and put it in the option that you want. 117 | 118 | Education = () elementary (x) hig school () post-secondary 119 | 120 | There are also check box groups, which work much the same as the radios except that you can put an "x" 121 | on all of the options you want or remove them the ones you don't want. 122 | 123 | Lists look something like this. 124 | 125 | Labels = ,, ___ 126 | 127 | You can add your own values like this. You don't need (and shouldn't) to remove the template at the end. 128 | Just type in your new elements or remove existing elements. 129 | 130 | Labels = ,, enhancement ,, ___ 131 | 132 | Date fields are shown in an RFC3339 (or ISO-8601) format that you can modify to specify the date that you 133 | would like. 134 | 135 | StartDate = 2010-01-02T15:04:05Z 136 | 137 | That's about all there is to know about markform. The format is designed to be readable, make it clear 138 | the expected format and make it easy to modify. 139 | 140 | `)}), 141 | }) 142 | 143 | d.AddFileEntry("/repos", &ReposHandler{dynamic.BasicDirHandler{d, nil}}) 144 | 145 | server = d 146 | 147 | NewStarredReposHandler() 148 | 149 | if err := s.Serve(ln); err != nil { 150 | log.Fatal(err) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sirnewton01/ghfs 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/Harvey-OS/ninep v0.0.0-20180612165028-a783d610e22e 7 | github.com/google/go-github v17.0.0+incompatible 8 | github.com/google/go-querystring v1.0.0 // indirect 9 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 10 | github.com/pmezard/go-difflib v1.0.0 // indirect 11 | github.com/russross/blackfriday/v2 v2.0.1 12 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 13 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/Harvey-OS/ninep v0.0.0-20180612165028-a783d610e22e h1:M7fv04NBkUnM5SQUY/K8d7aUc3JbqKUgeUNmGosCdfQ= 3 | github.com/Harvey-OS/ninep v0.0.0-20180612165028-a783d610e22e/go.mod h1:YmLTajv/R+cjEsJ8/vfGOckeEBbz06tgHlYBSpbe+eo= 4 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 5 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 6 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 7 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 8 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 9 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 10 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= 11 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 15 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 16 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 17 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 18 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 19 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= 20 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 21 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= 22 | golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 23 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= 24 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 26 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 27 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 28 | -------------------------------------------------------------------------------- /issues.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "path" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "text/template" 14 | "time" 15 | 16 | "github.com/Harvey-OS/ninep/protocol" 17 | "github.com/google/go-github/github" 18 | "github.com/russross/blackfriday/v2" 19 | "github.com/sirnewton01/ghfs/dynamic" 20 | "github.com/sirnewton01/ghfs/markform" 21 | ) 22 | 23 | var ( 24 | issueMarkdown = template.Must(template.New("issue").Funcs(funcMap).Parse( 25 | `# {{ markform .Form "Title" }} 26 | 27 | * {{ markform .Form "State" }} 28 | * OpenedBy: [{{ .Issue.User.Login }}](../../../{{ .Issue.User.Login }}) 29 | * CreatedAt: {{ .Issue.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} 30 | * {{ markform .Form "Assignee" }} 31 | * {{ markform .Form "Labels" }} 32 | 33 | {{ markform .Form "Body" }} 34 | 35 | 36 | `)) 37 | 38 | commentMarkdown = template.Must(template.New("comment").Funcs(funcMap).Parse( 39 | `## Comment 40 | {{if .Comment}} 41 | * User: [{{ .Comment.User.Login }}](../../../{{ .Comment.User.Login }}) 42 | * CreatedAt: {{ .Comment.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} 43 | {{end}} 44 | {{ markform .Form "Body" }} 45 | 46 | 47 | `)) 48 | 49 | issuesListMarkdown = template.Must(template.New("issueList").Funcs(funcMap).Parse( 50 | `# Issues 51 | 52 | This is a list of issues for the project. You can change the filter by editing filter.md, save it and Get this list again. You can create a new issue by opening {{ .NewIssueNumber }}.md . 53 | 54 | {{ range .Issues }} * {{ .Number }}.md [{{ .State }}] - {{ .Title }} - [ {{ range .Labels }}{{ .Name }} {{ end }}] - {{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} - {{ .Comments }} 55 | {{ end }} 56 | 57 | `)) 58 | 59 | issueFilterMarkdown = template.Must(template.New("issueFilter").Funcs(funcMap).Parse( 60 | `# Filter 61 | 62 | Use this filter to control the issues that are shown in this directory and the issues list. This file 63 | uses restful markdown. See the 0intro.md at the top level of this filesystem 64 | for more details on how to work with the format. 65 | 66 | * {{ markform . "Milestone" }} 67 | * {{ markform . "State" }} 68 | * {{ markform . "Assignee" }} 69 | * {{ markform . "Creator" }} 70 | * {{ markform . "Mentioned" }} 71 | 72 | Commonly used labels include bug, enhancement and task. 73 | 74 | * {{ markform . "Labels" }} 75 | * {{ markform . "Since" }} 76 | 77 | `)) 78 | ) 79 | 80 | type IssuesFilter struct { 81 | Milestone string ` = ___` 82 | State string ` = () open () closed () all` 83 | Assignee string ` = ___` 84 | Creator string ` = ___` 85 | Mentioned string ` = ___` 86 | Labels []string ` = ,, ___` 87 | Since time.Time ` = 2006-01-02T15:04:05Z` 88 | } 89 | 90 | type IssuesHandler struct { 91 | dynamic.BasicDirHandler 92 | options *github.IssueListByRepoOptions 93 | filter map[string]bool 94 | mutex sync.Mutex 95 | } 96 | 97 | func NewIssuesHandler(repoPath string) { 98 | handler := &IssuesHandler{} 99 | handler.options = &github.IssueListByRepoOptions{State: "open"} 100 | handler.BasicDirHandler = dynamic.BasicDirHandler{server, func(name string) bool { 101 | if handler.filter == nil { 102 | return true 103 | } 104 | 105 | _, ok := handler.filter[name] 106 | return ok 107 | }} 108 | 109 | server.AddFileEntry(path.Join(repoPath, "issues"), handler) 110 | NewIssuesCtl(server, path.Join(repoPath, "issues"), handler) 111 | NewIssuesListHandler(path.Join(repoPath, "issues"), handler) 112 | } 113 | 114 | func (ih *IssuesHandler) WalkChild(name string, child string) (int, error) { 115 | idx, _ := ih.BasicDirHandler.WalkChild(name, child) 116 | if idx == -1 { 117 | number, err := strconv.Atoi(strings.Replace(child, ".md", "", 1)) 118 | if err != nil { 119 | return idx, fmt.Errorf("Issue %s not found", child) 120 | } 121 | repo := path.Base(path.Dir(name)) 122 | owner := path.Base(path.Dir(path.Dir(name))) 123 | 124 | log.Printf("Checking if issue %d exists\n", number) 125 | issue, resp, err := uncachedClient.Issues.Get(context.Background(), owner, repo, number) 126 | if resp != nil && resp.Response.StatusCode == 404 { 127 | // We'll create a new issue provided that the number is just one greater 128 | // than the largest issue number 129 | log.Printf("Checking if this could be a new issue\n") 130 | _, _, err2 := uncachedClient.Issues.Get(context.Background(), owner, repo, number-1) 131 | if err2 != nil { 132 | return idx, err2 133 | } 134 | 135 | log.Printf("Creating a new issue\n") 136 | title := "New Issue" 137 | body := "" 138 | labels := []string{} 139 | _, _, err2 = client.Issues.Create(context.Background(), owner, repo, &github.IssueRequest{Title: &title, Body: &body, Labels: &labels}) 140 | if err2 != nil { 141 | return idx, err 142 | } 143 | 144 | issue, _, err = uncachedClient.Issues.Get(context.Background(), owner, repo, number) 145 | } 146 | if err != nil { 147 | return idx, err 148 | } 149 | 150 | NewIssue(server, owner, repo, issue) 151 | } 152 | 153 | return ih.BasicDirHandler.WalkChild(name, child) 154 | } 155 | 156 | func (ih *IssuesHandler) refresh(owner string, repo string) error { 157 | ih.mutex.Lock() 158 | defer ih.mutex.Unlock() 159 | 160 | log.Printf("Listing issues for repo %v/%v\n", owner, repo) 161 | ih.options.ListOptions = github.ListOptions{PerPage: 1} 162 | ih.filter = make(map[string]bool) 163 | ih.filter["/repos/"+owner+"/"+repo+"/issues/filter.md"] = true 164 | ih.filter["/repos/"+owner+"/"+repo+"/issues/0list.md"] = true 165 | 166 | for { 167 | issues, resp, err := uncachedClient.Issues.ListByRepo(context.Background(), owner, repo, ih.options) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | for _, issue := range issues { 173 | NewIssue(server, owner, repo, issue) 174 | ih.filter[fmt.Sprintf("/repos/%s/%s/issues/%d.md", owner, repo, *issue.Number)] = true 175 | } 176 | 177 | if resp.NextPage == 0 { 178 | break 179 | } 180 | 181 | ih.options.Page = resp.NextPage 182 | } 183 | 184 | return nil 185 | } 186 | 187 | func (ih *IssuesHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 188 | return nil 189 | } 190 | 191 | func (ih *IssuesHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 192 | if offset == 0 && count > 0 { 193 | repo := path.Base(path.Dir(name)) 194 | owner := path.Base(path.Dir(path.Dir(name))) 195 | err := ih.refresh(owner, repo) 196 | if err != nil { 197 | return []byte{}, err 198 | } 199 | } 200 | return ih.BasicDirHandler.Read(name, fid, offset, count) 201 | } 202 | 203 | type IssuesCtl struct { 204 | ih *IssuesHandler 205 | readbuf *bytes.Buffer 206 | writefid protocol.FID 207 | writebuf *bytes.Buffer 208 | mutex sync.Mutex 209 | } 210 | 211 | func NewIssuesCtl(server *dynamic.Server, issuesPath string, ih *IssuesHandler) { 212 | handler := &IssuesCtl{ih: ih, readbuf: &bytes.Buffer{}, writebuf: &bytes.Buffer{}} 213 | server.AddFileEntry(path.Join(issuesPath, "filter.md"), handler) 214 | 215 | isf := IssuesFilter{} 216 | isf.Mentioned = handler.ih.options.Mentioned 217 | isf.State = handler.ih.options.State 218 | isf.Assignee = handler.ih.options.Assignee 219 | isf.Creator = handler.ih.options.Creator 220 | isf.Labels = handler.ih.options.Labels 221 | isf.Since = handler.ih.options.Since 222 | 223 | issueFilterMarkdown.Execute(handler.readbuf, isf) 224 | } 225 | 226 | func (ic *IssuesCtl) WalkChild(name string, child string) (int, error) { 227 | return -1, fmt.Errorf("No children of the issues filter.md file") 228 | } 229 | 230 | func (ic *IssuesCtl) Open(name string, fid protocol.FID, mode protocol.Mode) error { 231 | ic.mutex.Lock() 232 | defer ic.mutex.Unlock() 233 | 234 | if mode&protocol.ORDWR != 0 || mode&protocol.OWRITE != 0 { 235 | if ic.writefid != 0 { 236 | return fmt.Errorf("Filter doesn't support concurrent writes") 237 | } 238 | 239 | ic.writefid = fid 240 | ic.writebuf = &bytes.Buffer{} 241 | 242 | isf := IssuesFilter{} 243 | isf.Mentioned = ic.ih.options.Mentioned 244 | isf.State = ic.ih.options.State 245 | isf.Assignee = ic.ih.options.Assignee 246 | isf.Creator = ic.ih.options.Creator 247 | isf.Labels = ic.ih.options.Labels 248 | isf.Since = ic.ih.options.Since 249 | 250 | issueFilterMarkdown.Execute(ic.writebuf, isf) 251 | } 252 | 253 | if mode == protocol.OREAD { 254 | ic.readbuf = &bytes.Buffer{} 255 | 256 | isf := IssuesFilter{} 257 | isf.Mentioned = ic.ih.options.Mentioned 258 | isf.State = ic.ih.options.State 259 | isf.Assignee = ic.ih.options.Assignee 260 | isf.Creator = ic.ih.options.Creator 261 | isf.Labels = ic.ih.options.Labels 262 | isf.Since = ic.ih.options.Since 263 | 264 | issueFilterMarkdown.Execute(ic.readbuf, isf) 265 | } 266 | 267 | return nil 268 | } 269 | 270 | func (ic *IssuesCtl) CreateChild(name string, child string) (int, error) { 271 | return -1, fmt.Errorf("Creating a child of an issue filter.md is not supported") 272 | } 273 | 274 | func (ic *IssuesCtl) Stat(name string) (protocol.Dir, error) { 275 | ic.mutex.Lock() 276 | defer ic.mutex.Unlock() 277 | 278 | // There's only one version and it is always a file 279 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTFILE}, Length: uint64(ic.readbuf.Len())}, nil 280 | } 281 | 282 | func (ic *IssuesCtl) Wstat(name string, dir protocol.Dir) error { 283 | ic.mutex.Lock() 284 | defer ic.mutex.Unlock() 285 | 286 | ic.writebuf.Truncate(int(dir.Length)) 287 | return nil 288 | } 289 | 290 | func (ic *IssuesCtl) Remove(name string) error { 291 | return fmt.Errorf("Removing issues filter.md isn't supported.") 292 | } 293 | 294 | func (ic *IssuesCtl) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 295 | ic.mutex.Lock() 296 | defer ic.mutex.Unlock() 297 | 298 | if offset >= int64(ic.readbuf.Len()) { 299 | return []byte{}, nil // TODO should an error be returned? 300 | } 301 | 302 | if offset+count >= int64(ic.readbuf.Len()) { 303 | return ic.readbuf.Bytes()[offset:], nil 304 | } 305 | 306 | return ic.readbuf.Bytes()[offset : offset+count], nil 307 | } 308 | 309 | func (ic *IssuesCtl) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 310 | ic.mutex.Lock() 311 | defer ic.mutex.Unlock() 312 | 313 | if fid != ic.writefid { 314 | return int64(len(buf)), nil 315 | } 316 | 317 | // TODO consider offset 318 | length, err := ic.writebuf.Write(buf) 319 | if err != nil { 320 | return int64(length), err 321 | } 322 | 323 | return int64(length), nil 324 | } 325 | 326 | func (ic *IssuesCtl) Clunk(name string, fid protocol.FID) error { 327 | ic.mutex.Lock() 328 | defer ic.mutex.Unlock() 329 | 330 | if fid != ic.writefid { 331 | return nil 332 | } 333 | ic.writefid = 0 334 | 335 | if len(ic.writebuf.Bytes()) == 0 { 336 | return nil 337 | } 338 | 339 | isf := IssuesFilter{} 340 | md := blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)) 341 | tree := md.Parse(ic.writebuf.Bytes()) 342 | err := markform.Unmarshal(tree, &isf) 343 | if err != nil { 344 | return err 345 | } 346 | 347 | ic.ih.options.Milestone = isf.Milestone 348 | ic.ih.options.State = isf.State 349 | ic.ih.options.Assignee = isf.Assignee 350 | ic.ih.options.Creator = isf.Creator 351 | ic.ih.options.Mentioned = isf.Mentioned 352 | ic.ih.options.Labels = isf.Labels 353 | ic.ih.options.Since = isf.Since 354 | 355 | return ic.ih.refresh(path.Base(path.Dir(path.Dir(path.Dir(name)))), path.Base(path.Dir(path.Dir(name)))) 356 | } 357 | 358 | type Comment struct { 359 | Comment *github.IssueComment 360 | Form struct { 361 | Body string ` = ___` 362 | } 363 | } 364 | 365 | type Issue struct { 366 | mtime time.Time 367 | Issue *github.Issue 368 | Comments []Comment 369 | Form struct { 370 | Title string ` = ___` 371 | Assignee string ` = ___` 372 | State string ` = () open () closed` 373 | Labels []string ` = ,, ___` 374 | Body string ` = ___` 375 | } 376 | 377 | readbuf *bytes.Buffer 378 | writefid protocol.FID 379 | writebuf *bytes.Buffer 380 | mutex sync.Mutex 381 | } 382 | 383 | func NewIssue(server *dynamic.Server, owner string, repo string, i *github.Issue) { 384 | issue := &Issue{readbuf: &bytes.Buffer{}} 385 | 386 | issue.mtime = i.GetUpdatedAt() 387 | 388 | log.Printf("Listing comments for issue %d\n", *i.Number) 389 | comments, _, _ := uncachedClient.Issues.ListComments(context.Background(), owner, repo, *i.Number, nil) 390 | for _, comment := range comments { 391 | if issue.mtime.Before(comment.GetUpdatedAt()) { 392 | issue.mtime = comment.GetUpdatedAt() 393 | } 394 | } 395 | 396 | server.AddFileEntry(path.Join("/repos", owner, repo, "issues", fmt.Sprintf("%d.md", *i.Number)), issue) 397 | } 398 | 399 | func (i *Issue) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 400 | i.mutex.Lock() 401 | defer i.mutex.Unlock() 402 | 403 | if offset >= int64(i.readbuf.Len()) { 404 | return []byte{}, nil // TODO should an error be returned? 405 | } 406 | 407 | if offset+count >= int64(i.readbuf.Len()) { 408 | return i.readbuf.Bytes()[offset:], nil 409 | } 410 | 411 | return i.readbuf.Bytes()[offset : offset+count], nil 412 | } 413 | 414 | func (i *Issue) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 415 | i.mutex.Lock() 416 | defer i.mutex.Unlock() 417 | 418 | if fid != i.writefid { 419 | return int64(len(buf)), nil 420 | } 421 | 422 | // TODO consider offset 423 | length, err := i.writebuf.Write(buf) 424 | if err != nil { 425 | return int64(length), err 426 | } 427 | 428 | return int64(length), nil 429 | } 430 | 431 | func (i *Issue) WalkChild(name string, child string) (int, error) { 432 | return -1, fmt.Errorf("No children of issues") 433 | } 434 | 435 | func (i *Issue) Open(name string, fid protocol.FID, mode protocol.Mode) error { 436 | owner := path.Base(path.Dir(path.Dir(path.Dir(name)))) 437 | repo := path.Base(path.Dir(path.Dir(name))) 438 | fn := path.Base(name) 439 | n, err := strconv.Atoi(strings.Replace(fn, ".md", "", 1)) 440 | if err != nil { 441 | return err 442 | } 443 | 444 | i.mutex.Lock() 445 | defer i.mutex.Unlock() 446 | 447 | if mode == protocol.OREAD { 448 | i.readbuf.Truncate(0) 449 | log.Printf("Loading issue %d\n", n) 450 | issue, _, err := uncachedClient.Issues.Get(context.Background(), owner, repo, n) 451 | if err != nil { 452 | return err 453 | } 454 | i.mtime = issue.GetUpdatedAt() 455 | i.Issue = issue 456 | 457 | i.Form.Title = *issue.Title 458 | i.Form.Assignee = "" 459 | if issue.Assignee != nil { 460 | i.Form.Assignee = *issue.Assignee.Login 461 | } 462 | i.Form.State = *issue.State 463 | if issue.Body != nil { 464 | i.Form.Body = "\n```\n" + *issue.Body + "\n```\n" 465 | } else { 466 | i.Form.Body = "\n```\n\n```\n" 467 | } 468 | i.Form.Labels = []string{} 469 | if issue.Labels != nil { 470 | for _, l := range issue.Labels { 471 | i.Form.Labels = append(i.Form.Labels, *l.Name) 472 | } 473 | } 474 | 475 | err = issueMarkdown.Execute(i.readbuf, i) 476 | if err != nil { 477 | return err 478 | } 479 | 480 | i.Comments = []Comment{} 481 | log.Printf("Listing comments for issue %d\n", n) 482 | comments, _, err := uncachedClient.Issues.ListComments(context.Background(), owner, repo, n, nil) 483 | for idx, comment := range comments { 484 | if i.mtime.Before(comment.GetUpdatedAt()) { 485 | i.mtime = comment.GetUpdatedAt() 486 | } 487 | 488 | i.Comments = append(i.Comments, Comment{}) 489 | i.Comments[idx].Comment = comment 490 | i.Comments[idx].Form.Body = "\n```\n" + *comment.Body + "\n```\n" 491 | 492 | bb := bytes.Buffer{} 493 | err := commentMarkdown.Execute(&bb, i.Comments[idx]) 494 | if err != nil { 495 | return err 496 | } 497 | i.readbuf.Write(bb.Bytes()) 498 | } 499 | 500 | // Comment template 501 | commentTemplate := Comment{} 502 | commentTemplate.Form.Body = "\n```\n\n```\n" 503 | i.Comments = append(i.Comments, commentTemplate) 504 | 505 | bb := bytes.Buffer{} 506 | err = commentMarkdown.Execute(&bb, commentTemplate) 507 | if err != nil { 508 | return err 509 | } 510 | i.readbuf.Write(bb.Bytes()) 511 | } 512 | 513 | if mode&protocol.ORDWR != 0 || mode&protocol.OWRITE != 0 { 514 | if i.writefid != 0 { 515 | return fmt.Errorf("Issue doesn't support concurrent writes") 516 | } 517 | 518 | i.writefid = fid 519 | i.writebuf = &bytes.Buffer{} 520 | } 521 | 522 | return nil 523 | } 524 | 525 | func (i *Issue) CreateChild(name string, child string) (int, error) { 526 | return -1, fmt.Errorf("Creating a child of an issue is not supported") 527 | } 528 | 529 | func (i *Issue) Stat(name string) (protocol.Dir, error) { 530 | i.mutex.Lock() 531 | defer i.mutex.Unlock() 532 | 533 | t := i.mtime.Unix() 534 | if i.mtime.IsZero() { 535 | t = 0 536 | } 537 | 538 | // There's only one version and it is always a file 539 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTFILE}, Length: uint64(i.readbuf.Len()), Mtime: uint32(t)}, nil 540 | } 541 | 542 | func (i *Issue) Wstat(name string, dir protocol.Dir) error { 543 | i.mutex.Lock() 544 | defer i.mutex.Unlock() 545 | 546 | i.writebuf.Truncate(int(dir.Length)) 547 | return nil 548 | } 549 | 550 | func (i *Issue) Remove(name string) error { 551 | return fmt.Errorf("Removing issues isn't supported.") 552 | } 553 | 554 | func (i *Issue) Clunk(name string, fid protocol.FID) error { 555 | owner := path.Base(path.Dir(path.Dir(path.Dir(name)))) 556 | repo := path.Base(path.Dir(path.Dir(name))) 557 | fn := path.Base(name) 558 | n, err := strconv.Atoi(strings.Replace(fn, ".md", "", 1)) 559 | if err != nil { 560 | return err 561 | } 562 | 563 | i.mutex.Lock() 564 | defer i.mutex.Unlock() 565 | 566 | if fid != i.writefid { 567 | return nil 568 | } 569 | i.writefid = 0 570 | 571 | // No bytes were written this time, leave it alone 572 | if len(i.writebuf.Bytes()) == 0 { 573 | return nil 574 | } 575 | 576 | newi := &Issue{} 577 | md := blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)) 578 | tree := md.Parse(i.writebuf.Bytes()) 579 | 580 | // Split out the comments into their own documents 581 | newparent := tree 582 | comments := []*blackfriday.Node{} 583 | 584 | node := tree.FirstChild 585 | for ; node != nil; node = node.Next { 586 | if node.Type == blackfriday.Heading { 587 | if node.Prev != nil { 588 | newparent.LastChild = node.Prev 589 | node.Prev.Next = nil 590 | } 591 | node.Prev = nil 592 | 593 | newparent = blackfriday.NewNode(blackfriday.Document) 594 | newparent.FirstChild = node 595 | comments = append(comments, newparent) 596 | } 597 | 598 | node.Parent = newparent 599 | } 600 | comments = comments[1:] 601 | 602 | newparent.LastChild = node 603 | 604 | err = markform.Unmarshal(tree, &newi.Form) 605 | if err != nil { 606 | return err 607 | } 608 | 609 | // TODO collapse these individual edits into one 610 | 611 | if newi.Form.Body != i.Form.Body { 612 | log.Printf("Setting issue body for %d\n", n) 613 | _, _, err := client.Issues.Edit(context.Background(), owner, repo, n, &github.IssueRequest{Body: &newi.Form.Body}) 614 | if err != nil { 615 | return err 616 | } 617 | } 618 | 619 | if newi.Form.Title != i.Form.Title { 620 | log.Printf("Setting issue title for %d\n", n) 621 | _, _, err := client.Issues.Edit(context.Background(), owner, repo, n, &github.IssueRequest{Title: &newi.Form.Title}) 622 | if err != nil { 623 | return err 624 | } 625 | } 626 | 627 | if newi.Form.State != i.Form.State { 628 | log.Printf("Changing issue state for %d\n", n) 629 | _, _, err := client.Issues.Edit(context.Background(), owner, repo, n, &github.IssueRequest{State: &newi.Form.State}) 630 | if err != nil { 631 | return err 632 | } 633 | } 634 | 635 | if !reflect.DeepEqual(newi.Form.Labels, i.Form.Labels) { 636 | log.Printf("Changing labels for %d\n", n) 637 | _, _, err := client.Issues.Edit(context.Background(), owner, repo, n, &github.IssueRequest{Labels: &newi.Form.Labels}) 638 | if err != nil { 639 | return err 640 | } 641 | } 642 | 643 | if newi.Form.Assignee != i.Form.Assignee { 644 | log.Printf("Assigning issue %d\n", n) 645 | _, _, err = client.Issues.Edit(context.Background(), owner, repo, n, &github.IssueRequest{Assignee: &newi.Form.Assignee}) 646 | if err != nil { 647 | return err 648 | } 649 | } 650 | 651 | for idx, c := range comments { 652 | comment := &Comment{} 653 | markform.Unmarshal(c, &comment.Form) 654 | 655 | // New comment 656 | if len(i.Comments) <= idx && len(strings.TrimSpace(comment.Form.Body)) != 0 { 657 | log.Printf("Creating a comment for issue %d\n", n) 658 | gc, _, err := client.Issues.CreateComment(context.Background(), owner, repo, n, &github.IssueComment{Body: &comment.Form.Body}) 659 | if err != nil { 660 | return err 661 | } 662 | i.Comments = append(i.Comments, Comment{Comment: gc}) 663 | i.Comments[idx].Form.Body = comment.Form.Body 664 | } else if i.Comments[idx].Form.Body == "\n```\n\n```\n" && len(strings.TrimSpace(comment.Form.Body)) != 0 { 665 | log.Printf("Creating a comment for issue %d\n", n) 666 | gc, _, err := client.Issues.CreateComment(context.Background(), owner, repo, n, &github.IssueComment{Body: &comment.Form.Body}) 667 | if err != nil { 668 | return err 669 | } 670 | i.Comments[idx].Comment = gc 671 | i.Comments[idx].Form.Body = comment.Form.Body 672 | // Edit existing comment 673 | } else if i.Comments[idx].Form.Body != comment.Form.Body && i.Comments[idx].Form.Body != "\n```\n\n```\n" { 674 | log.Printf("Editing comment for issue %d\n", n) 675 | _, _, err := client.Issues.EditComment(context.Background(), owner, repo, *i.Comments[idx].Comment.ID, &github.IssueComment{Body: &comment.Form.Body}) 676 | if err != nil { 677 | return err 678 | } 679 | i.Comments[idx].Form.Body = comment.Form.Body 680 | } 681 | } 682 | 683 | return nil 684 | } 685 | 686 | type IssuesListHandler struct { 687 | dynamic.StaticFileHandler 688 | ih *IssuesHandler 689 | mu sync.Mutex 690 | } 691 | 692 | func NewIssuesListHandler(repoIssuesPath string, ih *IssuesHandler) { 693 | server.AddFileEntry(path.Join(repoIssuesPath, "0list.md"), &IssuesListHandler{StaticFileHandler: dynamic.StaticFileHandler{[]byte{}}, ih: ih}) 694 | } 695 | 696 | func (ilh *IssuesListHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 697 | ilh.mu.Lock() 698 | defer ilh.mu.Unlock() 699 | 700 | list := struct { 701 | Issues []*github.Issue 702 | NewIssueNumber int 703 | }{} 704 | list.Issues = []*github.Issue{} 705 | 706 | repo := path.Base(path.Dir(path.Dir(name))) 707 | owner := path.Base(path.Dir(path.Dir(path.Dir(name)))) 708 | 709 | ilh.ih.mutex.Lock() 710 | defer ilh.ih.mutex.Unlock() 711 | 712 | ilh.ih.options.ListOptions = github.ListOptions{PerPage: 10} 713 | 714 | for { 715 | log.Printf("Listing issues for repo %s\n", repo) 716 | i, resp, err := uncachedClient.Issues.ListByRepo(context.Background(), owner, repo, ilh.ih.options) 717 | if err != nil { 718 | return err 719 | } 720 | 721 | for _, issue := range i { 722 | list.Issues = append(list.Issues, issue) 723 | if list.NewIssueNumber < *issue.Number { 724 | list.NewIssueNumber = *issue.Number 725 | } 726 | } 727 | 728 | if resp.NextPage == 0 { 729 | break 730 | } 731 | 732 | ilh.ih.options.Page = resp.NextPage 733 | } 734 | 735 | for { 736 | list.NewIssueNumber++ 737 | log.Printf("Finding new issue number for repo %s\n", repo) 738 | _, _, err := uncachedClient.Issues.Get(context.Background(), owner, repo, list.NewIssueNumber) 739 | if err != nil { 740 | break 741 | } 742 | } 743 | 744 | buf := bytes.Buffer{} 745 | err := issuesListMarkdown.Execute(&buf, list) 746 | if err != nil { 747 | return err 748 | } 749 | 750 | ilh.StaticFileHandler.Content = buf.Bytes() 751 | 752 | return ilh.StaticFileHandler.Open(name, fid, mode) 753 | } 754 | -------------------------------------------------------------------------------- /markform/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Markform is a form language for describing forms on top of Markdown. The format is inspired 4 | by Yevgeniy Brikman's https://github.com/brikis98/wmd notation with some modifications. 5 | The goal is to support RESTful self-documenting entities that can be easily read and modified 6 | using simple text editing and command line tools. More complex entities can be pretty printed 7 | in a rich format, such as HTML or PDF for in-depth study. 8 | 9 | The markform package provides tools to allow you to design markdown templates and link them to 10 | your structs for the purpose of marshaling and unmarshaling values of the struct. 11 | 12 | type Person struct { 13 | Name string `* = ___[50]` // text field maximum size 50 14 | Gender string `* = () male () female` // one of the specified values 15 | Student bool `* = []` // true/false, checked/not 16 | Affiliations []string ` = ,, ___` // list of any values from the user 17 | Description string ` = ___` // Unbounded, maybe multi-line string 18 | Education []string ` = [] elementary [] secondary [] post-secondary` 19 | } 20 | 21 | Note that the struct field tags provide the template of the suffix for each of the form elements. 22 | They include information, such as the type of element (radio, text, check, multi-valued user defined 23 | and multi-value predefined). It is subtle, but the template also indicates required field vs. optional 24 | with an asterisk. 25 | 26 | Along with the struct you can build a template for the entity using the templates package and helper 27 | functions provided in this package like this. The template gives you freedom to layout the information 28 | in a readable way and even add inline text that helps to guide the user. 29 | 30 | personTemplate := template.Must(template.New("person").Funcs(funcMap).Parse( 31 | `# {{ markform . "Name" }} - Personal Information 32 | 33 | Please ensure that the information is entered correctly. If you have any questions you can 34 | email the [support team](mailto:support@example.com). 35 | 36 | * {{ markform . "Description" }} 37 | * {{ markform . "Gender" }} 38 | * {{ markform . "Student" }} 39 | * {{ markform . "Affiliations" }} 40 | * {{ markform . "Education" }} 41 | 42 | Save this file to record any changes to the person record. 43 | 44 | `)) 45 | 46 | */ 47 | package markform 48 | -------------------------------------------------------------------------------- /markform/marshal.go: -------------------------------------------------------------------------------- 1 | package markform 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | var ( 13 | textPattern = regexp.MustCompilePOSIX(`(\*?) = ___((\[([0-9]+)\])?)`) 14 | boolCheckBoxPattern = regexp.MustCompile(`(\*??) = \[\]$`) 15 | radioPattern = regexp.MustCompile(`(\*??) = ((\(\) .*)+)`) 16 | checkboxPattern = regexp.MustCompile(`(\*??) = ((\[\] .*)+)`) 17 | listPattern = regexp.MustCompile(`(\*??) = ,, ___`) 18 | timePattern = regexp.MustCompile(`(\*??) = 2006-01-02T15:04:05Z`) 19 | ) 20 | 21 | // Marshal a specified field from a struct 22 | // in markform. 23 | func Marshal(v interface{}, fn string) string { 24 | t := reflect.TypeOf(v) 25 | 26 | f, ok := t.FieldByName(fn) 27 | 28 | if !ok { 29 | return "" 30 | } 31 | 32 | tag := string(f.Tag) 33 | 34 | if textPattern.MatchString(tag) { 35 | value := reflect.ValueOf(v).FieldByName(fn).String() 36 | components := textPattern.FindStringSubmatch(tag) 37 | if components[2] != "" { 38 | limit, _ := strconv.Atoi(components[4]) 39 | if len(value) > limit { 40 | value = value[:limit] 41 | } 42 | } 43 | return fmt.Sprintf("%s%s = %s___%s", fn, components[1], value, components[2]) 44 | } else if boolCheckBoxPattern.MatchString(tag) { 45 | value := reflect.ValueOf(v).FieldByName(fn).Bool() 46 | components := boolCheckBoxPattern.FindStringSubmatch(tag) 47 | checkbox := "[" 48 | if value { 49 | checkbox = checkbox + "x]" 50 | } else { 51 | checkbox = checkbox + "]" 52 | } 53 | return fmt.Sprintf("%s%s = %s", fn, components[1], checkbox) 54 | } else if radioPattern.MatchString(tag) { 55 | value := reflect.ValueOf(v).FieldByName(fn).String() 56 | tag = strings.Replace(tag, "() "+value, "(x) "+value, 1) 57 | return fn + tag 58 | } else if checkboxPattern.MatchString(tag) { 59 | length := reflect.ValueOf(v).FieldByName(fn).Len() 60 | for idx := 0; idx < length; idx++ { 61 | value := reflect.ValueOf(v).FieldByName(fn).Index(idx).String() 62 | tag = strings.Replace(tag, "[] "+value, "[x] "+value, 1) 63 | } 64 | return fn + tag 65 | } else if listPattern.MatchString(tag) { 66 | components := listPattern.FindStringSubmatch(tag) 67 | list := "" 68 | length := reflect.ValueOf(v).FieldByName(fn).Len() 69 | for idx := 0; idx < length; idx++ { 70 | value := reflect.ValueOf(v).FieldByName(fn).Index(idx).String() 71 | list = list + " ,, " + value 72 | } 73 | list = list + " ,, ___" 74 | 75 | return fmt.Sprintf("%s%s =%s", fn, components[1], list) 76 | } else if timePattern.MatchString(tag) { 77 | components := timePattern.FindStringSubmatch(tag) 78 | t := reflect.ValueOf(v).FieldByName(fn).Interface().(time.Time) 79 | return fmt.Sprintf("%s%s = %s", fn, components[1], t.Format(time.RFC3339)) 80 | } 81 | 82 | return "" 83 | } 84 | -------------------------------------------------------------------------------- /markform/marshal_test.go: -------------------------------------------------------------------------------- 1 | package markform 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "text/template" 7 | "time" 8 | ) 9 | 10 | func TestMarshal_Text(t *testing.T) { 11 | type astruct struct { 12 | textField string ` = ___` 13 | textFieldLimited string ` = ___[10]` 14 | textFieldRequired string `* = ___` 15 | } 16 | 17 | v := astruct{textField: "some value"} 18 | m := Marshal(v, "textField") 19 | if "textField = some value___" != m { 20 | t.Errorf("Unexpected value: %s != %s", m, "textField = some value___") 21 | } 22 | 23 | v = astruct{textFieldLimited: "some value"} 24 | m = Marshal(v, "textFieldLimited") 25 | if "textFieldLimited = some value___[10]" != m { 26 | t.Errorf("Unexpected value %s\n", m) 27 | } 28 | 29 | v = astruct{textFieldLimited: "same value longer than 10"} 30 | m = Marshal(v, "textFieldLimited") 31 | if "textFieldLimited = same value___[10]" != m { 32 | t.Errorf("Unexpected value %s\n", m) 33 | } 34 | 35 | v = astruct{textFieldRequired: "required"} 36 | m = Marshal(v, "textFieldRequired") 37 | if "textFieldRequired* = required___" != m { 38 | t.Errorf("Unexpected value %s\n", m) 39 | } 40 | 41 | v = astruct{textField: "line1\nline2"} 42 | m = Marshal(v, "textField") 43 | if "textField = line1\nline2___" != m { 44 | t.Errorf("Unexpected value %s\n", m) 45 | } 46 | } 47 | 48 | func TestMarshal_BasicCheckBox(t *testing.T) { 49 | type astruct struct { 50 | checkbox bool ` = []` 51 | requiredcheckbox bool `* = []` 52 | } 53 | 54 | v := astruct{checkbox: true} 55 | m := Marshal(v, "checkbox") 56 | if "checkbox = [x]" != m { 57 | t.Errorf("Unexpected value %s\n", m) 58 | } 59 | 60 | v = astruct{checkbox: false} 61 | m = Marshal(v, "checkbox") 62 | if "checkbox = []" != m { 63 | t.Errorf("Unexpected value %s\n", m) 64 | } 65 | 66 | v = astruct{requiredcheckbox: true} 67 | m = Marshal(v, "requiredcheckbox") 68 | if "requiredcheckbox* = [x]" != m { 69 | t.Errorf("Unexpected value %s\n", m) 70 | } 71 | } 72 | 73 | func TestMarshal_Radio(t *testing.T) { 74 | type astruct struct { 75 | radio string ` = () the fox () hare () other` 76 | requiredRadio string `* = () bard () troll () newt` 77 | } 78 | 79 | v := astruct{radio: "the fox"} 80 | m := Marshal(v, "radio") 81 | if "radio = (x) the fox () hare () other" != m { 82 | t.Errorf("Unexpected value %s\n", m) 83 | } 84 | 85 | v = astruct{requiredRadio: "troll"} 86 | m = Marshal(v, "requiredRadio") 87 | if "requiredRadio* = () bard (x) troll () newt" != m { 88 | t.Errorf("Unexpected value %s\n", m) 89 | } 90 | } 91 | 92 | func TestMarshal_CheckBox(t *testing.T) { 93 | type astruct struct { 94 | check []string ` = [] value1 [] value 2 [] some other value` 95 | requiredCheck []string `* = [] blue [] yellow [] red` 96 | } 97 | 98 | v := astruct{check: []string{"value1", "some other value"}} 99 | m := Marshal(v, "check") 100 | if "check = [x] value1 [] value 2 [x] some other value" != m { 101 | t.Errorf("Unexpected value %s\n", m) 102 | } 103 | } 104 | 105 | func TestMarshal_List(t *testing.T) { 106 | type astruct struct { 107 | list []string ` = ,, ___` 108 | requiredList []string `* = ,, ___` 109 | } 110 | 111 | v := astruct{list: []string{"value1", "another value", "something else"}} 112 | m := Marshal(v, "list") 113 | if "list = ,, value1 ,, another value ,, something else ,, ___" != m { 114 | t.Errorf("Unexpected value %s\n", m) 115 | } 116 | 117 | v = astruct{list: nil} 118 | m = Marshal(v, "list") 119 | if "list = ,, ___" != m { 120 | t.Errorf("Unexpected value %s\n", m) 121 | } 122 | 123 | v = astruct{requiredList: []string{"value"}} 124 | m = Marshal(v, "requiredList") 125 | if "requiredList* = ,, value ,, ___" != m { 126 | t.Errorf("Unexpected value %x\n", m) 127 | } 128 | } 129 | 130 | func TestMarshalTemplate(t *testing.T) { 131 | type Person struct { 132 | Name string `* = ___[50]` // text field maximum size 50 133 | Gender string `* = () male () female` // one of the specified values 134 | Student bool `* = []` // true/false, checked/not 135 | Affiliations []string ` = ,, ___` // list of any values from the user 136 | Description string ` = ___` // Unbounded, maybe multi-line string 137 | Education []string ` = [] elementary [] secondary [] post-secondary` 138 | DateOfBirth time.Time ` = 2006-01-02T15:04:05Z07:00` 139 | } 140 | 141 | funcMap := map[string]interface{}{"markform": Marshal} 142 | 143 | personTemplate := template.Must(template.New("person").Funcs(funcMap).Parse( 144 | 145 | `# {{ markform . "Name" }} - Personal Information 146 | 147 | Please ensure that the information is entered correctly. If you have any 148 | questions you can email the [support team](mailto:support@example.com). 149 | 150 | {{ markform . "Description" }} 151 | 152 | {{ markform . "Gender" }} 153 | 154 | {{ markform . "Student" }} 155 | 156 | {{ markform . "Affiliations" }} 157 | 158 | {{ markform . "Education" }} 159 | 160 | {{ markform . "DateOfBirth" }} 161 | 162 | Save this file to record any changes to the person record. 163 | 164 | `)) 165 | 166 | dob, err := time.Parse(time.RFC3339, "2010-01-02T15:04:05Z") 167 | if err != nil { 168 | panic(err) 169 | } 170 | 171 | person := Person{Name: "John Doe", Gender: "male", Student: true, Affiliations: []string{"Chess Club"}, Description: "Conscientious student", Education: []string{"elementary", "secondary"}, DateOfBirth: dob} 172 | buf := bytes.Buffer{} 173 | err = personTemplate.Execute(&buf, person) 174 | if err != nil { 175 | t.Error(err) 176 | } 177 | 178 | expected := `# Name* = John Doe___[50] - Personal Information 179 | 180 | Please ensure that the information is entered correctly. If you have any 181 | questions you can email the [support team](mailto:support@example.com). 182 | 183 | Description = Conscientious student___ 184 | 185 | Gender* = (x) male () female 186 | 187 | Student* = [x] 188 | 189 | Affiliations = ,, Chess Club ,, ___ 190 | 191 | Education = [x] elementary [x] secondary [] post-secondary 192 | 193 | DateOfBirth = 2010-01-02T15:04:05Z 194 | 195 | Save this file to record any changes to the person record. 196 | 197 | ` 198 | 199 | if expected != string(buf.Bytes()) { 200 | t.Errorf("Unexpected value: %s\n", buf.Bytes()) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /markform/unmarshal.go: -------------------------------------------------------------------------------- 1 | package markform 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/russross/blackfriday/v2" 11 | ) 12 | 13 | var ( 14 | formVarPattern = regexp.MustCompile(`(?s)(\w+)(\*??) =(.*)`) 15 | ) 16 | 17 | func Unmarshal(tree *blackfriday.Node, v interface{}) error { 18 | t := reflect.Indirect(reflect.ValueOf(v)).Type() 19 | tree.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus { 20 | if node.Type == blackfriday.Text { 21 | groups := formVarPattern.FindStringSubmatch(string(node.Literal)) 22 | if groups != nil { 23 | fn := groups[1] 24 | value := groups[3] 25 | value = strings.TrimSpace(value) 26 | f, ok := t.FieldByName(fn) 27 | if ok { 28 | // TODO handle required fields 29 | fv := reflect.Indirect(reflect.ValueOf(v)).FieldByName(fn) 30 | if boolCheckBoxPattern.MatchString(string(f.Tag)) { 31 | if strings.HasPrefix(value, "[x]") { 32 | fv.SetBool(true) 33 | } else if strings.HasPrefix(value, "[]") { 34 | fv.SetBool(false) 35 | } 36 | } else if textPattern.MatchString(string(f.Tag)) { 37 | endOfText := strings.Index(value, "___") 38 | if endOfText != -1 { 39 | value = value[:endOfText] 40 | } else { 41 | node = node.Parent.Next 42 | 43 | for node != nil && node.Type != blackfriday.HorizontalRule { 44 | nextValue := string(node.Literal) 45 | value = value + nextValue 46 | node = node.Next 47 | } 48 | } 49 | g := textPattern.FindStringSubmatch(string(f.Tag)) 50 | if g[2] != "" { 51 | size, _ := strconv.Atoi(g[4]) 52 | if len(value) > size { 53 | value = value[:size] 54 | } 55 | } 56 | value = strings.TrimSpace(value) 57 | fv.SetString(value) 58 | } else if radioPattern.MatchString(string(f.Tag)) { 59 | g := radioPattern.FindStringSubmatch(string(f.Tag)) 60 | options := strings.Split(g[2], "() ") 61 | for _, option := range options { 62 | option = strings.TrimRight(option, " ") 63 | if option != "" && strings.Contains(value, "(x) "+option) { 64 | fv.SetString(option) 65 | break 66 | } 67 | } 68 | } else if checkboxPattern.MatchString(string(f.Tag)) { 69 | g := checkboxPattern.FindStringSubmatch(string(f.Tag)) 70 | options := strings.Split(g[2], "[] ") 71 | fv.Set(reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf("")), 0, 0)) 72 | for _, option := range options { 73 | option = strings.TrimRight(option, " ") 74 | if option != "" && strings.Contains(value, "[x] "+option) { 75 | fv.Set(reflect.Append(fv, reflect.ValueOf(option))) 76 | } 77 | } 78 | } else if listPattern.MatchString(string(f.Tag)) { 79 | fv.Set(reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf("")), 0, 0)) 80 | listitems := strings.Split(value, ",, ") 81 | for _, listitem := range listitems { 82 | if listitem == "" || listitem == "___" { 83 | continue 84 | } 85 | 86 | // Trim the trailing space 87 | listitem = listitem[:len(listitem)-1] 88 | 89 | fv.Set(reflect.Append(fv, reflect.ValueOf(listitem))) 90 | } 91 | } else if timePattern.MatchString(string(f.Tag)) { 92 | value = strings.Trim(value, " ") 93 | t, err := time.Parse(time.RFC3339, value) 94 | if err == nil { 95 | fv.Set(reflect.ValueOf(t)) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | return blackfriday.GoToNext 102 | }) 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /markform/unmarshal_test.go: -------------------------------------------------------------------------------- 1 | package markform 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/russross/blackfriday/v2" 8 | ) 9 | 10 | func TestUnmarshalDocument(t *testing.T) { 11 | type Person struct { 12 | Name string `* = ___[50]` // text field maximum size 50 13 | Gender string `* = () male () female` // one of the specified values 14 | Student bool `* = []` // true/false, checked/not 15 | Affiliations []string ` = ,, ___` // list of any values from the user 16 | Description string ` = ___` // Unbounded, maybe multi-line string 17 | Education []string ` = [] elementary [] secondary [] post-secondary` 18 | DateOfBirth time.Time ` = 2006-01-02T15:04:05Z` 19 | } 20 | 21 | document := 22 | `# Name* = John Doe___[50] - Personal Information 23 | 24 | Please ensure that the information is entered correctly. If you have any 25 | questions you can email the [support team](mailto:support@example.com). 26 | 27 | Description = Conscientious 28 | student___ 29 | 30 | * Gender* = (x) male () female 31 | * Student* = [x] 32 | * Affiliations = ,, Chess Club ,, ___ 33 | * Education = [x] elementary [x] secondary [] post-secondary 34 | * DateOfBirth = 2010-01-02T15:04:05Z 35 | 36 | Save this file to record any changes to the person record. 37 | 38 | ` 39 | 40 | person := Person{} 41 | md := blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)) 42 | tree := md.Parse([]byte(document)) 43 | err := Unmarshal(tree, &person) 44 | if err != nil { 45 | t.Error(err) 46 | } 47 | 48 | if !person.Student { 49 | t.Errorf("Student flag not set") 50 | } 51 | 52 | if person.Description != "Conscientious\nstudent" { 53 | t.Errorf("Unexpected description: %s\n", person.Description) 54 | } 55 | 56 | if person.Name != "John Doe" { 57 | t.Errorf("Unexpected name: %s\n", person.Name) 58 | } 59 | 60 | if person.Gender != "male" { 61 | t.Errorf("Unexpected gender: %s\n", person.Gender) 62 | } 63 | 64 | if len(person.Education) != 2 { 65 | t.Errorf("Expected two education entries\n") 66 | } 67 | if person.Education[0] != "elementary" && person.Education[1] != "secondary" { 68 | t.Errorf("Elementary not found in education: %v\n", person.Education) 69 | } 70 | if person.Education[0] != "secondary" && person.Education[1] != "secondary" { 71 | t.Errorf("Secondary not found in education: %v\n", person.Education) 72 | } 73 | 74 | if len(person.Affiliations) != 1 { 75 | t.Errorf("Expected one affiliation\n") 76 | } 77 | if person.Affiliations[0] != "Chess Club" { 78 | t.Errorf("Unexpected affiliation: %v\n", person.Affiliations[0]) 79 | } 80 | 81 | if person.DateOfBirth.Format(time.RFC3339) != "2010-01-02T15:04:05Z" { 82 | t.Errorf("Unexpected date of birth: %v\n", person.DateOfBirth) 83 | } 84 | 85 | document = 86 | `# Name* = John Doe___[50] - Personal Information 87 | 88 | Please ensure that the information is entered correctly. If you have any 89 | questions you can email the [support team](mailto:support@example.com). 90 | 91 | Description = 92 | ` + "```" + ` 93 | Conscientious 94 | student 95 | 96 | * more info 97 | ` + "```" + ` 98 | ___ 99 | 100 | * Gender* = (x) male () female 101 | * Student* = [x] 102 | * Affiliations = ,, Chess Club ,, ___ 103 | * Education = [x] elementary [x] secondary [] post-secondary 104 | * DateOfBirth = 2010-01-02T15:04:05Z 105 | 106 | Save this file to record any changes to the person record. 107 | 108 | ` 109 | 110 | person = Person{} 111 | md = blackfriday.New(blackfriday.WithExtensions(blackfriday.FencedCode)) 112 | tree = md.Parse([]byte(document)) 113 | err = Unmarshal(tree, &person) 114 | if err != nil { 115 | t.Error(err) 116 | } 117 | 118 | if person.Description != "Conscientious\nstudent\n\n* more info" { 119 | t.Errorf("Unexpected description: %s\n", person.Description) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /repos.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log" 8 | "path" 9 | "strings" 10 | "sync" 11 | "text/template" 12 | 13 | "github.com/Harvey-OS/ninep/protocol" 14 | "github.com/google/go-github/github" 15 | "github.com/russross/blackfriday/v2" 16 | "github.com/sirnewton01/ghfs/dynamic" 17 | "github.com/sirnewton01/ghfs/markform" 18 | ) 19 | 20 | var ( 21 | repoMarkdown = template.Must(template.New("repository").Funcs(funcMap).Parse( 22 | `# {{ .Repository.FullName }} {{ if .Repository.GetFork }}[{{ .Repository.GetSource.FullName }}](../../{{ .Repository.GetSource.Owner.Login }}/{{ .Repository.GetSource.Name }}/repo.md){{ end }} 23 | 24 | * {{ markform .Form "Description" }} 25 | * {{ markform .Form "Starred" }} 26 | * {{ markform .Form "Notifications" }} 27 | * Created: {{ .Repository.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} 28 | * Watchers: {{ .Repository.WatchersCount }} 29 | * Stars: {{ .Repository.StargazersCount }} 30 | * Forks: {{ .Repository.ForksCount }} 31 | * Default branch: {{ .Repository.DefaultBranch }} 32 | * Pushed: {{ .Repository.PushedAt.Format "2006-01-02T15:04:05Z07:00" }} 33 | * Commit: {{ .Branch.GetCommit.SHA }} {{ .Branch.GetCommit.Commit.Author.Date.Format "2006-01-02T15:04:05Z07:00" }} 34 | 35 | git clone {{ .Repository.CloneURL }} 36 | `)) 37 | 38 | userMarkdown = template.Must(template.New("user").Funcs(funcMap).Parse( 39 | `# {{ .User.Name }} - {{ .User.Login }} 40 | 41 | * Location: {{ .User.Location }} 42 | * Email: {{ .User.Email }} 43 | 44 | {{ .User.Bio }} 45 | 46 | * Created: {{ .User.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} 47 | * Updated: {{ .User.UpdatedAt.Format "2006-01-02T15:04:05Z07:00" }} 48 | * Followers: {{ .User.Followers }} 49 | * {{ markform .Form "Follow" }} 50 | `)) 51 | 52 | orgMarkdown = template.Must(template.New("org").Funcs(funcMap).Parse( 53 | `# {{ .Name }} - {{ .Login }} 54 | 55 | * Location: {{ .Location }} 56 | * Email: {{ .Email }} 57 | 58 | {{ .Description }} 59 | 60 | * Created: {{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }} 61 | * Updated: {{ .UpdatedAt.Format "2006-01-02T15:04:05Z07:00" }} 62 | * Followers: {{ .Followers }} 63 | `)) 64 | 65 | starMarkdown = template.Must(template.New("star").Funcs(funcMap).Parse( 66 | `# Starred repositories 67 | 68 | {{ range . }} * repos/{{ .Repository.Owner.Login }}/{{ .Repository.Name }} 69 | {{ end }} 70 | `)) 71 | ) 72 | 73 | type repoMarkdownForm struct { 74 | Description string ` = ___` 75 | } 76 | 77 | type repoMarkdownModel struct { 78 | Form repoMarkdownForm 79 | Rest *github.Repository 80 | Branch *github.Branch 81 | } 82 | 83 | // ReposHandler handles the repos directory dynamically loading 84 | // owners as they are looked up so that they show up in directory 85 | // listings afterwards. If the connection is authenticated then 86 | // the authenticated user shows up right away. 87 | type ReposHandler struct { 88 | dynamic.BasicDirHandler 89 | } 90 | 91 | func (rh *ReposHandler) WalkChild(name string, child string) (int, error) { 92 | idx, err := rh.BasicDirHandler.WalkChild(name, child) 93 | 94 | if idx == -1 { 95 | log.Printf("Checking if owner %v exists\n", child) 96 | 97 | idx, err = NewOwnerHandler(child) 98 | if idx == -1 { 99 | return -1, fmt.Errorf("Child not found: %s", child) 100 | } 101 | } 102 | 103 | return idx, err 104 | } 105 | 106 | func (rh *ReposHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 107 | if offset == 0 && count > 0 && currentUser != "" { 108 | _, err := NewOwnerHandler(currentUser) 109 | if err != nil { 110 | return []byte{}, err 111 | } 112 | 113 | options := github.ListOptions{PerPage: 10} 114 | // Add following 115 | for { 116 | log.Printf("Listing following for %s\n", currentUser) 117 | users, resp, err := client.Users.ListFollowing(context.Background(), currentUser, &options) 118 | 119 | if err != nil { 120 | return []byte{}, err 121 | } 122 | 123 | if len(users) == 0 { 124 | break 125 | } 126 | 127 | for _, user := range users { 128 | log.Printf("Adding following %v\n", *user.Login) 129 | _, err = NewOwnerHandler(*user.Login) 130 | if err != nil { 131 | return []byte{}, err 132 | } 133 | } 134 | 135 | if resp.NextPage == 0 { 136 | break 137 | } 138 | options.Page = resp.NextPage 139 | } 140 | 141 | } 142 | return rh.BasicDirHandler.Read(name, fid, offset, count) 143 | } 144 | 145 | func (rh *ReposHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 146 | return 0, fmt.Errorf("Creating a new user or organization is not supported.") 147 | } 148 | 149 | func NewOwnerHandler(owner string) (int, error) { 150 | // Skip hidden files as they are not owners on GitHub 151 | if strings.HasPrefix(owner, ".") { 152 | return -1, nil 153 | } 154 | 155 | idx := server.AddFileEntry(path.Join("/repos", owner), &OwnerHandler{dynamic.BasicDirHandler{server, nil}}) 156 | 157 | // Check if it is an organization 158 | log.Printf("Checking whether owner %s is an organization\n", owner) 159 | org, _, err := client.Organizations.Get(context.Background(), owner) 160 | if err != nil { 161 | // It could be a user 162 | log.Printf("Checking whether owner %s is a user\n", owner) 163 | user, _, err := client.Users.Get(context.Background(), owner) 164 | if err != nil { 165 | return -1, err 166 | } 167 | NewUserHandler(*user.Login) 168 | return idx, nil 169 | } 170 | NewOrgHandler(*org.Login) 171 | return idx, nil 172 | } 173 | 174 | // OwnerHandler handles a owner within the repos directory listing 175 | // out all of their repositories. 176 | type OwnerHandler struct { 177 | dynamic.BasicDirHandler 178 | } 179 | 180 | func (oh *OwnerHandler) WalkChild(name string, child string) (int, error) { 181 | idx, err := oh.BasicDirHandler.WalkChild(name, child) 182 | 183 | // No hidden files as repo names on github 184 | // Also, Mac probes heavily for them costing 185 | // significant performance. 186 | if idx == -1 && strings.HasPrefix(child, ".") { 187 | return idx, err 188 | } 189 | 190 | if idx == -1 { 191 | owner := path.Base(name) 192 | err = oh.refresh(owner) 193 | if err != nil { 194 | return -1, err 195 | } 196 | } 197 | 198 | return oh.BasicDirHandler.WalkChild(name, child) 199 | } 200 | 201 | func (oh *OwnerHandler) refresh(owner string) error { 202 | log.Printf("Listing all of the repos for owner %v\n", owner) 203 | options := github.RepositoryListOptions{ 204 | ListOptions: github.ListOptions{PerPage: 100}, 205 | } 206 | 207 | for { 208 | log.Printf("Listing repositories owned by %s\n", owner) 209 | repos, resp, err := client.Repositories.List(context.Background(), owner, &options) 210 | if err != nil { 211 | return err 212 | } 213 | 214 | if len(repos) == 0 { 215 | return nil 216 | } 217 | 218 | for _, repo := range repos { 219 | log.Printf("Adding repo %v\n", *repo.Name) 220 | server.AddFileEntry(path.Join("/repos", owner, *repo.Name), &dynamic.BasicDirHandler{server, nil}) 221 | repoPath := path.Join("/repos", owner, *repo.Name) 222 | NewRepoOverviewHandler(repoPath) 223 | NewIssuesHandler(repoPath) 224 | NewRepoReadmeHandler(repoPath) 225 | } 226 | 227 | if resp.NextPage == 0 { 228 | break 229 | } 230 | options.Page = resp.NextPage 231 | } 232 | 233 | return nil 234 | } 235 | 236 | func (oh *OwnerHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 237 | if offset == 0 && count > 0 { 238 | err := oh.refresh(path.Base(name)) 239 | if err != nil { 240 | return []byte{}, err 241 | } 242 | } 243 | 244 | return oh.BasicDirHandler.Read(name, fid, offset, count) 245 | } 246 | 247 | func (oh *OwnerHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 248 | return 0, fmt.Errorf("Creating repos is not supported.") 249 | } 250 | 251 | // UserHandler handles the displaying and updating of the 252 | // 0user.md for a user. 253 | type UserHandler struct { 254 | User *github.User 255 | Form struct { 256 | Follow bool ` = []` 257 | } 258 | 259 | readbuf *bytes.Buffer 260 | writefid protocol.FID 261 | writebuf *bytes.Buffer 262 | mu sync.Mutex 263 | } 264 | 265 | func NewUserHandler(name string) { 266 | server.AddFileEntry(path.Join("/repos", name, "0user.md"), &UserHandler{readbuf: &bytes.Buffer{}}) 267 | } 268 | 269 | func (uh *UserHandler) WalkChild(name string, child string) (int, error) { 270 | return -1, fmt.Errorf("No children of the 0user.md file") 271 | } 272 | 273 | func (uh *UserHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 274 | username := path.Base(path.Dir(name)) 275 | 276 | log.Printf("Reading user %s\n", username) 277 | u, _, err := client.Users.Get(context.Background(), username) 278 | if err != nil { 279 | return err 280 | } 281 | 282 | following, _, err := client.Users.IsFollowing(context.Background(), "", username) 283 | if err != nil { 284 | return err 285 | } 286 | 287 | uh.mu.Lock() 288 | defer uh.mu.Unlock() 289 | 290 | uh.User = u 291 | uh.Form.Follow = following 292 | 293 | if mode == protocol.OREAD { 294 | buf := bytes.Buffer{} 295 | err = userMarkdown.Execute(&buf, uh) 296 | if err != nil { 297 | return err 298 | } 299 | uh.readbuf = &buf 300 | } 301 | 302 | if mode&protocol.ORDWR != 0 || mode&protocol.OWRITE != 0 { 303 | if uh.writefid != 0 { 304 | return fmt.Errorf("User metadata doesn't support concurrent writes") 305 | } 306 | 307 | uh.writefid = fid 308 | uh.writebuf = &bytes.Buffer{} 309 | } 310 | 311 | return nil 312 | } 313 | 314 | func (uh *UserHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 315 | uh.mu.Lock() 316 | defer uh.mu.Unlock() 317 | 318 | if fid != uh.writefid { 319 | return int64(len(buf)), nil 320 | } 321 | 322 | // TODO consider offset 323 | length, err := uh.writebuf.Write(buf) 324 | if err != nil { 325 | return int64(length), err 326 | } 327 | 328 | return int64(length), nil 329 | } 330 | 331 | func (uh *UserHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 332 | uh.mu.Lock() 333 | defer uh.mu.Unlock() 334 | 335 | if offset >= int64(uh.readbuf.Len()) { 336 | return []byte{}, nil // TODO should an error be returned? 337 | } 338 | 339 | if offset+count >= int64(uh.readbuf.Len()) { 340 | return uh.readbuf.Bytes()[offset:], nil 341 | } 342 | 343 | return uh.readbuf.Bytes()[offset : offset+count], nil 344 | } 345 | 346 | func (uh *UserHandler) CreateChild(name string, child string) (int, error) { 347 | return -1, fmt.Errorf("Creating a child of a 0user.md is not supported") 348 | } 349 | 350 | func (uh *UserHandler) Stat(name string) (protocol.Dir, error) { 351 | uh.mu.Lock() 352 | defer uh.mu.Unlock() 353 | 354 | // There's only one version and it is always a file 355 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTFILE}, Length: uint64(uh.readbuf.Len())}, nil 356 | } 357 | 358 | func (uh *UserHandler) Wstat(name string, dir protocol.Dir) error { 359 | uh.mu.Lock() 360 | defer uh.mu.Unlock() 361 | 362 | uh.writebuf.Truncate(int(dir.Length)) 363 | return nil 364 | } 365 | 366 | func (uh *UserHandler) Remove(name string) error { 367 | return fmt.Errorf("Removing 0user.md isn't supported.") 368 | } 369 | 370 | func (uh *UserHandler) Clunk(name string, fid protocol.FID) error { 371 | username := path.Base(path.Dir(name)) 372 | 373 | uh.mu.Lock() 374 | defer uh.mu.Unlock() 375 | 376 | if fid != uh.writefid { 377 | return nil 378 | } 379 | uh.writefid = 0 380 | 381 | // No bytes were written this time, leave it alone 382 | if len(uh.writebuf.Bytes()) == 0 { 383 | return nil 384 | } 385 | 386 | newuh := &UserHandler{} 387 | md := blackfriday.New() 388 | tree := md.Parse(uh.writebuf.Bytes()) 389 | err := markform.Unmarshal(tree, &newuh.Form) 390 | if err != nil { 391 | return err 392 | } 393 | 394 | if newuh.Form.Follow != uh.Form.Follow { 395 | if newuh.Form.Follow { 396 | log.Printf("Following %s\n", username) 397 | _, err := client.Users.Follow(context.Background(), username) 398 | if err != nil { 399 | return err 400 | } 401 | } else { 402 | log.Printf("Unfollowing %s\n", username) 403 | _, err := client.Users.Unfollow(context.Background(), username) 404 | if err != nil { 405 | return err 406 | } 407 | } 408 | } 409 | 410 | return nil 411 | } 412 | 413 | func NewOrgHandler(name string) { 414 | server.AddFileEntry(path.Join("/repos", name, "0org.md"), &OrgHandler{StaticFileHandler: dynamic.StaticFileHandler{[]byte{}}}) 415 | } 416 | 417 | // UserHandler handles the displaying and updating of the 418 | // 0org.md for a user. 419 | type OrgHandler struct { 420 | dynamic.StaticFileHandler 421 | mu sync.Mutex 422 | } 423 | 424 | func (oh *OrgHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 425 | user := path.Base(path.Dir(name)) 426 | 427 | log.Printf("Reading user %s\n", user) 428 | 429 | oh.mu.Lock() 430 | defer oh.mu.Unlock() 431 | 432 | u, _, err := client.Users.Get(context.Background(), user) 433 | if err != nil { 434 | return err 435 | } 436 | 437 | buf := bytes.Buffer{} 438 | err = userMarkdown.Execute(&buf, u) 439 | if err != nil { 440 | return err 441 | } 442 | 443 | oh.StaticFileHandler.Content = buf.Bytes() 444 | 445 | return oh.StaticFileHandler.Open(name, fid, mode) 446 | } 447 | 448 | // RepoOverviewHandler handles the displaying and updating of the 449 | // repo.md for a repo. 450 | type RepoOverviewHandler struct { 451 | Repository *github.Repository 452 | Branch *github.Branch 453 | Form struct { 454 | Description string ` = ___` 455 | Starred bool ` = []` 456 | Notifications string ` = () not watching () watching () ignoring` 457 | } 458 | 459 | readbuf *bytes.Buffer 460 | writefid protocol.FID 461 | writebuf *bytes.Buffer 462 | mu sync.Mutex 463 | } 464 | 465 | func NewRepoOverviewHandler(repoPath string) { 466 | server.AddFileEntry(path.Join(repoPath, "repo.md"), &RepoOverviewHandler{readbuf: &bytes.Buffer{}}) 467 | } 468 | 469 | func (roh *RepoOverviewHandler) WalkChild(name string, child string) (int, error) { 470 | return -1, fmt.Errorf("No children of the repo.md file") 471 | } 472 | 473 | func (roh *RepoOverviewHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 474 | owner := path.Base(path.Dir(path.Dir(name))) 475 | repo := path.Base(path.Dir(name)) 476 | 477 | log.Printf("Reading repository %s/%s\n", owner, repo) 478 | 479 | roh.mu.Lock() 480 | defer roh.mu.Unlock() 481 | 482 | r, _, err := client.Repositories.Get(context.Background(), owner, repo) 483 | if err != nil { 484 | return err 485 | } 486 | 487 | b, _, err := client.Repositories.GetBranch(context.Background(), owner, repo, *r.DefaultBranch) 488 | if err != nil { 489 | return err 490 | } 491 | 492 | s, _, err := client.Activity.IsStarred(context.Background(), owner, repo) 493 | if err != nil { 494 | return err 495 | } 496 | 497 | subs, _, err := client.Activity.GetRepositorySubscription(context.Background(), owner, repo) 498 | if err != nil { 499 | return err 500 | } 501 | 502 | roh.Repository = r 503 | roh.Branch = b 504 | 505 | if r.Description != nil { 506 | roh.Form.Description = *r.Description 507 | } 508 | roh.Form.Starred = s 509 | if subs == nil || (!*subs.Subscribed && !*subs.Ignored) { 510 | roh.Form.Notifications = "not watching" 511 | } else if *subs.Subscribed { 512 | roh.Form.Notifications = "watching" 513 | } else if *subs.Ignored { 514 | roh.Form.Notifications = "ignoring" 515 | } 516 | 517 | if mode == protocol.OREAD { 518 | buf := bytes.Buffer{} 519 | err = repoMarkdown.Execute(&buf, roh) 520 | if err != nil { 521 | return err 522 | } 523 | roh.readbuf = &buf 524 | } 525 | 526 | if mode&protocol.ORDWR != 0 || mode&protocol.OWRITE != 0 { 527 | if roh.writefid != 0 { 528 | return fmt.Errorf("Repo metadata doesn't support concurrent writes") 529 | } 530 | 531 | roh.writefid = fid 532 | roh.writebuf = &bytes.Buffer{} 533 | } 534 | 535 | return nil 536 | } 537 | 538 | func (roh *RepoOverviewHandler) Write(name string, fid protocol.FID, offset int64, buf []byte) (int64, error) { 539 | roh.mu.Lock() 540 | defer roh.mu.Unlock() 541 | 542 | if fid != roh.writefid { 543 | return int64(len(buf)), nil 544 | } 545 | 546 | // TODO consider offset 547 | length, err := roh.writebuf.Write(buf) 548 | if err != nil { 549 | return int64(length), err 550 | } 551 | 552 | return int64(length), nil 553 | } 554 | 555 | func (roh *RepoOverviewHandler) Read(name string, fid protocol.FID, offset int64, count int64) ([]byte, error) { 556 | roh.mu.Lock() 557 | defer roh.mu.Unlock() 558 | 559 | if offset >= int64(roh.readbuf.Len()) { 560 | return []byte{}, nil // TODO should an error be returned? 561 | } 562 | 563 | if offset+count >= int64(roh.readbuf.Len()) { 564 | return roh.readbuf.Bytes()[offset:], nil 565 | } 566 | 567 | return roh.readbuf.Bytes()[offset : offset+count], nil 568 | } 569 | 570 | func (roh *RepoOverviewHandler) CreateChild(name string, child string) (int, error) { 571 | return -1, fmt.Errorf("Creating a child of a repo.md is not supported") 572 | } 573 | 574 | func (roh *RepoOverviewHandler) Stat(name string) (protocol.Dir, error) { 575 | roh.mu.Lock() 576 | defer roh.mu.Unlock() 577 | 578 | // There's only one version and it is always a file 579 | return protocol.Dir{QID: protocol.QID{Version: 0, Type: protocol.QTFILE}, Length: uint64(roh.readbuf.Len())}, nil 580 | } 581 | 582 | func (roh *RepoOverviewHandler) Wstat(name string, dir protocol.Dir) error { 583 | roh.mu.Lock() 584 | defer roh.mu.Unlock() 585 | 586 | roh.writebuf.Truncate(int(dir.Length)) 587 | return nil 588 | } 589 | 590 | func (roh *RepoOverviewHandler) Remove(name string) error { 591 | return fmt.Errorf("Removing repo.md isn't supported.") 592 | } 593 | 594 | func (roh *RepoOverviewHandler) Clunk(name string, fid protocol.FID) error { 595 | owner := path.Base(path.Dir(path.Dir(name))) 596 | repo := path.Base(path.Dir(name)) 597 | 598 | roh.mu.Lock() 599 | defer roh.mu.Unlock() 600 | 601 | if fid != roh.writefid { 602 | return nil 603 | } 604 | roh.writefid = 0 605 | 606 | // No bytes were written this time, leave it alone 607 | if len(roh.writebuf.Bytes()) == 0 { 608 | return nil 609 | } 610 | 611 | newroh := &RepoOverviewHandler{} 612 | md := blackfriday.New() 613 | tree := md.Parse(roh.writebuf.Bytes()) 614 | err := markform.Unmarshal(tree, &newroh.Form) 615 | if err != nil { 616 | return err 617 | } 618 | 619 | if newroh.Form.Description != roh.Form.Description { 620 | roh.Repository.Description = &newroh.Form.Description 621 | log.Printf("Setting repository description for %s\n", repo) 622 | _, _, err := client.Repositories.Edit(context.Background(), owner, repo, roh.Repository) 623 | if err != nil { 624 | return err 625 | } 626 | } 627 | 628 | if newroh.Form.Starred != roh.Form.Starred { 629 | if newroh.Form.Starred { 630 | log.Printf("Starring repository %s\n", repo) 631 | _, err := client.Activity.Star(context.Background(), owner, repo) 632 | if err != nil { 633 | return err 634 | } 635 | } else { 636 | log.Printf("Unstarring repository %s\n", repo) 637 | _, err := client.Activity.Unstar(context.Background(), owner, repo) 638 | if err != nil { 639 | return err 640 | } 641 | } 642 | } 643 | 644 | subs := &github.Subscription{} 645 | f := false 646 | t := true 647 | 648 | if newroh.Form.Notifications != roh.Form.Notifications { 649 | log.Printf("Changing repository subscription for %s\n", repo) 650 | if newroh.Form.Notifications == "not watching" { 651 | subs.Subscribed = &f 652 | subs.Ignored = &f 653 | _, _, err := client.Activity.SetRepositorySubscription(context.Background(), owner, repo, subs) 654 | if err != nil { 655 | return err 656 | } 657 | 658 | _, err = client.Activity.DeleteRepositorySubscription(context.Background(), owner, repo) 659 | if err != nil { 660 | return err 661 | } 662 | } else if newroh.Form.Notifications == "watching" { 663 | subs.Subscribed = &t 664 | subs.Ignored = &f 665 | _, _, err := client.Activity.SetRepositorySubscription(context.Background(), owner, repo, subs) 666 | if err != nil { 667 | return err 668 | } 669 | } else if newroh.Form.Notifications == "ignoring" { 670 | subs.Subscribed = &f 671 | subs.Ignored = &t 672 | _, _, err := client.Activity.SetRepositorySubscription(context.Background(), owner, repo, subs) 673 | if err != nil { 674 | return err 675 | } 676 | } 677 | } 678 | 679 | return nil 680 | } 681 | 682 | type RepoReadmeHandler struct { 683 | dynamic.StaticFileHandler 684 | mu sync.Mutex 685 | } 686 | 687 | func NewRepoReadmeHandler(repoPath string) { 688 | server.AddFileEntry(path.Join(repoPath, "README.md"), &RepoReadmeHandler{StaticFileHandler: dynamic.StaticFileHandler{[]byte{}}}) 689 | } 690 | 691 | func (rrh *RepoReadmeHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 692 | owner := path.Base(path.Dir(path.Dir(name))) 693 | repo := path.Base(path.Dir(name)) 694 | 695 | rrh.mu.Lock() 696 | defer rrh.mu.Unlock() 697 | 698 | log.Printf("Getting project readme for %s\n", repo) 699 | readme, _, err := client.Repositories.GetReadme(context.Background(), owner, repo, nil) 700 | if err != nil { 701 | return err 702 | } 703 | 704 | c, err := readme.GetContent() 705 | if err != nil { 706 | return err 707 | } 708 | 709 | rrh.StaticFileHandler.Content = []byte(c) 710 | 711 | return rrh.StaticFileHandler.Open(name, fid, mode) 712 | } 713 | 714 | type StarredReposHandler struct { 715 | dynamic.StaticFileHandler 716 | mu sync.Mutex 717 | } 718 | 719 | func NewStarredReposHandler() { 720 | server.AddFileEntry(path.Join("/stars.md"), &StarredReposHandler{StaticFileHandler: dynamic.StaticFileHandler{[]byte{}}}) 721 | } 722 | 723 | func (srh *StarredReposHandler) Open(name string, fid protocol.FID, mode protocol.Mode) error { 724 | srh.mu.Lock() 725 | defer srh.mu.Unlock() 726 | 727 | log.Printf("Retrieving the current user's starred repositories\n") 728 | stars, _, err := client.Activity.ListStarred(context.Background(), currentUser, nil) 729 | if err != nil { 730 | return err 731 | } 732 | 733 | buf := bytes.Buffer{} 734 | err = starMarkdown.Execute(&buf, stars) 735 | if err != nil { 736 | return err 737 | } 738 | 739 | srh.StaticFileHandler.Content = buf.Bytes() 740 | 741 | return srh.StaticFileHandler.Open(name, fid, mode) 742 | } 743 | --------------------------------------------------------------------------------