@@ -0,0 +1,448 @@
+package web
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "time"
+
+ "github.com/charmbracelet/log/v2"
+ "github.com/charmbracelet/soft-serve/pkg/config"
+ "github.com/charmbracelet/soft-serve/pkg/proto"
+ "github.com/git-bug/git-bug/cache"
+ "github.com/git-bug/git-bug/entities/bug"
+ "github.com/git-bug/git-bug/entities/common"
+ "github.com/git-bug/git-bug/repository"
+ "github.com/gorilla/mux"
+)
+
+const defaultBugsPerPage = 20
+
+type BugsData struct {
+ Repo proto.Repository
+ DefaultBranch string
+ ActiveTab string
+ ServerName string
+ HasGitBug bool
+
+ Bugs []BugListItem
+ Status string
+ Page int
+ TotalPages int
+ HasPrevPage bool
+ HasNextPage bool
+}
+
+type BugListItem struct {
+ ID string
+ FullID string
+ Title string
+ Author string
+ Status string
+ CreatedAt time.Time
+ LastActivity time.Time
+ CommentCount int
+}
+
+type BugData struct {
+ Repo proto.Repository
+ DefaultBranch string
+ ActiveTab string
+ ServerName string
+ HasGitBug bool
+
+ ID string
+ Title string
+ Status string
+ Author string
+ CreatedAt time.Time
+ Edited bool
+ Timeline []TimelineItem
+ Labels []Label
+}
+
+type TimelineItem struct {
+ Type string
+ ID string
+ Author string
+ Timestamp time.Time
+ Edited bool
+
+ Message template.HTML
+ Title string
+ Status string
+ AddedLabels []string
+ RemovedLabels []string
+}
+
+type Label struct {
+ Name string
+ Color string
+}
+
+func hasGitBug(ctx context.Context, repo proto.Repository) bool {
+ cfg := config.FromContext(ctx)
+ repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
+ bugsPath := filepath.Join(repoPath, "refs", "bugs")
+
+ info, err := os.Stat(bugsPath)
+ if err != nil {
+ return false
+ }
+ return info.IsDir()
+}
+
+func openBugCache(ctx context.Context, repo proto.Repository) (*cache.RepoCache, error) {
+ cfg := config.FromContext(ctx)
+ repoPath := filepath.Join(cfg.DataPath, "repos", repo.Name()+".git")
+
+ goGitRepo, err := repository.OpenGoGitRepo(repoPath, "git-bug", nil)
+ if err != nil {
+ return nil, err
+ }
+
+ rc, err := cache.NewRepoCacheNoEvents(goGitRepo)
+ if err != nil {
+ goGitRepo.Close()
+ return nil, err
+ }
+
+ return rc, nil
+}
+
+func getBugsList(rc *cache.RepoCache, status string) ([]BugListItem, error) {
+ allBugs := rc.Bugs().AllIds()
+ bugs := make([]BugListItem, 0)
+
+ for _, id := range allBugs {
+ bugCache, err := rc.Bugs().Resolve(id)
+ if err != nil {
+ continue
+ }
+
+ snap := bugCache.Snapshot()
+
+ if status != "all" && snap.Status.String() != status {
+ continue
+ }
+
+ bugs = append(bugs, BugListItem{
+ ID: snap.Id().Human(),
+ FullID: snap.Id().String(),
+ Title: snap.Title,
+ Author: snap.Author.DisplayName(),
+ Status: snap.Status.String(),
+ CreatedAt: snap.CreateTime,
+ LastActivity: getLastActivity(snap),
+ CommentCount: countComments(snap),
+ })
+ }
+
+ sort.Slice(bugs, func(i, j int) bool {
+ return bugs[i].LastActivity.After(bugs[j].LastActivity)
+ })
+
+ return bugs, nil
+}
+
+func getLastActivity(snap *bug.Snapshot) time.Time {
+ var lastTime time.Time
+
+ for _, item := range snap.Timeline {
+ var itemTime time.Time
+
+ switch op := item.(type) {
+ case *bug.CreateTimelineItem:
+ itemTime = op.CreatedAt.Time()
+ case *bug.AddCommentTimelineItem:
+ itemTime = op.CreatedAt.Time()
+ case *bug.SetTitleTimelineItem:
+ itemTime = op.UnixTime.Time()
+ case *bug.SetStatusTimelineItem:
+ itemTime = op.UnixTime.Time()
+ case *bug.LabelChangeTimelineItem:
+ itemTime = op.UnixTime.Time()
+ }
+
+ if itemTime.After(lastTime) {
+ lastTime = itemTime
+ }
+ }
+
+ return lastTime
+}
+
+func countComments(snap *bug.Snapshot) int {
+ count := 0
+ for _, item := range snap.Timeline {
+ if _, ok := item.(*bug.AddCommentTimelineItem); ok {
+ count++
+ }
+ }
+ return count
+}
+
+func buildTimelineItems(snap *bug.Snapshot) []TimelineItem {
+ items := make([]TimelineItem, 0, len(snap.Timeline))
+
+ for _, item := range snap.Timeline {
+ switch op := item.(type) {
+ case *bug.CreateTimelineItem:
+ messageHTML := template.HTML("")
+ if !op.MessageIsEmpty() {
+ rendered, err := renderMarkdown([]byte(op.Message))
+ if err != nil {
+ messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
+ } else {
+ messageHTML = rendered
+ }
+ }
+
+ items = append(items, TimelineItem{
+ Type: "create",
+ ID: op.CombinedId().String(),
+ Author: op.Author.DisplayName(),
+ Timestamp: op.CreatedAt.Time(),
+ Edited: op.Edited(),
+ Message: messageHTML,
+ })
+
+ case *bug.AddCommentTimelineItem:
+ messageHTML := template.HTML("")
+ if !op.MessageIsEmpty() {
+ rendered, err := renderMarkdown([]byte(op.Message))
+ if err != nil {
+ messageHTML = template.HTML(template.HTMLEscapeString(op.Message))
+ } else {
+ messageHTML = rendered
+ }
+ }
+
+ items = append(items, TimelineItem{
+ Type: "comment",
+ ID: op.CombinedId().String(),
+ Author: op.Author.DisplayName(),
+ Timestamp: op.CreatedAt.Time(),
+ Edited: op.Edited(),
+ Message: messageHTML,
+ })
+
+ case *bug.SetTitleTimelineItem:
+ items = append(items, TimelineItem{
+ Type: "title",
+ ID: op.CombinedId().String(),
+ Author: op.Author.DisplayName(),
+ Timestamp: op.UnixTime.Time(),
+ Title: op.Title,
+ })
+
+ case *bug.SetStatusTimelineItem:
+ items = append(items, TimelineItem{
+ Type: "status",
+ ID: op.CombinedId().String(),
+ Author: op.Author.DisplayName(),
+ Timestamp: op.UnixTime.Time(),
+ Status: op.Status.Action(),
+ })
+
+ case *bug.LabelChangeTimelineItem:
+ added := make([]string, len(op.Added))
+ for i, label := range op.Added {
+ added[i] = label.String()
+ }
+
+ removed := make([]string, len(op.Removed))
+ for i, label := range op.Removed {
+ removed[i] = label.String()
+ }
+
+ items = append(items, TimelineItem{
+ Type: "labels",
+ ID: op.CombinedId().String(),
+ Author: op.Author.DisplayName(),
+ Timestamp: op.UnixTime.Time(),
+ AddedLabels: added,
+ RemovedLabels: removed,
+ })
+ }
+ }
+
+ return items
+}
+
+func findBugByHash(rc *cache.RepoCache, hash string) (*cache.BugCache, error) {
+ allBugs := rc.Bugs().AllIds()
+ for _, id := range allBugs {
+ if id.String() == hash || id.Human() == hash {
+ return rc.Bugs().Resolve(id)
+ }
+ }
+ return nil, fmt.Errorf("bug not found")
+}
+
+func labelToWebLabel(label common.Label) Label {
+ rgba := label.Color().RGBA()
+ return Label{
+ Name: label.String(),
+ Color: fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B),
+ }
+}
+
+func repoBugs(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := log.FromContext(ctx)
+ cfg := config.FromContext(ctx)
+ repo := proto.RepositoryFromContext(ctx)
+
+ if !hasGitBug(ctx, repo) {
+ renderNotFound(w, r)
+ return
+ }
+
+ rc, err := openBugCache(ctx, repo)
+ if err != nil {
+ logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
+ renderNotFound(w, r)
+ return
+ }
+ defer rc.Close()
+
+ status := r.URL.Query().Get("status")
+ if status == "" {
+ status = "all"
+ }
+ if status != "all" && status != "open" && status != "closed" {
+ status = "all"
+ }
+
+ page := 1
+ if pageStr := r.URL.Query().Get("page"); pageStr != "" {
+ if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+ page = p
+ }
+ }
+
+ bugs, err := getBugsList(rc, status)
+ if err != nil {
+ logger.Debug("failed to get bugs list", "repo", repo.Name(), "err", err)
+ renderInternalServerError(w, r)
+ return
+ }
+
+ totalBugs := len(bugs)
+ totalPages := (totalBugs + defaultBugsPerPage - 1) / defaultBugsPerPage
+ if totalPages < 1 {
+ totalPages = 1
+ }
+ if page > totalPages && totalPages > 0 {
+ page = totalPages
+ }
+ if page < 1 {
+ page = 1
+ }
+
+ start := (page - 1) * defaultBugsPerPage
+ end := start + defaultBugsPerPage
+ if end > totalBugs {
+ end = totalBugs
+ }
+
+ pagedBugs := bugs[start:end]
+
+ gr, err := openRepository(repo)
+ if err != nil {
+ logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+ renderInternalServerError(w, r)
+ return
+ }
+ defaultBranch := getDefaultBranch(gr)
+
+ data := BugsData{
+ Repo: repo,
+ DefaultBranch: defaultBranch,
+ ActiveTab: "bugs",
+ ServerName: cfg.Name,
+ HasGitBug: true,
+ Bugs: pagedBugs,
+ Status: status,
+ Page: page,
+ TotalPages: totalPages,
+ HasPrevPage: page > 1,
+ HasNextPage: page < totalPages,
+ }
+
+ renderHTML(w, "bugs.html", data)
+}
+
+func repoBug(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ logger := log.FromContext(ctx)
+ cfg := config.FromContext(ctx)
+ repo := proto.RepositoryFromContext(ctx)
+ vars := mux.Vars(r)
+ hash := vars["hash"]
+
+ if !hasGitBug(ctx, repo) {
+ renderNotFound(w, r)
+ return
+ }
+
+ rc, err := openBugCache(ctx, repo)
+ if err != nil {
+ logger.Debug("failed to open bug cache", "repo", repo.Name(), "err", err)
+ renderNotFound(w, r)
+ return
+ }
+ defer rc.Close()
+
+ bugCache, err := findBugByHash(rc, hash)
+ if err != nil {
+ logger.Debug("failed to find bug", "repo", repo.Name(), "hash", hash, "err", err)
+ renderNotFound(w, r)
+ return
+ }
+
+ snap := bugCache.Snapshot()
+ timeline := buildTimelineItems(snap)
+
+ labels := make([]Label, len(snap.Labels))
+ for i, label := range snap.Labels {
+ labels[i] = labelToWebLabel(label)
+ }
+
+ var edited bool
+ if len(timeline) > 0 && timeline[0].Type == "create" {
+ edited = timeline[0].Edited
+ }
+
+ gr, err := openRepository(repo)
+ if err != nil {
+ logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+ renderInternalServerError(w, r)
+ return
+ }
+ defaultBranch := getDefaultBranch(gr)
+
+ data := BugData{
+ Repo: repo,
+ DefaultBranch: defaultBranch,
+ ActiveTab: "bugs",
+ ServerName: cfg.Name,
+ HasGitBug: true,
+ ID: snap.Id().Human(),
+ Title: snap.Title,
+ Status: snap.Status.String(),
+ Author: snap.Author.DisplayName(),
+ CreatedAt: snap.CreateTime,
+ Edited: edited,
+ Timeline: timeline,
+ Labels: labels,
+ }
+
+ renderHTML(w, "bug.html", data)
+}