repos / git-pr

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

commit
3770873
parent
0c5b23a
author
Eric Bower
date
2025-03-13 12:16:06 -0400 EDT
refactor: add docs page
4 files changed,  +374, -263
M cli.go
M web.go
M cli.go
+89, -63
  1@@ -1,6 +1,7 @@
  2 package git
  3 
  4 import (
  5+	"errors"
  6 	"fmt"
  7 	"io"
  8 	"strconv"
  9@@ -627,101 +628,126 @@ Here's how it works:
 10 						Name:      "accept",
 11 						Usage:     "Accept a PR",
 12 						Args:      true,
 13-						ArgsUsage: "[prID]",
 14+						ArgsUsage: "[prID], [prID]...",
 15 						Action: func(cCtx *cli.Context) error {
 16 							args := cCtx.Args()
 17 							if !args.Present() {
 18-								return fmt.Errorf("must provide a patch request ID")
 19+								return fmt.Errorf("must provide at least one patch request ID")
 20 							}
 21 
 22-							prID, err := strToInt(args.First())
 23-							if err != nil {
 24-								return err
 25-							}
 26+							prIDs := args.Tail()
 27+							prIDs = append(prIDs, args.First())
 28 
 29-							prq, err := pr.GetPatchRequestByID(prID)
 30-							if err != nil {
 31-								return err
 32-							}
 33+							var errs error
 34+							for _, prIDStr := range prIDs {
 35+								prID, err := strToInt(prIDStr)
 36+								if err != nil {
 37+									wish.Errorln(sesh, err)
 38+									continue
 39+								}
 40 
 41-							user, err := pr.UpsertUser(pubkey, userName)
 42-							if err != nil {
 43-								return err
 44-							}
 45+								prq, err := pr.GetPatchRequestByID(prID)
 46+								if err != nil {
 47+									return err
 48+								}
 49 
 50-							repo, err := pr.GetRepoByID(prq.RepoID)
 51-							if err != nil {
 52-								return err
 53-							}
 54+								user, err := pr.UpsertUser(pubkey, userName)
 55+								if err != nil {
 56+									return err
 57+								}
 58 
 59-							acl := be.GetPatchRequestAcl(repo, prq, user)
 60-							if !acl.CanReview {
 61-								return fmt.Errorf("you are not authorized to accept a PR")
 62-							}
 63+								repo, err := pr.GetRepoByID(prq.RepoID)
 64+								if err != nil {
 65+									return err
 66+								}
 67 
 68-							if prq.Status == "accepted" {
 69-								return fmt.Errorf("PR has already been accepted")
 70-							}
 71+								acl := be.GetPatchRequestAcl(repo, prq, user)
 72+								if !acl.CanReview {
 73+									return fmt.Errorf("you are not authorized to accept a PR")
 74+								}
 75 
 76-							err = pr.UpdatePatchRequestStatus(prID, user.ID, "accepted")
 77-							if err != nil {
 78-								return err
 79+								if prq.Status == "accepted" {
 80+									return fmt.Errorf("PR has already been accepted")
 81+								}
 82+
 83+								err = pr.UpdatePatchRequestStatus(prID, user.ID, "accepted")
 84+								if err != nil {
 85+									return err
 86+								}
 87+								wish.Printf(sesh, "Accepted PR %s (#%d)\n", prq.Name, prq.ID)
 88+								err = prSummary(be, pr, sesh, prID)
 89+								if err != nil {
 90+									errs = errors.Join(errs, err)
 91+								}
 92+								wish.Printf(sesh, "\n\n")
 93 							}
 94-							wish.Printf(sesh, "Accepted PR %s (#%d)\n", prq.Name, prq.ID)
 95-							return prSummary(be, pr, sesh, prID)
 96+
 97+							return errs
 98 						},
 99 					},
100 					{
101 						Name:      "close",
102 						Usage:     "Close a PR",
103 						Args:      true,
104-						ArgsUsage: "[prID]",
105+						ArgsUsage: "[prID], [prID]...",
106 						Action: func(cCtx *cli.Context) error {
107 							args := cCtx.Args()
108 							if !args.Present() {
109 								return fmt.Errorf("must provide a patch request ID")
110 							}
111 
112-							prID, err := strToInt(args.First())
113-							if err != nil {
114-								return err
115-							}
116+							prIDs := args.Tail()
117+							prIDs = append(prIDs, args.First())
118 
119-							prq, err := pr.GetPatchRequestByID(prID)
120-							if err != nil {
121-								return err
122-							}
123+							var errs error
124+							for _, prIDStr := range prIDs {
125+								prID, err := strToInt(prIDStr)
126+								if err != nil {
127+									wish.Errorln(sesh, err)
128+									continue
129+								}
130 
131-							patchUser, err := pr.GetUserByID(prq.UserID)
132-							if err != nil {
133-								return err
134-							}
135+								prq, err := pr.GetPatchRequestByID(prID)
136+								if err != nil {
137+									return err
138+								}
139 
140-							repo, err := pr.GetRepoByID(prq.RepoID)
141-							if err != nil {
142-								return err
143-							}
144+								patchUser, err := pr.GetUserByID(prq.UserID)
145+								if err != nil {
146+									return err
147+								}
148 
149-							acl := be.GetPatchRequestAcl(repo, prq, patchUser)
150-							if !acl.CanModify {
151-								return fmt.Errorf("you are not authorized to change PR status")
152-							}
153+								repo, err := pr.GetRepoByID(prq.RepoID)
154+								if err != nil {
155+									return err
156+								}
157 
158-							if prq.Status == "closed" {
159-								return fmt.Errorf("PR has already been closed")
160-							}
161+								acl := be.GetPatchRequestAcl(repo, prq, patchUser)
162+								if !acl.CanModify {
163+									return fmt.Errorf("you are not authorized to change PR status")
164+								}
165 
166-							user, err := pr.UpsertUser(pubkey, userName)
167-							if err != nil {
168-								return err
169-							}
170+								if prq.Status == "closed" {
171+									return fmt.Errorf("PR has already been closed")
172+								}
173 
174-							err = pr.UpdatePatchRequestStatus(prID, user.ID, "closed")
175-							if err != nil {
176-								return err
177+								user, err := pr.UpsertUser(pubkey, userName)
178+								if err != nil {
179+									return err
180+								}
181+
182+								err = pr.UpdatePatchRequestStatus(prID, user.ID, "closed")
183+								if err != nil {
184+									return err
185+								}
186+								wish.Printf(sesh, "Closed PR %s (#%d)\n", prq.Name, prq.ID)
187+								err = prSummary(be, pr, sesh, prID)
188+								if err != nil {
189+									errs = errors.Join(errs, err)
190+								}
191+								wish.Printf(sesh, "\n\n")
192 							}
193-							wish.Printf(sesh, "Closed PR %s (#%d)\n", prq.Name, prq.ID)
194-							return prSummary(be, pr, sesh, prID)
195+							return errs
196 						},
197 					},
198 					{
A tmpl/docs.html
+258, -0
  1@@ -0,0 +1,258 @@
  2+{{template "base" .}}
  3+
  4+{{define "title"}}git-pr{{end}}
  5+
  6+{{define "meta"}}
  7+<link rel="alternate" type="application/atom+xml"
  8+      title="RSS feed for git collaboration server"
  9+      href="/rss" />
 10+{{end}}
 11+
 12+{{define "body"}}
 13+<header class="group">
 14+  <h1 class="text-2xl"><a href="/">DASHBOARD</a> / docs</h1>
 15+  <div>
 16+    <span>A pastebin supercharged for git collaboration</span> &middot;
 17+    <a href="https://github.com/picosh/git-pr">github</a> &middot;
 18+    <a href="https://youtu.be/d28Dih-BBUw">demo video</a>
 19+  </div>
 20+  <pre class="m-0">ssh {{.MetaData.URL}} help</pre>
 21+</header>
 22+
 23+<main class="group">
 24+  <details>
 25+    <summary>Intro</summary>
 26+
 27+    <div>
 28+      <p>
 29+        We are trying to build the simplest git collaboration tool. The goal is to make
 30+        self-hosting as simple as running an SSH server -- all without
 31+        sacrificing external collaborators time and energy.
 32+      </p>
 33+
 34+      <blockquote>
 35+        <code>git format-patch</code> isn't the problem and pull requests aren't the solution.
 36+      </blockquote>
 37+
 38+      <p>
 39+        We are combining mailing list and pull request workflows. In order to build the
 40+        simplest collaboration tool, we needed something as simple as generating patches
 41+        but the ease-of-use of pull requests.
 42+      </p>
 43+
 44+      <p>
 45+        The goal is not to create another code forge here. The goal is to create a very
 46+        simple self-hosted git solution with the ability to collaborate with external
 47+        contributors. All the code owner needs to setup a running git server:
 48+      </p>
 49+
 50+      <ul><li>A single golang binary</li></ul>
 51+
 52+      <div>
 53+        All an external contributor needs is:
 54+      </div>
 55+
 56+      <ul>
 57+        <li>An SSH keypair</li>
 58+        <li>An SSH client</li>
 59+      </ul>
 60+
 61+      <h2 class="text-xl">the problem</h2>
 62+
 63+      <p>
 64+        Email is great as a decentralized system to send and receive changes (patchsets)
 65+        to a git repo. However, onboarding a new user to a mailing list, properly
 66+        setting up their email client, and then finally submitting the code contribution
 67+        is enough to make many developers give up. Further, because we are leveraging
 68+        the email protocol for collaboration, we are limited by its feature-set. For
 69+        example, it is not possible to make edits to emails, everyone has a different
 70+        client, those clients have different limitations around plain text email and
 71+        downloading patches from it.
 72+      </p>
 73+
 74+      <p>
 75+        Github pull requests are easy to use, easy to edit, and easy to manage. The
 76+        downside is it forces the user to be inside their website to perform reviews.
 77+        For quick changes, this is great, but when you start reading code within a web
 78+        browser, there are quite a few downsides. At a certain point, it makes more
 79+        sense to review code inside your local development environment, IDE, etc. There
 80+        are tools and plugins that allow users to review PRs inside their IDE, but it
 81+        requires a herculean effort to make it usable.
 82+      </p>
 83+
 84+      <p>
 85+        Further, self-hosted solutions that mimic a pull request require a lot of
 86+        infrastructure in order to manage it. A database, a web site connected to git,
 87+        admin management, and services to manage it all. Another big point of friction:
 88+        before an external user submits a code change, they first need to create an
 89+        account and then login. This adds quite a bit of friction for a self-hosted
 90+        solution, not only for an external contributor, but also for the code owner who
 91+        has to provision the infra. Often times they also have to fork the repo within
 92+        the code forge before submitting a PR. Then they never make a contribution ever
 93+        again and keep a forked repo around forever. That seems silly.
 94+      </p>
 95+
 96+      <h2 class="text-xl">introducing patch requests (PR)</h2>
 97+
 98+      <p>
 99+        Instead, we want to create a self-hosted git "server" that can handle sending
100+        and receiving patches without the cumbersome nature of setting up email or the
101+        limitations imposed by the email protocol. Further, we want the primary workflow
102+        to surround the local development environment. Github is bringing the IDE to the
103+        browser in order to support their workflow, we want to flip that idea on its
104+        head by making code reviews a first-class citizen inside your local development
105+        environment.
106+      </p>
107+
108+      <p>
109+        We see this as a hybrid between the github workflow of a pull request and
110+        sending and receiving patches over email.
111+      </p>
112+
113+      <p>
114+        The basic idea is to leverage an SSH app to handle most of the interaction
115+        between contributor and owner of a project. Everything can be done completely
116+        within the terminal, in a way that is ergonomic and fully featured.
117+      </p>
118+
119+      <p>
120+        Notifications would happen with RSS and all state mutations would result in the
121+        generation of static web assets so it can all be hosted using a simple file web
122+        server.
123+      </p>
124+
125+      <h3 class="text-lg">format-patch workflow</h3>
126+
127+      <p>
128+        The fundamental collaboration tool here is <code>format-patch</code>. Whether you a
129+        submitting code changes or you are reviewing code changes, it all happens in
130+        code. Both contributor and owner are simply creating new commits and generating
131+        patches on top of each other. This obviates the need to have a web viewer where
132+        the reviewing can "comment" on a line of code block. There's no need, apply the
133+        contributor's patches, write comments or code changes, generate a new patch,
134+        send the patch to the git server as a "review." This flow also works the exact
135+        same if two users are collaborating on a set of changes.
136+      </p>
137+
138+      <p>
139+        This also solves the problem of sending multiple patchsets for the same code
140+        change. There's a single, central Patch Request where all changes and
141+        collaboration happens.
142+      </p>
143+
144+      <p>
145+        We could figure out a way to leverage <code>git notes</code> for reviews / comments, but
146+        honestly, that solution feels brutal and outside the comfort level of most git
147+        users. Just send reviews as code and write comments in the programming language
148+        you are using. It's the job of the contributor to "address" those comments and
149+        then remove them in subsequent patches. This is the forcing function to address
150+        all comments: the patch won't be merged if there are comment unaddressed in
151+        code; they cannot be ignored or else they will be upstreamed erroneously.
152+      </p>
153+    </div>
154+  </details>
155+
156+  <details>
157+    <summary>How do Patch Requests work?</summary>
158+      <div>
159+        Patch requests (PR) are the simplest way to submit, review, and accept changes to your git repository.
160+        Here's how it works:
161+      </div>
162+
163+      <ol>
164+        <li>External contributor clones repo (<code>git-clone</code>)</li>
165+        <li>External contributor makes a code change (<code>git-add</code> & <code>git-commit</code>)</li>
166+        <li>External contributor generates patches (<code>git-format-patch</code>)</li>
167+        <li>External contributor submits a PR to SSH server</li>
168+        <li>Owner receives RSS notification that there's a new PR</li>
169+        <li>Owner applies patches locally (<code>git-am</code>) from SSH server</li>
170+        <li>Owner makes suggestions in code! (<code>git-add</code> & <code>git-commit</code>)</li>
171+        <li>Owner submits review by piping patch to SSH server (<code>git-format-patch</code>)</li>
172+        <li>External contributor receives RSS notification of the PR review</li>
173+        <li>External contributor re-applies patches (<code>git-am</code>)</li>
174+        <li>External contributor reviews and removes comments in code!</li>
175+        <li>External contributor submits another patch (<code>git-format-patch</code>)</li>
176+        <li>Owner applies patches locally (<code>git-am</code>)</li>
177+        <li>Owner marks PR as accepted and pushes code to main (<code>git-push</code>)</li>
178+      </ol>
179+
180+      <div>Example commands</div>
181+
182+      <pre># Owner hosts repo `test.git` using github
183+
184+# Contributor clones repo
185+git clone git@github.com:picosh/test.git
186+
187+# Contributor wants to make a change
188+# Contributor makes changes via commits
189+git add -A && git commit -m "fix: some bugs"
190+
191+# Contributor runs:
192+git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr create test
193+# > Patch Request has been created (ID: 1)
194+
195+# Owner can checkout patch:
196+ssh {{.MetaData.URL}} pr print 1 | git am -3
197+# Owner can comment (IN CODE), commit, then send another format-patch
198+# on top of the PR:
199+git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr add --review 1
200+# UI clearly marks patch as a review
201+
202+# Contributor can checkout reviews
203+ssh {{.MetaData.URL}} pr print 1 | git am -3
204+
205+# Owner can reject a pr:
206+ssh {{.MetaData.URL}} pr close 1
207+
208+# Owner can accept a pr:
209+ssh {{.MetaData.URL}} pr accept 1
210+
211+# Owner can prep PR for upstream:
212+git rebase -i origin/main
213+
214+# Then push to upstream
215+git push origin main
216+
217+# Done!
218+</pre>
219+  </details>
220+
221+  <details>
222+    <summary>What's a repo?</summary>
223+
224+    <div>
225+      A repo is designed to mimick a git repo, but it's really just a tag.  When
226+      submitting a patch request, if the user does not provide a repo name then
227+      the default "bin" will be selected.  When a user creates a repo they become
228+      the repo owner and have special privileges.
229+    </div>
230+  </details>
231+
232+  <details>
233+    <summary>Can anyone use this service?</summary>
234+
235+    <div>
236+      This service is a public space for anyone to freely create "repos" and
237+      collaborate with users.  Anyone is able to add patchsets to a patch request
238+      and anyone is able to review any other patch requests, regardless of repo.
239+    </div>
240+  </details>
241+
242+  <details>
243+    <summary>First time user experience</summary>
244+
245+    <div>
246+      Using this service for the first time?  Creating a patch request is simple:
247+    </div>
248+
249+    <pre>git format-patch main --stdout | ssh pr.pico.sh pr create {repo}</pre>
250+
251+    <div>When running that command we will automatically create a user and a repo if one doesn't exist.</div>
252+
253+    <div>Want to submit a v2 of the patch request?</div>
254+
255+    <pre>git format-patch main --stdout | ssh pr.pico.sh pr add {prID}</pre>
256+  </details>
257+</main>
258+
259+{{end}}
M tmpl/index.html
+3, -200
  1@@ -10,209 +10,12 @@
  2 
  3 {{define "body"}}
  4 <header class="group">
  5-  <h1 class="text-2xl">git-pr</h1>
  6+  <h1 class="text-2xl">patchbin</h1>
  7   <div>
  8-    <span>A new git collaboration service</span> &middot;
  9-    <a href="https://github.com/picosh/git-pr">github</a> &middot;
 10-    <a href="https://youtu.be/d28Dih-BBUw">demo video</a>
 11+    <span>A pastebin supercharged for git collaboration</span> &middot;
 12+    <a href="/docs">docs</a>
 13   </div>
 14   <pre class="m-0">ssh {{.MetaData.URL}} help</pre>
 15-  <details>
 16-    <summary>Intro</summary>
 17-
 18-    <div>
 19-      <p>
 20-        We are trying to build the simplest git collaboration tool. The goal is to make
 21-        self-hosting a git server as simple as running an SSH server -- all without
 22-        sacrificing external collaborators time and energy.
 23-      </p>
 24-
 25-      <blockquote>
 26-        <code>git format-patch</code> isn't the problem and pull requests aren't the solution.
 27-      </blockquote>
 28-
 29-      <p>
 30-        We are combining mailing list and pull request workflows. In order to build the
 31-        simplest collaboration tool, we needed something as simple as generating patches
 32-        but the ease-of-use of pull requests.
 33-      </p>
 34-
 35-      <p>
 36-        The goal is not to create another code forge here. The goal is to create a very
 37-        simple self-hosted git solution with the ability to collaborate with external
 38-        contributors. All the code owner needs to setup a running git server:
 39-      </p>
 40-
 41-      <ul><li>A single golang binary</li></ul>
 42-
 43-      <div>
 44-        All an external contributor needs is:
 45-      </div>
 46-
 47-      <ul>
 48-        <li>An SSH keypair</li>
 49-        <li>An SSH client</li>
 50-      </ul>
 51-
 52-      <h2 class="text-xl">the problem</h2>
 53-
 54-      <p>
 55-        Email is great as a decentralized system to send and receive changes (patchsets)
 56-        to a git repo. However, onboarding a new user to a mailing list, properly
 57-        setting up their email client, and then finally submitting the code contribution
 58-        is enough to make many developers give up. Further, because we are leveraging
 59-        the email protocol for collaboration, we are limited by its feature-set. For
 60-        example, it is not possible to make edits to emails, everyone has a different
 61-        client, those clients have different limitations around plain text email and
 62-        downloading patches from it.
 63-      </p>
 64-
 65-      <p>
 66-        Github pull requests are easy to use, easy to edit, and easy to manage. The
 67-        downside is it forces the user to be inside their website to perform reviews.
 68-        For quick changes, this is great, but when you start reading code within a web
 69-        browser, there are quite a few downsides. At a certain point, it makes more
 70-        sense to review code inside your local development environment, IDE, etc. There
 71-        are tools and plugins that allow users to review PRs inside their IDE, but it
 72-        requires a herculean effort to make it usable.
 73-      </p>
 74-
 75-      <p>
 76-        Further, self-hosted solutions that mimic a pull request require a lot of
 77-        infrastructure in order to manage it. A database, a web site connected to git,
 78-        admin management, and services to manage it all. Another big point of friction:
 79-        before an external user submits a code change, they first need to create an
 80-        account and then login. This adds quite a bit of friction for a self-hosted
 81-        solution, not only for an external contributor, but also for the code owner who
 82-        has to provision the infra. Often times they also have to fork the repo within
 83-        the code forge before submitting a PR. Then they never make a contribution ever
 84-        again and keep a forked repo around forever. That seems silly.
 85-      </p>
 86-
 87-      <h2 class="text-xl">introducing patch requests (PR)</h2>
 88-
 89-      <p>
 90-        Instead, we want to create a self-hosted git "server" that can handle sending
 91-        and receiving patches without the cumbersome nature of setting up email or the
 92-        limitations imposed by the email protocol. Further, we want the primary workflow
 93-        to surround the local development environment. Github is bringing the IDE to the
 94-        browser in order to support their workflow, we want to flip that idea on its
 95-        head by making code reviews a first-class citizen inside your local development
 96-        environment.
 97-      </p>
 98-
 99-      <p>
100-        We see this as a hybrid between the github workflow of a pull request and
101-        sending and receiving patches over email.
102-      </p>
103-
104-      <p>
105-        The basic idea is to leverage an SSH app to handle most of the interaction
106-        between contributor and owner of a project. Everything can be done completely
107-        within the terminal, in a way that is ergonomic and fully featured.
108-      </p>
109-
110-      <p>
111-        Notifications would happen with RSS and all state mutations would result in the
112-        generation of static web assets so it can all be hosted using a simple file web
113-        server.
114-      </p>
115-
116-      <h3 class="text-lg">format-patch workflow</h3>
117-
118-      <p>
119-        The fundamental collaboration tool here is <code>format-patch</code>. Whether you a
120-        submitting code changes or you are reviewing code changes, it all happens in
121-        code. Both contributor and owner are simply creating new commits and generating
122-        patches on top of each other. This obviates the need to have a web viewer where
123-        the reviewing can "comment" on a line of code block. There's no need, apply the
124-        contributor's patches, write comments or code changes, generate a new patch,
125-        send the patch to the git server as a "review." This flow also works the exact
126-        same if two users are collaborating on a set of changes.
127-      </p>
128-
129-      <p>
130-        This also solves the problem of sending multiple patchsets for the same code
131-        change. There's a single, central Patch Request where all changes and
132-        collaboration happens.
133-      </p>
134-
135-      <p>
136-        We could figure out a way to leverage <code>git notes</code> for reviews / comments, but
137-        honestly, that solution feels brutal and outside the comfort level of most git
138-        users. Just send reviews as code and write comments in the programming language
139-        you are using. It's the job of the contributor to "address" those comments and
140-        then remove them in subsequent patches. This is the forcing function to address
141-        all comments: the patch won't be merged if there are comment unaddressed in
142-        code; they cannot be ignored or else they will be upstreamed erroneously.
143-      </p>
144-    </div>
145-  </details>
146-
147-  <details>
148-    <summary>How do Patch Requests work?</summary>
149-      <div>
150-        Patch requests (PR) are the simplest way to submit, review, and accept changes to your git repository.
151-        Here's how it works:
152-      </div>
153-
154-      <ol>
155-        <li>External contributor clones repo (<code>git-clone</code>)</li>
156-        <li>External contributor makes a code change (<code>git-add</code> & <code>git-commit</code>)</li>
157-        <li>External contributor generates patches (<code>git-format-patch</code>)</li>
158-        <li>External contributor submits a PR to SSH server</li>
159-        <li>Owner receives RSS notification that there's a new PR</li>
160-        <li>Owner applies patches locally (<code>git-am</code>) from SSH server</li>
161-        <li>Owner makes suggestions in code! (<code>git-add</code> & <code>git-commit</code>)</li>
162-        <li>Owner submits review by piping patch to SSH server (<code>git-format-patch</code>)</li>
163-        <li>External contributor receives RSS notification of the PR review</li>
164-        <li>External contributor re-applies patches (<code>git-am</code>)</li>
165-        <li>External contributor reviews and removes comments in code!</li>
166-        <li>External contributor submits another patch (<code>git-format-patch</code>)</li>
167-        <li>Owner applies patches locally (<code>git-am</code>)</li>
168-        <li>Owner marks PR as accepted and pushes code to main (<code>git-push</code>)</li>
169-      </ol>
170-
171-      <div>Example commands</div>
172-
173-      <pre># Owner hosts repo `test.git` using github
174-
175-# Contributor clones repo
176-git clone git@github.com:picosh/test.git
177-
178-# Contributor wants to make a change
179-# Contributor makes changes via commits
180-git add -A && git commit -m "fix: some bugs"
181-
182-# Contributor runs:
183-git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr create test
184-# > Patch Request has been created (ID: 1)
185-
186-# Owner can checkout patch:
187-ssh {{.MetaData.URL}} pr print 1 | git am -3
188-# Owner can comment (IN CODE), commit, then send another format-patch
189-# on top of the PR:
190-git format-patch origin/main --stdout | ssh {{.MetaData.URL}} pr add --review 1
191-# UI clearly marks patch as a review
192-
193-# Contributor can checkout reviews
194-ssh {{.MetaData.URL}} pr print 1 | git am -3
195-
196-# Owner can reject a pr:
197-ssh {{.MetaData.URL}} pr close 1
198-
199-# Owner can accept a pr:
200-ssh {{.MetaData.URL}} pr accept 1
201-
202-# Owner can prep PR for upstream:
203-git rebase -i origin/main
204-
205-# Then push to upstream
206-git push origin main
207-
208-# Done!
209-</pre>
210-  </details>
211 </header>
212 
213 <main>
M web.go
+24, -0
 1@@ -109,6 +109,10 @@ type LinkData struct {
 2 	Text string
 3 }
 4 
 5+type BasicData struct {
 6+	MetaData
 7+}
 8+
 9 type PrTableData struct {
10 	Prs         []*PrListData
11 	NumOpen     int
12@@ -268,6 +272,25 @@ func getPrTableData(web *WebCtx, prs []*PatchRequest, query url.Values) ([]*PrLi
13 	return prdata, nil
14 }
15 
16+func docsHandler(w http.ResponseWriter, r *http.Request) {
17+	web, err := getWebCtx(r)
18+	if err != nil {
19+		w.WriteHeader(http.StatusInternalServerError)
20+		return
21+	}
22+
23+	w.Header().Set("content-type", "text/html")
24+	tmpl := getTemplate("docs.html")
25+	err = tmpl.ExecuteTemplate(w, "docs.html", BasicData{
26+		MetaData: MetaData{
27+			URL: web.Backend.Cfg.Url,
28+		},
29+	})
30+	if err != nil {
31+		web.Backend.Logger.Error("cannot execute template", "err", err)
32+	}
33+}
34+
35 func indexHandler(w http.ResponseWriter, r *http.Request) {
36 	web, err := getWebCtx(r)
37 	if err != nil {
38@@ -1084,6 +1107,7 @@ func StartWebServer(cfg *GitCfg) {
39 	http.HandleFunc("GET /r/{user}", ctxMdw(ctx, userDetailHandler))
40 	http.HandleFunc("GET /rss/{user}", ctxMdw(ctx, rssHandler))
41 	http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
42+	http.HandleFunc("GET /docs", ctxMdw(ctx, docsHandler))
43 	http.HandleFunc("GET /", ctxMdw(ctx, indexHandler))
44 	http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
45 	embedFS, err := getEmbedFS(embedStaticFS, "static")