~gitpocket/gitpocket

8

feat(agit): open pull requests by pushing to refs/for

commit
f116a8c05a6b8b7c00d8b1236b1e55478a9c0b1a browse tree
parent
9964e9d0dda454f20256e9df8cb7ff2f59cb8d25
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 07:12 UTC

14 files changed, +705 −83

README.md modified

@@ -23,6 +23,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS
2323 - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
2424 - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
2525 - Pull mirrors that fetch a source on a schedule and stay read only and push mirrors that copy every branch and tag to another host after each change
26+- Pull requests opened by pushing to `refs/for/<branch>` with no fork needed
2627 - Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository
2728 - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories
2829 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
@@ -46,7 +47,14 @@ git clone https://git.example.org/~you/repo
4647 git clone [email protected]:~you/repo
4748 ```
4849
49-SSH pushes need a key added under settings and HTTPS pushes take an access token or your password. To contribute to something you do not own, fork it, push a branch to the fork and open a pull request from that branch.
50+SSH pushes need a key added under settings and HTTPS pushes take an access token or your password. To contribute to a repository you do not own you push your commits to `refs/for/` followed by the target branch, which opens a pull request on that repository without any need for a fork.
51+
52+```
53+git push origin HEAD:refs/for/main -o title="Fix the parser"
54+git push origin HEAD:refs/for/main/parser
55+```
56+
57+Pushing again with the same topic updates that pull request and records a new revision, and the topic defaults to the target branch when the ref names none. Without `-o title` the first commit subject becomes the title and its body becomes the description, while `-o description=` overrides the body and `-o draft=true` opens the pull request as a draft. Anyone who is not the owner or a collaborator can only push to `refs/for/` and those guest pushes are capped at 20 MB each and 30 an hour.
5058
5159 ### Pages
5260
@@ -64,7 +72,7 @@ Ticking *keep it as a read only mirror* on the import page turns the import into
6472 go test ./...
6573 ```
6674
67-The tests drive the real handler through `httptest` and cover each registration mode, cross site posts, private repositories, moved repositories, every merge strategy and Markdown injection payloads.
75+The tests drive the real handler through `httptest` and cover each registration mode, cross site posts, private repositories, moved repositories, every merge strategy and Markdown injection payloads. The `refs/for/` test builds the binary and pushes through the smart HTTP handler with a real git client, which takes several seconds, so `go test -short` skips it when you only want the quick checks.
6876
6977 ## Licence
7078

forge/agit.go added

