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	// RGBA() returns 16-bit channels (0-65535), convert to 8-bit (0-255)
291	r8 := uint8(rgba.R >> 8)
292	g8 := uint8(rgba.G >> 8)
293	b8 := uint8(rgba.B >> 8)
294	return Label{
295		Name:  label.String(),
296		Color: fmt.Sprintf("#%02x%02x%02x", r8, g8, b8),
297	}
298}
299
300func repoBugs(w http.ResponseWriter, r *http.Request) {
301	ctx := r.Context()
302	logger := log.FromContext(ctx)
303	cfg := config.FromContext(ctx)
304	repo := proto.RepositoryFromContext(ctx)
305
306	if !hasGitBug(ctx, repo) {
307		renderNotFound(w, r)
308		return
309	}
310
311	rc, err := openBugCache(ctx, repo)
312	if err != nil {
313		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
314		renderNotFound(w, r)
315		return
316	}
317	defer rc.Close()
318
319	status := r.URL.Query().Get("status")
320	if status == "" {
321		status = "all"
322	}
323	if status != "all" && status != "open" && status != "closed" {
324		status = "all"
325	}
326
327	page := 1
328	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
329		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
330			page = p
331		}
332	}
333
334	bugs, err := getBugsList(rc, status)
335	if err != nil {
336		logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
337		renderInternalServerError(w, r)
338		return
339	}
340
341	totalBugs := len(bugs)
342	totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
343	if totalPages < 1 {
344		totalPages = 1
345	}
346	if page > totalPages && totalPages > 0 {
347		page = totalPages
348	}
349	if page < 1 {
350		page = 1
351	}
352
353	start := (page - 1) * defaultBugsPerPage
354	end := start + defaultBugsPerPage
355	if end > totalBugs {
356		end = totalBugs
357	}
358
359	pagedBugs := bugs[start:end]
360
361	gr, err := openRepository(repo)
362	if err != nil {
363		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
364		renderInternalServerError(w, r)
365		return
366	}
367	defaultBranch := getDefaultBranch(gr)
368
369	data := BugsData{
370		Repo:          repo,
371		DefaultBranch: defaultBranch,
372		ActiveTab:     "bugs",
373		ServerName:    cfg.Name,
374		HasGitBug:     true,
375		Bugs:          pagedBugs,
376		Status:        status,
377		Page:          page,
378		TotalPages:    totalPages,
379		HasPrevPage:   page > 1,
380		HasNextPage:   page < totalPages,
381	}
382
383	renderHTML(w, "bugs.html", data)
384}
385
386func repoBug(w http.ResponseWriter, r *http.Request) {
387	ctx := r.Context()
388	logger := log.FromContext(ctx)
389	cfg := config.FromContext(ctx)
390	repo := proto.RepositoryFromContext(ctx)
391	vars := mux.Vars(r)
392	hash := vars["hash"]
393
394	if !hasGitBug(ctx, repo) {
395		renderNotFound(w, r)
396		return
397	}
398
399	rc, err := openBugCache(ctx, repo)
400	if err != nil {
401		logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
402		renderNotFound(w, r)
403		return
404	}
405	defer rc.Close()
406
407	bugCache, err := findBugByHash(rc, hash)
408	if err != nil {
409		logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
410		renderNotFound(w, r)
411		return
412	}
413
414	snap := bugCache.Snapshot()
415	timeline := buildTimelineItems(snap)
416
417	labels := make([]Label, len(snap.Labels))
418	for i, label := range snap.Labels {
419		labels[i] = labelToWebLabel(label)
420	}
421
422	var edited bool
423	if len(timeline) > 0 && timeline[0].Type == "create" {
424		edited = timeline[0].Edited
425	}
426
427	gr, err := openRepository(repo)
428	if err != nil {
429		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
430		renderInternalServerError(w, r)
431		return
432	}
433	defaultBranch := getDefaultBranch(gr)
434
435	data := BugData{
436		Repo:          repo,
437		DefaultBranch: defaultBranch,
438		ActiveTab:     "bugs",
439		ServerName:    cfg.Name,
440		HasGitBug:     true,
441		ID:            snap.Id().Human(),
442		Title:         snap.Title,
443		Status:        snap.Status.String(),
444		Author:        snap.Author.DisplayName(),
445		CreatedAt:     snap.CreateTime,
446		Edited:        edited,
447		Timeline:      timeline,
448		Labels:        labels,
449	}
450
451	renderHTML(w, "bug.html", data)
452}