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 rc.Close()
320
321 status := r.URL.Query().Get("status")
322 if status == "" {
323 status = "all"
324 }
325 if status != "all" && status != "open" && status != "closed" {
326 status = "all"
327 }
328
329 page := 1
330 if pageStr := r.URL.Query().Get("page"); pageStr != "" {
331 if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
332 page = p
333 }
334 }
335
336 bugs, err := getBugsList(rc, status)
337 if err != nil {
338 logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
339 renderInternalServerError(w, r)
340 return
341 }
342
343 totalBugs := len(bugs)
344 totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
345 if totalPages < 1 {
346 totalPages = 1
347 }
348 if page > totalPages && totalPages > 0 {
349 page = totalPages
350 }
351 if page < 1 {
352 page = 1
353 }
354
355 start := (page - 1) * defaultBugsPerPage
356 end := start + defaultBugsPerPage
357 if end > totalBugs {
358 end = totalBugs
359 }
360
361 pagedBugs := bugs[start:end]
362
363 gr, err := openRepository(repo)
364 if err != nil {
365 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
366 renderInternalServerError(w, r)
367 return
368 }
369 defaultBranch := getDefaultBranch(gr)
370
371 data := BugsData{
372 Repo: repo,
373 DefaultBranch: defaultBranch,
374 ActiveTab: "bugs",
375 ServerName: cfg.Name,
376 HasGitBug: true,
377 Bugs: pagedBugs,
378 Status: status,
379 Page: page,
380 TotalPages: totalPages,
381 HasPrevPage: page > 1,
382 HasNextPage: page < totalPages,
383 }
384
385 renderHTML(w, "bugs.html", data)
386}
387
388func repoBug(w http.ResponseWriter, r *http.Request) {
389 ctx := r.Context()
390 logger := log.FromContext(ctx)
391 cfg := config.FromContext(ctx)
392 repo := proto.RepositoryFromContext(ctx)
393 vars := mux.Vars(r)
394 hash := vars["hash"]
395
396 if !hasGitBug(ctx, repo) {
397 renderNotFound(w, r)
398 return
399 }
400
401 rc, err := openBugCache(ctx, repo)
402 if err != nil {
403 logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
404 renderNotFound(w, r)
405 return
406 }
407 defer rc.Close()
408
409 bugCache, err := findBugByHash(rc, hash)
410 if err != nil {
411 logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
412 renderNotFound(w, r)
413 return
414 }
415
416 snap := bugCache.Snapshot()
417 timeline := buildTimelineItems(snap)
418
419 labels := make([]Label, len(snap.Labels))
420 for i, label := range snap.Labels {
421 labels[i] = labelToWebLabel(label)
422 }
423
424 var edited bool
425 if len(timeline) > 0 && timeline[0].Type == "create" {
426 edited = timeline[0].Edited
427 }
428
429 gr, err := openRepository(repo)
430 if err != nil {
431 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
432 renderInternalServerError(w, r)
433 return
434 }
435 defaultBranch := getDefaultBranch(gr)
436
437 data := BugData{
438 Repo: repo,
439 DefaultBranch: defaultBranch,
440 ActiveTab: "bugs",
441 ServerName: cfg.Name,
442 HasGitBug: true,
443 ID: snap.Id().Human(),
444 Title: snap.Title,
445 Status: snap.Status.String(),
446 Author: snap.Author.DisplayName(),
447 CreatedAt: snap.CreateTime,
448 Edited: edited,
449 Timeline: timeline,
450 Labels: labels,
451 }
452
453 renderHTML(w, "bug.html", data)
454}