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