webui_bugs.go

  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}