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	Status        string
 72	AddedLabels   []Label
 73	RemovedLabels []Label
 74}
 75
 76type Label struct {
 77	Name  string
 78	Color string
 79}
 80
 81func hasGitBug(ctx context.Context, repo proto.Repository) bool {
 82	cfg := config.FromContext(ctx)
 83	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 84	bugsPath := filepath.Join(repoPath, "refs", "bugs")
 85
 86	info, err := os.Stat(bugsPath)
 87	if err != nil {
 88		return false
 89	}
 90	return info.IsDir()
 91}
 92
 93func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
 94	cfg := config.FromContext(ctx)
 95	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 96
 97	goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
 98	if err != nil {
 99		return nil, err
100	}
101
102	rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
103	if err != nil {
104		goGitRepo.Close()
105		return nil, err
106	}
107
108	return rc, nil
109}
110
111func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
112	allBugs := rc.Bugs().AllIds()
113	bugs := make([]BugListItem, 0)
114
115	for _, id := range allBugs {
116		bugCache, err := rc.Bugs().Resolve(id)
117		if err != nil {
118			continue
119		}
120
121		snap := bugCache.Snapshot()
122
123		if status != "all" && snap.Status.String() != status {
124			continue
125		}
126
127		labels := make([]Label, len(snap.Labels))
128		for i, label := range snap.Labels {
129			labels[i] = labelToWebLabel(label)
130		}
131
132		bugs = append(bugs, BugListItem{
133			ID:           snap.Id().Human(),
134			FullID:       snap.Id().String(),
135			Title:        snap.Title,
136			Author:       snap.Author.DisplayName(),
137			AuthorAvatar: snap.Author.AvatarUrl(),
138			Status:       snap.Status.String(),
139			CreatedAt:    snap.CreateTime,
140			LastActivity: getLastActivity(snap),
141			HasActivity:  len(snap.Timeline) > 1,
142			CommentCount: countComments(snap),
143			Labels:       labels,
144		})
145	}
146
147	sort.Slice(bugs, func(i, j int) bool {
148		return bugs[i].LastActivity.After(bugs[j].LastActivity)
149	})
150
151	return bugs, nil
152}
153
154func getLastActivity(snap *bug.Snapshot) time.Time {
155	var lastTime time.Time
156
157	for _, item := range snap.Timeline {
158		var itemTime time.Time
159
160		switch op := item.(type) {
161		case *bug.CreateTimelineItem:
162			itemTime = op.CreatedAt.Time()
163		case *bug.AddCommentTimelineItem:
164			itemTime = op.CreatedAt.Time()
165		case *bug.SetTitleTimelineItem:
166			itemTime = op.UnixTime.Time()
167		case *bug.SetStatusTimelineItem:
168			itemTime = op.UnixTime.Time()
169		case *bug.LabelChangeTimelineItem:
170			itemTime = op.UnixTime.Time()
171		}
172
173		if itemTime.After(lastTime) {
174			lastTime = itemTime
175		}
176	}
177
178	return lastTime
179}
180
181func countComments(snap *bug.Snapshot) int {
182	count := 0
183	for _, item := range snap.Timeline {
184		if _, ok := item.(*bug.AddCommentTimelineItem); ok {
185			count++
186		}
187	}
188	return count
189}
190
191func buildTimelineItems(snap *bug.Snapshot) []TimelineItem {
192	items := make([]TimelineItem, 0, len(snap.Timeline))
193
194	for _, item := range snap.Timeline {
195		switch op := item.(type) {
196		case *bug.CreateTimelineItem:
197			messageHTML := template.HTML("")
198			if !op.MessageIsEmpty() {
199				rendered, err := renderMarkdown([]byte(op.Message))
200				if err != nil {
201					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
202				} else {
203					messageHTML = rendered
204				}
205			}
206
207			items = append(items, TimelineItem{
208				Type:         "create",
209				ID:           op.CombinedId().String(),
210				Author:       op.Author.DisplayName(),
211				AuthorAvatar: op.Author.AvatarUrl(),
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				AuthorAvatar: op.Author.AvatarUrl(),
233				Timestamp:    op.CreatedAt.Time(),
234				Edited:       op.Edited(),
235				Message:      messageHTML,
236			})
237
238		case *bug.SetTitleTimelineItem:
239			items = append(items, TimelineItem{
240				Type:         "title",
241				ID:           op.CombinedId().String(),
242				Author:       op.Author.DisplayName(),
243				AuthorAvatar: op.Author.AvatarUrl(),
244				Timestamp:    op.UnixTime.Time(),
245				Title:        op.Title,
246			})
247
248		case *bug.SetStatusTimelineItem:
249			items = append(items, TimelineItem{
250				Type:         "status",
251				ID:           op.CombinedId().String(),
252				Author:       op.Author.DisplayName(),
253				AuthorAvatar: op.Author.AvatarUrl(),
254				Timestamp:    op.UnixTime.Time(),
255				Status:       op.Status.Action(),
256			})
257
258		case *bug.LabelChangeTimelineItem:
259			added := make([]Label, len(op.Added))
260			for i, label := range op.Added {
261				added[i] = labelToWebLabel(label)
262			}
263
264			removed := make([]Label, len(op.Removed))
265			for i, label := range op.Removed {
266				removed[i] = labelToWebLabel(label)
267			}
268
269			items = append(items, TimelineItem{
270				Type:          "labels",
271				ID:            op.CombinedId().String(),
272				Author:        op.Author.DisplayName(),
273				AuthorAvatar:  op.Author.AvatarUrl(),
274				Timestamp:     op.UnixTime.Time(),
275				AddedLabels:   added,
276				RemovedLabels: removed,
277			})
278		}
279	}
280
281	return items
282}
283
284func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
285	allBugs := rc.Bugs().AllIds()
286	for _, id := range allBugs {
287		if id.String() == hash || id.Human() == hash {
288			return rc.Bugs().Resolve(id)
289		}
290	}
291	return nil, fmt.Errorf("bug not found")
292}
293
294func labelToWebLabel(label common.Label) Label {
295	rgba := label.Color().RGBA()
296	return Label{
297		Name:  label.String(),
298		Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
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 = "open"
328	}
329	if status != "all" && status != "open" && status != "closed" {
330		status = "open"
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	repoDisplayName := repo.ProjectName()
376	if repoDisplayName == "" {
377		repoDisplayName = repo.Name()
378	}
379
380	data := BugsData{
381		RepoBaseData: RepoBaseData{
382			BaseData: BaseData{
383				ServerName: cfg.Name,
384				ActiveTab:  "bugs",
385				Title:      "Bugs | " + repoDisplayName,
386			},
387			Repo:          repo,
388			DefaultBranch: defaultBranch,
389			HasGitBug:     true,
390		},
391		PaginationData: PaginationData{
392			Page:        page,
393			TotalPages:  totalPages,
394			HasPrevPage: page > 1,
395			HasNextPage: page < totalPages,
396		},
397		Bugs:   pagedBugs,
398		Status: status,
399	}
400
401	renderHTML(w, "bugs.html", data)
402}
403
404func repoBug(w http.ResponseWriter, r *http.Request) {
405	ctx := r.Context()
406	logger := log.FromContext(ctx)
407	cfg := config.FromContext(ctx)
408	repo := proto.RepositoryFromContext(ctx)
409	vars := mux.Vars(r)
410	hash := vars["hash"]
411
412	if !hasGitBug(ctx, repo) {
413		renderNotFound(w, r)
414		return
415	}
416
417	rc, err := openBugCache(ctx, repo)
418	if err != nil {
419		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
420		renderNotFound(w, r)
421		return
422	}
423	defer func() {
424		if closeErr := rc.Close(); closeErr != nil {
425			logger.Debug("failed to close bug cache", "repo", repo.Name(), "err", closeErr)
426		}
427	}()
428
429	bugCache, err := findBugByHash(rc, hash)
430	if err != nil {
431		logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
432		renderNotFound(w, r)
433		return
434	}
435
436	snap := bugCache.Snapshot()
437	timeline := buildTimelineItems(snap)
438
439	labels := make([]Label, len(snap.Labels))
440	for i, label := range snap.Labels {
441		labels[i] = labelToWebLabel(label)
442	}
443
444	var edited bool
445	if len(timeline) > 0 && timeline[0].Type == "create" {
446		edited = timeline[0].Edited
447	}
448
449	gr, err := openRepository(repo)
450	if err != nil {
451		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
452		renderInternalServerError(w, r)
453		return
454	}
455	defaultBranch := getDefaultBranch(gr)
456
457	repoDisplayName := repo.ProjectName()
458	if repoDisplayName == "" {
459		repoDisplayName = repo.Name()
460	}
461
462	data := BugData{
463		RepoBaseData: RepoBaseData{
464			BaseData: BaseData{
465				ServerName: cfg.Name,
466				ActiveTab:  "bugs",
467				Title:      snap.Title + " | Bug " + snap.Id().Human() + " | " + repoDisplayName,
468			},
469			Repo:          repo,
470			DefaultBranch: defaultBranch,
471			HasGitBug:     true,
472		},
473		ID:        snap.Id().Human(),
474		Subject:   snap.Title,
475		Status:    snap.Status.String(),
476		Author:    snap.Author.DisplayName(),
477		CreatedAt: snap.CreateTime,
478		Edited:    edited,
479		Timeline:  timeline,
480		Labels:    labels,
481	}
482
483	renderHTML(w, "bug.html", data)
484}