├── LICENSE ├── README.md ├── events.go ├── fs ├── copier.go ├── events.go ├── fs.go ├── fs_test.go ├── ioutil.go ├── notifications.go ├── schema.go └── users.go ├── githubapi ├── githubapi.go ├── notifications.go └── reactions.go ├── go.mod ├── issues.go ├── maintner └── maintner.go └── options.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Dmitri Shuralyov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | issues 2 | ====== 3 | 4 | [![Go Reference](https://pkg.go.dev/badge/github.com/shurcooL/issues.svg)](https://pkg.go.dev/github.com/shurcooL/issues) 5 | 6 | Package issues provides an issues service definition. 7 | 8 | Installation 9 | ------------ 10 | 11 | ```sh 12 | go get github.com/shurcooL/issues 13 | ``` 14 | 15 | Directories 16 | ----------- 17 | 18 | | Path | Synopsis | 19 | |----------------------------------------------------------------------|-----------------------------------------------------------------------------------------| 20 | | [fs](https://pkg.go.dev/github.com/shurcooL/issues/fs) | Package fs implements issues.Service using a virtual filesystem. | 21 | | [githubapi](https://pkg.go.dev/github.com/shurcooL/issues/githubapi) | Package githubapi implements issues.Service using GitHub API clients. | 22 | | [maintner](https://pkg.go.dev/github.com/shurcooL/issues/maintner) | Package maintner implements a read-only issues.Service using a x/build/maintner corpus. | 23 | 24 | License 25 | ------- 26 | 27 | - [MIT License](LICENSE) 28 | -------------------------------------------------------------------------------- /events.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | import ( 4 | "time" 5 | 6 | "dmitri.shuralyov.com/state" 7 | "github.com/shurcooL/users" 8 | ) 9 | 10 | // Event represents an event that occurred around an issue. 11 | type Event struct { 12 | ID uint64 13 | Actor users.User 14 | CreatedAt time.Time 15 | Type EventType 16 | Close Close // Close is only specified for Closed events. 17 | Rename *Rename // Rename is only provided for Renamed events. 18 | Label *Label // Label is only provided for Labeled and Unlabeled events. 19 | Milestone *Milestone // Milestone is only provided for Milestoned and Demilestoned events. 20 | } 21 | 22 | // EventType is the type of an event. 23 | type EventType string 24 | 25 | const ( 26 | // Reopened is when an issue is reopened. 27 | Reopened EventType = "reopened" 28 | // Closed is when an issue is closed. 29 | Closed EventType = "closed" 30 | // Renamed is when an issue is renamed. 31 | Renamed EventType = "renamed" 32 | // Labeled is when an issue is labeled. 33 | Labeled EventType = "labeled" 34 | // Unlabeled is when an issue is unlabeled. 35 | Unlabeled EventType = "unlabeled" 36 | // Milestoned is when an issue is milestoned. 37 | Milestoned EventType = "milestoned" 38 | // Demilestoned is when an issue is demilestoned. 39 | Demilestoned EventType = "demilestoned" 40 | // CommentDeleted is when an issue comment is deleted. 41 | CommentDeleted EventType = "comment_deleted" 42 | ) 43 | 44 | // Valid returns non-nil error if the event type is invalid. 45 | func (et EventType) Valid() bool { 46 | switch et { 47 | case Reopened, Closed, Renamed, Labeled, Unlabeled, Milestoned, Demilestoned, CommentDeleted: 48 | return true 49 | default: 50 | return false 51 | } 52 | } 53 | 54 | // Close provides details for a Closed event. 55 | type Close struct { 56 | Closer interface{} // Change, Commit, nil. 57 | } 58 | 59 | // Change describes a change that closed an issue. 60 | type Change struct { 61 | State state.Change 62 | Title string 63 | HTMLURL string 64 | } 65 | 66 | // Commit describes a commit that closed an issue. 67 | type Commit struct { 68 | SHA string 69 | Message string 70 | AuthorAvatarURL string 71 | HTMLURL string 72 | } 73 | 74 | // Rename provides details for a Renamed event. 75 | type Rename struct { 76 | From string 77 | To string 78 | } 79 | -------------------------------------------------------------------------------- /fs/copier.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/shurcooL/issues" 8 | ) 9 | 10 | var _ issues.CopierFrom = &service{} 11 | 12 | func (s *service) CopyFrom(ctx context.Context, src issues.Service, repo issues.RepoSpec) error { 13 | s.fsMu.Lock() 14 | defer s.fsMu.Unlock() 15 | 16 | if err := s.createNamespace(ctx, repo); err != nil { 17 | return err 18 | } 19 | 20 | is, err := src.List(ctx, repo, issues.IssueListOptions{State: issues.AllStates}) 21 | if err != nil { 22 | return err 23 | } 24 | fmt.Printf("Copying %v issues.\n", len(is)) 25 | for _, i := range is { 26 | i, err = src.Get(ctx, repo, i.ID) // Needed to get the body, since List operation doesn't include all details. 27 | if err != nil { 28 | return err 29 | } 30 | // Copy issue. 31 | issue := issue{ 32 | State: i.State, 33 | Title: i.Title, 34 | comment: comment{ 35 | Author: fromUserSpec(i.User.UserSpec), 36 | CreatedAt: i.CreatedAt, 37 | // TODO: This doesn't work, Get doesn't return body, reactions, etc.; using ListComments for now for that... Improve this. 38 | // Perhaps this is motivation to separate Comment out of Issue, so get can return only Issue and it's clear that Comment details are elsewhere. 39 | // Just leave non-comment-specific things in Issue like Author and CreatedAt, but remove Body, Reactions, etc., those belong to comment only. 40 | // That would also make the distinction between reactions to first issue comment (its body) and reactions to entire issue (i.e. in list view), if that's ever desireable. 41 | // However, it would just mean that Create endpoint would likely need to create an issue and then a comment (2 storage ops rater than 1), but that's completely fair. 42 | //Body: i.Body, 43 | }, 44 | } 45 | 46 | // Put in storage. 47 | err = s.fs.Mkdir(ctx, issueDir(repo, i.ID), 0755) 48 | if err != nil { 49 | return err 50 | } 51 | err = s.fs.Mkdir(ctx, issueEventsDir(repo, i.ID), 0755) 52 | if err != nil { 53 | return err 54 | } 55 | // Issue will be created as part of first comment, since we need to embed its comment too. 56 | 57 | comments, err := src.ListComments(ctx, repo, i.ID, nil) 58 | if err != nil { 59 | return err 60 | } 61 | fmt.Printf("Issue %v: Copying %v comments.\n", i.ID, len(comments)) 62 | for _, c := range comments { 63 | // Copy comment. 64 | comment := comment{ 65 | Author: fromUserSpec(c.User.UserSpec), 66 | CreatedAt: c.CreatedAt, 67 | Body: c.Body, 68 | } 69 | for _, r := range c.Reactions { 70 | reaction := reaction{ 71 | EmojiID: r.Reaction, 72 | } 73 | for _, u := range r.Users { 74 | reaction.Authors = append(reaction.Authors, fromUserSpec(u.UserSpec)) 75 | } 76 | comment.Reactions = append(comment.Reactions, reaction) 77 | } 78 | 79 | if c.ID == 0 { 80 | issue.comment = comment 81 | 82 | // Put in storage. 83 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, i.ID, 0), issue) 84 | if err != nil { 85 | return err 86 | } 87 | continue 88 | } 89 | 90 | // Put in storage. 91 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, i.ID, c.ID), comment) 92 | if err != nil { 93 | return err 94 | } 95 | } 96 | 97 | events, err := src.ListEvents(ctx, repo, i.ID, nil) 98 | if err != nil { 99 | return err 100 | } 101 | fmt.Printf("Issue %v: Copying %v events.\n", i.ID, len(events)) 102 | for _, e := range events { 103 | // Copy event. 104 | event := event{ 105 | Actor: fromUserSpec(e.Actor.UserSpec), 106 | CreatedAt: e.CreatedAt, 107 | Type: e.Type, 108 | Rename: e.Rename, 109 | } 110 | 111 | // Put in storage. 112 | err = jsonEncodeFile(ctx, s.fs, issueEventPath(repo, i.ID, e.ID), event) 113 | if err != nil { 114 | return err 115 | } 116 | } 117 | } 118 | 119 | fmt.Println("All done.") 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /fs/events.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "dmitri.shuralyov.com/state" 8 | eventpkg "github.com/shurcooL/events/event" 9 | "github.com/shurcooL/issues" 10 | "github.com/shurcooL/users" 11 | ) 12 | 13 | func (s *service) logIssue(ctx context.Context, repo issues.RepoSpec, issueID uint64, fragment string, issue issue, actor users.User, action string, time time.Time) error { 14 | if s.events == nil { 15 | return nil 16 | } 17 | 18 | event := eventpkg.Event{ 19 | Time: time, 20 | Actor: actor, 21 | Container: repo.URI, 22 | 23 | Payload: eventpkg.Issue{ 24 | Action: action, 25 | IssueTitle: issue.Title, 26 | IssueBody: issue.Body, 27 | IssueHTMLURL: htmlURL(repo.URI, issueID, fragment), 28 | }, 29 | } 30 | return s.events.Log(ctx, event) 31 | } 32 | 33 | func (s *service) logIssueComment(ctx context.Context, repo issues.RepoSpec, issueID uint64, fragment string, actor users.User, time time.Time, body string) error { 34 | if s.events == nil { 35 | return nil 36 | } 37 | 38 | // TODO, THINK: Is this the best place/time? It's also being done in s.notify... 39 | // Get issue from storage for to populate event fields. 40 | var issue issue 41 | err := jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, issueID, 0), &issue) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | event := eventpkg.Event{ 47 | Time: time, 48 | Actor: actor, 49 | Container: repo.URI, 50 | 51 | Payload: eventpkg.IssueComment{ 52 | IssueTitle: issue.Title, 53 | IssueState: state.Issue(issue.State), // TODO: Make the conversion go away (by making issues.State type state.Issue). 54 | CommentBody: body, 55 | CommentHTMLURL: htmlURL(repo.URI, issueID, fragment), 56 | }, 57 | } 58 | return s.events.Log(ctx, event) 59 | } 60 | -------------------------------------------------------------------------------- /fs/fs.go: -------------------------------------------------------------------------------- 1 | // Package fs implements issues.Service using a virtual filesystem. 2 | package fs 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "os" 10 | "strconv" 11 | "sync" 12 | "time" 13 | 14 | "github.com/shurcooL/events" 15 | "github.com/shurcooL/issues" 16 | "github.com/shurcooL/notifications" 17 | "github.com/shurcooL/reactions" 18 | "github.com/shurcooL/users" 19 | "golang.org/x/net/webdav" 20 | ) 21 | 22 | // NewService creates a virtual filesystem-backed issues.Service using root for storage. 23 | // It uses notifications service, if not nil. 24 | // It uses events service, if not nil. 25 | func NewService(root webdav.FileSystem, notifications notifications.ExternalService, events events.ExternalService, users users.Service) (issues.Service, error) { 26 | return &service{ 27 | fs: root, 28 | notifications: notifications, 29 | events: events, 30 | users: users, 31 | }, nil 32 | } 33 | 34 | type service struct { 35 | fsMu sync.RWMutex 36 | fs webdav.FileSystem 37 | 38 | // notifications may be nil if there's no notifications service. 39 | notifications notifications.ExternalService 40 | // events may be nil if there's no events service. 41 | events events.ExternalService 42 | 43 | users users.Service 44 | } 45 | 46 | func (s *service) List(ctx context.Context, repo issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) { 47 | if opt.State != issues.StateFilter(issues.OpenState) && opt.State != issues.StateFilter(issues.ClosedState) && opt.State != issues.AllStates { 48 | return nil, fmt.Errorf("invalid issues.IssueListOptions.State value: %q", opt.State) // TODO: Map to 400 Bad Request HTTP error. 49 | } 50 | 51 | s.fsMu.RLock() 52 | defer s.fsMu.RUnlock() 53 | 54 | var is []issues.Issue 55 | 56 | dirs, err := readDirIDs(ctx, s.fs, issuesDir(repo)) 57 | if os.IsNotExist(err) { 58 | dirs = nil 59 | } else if err != nil { 60 | return is, err 61 | } 62 | for i := len(dirs); i > 0; i-- { 63 | dir := dirs[i-1] 64 | if !dir.IsDir() { 65 | continue 66 | } 67 | 68 | var issue issue 69 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, dir.ID, 0), &issue) 70 | if err != nil { 71 | return is, err 72 | } 73 | 74 | if opt.State != issues.AllStates && issue.State != issues.State(opt.State) { 75 | continue 76 | } 77 | 78 | comments, err := readDirIDs(ctx, s.fs, issueDir(repo, dir.ID)) // Count comments. 79 | if err != nil { 80 | return is, err 81 | } 82 | author := issue.Author.UserSpec() 83 | var labels []issues.Label 84 | for _, l := range issue.Labels { 85 | labels = append(labels, issues.Label{ 86 | Name: l.Name, 87 | Color: l.Color.RGB(), 88 | }) 89 | } 90 | is = append(is, issues.Issue{ 91 | ID: dir.ID, 92 | State: issue.State, 93 | Title: issue.Title, 94 | Labels: labels, 95 | Comment: issues.Comment{ 96 | User: s.user(ctx, author), 97 | CreatedAt: issue.CreatedAt, 98 | }, 99 | Replies: len(comments) - 1, 100 | }) 101 | } 102 | 103 | return is, nil 104 | } 105 | 106 | func (s *service) Count(ctx context.Context, repo issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) { 107 | if opt.State != issues.StateFilter(issues.OpenState) && opt.State != issues.StateFilter(issues.ClosedState) && opt.State != issues.AllStates { 108 | return 0, fmt.Errorf("invalid issues.IssueListOptions.State value: %q", opt.State) // TODO: Map to 400 Bad Request HTTP error. 109 | } 110 | 111 | s.fsMu.RLock() 112 | defer s.fsMu.RUnlock() 113 | 114 | var count uint64 115 | 116 | dirs, err := readDirIDs(ctx, s.fs, issuesDir(repo)) 117 | if os.IsNotExist(err) { 118 | dirs = nil 119 | } else if err != nil { 120 | return 0, err 121 | } 122 | for _, dir := range dirs { 123 | if !dir.IsDir() { 124 | continue 125 | } 126 | 127 | var issue issue 128 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, dir.ID, 0), &issue) 129 | if err != nil { 130 | return 0, err 131 | } 132 | 133 | if opt.State != issues.AllStates && issue.State != issues.State(opt.State) { 134 | continue 135 | } 136 | 137 | count++ 138 | } 139 | 140 | return count, nil 141 | } 142 | 143 | func (s *service) Get(ctx context.Context, repo issues.RepoSpec, id uint64) (issues.Issue, error) { 144 | currentUser, err := s.users.GetAuthenticated(ctx) 145 | if err != nil { 146 | return issues.Issue{}, err 147 | } 148 | 149 | s.fsMu.RLock() 150 | defer s.fsMu.RUnlock() 151 | 152 | var issue issue 153 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, id, 0), &issue) 154 | if err != nil { 155 | return issues.Issue{}, err 156 | } 157 | 158 | comments, err := readDirIDs(ctx, s.fs, issueDir(repo, id)) // Count comments. 159 | if err != nil { 160 | return issues.Issue{}, err 161 | } 162 | author := issue.Author.UserSpec() 163 | var labels []issues.Label 164 | for _, l := range issue.Labels { 165 | labels = append(labels, issues.Label{ 166 | Name: l.Name, 167 | Color: l.Color.RGB(), 168 | }) 169 | } 170 | 171 | if currentUser.ID != 0 { 172 | // Mark as read. 173 | err = s.markRead(ctx, repo, id) 174 | if err != nil { 175 | log.Println("service.Get: failed to s.markRead:", err) 176 | } 177 | } 178 | 179 | // TODO: Eliminate comment body properties from issues.Issue. It's missing increasingly more fields, like Edited, etc. 180 | return issues.Issue{ 181 | ID: id, 182 | State: issue.State, 183 | Title: issue.Title, 184 | Labels: labels, 185 | Comment: issues.Comment{ 186 | User: s.user(ctx, author), 187 | CreatedAt: issue.CreatedAt, 188 | Editable: nil == canEdit(currentUser, issue.Author), 189 | }, 190 | Replies: len(comments) - 1, 191 | }, nil 192 | } 193 | 194 | func (s *service) ListComments(ctx context.Context, repo issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) { 195 | currentUser, err := s.users.GetAuthenticated(ctx) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | s.fsMu.RLock() 201 | defer s.fsMu.RUnlock() 202 | 203 | var comments []issues.Comment 204 | 205 | fis, err := readDirIDs(ctx, s.fs, issueDir(repo, id)) 206 | if err != nil { 207 | return comments, err 208 | } 209 | for _, fi := range paginate(fis, opt) { 210 | var comment comment 211 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, id, fi.ID), &comment) 212 | if err != nil { 213 | return comments, err 214 | } 215 | 216 | author := comment.Author.UserSpec() 217 | var edited *issues.Edited 218 | if ed := comment.Edited; ed != nil { 219 | edited = &issues.Edited{ 220 | By: s.user(ctx, ed.By.UserSpec()), 221 | At: ed.At, 222 | } 223 | } 224 | var rs []reactions.Reaction 225 | for _, cr := range comment.Reactions { 226 | reaction := reactions.Reaction{ 227 | Reaction: cr.EmojiID, 228 | } 229 | for _, u := range cr.Authors { 230 | reactionAuthor := u.UserSpec() 231 | // TODO: Since we're potentially getting many of the same users multiple times here, consider caching them locally. 232 | reaction.Users = append(reaction.Users, s.user(ctx, reactionAuthor)) 233 | } 234 | rs = append(rs, reaction) 235 | } 236 | comments = append(comments, issues.Comment{ 237 | ID: fi.ID, 238 | User: s.user(ctx, author), 239 | CreatedAt: comment.CreatedAt, 240 | Edited: edited, 241 | Body: comment.Body, 242 | Reactions: rs, 243 | Editable: nil == canEdit(currentUser, comment.Author), 244 | }) 245 | } 246 | 247 | return comments, nil 248 | } 249 | 250 | func (s *service) ListEvents(ctx context.Context, repo issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) { 251 | s.fsMu.RLock() 252 | defer s.fsMu.RUnlock() 253 | 254 | var events []issues.Event 255 | 256 | fis, err := readDirIDs(ctx, s.fs, issueEventsDir(repo, id)) 257 | if err != nil { 258 | return events, err 259 | } 260 | for _, fi := range paginate(fis, opt) { 261 | var event event 262 | err = jsonDecodeFile(ctx, s.fs, issueEventPath(repo, id, fi.ID), &event) 263 | if err != nil { 264 | return events, err 265 | } 266 | 267 | actor := event.Actor.UserSpec() 268 | var label *issues.Label 269 | if l := event.Label; l != nil { 270 | label = &issues.Label{ 271 | Name: l.Name, 272 | Color: l.Color.RGB(), 273 | } 274 | } 275 | events = append(events, issues.Event{ 276 | ID: fi.ID, 277 | Actor: s.user(ctx, actor), 278 | CreatedAt: event.CreatedAt, 279 | Type: event.Type, 280 | Close: event.Close.Close(), 281 | Rename: event.Rename, 282 | Label: label, 283 | }) 284 | } 285 | 286 | return events, nil 287 | } 288 | 289 | func (s *service) CreateComment(ctx context.Context, repo issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) { 290 | // CreateComment operation requires an authenticated user with read access. 291 | currentUser, err := s.users.GetAuthenticated(ctx) 292 | if err != nil { 293 | return issues.Comment{}, err 294 | } 295 | if currentUser.ID == 0 { 296 | return issues.Comment{}, os.ErrPermission 297 | } 298 | 299 | if err := c.Validate(); err != nil { 300 | return issues.Comment{}, err 301 | } 302 | 303 | s.fsMu.Lock() 304 | defer s.fsMu.Unlock() 305 | 306 | comment := comment{ 307 | Author: fromUserSpec(currentUser.UserSpec), 308 | CreatedAt: time.Now().UTC(), 309 | Body: c.Body, 310 | } 311 | 312 | author := comment.Author.UserSpec() 313 | 314 | // Commit to storage. 315 | commentID, err := nextID(ctx, s.fs, issueDir(repo, id)) 316 | if err != nil { 317 | return issues.Comment{}, err 318 | } 319 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, id, commentID), comment) 320 | if err != nil { 321 | return issues.Comment{}, err 322 | } 323 | 324 | // Subscribe interested users. 325 | err = s.subscribe(ctx, repo, id, author, c.Body) 326 | if err != nil { 327 | log.Println("service.CreateComment: failed to s.subscribe:", err) 328 | } 329 | 330 | // Notify subscribed users. 331 | // TODO: Come up with a better way to compute fragment; that logic shouldn't be duplicated here from issuesapp router. 332 | err = s.notify(ctx, repo, id, fmt.Sprintf("comment-%d", commentID), author, comment.CreatedAt) 333 | if err != nil { 334 | log.Println("service.CreateComment: failed to s.notify:", err) 335 | } 336 | 337 | // Log event. 338 | // TODO: Come up with a better way to compute fragment; that logic shouldn't be duplicated here from issuesapp router. 339 | err = s.logIssueComment(ctx, repo, id, fmt.Sprintf("comment-%d", commentID), currentUser, comment.CreatedAt, comment.Body) 340 | if err != nil { 341 | log.Println("service.CreateComment: failed to s.logIssueComment:", err) 342 | } 343 | 344 | return issues.Comment{ 345 | ID: commentID, 346 | User: s.user(ctx, author), 347 | CreatedAt: comment.CreatedAt, 348 | Body: comment.Body, 349 | Editable: true, // You can always edit comments you've created. 350 | }, nil 351 | } 352 | 353 | func (s *service) Create(ctx context.Context, repo issues.RepoSpec, i issues.Issue) (issues.Issue, error) { 354 | // Create operation requires an authenticated user with read access. 355 | currentUser, err := s.users.GetAuthenticated(ctx) 356 | if err != nil { 357 | return issues.Issue{}, err 358 | } 359 | if currentUser.ID == 0 { 360 | return issues.Issue{}, os.ErrPermission 361 | } 362 | 363 | if err := i.Validate(); err != nil { 364 | return issues.Issue{}, err 365 | } 366 | 367 | s.fsMu.Lock() 368 | defer s.fsMu.Unlock() 369 | 370 | if err := s.createNamespace(ctx, repo); err != nil { 371 | return issues.Issue{}, err 372 | } 373 | 374 | var labels []label 375 | for _, l := range i.Labels { 376 | labels = append(labels, label{ 377 | Name: l.Name, 378 | Color: fromRGB(l.Color), 379 | }) 380 | } 381 | issue := issue{ 382 | State: issues.OpenState, 383 | Title: i.Title, 384 | Labels: labels, 385 | comment: comment{ 386 | Author: fromUserSpec(currentUser.UserSpec), 387 | CreatedAt: time.Now().UTC(), 388 | Body: i.Body, 389 | }, 390 | } 391 | 392 | author := issue.Author.UserSpec() 393 | 394 | // Commit to storage. 395 | issueID, err := nextID(ctx, s.fs, issuesDir(repo)) 396 | if err != nil { 397 | return issues.Issue{}, err 398 | } 399 | err = s.fs.Mkdir(ctx, issueDir(repo, issueID), 0755) 400 | if err != nil { 401 | return issues.Issue{}, err 402 | } 403 | err = s.fs.Mkdir(ctx, issueEventsDir(repo, issueID), 0755) 404 | if err != nil { 405 | return issues.Issue{}, err 406 | } 407 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, issueID, 0), issue) 408 | if err != nil { 409 | return issues.Issue{}, err 410 | } 411 | 412 | // Subscribe interested users. 413 | err = s.subscribe(ctx, repo, issueID, author, i.Body) 414 | if err != nil { 415 | log.Println("service.Create: failed to s.subscribe:", err) 416 | } 417 | 418 | // Notify subscribed users. 419 | err = s.notify(ctx, repo, issueID, "", author, issue.CreatedAt) 420 | if err != nil { 421 | log.Println("service.Create: failed to s.notify:", err) 422 | } 423 | 424 | // Log event. 425 | err = s.logIssue(ctx, repo, issueID, "", issue, currentUser, "opened", issue.CreatedAt) 426 | if err != nil { 427 | log.Println("service.Create: failed to s.logIssue:", err) 428 | } 429 | 430 | return issues.Issue{ 431 | ID: issueID, 432 | State: issue.State, 433 | Title: issue.Title, 434 | Comment: issues.Comment{ 435 | ID: 0, 436 | User: s.user(ctx, author), 437 | CreatedAt: issue.CreatedAt, 438 | Body: issue.Body, 439 | Editable: true, // You can always edit issues you've created. 440 | }, 441 | }, nil 442 | } 443 | 444 | // canEdit returns nil error if currentUser is authorized to edit an entry created by author. 445 | // It returns os.ErrPermission or an error that happened in other cases. 446 | func canEdit(currentUser users.User, author userSpec) error { 447 | if currentUser.ID == 0 { 448 | // Not logged in, cannot edit anything. 449 | return os.ErrPermission 450 | } 451 | if author.Equal(currentUser.UserSpec) { 452 | // If you're the author, you can always edit it. 453 | return nil 454 | } 455 | switch { 456 | case currentUser.SiteAdmin: 457 | // If you're a site admin, you can edit. 458 | return nil 459 | default: 460 | return os.ErrPermission 461 | } 462 | } 463 | 464 | // canReact returns nil error if currentUser is authorized to react to an entry. 465 | // It returns os.ErrPermission or an error that happened in other cases. 466 | func canReact(currentUser users.UserSpec) error { 467 | if currentUser.ID == 0 { 468 | // Not logged in, cannot react to anything. 469 | return os.ErrPermission 470 | } 471 | return nil 472 | } 473 | 474 | func (s *service) Edit(ctx context.Context, repo issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) { 475 | currentUser, err := s.users.GetAuthenticated(ctx) 476 | if err != nil { 477 | return issues.Issue{}, nil, err 478 | } 479 | if currentUser.ID == 0 { 480 | return issues.Issue{}, nil, os.ErrPermission 481 | } 482 | 483 | if err := ir.Validate(); err != nil { 484 | return issues.Issue{}, nil, err 485 | } 486 | 487 | s.fsMu.Lock() 488 | defer s.fsMu.Unlock() 489 | 490 | // Get from storage. 491 | var issue issue 492 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, id, 0), &issue) 493 | if err != nil { 494 | return issues.Issue{}, nil, err 495 | } 496 | 497 | // Authorization check. 498 | if err := canEdit(currentUser, issue.Author); err != nil { 499 | return issues.Issue{}, nil, err 500 | } 501 | 502 | author := issue.Author.UserSpec() 503 | actor := currentUser.UserSpec 504 | 505 | // Apply edits. 506 | origState := issue.State 507 | if ir.State != nil { 508 | issue.State = *ir.State 509 | } 510 | origTitle := issue.Title 511 | if ir.Title != nil { 512 | issue.Title = *ir.Title 513 | } 514 | 515 | // Commit to storage. 516 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, id, 0), issue) 517 | if err != nil { 518 | return issues.Issue{}, nil, err 519 | } 520 | 521 | // Create event and commit to storage. 522 | event := event{ 523 | Actor: fromUserSpec(actor), 524 | CreatedAt: time.Now().UTC(), 525 | } 526 | // TODO: A single edit operation can result in multiple events, we should emit multiple events in such cases. We're currently emitting at most one event. 527 | switch { 528 | case ir.State != nil && *ir.State != origState: 529 | switch *ir.State { 530 | case issues.OpenState: 531 | event.Type = issues.Reopened 532 | case issues.ClosedState: 533 | event.Type = issues.Closed 534 | event.Close = fromClose(issues.Close{Closer: nil}) 535 | } 536 | case ir.Title != nil && *ir.Title != origTitle: 537 | event.Type = issues.Renamed 538 | event.Rename = &issues.Rename{ 539 | From: origTitle, 540 | To: *ir.Title, 541 | } 542 | } 543 | var events []issues.Event 544 | if event.Type != "" { 545 | eventID, err := nextID(ctx, s.fs, issueEventsDir(repo, id)) 546 | if err != nil { 547 | return issues.Issue{}, nil, err 548 | } 549 | err = jsonEncodeFile(ctx, s.fs, issueEventPath(repo, id, eventID), event) 550 | if err != nil { 551 | return issues.Issue{}, nil, err 552 | } 553 | 554 | events = append(events, issues.Event{ 555 | ID: eventID, 556 | Actor: s.user(ctx, actor), 557 | CreatedAt: event.CreatedAt, 558 | Type: event.Type, 559 | Rename: event.Rename, 560 | }) 561 | } 562 | 563 | if ir.State != nil && *ir.State != origState { 564 | // Subscribe interested users. 565 | err = s.subscribe(ctx, repo, id, actor, "") 566 | if err != nil { 567 | log.Println("service.Edit: failed to s.subscribe:", err) 568 | } 569 | 570 | // Notify subscribed users. 571 | // TODO: Maybe set fragment to fmt.Sprintf("event-%d", eventID), etc. 572 | err = s.notify(ctx, repo, id, "", actor, event.CreatedAt) 573 | if err != nil { 574 | log.Println("service.Edit: failed to s.notify:", err) 575 | } 576 | 577 | // Log event. 578 | // TODO: Maybe set fragment to fmt.Sprintf("event-%d", eventID), etc. 579 | err = s.logIssue(ctx, repo, id, "", issue, currentUser, string(event.Type), event.CreatedAt) 580 | if err != nil { 581 | log.Println("service.Edit: failed to s.logIssue:", err) 582 | } 583 | } 584 | 585 | return issues.Issue{ 586 | ID: id, 587 | State: issue.State, 588 | Title: issue.Title, 589 | Comment: issues.Comment{ 590 | ID: 0, 591 | User: s.user(ctx, author), 592 | CreatedAt: issue.CreatedAt, 593 | Editable: true, // You can always edit issues you've edited. 594 | }, 595 | }, events, nil 596 | } 597 | 598 | func (s *service) EditComment(ctx context.Context, repo issues.RepoSpec, id uint64, cr issues.CommentRequest) (issues.Comment, error) { 599 | currentUser, err := s.users.GetAuthenticated(ctx) 600 | if err != nil { 601 | return issues.Comment{}, err 602 | } 603 | if currentUser.ID == 0 { 604 | return issues.Comment{}, os.ErrPermission 605 | } 606 | 607 | requiresEdit, err := cr.Validate() 608 | if err != nil { 609 | return issues.Comment{}, err 610 | } 611 | 612 | s.fsMu.Lock() 613 | defer s.fsMu.Unlock() 614 | 615 | // TODO: Merge these 2 cases (first comment aka issue vs reply comments) into one. 616 | if cr.ID == 0 { 617 | // Get from storage. 618 | var issue issue 619 | err := jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, id, 0), &issue) 620 | if err != nil { 621 | return issues.Comment{}, err 622 | } 623 | 624 | // Authorization check. 625 | switch requiresEdit { 626 | case true: 627 | if err := canEdit(currentUser, issue.Author); err != nil { 628 | return issues.Comment{}, err 629 | } 630 | case false: 631 | if err := canReact(currentUser.UserSpec); err != nil { 632 | return issues.Comment{}, err 633 | } 634 | } 635 | 636 | author := issue.Author.UserSpec() 637 | actor := currentUser.UserSpec 638 | editedAt := time.Now().UTC() 639 | 640 | // Apply edits. 641 | if cr.Body != nil { 642 | issue.Body = *cr.Body 643 | issue.Edited = &edited{ 644 | By: fromUserSpec(actor), 645 | At: editedAt, 646 | } 647 | } 648 | if cr.Reaction != nil { 649 | err := toggleReaction(&issue.comment, currentUser.UserSpec, *cr.Reaction) 650 | if err != nil { 651 | return issues.Comment{}, err 652 | } 653 | } 654 | 655 | // Commit to storage. 656 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, id, 0), issue) 657 | if err != nil { 658 | return issues.Comment{}, err 659 | } 660 | 661 | if cr.Body != nil { 662 | // Subscribe interested users. 663 | err = s.subscribe(ctx, repo, id, actor, *cr.Body) 664 | if err != nil { 665 | log.Println("service.EditComment: failed to s.subscribe:", err) 666 | } 667 | 668 | // TODO: Notify _newly mentioned_ users. 669 | } 670 | 671 | var edited *issues.Edited 672 | if ed := issue.Edited; ed != nil { 673 | edited = &issues.Edited{ 674 | By: s.user(ctx, ed.By.UserSpec()), 675 | At: ed.At, 676 | } 677 | } 678 | var rs []reactions.Reaction 679 | for _, cr := range issue.Reactions { 680 | reaction := reactions.Reaction{ 681 | Reaction: cr.EmojiID, 682 | } 683 | for _, u := range cr.Authors { 684 | reactionAuthor := u.UserSpec() 685 | // TODO: Since we're potentially getting many of the same users multiple times here, consider caching them locally. 686 | reaction.Users = append(reaction.Users, s.user(ctx, reactionAuthor)) 687 | } 688 | rs = append(rs, reaction) 689 | } 690 | return issues.Comment{ 691 | ID: 0, 692 | User: s.user(ctx, author), 693 | CreatedAt: issue.CreatedAt, 694 | Edited: edited, 695 | Body: issue.Body, 696 | Reactions: rs, 697 | Editable: true, // You can always edit comments you've edited. 698 | }, nil 699 | } 700 | 701 | // Get from storage. 702 | var comment comment 703 | err = jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, id, cr.ID), &comment) 704 | if err != nil { 705 | return issues.Comment{}, err 706 | } 707 | 708 | // Authorization check. 709 | switch requiresEdit { 710 | case true: 711 | if err := canEdit(currentUser, comment.Author); err != nil { 712 | return issues.Comment{}, err 713 | } 714 | case false: 715 | if err := canReact(currentUser.UserSpec); err != nil { 716 | return issues.Comment{}, err 717 | } 718 | } 719 | 720 | author := comment.Author.UserSpec() 721 | actor := currentUser.UserSpec 722 | editedAt := time.Now().UTC() 723 | 724 | // Apply edits. 725 | if cr.Body != nil { 726 | comment.Body = *cr.Body 727 | comment.Edited = &edited{ 728 | By: fromUserSpec(actor), 729 | At: editedAt, 730 | } 731 | } 732 | if cr.Reaction != nil { 733 | err := toggleReaction(&comment, currentUser.UserSpec, *cr.Reaction) 734 | if err != nil { 735 | return issues.Comment{}, err 736 | } 737 | } 738 | 739 | // Commit to storage. 740 | err = jsonEncodeFile(ctx, s.fs, issueCommentPath(repo, id, cr.ID), comment) 741 | if err != nil { 742 | return issues.Comment{}, err 743 | } 744 | 745 | if cr.Body != nil { 746 | // Subscribe interested users. 747 | err = s.subscribe(ctx, repo, id, actor, *cr.Body) 748 | if err != nil { 749 | log.Println("service.EditComment: failed to s.subscribe:", err) 750 | } 751 | 752 | // TODO: Notify _newly mentioned_ users. 753 | } 754 | 755 | var edited *issues.Edited 756 | if ed := comment.Edited; ed != nil { 757 | edited = &issues.Edited{ 758 | By: s.user(ctx, ed.By.UserSpec()), 759 | At: ed.At, 760 | } 761 | } 762 | var rs []reactions.Reaction 763 | for _, cr := range comment.Reactions { 764 | reaction := reactions.Reaction{ 765 | Reaction: cr.EmojiID, 766 | } 767 | for _, u := range cr.Authors { 768 | reactionAuthor := u.UserSpec() 769 | // TODO: Since we're potentially getting many of the same users multiple times here, consider caching them locally. 770 | reaction.Users = append(reaction.Users, s.user(ctx, reactionAuthor)) 771 | } 772 | rs = append(rs, reaction) 773 | } 774 | return issues.Comment{ 775 | ID: cr.ID, 776 | User: s.user(ctx, author), 777 | CreatedAt: comment.CreatedAt, 778 | Edited: edited, 779 | Body: comment.Body, 780 | Reactions: rs, 781 | Editable: true, // You can always edit comments you've edited. 782 | }, nil 783 | } 784 | 785 | func paginate(fis []fileInfoID, opt *issues.ListOptions) []fileInfoID { 786 | if opt == nil { 787 | return fis 788 | } 789 | start := opt.Start 790 | if start > len(fis) { 791 | start = len(fis) 792 | } 793 | end := opt.Start + opt.Length 794 | if end > len(fis) { 795 | end = len(fis) 796 | } 797 | return fis[start:end] 798 | } 799 | 800 | // toggleReaction toggles reaction emojiID to comment c for specified user u. 801 | // If user is creating a new reaction, they get added to the end of reaction authors. 802 | func toggleReaction(c *comment, u users.UserSpec, emojiID reactions.EmojiID) error { 803 | reactionsFromUser := 0 804 | reactionsLoop: 805 | for _, r := range c.Reactions { 806 | for _, author := range r.Authors { 807 | if author.Equal(u) { 808 | reactionsFromUser++ 809 | continue reactionsLoop 810 | } 811 | } 812 | } 813 | 814 | for i := range c.Reactions { 815 | if c.Reactions[i].EmojiID == emojiID { 816 | // Toggle this user's reaction. 817 | switch reacted := contains(c.Reactions[i].Authors, u); { 818 | case reacted == -1: 819 | // Add this reaction. 820 | if reactionsFromUser >= 20 { 821 | // TODO: Map to 400 Bad Request HTTP error. 822 | return errors.New("too many reactions from same user") 823 | } 824 | c.Reactions[i].Authors = append(c.Reactions[i].Authors, fromUserSpec(u)) 825 | default: 826 | // Remove this reaction. Delete without preserving order. 827 | c.Reactions[i].Authors[reacted] = c.Reactions[i].Authors[len(c.Reactions[i].Authors)-1] 828 | c.Reactions[i].Authors = c.Reactions[i].Authors[:len(c.Reactions[i].Authors)-1] 829 | 830 | // If there are no more authors backing it, this reaction goes away. 831 | if len(c.Reactions[i].Authors) == 0 { 832 | c.Reactions, c.Reactions[len(c.Reactions)-1] = append(c.Reactions[:i], c.Reactions[i+1:]...), reaction{} // Delete preserving order. 833 | } 834 | } 835 | return nil 836 | } 837 | } 838 | 839 | // If we get here, this is the first reaction of its kind. 840 | // Add it to the end of the list. 841 | if reactionsFromUser >= 20 { 842 | // TODO: Map to 400 Bad Request HTTP error. 843 | return errors.New("too many reactions from same user") 844 | } 845 | c.Reactions = append(c.Reactions, 846 | reaction{ 847 | EmojiID: emojiID, 848 | Authors: []userSpec{fromUserSpec(u)}, 849 | }, 850 | ) 851 | return nil 852 | } 853 | 854 | // contains returns index of e in set, or -1 if it's not there. 855 | func contains(set []userSpec, e users.UserSpec) int { 856 | for i, v := range set { 857 | if v.Equal(e) { 858 | return i 859 | } 860 | } 861 | return -1 862 | } 863 | 864 | // nextID returns the next id for the given dir. If there are no previous elements, it begins with id 1. 865 | func nextID(ctx context.Context, fs webdav.FileSystem, dir string) (uint64, error) { 866 | fis, err := readDirIDs(ctx, fs, dir) 867 | if err != nil { 868 | return 0, err 869 | } 870 | if len(fis) == 0 { 871 | return 1, nil 872 | } 873 | return fis[len(fis)-1].ID + 1, nil 874 | } 875 | 876 | func formatUint64(n uint64) string { return strconv.FormatUint(n, 10) } 877 | -------------------------------------------------------------------------------- /fs/fs_test.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/shurcooL/reactions" 8 | "github.com/shurcooL/users" 9 | ) 10 | 11 | func TestToggleReaction(t *testing.T) { 12 | c := comment{ 13 | Reactions: []reaction{ 14 | {EmojiID: reactions.EmojiID("bar"), Authors: []userSpec{{ID: 1}, {ID: 2}}}, 15 | {EmojiID: reactions.EmojiID("baz"), Authors: []userSpec{{ID: 3}}}, 16 | }, 17 | } 18 | 19 | toggleReaction(&c, users.UserSpec{ID: 1}, reactions.EmojiID("foo")) 20 | toggleReaction(&c, users.UserSpec{ID: 1}, reactions.EmojiID("bar")) 21 | toggleReaction(&c, users.UserSpec{ID: 1}, reactions.EmojiID("baz")) 22 | toggleReaction(&c, users.UserSpec{ID: 2}, reactions.EmojiID("bar")) 23 | 24 | want := comment{ 25 | Reactions: []reaction{ 26 | {EmojiID: reactions.EmojiID("baz"), Authors: []userSpec{{ID: 3}, {ID: 1}}}, 27 | {EmojiID: reactions.EmojiID("foo"), Authors: []userSpec{{ID: 1}}}, 28 | }, 29 | } 30 | 31 | if got := c; !reflect.DeepEqual(got, want) { 32 | t.Errorf("\ngot %+v\nwant %+v", got.Reactions, want.Reactions) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /fs/ioutil.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "sort" 8 | "strconv" 9 | 10 | "github.com/shurcooL/webdavfs/vfsutil" 11 | "golang.org/x/net/webdav" 12 | ) 13 | 14 | // fileInfoID describes a file, whose name is an ID of type uint64. 15 | type fileInfoID struct { 16 | os.FileInfo 17 | ID uint64 18 | } 19 | 20 | // byID implements sort.Interface. 21 | type byID []fileInfoID 22 | 23 | func (f byID) Len() int { return len(f) } 24 | func (f byID) Less(i, j int) bool { return f[i].ID < f[j].ID } 25 | func (f byID) Swap(i, j int) { f[i], f[j] = f[j], f[i] } 26 | 27 | // readDirIDs reads the directory named by path and returns 28 | // a list of directory entries whose names are IDs of type uint64, sorted by ID. 29 | // Other entries with names don't match the naming scheme are ignored. 30 | // If the directory doesn't exist, a not exist error is returned. 31 | func readDirIDs(ctx context.Context, fs webdav.FileSystem, path string) ([]fileInfoID, error) { 32 | fis, err := vfsutil.ReadDir(ctx, fs, path) 33 | if err != nil { 34 | return nil, err 35 | } 36 | var fiis []fileInfoID 37 | for _, fi := range fis { 38 | id, err := strconv.ParseUint(fi.Name(), 10, 64) 39 | if err != nil { 40 | continue 41 | } 42 | fiis = append(fiis, fileInfoID{ 43 | FileInfo: fi, 44 | ID: id, 45 | }) 46 | } 47 | sort.Sort(byID(fiis)) 48 | return fiis, nil 49 | } 50 | 51 | // jsonEncodeFile encodes v into file at path, overwriting or creating it. 52 | func jsonEncodeFile(ctx context.Context, fs webdav.FileSystem, path string, v interface{}) error { 53 | f, err := fs.OpenFile(ctx, path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) 54 | if err != nil { 55 | return err 56 | } 57 | defer f.Close() 58 | return json.NewEncoder(f).Encode(v) 59 | } 60 | 61 | // jsonDecodeFile decodes contents of file at path into v. 62 | func jsonDecodeFile(ctx context.Context, fs webdav.FileSystem, path string, v interface{}) error { 63 | f, err := vfsutil.Open(ctx, fs, path) 64 | if err != nil { 65 | return err 66 | } 67 | defer f.Close() 68 | return json.NewDecoder(f).Decode(v) 69 | } 70 | -------------------------------------------------------------------------------- /fs/notifications.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/shurcooL/issues" 10 | "github.com/shurcooL/notifications" 11 | "github.com/shurcooL/users" 12 | ) 13 | 14 | // threadType is the notifications thread type for this service. 15 | const threadType = "issues" 16 | 17 | // ThreadType returns the notifications thread type for this service. 18 | func (*service) ThreadType(issues.RepoSpec) string { return threadType } 19 | 20 | // subscribe subscribes user and anyone mentioned in body to the issue. 21 | func (s *service) subscribe(ctx context.Context, repo issues.RepoSpec, issueID uint64, user users.UserSpec, body string) error { 22 | if s.notifications == nil { 23 | return nil 24 | } 25 | 26 | subscribers := []users.UserSpec{user} 27 | 28 | // TODO: Find mentioned users in body. 29 | /*mentions, err := mentions(ctx, body) 30 | if err != nil { 31 | return err 32 | } 33 | subscribers = append(subscribers, mentions...)*/ 34 | 35 | return s.notifications.Subscribe(ctx, notifications.RepoSpec(repo), threadType, issueID, subscribers) 36 | } 37 | 38 | // markRead marks the specified issue as read for current user. 39 | func (s *service) markRead(ctx context.Context, repo issues.RepoSpec, issueID uint64) error { 40 | if s.notifications == nil { 41 | return nil 42 | } 43 | 44 | return s.notifications.MarkRead(ctx, notifications.RepoSpec(repo), threadType, issueID) 45 | } 46 | 47 | // notify notifies all subscribed users of an update that shows up in their Notification Center. 48 | func (s *service) notify(ctx context.Context, repo issues.RepoSpec, issueID uint64, fragment string, actor users.UserSpec, time time.Time) error { 49 | if s.notifications == nil { 50 | return nil 51 | } 52 | 53 | // TODO, THINK: Is this the best place/time? 54 | // Get issue from storage for to populate notification fields. 55 | var issue issue 56 | err := jsonDecodeFile(ctx, s.fs, issueCommentPath(repo, issueID, 0), &issue) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | nr := notifications.NotificationRequest{ 62 | Title: issue.Title, 63 | Icon: notificationIcon(issue.State), 64 | Color: notificationColor(issue.State), 65 | Actor: actor, 66 | UpdatedAt: time, 67 | HTMLURL: htmlURL(repo.URI, issueID, fragment), 68 | } 69 | 70 | return s.notifications.Notify(ctx, notifications.RepoSpec(repo), threadType, issueID, nr) 71 | } 72 | 73 | // TODO, THINK: Where should the logic to come up with the URL live? 74 | // It's kinda related to the router/URL scheme of issuesapp... 75 | func htmlURL(repoURI string, issueID uint64, fragment string) string { 76 | var htmlURL string 77 | // TODO: Find a good way to factor out this logic and provide it to issues/fs in a reasonable way. 78 | switch { 79 | default: 80 | htmlURL = fmt.Sprintf("https://%s/...$issues/%v", repoURI, issueID) 81 | case repoURI == "dmitri.shuralyov.com/blog": 82 | htmlURL = fmt.Sprintf("https://dmitri.shuralyov.com/blog/%v", issueID) 83 | case repoURI == "dmitri.shuralyov.com/idiomatic-go": 84 | htmlURL = fmt.Sprintf("https://dmitri.shuralyov.com/idiomatic-go/entries/%v", issueID) 85 | case strings.HasPrefix(repoURI, "github.com/shurcooL/"): 86 | htmlURL = fmt.Sprintf("https://dmitri.shuralyov.com/issues/%s/%v", repoURI, issueID) 87 | } 88 | if fragment != "" { 89 | htmlURL += "#" + fragment 90 | } 91 | return htmlURL 92 | } 93 | 94 | // TODO: This is display/presentation logic; try to factor it out of the backend service implementation. 95 | // (Have it be provided to the service, maybe? Or another way.) 96 | func notificationIcon(state issues.State) notifications.OcticonID { 97 | switch state { 98 | case issues.OpenState: 99 | return "issue-opened" 100 | case issues.ClosedState: 101 | return "issue-closed" 102 | default: 103 | return "" 104 | } 105 | } 106 | 107 | /* TODO 108 | func (e event) Octicon() string { 109 | switch e.Event.Type { 110 | case issues.Reopened: 111 | return "octicon-primitive-dot" 112 | case issues.Closed: 113 | return "octicon-circle-slash" 114 | default: 115 | return "octicon-primitive-dot" 116 | } 117 | }*/ 118 | 119 | func notificationColor(state issues.State) notifications.RGB { 120 | switch state { 121 | case issues.OpenState: // Open. 122 | return notifications.RGB{R: 0x6c, G: 0xc6, B: 0x44} 123 | case issues.ClosedState: // Closed. 124 | return notifications.RGB{R: 0xbd, G: 0x2c, B: 0x00} 125 | default: 126 | return notifications.RGB{} 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /fs/schema.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "path" 8 | "time" 9 | 10 | "dmitri.shuralyov.com/state" 11 | "github.com/shurcooL/issues" 12 | "github.com/shurcooL/reactions" 13 | "github.com/shurcooL/users" 14 | "github.com/shurcooL/webdavfs/vfsutil" 15 | ) 16 | 17 | // userSpec is an on-disk representation of users.UserSpec. 18 | type userSpec struct { 19 | ID uint64 20 | Domain string `json:",omitempty"` 21 | } 22 | 23 | func fromUserSpec(us users.UserSpec) userSpec { 24 | return userSpec{ID: us.ID, Domain: us.Domain} 25 | } 26 | 27 | func (us userSpec) UserSpec() users.UserSpec { 28 | return users.UserSpec{ID: us.ID, Domain: us.Domain} 29 | } 30 | 31 | func (us userSpec) Equal(other users.UserSpec) bool { 32 | return us.Domain == other.Domain && us.ID == other.ID 33 | } 34 | 35 | // rgb is an on-disk representation of issues.RGB. 36 | type rgb struct { 37 | R, G, B uint8 38 | } 39 | 40 | func fromRGB(c issues.RGB) rgb { 41 | return rgb(c) 42 | } 43 | 44 | func (c rgb) RGB() issues.RGB { 45 | return issues.RGB(c) 46 | } 47 | 48 | // issue is an on-disk representation of issues.Issue. 49 | type issue struct { 50 | State issues.State 51 | Title string 52 | Labels []label `json:",omitempty"` 53 | comment 54 | } 55 | 56 | // label is an on-disk representation of issues.Label. 57 | type label struct { 58 | Name string 59 | Color rgb 60 | } 61 | 62 | // comment is an on-disk representation of issues.Comment. 63 | type comment struct { 64 | Author userSpec 65 | CreatedAt time.Time 66 | Edited *edited `json:",omitempty"` 67 | Body string 68 | Reactions []reaction `json:",omitempty"` 69 | } 70 | 71 | type edited struct { 72 | By userSpec 73 | At time.Time 74 | } 75 | 76 | // reaction is an on-disk representation of reactions.Reaction. 77 | type reaction struct { 78 | EmojiID reactions.EmojiID 79 | Authors []userSpec // First entry is first person who reacted. 80 | } 81 | 82 | // event is an on-disk representation of issues.Event. 83 | type event struct { 84 | Actor userSpec 85 | CreatedAt time.Time 86 | Type issues.EventType 87 | Close *closeDisk `json:",omitempty"` 88 | Rename *issues.Rename `json:",omitempty"` 89 | Label *label `json:",omitempty"` 90 | } 91 | 92 | // closeDisk is an on-disk representation of issues.Close. 93 | // Nil issues.Close.Closer is represented by nil *closeDisk. 94 | type closeDisk struct { 95 | Closer interface{} // issues.Change, issues.Commit. 96 | } 97 | 98 | func (c closeDisk) MarshalJSON() ([]byte, error) { 99 | var v struct { 100 | Type string // "change", "commit". 101 | Closer interface{} // change, commit. 102 | } 103 | switch p := c.Closer.(type) { 104 | case issues.Change: 105 | v.Type = "change" 106 | v.Closer = fromChange(p) 107 | case issues.Commit: 108 | v.Type = "commit" 109 | v.Closer = fromCommit(p) 110 | default: 111 | return nil, fmt.Errorf("closeDisk.MarshalJSON: unsupported Closer type %T", c.Closer) 112 | } 113 | return json.Marshal(v) 114 | } 115 | 116 | func (c *closeDisk) UnmarshalJSON(b []byte) error { 117 | // Ignore null, like in the main JSON package. 118 | if string(b) == "null" { 119 | return nil 120 | } 121 | var v struct { 122 | Type string // "change", "commit". 123 | Closer json.RawMessage // change, commit. 124 | } 125 | err := json.Unmarshal(b, &v) 126 | if err != nil { 127 | return err 128 | } 129 | *c = closeDisk{} 130 | switch v.Type { 131 | case "change": 132 | var p change 133 | err := json.Unmarshal(v.Closer, &p) 134 | if err != nil { 135 | return err 136 | } 137 | c.Closer = p.Change() 138 | case "commit": 139 | var p commit 140 | err := json.Unmarshal(v.Closer, &p) 141 | if err != nil { 142 | return err 143 | } 144 | c.Closer = p.Commit() 145 | default: 146 | return fmt.Errorf("closeDisk.UnmarshalJSON: unsupported Closer type %q", v.Type) 147 | } 148 | return nil 149 | } 150 | 151 | func fromClose(c issues.Close) *closeDisk { 152 | if c.Closer == nil { 153 | return nil 154 | } 155 | return (*closeDisk)(&c) 156 | } 157 | 158 | func (c *closeDisk) Close() issues.Close { 159 | if c == nil { 160 | return issues.Close{Closer: nil} 161 | } 162 | return issues.Close(*c) 163 | } 164 | 165 | // change is an on-disk representation of issues.Change. 166 | type change struct { 167 | State state.Change 168 | Title string 169 | HTMLURL string 170 | } 171 | 172 | func fromChange(c issues.Change) change { 173 | return change(c) 174 | } 175 | 176 | func (c change) Change() issues.Change { 177 | return issues.Change(c) 178 | } 179 | 180 | // commit is an on-disk representation of issues.Commit. 181 | type commit struct { 182 | SHA string 183 | Message string 184 | AuthorAvatarURL string 185 | HTMLURL string 186 | } 187 | 188 | func fromCommit(c issues.Commit) commit { 189 | return commit(c) 190 | } 191 | 192 | func (c commit) Commit() issues.Commit { 193 | return issues.Commit(c) 194 | } 195 | 196 | // Tree layout: 197 | // 198 | // root 199 | // └── domain.com 200 | // └── path 201 | // └── issues 202 | // ├── 1 203 | // │ ├── 0 - encoded issue 204 | // │ ├── 1 - encoded comment 205 | // │ ├── 2 206 | // │ └── events 207 | // │ ├── 1 - encoded event 208 | // │ └── 2 209 | // └── 2 210 | // ├── 0 211 | // └── events 212 | 213 | func (s *service) createNamespace(ctx context.Context, repo issues.RepoSpec) error { 214 | if path.Clean("/"+repo.URI) != "/"+repo.URI { 215 | return fmt.Errorf("invalid repo.URI (not clean): %q", repo.URI) 216 | } 217 | 218 | // Only needed for first issue in the repo. 219 | // THINK: Consider implicit dir adapter? 220 | return vfsutil.MkdirAll(ctx, s.fs, issuesDir(repo), 0755) 221 | } 222 | 223 | // issuesDir is '/'-separated path to issue storage dir. 224 | func issuesDir(repo issues.RepoSpec) string { 225 | return path.Join(repo.URI, "issues") 226 | } 227 | 228 | func issueDir(repo issues.RepoSpec, issueID uint64) string { 229 | return path.Join(repo.URI, "issues", formatUint64(issueID)) 230 | } 231 | 232 | func issueCommentPath(repo issues.RepoSpec, issueID, commentID uint64) string { 233 | return path.Join(repo.URI, "issues", formatUint64(issueID), formatUint64(commentID)) 234 | } 235 | 236 | // issueEventsDir is '/'-separated path to issue events dir. 237 | func issueEventsDir(repo issues.RepoSpec, issueID uint64) string { 238 | return path.Join(repo.URI, "issues", formatUint64(issueID), "events") 239 | } 240 | 241 | func issueEventPath(repo issues.RepoSpec, issueID, eventID uint64) string { 242 | return path.Join(repo.URI, "issues", formatUint64(issueID), "events", formatUint64(eventID)) 243 | } 244 | -------------------------------------------------------------------------------- /fs/users.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/shurcooL/users" 8 | ) 9 | 10 | func (s *service) user(ctx context.Context, user users.UserSpec) users.User { 11 | u, err := s.users.Get(ctx, user) 12 | if err != nil { 13 | return users.User{ 14 | UserSpec: user, 15 | Login: fmt.Sprintf("%d@%s", user.ID, user.Domain), 16 | AvatarURL: "https://secure.gravatar.com/avatar?d=mm&f=y&s=96", 17 | HTMLURL: "", 18 | } 19 | } 20 | return u 21 | } 22 | -------------------------------------------------------------------------------- /githubapi/githubapi.go: -------------------------------------------------------------------------------- 1 | // Package githubapi implements issues.Service using GitHub API clients. 2 | package githubapi 3 | 4 | import ( 5 | "context" 6 | "encoding/base64" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "strings" 11 | "time" 12 | 13 | "dmitri.shuralyov.com/route/github" 14 | "dmitri.shuralyov.com/state" 15 | githubv3 "github.com/google/go-github/github" 16 | "github.com/shurcooL/githubv4" 17 | "github.com/shurcooL/issues" 18 | "github.com/shurcooL/notifications" 19 | "github.com/shurcooL/users" 20 | ) 21 | 22 | // NewService creates a GitHub-backed issues.Service using given GitHub clients. 23 | // It uses notifications service, if not nil. At this time it infers the current user 24 | // from GitHub clients (their authentication info), and cannot be used to serve multiple users. 25 | // Both GitHub clients must use same authentication info. 26 | // 27 | // If router is nil, github.DotCom router is used, which links to subjects on github.com. 28 | func NewService(clientV3 *githubv3.Client, clientV4 *githubv4.Client, notifications notifications.ExternalService, router github.Router) issues.Service { 29 | if router == nil { 30 | router = github.DotCom{} 31 | } 32 | return service{ 33 | clV3: clientV3, 34 | clV4: clientV4, 35 | rtr: router, 36 | notifications: notifications, 37 | } 38 | } 39 | 40 | type service struct { 41 | clV3 *githubv3.Client // GitHub REST API v3 client. 42 | clV4 *githubv4.Client // GitHub GraphQL API v4 client. 43 | rtr github.Router 44 | 45 | // notifications may be nil if there's no notifications service. 46 | notifications notifications.ExternalService 47 | } 48 | 49 | // We use 0 as a special ID for the comment that is the issue description. This comment is edited differently. 50 | const issueDescriptionCommentID uint64 = 0 51 | 52 | func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) { 53 | repo, err := ghRepoSpec(rs) 54 | if err != nil { 55 | // TODO: Map to 400 Bad Request HTTP error. 56 | return nil, err 57 | } 58 | var states *[]githubv4.IssueState 59 | switch opt.State { 60 | case issues.StateFilter(issues.OpenState): 61 | states = &[]githubv4.IssueState{githubv4.IssueStateOpen} 62 | case issues.StateFilter(issues.ClosedState): 63 | states = &[]githubv4.IssueState{githubv4.IssueStateClosed} 64 | case issues.AllStates: 65 | states = nil // No states to filter the issues by. 66 | default: 67 | // TODO: Map to 400 Bad Request HTTP error. 68 | return nil, fmt.Errorf("invalid issues.IssueListOptions.State value: %q", opt.State) 69 | } 70 | var q struct { 71 | Repository struct { 72 | Issues struct { 73 | Nodes []struct { 74 | Number uint64 75 | State githubv4.IssueState 76 | Title string 77 | Labels struct { 78 | Nodes []struct { 79 | Name string 80 | Color string 81 | } 82 | } `graphql:"labels(first:100)"` 83 | Author *githubV4Actor 84 | CreatedAt githubv4.DateTime 85 | Comments struct { 86 | TotalCount int 87 | } 88 | } 89 | } `graphql:"issues(first:30,orderBy:{field:CREATED_AT,direction:DESC},states:$issuesStates)"` 90 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 91 | } 92 | variables := map[string]interface{}{ 93 | "repositoryOwner": githubv4.String(repo.Owner), 94 | "repositoryName": githubv4.String(repo.Repo), 95 | "issuesStates": states, 96 | } 97 | err = s.clV4.Query(ctx, &q, variables) 98 | if err != nil { 99 | return nil, err 100 | } 101 | var is []issues.Issue 102 | for _, issue := range q.Repository.Issues.Nodes { 103 | var labels []issues.Label 104 | for _, l := range issue.Labels.Nodes { 105 | labels = append(labels, issues.Label{ 106 | Name: l.Name, 107 | Color: ghColor(l.Color), 108 | }) 109 | } 110 | is = append(is, issues.Issue{ 111 | ID: issue.Number, 112 | State: ghIssueState(issue.State), 113 | Title: issue.Title, 114 | Labels: labels, 115 | Comment: issues.Comment{ 116 | User: ghActor(issue.Author), 117 | CreatedAt: issue.CreatedAt.Time, 118 | }, 119 | Replies: issue.Comments.TotalCount, 120 | }) 121 | } 122 | return is, nil 123 | } 124 | 125 | func (s service) Count(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) { 126 | repo, err := ghRepoSpec(rs) 127 | if err != nil { 128 | // TODO: Map to 400 Bad Request HTTP error. 129 | return 0, err 130 | } 131 | var states *[]githubv4.IssueState 132 | switch opt.State { 133 | case issues.StateFilter(issues.OpenState): 134 | states = &[]githubv4.IssueState{githubv4.IssueStateOpen} 135 | case issues.StateFilter(issues.ClosedState): 136 | states = &[]githubv4.IssueState{githubv4.IssueStateClosed} 137 | case issues.AllStates: 138 | states = nil // No states to filter the issues by. 139 | default: 140 | // TODO: Map to 400 Bad Request HTTP error. 141 | return 0, fmt.Errorf("invalid issues.IssueListOptions.State value: %q", opt.State) 142 | } 143 | var q struct { 144 | Repository struct { 145 | Issues struct { 146 | TotalCount uint64 147 | } `graphql:"issues(states:$issuesStates)"` 148 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 149 | } 150 | variables := map[string]interface{}{ 151 | "repositoryOwner": githubv4.String(repo.Owner), 152 | "repositoryName": githubv4.String(repo.Repo), 153 | "issuesStates": states, 154 | } 155 | err = s.clV4.Query(ctx, &q, variables) 156 | return q.Repository.Issues.TotalCount, err 157 | } 158 | 159 | func (s service) Get(ctx context.Context, rs issues.RepoSpec, id uint64) (issues.Issue, error) { 160 | repo, err := ghRepoSpec(rs) 161 | if err != nil { 162 | // TODO: Map to 400 Bad Request HTTP error. 163 | return issues.Issue{}, err 164 | } 165 | var q struct { 166 | Repository struct { 167 | Issue struct { 168 | Number uint64 169 | State githubv4.IssueState 170 | Title string 171 | Author *githubV4Actor 172 | CreatedAt githubv4.DateTime 173 | ViewerCanUpdate githubv4.Boolean 174 | } `graphql:"issue(number:$issueNumber)"` 175 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 176 | } 177 | variables := map[string]interface{}{ 178 | "repositoryOwner": githubv4.String(repo.Owner), 179 | "repositoryName": githubv4.String(repo.Repo), 180 | "issueNumber": githubv4.Int(id), 181 | } 182 | err = s.clV4.Query(ctx, &q, variables) 183 | if err != nil { 184 | return issues.Issue{}, err 185 | } 186 | 187 | // Mark as read. (We know there's an authenticated user since we're using GitHub GraphQL API v4 above.) 188 | err = s.markRead(ctx, rs, id) 189 | if err != nil { 190 | log.Println("service.Get: failed to markRead:", err) 191 | } 192 | 193 | // TODO: Eliminate comment body properties from issues.Issue. It's missing increasingly more fields, like Edited, etc. 194 | issue := q.Repository.Issue 195 | return issues.Issue{ 196 | ID: issue.Number, 197 | State: ghIssueState(issue.State), 198 | Title: issue.Title, 199 | Comment: issues.Comment{ 200 | User: ghActor(issue.Author), 201 | CreatedAt: issue.CreatedAt.Time, 202 | Editable: bool(issue.ViewerCanUpdate), 203 | }, 204 | }, nil 205 | } 206 | 207 | // ListComments used to list only comments, but isn't implemented anymore. 208 | // 209 | // Deprecated: Use ListTimeline instead. 210 | func (service) ListComments(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) { 211 | return nil, errors.New("ListComments is not implemented, use ListTimeline instead") 212 | } 213 | 214 | // ListEvents used to list only events, but isn't implemented anymore. 215 | // 216 | // Deprecated: Use ListTimeline instead. 217 | func (service) ListEvents(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) { 218 | return nil, errors.New("ListEvents is not implemented, use ListTimeline instead") 219 | } 220 | 221 | func (service) IsTimelineLister(issues.RepoSpec) bool { return true } 222 | 223 | func (s service) ListTimeline(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]interface{}, error) { 224 | repo, err := ghRepoSpec(rs) 225 | if err != nil { 226 | // TODO: Map to 400 Bad Request HTTP error. 227 | return nil, err 228 | } 229 | type comment struct { // Comment fields. 230 | Author *githubV4Actor 231 | PublishedAt githubv4.DateTime 232 | LastEditedAt *githubv4.DateTime 233 | Editor *githubV4Actor 234 | Body string 235 | ReactionGroups reactionGroups 236 | ViewerCanUpdate bool 237 | } 238 | type event struct { // Common fields for all events. 239 | Actor *githubV4Actor 240 | CreatedAt githubv4.DateTime 241 | } 242 | var q struct { 243 | Repository struct { 244 | Issue struct { 245 | comment `graphql:"...@include(if:$firstPage)"` // Fetch the issue description only on first page. 246 | Timeline struct { 247 | Nodes []struct { 248 | Typename string `graphql:"__typename"` 249 | IssueComment struct { 250 | DatabaseID uint64 251 | comment 252 | } `graphql:"...on IssueComment"` 253 | ClosedEvent struct { 254 | event 255 | Closer struct { 256 | Typename string `graphql:"__typename"` 257 | PullRequest struct { 258 | State githubv4.PullRequestState 259 | Title string 260 | Repository struct { 261 | Owner struct{ Login string } 262 | Name string 263 | } 264 | Number uint64 265 | } `graphql:"...on PullRequest"` 266 | Commit struct { 267 | OID string 268 | Message string 269 | Author struct { 270 | AvatarURL string `graphql:"avatarUrl(size:96)"` 271 | } 272 | URL string 273 | } `graphql:"...on Commit"` 274 | } 275 | } `graphql:"...on ClosedEvent"` 276 | ReopenedEvent struct { 277 | event 278 | } `graphql:"...on ReopenedEvent"` 279 | RenamedTitleEvent struct { 280 | event 281 | CurrentTitle string 282 | PreviousTitle string 283 | } `graphql:"...on RenamedTitleEvent"` 284 | LabeledEvent struct { 285 | event 286 | Label struct { 287 | Name string 288 | Color string 289 | } 290 | } `graphql:"...on LabeledEvent"` 291 | UnlabeledEvent struct { 292 | event 293 | Label struct { 294 | Name string 295 | Color string 296 | } 297 | } `graphql:"...on UnlabeledEvent"` 298 | } 299 | PageInfo struct { 300 | EndCursor githubv4.String 301 | HasNextPage githubv4.Boolean 302 | } 303 | } `graphql:"timeline(first:100,after:$timelineCursor)"` 304 | } `graphql:"issue(number:$issueNumber)"` 305 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 306 | Viewer githubV4User 307 | } 308 | variables := map[string]interface{}{ 309 | "repositoryOwner": githubv4.String(repo.Owner), 310 | "repositoryName": githubv4.String(repo.Repo), 311 | "issueNumber": githubv4.Int(id), 312 | "firstPage": githubv4.Boolean(true), 313 | "timelineCursor": (*githubv4.String)(nil), 314 | } 315 | var timeline []interface{} // Of type issues.Comment and issues.Event. 316 | for { 317 | err := s.clV4.Query(ctx, &q, variables) 318 | if err != nil { 319 | return timeline, err 320 | } 321 | if variables["firstPage"].(githubv4.Boolean) { 322 | issue := q.Repository.Issue.comment // Issue description comment. 323 | var edited *issues.Edited 324 | if issue.LastEditedAt != nil { 325 | edited = &issues.Edited{ 326 | By: ghActor(issue.Editor), 327 | At: issue.LastEditedAt.Time, 328 | } 329 | } 330 | timeline = append(timeline, issues.Comment{ 331 | ID: issueDescriptionCommentID, 332 | User: ghActor(issue.Author), 333 | CreatedAt: issue.PublishedAt.Time, 334 | Edited: edited, 335 | Body: issue.Body, 336 | Reactions: ghReactions(issue.ReactionGroups, ghUser(&q.Viewer)), 337 | Editable: issue.ViewerCanUpdate, 338 | }) 339 | } 340 | for _, n := range q.Repository.Issue.Timeline.Nodes { 341 | switch n.Typename { 342 | case "IssueComment": 343 | comment := n.IssueComment 344 | var edited *issues.Edited 345 | if comment.LastEditedAt != nil { 346 | edited = &issues.Edited{ 347 | By: ghActor(comment.Editor), 348 | At: comment.LastEditedAt.Time, 349 | } 350 | } 351 | timeline = append(timeline, issues.Comment{ 352 | ID: comment.DatabaseID, 353 | User: ghActor(comment.Author), 354 | CreatedAt: comment.PublishedAt.Time, 355 | Edited: edited, 356 | Body: comment.Body, 357 | Reactions: ghReactions(comment.ReactionGroups, ghUser(&q.Viewer)), 358 | Editable: comment.ViewerCanUpdate, 359 | }) 360 | default: 361 | et := ghEventType(n.Typename) 362 | if !et.Valid() { 363 | continue 364 | } 365 | e := issues.Event{ 366 | //ID: 0, // TODO. 367 | Type: et, 368 | } 369 | switch et { 370 | case issues.Closed: 371 | e.Actor = ghActor(n.ClosedEvent.Actor) 372 | e.CreatedAt = n.ClosedEvent.CreatedAt.Time 373 | switch n.ClosedEvent.Closer.Typename { 374 | case "PullRequest": 375 | pr := n.ClosedEvent.Closer.PullRequest 376 | e.Close = issues.Close{ 377 | Closer: issues.Change{ 378 | State: ghPRState(pr.State), 379 | Title: pr.Title, 380 | HTMLURL: s.rtr.PullRequestURL(ctx, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), 381 | }, 382 | } 383 | case "Commit": 384 | c := n.ClosedEvent.Closer.Commit 385 | e.Close = issues.Close{ 386 | Closer: issues.Commit{ 387 | SHA: c.OID, 388 | Message: c.Message, 389 | AuthorAvatarURL: c.Author.AvatarURL, 390 | HTMLURL: c.URL, 391 | }, 392 | } 393 | default: 394 | e.Close = issues.Close{} 395 | } 396 | case issues.Reopened: 397 | e.Actor = ghActor(n.ReopenedEvent.Actor) 398 | e.CreatedAt = n.ReopenedEvent.CreatedAt.Time 399 | case issues.Renamed: 400 | e.Actor = ghActor(n.RenamedTitleEvent.Actor) 401 | e.CreatedAt = n.RenamedTitleEvent.CreatedAt.Time 402 | e.Rename = &issues.Rename{ 403 | From: n.RenamedTitleEvent.PreviousTitle, 404 | To: n.RenamedTitleEvent.CurrentTitle, 405 | } 406 | case issues.Labeled: 407 | e.Actor = ghActor(n.LabeledEvent.Actor) 408 | e.CreatedAt = n.LabeledEvent.CreatedAt.Time 409 | e.Label = &issues.Label{ 410 | Name: n.LabeledEvent.Label.Name, 411 | Color: ghColor(n.LabeledEvent.Label.Color), 412 | } 413 | case issues.Unlabeled: 414 | e.Actor = ghActor(n.UnlabeledEvent.Actor) 415 | e.CreatedAt = n.UnlabeledEvent.CreatedAt.Time 416 | e.Label = &issues.Label{ 417 | Name: n.UnlabeledEvent.Label.Name, 418 | Color: ghColor(n.UnlabeledEvent.Label.Color), 419 | } 420 | default: 421 | continue 422 | } 423 | timeline = append(timeline, e) 424 | } 425 | } 426 | if !q.Repository.Issue.Timeline.PageInfo.HasNextPage { 427 | break 428 | } 429 | variables["firstPage"] = githubv4.Boolean(false) 430 | variables["timelineCursor"] = githubv4.NewString(q.Repository.Issue.Timeline.PageInfo.EndCursor) 431 | } 432 | // We can't just delegate pagination to GitHub because our timeline items may not match up 1:1, 433 | // e.g., we want to skip Commit in the timeline, etc. (At least for now; may reconsider later.) 434 | if opt != nil { 435 | start := opt.Start 436 | if start > len(timeline) { 437 | start = len(timeline) 438 | } 439 | end := opt.Start + opt.Length 440 | if end > len(timeline) { 441 | end = len(timeline) 442 | } 443 | timeline = timeline[start:end] 444 | } 445 | return timeline, nil 446 | } 447 | 448 | func (s service) CreateComment(ctx context.Context, rs issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) { 449 | repo, err := ghRepoSpec(rs) 450 | if err != nil { 451 | // TODO: Map to 400 Bad Request HTTP error. 452 | return issues.Comment{}, err 453 | } 454 | var q struct { 455 | Repository struct { 456 | Issue struct { 457 | ID githubv4.ID 458 | } `graphql:"issue(number:$issueNumber)"` 459 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 460 | } 461 | variables := map[string]interface{}{ 462 | "repositoryOwner": githubv4.String(repo.Owner), 463 | "repositoryName": githubv4.String(repo.Repo), 464 | "issueNumber": githubv4.Int(id), 465 | } 466 | err = s.clV4.Query(ctx, &q, variables) 467 | if err != nil { 468 | return issues.Comment{}, err 469 | } 470 | var m struct { 471 | AddComment struct { 472 | CommentEdge struct { 473 | Node struct { 474 | DatabaseID githubv4.Int 475 | Author *githubV4Actor 476 | PublishedAt githubv4.DateTime 477 | Body githubv4.String 478 | ViewerCanUpdate githubv4.Boolean 479 | } 480 | } 481 | } `graphql:"addComment(input:$input)"` 482 | } 483 | input := githubv4.AddCommentInput{ 484 | SubjectID: q.Repository.Issue.ID, 485 | Body: githubv4.String(c.Body), 486 | } 487 | err = s.clV4.Mutate(ctx, &m, input, nil) 488 | if err != nil { 489 | return issues.Comment{}, err 490 | } 491 | comment := m.AddComment.CommentEdge.Node 492 | return issues.Comment{ 493 | ID: uint64(comment.DatabaseID), 494 | User: ghActor(comment.Author), 495 | CreatedAt: comment.PublishedAt.Time, 496 | Body: string(comment.Body), 497 | Editable: bool(comment.ViewerCanUpdate), 498 | }, nil 499 | } 500 | 501 | func (s service) Create(ctx context.Context, rs issues.RepoSpec, i issues.Issue) (issues.Issue, error) { 502 | repo, err := ghRepoSpec(rs) 503 | if err != nil { 504 | return issues.Issue{}, err 505 | } 506 | issue, _, err := s.clV3.Issues.Create(ctx, repo.Owner, repo.Repo, &githubv3.IssueRequest{ 507 | Title: &i.Title, 508 | Body: &i.Body, 509 | }) 510 | if err != nil { 511 | return issues.Issue{}, err 512 | } 513 | 514 | return issues.Issue{ 515 | ID: uint64(*issue.Number), 516 | State: issues.State(*issue.State), 517 | Title: *issue.Title, 518 | Comment: issues.Comment{ 519 | ID: issueDescriptionCommentID, 520 | User: ghV3User(*issue.User), 521 | CreatedAt: *issue.CreatedAt, 522 | Editable: true, // You can always edit issues you've created. 523 | }, 524 | }, nil 525 | } 526 | 527 | func (s service) Edit(ctx context.Context, rs issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) { 528 | // TODO: Why Validate here but not Create, etc.? Figure this out. Might only be needed in fs implementation. 529 | if err := ir.Validate(); err != nil { 530 | // TODO: Map to 400 Bad Request HTTP error. 531 | return issues.Issue{}, nil, err 532 | } 533 | repo, err := ghRepoSpec(rs) 534 | if err != nil { 535 | // TODO: Map to 400 Bad Request HTTP error. 536 | return issues.Issue{}, nil, err 537 | } 538 | 539 | // Fetch issue state and title before the edit, as well as current user. 540 | var q struct { 541 | Repository struct { 542 | Issue struct { 543 | State githubv4.IssueState 544 | Title string 545 | } `graphql:"issue(number:$issueNumber)"` 546 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 547 | Viewer githubV4User 548 | } 549 | variables := map[string]interface{}{ 550 | "repositoryOwner": githubv4.String(repo.Owner), 551 | "repositoryName": githubv4.String(repo.Repo), 552 | "issueNumber": githubv4.Int(id), 553 | } 554 | err = s.clV4.Query(ctx, &q, variables) 555 | if err != nil { 556 | return issues.Issue{}, nil, err 557 | } 558 | beforeEdit := q.Repository.Issue 559 | 560 | ghIR := githubv3.IssueRequest{ 561 | Title: ir.Title, 562 | } 563 | if ir.State != nil { 564 | ghIR.State = githubv3.String(string(*ir.State)) 565 | } 566 | 567 | issue, _, err := s.clV3.Issues.Edit(ctx, repo.Owner, repo.Repo, int(id), &ghIR) 568 | if err != nil { 569 | return issues.Issue{}, nil, err 570 | } 571 | 572 | // GitHub API doesn't return the event that will be generated as a result, so we predict what it'll be. 573 | event := issues.Event{ 574 | // TODO: Figure out if event ID needs to be set, and if so, how to best do that... 575 | Actor: ghUser(&q.Viewer), 576 | CreatedAt: time.Now().UTC(), 577 | } 578 | // TODO: A single edit operation can result in multiple events, we should emit multiple events in such cases. We're currently emitting at most one event. 579 | switch { 580 | case ir.State != nil && *ir.State != ghIssueState(beforeEdit.State): 581 | switch *ir.State { 582 | case issues.OpenState: 583 | event.Type = issues.Reopened 584 | case issues.ClosedState: 585 | event.Type = issues.Closed 586 | } 587 | case ir.Title != nil && *ir.Title != beforeEdit.Title: 588 | event.Type = issues.Renamed 589 | event.Rename = &issues.Rename{ 590 | From: beforeEdit.Title, 591 | To: *ir.Title, 592 | } 593 | } 594 | var events []issues.Event 595 | if event.Type != "" { 596 | events = append(events, event) 597 | } 598 | 599 | return issues.Issue{ 600 | ID: uint64(*issue.Number), 601 | State: issues.State(*issue.State), 602 | Title: *issue.Title, 603 | Comment: issues.Comment{ 604 | ID: issueDescriptionCommentID, 605 | User: ghV3User(*issue.User), 606 | CreatedAt: *issue.CreatedAt, 607 | Editable: true, // You can always edit issues you've edited. 608 | }, 609 | }, events, nil 610 | } 611 | 612 | func (s service) EditComment(ctx context.Context, rs issues.RepoSpec, id uint64, cr issues.CommentRequest) (issues.Comment, error) { 613 | // TODO: Why Validate here but not CreateComment, etc.? Figure this out. Might only be needed in fs implementation. 614 | if _, err := cr.Validate(); err != nil { 615 | return issues.Comment{}, err 616 | } 617 | repo, err := ghRepoSpec(rs) 618 | if err != nil { 619 | return issues.Comment{}, err 620 | } 621 | 622 | if cr.ID == issueDescriptionCommentID { 623 | var comment issues.Comment 624 | 625 | // Apply edits. 626 | if cr.Body != nil { 627 | // Use Issues.Edit() API to edit comment 0 (the issue description). 628 | issue, _, err := s.clV3.Issues.Edit(ctx, repo.Owner, repo.Repo, int(id), &githubv3.IssueRequest{ 629 | Body: cr.Body, 630 | }) 631 | if err != nil { 632 | return issues.Comment{}, err 633 | } 634 | 635 | var edited *issues.Edited 636 | /* TODO: Get the actual edited information once GitHub API allows it. Can't use issue.UpdatedAt because of false positives, since it includes the entire issue, not just its comment body. 637 | if !issue.UpdatedAt.Equal(*issue.CreatedAt) { 638 | edited = &issues.Edited{ 639 | By: users.User{Login: "Someone"}, //ghV3User(*issue.Actor), // TODO: Get the actual actor once GitHub API allows it. 640 | At: *issue.UpdatedAt, 641 | } 642 | }*/ 643 | // TODO: Consider setting reactions? But it's semi-expensive (to fetch all user details) and not used by app... 644 | comment.ID = issueDescriptionCommentID 645 | comment.User = ghV3User(*issue.User) 646 | comment.CreatedAt = *issue.CreatedAt 647 | comment.Edited = edited 648 | comment.Body = *issue.Body 649 | comment.Editable = true // You can always edit comments you've edited. 650 | } 651 | if cr.Reaction != nil { 652 | reactionContent, err := externalizeReaction(*cr.Reaction) 653 | if err != nil { 654 | return issues.Comment{}, err 655 | } 656 | // See if user has already reacted with that reaction. 657 | // If not, add it. Otherwise, remove it. 658 | var q struct { 659 | Repository struct { 660 | Issue struct { 661 | ID githubv4.ID 662 | Reactions struct { 663 | ViewerHasReacted githubv4.Boolean 664 | } `graphql:"reactions(content:$reactionContent)"` 665 | } `graphql:"issue(number:$issueNumber)"` 666 | } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` 667 | Viewer githubV4User 668 | } 669 | variables := map[string]interface{}{ 670 | "repositoryOwner": githubv4.String(repo.Owner), 671 | "repositoryName": githubv4.String(repo.Repo), 672 | "issueNumber": githubv4.Int(id), 673 | "reactionContent": reactionContent, 674 | } 675 | err = s.clV4.Query(ctx, &q, variables) 676 | if err != nil { 677 | return issues.Comment{}, err 678 | } 679 | 680 | var rgs reactionGroups 681 | if !q.Repository.Issue.Reactions.ViewerHasReacted { 682 | // Add reaction. 683 | var m struct { 684 | AddReaction struct { 685 | Subject struct { 686 | ReactionGroups reactionGroups 687 | } 688 | } `graphql:"addReaction(input:$input)"` 689 | } 690 | input := githubv4.AddReactionInput{ 691 | SubjectID: q.Repository.Issue.ID, 692 | Content: reactionContent, 693 | } 694 | err := s.clV4.Mutate(ctx, &m, input, nil) 695 | if err != nil { 696 | return issues.Comment{}, err 697 | } 698 | rgs = m.AddReaction.Subject.ReactionGroups 699 | } else { 700 | // Remove reaction. 701 | var m struct { 702 | RemoveReaction struct { 703 | Subject struct { 704 | ReactionGroups reactionGroups 705 | } 706 | } `graphql:"removeReaction(input:$input)"` 707 | } 708 | input := githubv4.RemoveReactionInput{ 709 | SubjectID: q.Repository.Issue.ID, 710 | Content: reactionContent, 711 | } 712 | err := s.clV4.Mutate(ctx, &m, input, nil) 713 | if err != nil { 714 | return issues.Comment{}, err 715 | } 716 | rgs = m.RemoveReaction.Subject.ReactionGroups 717 | } 718 | // TODO: Consider setting other fields? But it's semi-expensive (another API call) and not used by app... 719 | // Actually, now that using GraphQL, no longer that expensive (can be same API call). 720 | comment.Reactions = ghReactions(rgs, ghUser(&q.Viewer)) 721 | } 722 | 723 | return comment, nil 724 | } 725 | 726 | var comment issues.Comment 727 | 728 | // Apply edits. 729 | if cr.Body != nil { 730 | // GitHub API uses comment ID and doesn't need issue ID. Comment IDs are unique per repo (rather than per issue). 731 | ghComment, _, err := s.clV3.Issues.EditComment(ctx, repo.Owner, repo.Repo, int64(cr.ID), &githubv3.IssueComment{ 732 | Body: cr.Body, 733 | }) 734 | if err != nil { 735 | return issues.Comment{}, err 736 | } 737 | 738 | var edited *issues.Edited 739 | if !ghComment.UpdatedAt.Equal(*ghComment.CreatedAt) { 740 | edited = &issues.Edited{ 741 | By: users.User{Login: "Someone"}, //ghV3User(*ghComment.Actor), // TODO: Get the actual actor once GitHub API allows it. 742 | At: *ghComment.UpdatedAt, 743 | } 744 | } 745 | // TODO: Consider setting reactions? But it's semi-expensive (to fetch all user details) and not used by app... 746 | comment.ID = uint64(*ghComment.ID) 747 | comment.User = ghV3User(*ghComment.User) 748 | comment.CreatedAt = *ghComment.CreatedAt 749 | comment.Edited = edited 750 | comment.Body = *ghComment.Body 751 | comment.Editable = true // You can always edit comments you've edited. 752 | } 753 | if cr.Reaction != nil { 754 | reactionContent, err := externalizeReaction(*cr.Reaction) 755 | if err != nil { 756 | return issues.Comment{}, err 757 | } 758 | commentID := githubv4.ID(base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("012:IssueComment%d", cr.ID)))) // HACK, TODO: Confirm StdEncoding vs URLEncoding. 759 | // See if user has already reacted with that reaction. 760 | // If not, add it. Otherwise, remove it. 761 | var q struct { 762 | Node struct { 763 | IssueComment struct { 764 | Reactions struct { 765 | ViewerHasReacted githubv4.Boolean 766 | } `graphql:"reactions(content:$reactionContent)"` 767 | } `graphql:"...on IssueComment"` 768 | } `graphql:"node(id:$commentID)"` 769 | Viewer githubV4User 770 | } 771 | variables := map[string]interface{}{ 772 | "commentID": commentID, 773 | "reactionContent": reactionContent, 774 | } 775 | err = s.clV4.Query(ctx, &q, variables) 776 | if err != nil { 777 | return issues.Comment{}, err 778 | } 779 | 780 | var rgs reactionGroups 781 | if !q.Node.IssueComment.Reactions.ViewerHasReacted { 782 | // Add reaction. 783 | var m struct { 784 | AddReaction struct { 785 | Subject struct { 786 | ReactionGroups reactionGroups 787 | } 788 | } `graphql:"addReaction(input:$input)"` 789 | } 790 | input := githubv4.AddReactionInput{ 791 | SubjectID: commentID, 792 | Content: reactionContent, 793 | } 794 | err := s.clV4.Mutate(ctx, &m, input, nil) 795 | if err != nil { 796 | return issues.Comment{}, err 797 | } 798 | rgs = m.AddReaction.Subject.ReactionGroups 799 | } else { 800 | // Remove reaction. 801 | var m struct { 802 | RemoveReaction struct { 803 | Subject struct { 804 | ReactionGroups reactionGroups 805 | } 806 | } `graphql:"removeReaction(input:$input)"` 807 | } 808 | input := githubv4.RemoveReactionInput{ 809 | SubjectID: commentID, 810 | Content: reactionContent, 811 | } 812 | err := s.clV4.Mutate(ctx, &m, input, nil) 813 | if err != nil { 814 | return issues.Comment{}, err 815 | } 816 | rgs = m.RemoveReaction.Subject.ReactionGroups 817 | } 818 | // TODO: Consider setting other fields? But it's semi-expensive (another API call) and not used by app... 819 | // Actually, now that using GraphQL, no longer that expensive (can be same API call). 820 | comment.Reactions = ghReactions(rgs, ghUser(&q.Viewer)) 821 | } 822 | 823 | return comment, nil 824 | } 825 | 826 | type repoSpec struct { 827 | Owner string 828 | Repo string 829 | } 830 | 831 | func ghRepoSpec(repo issues.RepoSpec) (repoSpec, error) { 832 | // The "github.com/" prefix is expected to be included. 833 | ghOwnerRepo := strings.Split(repo.URI, "/") 834 | if len(ghOwnerRepo) != 3 || ghOwnerRepo[0] != "github.com" || ghOwnerRepo[1] == "" || ghOwnerRepo[2] == "" { 835 | return repoSpec{}, fmt.Errorf(`RepoSpec is not of form "github.com/owner/repo": %q`, repo.URI) 836 | } 837 | return repoSpec{ 838 | Owner: ghOwnerRepo[1], 839 | Repo: ghOwnerRepo[2], 840 | }, nil 841 | } 842 | 843 | type githubV4Actor struct { 844 | User struct { 845 | DatabaseID uint64 846 | } `graphql:"...on User"` 847 | Bot struct { 848 | DatabaseID uint64 849 | } `graphql:"...on Bot"` 850 | Login string 851 | AvatarURL string `graphql:"avatarUrl(size:96)"` 852 | URL string 853 | } 854 | 855 | func ghActor(actor *githubV4Actor) users.User { 856 | if actor == nil { 857 | return ghost // Deleted user, replace with https://github.com/ghost. 858 | } 859 | return users.User{ 860 | UserSpec: users.UserSpec{ 861 | ID: actor.User.DatabaseID | actor.Bot.DatabaseID, 862 | Domain: "github.com", 863 | }, 864 | Login: actor.Login, 865 | AvatarURL: actor.AvatarURL, 866 | HTMLURL: actor.URL, 867 | } 868 | } 869 | 870 | type githubV4User struct { 871 | DatabaseID uint64 872 | Login string 873 | AvatarURL string `graphql:"avatarUrl(size:96)"` 874 | URL string 875 | } 876 | 877 | func ghUser(user *githubV4User) users.User { 878 | if user == nil { 879 | return ghost // Deleted user, replace with https://github.com/ghost. 880 | } 881 | return users.User{ 882 | UserSpec: users.UserSpec{ 883 | ID: user.DatabaseID, 884 | Domain: "github.com", 885 | }, 886 | Login: user.Login, 887 | AvatarURL: user.AvatarURL, 888 | HTMLURL: user.URL, 889 | } 890 | } 891 | 892 | func ghV3User(user githubv3.User) users.User { 893 | if *user.ID == 0 { 894 | return ghost // Deleted user, replace with https://github.com/ghost. 895 | } 896 | return users.User{ 897 | UserSpec: users.UserSpec{ 898 | ID: uint64(*user.ID), 899 | Domain: "github.com", 900 | }, 901 | Login: *user.Login, 902 | AvatarURL: *user.AvatarURL, 903 | HTMLURL: *user.HTMLURL, 904 | } 905 | } 906 | 907 | // ghost is https://github.com/ghost, a replacement for deleted users. 908 | var ghost = users.User{ 909 | UserSpec: users.UserSpec{ 910 | ID: 10137, 911 | Domain: "github.com", 912 | }, 913 | Login: "ghost", 914 | AvatarURL: "https://avatars3.githubusercontent.com/u/10137?v=4", 915 | HTMLURL: "https://github.com/ghost", 916 | } 917 | 918 | // ghIssueState converts a GitHub IssueState to issues.State. 919 | func ghIssueState(state githubv4.IssueState) issues.State { 920 | switch state { 921 | case githubv4.IssueStateOpen: 922 | return issues.OpenState 923 | case githubv4.IssueStateClosed: 924 | return issues.ClosedState 925 | default: 926 | panic("unreachable") 927 | } 928 | } 929 | 930 | // ghPRState converts a GitHub PullRequestState to state.Change. 931 | func ghPRState(prState githubv4.PullRequestState) state.Change { 932 | switch prState { 933 | case githubv4.PullRequestStateOpen: 934 | return state.ChangeOpen 935 | case githubv4.PullRequestStateClosed: 936 | return state.ChangeClosed 937 | case githubv4.PullRequestStateMerged: 938 | return state.ChangeMerged 939 | default: 940 | panic("unreachable") 941 | } 942 | } 943 | 944 | func ghEventType(typename string) issues.EventType { 945 | switch typename { 946 | case "ReopenedEvent": // TODO: Use githubv4.IssueTimelineItemReopenedEvent or so. 947 | return issues.Reopened 948 | case "ClosedEvent": // TODO: Use githubv4.IssueTimelineItemClosedEvent or so. 949 | return issues.Closed 950 | case "RenamedTitleEvent": 951 | return issues.Renamed 952 | case "LabeledEvent": 953 | return issues.Labeled 954 | case "UnlabeledEvent": 955 | return issues.Unlabeled 956 | case "CommentDeletedEvent": 957 | return issues.CommentDeleted 958 | default: 959 | return issues.EventType(typename) 960 | } 961 | } 962 | 963 | // ghColor converts a GitHub color hex string like "ff0000" 964 | // into an issues.RGB value. 965 | func ghColor(hex string) issues.RGB { 966 | var c issues.RGB 967 | fmt.Sscanf(hex, "%02x%02x%02x", &c.R, &c.G, &c.B) 968 | return c 969 | } 970 | -------------------------------------------------------------------------------- /githubapi/notifications.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shurcooL/issues" 7 | "github.com/shurcooL/notifications" 8 | ) 9 | 10 | // threadType is the notifications thread type for this service. 11 | const threadType = "Issue" 12 | 13 | // ThreadType returns the notifications thread type for this service. 14 | func (service) ThreadType(issues.RepoSpec) string { return threadType } 15 | 16 | // markRead marks the specified issue as read for current user. 17 | func (s service) markRead(ctx context.Context, repo issues.RepoSpec, id uint64) error { 18 | if s.notifications == nil { 19 | return nil 20 | } 21 | 22 | return s.notifications.MarkRead(ctx, notifications.RepoSpec(repo), threadType, id) 23 | } 24 | -------------------------------------------------------------------------------- /githubapi/reactions.go: -------------------------------------------------------------------------------- 1 | package githubapi 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/shurcooL/githubv4" 7 | "github.com/shurcooL/reactions" 8 | "github.com/shurcooL/users" 9 | ) 10 | 11 | type reactionGroups []struct { 12 | Content githubv4.ReactionContent 13 | Users struct { 14 | Nodes []*githubV4User 15 | TotalCount int 16 | } `graphql:"users(first:10)"` 17 | ViewerHasReacted bool 18 | } 19 | 20 | // ghReactions converts []githubv4.ReactionGroup to []reactions.Reaction. 21 | func ghReactions(rgs reactionGroups, viewer users.User) []reactions.Reaction { 22 | var rs []reactions.Reaction 23 | for _, rg := range rgs { 24 | if rg.Users.TotalCount == 0 { 25 | continue 26 | } 27 | 28 | // Only return the details of first few users and viewer. 29 | var us []users.User 30 | addedViewer := false 31 | for i := 0; i < rg.Users.TotalCount; i++ { 32 | if i < len(rg.Users.Nodes) { 33 | user := ghUser(rg.Users.Nodes[i]) 34 | us = append(us, user) 35 | if user.UserSpec == viewer.UserSpec { 36 | addedViewer = true 37 | } 38 | } else if i == len(rg.Users.Nodes) { 39 | // Add viewer last if they've reacted, but haven't been added already. 40 | if rg.ViewerHasReacted && !addedViewer { 41 | us = append(us, viewer) 42 | } else { 43 | us = append(us, users.User{}) 44 | } 45 | } else { 46 | us = append(us, users.User{}) 47 | } 48 | } 49 | 50 | rs = append(rs, reactions.Reaction{ 51 | Reaction: internalizeReaction(rg.Content), 52 | Users: us, 53 | }) 54 | } 55 | return rs 56 | } 57 | 58 | // internalizeReaction converts githubv4.ReactionContent to reactions.EmojiID. 59 | func internalizeReaction(reaction githubv4.ReactionContent) reactions.EmojiID { 60 | switch reaction { 61 | case githubv4.ReactionContentThumbsUp: 62 | return "+1" 63 | case githubv4.ReactionContentThumbsDown: 64 | return "-1" 65 | case githubv4.ReactionContentLaugh: 66 | return "smile" 67 | case githubv4.ReactionContentHooray: 68 | return "tada" 69 | case githubv4.ReactionContentConfused: 70 | return "confused" 71 | case githubv4.ReactionContentHeart: 72 | return "heart" 73 | case githubv4.ReactionContentRocket: 74 | return "rocket" 75 | case githubv4.ReactionContentEyes: 76 | return "eyes" 77 | default: 78 | panic("unreachable") 79 | } 80 | } 81 | 82 | // externalizeReaction converts reactions.EmojiID to githubv4.ReactionContent. 83 | func externalizeReaction(reaction reactions.EmojiID) (githubv4.ReactionContent, error) { 84 | switch reaction { 85 | case "+1": 86 | return githubv4.ReactionContentThumbsUp, nil 87 | case "-1": 88 | return githubv4.ReactionContentThumbsDown, nil 89 | case "smile": 90 | return githubv4.ReactionContentLaugh, nil 91 | case "tada": 92 | return githubv4.ReactionContentHooray, nil 93 | case "confused": 94 | return githubv4.ReactionContentConfused, nil 95 | case "heart": 96 | return githubv4.ReactionContentHeart, nil 97 | case "rocket": 98 | return githubv4.ReactionContentRocket, nil 99 | case "eyes": 100 | return githubv4.ReactionContentEyes, nil 101 | default: 102 | return "", fmt.Errorf("%q is an unsupported reaction", reaction) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shurcooL/issues 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /issues.go: -------------------------------------------------------------------------------- 1 | // Package issues provides an issues service definition. 2 | package issues 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/shurcooL/reactions" 11 | "github.com/shurcooL/users" 12 | ) 13 | 14 | // RepoSpec is a specification for a repository. 15 | type RepoSpec struct { 16 | URI string // URI is clean '/'-separated URI. E.g., "example.com/user/repo". 17 | } 18 | 19 | // String implements fmt.Stringer. 20 | func (rs RepoSpec) String() string { 21 | return rs.URI 22 | } 23 | 24 | // Service defines methods of an issue tracking service. 25 | type Service interface { 26 | // List issues. 27 | List(ctx context.Context, repo RepoSpec, opt IssueListOptions) ([]Issue, error) 28 | // Count issues. 29 | Count(ctx context.Context, repo RepoSpec, opt IssueListOptions) (uint64, error) 30 | 31 | // Get an issue. 32 | Get(ctx context.Context, repo RepoSpec, id uint64) (Issue, error) 33 | 34 | // TODO: After some time, if ListTimeline proves to be a good replacement for ListComments 35 | // and ListEvents, replace them here to simplify things. 36 | 37 | // ListComments lists comments for specified issue id. 38 | ListComments(ctx context.Context, repo RepoSpec, id uint64, opt *ListOptions) ([]Comment, error) 39 | // ListEvents lists events for specified issue id. 40 | ListEvents(ctx context.Context, repo RepoSpec, id uint64, opt *ListOptions) ([]Event, error) 41 | 42 | // Create a new issue. 43 | Create(ctx context.Context, repo RepoSpec, issue Issue) (Issue, error) 44 | // CreateComment creates a new comment for specified issue id. 45 | CreateComment(ctx context.Context, repo RepoSpec, id uint64, comment Comment) (Comment, error) 46 | 47 | // Edit the specified issue id. 48 | Edit(ctx context.Context, repo RepoSpec, id uint64, ir IssueRequest) (Issue, []Event, error) 49 | // EditComment edits comment of specified issue id. 50 | EditComment(ctx context.Context, repo RepoSpec, id uint64, cr CommentRequest) (Comment, error) 51 | } 52 | 53 | // TimelineLister is an optional interface that combines ListComments and ListEvents methods into one 54 | // that includes both. It's available for situations where this is more efficient to implement. 55 | type TimelineLister interface { 56 | // IsTimelineLister reports whether the underlying service implements TimelineLister 57 | // fully for the specified repo. 58 | IsTimelineLister(repo RepoSpec) bool 59 | 60 | // ListTimeline lists timeline items (Comment, Event) for specified issue id 61 | // in chronological order, if IsTimelineLister(repo) reported positively. 62 | // The issue description comes first in a timeline. 63 | ListTimeline(ctx context.Context, repo RepoSpec, id uint64, opt *ListOptions) ([]interface{}, error) 64 | } 65 | 66 | // CopierFrom is an optional interface that allows copying issues between services. 67 | type CopierFrom interface { 68 | // CopyFrom copies all issues from src for specified repo. 69 | // ctx should provide permission to access all issues in src. 70 | CopyFrom(ctx context.Context, src Service, repo RepoSpec) error 71 | } 72 | 73 | // Issue represents an issue on a repository. 74 | type Issue struct { 75 | ID uint64 76 | State State 77 | Title string 78 | Labels []Label 79 | Comment 80 | Replies int // Number of replies to this issue (not counting the mandatory issue description comment). 81 | } 82 | 83 | // Label represents a label. 84 | type Label struct { 85 | Name string 86 | Color RGB 87 | } 88 | 89 | // TODO: Dedup. 90 | // 91 | // RGB represents a 24-bit color without alpha channel. 92 | type RGB struct { 93 | R, G, B uint8 94 | } 95 | 96 | func (c RGB) RGBA() (r, g, b, a uint32) { 97 | r = uint32(c.R) 98 | r |= r << 8 99 | g = uint32(c.G) 100 | g |= g << 8 101 | b = uint32(c.B) 102 | b |= b << 8 103 | a = uint32(255) 104 | a |= a << 8 105 | return 106 | } 107 | 108 | // HexString returns a hexadecimal color string. For example, "#ff0000" for red. 109 | func (c RGB) HexString() string { 110 | return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) 111 | } 112 | 113 | // Milestone represents a milestone. 114 | type Milestone struct { 115 | Name string 116 | } 117 | 118 | // Comment represents a comment left on an issue. 119 | type Comment struct { 120 | ID uint64 121 | User users.User 122 | CreatedAt time.Time 123 | Edited *Edited // Edited is nil if the comment hasn't been edited. 124 | Body string 125 | Reactions []reactions.Reaction 126 | Editable bool // Editable represents whether the current user (if any) can perform edit operations on this comment (or the encompassing issue). 127 | } 128 | 129 | // Edited provides the actor and timing information for an edited item. 130 | type Edited struct { 131 | By users.User 132 | At time.Time 133 | } 134 | 135 | // IssueRequest is a request to edit an issue. 136 | // To edit the body, use EditComment with comment ID 0. 137 | type IssueRequest struct { 138 | State *State 139 | Title *string 140 | // TODO: Labels *[]Label 141 | } 142 | 143 | // CommentRequest is a request to edit a comment. 144 | type CommentRequest struct { 145 | ID uint64 146 | Body *string // If not nil, set the body. 147 | Reaction *reactions.EmojiID // If not nil, toggle this reaction. 148 | } 149 | 150 | // State represents the issue state. 151 | type State string 152 | 153 | const ( 154 | // OpenState is when an issue is open. 155 | OpenState State = "open" 156 | // ClosedState is when an issue is closed. 157 | ClosedState State = "closed" 158 | ) 159 | 160 | // Validate returns non-nil error if the issue is invalid. 161 | func (i Issue) Validate() error { 162 | if strings.TrimSpace(i.Title) == "" { 163 | return fmt.Errorf("title can't be blank or all whitespace") 164 | } 165 | return nil 166 | } 167 | 168 | // Validate returns non-nil error if the issue request is invalid. 169 | func (ir IssueRequest) Validate() error { 170 | if ir.State != nil { 171 | switch *ir.State { 172 | case OpenState, ClosedState: 173 | default: 174 | return fmt.Errorf("bad state") 175 | } 176 | } 177 | if ir.Title != nil { 178 | if strings.TrimSpace(*ir.Title) == "" { 179 | return fmt.Errorf("title can't be blank or all whitespace") 180 | } 181 | } 182 | return nil 183 | } 184 | 185 | // Validate returns non-nil error if the comment is invalid. 186 | func (c Comment) Validate() error { 187 | // TODO: Issue descriptions can have blank bodies, support that (primarily for editing comments). 188 | if strings.TrimSpace(c.Body) == "" { 189 | return fmt.Errorf("comment body can't be blank or all whitespace") 190 | } 191 | return nil 192 | } 193 | 194 | // Validate validates the comment edit request, returning an non-nil error if it's invalid. 195 | // requiresEdit reports if the edit request needs edit rights or if it can be done by anyone that can react. 196 | func (cr CommentRequest) Validate() (requiresEdit bool, err error) { 197 | if cr.Body != nil { 198 | requiresEdit = true 199 | 200 | // TODO: Issue descriptions can have blank bodies, support that (primarily for editing comments). 201 | if strings.TrimSpace(*cr.Body) == "" { 202 | return requiresEdit, fmt.Errorf("comment body can't be blank or all whitespace") 203 | } 204 | } 205 | /*if cr.Reaction != nil { 206 | // TODO: Maybe validate that the emojiID is one of supported ones. 207 | // Or maybe not (unsupported ones can be handled by frontend component). 208 | // That way custom emoji can be added/removed, etc. Figure out what the best thing to do is and do it. 209 | }*/ 210 | return requiresEdit, nil 211 | } 212 | -------------------------------------------------------------------------------- /maintner/maintner.go: -------------------------------------------------------------------------------- 1 | // Package maintner implements a read-only issues.Service using 2 | // a x/build/maintner corpus. 3 | package maintner 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "os" 9 | "sort" 10 | "strings" 11 | 12 | "github.com/shurcooL/issues" 13 | "github.com/shurcooL/users" 14 | "golang.org/x/build/maintner" 15 | ) 16 | 17 | // NewService creates an issues.Service backed with the given corpus. 18 | func NewService(corpus *maintner.Corpus) issues.Service { 19 | return service{ 20 | c: corpus, 21 | } 22 | } 23 | 24 | type service struct { 25 | c *maintner.Corpus 26 | } 27 | 28 | func (s service) List(_ context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) { 29 | // TODO: Pagination. 30 | 31 | repoID, err := ghRepoID(rs) 32 | if err != nil { 33 | return nil, err 34 | } 35 | s.c.RLock() 36 | defer s.c.RUnlock() 37 | repo := s.c.GitHub().Repo(repoID.Owner, repoID.Repo) 38 | if repo == nil { 39 | return nil, fmt.Errorf("repo %v not found", rs) 40 | } 41 | 42 | var is []issues.Issue 43 | err = repo.ForeachIssue(func(i *maintner.GitHubIssue) error { 44 | if i.NotExist || i.PullRequest { 45 | return nil 46 | } 47 | 48 | state := ghState(i) 49 | switch { 50 | case opt.State == issues.StateFilter(issues.OpenState) && state != issues.OpenState: 51 | return nil 52 | case opt.State == issues.StateFilter(issues.ClosedState) && state != issues.ClosedState: 53 | return nil 54 | } 55 | 56 | var labels []issues.Label 57 | for _, l := range i.Labels { 58 | labels = append(labels, issues.Label{ 59 | Name: l.Name, 60 | // TODO: Can we use label ID to figure out its color? 61 | Color: issues.RGB{R: 0xed, G: 0xed, B: 0xed}, // maintner.Corpus doesn't support GitHub issue label colors, so fall back to a default light gray. 62 | }) 63 | } 64 | replies := 0 65 | err := i.ForeachComment(func(*maintner.GitHubComment) error { 66 | replies++ 67 | return nil 68 | }) 69 | if err != nil { 70 | return err 71 | } 72 | is = append(is, issues.Issue{ 73 | ID: uint64(i.Number), 74 | State: state, 75 | Title: i.Title, 76 | Labels: labels, 77 | Comment: issues.Comment{ 78 | User: ghUser(i.User), 79 | CreatedAt: i.Created, 80 | }, 81 | Replies: replies, 82 | }) 83 | return nil 84 | }) 85 | if err != nil { 86 | return nil, err 87 | } 88 | sort.Slice(is, func(i, j int) bool { return is[i].ID > is[j].ID }) 89 | return is, nil 90 | } 91 | 92 | func (s service) Count(_ context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) { 93 | repoID, err := ghRepoID(rs) 94 | if err != nil { 95 | return 0, err 96 | } 97 | s.c.RLock() 98 | defer s.c.RUnlock() 99 | repo := s.c.GitHub().Repo(repoID.Owner, repoID.Repo) 100 | if repo == nil { 101 | return 0, fmt.Errorf("repo %v not found", rs) 102 | } 103 | 104 | var count uint64 105 | err = repo.ForeachIssue(func(issue *maintner.GitHubIssue) error { 106 | if issue.NotExist || issue.PullRequest { 107 | return nil 108 | } 109 | 110 | state := ghState(issue) 111 | switch { 112 | case opt.State == issues.StateFilter(issues.OpenState) && state != issues.OpenState: 113 | return nil 114 | case opt.State == issues.StateFilter(issues.ClosedState) && state != issues.ClosedState: 115 | return nil 116 | } 117 | 118 | count++ 119 | 120 | return nil 121 | }) 122 | if err != nil { 123 | return 0, err 124 | } 125 | 126 | return count, nil 127 | } 128 | 129 | func (s service) Get(_ context.Context, rs issues.RepoSpec, id uint64) (issues.Issue, error) { 130 | repoID, err := ghRepoID(rs) 131 | if err != nil { 132 | return issues.Issue{}, err 133 | } 134 | s.c.RLock() 135 | defer s.c.RUnlock() 136 | repo := s.c.GitHub().Repo(repoID.Owner, repoID.Repo) 137 | if repo == nil { 138 | return issues.Issue{}, fmt.Errorf("repo %v not found", rs) 139 | } 140 | i := repo.Issue(int32(id)) 141 | if i == nil || i.NotExist || i.PullRequest { 142 | return issues.Issue{}, os.ErrNotExist 143 | } 144 | 145 | return issues.Issue{ 146 | ID: uint64(i.Number), 147 | State: ghState(i), 148 | Title: i.Title, 149 | Comment: issues.Comment{ 150 | User: ghUser(i.User), 151 | CreatedAt: i.Created, 152 | }, 153 | }, nil 154 | } 155 | 156 | func (s service) ListComments(_ context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) { 157 | repoID, err := ghRepoID(rs) 158 | if err != nil { 159 | return nil, err 160 | } 161 | s.c.RLock() 162 | defer s.c.RUnlock() 163 | repo := s.c.GitHub().Repo(repoID.Owner, repoID.Repo) 164 | if repo == nil { 165 | return nil, fmt.Errorf("repo %v not found", rs) 166 | } 167 | i := repo.Issue(int32(id)) 168 | if i == nil || i.NotExist || i.PullRequest { 169 | return nil, os.ErrNotExist 170 | } 171 | 172 | var cs []issues.Comment 173 | cs = append(cs, issues.Comment{ 174 | ID: 0, // We use 0 as a special ID for the comment that is the issue description. 175 | User: ghUser(i.User), 176 | CreatedAt: i.Created, 177 | // Can't use i.Updated for issue body because of false positives, since it includes the entire issue (e.g., if it was closed, that changes its Updated time). 178 | Body: i.Body, 179 | Reactions: nil, // maintner.Corpus doesn't support GitHub issue reactions. 180 | }) 181 | err = i.ForeachComment(func(c *maintner.GitHubComment) error { 182 | var edited *issues.Edited 183 | if !c.Updated.Equal(c.Created) { 184 | edited = &issues.Edited{ 185 | By: users.User{Login: "Someone"}, // maintner.Corpus doesn't expose GitHub issue comment editor user. 186 | At: c.Updated, 187 | } 188 | } 189 | cs = append(cs, issues.Comment{ 190 | ID: uint64(c.ID), 191 | User: ghUser(c.User), 192 | CreatedAt: c.Created, 193 | Edited: edited, 194 | Body: c.Body, 195 | Reactions: nil, // maintner.Corpus doesn't support GitHub issue reactions. 196 | }) 197 | return nil 198 | }) 199 | if opt != nil { 200 | // Pagination. 201 | start := opt.Start 202 | if start > len(cs) { 203 | start = len(cs) 204 | } 205 | end := opt.Start + opt.Length 206 | if end > len(cs) { 207 | end = len(cs) 208 | } 209 | cs = cs[start:end] 210 | } 211 | return cs, err 212 | } 213 | 214 | func (s service) ListEvents(_ context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) { 215 | repoID, err := ghRepoID(rs) 216 | if err != nil { 217 | return nil, err 218 | } 219 | s.c.RLock() 220 | defer s.c.RUnlock() 221 | repo := s.c.GitHub().Repo(repoID.Owner, repoID.Repo) 222 | if repo == nil { 223 | return nil, fmt.Errorf("repo %v not found", rs) 224 | } 225 | i := repo.Issue(int32(id)) 226 | if i == nil || i.NotExist || i.PullRequest { 227 | return nil, os.ErrNotExist 228 | } 229 | 230 | var es []issues.Event 231 | err = i.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { 232 | et := issues.EventType(e.Type) 233 | if !et.Valid() { 234 | return nil 235 | } 236 | ev := issues.Event{ 237 | ID: uint64(e.ID), 238 | Actor: ghUser(e.Actor), 239 | CreatedAt: e.Created, 240 | Type: et, 241 | } 242 | switch et { 243 | case issues.Renamed: 244 | ev.Rename = &issues.Rename{ 245 | From: e.From, 246 | To: e.To, 247 | } 248 | case issues.Labeled, issues.Unlabeled: 249 | ev.Label = &issues.Label{ 250 | Name: e.Label, 251 | Color: issues.RGB{R: 0xED, G: 0xED, B: 0xED}, // maintner.Corpus doesn't support GitHub issue label colors, so fall back to a default light gray. 252 | } 253 | case issues.Milestoned, issues.Demilestoned: 254 | ev.Milestone = &issues.Milestone{ 255 | Name: e.Milestone, 256 | } 257 | } 258 | es = append(es, ev) 259 | return nil 260 | }) 261 | if opt != nil { 262 | // Pagination. 263 | start := opt.Start 264 | if start > len(es) { 265 | start = len(es) 266 | } 267 | end := opt.Start + opt.Length 268 | if end > len(es) { 269 | end = len(es) 270 | } 271 | es = es[start:end] 272 | } 273 | return es, err 274 | } 275 | 276 | func (service) CreateComment(_ context.Context, rs issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) { 277 | return issues.Comment{}, fmt.Errorf("CreateComment: not implemented") 278 | } 279 | 280 | func (service) Create(_ context.Context, rs issues.RepoSpec, i issues.Issue) (issues.Issue, error) { 281 | return issues.Issue{}, fmt.Errorf("Create: not implemented") 282 | } 283 | 284 | func (service) Edit(_ context.Context, rs issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) { 285 | return issues.Issue{}, nil, fmt.Errorf("Edit: not implemented") 286 | } 287 | 288 | func (service) EditComment(_ context.Context, rs issues.RepoSpec, id uint64, cr issues.CommentRequest) (issues.Comment, error) { 289 | return issues.Comment{}, fmt.Errorf("EditComment: not implemented") 290 | } 291 | 292 | // ghRepoID converts a RepoSpec into a maintner.GitHubRepoID. 293 | func ghRepoID(repo issues.RepoSpec) (maintner.GitHubRepoID, error) { 294 | elems := strings.Split(repo.URI, "/") 295 | if len(elems) != 2 || elems[0] == "" || elems[1] == "" { 296 | return maintner.GitHubRepoID{}, fmt.Errorf(`RepoSpec is not of form "owner/repo": %q`, repo.URI) 297 | } 298 | return maintner.GitHubRepoID{ 299 | Owner: elems[0], 300 | Repo: elems[1], 301 | }, nil 302 | } 303 | 304 | // ghState converts a GitHub issue state into a issues.State. 305 | func ghState(issue *maintner.GitHubIssue) issues.State { 306 | switch issue.Closed { 307 | case false: 308 | return issues.OpenState 309 | case true: 310 | return issues.ClosedState 311 | default: 312 | panic("unreachable") 313 | } 314 | } 315 | 316 | // ghUser converts a GitHub user into a users.User. 317 | func ghUser(user *maintner.GitHubUser) users.User { 318 | return users.User{ 319 | UserSpec: users.UserSpec{ 320 | ID: uint64(user.ID), 321 | Domain: "github.com", 322 | }, 323 | Login: user.Login, 324 | AvatarURL: fmt.Sprintf("https://avatars.githubusercontent.com/u/%d?v=4&s=96", user.ID), 325 | HTMLURL: fmt.Sprintf("https://github.com/%v", user.Login), 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package issues 2 | 3 | // IssueListOptions are options for list operations. 4 | type IssueListOptions struct { 5 | State StateFilter 6 | } 7 | 8 | // StateFilter is a filter by state. 9 | type StateFilter State 10 | 11 | const ( 12 | // AllStates is a state filter that includes all issues. 13 | AllStates StateFilter = "all" 14 | ) 15 | 16 | // ListOptions controls pagination. 17 | type ListOptions struct { 18 | // Start is the index of first result to retrieve, zero-indexed. 19 | Start int 20 | 21 | // Length is the number of results to include. 22 | Length int 23 | } 24 | --------------------------------------------------------------------------------