main
web_pr_detail.go
Eric Bower
·
2026-02-25
1package patchbin
2
3import (
4 "fmt"
5 "html/template"
6 "net/http"
7 "slices"
8 "strconv"
9 "time"
10
11 "github.com/bluekeyes/go-gitdiff/gitdiff"
12)
13
14type UserData struct {
15 UserID int64
16 Name string
17 IsAdmin bool
18 Pubkey string
19 CreatedAt string
20}
21
22type PatchsetData struct {
23 *Patchset
24 UserData
25 FormattedID string
26 Date string
27 RangeDiff []*RangeDiffOutput
28}
29
30type PrData struct {
31 UserData
32 ID int64
33 Title string
34 Date string
35 Status Status
36}
37
38type EventLogData struct {
39 *EventLog
40 UserData
41 *Patchset
42 FormattedPatchsetID string
43 Date string
44 RangeDiff []*RangeDiffOutput
45}
46
47type PatchHunk struct {
48 Anchor string
49 DiffText template.HTML
50}
51
52type PatchFile struct {
53 *gitdiff.File
54 DisplayName string
55 FileAnchor string
56 Adds int64
57 Dels int64
58 Hunks []PatchHunk
59 SemanticChanges []SemanticChange
60}
61
62// PatchSummary is a lightweight view of a patch used for the commit list,
63// without the expensive diff parsing/rendering that a full PatchData needs.
64type PatchSummary struct {
65 *Patch
66 Url template.URL
67 FormattedAuthorDate string
68}
69
70type PatchData struct {
71 *Patch
72 PatchFiles []*PatchFile
73 PatchHeader *gitdiff.PatchHeader
74 Url template.URL
75 FormattedAuthorDate string
76 SemanticSummary SemanticSummary
77}
78
79type PrDetailData struct {
80 Page string
81 RepoName string
82 Branch string
83 Pr PrData
84 Patchset *Patchset
85 FormattedPatchsetID string
86 PatchsetDate string
87 Patches []PatchSummary
88 Patch *PatchData
89 PrevUrl string
90 NextUrl string
91 Logs []EventLogData
92 MetaData
93}
94
95type AllPatchData struct {
96 Patches []PatchSummary
97 Patchsets []*PatchsetData
98}
99
100func getAllPatchData(web *WebCtx, pr *PatchRequest, ps *Patchset) (*AllPatchData, error) {
101 patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
102 if err != nil {
103 return nil, err
104 }
105
106 // get patchsets and diff from previous patchset
107 patchsetsData := []*PatchsetData{}
108 for idx, patchset := range patchsets {
109 user, err := web.Pr.GetUserByID(patchset.UserID)
110 if err != nil {
111 web.Logger.Error("could not get user for patch", "err", err)
112 continue
113 }
114
115 var prevPatchset *Patchset
116 if idx > 0 {
117 prevPatchset = patchsets[idx-1]
118 }
119
120 var rangeDiff []*RangeDiffOutput
121 if idx > 0 {
122 rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
123 if err != nil {
124 web.Logger.Error("could not diff patchset", "err", err)
125 continue
126 }
127 }
128
129 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
130 if err != nil {
131 return nil, err
132 }
133
134 displayName := web.Backend.ComputeUserName(user.Pubkey)
135 data := PatchsetData{
136 Patchset: patchset,
137 FormattedID: getFormattedPatchsetID(patchset.ID),
138 UserData: UserData{
139 UserID: user.ID,
140 Name: displayName,
141 IsAdmin: web.Backend.IsAdmin(pk),
142 Pubkey: user.Pubkey,
143 CreatedAt: user.CreatedAt.Format(time.RFC3339),
144 },
145 Date: patchset.CreatedAt.Format(time.RFC3339),
146 RangeDiff: rangeDiff,
147 }
148 patchsetsData = append(patchsetsData, &data)
149 }
150
151 patchesData := []PatchSummary{}
152 if len(patchsetsData) >= 1 {
153 psID := ps.ID
154 patches, err := web.Pr.GetPatchesByPatchsetID(psID)
155 if err != nil {
156 return nil, err
157 }
158
159 for _, patch := range patches {
160 timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
161 patchesData = append(patchesData, PatchSummary{
162 Patch: patch,
163 Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
164 FormattedAuthorDate: timestamp,
165 })
166 }
167 }
168
169 return &AllPatchData{
170 Patches: patchesData,
171 Patchsets: patchsetsData,
172 }, nil
173}
174
175func hunkAnchor(patchID int64, fileName string, hunkIdx int) string {
176 return fmt.Sprintf("patch-%d-%s-hunk-%d", patchID, fileName, hunkIdx)
177}
178
179func getPatchData(web *WebCtx, patch *Patch) (*PatchData, error) {
180 diffFiles, preamble, err := ParsePatch(patch.RawText)
181 if err != nil {
182 return nil, err
183 }
184 header, err := gitdiff.ParsePatchHeader(preamble)
185 if err != nil {
186 return nil, err
187 }
188
189 patchFiles := []*PatchFile{}
190 var semanticSummary SemanticSummary
191 for _, file := range diffFiles {
192 var adds int64 = 0
193 var dels int64 = 0
194
195 fileName := file.NewName
196 if fileName == "" {
197 fileName = file.OldName
198 }
199
200 hunks := make([]PatchHunk, 0, len(file.TextFragments))
201 for hunkIdx, frag := range file.TextFragments {
202 adds += frag.LinesAdded
203 dels += frag.LinesDeleted
204
205 diffStr, err := parseText(web.Formatter, web.Theme, frag.String())
206 if err != nil {
207 return nil, err
208 }
209
210 hunks = append(hunks, PatchHunk{
211 Anchor: hunkAnchor(patch.ID, fileName, hunkIdx),
212 DiffText: template.HTML(diffStr),
213 })
214 }
215
216 semanticChanges := AnalyzeSemanticChanges(file)
217 for i := range semanticChanges {
218 semanticChanges[i].HunkAnchor = hunkAnchor(patch.ID, fileName, semanticChanges[i].HunkIndex)
219 }
220 semanticSummary = SummarizeSemanticChanges(semanticSummary, fileName, SupportsSemanticDiff(fileName) && !file.IsBinary, semanticChanges)
221
222 patchFiles = append(patchFiles, &PatchFile{
223 File: file,
224 DisplayName: fileName,
225 FileAnchor: fmt.Sprintf("patch-%d-%s", patch.ID, fileName),
226 Adds: adds,
227 Dels: dels,
228 Hunks: hunks,
229 SemanticChanges: semanticChanges,
230 })
231 }
232
233 timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
234 return &PatchData{
235 Patch: patch,
236 Url: template.URL(fmt.Sprintf("patch-%d", patch.ID)),
237 FormattedAuthorDate: timestamp,
238 PatchFiles: patchFiles,
239 PatchHeader: header,
240 SemanticSummary: semanticSummary,
241 }, nil
242}
243
244func getLogData(web *WebCtx, prID int64, patchsetsData []*PatchsetData) ([]EventLogData, error) {
245 logData := []EventLogData{}
246 logs, err := web.Pr.GetEventLogsByPrID(prID)
247 if err != nil {
248 return logData, err
249 }
250
251 slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
252 return a.CreatedAt.Compare(b.CreatedAt)
253 })
254
255 for _, eventlog := range logs {
256 logUser, _ := web.Pr.GetUserByID(eventlog.UserID)
257 pk, err := web.Backend.PubkeyToPublicKey(logUser.Pubkey)
258 if err != nil {
259 return logData, err
260 }
261 var logps *Patchset
262 var rangeDiff []*RangeDiffOutput
263 if eventlog.PatchsetID.Int64 > 0 {
264 logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
265 if err != nil {
266 web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
267 return logData, err
268 }
269 for _, psData := range patchsetsData {
270 if psData.ID == eventlog.PatchsetID.Int64 {
271 rangeDiff = psData.RangeDiff
272 break
273 }
274 }
275 }
276
277 logDisplayName := web.Backend.ComputeUserName(logUser.Pubkey)
278 logData = append(logData, EventLogData{
279 EventLog: eventlog,
280 FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
281 Patchset: logps,
282 RangeDiff: rangeDiff,
283 UserData: UserData{
284 UserID: logUser.ID,
285 Name: logDisplayName,
286 IsAdmin: web.Backend.IsAdmin(pk),
287 Pubkey: logUser.Pubkey,
288 CreatedAt: logUser.CreatedAt.Format(time.RFC3339),
289 },
290 Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
291 })
292 }
293
294 return logData, nil
295}
296
297func createPrDetail(page string) http.HandlerFunc {
298 return func(w http.ResponseWriter, r *http.Request) {
299 id := r.PathValue("id")
300 prID, err := strconv.Atoi(id)
301 if err != nil {
302 w.WriteHeader(http.StatusUnprocessableEntity)
303 return
304 }
305
306 web, err := getWebCtx(r)
307 if err != nil {
308 w.WriteHeader(http.StatusInternalServerError)
309 return
310 }
311
312 var pr *PatchRequest
313 var ps *Patchset
314 switch page {
315 case "pr":
316 {
317 pr, err = web.Pr.GetPatchRequestByID(int64(prID))
318 if err != nil {
319 web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
320 w.WriteHeader(http.StatusInternalServerError)
321 return
322 }
323
324 ps, err = web.Pr.GetLatestPatchsetByPrID(int64(prID))
325 if err != nil {
326 web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
327 w.WriteHeader(http.StatusInternalServerError)
328 return
329 }
330 }
331 case "ps":
332 {
333 ps, err = web.Pr.GetPatchsetByID(int64(prID))
334 if err != nil {
335 web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
336 w.WriteHeader(http.StatusInternalServerError)
337 return
338 }
339
340 pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
341 if err != nil {
342 web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
343 w.WriteHeader(http.StatusInternalServerError)
344 return
345 }
346 }
347 }
348
349 user, err := web.Pr.GetUserByID(pr.UserID)
350 if err != nil {
351 w.WriteHeader(http.StatusNotFound)
352 return
353 }
354
355 pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
356 if err != nil {
357 web.Logger.Error("cannot parse pubkey for pr user", "err", err)
358 w.WriteHeader(http.StatusUnprocessableEntity)
359 return
360 }
361 isAdmin := web.Backend.IsAdmin(pk)
362 displayName := web.Backend.ComputeUserName(user.Pubkey)
363
364 aps, err := getAllPatchData(web, pr, ps)
365 if err != nil {
366 web.Logger.Error("cannot compute all patch data", "err", err)
367 w.WriteHeader(http.StatusUnprocessableEntity)
368 return
369 }
370
371 if len(aps.Patches) == 0 {
372 web.Logger.Error("no patches found for patchset", "ps", ps.ID)
373 w.WriteHeader(http.StatusNotFound)
374 return
375 }
376
377 selectedIdx := 0
378 if patchIDStr := r.PathValue("patchID"); patchIDStr != "" {
379 patchID, err := strconv.ParseInt(patchIDStr, 10, 64)
380 if err != nil {
381 w.WriteHeader(http.StatusUnprocessableEntity)
382 return
383 }
384 found := false
385 for idx, summary := range aps.Patches {
386 if summary.ID == patchID {
387 selectedIdx = idx
388 found = true
389 break
390 }
391 }
392 if !found {
393 w.WriteHeader(http.StatusNotFound)
394 return
395 }
396 }
397
398 selectedPatch, err := getPatchData(web, aps.Patches[selectedIdx].Patch)
399 if err != nil {
400 web.Logger.Error("cannot compute selected patch data", "err", err)
401 w.WriteHeader(http.StatusUnprocessableEntity)
402 return
403 }
404
405 var prevUrl, nextUrl string
406 if selectedIdx > 0 {
407 prevUrl = fmt.Sprintf("/ps/%d/patches/%d", ps.ID, aps.Patches[selectedIdx-1].ID)
408 }
409 if selectedIdx < len(aps.Patches)-1 {
410 nextUrl = fmt.Sprintf("/ps/%d/patches/%d", ps.ID, aps.Patches[selectedIdx+1].ID)
411 }
412
413 logData, err := getLogData(web, pr.ID, aps.Patchsets)
414 if err != nil {
415 web.Logger.Error("cannot fetch log data", "err", err)
416 w.WriteHeader(http.StatusUnprocessableEntity)
417 return
418 }
419
420 w.Header().Set("content-type", "text/html")
421 err = prTmpl.Execute(w, PrDetailData{
422 Page: page,
423 RepoName: pr.RepoName,
424 Branch: "main",
425 Patchset: ps,
426 FormattedPatchsetID: getFormattedPatchsetID(ps.ID),
427 PatchsetDate: ps.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
428 Patches: aps.Patches,
429 Patch: selectedPatch,
430 PrevUrl: prevUrl,
431 NextUrl: nextUrl,
432 Logs: logData,
433 Pr: PrData{
434 ID: pr.ID,
435 UserData: UserData{
436 UserID: user.ID,
437 Name: displayName,
438 IsAdmin: isAdmin,
439 Pubkey: user.Pubkey,
440 CreatedAt: user.CreatedAt.Format(time.RFC3339),
441 },
442 Title: pr.Name,
443 Date: pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
444 Status: pr.Status,
445 },
446 MetaData: MetaData{
447 URL: web.Backend.Cfg.Url,
448 },
449 })
450 if err != nil {
451 web.Backend.Logger.Error("cannot execute template", "err", err)
452 }
453 }
454}