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 Status string
47 CreatedAt time.Time
48 LastActivity time.Time
49 HasActivity bool
50 CommentCount int
51}
52
53type BugData struct {
54 Repo proto.Repository
55 DefaultBranch string
56 ActiveTab string
57 ServerName string
58 HasGitBug bool
59
60 ID string
61 Title string
62 Status string
63 Author string
64 CreatedAt time.Time
65 Edited bool
66 Timeline []TimelineItem
67 Labels []Label
68}
69
70type TimelineItem struct {
71 Type string
72 ID string
73 Author string
74 Timestamp time.Time
75 Edited bool
76
77 Message template.HTML
78 Title string
79 Status string
80 AddedLabels []string
81 RemovedLabels []string
82}
83
84type Label struct {
85 Name string
86 Color string
87}
88
89func hasGitBug(ctx context.Context, repo proto.Repository) bool {
90 cfg := config.FromContext(ctx)
91 repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
92 bugsPath := filepath.Join(repoPath, "refs", "bugs")
93
94 info, err := os.Stat(bugsPath)
95 if err != nil {
96 return false
97 }
98 return info.IsDir()
99}
100
101func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
102 cfg := config.FromContext(ctx)
103 repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
104
105 goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
106 if err != nil {
107 return nil, err
108 }
109
110 rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
111 if err != nil {
112 goGitRepo.Close()
113 return nil, err
114 }
115
116 return rc, nil
117}
118
119func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
120 allBugs := rc.Bugs().AllIds()
121 bugs := make([]BugListItem, 0)
122
123 for _, id := range allBugs {
124 bugCache, err := rc.Bugs().Resolve(id)
125 if err != nil {
126 continue
127 }
128
129 snap := bugCache.Snapshot()
130
131 if status != "all" && snap.Status.String() != status {
132 continue
133 }
134
135 bugs = append(bugs, BugListItem{
136 ID: snap.Id().Human(),
137 FullID: snap.Id().String(),
138 Title: snap.Title,
139 Author: snap.Author.DisplayName(),
140 Status: snap.Status.String(),
141 CreatedAt: snap.CreateTime,
142 LastActivity: getLastActivity(snap),
143 HasActivity: len(snap.Timeline) > 1,
144 CommentCount: countComments(snap),
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 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 Timestamp: op.CreatedAt.Time(),
233 Edited: op.Edited(),
234 Message: messageHTML,
235 })
236
237 case *bug.SetTitleTimelineItem:
238 items = append(items, TimelineItem{
239 Type: "title",
240 ID: op.CombinedId().String(),
241 Author: op.Author.DisplayName(),
242 Timestamp: op.UnixTime.Time(),
243 Title: op.Title,
244 })
245
246 case *bug.SetStatusTimelineItem:
247 items = append(items, TimelineItem{
248 Type: "status",
249 ID: op.CombinedId().String(),
250 Author: op.Author.DisplayName(),
251 Timestamp: op.UnixTime.Time(),
252 Status: op.Status.Action(),
253 })
254
255 case *bug.LabelChangeTimelineItem:
256 added := make([]string, len(op.Added))
257 for i, label := range op.Added {
258 added[i] = label.String()
259 }
260
261 removed := make([]string, len(op.Removed))
262 for i, label := range op.Removed {
263 removed[i] = label.String()
264 }
265
266 items = append(items, TimelineItem{
267 Type: "labels",
268 ID: op.CombinedId().String(),
269 Author: op.Author.DisplayName(),
270 Timestamp: op.UnixTime.Time(),
271 AddedLabels: added,
272 RemovedLabels: removed,
273 })
274 }
275 }
276
277 return items
278}
279
280func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
281 allBugs := rc.Bugs().AllIds()
282 for _, id := range allBugs {
283 if id.String() == hash || id.Human() == hash {
284 return rc.Bugs().Resolve(id)
285 }
286 }
287 return nil, fmt.Errorf("bug not found")
288}
289
290func labelToWebLabel(label common.Label) Label {
291 rgba := label.Color().RGBA()
292 // RGBA() returns 16-bit channels (0-65535), convert to 8-bit (0-255)
293 r8 := uint8(rgba.R >> 8)
294 g8 := uint8(rgba.G >> 8)
295 b8 := uint8(rgba.B >> 8)
296 return Label{
297 Name: label.String(),
298 Color: fmt.Sprintf("#%02x%02x%02x", r8, g8, b8),
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 data := BugsData{
376 Repo: repo,
377 DefaultBranch: defaultBranch,
378 ActiveTab: "bugs",
379 ServerName: cfg.Name,
380 HasGitBug: true,
381 Bugs: pagedBugs,
382 Status: status,
383 Page: page,
384 TotalPages: totalPages,
385 HasPrevPage: page > 1,
386 HasNextPage: page < totalPages,
387 }
388
389 renderHTML(w, "bugs.html", data)
390}
391
392func repoBug(w http.ResponseWriter, r *http.Request) {
393 ctx := r.Context()
394 logger := log.FromContext(ctx)
395 cfg := config.FromContext(ctx)
396 repo := proto.RepositoryFromContext(ctx)
397 vars := mux.Vars(r)
398 hash := vars["hash"]
399
400 if !hasGitBug(ctx, repo) {
401 renderNotFound(w, r)
402 return
403 }
404
405 rc, err := openBugCache(ctx, repo)
406 if err != nil {
407 logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
408 renderNotFound(w, r)
409 return
410 }
411 defer func() {
412 if closeErr := rc.Close(); closeErr != nil {
413 logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
414 }
415 }()
416
417 bugCache, err := findBugByHash(rc, hash)
418 if err != nil {
419 logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
420 renderNotFound(w, r)
421 return
422 }
423
424 snap := bugCache.Snapshot()
425 timeline := buildTimelineItems(snap)
426
427 labels := make([]Label, len(snap.Labels))
428 for i, label := range snap.Labels {
429 labels[i] = labelToWebLabel(label)
430 }
431
432 var edited bool
433 if len(timeline) > 0 && timeline[0].Type == "create" {
434 edited = timeline[0].Edited
435 }
436
437 gr, err := openRepository(repo)
438 if err != nil {
439 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
440 renderInternalServerError(w, r)
441 return
442 }
443 defaultBranch := getDefaultBranch(gr)
444
445 data := BugData{
446 Repo: repo,
447 DefaultBranch: defaultBranch,
448 ActiveTab: "bugs",
449 ServerName: cfg.Name,
450 HasGitBug: true,
451 ID: snap.Id().Human(),
452 Title: snap.Title,
453 Status: snap.Status.String(),
454 Author: snap.Author.DisplayName(),
455 CreatedAt: snap.CreateTime,
456 Edited: edited,
457 Timeline: timeline,
458 Labels: labels,
459 }
460
461 renderHTML(w, "bug.html", data)
462}