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	RepoBaseData
 28	PaginationData
 29
 30	Bugs   []BugListItem
 31	Status string
 32}
 33
 34type BugListItem struct {
 35	ID              string
 36	FullID          string
 37	Title           string
 38	Author          string
 39	AuthorAvatar    string
 40	Status          string
 41	CreatedAt       time.Time
 42	LastActivity    time.Time
 43	HasActivity     bool
 44	LastEventAction string
 45	CommentCount    int
 46	Labels          []Label
 47}
 48
 49type BugData struct {
 50	RepoBaseData
 51
 52	ID        string
 53	Subject   string
 54	Status    string
 55	Author    string
 56	CreatedAt time.Time
 57	Edited    bool
 58	Timeline  []TimelineItem
 59	Labels    []Label
 60}
 61
 62type TimelineItem struct {
 63	Type         string
 64	ID           string
 65	Author       string
 66	AuthorAvatar string
 67	Timestamp    time.Time
 68	Edited       bool
 69
 70	Message       template.HTML
 71	Title         string
 72	PreviousTitle string
 73	Status        string
 74	AddedLabels   []Label
 75	RemovedLabels []Label
 76}
 77
 78type Label struct {
 79	Name  string
 80	Color string
 81}
 82
 83func hasGitBug(ctx context.Context, repo proto.Repository) bool {
 84	cfg := config.FromContext(ctx)
 85	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 86	bugsPath := filepath.Join(repoPath, "refs", "bugs")
 87
 88	info, err := os.Stat(bugsPath)
 89	if err != nil {
 90		return false
 91	}
 92	return info.IsDir()
 93}
 94
 95func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
 96	cfg := config.FromContext(ctx)
 97	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 98
 99	goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
