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	CommentCount int
 45	Labels       []Label
 46}
 47
 48type BugData struct {
 49	RepoBaseData
 50
 51	ID        string
 52	Subject   string
 53	Status    string
 54	Author    string
 55	CreatedAt time.Time
 56	Edited    bool
 57	Timeline  []TimelineItem
 58	Labels    []Label
 59}
 60
 61type TimelineItem struct {
 62	Type         string
 63	ID           string
 64	Author       string
 65	AuthorAvatar string
 66	Timestamp    time.Time
 67	Edited       bool
 68
 69	Message       template.HTML
 70	Title         string
 71	PreviousTitle string
 72	Status        string
 73	AddedLabels   []Label
 74	RemovedLabels []Label
 75}
 76
 77type Label struct {
 78	Name  string
 79	Color string
 80}
 81
 82func hasGitBug(ctx context.Context, repo proto.Repository) bool {
 83	cfg := config.FromContext(ctx)
 84	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 85	bugsPath := filepath.Join(repoPath, "refs", "bugs")
 86
 87	info, err := os.Stat(bugsPath)
 88	if err != nil {
 89		return false
 90	}
 91	return info.IsDir()
 92}
 93
 94func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
 95	cfg := config.FromContext(ctx)
 96	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 97
 98	goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
 99	if err != nil {
100		return nil, err
101	}
102
103	rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
104	if err != nil {
105		goGitRepo.Close()
106		return nil, err
107	}
108
109	return rc, nil
110}
111
112func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
113	allBugs := rc.Bugs().AllIds()
114	bugs := make([]BugListItem, 0)
115
116	for _, id := range allBugs {
117		bugCache, err := rc.Bugs().Resolve(id)
118		if err != nil {
119			continue
120		}
121
122		snap := bugCache.Snapshot()
123
124		if status != "all" && snap.Status.String() != status {
125			continue
126		}
127
128		labels := make([]Label, len(snap.Labels))
129		for i, label := range snap.Labels {
130			labels[i] = labelToWebLabel(label)
131		}
132
133		bugs = append(bugs, BugListItem{
134			ID:           snap.Id().Human(),
135			FullID:       snap.Id().String(),
136			Title:        snap.Title,
137			Author:       snap.Author.DisplayName(),
138			AuthorAvatar: snap.Author.AvatarUrl(),
139			Status:       snap.Status.String(),
140			CreatedAt:    snap.CreateTime,
141			LastActivity: getLastActivity(snap),
142			HasActivity:  len(snap.Timeline) > 1,
143			CommentCount: countComments(snap),
144			Labels:       labels,
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				AuthorAvatar: op.Author.AvatarUrl(),
213				Timestamp:    op.CreatedAt.Time(),
214				Edited:       op.Edited(),
215				Message:      messageHTML,
216			})
217
218		case *bug.AddCommentTimelineItem:
219			messageHTML := template.HTML("")
220			if !op.MessageIsEmpty() {
221				rendered, err := renderMarkdown([]byte(op.Message))
222				if err != nil {
223					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
224				} else {
225					messageHTML = rendered
226				}
227			}
228
229			items = append(items, TimelineItem{
230				Type:         "comment",
231				ID:           op.CombinedId().String(),
232				Author:       op.Author.DisplayName(),
233				AuthorAvatar: op.Author.AvatarUrl(),
234				Timestamp:    op.CreatedAt.Time(),
235				Edited:       op.Edited(),
236				Message:      messageHTML,
237			})
238
239		case *bug.SetTitleTimelineItem:
240			items = append(items, TimelineItem{
241				Type:          "title",
242				ID:            op.CombinedId().String(),
243				Author:        op.Author.DisplayName(),
244				AuthorAvatar:  op.Author.AvatarUrl(),
245				Timestamp:     op.UnixTime.Time(),
246				Title:         op.Title,
247				PreviousTitle: op.Was,
248			})
249
250		case *bug.SetStatusTimelineItem:
251			items = append(items, TimelineItem{
252				Type:         "status",
253				ID:           op.CombinedId().String(),
254				Author:       op.Author.DisplayName(),
255				AuthorAvatar: op.Author.AvatarUrl(),
256				Timestamp:    op.UnixTime.Time(),
257				Status:       op.Status.Action(),
258			})
259
260		case *bug.LabelChangeTimelineItem:
261			added := make([]Label, len(op.Added))
262			for i, label := range op.Added {
263				added[i] = labelToWebLabel(label)
264			}
265
266			removed := make([]Label, len(op.Removed))
267			for i, label := range op.Removed {
268				removed[i] = labelToWebLabel(label)
269			}
270
271			items = append(items, TimelineItem{
272				Type:          "labels",
273				ID:            op.CombinedId().String(),
274				Author:        op.Author.DisplayName(),
275				AuthorAvatar:  op.Author.AvatarUrl(),
276				Timestamp:     op.UnixTime.Time(),
277				AddedLabels:   added,
278				RemovedLabels: removed,
279			})
280		}
281	}
282
283	return items
284}
285
286func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
287	allBugs := rc.Bugs().AllIds()
288	for _, id := range allBugs {
289		if id.String() == hash || id.Human() == hash {
290			return rc.Bugs().Resolve(id)
291		}
292	}
293	return nil, fmt.Errorf("bug not found")
294}
295
296func labelToWebLabel(label common.Label) Label {
297	rgba := label.Color().RGBA()
298	return Label{
299		Name:  label.String(),
300		Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
301	}
302}
303
304func repoBugs(w http.ResponseWriter, r *http.Request) {
305	ctx := r.Context()
306	logger := log.FromContext(ctx)
307	cfg := config.FromContext(ctx)
308	repo := proto.RepositoryFromContext(ctx)
309
310	if !hasGitBug(ctx, repo) {
311		renderNotFound(w, r)
312		return
313	}
314
315	rc, err := openBugCache(ctx, repo)
316	if err != nil {
317		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
318		renderNotFound(w, r)
319		return
320	}
321	defer func() {
322		if closeErr := rc.Close(); closeErr != nil {
323			logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
324		}
325	}()
326
327	status := r.URL.Query().Get("status")
328	if status == "" {
329		status = "open"
330	}
331	if status != "all" && status != "open" && status != "closed" {
332		status = "open"
333	}
334
335	page := 1
336	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
337		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
338			page = p
339		}
340	}
341
342	bugs, err := getBugsList(rc, status)
343	if err != nil {
344		logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
345		renderInternalServerError(w, r)
346		return
347	}
348
349	totalBugs := len(bugs)
350	totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
351	if totalPages < 1 {
352		totalPages = 1
353	}
354	if page > totalPages && totalPages > 0 {
355		page = totalPages
356	}
357	if page < 1 {
358		page = 1
359	}
360
361	start := (page - 1) * defaultBugsPerPage
362	end := start + defaultBugsPerPage
363	if end > totalBugs {
364		end = totalBugs
365	}
366
367	pagedBugs := bugs[start:end]
368
369	gr, err := openRepository(repo)
370	if err != nil {
371		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
372		renderInternalServerError(w, r)
373		return
374	}
375	defaultBranch := getDefaultBranch(gr)
376
377	repoDisplayName := repo.ProjectName()
378	if repoDisplayName == "" {
379		repoDisplayName = repo.Name()
380	}
381
382	data := BugsData{
383		RepoBaseData: RepoBaseData{
384			BaseData: BaseData{
385				ServerName: cfg.Name,
386				ActiveTab:  "bugs",
387				Title:      "Bugs | " + repoDisplayName,
388			},
389			Repo:          repo,
390			DefaultBranch: defaultBranch,
391			HasGitBug:     true,
392		},
393		PaginationData: PaginationData{
394			Page:        page,
395			TotalPages:  totalPages,
396			HasPrevPage: page > 1,
397			HasNextPage: page < totalPages,
398		},
399		Bugs:   pagedBugs,
400		Status: status,
401	}
402
403	renderHTML(w, "bugs.html", data)
404}
405
406func repoBug(w http.ResponseWriter, r *http.Request) {
407	ctx := r.Context()
408	logger := log.FromContext(ctx)
409	cfg := config.FromContext(ctx)
410	repo := proto.RepositoryFromContext(ctx)
411	vars := mux.Vars(r)
412	hash := vars["hash"]
413
414	if !hasGitBug(ctx, repo) {
415		renderNotFound(w, r)
416		return
417	}
418
419	rc, err := openBugCache(ctx, repo)
420	if err != nil {
421		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
422		renderNotFound(w, r)
423		return
424	}
425	defer func() {
426		if closeErr := rc.Close(); closeErr != nil {
427			logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
428		}
429	}()
430
431	bugCache, err := findBugByHash(rc, hash)
432	if err != nil {
433		logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
434		renderNotFound(w, r)
435		return
436	}
437
438	snap := bugCache.Snapshot()
439	timeline := buildTimelineItems(snap)
440
441	labels := make([]Label, len(snap.Labels))
442	for i, label := range snap.Labels {
443		labels[i] = labelToWebLabel(label)
444	}
445
446	var edited bool
447	if len(timeline) > 0 && timeline[0].Type == "create" {
448		edited = timeline[0].Edited
449	}
450
451	gr, err := openRepository(repo)
452	if err != nil {
453		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
454		renderInternalServerError(w, r)
455		return
456	}
457	defaultBranch := getDefaultBranch(gr)
458
459	repoDisplayName := repo.ProjectName()
460	if repoDisplayName == "" {
461		repoDisplayName = repo.Name()
462	}
463
464	data := BugData{
465		RepoBaseData: RepoBaseData{
466			BaseData: BaseData{
467				ServerName: cfg.Name,
468				ActiveTab:  "bugs",
469				Title:      snap.Title + " | Bug " + snap.Id().Human() + " | " + repoDisplayName,
470			},
471			Repo:          repo,
472			DefaultBranch: defaultBranch,
473			HasGitBug:     true,
474		},
475		ID:        snap.Id().Human(),
476		Subject:   snap.Title,
477		Status:    snap.Status.String(),
478		Author:    snap.Author.DisplayName(),
479		CreatedAt: snap.CreateTime,
480		Edited:    edited,
481		Timeline:  timeline,
482		Labels:    labels,
483	}
484
485	renderHTML(w, "bug.html", data)
486}