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