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