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