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