1package web
2
3import (
4 "context"
5 "fmt"
6 "html/template"
7 "net/http"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "time"
13
14 "github.com/charmbracelet/log/v2"
15 "github.com/charmbracelet/soft-serve/pkg/config"
16 "github.com/charmbracelet/soft-serve/pkg/proto"
17 "github.com/git-bug/git-bug/cache"
18 "github.com/git-bug/git-bug/entities/bug"
19 "github.com/git-bug/git-bug/entities/common"
20 "github.com/git-bug/git-bug/repository"
21 "github.com/gorilla/mux"
22)
23
24const defaultBugsPerPage = 20
25
26type BugsData struct {
27 Repo proto.Repository
28 DefaultBranch string
29 ActiveTab string
30 ServerName string
31 HasGitBug bool
32
33 Bugs []BugListItem
34 Status string
35 Page int
36 TotalPages int
37 HasPrevPage bool
38 HasNextPage bool
39}
40
41type BugListItem struct {
42 ID string
43 FullID string
44 Title string
45 Author string
46 AuthorAvatar string
47 Status string
48 CreatedAt time.Time
49 LastActivity time.Time
50 HasActivity bool
51 CommentCount int
52}
53
54type BugData struct {
55 Repo proto.Repository
56 DefaultBranch string
57 ActiveTab string
58 ServerName string
59 HasGitBug bool
60
61 ID string
62 Title string
63 Status string
64 Author string
65 CreatedAt time.Time
66 Edited bool
67 Timeline []TimelineItem
68 Labels []Label
69}
70
71type TimelineItem struct {
72 Type string
73 ID string
74 Author string
75 AuthorAvatar string
76 Timestamp time.Time
77 Edited bool
78
79 Message template.HTML
80 Title string
81 Status string
82 AddedLabels []string
83 RemovedLabels []string
84}
85
86type Label struct {
87 Name string
88 Color string
89}
90
91func hasGitBug(ctx context.Context, repo proto.Repository) bool {
92 cfg := config.FromContext(ctx)
93 repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
94 bugsPath := filepath.Join(repoPath, "refs", "bugs")
95
96 info, err := os.Stat(bugsPath)
97 if err != nil {
98 return false
99 }
100 return info.IsDir()
101}
102
103func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
104 cfg := config.FromContext(ctx)
105 repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
106
107 goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
108 if err != nil {
109 return nil, err
110 }
111
112 rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
113 if err != nil {
114 goGitRepo.Close()
115 return nil, err
116 }
117
118 return rc, nil
119}
120
121func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
122 allBugs := rc.Bugs().AllIds()
123 bugs := make([]BugListItem, 0)
124
125 for _, id := range allBugs {
126 bugCache, err := rc.Bugs().Resolve(id)
127 if err != nil {
128 continue
129 }
130
131 snap := bugCache.Snapshot()
132
133 if status != "all" && snap.Status.String() != status {
134 continue
135 }
136
137 bugs = append(bugs, BugListItem{
138 ID: snap.Id().Human(),
139 FullID: snap.Id().String(),
140 Title: snap.Title,
141 Author: snap.Author.DisplayName(),
142 AuthorAvatar: snap.Author.AvatarUrl(),
143 Status: snap.Status.String(),
144 CreatedAt: snap.CreateTime,
145 LastActivity: getLastActivity(snap),
146 HasActivity: len(snap.Timeline) > 1,
147 CommentCount: countComments(snap),
148 })
149 }
150
151 sort.Slice(bugs, func(i, j int) bool {
152 return bugs[i].LastActivity.After(bugs[j].LastActivity)
153 })
154
155 return bugs, nil
156}
157
158func getLastActivity(snap *bug.Snapshot) time.Time {
159 var lastTime time.Time
160
161 for _, item := range snap.Timeline {
162 var itemTime time.Time
163
164 switch op := item.(type) {
165 case *bug.CreateTimelineItem:
166 itemTime = op.CreatedAt.Time()
167 case *bug.AddCommentTimelineItem:
168 itemTime = op.CreatedAt.Time()
169 case *bug.SetTitleTimelineItem:
170 itemTime = op.UnixTime.Time()
171 case *bug.SetStatusTimelineItem:
172 itemTime = op.UnixTime.Time()
173 case *bug.LabelChangeTimelineItem:
174 itemTime = op.UnixTime.Time()
175 }
176
177 if itemTime.After(lastTime) {
178 lastTime = itemTime
179 }
180 }
181
182 return lastTime
183}
184
185func countComments(snap *bug.Snapshot) int {
186 count := 0
187 for _, item := range snap.Timeline {
188 if _, ok := item.(*bug.AddCommentTimelineItem); ok {
189 count++
190 }
191 }
192 return count
193}
194
195func buildTimelineItems(snap *bug.Snapshot) []TimelineItem {
196 items := make([]TimelineItem, 0, len(snap.Timeline))
197
198 for _, item := range snap.Timeline {
199 switch op := item.(type) {
200 case *bug.CreateTimelineItem:
201 messageHTML := template.HTML("")
202 if !op.MessageIsEmpty() {
203 rendered, err := renderMarkdown([]byte(op.Message), nil)
204 if err != nil {
205 messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
206 } else {
207 messageHTML = rendered
208 }
209 }
210
211 items = append(items, TimelineItem{
212 Type: "create",
213 ID: op.CombinedId().String(),
214 Author: op.Author.DisplayName(),
215 AuthorAvatar: op.Author.AvatarUrl(),
216 Timestamp: op.CreatedAt.Time(),
217 Edited: op.Edited(),
218 Message: messageHTML,
219 })
220
221 case *bug.AddCommentTimelineItem:
222 messageHTML := template.HTML("")
223 if !op.MessageIsEmpty() {
224 rendered, err := renderMarkdown([]byte(op.Message), nil)
225 if err != nil {
226 messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
227 } else {
228 messageHTML = rendered
229 }
230 }
231
232 items = append(items, TimelineItem{
233 Type: "comment",
234 ID: op.CombinedId().String(),
235 Author: op.Author.DisplayName(),
236 AuthorAvatar: op.Author.AvatarUrl(),
237 Timestamp: op.CreatedAt.Time(),
238 Edited: op.Edited(),
239 Message: messageHTML,
240 })
241
242 case *bug.SetTitleTimelineItem:
243 items = append(items, TimelineItem{
244 Type: "title",
245 ID: op.CombinedId().String(),
246 Author: op.Author.DisplayName(),
247 AuthorAvatar: op.Author.AvatarUrl(),
248 Timestamp: op.UnixTime.Time(),
249 Title: op.Title,
250 })
251
252 case *bug.SetStatusTimelineItem:
253 items = append(items, TimelineItem{
254 Type: "status",
255 ID: op.CombinedId().String(),
256 Author: op.Author.DisplayName(),
257 AuthorAvatar: op.Author.AvatarUrl(),
258 Timestamp: op.UnixTime.Time(),
259 Status: op.Status.Action(),
260 })
261
262 case *bug.LabelChangeTimelineItem:
263 added := make([]string, len(op.Added))
264 for i, label := range op.Added {
265 added[i] = label.String()
266 }
267
268 removed := make([]string, len(op.Removed))
269 for i, label := range op.Removed {
270 removed[i] = label.String()
271 }
272
273 items = append(items, TimelineItem{
274 Type: "labels",
275 ID: op.CombinedId().String(),
276 Author: op.Author.DisplayName(),
277 AuthorAvatar: op.Author.AvatarUrl(),
278 Timestamp: op.UnixTime.Time(),
279 AddedLabels: added,
280 RemovedLabels: removed,
281 })
282 }
283 }
284
285 return items
286}
287
288func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
289 allBugs := rc.Bugs().AllIds()
290 for _, id := range allBugs {
291 if id.String() == hash || id.Human() == hash {
292 return rc.Bugs().Resolve(id)
293 }
294 }
295 return nil, fmt.Errorf("bug not found")
296}
297
298func labelToWebLabel(label common.Label) Label {
299 rgba := label.Color().RGBA()
300 return Label{
301 Name: label.String(),
302 Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
303 }
304}
305
306func repoBugs(w http.ResponseWriter, r *http.Request) {
307 ctx := r.Context()
308 logger := log.FromContext(ctx)
309 cfg := config.FromContext(ctx)
310 repo := proto.RepositoryFromContext(ctx)
311
312 if !hasGitBug(ctx, repo) {
313 renderNotFound(w, r)
314 return
315 }
316
317 rc, err := openBugCache(ctx, repo)
318 if err != nil {
319 logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
320 renderNotFound(w, r)
321 return
322 }
323 defer func() {
324 if closeErr := rc.Close(); closeErr != nil {
325 logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
326 }
327 }()
328
329 status := r.URL.Query().Get("status")
330 if status == "" {
331 status = "open"
332 }
333 if status != "all" && status != "open" && status != "closed" {
334 status = "open"
335 }
336
337 page := 1
338 if pageStr := r.URL.Query().Get("page"); pageStr != "" {
339 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
340 page = p
341 }
342 }
343
344 bugs, err := getBugsList(rc, status)
345 if err != nil {
346 logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
347 renderInternalServerError(w, r)
348 return
349 }
350
351 totalBugs := len(bugs)
352 totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
353 if totalPages < 1 {
354 totalPages = 1
355 }
356 if page > totalPages && totalPages > 0 {
357 page = totalPages
358 }
359 if page < 1 {
360 page = 1
361 }
362
363 start := (page - 1) * defaultBugsPerPage
364 end := start + defaultBugsPerPage
365 if end > totalBugs {
366 end = totalBugs
367 }
368
369 pagedBugs := bugs[start:end]
370
371 gr, err := openRepository(repo)
372 if err != nil {
373 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
374 renderInternalServerError(w, r)
375 return
376 }
377 defaultBranch := getDefaultBranch(gr)
378
379 data := BugsData{
380 Repo: repo,
381 DefaultBranch: defaultBranch,
382 ActiveTab: "bugs",
383 ServerName: cfg.Name,
384 HasGitBug: true,
385 Bugs: pagedBugs,
386 Status: status,
387 Page: page,
388 TotalPages: totalPages,
389 HasPrevPage: page > 1,
390 HasNextPage: page < totalPages,
391 }
392
393 renderHTML(w, "bugs.html", data)
394}
395
396func repoBug(w http.ResponseWriter, r *http.Request) {
397 ctx := r.Context()
398 logger := log.FromContext(ctx)
399 cfg := config.FromContext(ctx)
400 repo := proto.RepositoryFromContext(ctx)
401 vars := mux.Vars(r)
402 hash := vars["hash"]
403
404 if !hasGitBug(ctx, repo) {
405 renderNotFound(w, r)
406 return
407 }
408
409 rc, err := openBugCache(ctx, repo)
410 if err != nil {
411 logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
412 renderNotFound(w, r)
413 return
414 }
415 defer func() {
416 if closeErr := rc.Close(); closeErr != nil {
417 logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
418 }
419 }()
420
421 bugCache, err := findBugByHash(rc, hash)
422 if err != nil {
423 logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
424 renderNotFound(w, r)
425 return
426 }
427
428 snap := bugCache.Snapshot()
429 timeline := buildTimelineItems(snap)
430
431 labels := make([]Label, len(snap.Labels))
432 for i, label := range snap.Labels {
433 labels[i] = labelToWebLabel(label)
434 }
435
436 var edited bool
437 if len(timeline) > 0 && timeline[0].Type == "create" {
438 edited = timeline[0].Edited
439 }
440
441 gr, err := openRepository(repo)
442 if err != nil {
443 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
444 renderInternalServerError(w, r)
445 return
446 }
447 defaultBranch := getDefaultBranch(gr)
448
449 data := BugData{
450 Repo: repo,
451 DefaultBranch: defaultBranch,
452 ActiveTab: "bugs",
453 ServerName: cfg.Name,
454 HasGitBug: true,
455 ID: snap.Id().Human(),
456 Title: snap.Title,
457 Status: snap.Status.String(),
458 Author: snap.Author.DisplayName(),
459 CreatedAt: snap.CreateTime,
460 Edited: edited,
461 Timeline: timeline,
462 Labels: labels,
463 }
464
465 renderHTML(w, "bug.html", data)
466}