100	if err != nil {
101		return nil, err
102	}
103
104	rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
105	if err != nil {
106		goGitRepo.Close()
107		return nil, err
108	}
109
110	return rc, nil
111}
112
113func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
114	allBugs := rc.Bugs().AllIds()
115	bugs := make([]BugListItem, 0)
116
117	for _, id := range allBugs {
118		bugCache, err := rc.Bugs().Resolve(id)
119		if err != nil {
120			continue
121		}
122
123		snap := bugCache.Snapshot()
124
125		if status != "all" && snap.Status.String() != status {
126			continue
127		}
128
129		labels := make([]Label, len(snap.Labels))
130		for i, label := range snap.Labels {
131			labels[i] = labelToWebLabel(label)
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			AuthorAvatar:    snap.Author.AvatarUrl(),
140			Status:          snap.Status.String(),
141			CreatedAt:       snap.CreateTime,
142			LastActivity:    getLastActivity(snap),
143			HasActivity:     len(snap.Timeline) > 1,
144			LastEventAction: getLastEventAction(snap),
145			CommentCount:    countComments(snap),
146			Labels:          labels,
147		})
148	}
149
150	sort.Slice(bugs, func(i, j int) bool {
151		return bugs[i].LastActivity.After(bugs[j].LastActivity)
152	})
153
154	return bugs, nil
155}
156
157func getLastActivity(snap *bug.Snapshot) time.Time {
158	var lastTime time.Time
159
160	for _, item := range snap.Timeline {
161		var itemTime time.Time
162
163		switch op := item.(type) {
164		case *bug.CreateTimelineItem:
165			itemTime = op.CreatedAt.Time()
166		case *bug.AddCommentTimelineItem:
167			itemTime = op.CreatedAt.Time()
168		case *bug.SetTitleTimelineItem:
169			itemTime = op.UnixTime.Time()
170		case *bug.SetStatusTimelineItem:
171			itemTime = op.UnixTime.Time()
172		case *bug.LabelChangeTimelineItem:
173			itemTime = op.UnixTime.Time()
174		}
175
176		if itemTime.After(lastTime) {
177			lastTime = itemTime
178		}
179	}
180
181	return lastTime
182}
183
184func getLastEventAction(snap *bug.Snapshot) string {
185	var lastTime time.Time
186	var lastAction string
187
188	for _, item := range snap.Timeline {
189		var itemTime time.Time
190
191		switch op := item.(type) {
192		case *bug.CreateTimelineItem:
193			itemTime = op.CreatedAt.Time()
194			if itemTime.After(lastTime) {
195				lastTime = itemTime
196				lastAction = "created"
197			}
198		case *bug.AddCommentTimelineItem:
199			itemTime = op.CreatedAt.Time()
200			if itemTime.After(lastTime) {
201				lastTime = itemTime
202				lastAction = "commented"
203			}
204		case *bug.SetTitleTimelineItem:
205			itemTime = op.UnixTime.Time()
206			if itemTime.After(lastTime) {
207				lastTime = itemTime
208				lastAction = "updated"
209			}
210		case *bug.SetStatusTimelineItem:
211			itemTime = op.UnixTime.Time()
212			if itemTime.After(lastTime) {
213				lastTime = itemTime
214				lastAction = op.Status.Action()
215			}
216		case *bug.LabelChangeTimelineItem:
217			itemTime = op.UnixTime.Time()
218			if itemTime.After(lastTime) {
219				lastTime = itemTime
220				lastAction = "updated"
221			}
222		}
223	}
224
225	return lastAction
226}
227
228func countComments(snap *bug.Snapshot) int {
229	count := 0
230	for _, item := range snap.Timeline {
231		if _, ok := item.(*bug.AddCommentTimelineItem); ok {
232			count++
233		}
234	}
235	return count
236}
237
238func buildTimelineItems(snap *bug.Snapshot) []TimelineItem {
239	items := make([]TimelineItem, 0, len(snap.Timeline))
240
241	for _, item := range snap.Timeline {
242		switch op := item.(type) {
243		case *bug.CreateTimelineItem:
244			messageHTML := template.HTML("")
245			if !op.MessageIsEmpty() {
246				rendered, err := renderMarkdown([]byte(op.Message))
247				if err != nil {
248					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
249				} else {
250					messageHTML = rendered
251				}
252			}
253
254			items = append(items, TimelineItem{
255				Type:         "create",
256				ID:           op.CombinedId().String(),
257				Author:       op.Author.DisplayName(),
258				AuthorAvatar: op.Author.AvatarUrl(),
259				Timestamp:    op.CreatedAt.Time(),
260				Edited:       op.Edited(),
261				Message:      messageHTML,
262			})
263
264		case *bug.AddCommentTimelineItem:
265			messageHTML := template.HTML("")
266			if !op.MessageIsEmpty() {
267				rendered, err := renderMarkdown([]byte(op.Message))
268				if err != nil {
269					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
270				} else {
271					messageHTML = rendered
272				}
273			}
274
275			items = append(items, TimelineItem{
276				Type:         "comment",
277				ID:           op.CombinedId().String(),
278				Author:       op.Author.DisplayName(),
279				AuthorAvatar: op.Author.AvatarUrl(),
280				Timestamp:    op.CreatedAt.Time(),
281				Edited:       op.Edited(),
282				Message:      messageHTML,
283			})
284
285		case *bug.SetTitleTimelineItem:
286			items = append(items, TimelineItem{
287				Type:          "title",
288				ID:            op.CombinedId().String(),
289				Author:        op.Author.DisplayName(),
290				AuthorAvatar:  op.Author.AvatarUrl(),
291				Timestamp:     op.UnixTime.Time(),
292				Title:         op.Title,
293				PreviousTitle: op.Was,
294			})
295
296		case *bug.SetStatusTimelineItem:
297			items = append(items, TimelineItem{
298				Type:         "status",
299				ID:           op.CombinedId().String(),
300				Author:       op.Author.DisplayName(),
301				AuthorAvatar: op.Author.AvatarUrl(),
302				Timestamp:    op.UnixTime.Time(),
303				Status:       op.Status.Action(),
304			})
305
306		case *bug.LabelChangeTimelineItem:
307			added := make([]Label, len(op.Added))
308			for i, label := range op.Added {
309				added[i] = labelToWebLabel(label)
310			}
311
312			removed := make([]Label, len(op.Removed))
313			for i, label := range op.Removed {
314				removed[i] = labelToWebLabel(label)
315			}
316
317			items = append(items, TimelineItem{
318				Type:          "labels",
319				ID:            op.CombinedId().String(),
320				Author:        op.Author.DisplayName(),
321				AuthorAvatar:  op.Author.AvatarUrl(),
322				Timestamp:     op.UnixTime.Time(),
323				AddedLabels:   added,
324				RemovedLabels: removed,
325			})
326		}
327	}
328
329	return items
330}
331
332func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
333	allBugs := rc.Bugs().AllIds()
334	for _, id := range allBugs {
335		if id.String() == hash || id.Human() == hash {
336			return rc.Bugs().Resolve(id)
337		}
338	}
339	return nil, fmt.Errorf("bug not found")
340}
341
342func labelToWebLabel(label common.Label) Label {
343	rgba := label.Color().RGBA()
344	return Label{
345		Name:  label.String(),
346		Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
347	}
348}
349
350func repoBugs(w http.ResponseWriter, r *http.Request) {
351	ctx := r.Context()
352	logger := log.FromContext(ctx)
353	cfg := config.FromContext(ctx)
354	repo := proto.RepositoryFromContext(ctx)
355
356	if !hasGitBug(ctx, repo) {
357		renderNotFound(w, r)
358		return
359	}
360
361	rc, err := openBugCache(ctx, repo)
362	if err != nil {
363		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
364		renderNotFound(w, r)
365		return
366	}
367	defer func() {
368		if closeErr := rc.Close(); closeErr != nil {
369			logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
370		}
371	}()
372
373	status := r.URL.Query().Get("status")
374	if status == "" {
375		status = "open"
376	}
377	if status != "all" && status != "open" && status != "closed" {
378		status = "open"
379	}
380
381	page := 1
382	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
383		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
384			page = p
385		}
386	}
387
388	bugs, err := getBugsList(rc, status)
389	if err != nil {
390		logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
391		renderInternalServerError(w, r)
392		return
393	}
394
395	totalBugs := len(bugs)
396	totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
397	if totalPages < 1 {
398		totalPages = 1
399	}
400	if page > totalPages && totalPages > 0 {
401		page = totalPages
402	}
403	if page < 1 {
404		page = 1
405	}
406
407	start := (page - 1) * defaultBugsPerPage
408	end := start + defaultBugsPerPage
409	if end > totalBugs {
410		end = totalBugs
411	}
412
413	pagedBugs := bugs[start:end]
414
415	gr, err := openRepository(repo)
416	if err != nil {
417		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
418		renderInternalServerError(w, r)
419		return
420	}
421	defaultBranch := getDefaultBranch(gr)
422
423	repoDisplayName := repo.ProjectName()
424	if repoDisplayName == "" {
425		repoDisplayName = repo.Name()
426	}
427
428	description := getRepoDescriptionOrFallback(repo, "Bugs in "+repoDisplayName)
429
430	data := BugsData{
431		RepoBaseData: RepoBaseData{
432			BaseData: BaseData{
433				ServerName:  cfg.Name,
434				ActiveTab:   "bugs",
435				Title:       "Bugs | " + repoDisplayName,
436				Description: description,
437			},
438			Repo:          repo,
439			DefaultBranch: defaultBranch,
440			HasGitBug:     true,
441		},
442		PaginationData: PaginationData{
443			Page:        page,
444			TotalPages:  totalPages,
445			HasPrevPage: page > 1,
446			HasNextPage: page < totalPages,
447		},
448		Bugs:   pagedBugs,
449		Status: status,
450	}
451
452	renderHTML(w, "bugs.html", data)
453}
454
455func repoBug(w http.ResponseWriter, r *http.Request) {
456	ctx := r.Context()
457	logger := log.FromContext(ctx)
458	cfg := config.FromContext(ctx)
459	repo := proto.RepositoryFromContext(ctx)
460	vars := mux.Vars(r)
461	hash := vars["hash"]
462
463	if !hasGitBug(ctx, repo) {
464		renderNotFound(w, r)
465		return
466	}
467
468	rc, err := openBugCache(ctx, repo)
469	if err != nil {
470		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
471		renderNotFound(w, r)
472		return
473	}
474	defer func() {
475		if closeErr := rc.Close(); closeErr != nil {
476			logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
477		}
478	}()
479
480	bugCache, err := findBugByHash(rc, hash)
481	if err != nil {
482		logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
483		renderNotFound(w, r)
484		return
485	}
486
487	snap := bugCache.Snapshot()
488	timeline := buildTimelineItems(snap)
489
490	labels := make([]Label, len(snap.Labels))
491	for i, label := range snap.Labels {
492		labels[i] = labelToWebLabel(label)
493	}
494
495	var edited bool
496	if len(timeline) > 0 && timeline[0].Type == "create" {
497		edited = timeline[0].Edited
498	}
499
500	// Extract raw message for description
501	var rawMessage string
502	if len(snap.Timeline) > 0 {
503		if createOp, ok := snap.Timeline[0].(*bug.CreateTimelineItem); ok {
504			if !createOp.MessageIsEmpty() {
505				rawMessage = createOp.Message
506			}
507		}
508	}
509
510	// Generate description from bug message or fallback to title
511	description := extractPlainTextFromMarkdown(rawMessage, 200)
512	if description == "" {
513		description = truncateText(snap.Title, 200)
514	}
515
516	gr, err := openRepository(repo)
517	if err != nil {
518		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
519		renderInternalServerError(w, r)
520		return
521	}
522	defaultBranch := getDefaultBranch(gr)
523
524	repoDisplayName := repo.ProjectName()
525	if repoDisplayName == "" {
526		repoDisplayName = repo.Name()
527	}
528
529	data := BugData{
530		RepoBaseData: RepoBaseData{
531			BaseData: BaseData{
532				ServerName:  cfg.Name,
533				ActiveTab:   "bugs",
534				Title:       snap.Title + " | Bug " + snap.Id().Human() + " | " + repoDisplayName,
535				Description: description,
536			},
537			Repo:          repo,
538			DefaultBranch: defaultBranch,
539			HasGitBug:     true,
540		},
541		ID:        snap.Id().Human(),
542		Subject:   snap.Title,
543		Status:    snap.Status.String(),
544		Author:    snap.Author.DisplayName(),
545		CreatedAt: snap.CreateTime,
546		Edited:    edited,
547		Timeline:  timeline,
548		Labels:    labels,
549	}
550
551	renderHTML(w, "bug.html", data)
552}