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