issues.go
1package pgit
2
3import (
4 "fmt"
5 "html/template"
6 "net/url"
7 "path/filepath"
8 "regexp"
9 "sort"
10 "time"
11
12 "github.com/git-bug/git-bug/entities/bug"
13 "github.com/git-bug/git-bug/repository"
14)
15
16var commitRefRegex = regexp.MustCompile(`(?i)\bFixed in commit ([a-f0-9]{40})\b`)
17
18func (c *Config) transformCommitRefs(content template.HTML) template.HTML {
19 contentStr := string(content)
20
21 result := commitRefRegex.ReplaceAllStringFunc(contentStr, func(match string) string {
22 if len(match) < 40 {
23 return match
24 }
25 hash := match[len(match)-40:]
26 prefix := match[:len(match)-40]
27 shortHash := GetShortID(hash)
28 commitURL := c.GetCommitURL(hash)
29
30 return fmt.Sprintf(`%s<a href="%s">%s</a>`, prefix, commitURL, shortHash)
31 })
32
33 return template.HTML(result)
34}
35
36type IssueData struct {
37 ID string
38 FullID string
39 Title string
40 Status string
41 Author string
42 CreatedAt string
43 CreatedAtISO string
44 CreatedAtDisp string
45 Labels []string
46 CommentCount int
47 Description template.HTML
48 Comments []CommentData
49 URL template.URL
50}
51
52type CommentData struct {
53 ID string
54 Author string
55 CreatedAt string
56 CreatedAtISO string
57 CreatedAtDisp string
58 Body template.HTML
59}
60
61type IssuesListPageData struct {
62 *PageData
63 Filter string
64 OpenCount int
65 ClosedCount int
66 Issues []*IssueData
67 Label string
68 AllLabels []string
69 OpenIssuesURL template.URL
70 ClosedIssuesURL template.URL
71}
72
73type IssueDetailPageData struct {
74 *PageData
75 Issue *IssueData
76}
77
78func (i *IssuesListPageData) Active() string { return "issues" }
79func (i *IssueDetailPageData) Active() string { return "issue" }
80
81func (c *Config) loadIssues() ([]*IssueData, error) {
82 c.Logger.Info("loading issues from git-bug", "repoPath", c.RepoPath)
83
84 repo, err := repository.OpenGoGitRepo(c.RepoPath, "", nil)
85 if err != nil {
86 return nil, fmt.Errorf("failed to open repository: %w", err)
87 }
88
89 var issues []*IssueData
90
91 for streamedBug := range bug.ReadAll(repo) {
92 if streamedBug.Err != nil {
93 c.Logger.Warn("failed to read bug", "error", streamedBug.Err)
94 continue
95 }
96
97 b := streamedBug.Entity
98 snap := b.Compile()
99
100 commentCount := len(snap.Comments) - 1
101 if commentCount < 0 {
102 commentCount = 0
103 }
104
105 labels := make([]string, len(snap.Labels))
106 for i, label := range snap.Labels {
107 labels[i] = string(label)
108 }
109
110 var comments []CommentData
111 for i, comment := range snap.Comments {
112 if i == 0 {
113 continue
114 }
115 createdAtTime, _ := time.Parse("Mon Jan 2 15:04:05 2006 -0700", comment.FormatTime())
116 comments = append(comments, CommentData{
117 ID: GetShortID(comment.CombinedId().String()),
118 Author: comment.Author.Name(),
119 CreatedAt: comment.FormatTime(),
120 CreatedAtISO: createdAtTime.UTC().Format(time.RFC3339),
121 CreatedAtDisp: FormatDateForDisplay(createdAtTime),
122 Body: c.transformCommitRefs(c.RenderMarkdown(comment.Message)),
123 })
124 }
125
126 var description template.HTML
127 if len(snap.Comments) > 0 {
128 description = c.RenderMarkdown(snap.Comments[0].Message)
129 description = c.transformCommitRefs(description)
130 }
131
132 fullID := b.Id().String()
133 issue := &IssueData{
134 ID: GetShortID(fullID),
135 FullID: fullID,
136 Title: snap.Title,
137 Status: snap.Status.String(),
138 Author: snap.Author.Name(),
139 CreatedAt: snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
140 CreatedAtISO: snap.CreateTime.UTC().Format(time.RFC3339),
141 CreatedAtDisp: FormatDateForDisplay(snap.CreateTime),
142 Labels: labels,
143 CommentCount: commentCount,
144 Description: description,
145 Comments: comments,
146 URL: c.GetIssueURL(fullID),
147 }
148 issues = append(issues, issue)
149 }
150
151 return issues, nil
152}
153
154func (c *Config) GetIssuesListURL(filter string, label string) template.URL {
155 var path string
156 switch filter {
157 case "open", "closed":
158 path = filepath.Join("issues", filter, "index.html")
159 case "label":
160 encodedLabel := url.PathEscape(label)
161 path = filepath.Join("issues", "label", encodedLabel, "index.html")
162 default:
163 path = filepath.Join("issues", "open", "index.html")
164 }
165 return c.CompileURL("/", path)
166}
167
168func (c *Config) writeIssueListPage(data *PageData, filter string, label string, issues []*IssueData, openCount, closedCount int, allLabels []string) {
169 c.Logger.Info("writing issues list", "filter", filter, "label", label, "count", len(issues))
170
171 pageData := &IssuesListPageData{
172 PageData: data,
173 Filter: filter,
174 OpenCount: openCount,
175 ClosedCount: closedCount,
176 Issues: issues,
177 Label: label,
178 AllLabels: allLabels,
179 OpenIssuesURL: c.GetIssuesListURL("open", ""),
180 ClosedIssuesURL: c.GetIssuesListURL("closed", ""),
181 }
182
183 var subdir string
184 switch filter {
185 case "open":
186 subdir = "issues/open"
187 case "closed":
188 subdir = "issues/closed"
189 case "label":
190 encodedLabel := url.PathEscape(label)
191 subdir = filepath.Join("issues/label", encodedLabel)
192 }
193
194 c.WriteHTML(&WriteData{
195 Filename: "index.html",
196 Subdir: subdir,
197 Template: "html/issues_list.page.tmpl",
198 Data: pageData,
199 })
200}
201
202func (c *Config) writeIssueDetailPage(data *PageData, issue *IssueData) {
203 c.Logger.Info("writing issue detail", "id", issue.ID, "title", issue.Title)
204
205 pageData := &IssueDetailPageData{
206 PageData: data,
207 Issue: issue,
208 }
209
210 c.WriteHTML(&WriteData{
211 Filename: fmt.Sprintf("%s.html", issue.FullID),
212 Subdir: "issues",
213 Template: "html/issue_detail.page.tmpl",
214 Data: pageData,
215 })
216}
217
218func (c *Config) WriteIssues(pageData *PageData) error {
219 issues, err := c.loadIssues()
220 if err != nil {
221 return fmt.Errorf("failed to load issues: %w", err)
222 }
223
224 if len(issues) == 0 {
225 c.Logger.Info("no git-bug issues found, skipping issue generation")
226 return nil
227 }
228
229 c.Logger.Info("loaded issues", "count", len(issues))
230
231 var openIssues, closedIssues []*IssueData
232 labelIssues := make(map[string][]*IssueData)
233 allLabels := make(map[string]bool)
234
235 for _, issue := range issues {
236 if issue.Status == "open" {
237 openIssues = append(openIssues, issue)
238 for _, label := range issue.Labels {
239 allLabels[label] = true
240 labelIssues[label] = append(labelIssues[label], issue)
241 }
242 } else {
243 closedIssues = append(closedIssues, issue)
244 }
245 }
246
247 openCount := len(openIssues)
248 closedCount := len(closedIssues)
249
250 // Sort issues by creation time, newest first
251 sort.Slice(openIssues, func(i, j int) bool {
252 return openIssues[i].CreatedAtISO > openIssues[j].CreatedAtISO
253 })
254 sort.Slice(closedIssues, func(i, j int) bool {
255 return closedIssues[i].CreatedAtISO > closedIssues[j].CreatedAtISO
256 })
257
258 var sortedLabels []string
259 for label := range allLabels {
260 sortedLabels = append(sortedLabels, label)
261 }
262
263 for _, issue := range issues {
264 c.writeIssueDetailPage(pageData, issue)
265 }
266
267 c.writeIssueListPage(pageData, "open", "", openIssues, openCount, closedCount, sortedLabels)
268 c.writeIssueListPage(pageData, "closed", "", closedIssues, openCount, closedCount, sortedLabels)
269
270 for label, issues := range labelIssues {
271 c.writeIssueListPage(pageData, "label", label, issues, openCount, closedCount, sortedLabels)
272 }
273
274 return nil
275}