@@ -0,0 +1,455 @@
1+package forge
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+)
13+
14+const (
15+ maxPkt = 65520
16+ maxCommands = 64
17+ maxOptions = 64
18+ maxTopic = 100
19+)
20+
21+var zeroOID = strings.Repeat("0", 40)
22+
23+func hooksDir(root string) string { return filepath.Join(root, "hooks") }
24+
25+func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
26+
27+func hookScript(root, host, binary, kind string) string {
28+ return fmt.Sprintf("#!/bin/sh\nGITPOCKET_HOST=%s exec %s -root %s hook %s\n", shellQuote(host), shellQuote(binary), shellQuote(root), kind)
29+}
30+
31+func writeHook(path, script string) error {
32+ if b, err := os.ReadFile(path); err == nil && string(b) == script {
33+ return os.Chmod(path, 0o755)
34+ }
35+ tmp := path + ".tmp"
36+ if err := os.WriteFile(tmp, []byte(script), 0o755); err != nil {
37+ return err
38+ }
39+ return os.Rename(tmp, path)
40+}
41+
42+func installCentralHooks(root, host string) error {
43+ binary, err := os.Executable()
44+ if err != nil {
45+ return err
46+ }
47+ dir := hooksDir(root)
48+ if err := os.MkdirAll(dir, 0o755); err != nil {
49+ return err
50+ }
51+ for _, kind := range []string{"pre-receive", "proc-receive"} {
52+ if err := writeHook(filepath.Join(dir, kind), hookScript(root, host, binary, kind)); err != nil {
53+ return err
54+ }
55+ }
56+ return nil
57+}
58+
59+func hooksReady(root string) bool {
60+ for _, kind := range []string{"pre-receive", "proc-receive"} {
61+ fi, err := os.Stat(filepath.Join(hooksDir(root), kind))
62+ if err != nil || !fi.Mode().IsRegular() || fi.Mode()&0o111 == 0 {
63+ return false
64+ }
65+ }
66+ return true
67+}
68+
69+func receiveArgs(root string) []string {
70+ return []string{"-c", "core.hooksPath=" + hooksDir(root), "-c", "receive.procReceiveRefs=refs/for", "-c", "receive.advertisePushOptions=true"}
71+}
72+
73+func receiveEnv(repo Repo, pusher User) []string {
74+ return []string{"GITPOCKET_REPO=" + repo.Owner.Name + "/" + repo.Name, "GITPOCKET_PUSHER=" + strconv.FormatInt(pusher.ID, 10)}
75+}
76+
77+type hookContext struct {
78+ store *Store
79+ repo Repo
80+ pusher User
81+ writer bool
82+ g Git
83+}
84+
85+func loadHook(root string, readonly bool) (hookContext, error) {
86+ path := os.Getenv("GITPOCKET_REPO")
87+ id, err := strconv.ParseInt(os.Getenv("GITPOCKET_PUSHER"), 10, 64)
88+ owner, name, ok := strings.Cut(path, "/")
89+ if !ok || err != nil || id <= 0 {
90+ return hookContext{}, errors.New("push did not come through gitpocket")
91+ }
92+ store, err := openStore(filepath.Join(root, "gitpocket.db"), readonly)
93+ if err != nil {
94+ return hookContext{}, errors.New("database unavailable")
95+ }
96+ repo, err := store.RepoByPath(owner, name)
97+ if err != nil {
98+ return hookContext{}, errors.New("unknown repository")
99+ }
100+ pusher, err := store.userByID(id)
101+ if err != nil || store.IsBanned(id) {
102+ return hookContext{}, errors.New("unknown account")
103+ }
104+ if !store.CanRead(repo, &pusher) {
105+ return hookContext{}, errors.New("unknown repository")
106+ }
107+ return hookContext{store: store, repo: repo, pusher: pusher, writer: store.CanWrite(repo, &pusher), g: Git{Dir: filepath.Join(root, "repos", repo.Owner.Name, repo.Name+".git")}}, nil
108+}
109+
110+func hook(root, kind string) int {
111+ switch kind {
112+ case "pre-receive":
113+ return preReceive(root, os.Stdin, os.Stderr)
114+ case "proc-receive":
115+ return procReceive(root, os.Stdin, os.Stdout, os.Stderr)
116+ }
117+ return 0
118+}
119+
120+func preReceive(root string, in io.Reader, errw io.Writer) int {
121+ refuse := func(format string, args ...any) int {
122+ fmt.Fprintf(errw, "gitpocket: "+format+"\n", args...)
123+ return 1
124+ }
125+ hc, err := loadHook(root, true)
126+ if err != nil {
127+ return refuse("%v, push refused", err)
128+ }
129+ for _, kv := range os.Environ() {
130+ if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") {
131+ passEnv = append(passEnv, kv)
132+ }
133+ }
134+ mirror := hc.store.IsPullMirror(hc.repo.ID)
135+ if !hc.writer && (!hc.store.GuestPushBudget(hc.pusher.ID) || hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID) || hc.repo.Archived || mirror) {
136+ return refuse("you cannot open pull requests here right now")
137+ }
138+ scanner := bufio.NewScanner(in)
139+ for scanner.Scan() {
140+ f := strings.Fields(scanner.Text())
141+ if len(f) != 3 {
142+ return refuse("malformed update")
143+ }
144+ ref := f[2]
145+ if strings.HasPrefix(ref, "refs/for/") {
146+ continue
147+ }
148+ if !hc.writer {
149+ return refuse("you can only open pull requests here, push to refs/for/<branch>")
150+ }
151+ if mirror {
152+ return refuse("%s is a pull mirror and read only", hc.repo.Path())
153+ }
154+ if !strings.HasPrefix(ref, "refs/heads/") && !strings.HasPrefix(ref, "refs/tags/") {
155+ return refuse("only branches and tags can be pushed, not %s", ref)
156+ }
157+ branch, ok := strings.CutPrefix(ref, "refs/heads/")
158+ if !ok {
159+ continue
160+ }
161+ prot, ok := hc.store.Protection(hc.repo.ID, branch)
162+ if !ok {
163+ continue
164+ }
165+ if f[1] == zeroOID {
166+ return refuse("%s is protected and cannot be deleted", branch)
167+ }
168+ if prot.RequirePR && f[0] != zeroOID {
169+ return refuse("%s only accepts changes through pull requests", branch)
170+ }
171+ if f[0] != zeroOID {
172+ if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil {
173+ return refuse("%s is protected, force pushes are refused", branch)
174+ }
175+ }
176+ }
177+ return 0
178+}
179+
180+type pktReader struct{ r *bufio.Reader }
181+
182+func (p pktReader) read() (string, bool, error) {
183+ var head [4]byte
184+ if _, err := io.ReadFull(p.r, head[:]); err != nil {
185+ return "", false, err
186+ }
187+ n, err := strconv.ParseUint(string(head[:]), 16, 16)
188+ if err != nil {
189+ return "", false, errors.New("bad pkt-line length")
190+ }
191+ if n == 0 {
192+ return "", true, nil
193+ }
194+ if n < 4 || n > maxPkt {
195+ return "", false, errors.New("bad pkt-line length")
196+ }
197+ buf := make([]byte, n-4)
198+ if _, err := io.ReadFull(p.r, buf); err != nil {
199+ return "", false, err
200+ }
201+ return strings.TrimSuffix(string(buf), "\n"), false, nil
202+}
203+
204+func (p pktReader) section(limit int) ([]string, error) {
205+ var out []string
206+ for {
207+ line, flush, err := p.read()
208+ if err != nil {
209+ return nil, err
210+ }
211+ if flush {
212+ return out, nil
213+ }
214+ if len(out) >= limit {
215+ return nil, errors.New("too many lines")
216+ }
217+ out = append(out, line)
218+ }
219+}
220+
221+func writePkt(w io.Writer, line string) error {
222+ if line == "" {
223+ _, err := io.WriteString(w, "0000")
224+ return err
225+ }
226+ _, err := fmt.Fprintf(w, "%04x%s\n", len(line)+5, line)
227+ return err
228+}
229+
230+type agitCommand struct{ old, new, ref string }
231+
232+type agitResult struct {
233+ ok bool
234+ reason string
235+ ref, oldOID string
236+ newOID string
237+}
238+
239+func procReceive(root string, in io.Reader, out, errw io.Writer) int {
240+ pr := pktReader{bufio.NewReader(in)}
241+ hello, err := pr.section(4)
242+ if err != nil || len(hello) == 0 {
243+ return 1
244+ }
245+ version, caps, _ := strings.Cut(hello[0], "\x00")
246+ if version != "version=1" {
247+ return 1
248+ }
249+ options := false
250+ for _, c := range strings.Fields(caps) {
251+ options = options || c == "push-options"
252+ }
253+ reply := "version=1"
254+ if options {
255+ reply += "\x00push-options"
256+ }
257+ if writePkt(out, reply) != nil || writePkt(out, "") != nil {
258+ return 1
259+ }
260+ lines, err := pr.section(maxCommands)
261+ if err != nil {
262+ return 1
263+ }
264+ var cmds []agitCommand
265+ for _, line := range lines {
266+ f := strings.Fields(line)
267+ if len(f) != 3 {
268+ return 1
269+ }
270+ cmds = append(cmds, agitCommand{f[0], f[1], f[2]})
271+ }
272+ opts := map[string]string{}
273+ if options {
274+ raw, err := pr.section(maxOptions)
275+ if err != nil {
276+ return 1
277+ }
278+ for _, o := range raw {
279+ k, v, _ := strings.Cut(o, "=")
280+ opts[k] = v
281+ }
282+ }
283+ hc, herr := loadHook(root, false)
284+ if herr == nil && !hc.writer {
285+ hc.store.GuestPushed(hc.pusher.ID)
286+ }
287+ for _, c := range cmds {
288+ var res agitResult
289+ if herr != nil {
290+ res = agitResult{reason: herr.Error()}
291+ } else {
292+ res = hc.agit(c, opts, errw)
293+ }
294+ res.ref = c.ref
295+ if !res.ok {
296+ writePkt(out, "ng "+c.ref+" "+sanitizeReason(res.reason))
297+ continue
298+ }
299+ writePkt(out, "ok "+c.ref)
300+ writePkt(out, "option refname "+res.ref)
301+ writePkt(out, "option old-oid "+res.oldOID)
302+ writePkt(out, "option new-oid "+res.newOID)
303+ }
304+ writePkt(out, "")
305+ return 0
306+}
307+
308+func sanitizeReason(s string) string {
309+ s = strings.Map(func(r rune) rune {
310+ if r < 0x20 || r == 0x7f {
311+ return ' '
312+ }
313+ return r
314+ }, s)
315+ if len(s) > 200 {
316+ s = s[:200]
317+ }
318+ return s
319+}
320+
321+func validOID(s string) bool {
322+ if len(s) != 40 && len(s) != 64 {
323+ return false
324+ }
325+ for _, c := range s {
326+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
327+ return false
328+ }
329+ }
330+ return true
331+}
332+
333+func (hc hookContext) agitTarget(rest, option string) (target, topic string, ok bool) {
334+ parts := strings.Split(rest, "/")
335+ for i := len(parts); i > 0; i-- {
336+ branch := strings.Join(parts[:i], "/")
337+ if !validBranch(branch) {
338+ continue
339+ }
340+ if _, err := hc.g.Resolve("refs/heads/" + branch); err != nil {
341+ continue
342+ }
343+ topic = strings.Join(parts[i:], "/")
344+ if topic == "" {
345+ topic = option
346+ }
347+ if topic == "" {
348+ topic = branch
349+ }
350+ return branch, topic, validBranch(topic) && len(topic) <= maxTopic
351+ }
352+ return "", "", false
353+}
354+
355+func (hc hookContext) agit(c agitCommand, opts map[string]string, errw io.Writer) agitResult {
356+ fail := func(msg string) agitResult { return agitResult{reason: msg} }
357+ switch {
358+ case !validOID(c.new) || !validOID(c.old):
359+ return fail("malformed update")
360+ case c.new == zeroOID || strings.Trim(c.new, "0") == "":
361+ return fail("pull requests cannot be deleted by push")
362+ case hc.repo.Archived:
363+ return fail("repository is archived")
364+ case hc.store.IsPullMirror(hc.repo.ID):
365+ return fail("repository is a pull mirror")
366+ case hc.store.Blocked(hc.repo.Owner.ID, hc.pusher.ID):
367+ return fail("you cannot open pull requests here")
368+ }
369+ rest, _ := strings.CutPrefix(c.ref, "refs/for/")
370+ target, topic, ok := hc.agitTarget(rest, strings.TrimSpace(opts["topic"]))
371+ if !ok {
372+ return fail("push to refs/for/<existing branch>[/<topic>]")
373+ }
374+ if t, err := hc.g.run(nil, "cat-file", "-t", c.new); err != nil || strings.TrimSpace(string(t)) != "commit" {
375+ return fail("not a commit")
376+ }
377+ if _, err := hc.g.run(nil, "merge-base", "--is-ancestor", c.new, "refs/heads/"+target); err == nil {
378+ return fail("no new commits compared to " + target)
379+ }
380+ if pull, found := hc.store.AgitPull(hc.repo, hc.pusher.ID, target, topic); found {
381+ old, _ := hc.g.Resolve(pull.Ref())
382+ if old == c.new {
383+ return fail("pull request is already up to date")
384+ }
385+ if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new, old); err != nil {
386+ return fail("pull request moved, push again")
387+ }
388+ hc.g.RecordRevision(pull)
389+ hc.store.TouchPull(pull.ID)
390+ hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "updated")
391+ fmt.Fprintf(errw, "\nupdated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number))
392+ return agitResult{ok: true, ref: pull.Ref(), oldOID: old, newOID: c.new}
393+ }
394+ if !hc.store.ThreadBudget(hc.pusher.ID) {
395+ return fail("too many new issues and pull requests, try again later")
396+ }
397+ title, body := strings.TrimSpace(opts["title"]), strings.TrimSpace(opts["description"])
398+ if title == "" {
399+ msg, _ := hc.g.run(nil, "log", "-1", "--format=%B", c.new)
400+ subject, rest, _ := strings.Cut(strings.TrimSpace(string(msg)), "\n")
401+ title = strings.TrimSpace(subject)
402+ if body == "" {
403+ body = strings.TrimSpace(rest)
404+ }
405+ }
406+ if len(title) > 200 {
407+ title = title[:200]
408+ }
409+ if len(body) > 20000 {
410+ body = body[:20000]
411+ }
412+ pull, err := hc.store.CreatePull(hc.repo, hc.pusher, title, body, hc.repo, "", target)
413+ if err != nil {
414+ return fail(err.Error())
415+ }
416+ hc.store.SetAgit(pull.ID, topic)
417+ pull.Agit = topic
418+ if _, err := hc.g.run(nil, "update-ref", pull.Ref(), c.new); err != nil {
419+ hc.store.DeletePull(pull.ID)
420+ return fail("could not record the pull request")
421+ }
422+ if opts["draft"] == "true" || opts["draft"] == "1" {
423+ hc.store.SetDraft(pull.ID, true)
424+ }
425+ hc.g.RecordRevision(pull)
426+ hc.store.Notify(hc.store.Watchers(hc.repo, "pull", pull.ID, pull.Author.ID), hc.pusher, hc.repo, "pull", pull.Number, pull.Title, "opened")
427+ fmt.Fprintf(errw, "\ncreated pull request #%d:\n %s\n\n", pull.Number, pullURL(hc.repo, pull.Number))
428+ return agitResult{ok: true, ref: pull.Ref(), oldOID: zeroOID, newOID: c.new}
429+}
430+
431+func pullURL(repo Repo, n int64) string {
432+ host := os.Getenv("GITPOCKET_HOST")
433+ if host == "" {
434+ return fmt.Sprintf("/%s/pulls/%d", repo.Path(), n)
435+ }
436+ return fmt.Sprintf("https://%s/%s/pulls/%d", host, repo.Path(), n)
437+}
438+
439+func (s *Store) AgitPull(repo Repo, author int64, target, topic string) (Pull, bool) {
440+ var number int64
441+ err := s.db.QueryRow(`select number from pulls where repo = ? and author = ? and agit = ? and target_branch = ? and state = 'open' order by number desc limit 1`, repo.ID, author, topic, target).Scan(&number)
442+ if err != nil {
443+ return Pull{}, false
444+ }
445+ p, err := s.Pull(repo, number)
446+ return p, err == nil
447+}
448+
449+func (s *Store) SetAgit(id int64, topic string) {
450+ s.db.Exec(`update pulls set agit = ? where id = ?`, topic, id)
451+}
452+
453+func (s *Store) DeletePull(id int64) {
454+ s.db.Exec(`delete from pulls where id = ?`, id)
455+}

