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), nil)
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), nil)
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 return Label{
293 Name: label.String(),
294 Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
295 }
296}
297
298func repoBugs(w http.ResponseWriter, r *http.Request) {
299 ctx := r.Context()
300 logger := log.FromContext(ctx)
301 cfg := config.FromContext(ctx)
302 repo := proto.RepositoryFromContext(ctx)
303
304 if !hasGitBug(ctx, repo) {
305 renderNotFound(w, r)
306 return
307 }
308
309 rc, err := openBugCache(ctx, repo)
310 if err != nil {
311 logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
312 renderNotFound(w, r)
313 return
314 }
315 defer func() {
316 if closeErr := rc.Close(); closeErr != nil {
317 logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
318 }
319 }()
320
321 status := r.URL.Query().Get("status")
322 if status == "" {
323 status = "open"
324 }
325 if status != "all" && status != "open" && status != "closed" {
326 status = "open"
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 func() {
408 if closeErr := rc.Close(); closeErr != nil {
409 logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
410 }
411 }()
412
413 bugCache, err := findBugByHash(rc, hash)
414 if err != nil {
415 logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
416 renderNotFound(w, r)
417 return
418 }
419
420 snap := bugCache.Snapshot()
421 timeline := buildTimelineItems(snap)
422
423 labels := make([]Label, len(snap.Labels))
424 for i, label := range snap.Labels {
425 labels[i] = labelToWebLabel(label)
426 }
427
428 var edited bool
429 if len(timeline) > 0 && timeline[0].Type == "create" {
430 edited = timeline[0].Edited
431 }
432
433 gr, err := openRepository(repo)
434 if err != nil {
435 logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
436 renderInternalServerError(w, r)
437 return
438 }
439 defaultBranch := getDefaultBranch(gr)
440
441 data := BugData{
442 Repo: repo,
443 DefaultBranch: defaultBranch,
444 ActiveTab: "bugs",
445 ServerName: cfg.Name,
446 HasGitBug: true,
447 ID: snap.Id().Human(),
448 Title: snap.Title,
449 Status: snap.Status.String(),
450 Author: snap.Author.DisplayName(),
451 CreatedAt: snap.CreateTime,
452 Edited: edited,
453 Timeline: timeline,
454 Labels: labels,
455 }
456
457 renderHTML(w, "bug.html", data)
458}