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