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