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	CommentCount int
 50}
 51
 52type BugData struct {
 53	Repo          proto.Repository
 54	DefaultBranch string
 55	ActiveTab     string
 56	ServerName    string
 57	HasGitBug     bool
 58
 59	ID        string
 60	Title     string
 61	Status    string
 62	Author    string
 63	CreatedAt time.Time
 64	Edited    bool
 65	Timeline  []TimelineItem
 66	Labels    []Label
 67}
 68
 69type TimelineItem struct {
 70	Type      string
 71	ID        string
 72	Author    string
 73	Timestamp time.Time
 74	Edited    bool
 75
 76	Message       template.HTML
 77	Title         string
 78	Status        string
 79	AddedLabels   []string
 80	RemovedLabels []string
 81}
 82
 83type Label struct {
 84	Name  string
 85	Color string
 86}
 87
 88func hasGitBug(ctx context.Context, repo proto.Repository) bool {
 89	cfg := config.FromContext(ctx)
 90	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
 91	bugsPath := filepath.Join(repoPath, "refs", "bugs")
 92
 93	info, err := os.Stat(bugsPath)
 94	if err != nil {
 95		return false
 96	}
 97	return info.IsDir()
 98}
 99
100func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
101	cfg := config.FromContext(ctx)
102	repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
103
104	goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
105	if err != nil {
106		return nil, err
107	}
108
109	rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
110	if err != nil {
111		goGitRepo.Close()
112		return nil, err
113	}
114
115	return rc, nil
116}
117
118func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
119	allBugs := rc.Bugs().AllIds()
120	bugs := make([]BugListItem, 0)
121
122	for _, id := range allBugs {
123		bugCache, err := rc.Bugs().Resolve(id)
124		if err != nil {
125			continue
126		}
127
128		snap := bugCache.Snapshot()
129
130		if status != "all" && snap.Status.String() != status {
131			continue
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			Status:       snap.Status.String(),
140			CreatedAt:    snap.CreateTime,
141			LastActivity: getLastActivity(snap),
142			CommentCount: countComments(snap),
143		})
144	}
145
146	sort.Slice(bugs, func(i, j int) bool {
147		return bugs[i].LastActivity.After(bugs[j].LastActivity)
148	})
149
150	return bugs, nil
151}
152
153func getLastActivity(snap *bug.Snapshot) time.Time {
154	var lastTime time.Time
155
156	for _, item := range snap.Timeline {
157		var itemTime time.Time
158
159		switch op := item.(type) {
160		case *bug.CreateTimelineItem:
161			itemTime = op.CreatedAt.Time()
162		case *bug.AddCommentTimelineItem:
163			itemTime = op.CreatedAt.Time()
164		case *bug.SetTitleTimelineItem:
165			itemTime = op.UnixTime.Time()
166		case *bug.SetStatusTimelineItem:
167			itemTime = op.UnixTime.Time()
168		case *bug.LabelChangeTimelineItem:
169			itemTime = op.UnixTime.Time()
170		}
171
172		if itemTime.After(lastTime) {
173			lastTime = itemTime
174		}
175	}
176
177	return lastTime
178}
179
180func countComments(snap *bug.Snapshot) int {
181	count := 0
182	for _, item := range snap.Timeline {
183		if _, ok := item.(*bug.AddCommentTimelineItem); ok {
184			count++
185		}
186	}
187	return count
188}
189
190func buildTimelineItems(snap *bug.Snapshot) []TimelineItem {
191	items := make([]TimelineItem, 0, len(snap.Timeline))
192
193	for _, item := range snap.Timeline {
194		switch op := item.(type) {
195		case *bug.CreateTimelineItem:
196			messageHTML := template.HTML("")
197			if !op.MessageIsEmpty() {
198				rendered, err := renderMarkdown([]byte(op.Message))
199				if err != nil {
200					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
201				} else {
202					messageHTML = rendered
203				}
204			}
205
206			items = append(items, TimelineItem{
207				Type:      "create",
208				ID:        op.CombinedId().String(),
209				Author:    op.Author.DisplayName(),
210				Timestamp: op.CreatedAt.Time(),
211				Edited:    op.Edited(),
212				Message:   messageHTML,
213			})
214
215		case *bug.AddCommentTimelineItem:
216			messageHTML := template.HTML("")
217			if !op.MessageIsEmpty() {
218				rendered, err := renderMarkdown([]byte(op.Message))
219				if err != nil {
220					messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
221				} else {
222					messageHTML = rendered
223				}
224			}
225
226			items = append(items, TimelineItem{
227				Type:      "comment",
228				ID:        op.CombinedId().String(),
229				Author:    op.Author.DisplayName(),
230				Timestamp: op.CreatedAt.Time(),
231				Edited:    op.Edited(),
232				Message:   messageHTML,
233			})
234
235		case *bug.SetTitleTimelineItem:
236			items = append(items, TimelineItem{
237				Type:      "title",
238				ID:        op.CombinedId().String(),
239				Author:    op.Author.DisplayName(),
240				Timestamp: op.UnixTime.Time(),
241				Title:     op.Title,
242			})
243
244		case *bug.SetStatusTimelineItem:
245			items = append(items, TimelineItem{
246				Type:      "status",
247				ID:        op.CombinedId().String(),
248				Author:    op.Author.DisplayName(),
249				Timestamp: op.UnixTime.Time(),
250				Status:    op.Status.Action(),
251			})
252
253		case *bug.LabelChangeTimelineItem:
254			added := make([]string, len(op.Added))
255			for i, label := range op.Added {
256				added[i] = label.String()
257			}
258
259			removed := make([]string, len(op.Removed))
260			for i, label := range op.Removed {
261				removed[i] = label.String()
262			}
263
264			items = append(items, TimelineItem{
265				Type:          "labels",
266				ID:            op.CombinedId().String(),
267				Author:        op.Author.DisplayName(),
268				Timestamp:     op.UnixTime.Time(),
269				AddedLabels:   added,
270				RemovedLabels: removed,
271			})
272		}
273	}
274
275	return items
276}
277
278func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
279	allBugs := rc.Bugs().AllIds()
280	for _, id := range allBugs {
281		if id.String() == hash || id.Human() == hash {
282			return rc.Bugs().Resolve(id)
283		}
284	}
285	return nil, fmt.Errorf("bug not found")
286}
287
288func labelToWebLabel(label common.Label) Label {
289	rgba := label.Color().RGBA()
290	return Label{
291		Name:  label.String(),
292		Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
293	}
294}
295
296func repoBugs(w http.ResponseWriter, r *http.Request) {
297	ctx := r.Context()
298	logger := log.FromContext(ctx)
299	cfg := config.FromContext(ctx)
300	repo := proto.RepositoryFromContext(ctx)
301
302	if !hasGitBug(ctx, repo) {
303		renderNotFound(w, r)
304		return
305	}
306
307	rc, err := openBugCache(ctx, repo)
308	if err != nil {
309		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
310		renderNotFound(w, r)
311		return
312	}
313	defer rc.Close()
314
315	status := r.URL.Query().Get("status")
316	if status == "" {
317		status = "all"
318	}
319	if status != "all" && status != "open" && status != "closed" {
320		status = "all"
321	}
322
323	page := 1
324	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
325		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
326			page = p
327		}
328	}
329
330	bugs, err := getBugsList(rc, status)
331	if err != nil {
332		logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
333		renderInternalServerError(w, r)
334		return
335	}
336
337	totalBugs := len(bugs)
338	totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
339	if totalPages < 1 {
340		totalPages = 1
341	}
342	if page > totalPages && totalPages > 0 {
343		page = totalPages
344	}
345	if page < 1 {
346		page = 1
347	}
348
349	start := (page - 1) * defaultBugsPerPage
350	end := start + defaultBugsPerPage
351	if end > totalBugs {
352		end = totalBugs
353	}
354
355	pagedBugs := bugs[start:end]
356
357	gr, err := openRepository(repo)
358	if err != nil {
359		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
360		renderInternalServerError(w, r)
361		return
362	}
363	defaultBranch := getDefaultBranch(gr)
364
365	data := BugsData{
366		Repo:          repo,
367		DefaultBranch: defaultBranch,
368		ActiveTab:     "bugs",
369		ServerName:    cfg.Name,
370		HasGitBug:     true,
371		Bugs:          pagedBugs,
372		Status:        status,
373		Page:          page,
374		TotalPages:    totalPages,
375		HasPrevPage:   page > 1,
376		HasNextPage:   page < totalPages,
377	}
378
379	renderHTML(w, "bugs.html", data)
380}
381
382func repoBug(w http.ResponseWriter, r *http.Request) {
383	ctx := r.Context()
384	logger := log.FromContext(ctx)
385	cfg := config.FromContext(ctx)
386	repo := proto.RepositoryFromContext(ctx)
387	vars := mux.Vars(r)
388	hash := vars["hash"]
389
390	if !hasGitBug(ctx, repo) {
391		renderNotFound(w, r)
392		return
393	}
394
395	rc, err := openBugCache(ctx, repo)
396	if err != nil {
397		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
398		renderNotFound(w, r)
399		return
400	}
401	defer rc.Close()
402
403	bugCache, err := findBugByHash(rc, hash)
404	if err != nil {
405		logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
406		renderNotFound(w, r)
407		return
408	}
409
410	snap := bugCache.Snapshot()
411	timeline := buildTimelineItems(snap)
412
413	labels := make([]Label, len(snap.Labels))
414	for i, label := range snap.Labels {
415		labels[i] = labelToWebLabel(label)
416	}
417
418	var edited bool
419	if len(timeline) > 0 && timeline[0].Type == "create" {
420		edited = timeline[0].Edited
421	}
422
423	gr, err := openRepository(repo)
424	if err != nil {
425		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
426		renderInternalServerError(w, r)
427		return
428	}
429	defaultBranch := getDefaultBranch(gr)
430
431	data := BugData{
432		Repo:          repo,
433		DefaultBranch: defaultBranch,
434		ActiveTab:     "bugs",
435		ServerName:    cfg.Name,
436		HasGitBug:     true,
437		ID:            snap.Id().Human(),
438		Title:         snap.Title,
439		Status:        snap.Status.String(),
440		Author:        snap.Author.DisplayName(),
441		CreatedAt:     snap.CreateTime,
442		Edited:        edited,
443		Timeline:      timeline,
444		Labels:        labels,
445	}
446
447	renderHTML(w, "bug.html", data)
448}