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