forge/cli.go modified

@@ -48,10 +48,10 @@ func Main() {
4848 binary, _ := os.Executable()
4949 os.Exit(keys(*root, binary, args[1:]))
5050 case "hook":
51- if len(args) != 3 {
51+ if len(args) < 2 {
5252 os.Exit(2)
5353 }
54- os.Exit(hook(*root, args[1], args[2]))
54+ os.Exit(hook(*root, args[1]))
5555 case "shell":
5656 if len(args) != 2 {
5757 os.Exit(2)

forge/edit.go modified

@@ -1,10 +1,9 @@
11 package forge
22
33 import (
4- "bufio"
54 "errors"
6- "fmt"
75 "html/template"
6+ "log"
87 "net/http"
98 "os"
109 "path"
@@ -12,18 +11,20 @@ import (
1211 "strings"
1312 )
1413
15-func installHook(root, dir string) {
14+func installHook(root, host, dir string) {
1615 binary, err := os.Executable()
1716 if err != nil {
1817 return
1918 }
2019 hooks := filepath.Join(dir, "hooks")
2120 os.MkdirAll(hooks, 0o755)
22- script := fmt.Sprintf("#!/bin/sh\nd=$(cd \"$(dirname \"$0\")/..\" && pwd)\nexec %s -root %s hook pre-receive \"$(basename \"$(dirname \"$d\")\")|$(basename \"$d\")\"\n", binary, root)
23- os.WriteFile(filepath.Join(hooks, "pre-receive"), []byte(script), 0o755)
21+ writeHook(filepath.Join(hooks, "pre-receive"), hookScript(root, host, binary, "pre-receive"))
2422 }
2523
2624 func (s *Server) installHooks() {
25+ if err := installCentralHooks(s.root, s.host); err != nil {
26+ log.Printf("hooks: %v", err)
27+ }
2728 owners, err := os.ReadDir(filepath.Join(s.root, "repos"))
2829 if err != nil {
2930 return
@@ -32,65 +33,10 @@ func (s *Server) installHooks() {
3233 repos, _ := os.ReadDir(filepath.Join(s.root, "repos", o.Name()))
3334 for _, r := range repos {
3435 if strings.HasSuffix(r.Name(), ".git") {
35- installHook(s.root, filepath.Join(s.root, "repos", o.Name(), r.Name()))
36- }
37- }
38- }
39-}
40-
41-func hook(root, kind, spec string) int {
42- if kind != "pre-receive" {
43- return 0
44- }
45- owner, repoDir, ok := strings.Cut(spec, "|")
46- if !ok {
47- return 0
48- }
49- name := strings.TrimSuffix(repoDir, ".git")
50- store, err := openStore(filepath.Join(root, "gitpocket.db"), true)
51- if err != nil {
52- fmt.Fprintln(os.Stderr, "gitpocket: database unavailable, push refused")
53- return 1
54- }
55- repo, err := store.RepoByPath(owner, name)
56- if err != nil {
57- fmt.Fprintln(os.Stderr, "gitpocket: unknown repository, push refused")
58- return 1
59- }
60- for _, kv := range os.Environ() {
61- if strings.HasPrefix(kv, "GIT_OBJECT_DIRECTORY=") || strings.HasPrefix(kv, "GIT_ALTERNATE_OBJECT_DIRECTORIES=") || strings.HasPrefix(kv, "GIT_QUARANTINE_PATH=") {
62- passEnv = append(passEnv, kv)
63- }
64- }
65- g := Git{Dir: filepath.Join(root, "repos", owner, repoDir)}
66- zero := strings.Repeat("0", 40)
67- scanner := bufio.NewScanner(os.Stdin)
68- for scanner.Scan() {
69- f := strings.Fields(scanner.Text())
70- if len(f) != 3 || !strings.HasPrefix(f[2], "refs/heads/") {
71- continue
72- }
73- branch := strings.TrimPrefix(f[2], "refs/heads/")
74- prot, ok := store.Protection(repo.ID, branch)
75- if !ok {
76- continue
77- }
78- if f[1] == zero {
79- fmt.Fprintf(os.Stderr, "gitpocket: %s is protected and cannot be deleted\n", branch)
80- return 1
81- }
82- if prot.RequirePR && f[0] != zero {
83- fmt.Fprintf(os.Stderr, "gitpocket: %s only accepts changes through pull requests\n", branch)
84- return 1
85- }
86- if f[0] != zero {
87- if _, err := g.run(nil, "merge-base", "--is-ancestor", f[0], f[1]); err != nil {
88- fmt.Fprintf(os.Stderr, "gitpocket: %s is protected, force pushes are refused\n", branch)
89- return 1
36+ installHook(s.root, s.host, filepath.Join(s.root, "repos", o.Name(), r.Name()))
9037 }
9138 }
9239 }
93- return 0
9440 }
9541
9642 func (g Git) Commit1(branch, parent, filePath string, content []byte, remove bool, message, name, email string) (string, error) {

forge/feature_test.go modified

@@ -179,6 +179,176 @@ func TestReservedNames(t *testing.T) {
179179 }
180180 }
181181
182+type pushRig struct {
183+ t *testing.T
184+ s *Server
185+ url string
186+ work string
187+}
188+
189+func (p pushRig) git(args ...string) (string, error) {
190+ cmd := exec.Command("git", append([]string{"-C", p.work}, args...)...)
191+ cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=B", "[email protected]", "GIT_COMMITTER_NAME=B", "[email protected]")
192+ out, err := cmd.CombinedOutput()
193+ return string(out), err
194+}
195+
196+func (p pushRig) must(args ...string) string {
197+ p.t.Helper()
198+ out, err := p.git(args...)
199+ if err != nil {
200+ p.t.Fatalf("git %v: %v\n%s", args, err, out)
201+ }
202+ return out
203+}
204+
205+func (p pushRig) commit(name, content, message string) {
206+ p.t.Helper()
207+ os.WriteFile(filepath.Join(p.work, name), []byte(content), 0o644)
208+ p.must("add", name)
209+ p.must("commit", "-q", "-m", message)
210+}
211+
212+func remote(base, user, repo string) string {
213+ u, _ := url.Parse(base)
214+ u.User = url.UserPassword(user, "correct horse")
215+ u.Path = "/~" + repo
216+ return u.String()
217+}
218+
219+func TestAgit(t *testing.T) {
220+ if _, err := exec.LookPath("git"); err != nil {
221+ t.Skip("git not installed")
222+ }
223+ if testing.Short() {
224+ t.Skip("builds the binary")
225+ }
226+ s, h := testServer(t)
227+ bin := filepath.Join(t.TempDir(), "gitpocket")
228+ if out, err := exec.Command("go", "build", "-o", bin, "..").CombinedOutput(); err != nil {
229+ t.Fatalf("build: %v\n%s", err, out)
230+ }
231+ for _, kind := range []string{"pre-receive", "proc-receive"} {
232+ if err := writeHook(filepath.Join(hooksDir(s.root), kind), hookScript(s.root, "example.org", bin, kind)); err != nil {
233+ t.Fatal(err)
234+ }
235+ }
236+ srv := httptest.NewServer(h)
237+ defer srv.Close()
238+ alice, _ := s.store.CreateUser("alice", "correct horse")
239+ bob, _ := s.store.CreateUser("bob", "correct horse")
240+ repo, g := newRepo(t, s, alice, "proj", Public)
241+ base := commitFile(t, g, g.Head(), "", "a.txt", "one\n")
242+
243+ p := pushRig{t: t, s: s, work: t.TempDir()}
244+ p.must("init", "-q", "-b", "main")
245+ p.must("fetch", "-q", remote(srv.URL, "bob", "alice/proj"), "main")
246+ p.must("reset", "-q", "--hard", "FETCH_HEAD")
247+ p.commit("b.txt", "two\n", "Add b\n\nBody text")
248+ bobURL := remote(srv.URL, "bob", "alice/proj")
249+
250+ for _, ref := range []string{"HEAD:refs/heads/main", "HEAD:refs/heads/evil", "HEAD:refs/tags/v1", "HEAD:refs/pull/9/head"} {
251+ if out, err := p.git("push", bobURL, ref); err == nil {
252+ t.Errorf("non-writer push to %s accepted\n%s", ref, out)
253+ }
254+ }
255+ if head, _ := g.Resolve("refs/heads/main"); head != base {
256+ t.Fatal("main moved after refused pushes")
257+ }
258+ if out, err := p.git("push", "-o", "title=Hello there", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "pulls/1") {
259+ t.Fatalf("agit push failed: %v\n%s", err, out)
260+ }
261+ pull, err := s.store.Pull(repo, 1)
262+ if err != nil || pull.Agit != "main" || pull.Author.ID != bob.ID || pull.Title != "Hello there" || pull.TargetBranch != "main" {
263+ t.Fatalf("pull request not created as expected: %+v %v", pull, err)
264+ }
265+ first := strings.TrimSpace(p.must("rev-parse", "HEAD"))
266+ if got, _ := g.Resolve(pull.Ref()); got != first {
267+ t.Fatalf("pull ref %s, want %s", got, first)
268+ }
269+ p.commit("b.txt", "three\n", "Add b, again")
270+ if out, err := p.git("push", bobURL, "HEAD:refs/for/main"); err != nil || !strings.Contains(out, "updated pull request #1") {
271+ t.Fatalf("agit update failed: %v\n%s", err, out)
272+ }
273+ second := strings.TrimSpace(p.must("rev-parse", "HEAD"))
274+ if got, _ := g.Resolve(pull.Ref()); got != second {
275+ t.Error("pull ref not updated")
276+ }
277+ if n := len(g.RecordRevision(pull)); n != 2 {
278+ t.Errorf("revisions %d, want 2", n)
279+ }
280+ if out, err := p.git("push", bobURL, "HEAD:refs/for/main/other"); err != nil {
281+ t.Fatalf("topic push failed: %v\n%s", err, out)
282+ }
283+ if pr, err := s.store.Pull(repo, 2); err != nil || pr.Agit != "other" || pr.Title != "Add b, again" {
284+ t.Errorf("topic pull request: %+v %v", pr, err)
285+ }
286+ if _, err := p.git("push", bobURL, "HEAD:refs/for/nosuch"); err == nil {
287+ t.Error("push for a missing branch accepted")
288+ }
289+ if _, err := p.git("push", bobURL, base+":refs/for/main/stale"); err == nil {
290+ t.Error("push without new commits accepted")
291+ }
292+
293+ aliceURL := remote(srv.URL, "alice", "alice/proj")
294+ if _, err := p.git("push", "-f", aliceURL, base+":refs/pull/1/head"); err == nil {
295+ t.Error("writer overwrote a pull ref directly")
296+ }
297+ if _, err := p.git("push", aliceURL, "HEAD:refs/heads/main"); err != nil {
298+ t.Error("writer push refused")
299+ }
300+
301+ own, _ := s.store.CreateSession(alice.ID)
302+ if body := get(h, "/~alice/proj/pulls/1", own).Body.String(); !strings.Contains(body, "~bob:main") || strings.Contains(body, "Update from source") {
303+ t.Error("agit pull page does not show the topic")
304+ }
305+
306+ s.store.SetBlock(alice.ID, bob.ID, true)
307+ p.commit("c.txt", "c\n", "Add c")
308+ if _, err := p.git("push", bobURL, "HEAD:refs/for/main/blocked"); err == nil {
309+ t.Error("blocked user opened a pull request")
310+ }
311+ s.store.SetBlock(alice.ID, bob.ID, false)
312+
313+ secret, _ := newRepo(t, s, alice, "secret", Private)
314+ commitFile(t, Git{Dir: s.repoDir(secret)}, "main", "", "a.txt", "x\n")
315+ if _, err := p.git("push", remote(srv.URL, "bob", "alice/secret"), "HEAD:refs/for/main"); err == nil {
316+ t.Error("agit push into a private repository accepted")
317+ }
318+
319+ s.store.AddMirror(repo.ID, "pull", "https://example.com/x.git", "oauth2", nil, time.Hour)
320+ if _, err := p.git("push", aliceURL, "HEAD:refs/heads/mirrored"); err == nil {
321+ t.Error("push into a pull mirror accepted")
322+ }
323+ s.store.db.Exec(`delete from mirrors`)
324+
325+ os.Remove(filepath.Join(hooksDir(s.root), "proc-receive"))
326+ if _, err := p.git("push", aliceURL, "HEAD:refs/heads/nohooks"); err == nil {
327+ t.Error("push accepted without hooks installed")
328+ }
329+}
330+
331+func TestProcReceiveParsing(t *testing.T) {
332+ var out bytes.Buffer
333+ for _, in := range []string{"", "0003", "ffff", "zzzz", "0008abc", "000eversion=2\n0000"} {
334+ out.Reset()
335+ if procReceive(t.TempDir(), strings.NewReader(in), &out, &out) == 0 {
336+ t.Errorf("accepted %q", in)
337+ }
338+ }
339+ var buf bytes.Buffer
340+ writePkt(&buf, "version=1\x00push-options")
341+ writePkt(&buf, "")
342+ writePkt(&buf, zeroOID+" "+strings.Repeat("a", 40)+" refs/for/main")
343+ writePkt(&buf, "")
344+ writePkt(&buf, "title=x")
345+ writePkt(&buf, "")
346+ out.Reset()
347+ if procReceive(t.TempDir(), &buf, &out, &out) != 0 || !strings.Contains(out.String(), "ng refs/for/main") {
348+ t.Errorf("command without gitpocket context not refused: %q", out.String())
349+ }
350+}
351+
182352 func TestSeal(t *testing.T) {
183353 root := t.TempDir()
184354 box, err := seal(root, "hunter2")

forge/import.go modified

@@ -199,7 +199,7 @@ func (s *Server) importRepo(w http.ResponseWriter, r *http.Request) {
199199 fail("Could not create the repository")
200200 return
201201 }
202- installHook(s.root, dir)
202+ installHook(s.root, s.host, dir)
203203 s.uploads.fail("import:" + u.Name)
204204 if r.FormValue("mirror") == "on" {
205205 box, err := seal(s.root, strings.TrimSpace(r.FormValue("token")))

forge/pull.go modified

@@ -67,6 +67,7 @@ type Pull struct {
6767 Merged string
6868 Comments int
6969 Draft bool
70+ Agit string
7071 }
7172
7273 type PRReview struct {
@@ -165,6 +166,16 @@ func (p Pull) Ref() string {
165166 return fmt.Sprintf("refs/pull/%d/head", p.Number)
166167 }
167168
169+func (p Pull) Source() string {
170+ if p.Agit != "" {
171+ return "~" + p.Author.Name + ":" + p.Agit
172+ }
173+ if p.SameRepo() {
174+ return p.SourceBranch
175+ }
176+ return p.SourceRepo.Path() + ":" + p.SourceBranch
177+}
178+
168179 func (p Pull) SameRepo() bool {
169180 return p.SourceRepo.ID == p.Repo.ID
170181 }
@@ -202,7 +213,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) {
202213 var created, updated, aCreated int64
203214 var sourceID int64
204215 var draft int
205- if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &draft, &p.Author.ID, &p.Author.Name, &aCreated); err != nil {
216+ if err := rows.Scan(&p.ID, &p.Number, &p.Title, &p.Body, &sourceID, &p.SourceBranch, &p.TargetBranch, &p.State, &created, &updated, &p.Merged, &p.Comments, &draft, &p.Agit, &p.Author.ID, &p.Author.Name, &aCreated); err != nil {
206217 return nil, err
207218 }
208219 p.Draft = draft != 0
@@ -214,7 +225,7 @@ func (s *Store) scanPulls(rows *sql.Rows) ([]Pull, error) {
214225 }
215226
216227 const pullColumns = `p.id, p.number, p.title, p.body, p.source_repo, p.source_branch, p.target_branch, p.state, p.created, p.updated, p.merged,
217- (select count(*) from comments c where c.pull = p.id), p.draft, u.id, u.name, u.created from pulls p join users u on u.id = p.author`
228+ (select count(*) from comments c where c.pull = p.id), p.draft, p.agit, u.id, u.name, u.created from pulls p join users u on u.id = p.author`
218229
219230 func (s *Store) Pulls(repo Repo, state string) ([]Pull, error) {
220231 rows, err := s.db.Query(`select `+pullColumns+` where p.repo = ? and (? = '' or p.state = ?) order by p.number desc`, repo.ID, state, state)
@@ -661,7 +672,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
661672 }
662673 back += "#diff"
663674 case "update":
664- if (isOwner || isAuthor) && pull.State == "open" {
675+ if (isOwner || isAuthor) && pull.State == "open" && pull.Agit == "" {
665676 if err := g.Fetch(s.repoDir(pull.SourceRepo), "refs/heads/"+pull.SourceBranch, pull.Ref()); err != nil {
666677 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
667678 return
@@ -842,6 +853,9 @@ func (s *Server) notifyPull(pull Pull, actor User, verb, body string) {
842853 }
843854
844855 func mergeTitle(pull Pull) string {
856+ if pull.Agit != "" {
857+ return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.Source())
858+ }
845859 return fmt.Sprintf("Merge pull request #%d from %s", pull.Number, pull.SourceRepo.Path()+":"+pull.SourceBranch)
846860 }
847861
@@ -880,7 +894,7 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
880894 s.fail(w, r, 500, "Could not create the repository")
881895 return
882896 }
883- installHook(s.root, dir)
897+ installHook(s.root, s.host, dir)
884898 http.Redirect(w, r, "/"+fork.Path(), http.StatusSeeOther)
885899 }
886900

forge/quota.go modified

@@ -68,11 +68,20 @@ func writeQuota(root string, n int64) error {
6868 return os.WriteFile(filepath.Join(root, "quota"), []byte(strconv.FormatInt(n, 10)+"\n"), 0o644)
6969 }
7070
71-func pushLimit(root, owner string) []string {
71+const guestPush = 20 << 20
72+
73+func pushLimit(root, owner string, writer bool) []string {
74+ limit := int64(0)
7275 if over, used, quota := overQuota(root, owner); quota > 0 && !over {
73- return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(quota-used, 10)}
76+ limit = quota - used
77+ }
78+ if !writer && (limit == 0 || limit > guestPush) {
79+ limit = guestPush
80+ }
81+ if limit == 0 {
82+ return nil
7483 }
75- return nil
84+ return []string{"-c", "receive.maxInputSize=" + strconv.FormatInt(limit, 10)}
7685 }
7786
7887 func compactSize(n int64) string {

forge/shell.go modified

@@ -70,8 +70,8 @@ func shell(root, name string) int {
7070 if repo.Private && !canWrite {
7171 return fail("repository not found")
7272 }
73- if verb == "git-receive-pack" && !canWrite {
74- return fail("you do not have write access to " + repo.Path())
73+ if verb == "git-receive-pack" && !hooksReady(root) {
74+ return fail("pushes are unavailable right now")
7575 }
7676 if verb == "git-receive-pack" && repo.Archived {
7777 return fail("repository is archived and read only")
@@ -92,7 +92,8 @@ func shell(root, name string) int {
9292 }
9393 argv := []string{"git"}
9494 if verb == "git-receive-pack" {
95- argv = append(argv, pushLimit(root, repo.Owner.Name)...)
95+ argv = append(append(argv, receiveArgs(root)...), pushLimit(root, repo.Owner.Name, canWrite)...)
96+ env = append(env, receiveEnv(repo, user)...)
9697 }
9798 err = syscall.Exec(git, append(argv, strings.TrimPrefix(verb, "git-"), dir), env)
9899 return fail(err.Error())

forge/smart.go modified

@@ -55,6 +55,8 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
5555 return
5656 }
5757 needAuth := service == "git-receive-pack" || repo.Private
58+ var pusher User
59+ writer := false
5860 if needAuth {
5961 ip := clientIP(r)
6062 if s.limiter.blocked(ip) {
@@ -62,7 +64,7 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
6264 return
6365 }
6466 u, ok := s.basicUser(r)
65- if !ok || !s.store.CanWrite(repo, &u) {
67+ if !ok || !s.store.CanRead(repo, &u) {
6668 if _, _, given := r.BasicAuth(); given {
6769 s.limiter.fail(ip)
6870 }
@@ -70,6 +72,11 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
7072 http.Error(w, "authentication required", 401)
7173 return
7274 }
75+ pusher, writer = u, s.store.CanWrite(repo, &u)
76+ }
77+ if service == "git-receive-pack" && !hooksReady(s.root) {
78+ http.Error(w, "pushes are unavailable right now", 503)
79+ return
7380 }
7481 if service == "git-receive-pack" && repo.Archived {
7582 http.Error(w, "repository is archived and read only", 403)
@@ -86,7 +93,12 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
8693 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
8794 defer cancel()
8895 if rest == "info/refs" {
89- cmd := exec.CommandContext(ctx, "git", strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", dir)
96+ argv := []string{strings.TrimPrefix(service, "git-"), "--stateless-rpc", "--advertise-refs", dir}
97+ if service == "git-receive-pack" {
98+ argv = append(receiveArgs(s.root), argv...)
99+ }
100+ cmd := exec.CommandContext(ctx, "git", argv...)
101+ cmd.Env = []string{"HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
90102 out, err := cmd.Output()
91103 if err != nil {
92104 http.Error(w, "git failed", 500)
@@ -109,14 +121,20 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
109121 }
110122 argv := []string{strings.TrimPrefix(service, "git-"), "--stateless-rpc", dir}
111123 if service == "git-receive-pack" {
112- argv = append(pushLimit(s.root, repo.Owner.Name), argv...)
124+ argv = append(append(receiveArgs(s.root), pushLimit(s.root, repo.Owner.Name, writer)...), argv...)
113125 if over, used, quota := overQuota(s.root, repo.Owner.Name); quota > 0 && !over {
114126 body = io.LimitReader(body, quota-used+1<<20)
115127 }
128+ if !writer {
129+ body = io.LimitReader(body, guestPush+1<<20)
130+ }
116131 }
117132 cmd := exec.CommandContext(ctx, "git", argv...)
118133 cmd.Stdin = body
119134 cmd.Env = []string{"GIT_PROTOCOL=" + r.Header.Get("Git-Protocol"), "HOME=/nonexistent", "PATH=" + os.Getenv("PATH")}
135+ if service == "git-receive-pack" {
136+ cmd.Env = append(cmd.Env, receiveEnv(repo, pusher)...)
137+ }
120138 stdout, err := cmd.StdoutPipe()
121139 if err != nil {
122140 http.Error(w, "git failed", 500)

forge/store.go modified

@@ -83,6 +83,7 @@ var migrations = []string{
8383 `alter table repos add column pinned integer not null default 0`,
8484 `alter table users add column theme text not null default ''`,
8585 `alter table users add column pages integer not null default 1`,
86+ `alter table pulls add column agit text not null default ''`,
8687 }
8788
8889 const extraSchema = `

forge/web.go modified

@@ -489,7 +489,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
489489 s.fail(w, r, 500, "Could not create the repository")
490490 return
491491 }
492- installHook(s.root, dir)
492+ installHook(s.root, s.host, dir)
493493 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
494494 }
495495

web/templates/pull.tmpl modified

@@ -3,7 +3,7 @@
33 {{$pr := .Data.Pull}}
44 <div class="pulltitle">
55 <h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3>
6-<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&amp;target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
6+<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{$pr.Source}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&amp;target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
77 </div>
88 <div class="columns pullbody">
99 <div class="main">
@@ -45,7 +45,7 @@
4545 <div class="actions left"><button class="btn primary">Confirm merge ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}">Cancel</a></div>
4646 </form>
4747 {{else}}{{if .Data.Mergeable}}<a class="btn primary block" href="?merge=1">Merge pull request ▸</a>{{else}}<button class="btn primary block" disabled>Merge pull request ▸</button>{{end}}{{end}}{{end}}
48-{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form>
48+{{if or .Data.Owner .Data.Author}}{{if not $pr.Agit}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/update"><button class="btn block">Update from source ▸</button></form>{{end}}
4949 <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/close"><button class="btn danger block">Close ▸</button></form>{{end}}
5050 {{else if eq $pr.State "closed"}}<p>Closed without merging.</p>{{if or .Data.Owner .Data.Author}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/reopen"><button class="btn block">Reopen ▸</button></form>{{end}}
5151 {{else}}<p>Merged into <code>{{$pr.TargetBranch}}</code>.</p>{{end}}

web/templates/pulls.tmpl modified

@@ -6,5 +6,5 @@
66 <span><a class="muted small feedlink" href="/{{$p.Repo.Path}}/pulls/feed">atom feed</a>{{if $p.User}} <a class="btn primary" href="/{{$p.Repo.Path}}/pulls/new">New pull request ▸</a>{{end}}</span>
77 </div>
88 {{if .Data.Labels}}<p class="labelbar">{{range .Data.Labels}}<a class="label{{if eq .ID $.Data.Filter}} on{{end}}" style="background:{{.Colour}}" href="?state={{$.Data.State}}&label={{.ID}}">{{.Name}}</a> {{end}}{{if .Data.Filter}}<a class="muted small" href="?state={{.Data.State}}">clear</a>{{end}}</p>{{end}}
9-{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}{{if .Draft}} draft{{end}}">{{if and .Draft (eq .State "open")}}draft{{else}}{{.State}}{{end}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">{{if .SameRepo}}{{.SourceBranch}}{{else}}{{.SourceRepo.Path}}:{{.SourceBranch}}{{end}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}}
9+{{if .Data.Pulls}}<div class="events">{{range .Data.Pulls}}<div class="event pull"><h4><span class="state {{.State}}{{if .Draft}} draft{{end}}">{{if and .Draft (eq .State "open")}}draft{{else}}{{.State}}{{end}}</span> <a href="/{{$p.Repo.Path}}/pulls/{{.Number}}">{{.Title}}</a> <span class="muted">#{{.Number}}</span>{{range index $.Data.ItemLabels .ID}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h4><p class="muted small">{{.Source}} → {{.TargetBranch}} · opened by <a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> {{ago .Created}}{{if .Comments}} · {{.Comments}} comments{{end}}</p></div>{{end}}</div>{{else}}<p class="info">No pull requests here.</p>{{end}}
1010 {{end}}