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}