repos / git-pr

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

commit
46ea9e7
parent
59a4a00
author
Eric Bower
date
2024-06-04 13:10:09 -0400 EDT
feat: rss feeds
11 files changed,  +352, -49
M cfg.go
M cli.go
M db.go
M go.mod
M go.sum
M pr.go
M web.go
M cfg.go
+2, -0
 1@@ -12,11 +12,13 @@ type GitCfg struct {
 2 	DataPath string
 3 	Admins   []ssh.PublicKey
 4 	Repos    []Repo
 5+	Url      string
 6 }
 7 
 8 func NewGitCfg() *GitCfg {
 9 	return &GitCfg{
10 		DataPath: "./ssh_data",
11+		Url:      "pr.pico.sh",
12 		Repos: []Repo{
13 			{
14 				ID:        "test",
M cli.go
+58, -4
 1@@ -89,6 +89,60 @@ Here's how it works:
 2 					return nil
 3 				},
 4 			},
 5+			{
 6+				Name:  "logs",
 7+				Usage: "List event logs by on filters",
 8+				Args:  true,
 9+				Flags: []cli.Flag{
10+					&cli.Int64Flag{
11+						Name:  "pr",
12+						Usage: "show all events related to the provided patch request",
13+					},
14+					&cli.BoolFlag{
15+						Name:  "pubkey",
16+						Usage: "show all events related to your pubkey",
17+					},
18+					&cli.StringFlag{
19+						Name:  "repo",
20+						Usage: "show all events related to a repo",
21+					},
22+				},
23+				Action: func(cCtx *cli.Context) error {
24+					pubkey := be.Pubkey(sesh.PublicKey())
25+					isPubkey := cCtx.Bool("pubkey")
26+					prID := cCtx.Int64("pr")
27+					repoID := cCtx.String("repo")
28+					var eventLogs []*EventLog
29+					var err error
30+					if isPubkey {
31+						eventLogs, err = pr.GetEventLogsByPubkey(pubkey)
32+					} else if prID != 0 {
33+						eventLogs, err = pr.GetEventLogsByPrID(prID)
34+					} else if repoID != "" {
35+						eventLogs, err = pr.GetEventLogsByRepoID(repoID)
36+					} else {
37+						eventLogs, err = pr.GetEventLogs()
38+					}
39+					if err != nil {
40+						return err
41+					}
42+					writer := NewTabWriter(sesh)
43+					fmt.Fprintln(writer, "RepoID\tPrID\tEvent\tCreated\tData")
44+					for _, eventLog := range eventLogs {
45+						fmt.Fprintf(
46+							writer,
47+							"%s\t%d\t%s\t%s\t%s\n",
48+							eventLog.RepoID,
49+							eventLog.PatchRequestID,
50+							eventLog.Event,
51+							eventLog.CreatedAt.Format(time.RFC3339Nano),
52+							eventLog.Data,
53+						)
54+					}
55+					writer.Flush()
56+					return nil
57+				},
58+			},
59 			{
60 				Name:  "pr",
61 				Usage: "Manage Patch Requests (PR)",
62@@ -300,7 +354,7 @@ Here's how it works:
63 							if !isAdmin {
64 								return fmt.Errorf("you are not authorized to accept a PR")
65 							}
66-							err = pr.UpdatePatchRequest(prID, "accept")
67+							err = pr.UpdatePatchRequest(prID, pubkey, "accepted")
68 							return err
69 						},
70 					},
71@@ -324,7 +378,7 @@ Here's how it works:
72 							if !isAdmin && !isContrib {
73 								return fmt.Errorf("you are not authorized to change PR status")
74 							}
75-							err = pr.UpdatePatchRequest(prID, "closed")
76+							err = pr.UpdatePatchRequest(prID, pubkey, "closed")
77 							return err
78 						},
79 					},
80@@ -349,7 +403,7 @@ Here's how it works:
81 								return fmt.Errorf("you are not authorized to change PR status")
82 							}
83 
84-							err = pr.UpdatePatchRequest(prID, "open")
85+							err = pr.UpdatePatchRequest(prID, pubkey, "open")
86 							return err
87 						},
88 					},
89@@ -405,7 +459,7 @@ Here's how it works:
90 
91 							reviewTxt := ""
92 							if isReview {
93-								err = pr.UpdatePatchRequest(prID, "reviewed")
94+								err = pr.UpdatePatchRequest(prID, pubkey, "reviewed")
95 								if err != nil {
96 									return err
97 								}
M db.go
+29, -1
 1@@ -49,7 +49,16 @@ type Comment struct {
 2 	UpdatedAt      time.Time `db:"updated_at"`
 3 }
 4 
 5-type GitDB interface {
 6+// EventLog is a event log for RSS or other notification systems.
 7+type EventLog struct {
 8+	ID             int64     `db:"id"`
 9+	Pubkey         string    `db:"pubkey"`
10+	RepoID         string    `db:"repo_id"`
11+	PatchRequestID int64     `db:"patch_request_id"`
12+	CommentID      int64     `db:"comment_id"`
13+	Event          string    `db:"event"`
14+	Data           string    `db:"data"`
15+	CreatedAt      time.Time `db:"created_at"`
16 }
17 
18 // DB is the interface for a pico/git database.
19@@ -103,6 +112,25 @@ CREATE TABLE IF NOT EXISTS comments (
20   ON DELETE CASCADE
21   ON UPDATE CASCADE
22 );
23+
24+CREATE TABLE IF NOT EXISTS event_logs (
25+  id INTEGER PRIMARY KEY AUTOINCREMENT,
26+  pubkey TEXT NOT NULL,
27+  repo_id TEXT,
28+  patch_request_id INTEGER,
29+  comment_id INTEGER,
30+  event TEXT NOT NULL,
31+  data TEXT,
32+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
33+  CONSTRAINT event_logs_pr_id_fk
34+  FOREIGN KEY(patch_request_id) REFERENCES patch_requests(id)
35+  ON DELETE CASCADE
36+  ON UPDATE CASCADE,
37+  CONSTRAINT event_logs_comment_id_fk
38+  FOREIGN KEY(comment_id) REFERENCES comments(id)
39+  ON DELETE CASCADE
40+  ON UPDATE CASCADE
41+);
42 `
43 
44 // Open opens a database connection.
M go.mod
+1, -0
1@@ -8,6 +8,7 @@ require (
2 	github.com/charmbracelet/soft-serve v0.7.4
3 	github.com/charmbracelet/ssh v0.0.0-20240301204039-e79ff702f5b3
4 	github.com/charmbracelet/wish v1.3.2
5+	github.com/gorilla/feeds v1.1.2
6 	github.com/jmoiron/sqlx v1.3.5
7 	github.com/urfave/cli/v2 v2.27.2
8 	golang.org/x/crypto v0.21.0
M go.sum
+8, -0
 1@@ -50,12 +50,18 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
 2 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
 3 github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
 4 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 5+github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
 6+github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
 7 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 8 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 9 github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
10 github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
11 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
12 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
13+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
16+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
17 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
18 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
19 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
20@@ -87,6 +93,8 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
21 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
22 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
23 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
24+github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
25+github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
26 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
27 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
28 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
M pr.go
+117, -39
  1@@ -32,8 +32,13 @@ type GitPatchRequest interface {
  2 	GetPatchRequests() ([]*PatchRequest, error)
  3 	GetPatchRequestsByRepoID(repoID string) ([]*PatchRequest, error)
  4 	GetPatchesByPrID(prID int64) ([]*Patch, error)
  5-	UpdatePatchRequest(prID int64, status string) error
  6+	UpdatePatchRequest(prID int64, pubkey, status string) error
  7 	DeletePatchesByPrID(prID int64) error
  8+	CreateEventLog(eventLog EventLog) error
  9+	GetEventLogs() ([]*EventLog, error)
 10+	GetEventLogsByRepoID(repoID string) ([]*EventLog, error)
 11+	GetEventLogsByPrID(prID int64) ([]*EventLog, error)
 12+	GetEventLogsByPubkey(pubkey string) ([]*EventLog, error)
 13 }
 14 
 15 type PrCmd struct {
 16@@ -107,11 +112,55 @@ func (cmd PrCmd) GetPatchRequestByID(prID int64) (*PatchRequest, error) {
 17 	return &pr, err
 18 }
 19 
 20-// Status types: open, close, accept, review.
 21-func (cmd PrCmd) UpdatePatchRequest(prID int64, status string) error {
 22+// Status types: open, closed, accepted, reviewed.
 23+func (cmd PrCmd) UpdatePatchRequest(prID int64, pubkey string, status string) error {
 24 	_, err := cmd.Backend.DB.Exec(
 25-		"UPDATE patch_requests SET status=? WHERE id=?", status, prID,
 26+		"UPDATE patch_requests SET status=? WHERE id=?",
 27+		status,
 28+		prID,
 29+	)
 30+	_ = cmd.CreateEventLog(EventLog{
 31+		Pubkey:         pubkey,
 32+		PatchRequestID: prID,
 33+		Event:          "pr_status_changed",
 34+		Data:           fmt.Sprintf(`{"status":"%s"}`, status),
 35+	})
 36+	return err
 37+}
 38+
 39+func (cmd PrCmd) CreateEventLog(eventLog EventLog) error {
 40+	if eventLog.RepoID == "" && eventLog.PatchRequestID != 0 {
 41+		var pr PatchRequest
 42+		err := cmd.Backend.DB.Get(
 43+			&pr,
 44+			"SELECT repo_id FROM patch_requests WHERE id=?",
 45+			eventLog.PatchRequestID,
 46+		)
 47+		if err != nil {
 48+			cmd.Backend.Logger.Error(
 49+				"could not find pr when creating eventLog",
 50+				"err", err,
 51+			)
 52+			return nil
 53+		}
 54+		eventLog.RepoID = pr.RepoID
 55+	}
 56+
 57+	_, err := cmd.Backend.DB.Exec(
 58+		"INSERT INTO event_logs (pubkey, repo_id, patch_request_id, comment_id, event, data) VALUES (?, ?, ?, ?, ?, ?)",
 59+		eventLog.Pubkey,
 60+		eventLog.RepoID,
 61+		eventLog.PatchRequestID,
 62+		eventLog.CommentID,
 63+		eventLog.Event,
 64+		eventLog.Data,
 65 	)
 66+	if err != nil {
 67+		cmd.Backend.Logger.Error(
 68+			"could not create eventLog",
 69+			"err", err,
 70+		)
 71+	}
 72 	return err
 73 }
 74 
 75@@ -199,7 +248,7 @@ func (cmd PrCmd) parsePatchSet(patchset io.Reader) ([]*Patch, error) {
 76 
 77 func (cmd PrCmd) createPatch(tx *sqlx.Tx, review bool, patch *Patch) (int64, error) {
 78 	patchExists := []Patch{}
 79-	_ = cmd.Backend.DB.Select(&patchExists, "SELECT * FROM patches WHERE patch_request_id = ? AND content_sha = ?", patch.PatchRequestID, patch.ContentSha)
 80+	_ = cmd.Backend.DB.Select(&patchExists, "SELECT * FROM patches WHERE patch_request_id=? AND content_sha=?", patch.PatchRequestID, patch.ContentSha)
 81 	if len(patchExists) > 0 {
 82 		return 0, ErrPatchExists
 83 	}
 84@@ -283,6 +332,12 @@ func (cmd PrCmd) SubmitPatchRequest(repoID string, pubkey string, patchset io.Re
 85 		return nil, err
 86 	}
 87 
 88+	_ = cmd.CreateEventLog(EventLog{
 89+		Pubkey:         pubkey,
 90+		PatchRequestID: prID,
 91+		Event:          "pr_created",
 92+	})
 93+
 94 	var pr PatchRequest
 95 	err = cmd.Backend.DB.Get(&pr, "SELECT * FROM patch_requests WHERE id=?", prID)
 96 	return &pr, err
 97@@ -330,42 +385,19 @@ func (cmd PrCmd) SubmitPatchSet(prID int64, pubkey string, op PatchsetOp, patchs
 98 		return fin, err
 99 	}
100 
101-	return fin, err
102-}
103-
104-func (cmd PrCmd) ReplacePatchSet(prID int64, pubkey string, patchset io.Reader) ([]*Patch, error) {
105-	fin := []*Patch{}
106-	tx, err := cmd.Backend.DB.Beginx()
107-	if err != nil {
108-		return fin, err
109-	}
110-
111-	defer func() {
112-		_ = tx.Rollback()
113-	}()
114-
115-	patches, err := cmd.parsePatchSet(patchset)
116-	if err != nil {
117-		return fin, err
118-	}
119-
120-	for _, patch := range patches {
121-		patch.Pubkey = pubkey
122-		patch.PatchRequestID = prID
123-		patchID, err := cmd.createPatch(tx, false, patch)
124-		if err == nil {
125-			patch.ID = patchID
126-			fin = append(fin, patch)
127-		} else {
128-			if !errors.Is(ErrPatchExists, err) {
129-				return fin, err
130-			}
131+	if len(fin) > 0 {
132+		event := "pr_patchset_added"
133+		if op == OpReview {
134+			event = "pr_reviewed"
135+		} else if op == OpReplace {
136+			event = "pr_patchset_replaced"
137 		}
138-	}
139 
140-	err = tx.Commit()
141-	if err != nil {
142-		return fin, err
143+		_ = cmd.CreateEventLog(EventLog{
144+			Pubkey:         pubkey,
145+			PatchRequestID: prID,
146+			Event:          event,
147+		})
148 	}
149 
150 	return fin, err
151@@ -377,3 +409,49 @@ func (cmd PrCmd) DeletePatchesByPrID(prID int64) error {
152 	)
153 	return err
154 }
155+
156+func (cmd PrCmd) GetEventLogs() ([]*EventLog, error) {
157+	eventLogs := []*EventLog{}
158+	err := cmd.Backend.DB.Select(
159+		&eventLogs,
160+		"SELECT * FROM event_logs ORDER BY created_at DESC",
161+	)
162+	return eventLogs, err
163+}
164+
165+func (cmd PrCmd) GetEventLogsByRepoID(repoID string) ([]*EventLog, error) {
166+	eventLogs := []*EventLog{}
167+	err := cmd.Backend.DB.Select(
168+		&eventLogs,
169+		"SELECT * FROM event_logs WHERE repo_id=? ORDER BY created_at DESC",
170+		repoID,
171+	)
172+	return eventLogs, err
173+}
174+
175+func (cmd PrCmd) GetEventLogsByPrID(prID int64) ([]*EventLog, error) {
176+	eventLogs := []*EventLog{}
177+	err := cmd.Backend.DB.Select(
178+		&eventLogs,
179+		"SELECT * FROM event_logs WHERE patch_request_id=? ORDER BY created_at DESC",
180+		prID,
181+	)
182+	return eventLogs, err
183+}
184+
185+func (cmd PrCmd) GetEventLogsByPubkey(pubkey string) ([]*EventLog, error) {
186+	eventLogs := []*EventLog{}
187+	query := `SELECT * FROM event_logs
188+	WHERE pubkey=?
189+		OR patch_request_id IN (
190+			SELECT id FROM patch_requests WHERE pubkey=?
191+		)
192+	ORDER BY created_at DESC`
193+	err := cmd.Backend.DB.Select(
194+		&eventLogs,
195+		query,
196+		pubkey,
197+		pubkey,
198+	)
199+	return eventLogs, err
200+}
M tmpl/pr-detail-patches.html
+9, -0
 1@@ -3,6 +3,9 @@
 2 {{define "title"}}{{.Pr.Title}} - pr patches{{end}}
 3 
 4 {{define "meta"}}
 5+<link rel="alternate" type="application/atom+xml"
 6+      title="RSS feed for git collaboration server"
 7+      href="/prs/{{.Pr.ID}}/rss" />
 8 {{end}}
 9 
10 {{define "body"}}
11@@ -24,4 +27,10 @@
12   </div>
13   <hr />
14 </main>
15+
16+<hr />
17+
18+<footer>
19+  <a href="/prs/{{.Pr.ID}}/rss">rss</a>
20+</footer>
21 {{end}}
M tmpl/pr-detail.html
+9, -0
 1@@ -3,6 +3,9 @@
 2 {{define "title"}}{{.Pr.Title}} - pr summary{{end}}
 3 
 4 {{define "meta"}}
 5+<link rel="alternate" type="application/atom+xml"
 6+      title="RSS feed for git collaboration server"
 7+      href="/prs/{{.Pr.ID}}/rss" />
 8 {{end}}
 9 
10 {{define "body"}}
11@@ -32,4 +35,10 @@
12     {{end}}
13   </div>
14 </main>
15+
16+<hr />
17+
18+<footer>
19+  <a href="/prs/{{.Pr.ID}}/rss">rss</a>
20+</footer>
21 {{end}}
M tmpl/repo-detail.html
+9, -0
 1@@ -3,6 +3,9 @@
 2 {{define "title"}}{{.ID}} - repo{{end}}
 3 
 4 {{define "meta"}}
 5+<link rel="alternate" type="application/atom+xml"
 6+      title="RSS feed for git collaboration server"
 7+      href="/repos/{{.ID}}/rss" />
 8 {{end}}
 9 
10 {{define "body"}}
11@@ -29,4 +32,10 @@
12   </article>
13   {{end}}
14 </main>
15+
16+<hr />
17+
18+<footer>
19+  <a href="/repos/{{.ID}}/rss">rss</a>
20+</footer>
21 {{end}}
M tmpl/repo-list.html
+12, -5
 1@@ -3,19 +3,19 @@
 2 {{define "title"}}repo list{{end}}
 3 
 4 {{define "meta"}}
 5+<link rel="alternate" type="application/atom+xml"
 6+      title="RSS feed for git collaboration server"
 7+      href="/rss" />
 8 {{end}}
 9 
10 {{define "body"}}
11 <header>
12   <h1 class="text-2xl mb">Repos</h1>
13-  <p>A new git collaboration service</p>
14+  <p>A new git collaboration service.</p>
15 </header>
16 <main>
17   <div class="group">
18-    <div>
19-      <div>Learn how it works:</div>
20-      <div><pre>ssh pr.pico.sh help</pre></div>
21-    </div>
22+    <pre>ssh pr.pico.sh help</pre>
23 
24     {{range .Repos}}
25     <div class="box">
26@@ -25,4 +25,11 @@
27     {{end}}
28   </div>
29 </main>
30+
31+<hr />
32+
33+<footer>
34+  <div><a href="/rss">rss</a></div>
35+  <div class="text-sm">OR with fingerprint, url encoded: <code>/rss?pubkey=SHA256%3Axxx</code></div>
36+</footer>
37 {{end}}
M web.go
+98, -0
  1@@ -17,6 +17,7 @@ import (
  2 	formatterHtml "github.com/alecthomas/chroma/v2/formatters/html"
  3 	"github.com/alecthomas/chroma/v2/lexers"
  4 	"github.com/alecthomas/chroma/v2/styles"
  5+	"github.com/gorilla/feeds"
  6 )
  7 
  8 //go:embed tmpl/*
  9@@ -353,6 +354,100 @@ func prPatchesHandler(w http.ResponseWriter, r *http.Request) {
 10 	}
 11 }
 12 
 13+func rssHandler(w http.ResponseWriter, r *http.Request) {
 14+	web, err := getWebCtx(r)
 15+	if err != nil {
 16+		w.WriteHeader(http.StatusUnprocessableEntity)
 17+		return
 18+	}
 19+
 20+	desc := fmt.Sprintf(
 21+		"Events related to git collaboration server %s",
 22+		web.Backend.Cfg.Url,
 23+	)
 24+	feed := &feeds.Feed{
 25+		Title:       fmt.Sprintf("%s events", web.Backend.Cfg.Url),
 26+		Link:        &feeds.Link{Href: web.Backend.Cfg.Url},
 27+		Description: desc,
 28+		Author:      &feeds.Author{Name: "git collaboration server"},
 29+		Created:     time.Now(),
 30+	}
 31+
 32+	var eventLogs []*EventLog
 33+	id := r.PathValue("id")
 34+	repoID := r.PathValue("repoid")
 35+	pubkey := r.URL.Query().Get("pubkey")
 36+	if id != "" {
 37+		var prID int64
 38+		prID, err = getPrID(id)
 39+		if err != nil {
 40+			w.WriteHeader(http.StatusUnprocessableEntity)
 41+			return
 42+		}
 43+		eventLogs, err = web.Pr.GetEventLogsByPrID(prID)
 44+	} else if pubkey != "" {
 45+		eventLogs, err = web.Pr.GetEventLogsByPubkey(pubkey)
 46+	} else if repoID != "" {
 47+		eventLogs, err = web.Pr.GetEventLogsByRepoID(repoID)
 48+	} else {
 49+		eventLogs, err = web.Pr.GetEventLogs()
 50+	}
 51+
 52+	if err != nil {
 53+		web.Logger.Error("rss could not get eventLogs", "err", err)
 54+		w.WriteHeader(http.StatusInternalServerError)
 55+		return
 56+	}
 57+
 58+	var feedItems []*feeds.Item
 59+	for _, eventLog := range eventLogs {
 60+		realUrl := fmt.Sprintf("%s/prs/%d", web.Backend.Cfg.Url, eventLog.PatchRequestID)
 61+		content := fmt.Sprintf(
 62+			"<div><div>RepoID: %s</div><div>PatchRequestID: %d</div><div>Event: %s</div><div>Created: %s</div><div>Data: %s</div></div>",
 63+			eventLog.RepoID,
 64+			eventLog.PatchRequestID,
 65+			eventLog.Event,
 66+			eventLog.CreatedAt.Format(time.RFC3339Nano),
 67+			eventLog.Data,
 68+		)
 69+		pr, err := web.Pr.GetPatchRequestByID(eventLog.PatchRequestID)
 70+		if err != nil {
 71+			continue
 72+		}
 73+		title := fmt.Sprintf(
 74+			`%s in %s for PR "%s" (#%d)`,
 75+			eventLog.Event,
 76+			eventLog.RepoID,
 77+			pr.Name,
 78+			eventLog.PatchRequestID,
 79+		)
 80+		item := &feeds.Item{
 81+			Id:          fmt.Sprintf("%d", eventLog.ID),
 82+			Title:       title,
 83+			Link:        &feeds.Link{Href: realUrl},
 84+			Content:     content,
 85+			Created:     eventLog.CreatedAt,
 86+			Description: title,
 87+			Author:      &feeds.Author{Name: eventLog.Pubkey},
 88+		}
 89+
 90+		feedItems = append(feedItems, item)
 91+	}
 92+	feed.Items = feedItems
 93+
 94+	rss, err := feed.ToAtom()
 95+	if err != nil {
 96+		web.Logger.Error("could not generate atom rss feed", "err", err)
 97+		http.Error(w, "Could not generate atom rss feed", http.StatusInternalServerError)
 98+	}
 99+
100+	w.Header().Add("Content-Type", "application/atom+xml; charset=utf-8")
101+	_, err = w.Write([]byte(rss))
102+	if err != nil {
103+		web.Logger.Error("write error atom rss feed", "err", err)
104+	}
105+}
106+
107 func chromaStyleHandler(w http.ResponseWriter, r *http.Request) {
108 	web, err := getWebCtx(r)
109 	if err != nil {
110@@ -412,9 +507,12 @@ func StartWebServer() {
111 	// GODEBUG=httpmuxgo121=0
112 	http.HandleFunc("GET /prs/{id}/patches", ctxMdw(ctx, prPatchesHandler))
113 	http.HandleFunc("GET /prs/{id}", ctxMdw(ctx, prHandler))
114+	http.HandleFunc("GET /prs/{id}/rss", ctxMdw(ctx, rssHandler))
115 	http.HandleFunc("GET /repos/{id}", ctxMdw(ctx, repoHandler))
116+	http.HandleFunc("GET /repos/{repoid}/rss", ctxMdw(ctx, rssHandler))
117 	http.HandleFunc("GET /", ctxMdw(ctx, repoListHandler))
118 	http.HandleFunc("GET /syntax.css", ctxMdw(ctx, chromaStyleHandler))
119+	http.HandleFunc("GET /rss", ctxMdw(ctx, rssHandler))
120 
121 	logger.Info("starting web server", "addr", addr)
122 	err = http.ListenAndServe(addr, nil)