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