diff --git a/pkg/web/static/overrides.css b/pkg/web/static/overrides.css
index b974f316ca94320ecf4f2de9aaa69cee0ff5e112..78caec623b111b7b9a920324a68ff40e1cbf36c4 100644
--- a/pkg/web/static/overrides.css
+++ b/pkg/web/static/overrides.css
@@ -103,6 +103,46 @@ section[aria-labelledby="diff-heading"] article h4:focus-within .secondary {
--pico-font-family-monospace: "0xProto","JetBrains Mono",ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);
}
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1;
+ border-radius: 0.25rem;
+ text-transform: lowercase;
+}
+
+.badge-open {
+ background-color: #22c55e;
+ color: #ffffff;
+}
+
+.badge-closed {
+ background-color: #ef4444;
+ color: #ffffff;
+}
+
+.bug-message {
+ margin: 1rem 0;
+}
+
+.muted {
+ color: var(--pico-muted-color);
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
article > header > h1,
article > header > h2,
article > header > h3,
@@ -115,4 +155,4 @@ article > header > p {
article > p:only-child {
margin-bottom: 0;
-}
+}
diff --git a/pkg/web/templates/base.html b/pkg/web/templates/base.html
index c46d5cd931004f2ef632ffe93ccb609e214d75fb..edddf1d39b6b8533f23076853ec4184670b232a9 100644
--- a/pkg/web/templates/base.html
+++ b/pkg/web/templates/base.html
@@ -49,6 +49,9 @@
- README
+ {{if .HasGitBug}}
+ - Bugs
+ {{end}}
- Files
- Commits
- Branches
diff --git a/pkg/web/templates/bug.html b/pkg/web/templates/bug.html
new file mode 100644
index 0000000000000000000000000000000000000000..5e5da2b8f6a5089f38d3733242b61b7e673d0d67
--- /dev/null
+++ b/pkg/web/templates/bug.html
@@ -0,0 +1,88 @@
+{{define "content"}}
+
+
+
+
+ [{{.ID}}] {{.Title}}
+ {{.Status}}
+
+
+ {{.Author}} opened this bug on
+
+ {{if .Edited}}(edited){{end}}
+
+ {{if .Labels}}
+
+ {{range .Labels}}
+ {{.Name}}
+ {{end}}
+
+ {{end}}
+
+
+
+ Timeline
+
+ {{range .Timeline}}
+
+ {{if eq .Type "create"}}
+ Initial description
+ {{if .Message}}
+
+ {{.Message}}
+
+ {{else}}
+ No description provided.
+ {{end}}
+
+ {{else if eq .Type "comment"}}
+
+ {{.Author}} commented on
+
+ {{if .Edited}}(edited){{end}}
+
+ {{if .Message}}
+
+ {{.Message}}
+
+ {{else}}
+ No comment provided.
+ {{end}}
+
+ {{else if eq .Type "title"}}
+
+ {{.Author}} changed the title to {{.Title}} on
+
+
+
+ {{else if eq .Type "status"}}
+
+ {{.Author}} {{.Status}} the bug on
+
+
+
+ {{else if eq .Type "labels"}}
+
+ {{.Author}}
+ {{if .AddedLabels}}
+ added label{{if gt (len .AddedLabels) 1}}s{{end}}
+ {{range $i, $label := .AddedLabels}}{{if $i}}, {{end}}{{$label}}{{end}}
+ {{end}}
+ {{if and .AddedLabels .RemovedLabels}}and{{end}}
+ {{if .RemovedLabels}}
+ removed label{{if gt (len .RemovedLabels) 1}}s{{end}}
+ {{range $i, $label := .RemovedLabels}}{{if $i}}, {{end}}{{$label}}{{end}}
+ {{end}}
+ on
+
+ {{end}}
+
+ {{end}}
+
+{{end}}
diff --git a/pkg/web/templates/bugs.html b/pkg/web/templates/bugs.html
new file mode 100644
index 0000000000000000000000000000000000000000..1b8ff82dd378081270148c431de622553c8ccc5d
--- /dev/null
+++ b/pkg/web/templates/bugs.html
@@ -0,0 +1,64 @@
+{{define "content"}}
+
+ Bugs
+
+
+
+ {{if .Bugs}}
+ {{range .Bugs}}
+
+
+ {{.ID}}
+ {{.Title}}
+ {{.Status}}
+
+
+ by {{.Author}} on
+
+ • Last activity:
+
+ {{if gt .CommentCount 0}}
+ • {{.CommentCount}} comment{{if ne .CommentCount 1}}s{{end}}
+ {{end}}
+
+
+ {{end}}
+
+ {{if or .HasPrevPage .HasNextPage}}
+
+ {{end}}
+ {{else}}
+ {{if eq .Status "all"}}
+ git-bug is initialised, but this repo has no bugs.
+ {{else if eq .Status "open"}}
+ No open bugs.
+ {{else if eq .Status "closed"}}
+ No closed bugs.
+ {{end}}
+ {{end}}
+
+{{end}}
diff --git a/pkg/web/webui.go b/pkg/web/webui.go
index 51eb8ea96cc28071998d37f9ff95be50c93b6644..5aefdf0f1bc9462dd13508d7466478e4cbbd98ad 100644
--- a/pkg/web/webui.go
+++ b/pkg/web/webui.go
@@ -51,6 +51,7 @@ type RepoBaseData struct {
BaseData
Repo proto.Repository
DefaultBranch string
+ HasGitBug bool
}
// PaginationData contains common fields for paginated views.
@@ -365,6 +366,12 @@ func WebUIController(ctx context.Context, r *mux.Router) {
r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
Methods(http.MethodGet)
+ // Bugs routes
+ r.Handle(basePrefix+"/bugs", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBugs)))).
+ Methods(http.MethodGet)
+ r.Handle(basePrefix+"/bug/{hash:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBug)))).
+ Methods(http.MethodGet)
+
// Repository overview (catch-all, must be last)
r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
Methods(http.MethodGet)
diff --git a/pkg/web/webui_blob.go b/pkg/web/webui_blob.go
index 878156497e637ab1f047d9c593d72f8db044632b..ae9c9cf6edc480e43fef9b1f38cfa284ebda078d 100644
--- a/pkg/web/webui_blob.go
+++ b/pkg/web/webui_blob.go
@@ -117,6 +117,7 @@ func repoBlob(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
Ref: ref,
Path: path,
diff --git a/pkg/web/webui_branches.go b/pkg/web/webui_branches.go
index 26bf33a6a98fbdf018cab838b3e5be3c82489c9f..26fbd6c9d29abb8e67b3e1ddd87d0547079b074c 100644
--- a/pkg/web/webui_branches.go
+++ b/pkg/web/webui_branches.go
@@ -105,6 +105,7 @@ func repoBranches(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
PaginationData: PaginationData{
Page: page,
diff --git a/pkg/web/webui_bugs.go b/pkg/web/webui_bugs.go
new file mode 100644
index 0000000000000000000000000000000000000000..532a399db2fedaa534954c11d09a21081863b15a
--- /dev/null
+++ b/pkg/web/webui_bugs.go
@@ -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)
+}
diff --git a/pkg/web/webui_commit.go b/pkg/web/webui_commit.go
index cafe19797493b4f6706018667ac55e5459dd3bbf..352a7c45915ccccac6405ed06b13836a9704edbe 100644
--- a/pkg/web/webui_commit.go
+++ b/pkg/web/webui_commit.go
@@ -90,6 +90,7 @@ func repoCommit(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
Commit: commit,
Diff: diff,
diff --git a/pkg/web/webui_commits.go b/pkg/web/webui_commits.go
index b339afa8688616da14495f801745da69d44eeb00..d21bb88dc4dc68a4d6e7cca8f5d863e69142441b 100644
--- a/pkg/web/webui_commits.go
+++ b/pkg/web/webui_commits.go
@@ -99,6 +99,7 @@ func repoCommits(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
PaginationData: PaginationData{
Page: page,
diff --git a/pkg/web/webui_overview.go b/pkg/web/webui_overview.go
index 7b14bc97f29038e66a916da635a4ff2029129807..5503584785edfcb2104b85c565aec3a5ef1e7522 100644
--- a/pkg/web/webui_overview.go
+++ b/pkg/web/webui_overview.go
@@ -65,6 +65,7 @@ func repoOverview(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
IsEmpty: isEmpty,
SSHURL: sshURL,
diff --git a/pkg/web/webui_tags.go b/pkg/web/webui_tags.go
index 416b9534bb76c25274853bd535f1a2d0ada06dc9..6d2b0b3e1352db35a48a1768b1ab99d654979e0a 100644
--- a/pkg/web/webui_tags.go
+++ b/pkg/web/webui_tags.go
@@ -125,6 +125,7 @@ func repoTags(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
PaginationData: PaginationData{
Page: page,
diff --git a/pkg/web/webui_tree.go b/pkg/web/webui_tree.go
index 70d18bcd09e5362c5e37444517f18a86bd0eb08b..832353a84a932ef79c7d09c4bef0de23176ef31a 100644
--- a/pkg/web/webui_tree.go
+++ b/pkg/web/webui_tree.go
@@ -113,6 +113,7 @@ func repoTree(w http.ResponseWriter, r *http.Request) {
},
Repo: repo,
DefaultBranch: defaultBranch,
+ HasGitBug: hasGitBug(ctx, repo),
},
Ref: ref,
Path: path,