repos / git-pr

a self-hosted git collaboration server
git clone https://github.com/picosh/git-pr.git

commit
fe35422
parent
38474f6
author
Eric Bower
date
2024-10-21 22:43:41 -0400 EDT
feat: ability to view all patchsets
8 files changed,  +296, -234
M web.go
M static/git-pr.css
+4, -0
 1@@ -7,6 +7,10 @@ td, th {
 2   border-bottom: 1px solid var(--grey-light);
 3 }
 4 
 5+details {
 6+  margin-bottom: 0;
 7+}
 8+
 9 .pill-success {
10   border: 1px solid var(--success);
11   color: var(--success);
A tmpl/patchset.html
+16, -0
 1@@ -0,0 +1,16 @@
 2+{{define "patchset"}}
 3+<div class="group">
 4+  <h2 class="text-xl">
 5+    Patchset <code>ps-{{.Patchset.ID}}</code>
 6+  </h2>
 7+  {{range $idx, $val := .Patches}}
 8+    <div class="group" id="{{.Url}}">
 9+      {{template "patch" .}}
10+    </div>
11+  {{else}}
12+  <div class="box">
13+    No patches found for patch request.
14+  </div>
15+  {{end}}
16+</div>
17+{{end}}
M tmpl/pr-detail.html
+72, -75
  1@@ -15,94 +15,91 @@
  2 {{define "body"}}
  3 {{template "pr-header" .}}
  4 
  5-<hr />
  6-
  7 <main class="group">
  8-  <div class="box-sm group text-sm">
  9-    <h3 class="text-lg">Logs</h3>
 10-
 11-    {{range .Logs}}
 12-    <div>
 13-      <code class='pill{{if .UserData.IsAdmin}}-admin{{end}}' title="{{.UserData.Pubkey}}">{{.UserData.Name}}</code>
 14-      <span class="font-bold">
 15-        {{if eq .Event "pr_created"}}
 16-          created pr with <code>{{.FormattedPatchsetID}}</code>
 17-        {{else if eq .Event "pr_patchset_added"}}
 18-          added <code>{{.FormattedPatchsetID}}</code>
 19-        {{else if eq .Event "pr_patchset_deleted"}}
 20-          deleted <code>{{.FormattedPatchsetID}}</code>
 21-        {{else if eq .Event "pr_reviewed"}}
 22-          reviewed pr with <code class="pill-review">{{.FormattedPatchsetID}}</code>
 23-        {{else if eq .Event "pr_patchset_replaced"}}
 24-          replaced <code>{{.FormattedPatchsetID}}</code>
 25-        {{else if eq .Event "pr_status_changed"}}
 26-          changed status
 27-        {{else if eq .Event "pr_name_changed"}}
 28-          changed pr name
 29-        {{else}}
 30-          {{.Event}}
 31-        {{end}}
 32-      </span>
 33-      <span>on <date>{{.Date}}</date></span>
 34-      {{if .Data}}<code>{{.Data}}</code>{{end}}
 35+  <div class="flex gap-2">
 36+    <div class="group text-sm" style="width: 300px;">
 37+      <h3 class="text-lg">Logs</h3>
 38+      {{range .Logs}}
 39+      <div>
 40+        {{template "user-pill" .UserData}}
 41+        <span class="font-bold">
 42+          {{if eq .Event "pr_created"}}
 43+            {{if eq $.Patchset.ID .Patchset.ID}}
 44+            created pr with <code>{{.FormattedPatchsetID}}</code>
 45+            {{else}}
 46+            created pr with <a href="/ps/{{.Patchset.ID}}"><code>{{.FormattedPatchsetID}}</code></a>
 47+            {{end}}
 48+          {{else if eq .Event "pr_patchset_added"}}
 49+            {{if eq $.Patchset.ID .Patchset.ID}}
 50+            added <code>{{.FormattedPatchsetID}}</code>
 51+            {{else}}
 52+            added <a href="/ps/{{.Patchset.ID}}"><code>{{.FormattedPatchsetID}}</code></a>
 53+            {{end}}
 54+          {{else if eq .Event "pr_patchset_deleted"}}
 55+            deleted <code>{{.FormattedPatchsetID}}</code>
 56+          {{else if eq .Event "pr_reviewed"}}
 57+            reviewed pr with <code class="pill-review">{{.FormattedPatchsetID}}</code>
 58+          {{else if eq .Event "pr_patchset_replaced"}}
 59+            replaced <code>{{.FormattedPatchsetID}}</code>
 60+          {{else if eq .Event "pr_status_changed"}}
 61+            changed status
 62+          {{else if eq .Event "pr_name_changed"}}
 63+            changed pr name
 64+          {{else}}
 65+            {{.Event}}
 66+          {{end}}
 67+        </span>
 68+        <span>on <date>{{.Date}}</date></span>
 69+        {{if .Data}}<code>{{.Data}}</code>{{end}}
 70+      </div>
 71+      {{end}}
 72     </div>
 73-    {{end}}
 74-  </div>
 75 
 76-  <div class="box-sm group text-sm">
 77-    <h3 class="text-lg">Patchsets</h3>
 78+    <div class="group text-sm flex-1">
 79+      <h3 class="text-lg">Patchsets</h3>
 80 
 81-    {{range .Patchsets}}
 82-      {{if .RangeDiff}}
 83-      <details>
 84-        <summary class="text-sm">Range Diff ↕</summary>
 85-        <div class="group">
 86-        {{- range .RangeDiff -}}
 87-          <div>
 88-            <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
 89-              {{.Header}}
 90-            </code>
 91-          </div>
 92-          {{- if .Diff -}}
 93-          <pre>
 94-            {{- range .Diff -}}
 95-              {{- if eq .Type -1 -}}
 96-                <span style="color: tomato;">{{.Text}}</span>
 97-              {{- else if eq .Type 1 -}}
 98-                <span style="color: limegreen;">{{.Text}}</span>
 99-              {{- else -}}
100-                <span>{{.Text}}</span>
101+      {{range .Patchsets}}
102+        {{if .RangeDiff}}
103+        <details>
104+          <summary class="text-sm">Range Diff ↕</summary>
105+          <div class="group">
106+          {{- range .RangeDiff -}}
107+            <div>
108+              <code class='{{if eq .Type "rm"}}pill-admin{{else if eq .Type "add"}}pill-success{{else if eq .Type "diff"}}pill-review{{end}}'>
109+                {{.Header}}
110+              </code>
111+            </div>
112+            {{- if .Diff -}}
113+            <pre>
114+              {{- range .Diff -}}
115+                {{- if eq .Type -1 -}}
116+                  <span style="color: tomato;">{{.Text}}</span>
117+                {{- else if eq .Type 1 -}}
118+                  <span style="color: limegreen;">{{.Text}}</span>
119+                {{- else -}}
120+                  <span>{{.Text}}</span>
121+                {{- end -}}
122               {{- end -}}
123+            </pre>
124             {{- end -}}
125-          </pre>
126           {{- end -}}
127-        {{- end -}}
128+          </div>
129+        </details>
130+        {{end}}
131+
132+        <div>
133+          <code class="{{if .Review}}pill-review{{end}}">{{.FormattedID}}</code>
134+          <span> by </span>
135+          {{template "user-pill" .UserData}}
136+          <span>on <date>{{.Date}}</date></span>
137         </div>
138-      </details>
139       {{end}}
140-
141-      <div>
142-        <code class="{{if .Review}}pill-review{{end}}">{{.FormattedID}}</code>
143-        <span> by </span>
144-        <code class="pill{{if .UserData.IsAdmin}}-admin{{end}}" title="{{.UserData.Pubkey}}">{{.UserData.Name}}</code>
145-        <span>on <date>{{.Date}}</date></span>
146-      </div>
147-    {{end}}
148+    </div>
149   </div>
150 
151   <hr class="w-full" />
152 
153-  <div class="group">
154-    {{range $idx, $val := .Patches}}
155-      <div class="group" id="{{.Url}}">
156-        {{template "patch" .}}
157-      </div>
158-    {{else}}
159-    <div class="box">
160-      No patches found for patch request.
161-    </div>
162-    {{end}}
163-  </div>
164+  {{template "patchset" .}}
165 </main>
166 
167 <hr />
M tmpl/pr-header.html
+2, -2
 1@@ -2,14 +2,14 @@
 2 <header>
 3   <h1 class="text-2xl mb">
 4     <a href="{{.Repo.Url}}">{{.Repo.Text}}</a>
 5-    <span> / {{.Pr.Title}} <code>#{{.Pr.ID}}</code></span>
 6+    <span> / {{.Pr.Title}} <a href="/prs/{{.Pr.ID}}"><code>#{{.Pr.ID}}</code></a></span>
 7   </h1>
 8 
 9   <div class="text-sm mb">
10     {{template "pr-status" .Pr.Status}}
11     <span>&middot;</span>
12     <span>opened on <date>{{.Pr.Date}}</date> by</span>
13-    <code class="pill{{if .Pr.UserData.IsAdmin}}-admin{{end}}" title="{{.Pr.UserData.Pubkey}}">{{.Pr.UserData.Name}}</code>
14+    {{template "user-pill" .Pr.UserData}}
15   </div>
16 
17   <details>
M tmpl/pr-list-item.html
+1, -1
1@@ -7,7 +7,7 @@
2   <div>
3     <code>#{{.ID}}</code>
4     <span>opened on <date>{{.Date}}</date> by </span>
5-    <code class="{{if .UserData.IsAdmin}}pill-admin{{end}}" title="{{.UserData.Pubkey}}">{{.UserData.Name}}</code>
6+    {{template "user-pill" .UserData}}
7   </div>
8 </div>
9 {{end}}
M tmpl/pr-table.html
+1, -3
 1@@ -22,9 +22,7 @@
 2           <a href="{{.PrLink.Url}}">{{.PrLink.Text}}</a>
 3         </td>
 4         <td>
 5-          <code class="{{if .UserData.IsAdmin}}pill-admin{{end}}" title="{{.UserData.Pubkey}}">
 6-            <a href="/users/{{.UserData.Name}}">{{.UserData.Name}}</a>
 7-          </code>
 8+          {{template "user-pill" .UserData}}
 9         </td>
10         <td><date>{{.Date}}</date></td>
11       </tr>
A tmpl/user-pill.html
+5, -0
1@@ -0,0 +1,5 @@
2+{{define "user-pill"}}
3+<a href="/users/{{.Name}}">
4+  <code class='pill{{if .IsAdmin}}-admin{{end}}' title="{{.Pubkey}}">{{.Name}}</code>
5+</a>
6+{{end}}
M web.go
+195, -153
  1@@ -80,7 +80,9 @@ func getTemplate(file string) *template.Template {
  2 		template.ParseFS(
  3 			tmplFS,
  4 			filepath.Join("tmpl", file),
  5+			filepath.Join("tmpl", "user-pill.html"),
  6 			filepath.Join("tmpl", "patch.html"),
  7+			filepath.Join("tmpl", "patchset.html"),
  8 			filepath.Join("tmpl", "pr-header.html"),
  9 			filepath.Join("tmpl", "pr-list-item.html"),
 10 			filepath.Join("tmpl", "pr-table.html"),
 11@@ -425,6 +427,7 @@ type PatchData struct {
 12 type EventLogData struct {
 13 	*EventLog
 14 	UserData
 15+	*Patchset
 16 	FormattedPatchsetID string
 17 	Date                string
 18 }
 19@@ -441,6 +444,7 @@ type PrDetailData struct {
 20 	Page      string
 21 	Repo      LinkData
 22 	Pr        PrData
 23+	Patchset  *Patchset
 24 	Patches   []PatchData
 25 	Branch    string
 26 	Logs      []EventLogData
 27@@ -448,196 +452,233 @@ type PrDetailData struct {
 28 	MetaData
 29 }
 30 
 31-func prDetailHandler(w http.ResponseWriter, r *http.Request) {
 32-	id := r.PathValue("id")
 33-	prID, err := strconv.Atoi(id)
 34-	if err != nil {
 35-		w.WriteHeader(http.StatusUnprocessableEntity)
 36-		return
 37-	}
 38-
 39-	web, err := getWebCtx(r)
 40-	if err != nil {
 41-		w.WriteHeader(http.StatusInternalServerError)
 42-		return
 43-	}
 44-
 45-	pr, err := web.Pr.GetPatchRequestByID(int64(prID))
 46-	if err != nil {
 47-		web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
 48-		w.WriteHeader(http.StatusInternalServerError)
 49-		return
 50-	}
 51-
 52-	repo, err := web.Pr.GetRepoByID(pr.RepoID)
 53-	if err != nil {
 54-		web.Logger.Error("cannot get repo", "err", err)
 55-		w.WriteHeader(http.StatusInternalServerError)
 56-		return
 57-	}
 58-
 59-	patchsets, err := web.Pr.GetPatchsetsByPrID(int64(prID))
 60-	if err != nil {
 61-		web.Logger.Error("cannot get latest patchset", "err", err)
 62-		w.WriteHeader(http.StatusInternalServerError)
 63-		return
 64-	}
 65-
 66-	// get patchsets and diff from previous patchset
 67-	patchsetsData := []PatchsetData{}
 68-	for idx, patchset := range patchsets {
 69-		user, err := web.Pr.GetUserByID(patchset.UserID)
 70+func createPrDetail(page string) http.HandlerFunc {
 71+	return func(w http.ResponseWriter, r *http.Request) {
 72+		id := r.PathValue("id")
 73+		prID, err := strconv.Atoi(id)
 74 		if err != nil {
 75-			web.Logger.Error("could not get user for patch", "err", err)
 76-			continue
 77+			w.WriteHeader(http.StatusUnprocessableEntity)
 78+			return
 79 		}
 80 
 81-		var prevPatchset *Patchset
 82-		if idx > 0 {
 83-			prevPatchset = patchsets[idx-1]
 84+		web, err := getWebCtx(r)
 85+		if err != nil {
 86+			w.WriteHeader(http.StatusInternalServerError)
 87+			return
 88 		}
 89 
 90-		var rangeDiff []*RangeDiffOutput
 91-		if idx > 0 {
 92-			rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
 93+		var pr *PatchRequest
 94+		var ps *Patchset
 95+		if page == "pr" {
 96+			pr, err = web.Pr.GetPatchRequestByID(int64(prID))
 97 			if err != nil {
 98-				web.Logger.Error("could not diff patchset", "err", err)
 99-				continue
100+				web.Pr.Backend.Logger.Error("cannot get prs", "err", err)
101+				w.WriteHeader(http.StatusInternalServerError)
102+				return
103+			}
104+		} else if page == "ps" {
105+			ps, err = web.Pr.GetPatchsetByID(int64(prID))
106+			if err != nil {
107+				web.Pr.Backend.Logger.Error("cannot get patchset", "err", err)
108+				w.WriteHeader(http.StatusInternalServerError)
109+				return
110+			}
111+
112+			pr, err = web.Pr.GetPatchRequestByID(int64(ps.PatchRequestID))
113+			if err != nil {
114+				web.Pr.Backend.Logger.Error("cannot get pr", "err", err)
115+				w.WriteHeader(http.StatusInternalServerError)
116+				return
117 			}
118 		}
119 
120-		pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
121+		repo, err := web.Pr.GetRepoByID(pr.RepoID)
122 		if err != nil {
123-			web.Logger.Error("cannot parse pubkey for pr user", "err", err)
124-			w.WriteHeader(http.StatusUnprocessableEntity)
125+			web.Logger.Error("cannot get repo", "err", err)
126+			w.WriteHeader(http.StatusInternalServerError)
127 			return
128 		}
129 
130-		patchsetsData = append(patchsetsData, PatchsetData{
131-			Patchset:    patchset,
132-			FormattedID: getFormattedPatchsetID(patchset.ID),
133-			UserData: UserData{
134-				UserID:    user.ID,
135-				Name:      user.Name,
136-				IsAdmin:   web.Backend.IsAdmin(pk),
137-				Pubkey:    user.Pubkey,
138-				CreatedAt: user.CreatedAt.Format(time.RFC3339),
139-			},
140-			Date:      patchset.CreatedAt.Format(time.RFC3339),
141-			RangeDiff: rangeDiff,
142-		})
143-	}
144-
145-	patchesData := []PatchData{}
146-	if len(patchsetsData) >= 1 {
147-		latest := patchsetsData[len(patchsets)-1]
148-		patches, err := web.Pr.GetPatchesByPatchsetID(latest.ID)
149+		patchsets, err := web.Pr.GetPatchsetsByPrID(pr.ID)
150 		if err != nil {
151-			web.Logger.Error("cannot get patches", "err", err)
152+			web.Logger.Error("cannot get latest patchset", "err", err)
153 			w.WriteHeader(http.StatusInternalServerError)
154 			return
155 		}
156 
157-		for _, patch := range patches {
158-			timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
159-			diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
160+		// get patchsets and diff from previous patchset
161+		patchsetsData := []PatchsetData{}
162+		for idx, patchset := range patchsets {
163+			user, err := web.Pr.GetUserByID(patchset.UserID)
164+			if err != nil {
165+				web.Logger.Error("could not get user for patch", "err", err)
166+				continue
167+			}
168+
169+			var prevPatchset *Patchset
170+			if idx > 0 {
171+				prevPatchset = patchsets[idx-1]
172+			}
173+
174+			var rangeDiff []*RangeDiffOutput
175+			if idx > 0 {
176+				rangeDiff, err = web.Pr.DiffPatchsets(prevPatchset, patchset)
177+				if err != nil {
178+					web.Logger.Error("could not diff patchset", "err", err)
179+					continue
180+				}
181+			}
182+
183+			pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
184 			if err != nil {
185-				web.Logger.Error("cannot parse patch", "err", err)
186+				web.Logger.Error("cannot parse pubkey for pr user", "err", err)
187 				w.WriteHeader(http.StatusUnprocessableEntity)
188 				return
189 			}
190 
191-			// highlight review
192-			isReview := false
193+			// set selected patchset to latest when no ps already set
194+			if ps == nil && idx == len(patchsets)-1 {
195+				ps = patchset
196+			}
197 
198-			patchesData = append(patchesData, PatchData{
199-				Patch:               patch,
200-				Url:                 template.URL(fmt.Sprintf("patch-%d", patch.ID)),
201-				DiffStr:             template.HTML(diffStr),
202-				Review:              isReview,
203-				FormattedAuthorDate: timestamp,
204+			patchsetsData = append(patchsetsData, PatchsetData{
205+				Patchset:    patchset,
206+				FormattedID: getFormattedPatchsetID(patchset.ID),
207+				UserData: UserData{
208+					UserID:    user.ID,
209+					Name:      user.Name,
210+					IsAdmin:   web.Backend.IsAdmin(pk),
211+					Pubkey:    user.Pubkey,
212+					CreatedAt: user.CreatedAt.Format(time.RFC3339),
213+				},
214+				Date:      patchset.CreatedAt.Format(time.RFC3339),
215+				RangeDiff: rangeDiff,
216 			})
217 		}
218-	}
219 
220-	user, err := web.Pr.GetUserByID(pr.UserID)
221-	if err != nil {
222-		w.WriteHeader(http.StatusNotFound)
223-		return
224-	}
225+		patchesData := []PatchData{}
226+		if len(patchsetsData) >= 1 {
227+			psID := ps.ID
228+			patches, err := web.Pr.GetPatchesByPatchsetID(psID)
229+			if err != nil {
230+				web.Logger.Error("cannot get patches", "err", err)
231+				w.WriteHeader(http.StatusInternalServerError)
232+				return
233+			}
234 
235-	w.Header().Set("content-type", "text/html")
236-	tmpl := getTemplate("pr-detail.html")
237-	pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
238-	if err != nil {
239-		web.Logger.Error("cannot parse pubkey for pr user", "err", err)
240-		w.WriteHeader(http.StatusUnprocessableEntity)
241-		return
242-	}
243-	isAdmin := web.Backend.IsAdmin(pk)
244-	logs, err := web.Pr.GetEventLogsByPrID(int64(prID))
245-	if err != nil {
246-		web.Logger.Error("cannot get logs for pr", "err", err)
247-		w.WriteHeader(http.StatusUnprocessableEntity)
248-		return
249-	}
250-	slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
251-		return a.CreatedAt.Compare(b.CreatedAt)
252-	})
253+			for _, patch := range patches {
254+				timestamp := patch.AuthorDate.Format(web.Backend.Cfg.TimeFormat)
255+				diffStr, err := parseText(web.Formatter, web.Theme, patch.RawText)
256+				if err != nil {
257+					web.Logger.Error("cannot parse patch", "err", err)
258+					w.WriteHeader(http.StatusUnprocessableEntity)
259+					return
260+				}
261+
262+				// highlight review
263+				isReview := false
264+
265+				patchesData = append(patchesData, PatchData{
266+					Patch:               patch,
267+					Url:                 template.URL(fmt.Sprintf("patch-%d", patch.ID)),
268+					DiffStr:             template.HTML(diffStr),
269+					Review:              isReview,
270+					FormattedAuthorDate: timestamp,
271+				})
272+			}
273+		}
274 
275-	logData := []EventLogData{}
276-	for _, eventlog := range logs {
277-		user, _ := web.Pr.GetUserByID(eventlog.UserID)
278+		user, err := web.Pr.GetUserByID(pr.UserID)
279+		if err != nil {
280+			w.WriteHeader(http.StatusNotFound)
281+			return
282+		}
283+
284+		w.Header().Set("content-type", "text/html")
285+		tmpl := getTemplate("pr-detail.html")
286 		pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
287 		if err != nil {
288 			web.Logger.Error("cannot parse pubkey for pr user", "err", err)
289 			w.WriteHeader(http.StatusUnprocessableEntity)
290 			return
291 		}
292-
293-		logData = append(logData, EventLogData{
294-			EventLog:            eventlog,
295-			FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
296-			UserData: UserData{
297-				UserID:    user.ID,
298-				Name:      user.Name,
299-				IsAdmin:   web.Backend.IsAdmin(pk),
300-				Pubkey:    user.Pubkey,
301-				CreatedAt: user.CreatedAt.Format(time.RFC3339),
302-			},
303-			Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
304+		isAdmin := web.Backend.IsAdmin(pk)
305+		logs, err := web.Pr.GetEventLogsByPrID(pr.ID)
306+		if err != nil {
307+			web.Logger.Error("cannot get logs for pr", "err", err)
308+			w.WriteHeader(http.StatusUnprocessableEntity)
309+			return
310+		}
311+		slices.SortFunc(logs, func(a *EventLog, b *EventLog) int {
312+			return a.CreatedAt.Compare(b.CreatedAt)
313 		})
314-	}
315 
316-	err = tmpl.Execute(w, PrDetailData{
317-		Page: "pr",
318-		Repo: LinkData{
319-			Url:  template.URL("/repos/" + repo.ID),
320-			Text: repo.ID,
321-		},
322-		Branch:    repo.DefaultBranch,
323-		Patches:   patchesData,
324-		Patchsets: patchsetsData,
325-		Logs:      logData,
326-		Pr: PrData{
327-			ID: pr.ID,
328-			UserData: UserData{
329-				UserID:    user.ID,
330-				Name:      user.Name,
331-				IsAdmin:   isAdmin,
332-				Pubkey:    user.Pubkey,
333-				CreatedAt: user.CreatedAt.Format(time.RFC3339),
334+		logData := []EventLogData{}
335+		for _, eventlog := range logs {
336+			user, _ := web.Pr.GetUserByID(eventlog.UserID)
337+			pk, err := web.Backend.PubkeyToPublicKey(user.Pubkey)
338+			if err != nil {
339+				web.Logger.Error("cannot parse pubkey for pr user", "err", err)
340+				w.WriteHeader(http.StatusUnprocessableEntity)
341+				return
342+			}
343+			var logps *Patchset
344+			if eventlog.PatchsetID.Int64 > 0 {
345+				logps, err = web.Pr.GetPatchsetByID(eventlog.PatchsetID.Int64)
346+				if err != nil {
347+					web.Logger.Error("cannot get patchset", "err", err, "ps", eventlog.PatchsetID)
348+					w.WriteHeader(http.StatusUnprocessableEntity)
349+					return
350+				}
351+			}
352+
353+			logData = append(logData, EventLogData{
354+				EventLog:            eventlog,
355+				FormattedPatchsetID: getFormattedPatchsetID(eventlog.PatchsetID.Int64),
356+				Patchset:            logps,
357+				UserData: UserData{
358+					UserID:    user.ID,
359+					Name:      user.Name,
360+					IsAdmin:   web.Backend.IsAdmin(pk),
361+					Pubkey:    user.Pubkey,
362+					CreatedAt: user.CreatedAt.Format(time.RFC3339),
363+				},
364+				Date: eventlog.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
365+			})
366+		}
367+
368+		fmt.Println("PSSPSPSPS", ps)
369+		err = tmpl.Execute(w, PrDetailData{
370+			Page: "pr",
371+			Repo: LinkData{
372+				Url:  template.URL("/repos/" + repo.ID),
373+				Text: repo.ID,
374 			},
375-			Title:  pr.Name,
376-			Date:   pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
377-			Status: pr.Status,
378-		},
379-		MetaData: MetaData{
380-			URL: web.Backend.Cfg.Url,
381-		},
382-	})
383-	if err != nil {
384-		web.Backend.Logger.Error("cannot execute template", "err", err)
385+			Branch:    repo.DefaultBranch,
386+			Patchset:  ps,
387+			Patches:   patchesData,
388+			Patchsets: patchsetsData,
389+			Logs:      logData,
390+			Pr: PrData{
391+				ID: pr.ID,
392+				UserData: UserData{
393+					UserID:    user.ID,
394+					Name:      user.Name,
395+					IsAdmin:   isAdmin,
396+					Pubkey:    user.Pubkey,
397+					CreatedAt: user.CreatedAt.Format(time.RFC3339),
398+				},
399+				Title:  pr.Name,
400+				Date:   pr.CreatedAt.Format(web.Backend.Cfg.TimeFormat),
401+				Status: pr.Status,
402+			},
403+			MetaData: MetaData{
404+				URL: web.Backend.Cfg.Url,
405+			},
406+		})
407+		if err != nil {
408+			web.Backend.Logger.Error("cannot execute template", "err", err)
409+		}
410 	}
411 }
412 
413@@ -870,8 +911,9 @@ func StartWebServer(cfg *GitCfg) {
414 
415 	// ensure legacy router is disabled
416 	// GODEBUG=httpmuxgo121=0
417-	http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, prDetailHandler))
418+	http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, createPrDetail("pr")))
419 	http.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
420+	http.HandleFunc("GET /ps/{id}", ctxMdw(ctx, createPrDetail("ps")))
421 	http.HandleFunc("GET /repos/{id}", ctxMdw(ctx, repoDetailHandler))
422 	http.HandleFunc("GET /repos/{repoid}/rss", ctxMdw(ctx, rssHandler))
423 	http.HandleFunc("GET /users/{name}", ctxMdw(ctx, userDetailHandler))