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}