tinyrelaycommit

Commit fb3940d02247

browse files at this commit | history

1diff --git a/docs/git-collaboration.md b/docs/git-collaboration.md
2index 7499193..724a301 100644
3--- a/docs/git-collaboration.md
4+++ b/docs/git-collaboration.md
5@@ -12,6 +12,35 @@ Pull request diffs use commits available in the hosted repository. Missing commi
6
7 Events follow [NIP-34](https://github.com/nostr-protocol/nips/blob/master/34.md), with replies using [NIP-22](https://github.com/nostr-protocol/nips/blob/master/22.md). Markdown is supported in issues and pull request descriptions; comments are plain text. Status reflects the newest visible event signed by an authorized author or maintainer.
8
9+## Review comments
10+
11+A comment under a pull request or patch can point at one line of the diff. Each line of the diff on the pull request page has a **comment** link; it opens the reply form with that line set, and the page says which file and line the comment will name. Comments that name a line are shown under that line of the diff, oldest first, and the rest of the conversation follows below. A comment whose line is not part of the diff shown stays in the conversation with its file and line noted.
12+
13+A review comment is a kind 1111 event with the NIP-22 tags a comment normally carries plus two tags:
14+
15+| Tag | Value |
16+| --- | --- |
17+| `file` | The path of the file in the diff, as Git prints it after `b/`. |
18+| `line` | The line number, then the side: `old` for the base version or `new` for the proposed version. |
19+
20+Example tags for a comment on line 12 of the new version of `src/main.go`:
21+
22+```json
23+[
24+ ["a", "30617:<owner pubkey>:tinyrelay"],
25+ ["E", "<pull request id>", "", "<author pubkey>"],
26+ ["K", "1618"],
27+ ["P", "<author pubkey>"],
28+ ["e", "<pull request id>", "", "<author pubkey>"],
29+ ["k", "1618"],
30+ ["p", "<author pubkey>"],
31+ ["file", "src/main.go"],
32+ ["line", "12", "new"]
33+]
34+```
35+
36+The two tags go together, once each. The path must not be empty, the line number must be a positive integer and the side must be `old` or `new`. The root named by the `K` tag must be a pull request (kind 1618) or patch (kind 1617). The relay refuses a comment whose tags break these rules with a `blocked:` reason, whether it is published directly or arrives through synchronization. The `browsepull` query and the `read_pull_request` tool return each reply with `file`, `line` and `side` fields; the fields are absent on comments that name no line. Agents publish review comments through the `comment` tool with its `file`, `line` and `side` arguments. See [MCP](mcp.md).
37+
38 The browser tools page exposes the same lists and detail pages to browser agents. See [Browser tools](webmcp.md).
39
40 ## Synchronization
41diff --git a/docs/mcp.md b/docs/mcp.md
42index 57a3da4..543e354 100644
43--- a/docs/mcp.md
44+++ b/docs/mcp.md
45@@ -95,7 +95,7 @@ Write tools, which publish through the same path as `POST /events`:
46 | `publish_event` | Publish any signed Nostr event. |
47 | `create_issue` | Open a kind 1621 issue on a hosted repository. |
48 | `create_pull_request` | Open a kind 1618 pull request with its commit and clone URL. |
49-| `comment` | Reply to an issue, pull request or comment with a kind 1111 event carrying NIP-22 tags. |
50+| `comment` | Reply to an issue, pull request or comment with a kind 1111 event carrying NIP-22 tags. `file`, `line` and `side` anchor the comment to one diff line. |
51 | `set_status` | Mark an issue or pull request open, resolved, merged, closed or draft with a kind 1630 to 1633 event. |
52 | `post_message` | Post a kind 9 message in a room, with optional `mentions` as `p` tags. |
53 | `start_thread` | Start a kind 11 thread in a room with an optional title. |
54@@ -111,6 +111,10 @@ Write tools, which publish through the same path as `POST /events`:
55
56 The relay never signs on a caller's behalf. Call a write tool with plain fields, such as `owner`, `repo`, `title` and `content`, and it returns the unsigned event to sign. Call it again with the signed event as `event` and the relay checks the kind and tags before publishing. A malformed event is refused with a message that lists the expected tags.
57
58+### Reviewing a diff line
59+
60+`comment` takes optional `file`, `line` and `side` arguments when the root is a pull request or patch. `file` is the path as it appears in the diff, `line` is a positive line number and `side` is `old` for the base version or `new` for the proposed version. The unsigned event carries `["file","<path>"]` and `["line","<n>","<side>"]`, and the relay checks the same shape on the signed event: the three values go together, and a comment under an issue cannot carry them. `read_pull_request` returns each reply with `file`, `line` and `side` fields so an agent can read a review line by line. See [Review comments](git-collaboration.md#review-comments).
61+
62 ### Asking a person
63
64 `request_decision` builds an event addressed to one person. Pass `pubkey`, `request` (`approve`, `decide` or `question`) and `content`, then either `room` for a kind 9 message in that room or `root`, `root_kind` and `root_pubkey` for a kind 1111 comment under an issue, pull request or other event. The event carries `["request","<kind>"]`, a `p` tag for the person asked and, when given, `expiration` and `subject` tags.
65diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
66index 5ae16c5..ae8ace7 100644
67--- a/internal/daemon/collaboration_browse.go
68+++ b/internal/daemon/collaboration_browse.go
69@@ -27,17 +27,17 @@ type collaborationItem struct {
70 }
71
72 type collaborationDetail struct {
73- Repository map[string]any `json:"repository"`
74- Root event.Event `json:"root"`
75- Item collaborationItem `json:"item"`
76- Replies []event.Event `json:"replies"`
77- Updates []event.Event `json:"updates"`
78- Statuses []event.Event `json:"statuses"`
79- Diff string `json:"diff,omitempty"`
80- CanStatus bool `json:"can_status"`
81- DiffUnavailable string `json:"diff_unavailable,omitempty"`
82- DiffTruncated bool `json:"diff_truncated,omitempty"`
83- ReplyCursor string `json:"reply_cursor,omitempty"`
84+ Repository map[string]any `json:"repository"`
85+ Root event.Event `json:"root"`
86+ Item collaborationItem `json:"item"`
87+ Replies []collaborationReply `json:"replies"`
88+ Updates []event.Event `json:"updates"`
89+ Statuses []event.Event `json:"statuses"`
90+ Diff string `json:"diff,omitempty"`
91+ CanStatus bool `json:"can_status"`
92+ DiffUnavailable string `json:"diff_unavailable,omitempty"`
93+ DiffTruncated bool `json:"diff_truncated,omitempty"`
94+ ReplyCursor string `json:"reply_cursor,omitempty"`
95 }
96
97 func (t *Tenant) collaborationRepository(ctx context.Context, repo gitrelay.Repository) map[string]any {
98@@ -196,12 +196,11 @@ func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q
99 if err != nil {
100 return nil, err
101 }
102- detail.Replies = replies
103- if len(detail.Replies) > replyLimit {
104- detail.Replies = detail.Replies[:replyLimit]
105- last := detail.Replies[replyLimit-1]
106- detail.ReplyCursor = collaborationCursor(collaborationItemFrom(last))
107+ if len(replies) > replyLimit {
108+ replies = replies[:replyLimit]
109+ detail.ReplyCursor = collaborationCursor(collaborationItemFrom(replies[replyLimit-1]))
110 }
111+ detail.Replies = collaborationReplies(replies)
112 if kind == event.KIND_GIT_PR {
113 detail.Updates, err = t.Query(ctx, []event.Filter{{Authors: []string{root.PubKey}, Kinds: []int{1619}, Tags: map[string][]string{"E": {root.ID}, "P": {root.PubKey}}, Limit: intPtr(100)}}, browseSession(t, actor))
114 if err != nil {
115diff --git a/internal/daemon/collaboration_review.go b/internal/daemon/collaboration_review.go
116new file mode 100644
117index 0000000..3a3d8be
118--- /dev/null
119+++ b/internal/daemon/collaboration_review.go
120@@ -0,0 +1,93 @@
121+package daemon
122+
123+import (
124+ "errors"
125+ "strconv"
126+ "strings"
127+
128+ "github.com/FelineStateMachine/tinyrelay/internal/event"
129+)
130+
131+// Review comments. A kind 1111 comment under a pull request or patch may name
132+// one line of the diff with ["file", "<path>"] and ["line", "<n>", "<side>"],
133+// where the side is "old" for the base or "new" for the proposed version. The
134+// relay validates the shape whenever the tags are present and browse results
135+// carry the anchor as plain fields so clients can place the comment.
136+
137+const reviewFileMax = 4096
138+
139+// reviewAnchor is the diff line a comment names. A zero value means the
140+// comment is not anchored.
141+type reviewAnchor struct {
142+ File string `json:"file,omitempty"`
143+ Line int `json:"line,omitempty"`
144+ Side string `json:"side,omitempty"`
145+}
146+
147+// collaborationReply is one comment in a thread with its anchor, when it has
148+// one, alongside the event fields.
149+type collaborationReply struct {
150+ event.Event
151+ reviewAnchor
152+}
153+
154+func collaborationReplies(rows []event.Event) []collaborationReply {
155+ out := make([]collaborationReply, 0, len(rows))
156+ for _, row := range rows {
157+ anchor, _, _ := reviewAnchorOf(row)
158+ out = append(out, collaborationReply{Event: row, reviewAnchor: anchor})
159+ }
160+ return out
161+}
162+
163+// reviewAnchorOf reads a comment's anchor. It reports whether the tags are
164+// present and returns a blocked reason when they are malformed.
165+func reviewAnchorOf(e event.Event) (reviewAnchor, bool, error) {
166+ if e.Kind != 1111 {
167+ return reviewAnchor{}, false, nil
168+ }
169+ var files, lines [][]string
170+ for _, tag := range e.Tags {
171+ if len(tag) == 0 {
172+ continue
173+ }
174+ switch tag[0] {
175+ case "file":
176+ files = append(files, tag)
177+ case "line":
178+ lines = append(lines, tag)
179+ }
180+ }
181+ if len(files) == 0 && len(lines) == 0 {
182+ return reviewAnchor{}, false, nil
183+ }
184+ if len(files) != 1 || len(lines) != 1 {
185+ return reviewAnchor{}, true, errors.New("blocked: a review comment carries one file tag and one line tag")
186+ }
187+ file := files[0]
188+ if len(file) < 2 || strings.TrimSpace(file[1]) == "" || len(file[1]) > reviewFileMax || strings.ContainsAny(file[1], "\n\r\x00") {
189+ return reviewAnchor{}, true, errors.New("blocked: a review comment file tag needs a path")
190+ }
191+ line := lines[0]
192+ if len(line) < 3 {
193+ return reviewAnchor{}, true, errors.New("blocked: a review comment line tag needs a line number and a side, old or new")
194+ }
195+ n, err := strconv.Atoi(line[1])
196+ if err != nil || n < 1 || strconv.Itoa(n) != line[1] {
197+ return reviewAnchor{}, true, errors.New("blocked: a review comment line must be a positive integer")
198+ }
199+ if line[2] != "old" && line[2] != "new" {
200+ return reviewAnchor{}, true, errors.New("blocked: a review comment side must be old or new")
201+ }
202+ if kind := event.Tag(e, "K"); kind != "1617" && kind != "1618" {
203+ return reviewAnchor{}, true, errors.New("blocked: a review comment anchors to a pull request or patch")
204+ }
205+ return reviewAnchor{File: file[1], Line: n, Side: line[2]}, true, nil
206+}
207+
208+// validateReviewAnchor is the admission check for comments that carry file
209+// and line tags. Comments without them pass.
210+func validateReviewAnchor(e event.Event) error {
211+ _, _, err := reviewAnchorOf(e)
212+ return err
213+}
214diff --git a/internal/daemon/collaboration_review_test.go b/internal/daemon/collaboration_review_test.go
215new file mode 100644
216index 0000000..940f55a
217--- /dev/null
218+++ b/internal/daemon/collaboration_review_test.go
219@@ -0,0 +1,113 @@
220+package daemon
221+
222+import (
223+ "context"
224+ "encoding/json"
225+ "strings"
226+ "testing"
227+ "time"
228+
229+ "github.com/FelineStateMachine/tinyrelay/internal/event"
230+ "github.com/FelineStateMachine/tinyrelay/internal/replication"
231+)
232+
233+func TestReviewAnchorShape(t *testing.T) {
234+ base := [][]string{{"a", "30617:" + strings.Repeat("a", 64) + ":test"}, {"E", strings.Repeat("b", 64), "", strings.Repeat("a", 64)}, {"K", "1618"}, {"P", strings.Repeat("a", 64)}}
235+ comment := func(kind string, extra ...[]string) event.Event {
236+ tags := append([][]string(nil), base...)
237+ tags[2] = []string{"K", kind}
238+ return event.Event{Kind: 1111, Tags: append(tags, extra...)}
239+ }
240+ anchor, present, err := reviewAnchorOf(comment("1618", []string{"file", "src/main.go"}, []string{"line", "12", "new"}))
241+ if err != nil || !present || anchor != (reviewAnchor{File: "src/main.go", Line: 12, Side: "new"}) {
242+ t.Fatalf("valid anchor: %+v %v %v", anchor, present, err)
243+ }
244+ if _, present, err := reviewAnchorOf(comment("1621")); err != nil || present {
245+ t.Fatalf("plain comment: %v %v", present, err)
246+ }
247+ cases := map[string][][]string{
248+ "missing line": {{"file", "a"}},
249+ "missing file": {{"line", "1", "new"}},
250+ "two files": {{"file", "a"}, {"file", "b"}, {"line", "1", "new"}},
251+ "empty path": {{"file", " "}, {"line", "1", "new"}},
252+ "zero line": {{"file", "a"}, {"line", "0", "new"}},
253+ "padded line": {{"file", "a"}, {"line", "01", "new"}},
254+ "word line": {{"file", "a"}, {"line", "one", "new"}},
255+ "no side": {{"file", "a"}, {"line", "1"}},
256+ "unknown side": {{"file", "a"}, {"line", "1", "left"}},
257+ "newline in path": {{"file", "a\nb"}, {"line", "1", "new"}},
258+ }
259+ for name, extra := range cases {
260+ if err := validateReviewAnchor(comment("1618", extra...)); err == nil || !strings.HasPrefix(err.Error(), "blocked: ") {
261+ t.Fatalf("%s: %v", name, err)
262+ }
263+ }
264+ if err := validateReviewAnchor(comment("1621", []string{"file", "a"}, []string{"line", "1", "new"})); err == nil || !strings.Contains(err.Error(), "pull request or patch") {
265+ t.Fatalf("anchor under an issue: %v", err)
266+ }
267+ if err := validateReviewAnchor(comment("1617", []string{"file", "a"}, []string{"line", "3", "old"})); err != nil {
268+ t.Fatalf("anchor under a patch: %v", err)
269+ }
270+}
271+
272+func TestReviewCommentsAreValidatedAndBrowsedWithTheirAnchor(t *testing.T) {
273+ ctx := context.Background()
274+ _, tenant := testTenant(t)
275+ p := tenant.Policy()
276+ p.Features.Grasp = true
277+ if err := tenant.applyPolicy(ctx, p); err != nil {
278+ t.Fatal(err)
279+ }
280+ owner, _ := event.PublicKey(testOwnerSecret)
281+ now := time.Now().Unix()
282+ agentTestRepo(t, tenant, "review")
283+ coordinate := "30617:" + owner + ":review"
284+ pull := signedEvent(t, testOwnerSecret, event.KIND_GIT_PR, now, [][]string{{"a", coordinate}, {"p", owner}, {"subject", "Change"}, {"c", strings.Repeat("1", 40)}, {"clone", "https://example.test/review.git"}}, "Why")
285+ if err := publishAs(t, tenant, pull); err != nil {
286+ t.Fatalf("pull request rejected: %v", err)
287+ }
288+ comment := func(createdAt int64, extra ...[]string) event.Event {
289+ tags := append([][]string{{"a", coordinate}, {"E", pull.ID, "", owner}, {"K", "1618"}, {"P", owner}, {"e", pull.ID, "", owner}, {"k", "1618"}, {"p", owner}}, extra...)
290+ return signedEvent(t, testOwnerSecret, 1111, createdAt, tags, "Review note")
291+ }
292+ if err := publishAs(t, tenant, comment(now+1, []string{"file", "README"}, []string{"line", "2", "left"})); err == nil || !strings.HasPrefix(err.Error(), "blocked: a review comment") {
293+ t.Fatalf("malformed anchor (bad side): %v", err)
294+ }
295+ if err := publishAs(t, tenant, comment(now+2, []string{"file", "README"}, []string{"line", "2"})); err == nil || !strings.HasPrefix(err.Error(), "blocked: a review comment") {
296+ t.Fatalf("malformed anchor (no side): %v", err)
297+ }
298+ if err := tenant.ingest(ctx, comment(now+3, []string{"file", ""}, []string{"line", "2", "new"}), replication.OriginImport); err == nil || !strings.HasPrefix(err.Error(), "blocked: a review comment") {
299+ t.Fatalf("malformed anchor over import: %v", err)
300+ }
301+ anchored := comment(now+4, []string{"file", "README"}, []string{"line", "2", "new"})
302+ if err := publishAs(t, tenant, anchored); err != nil {
303+ t.Fatalf("anchored comment rejected: %v", err)
304+ }
305+ plain := comment(now + 5)
306+ if err := publishAs(t, tenant, plain); err != nil {
307+ t.Fatalf("plain comment rejected: %v", err)
308+ }
309+ raw, _ := json.Marshal(map[string]any{"owner": owner, "repo": "review", "event": pull.ID})
310+ result, err := tenant.Execute(ctx, "", "browsepull", []json.RawMessage{raw})
311+ if err != nil {
312+ t.Fatalf("browsepull: %v", err)
313+ }
314+ encoded, _ := json.Marshal(result)
315+ var detail struct {
316+ Replies []struct {
317+ ID string `json:"id"`
318+ File string `json:"file"`
319+ Line int `json:"line"`
320+ Side string `json:"side"`
321+ } `json:"replies"`
322+ }
323+ if err := json.Unmarshal(encoded, &detail); err != nil || len(detail.Replies) != 2 {
324+ t.Fatalf("replies = %s %v", encoded, err)
325+ }
326+ if detail.Replies[0].ID != plain.ID || detail.Replies[0].File != "" || detail.Replies[0].Line != 0 || detail.Replies[0].Side != "" {
327+ t.Fatalf("plain reply carried an anchor: %+v", detail.Replies[0])
328+ }
329+ if detail.Replies[1].ID != anchored.ID || detail.Replies[1].File != "README" || detail.Replies[1].Line != 2 || detail.Replies[1].Side != "new" {
330+ t.Fatalf("anchored reply = %+v", detail.Replies[1])
331+ }
332+}
333diff --git a/internal/daemon/mcp_test.go b/internal/daemon/mcp_test.go
334index 9d5e799..87a8dee 100644
335--- a/internal/daemon/mcp_test.go
336+++ b/internal/daemon/mcp_test.go
337@@ -178,6 +178,23 @@ func TestMCPWriteToolsBuildValidateAndPublish(t *testing.T) {
338 if w.Code != http.StatusOK || isError || unsigned["kind"] != float64(1111) || string(tags) != `[["a","30617:`+owner+`:tiny"],["E","`+rootID+`","","`+owner+`"],["K","1621"],["P","`+owner+`"],["e","`+rootID+`","","`+owner+`"],["k","1621"],["p","`+owner+`"]]` {
339 t.Fatalf("comment template: %d %s", w.Code, w.Body.String())
340 }
341+ w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_kind": 1621, "root_pubkey": owner, "content": "Here.", "file": "README", "line": 2, "side": "new"}, sign: true}.do(t, app, "/mcp")
342+ result, isError = mcpToolResult(t, response)
343+ if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "pull request or patch") {
344+ t.Fatalf("comment anchor under an issue: %d %s", w.Code, w.Body.String())
345+ }
346+ w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": strings.Repeat("d", 64), "root_kind": 1618, "root_pubkey": owner, "content": "Here.", "file": "README", "line": 2, "side": "new"}, sign: true}.do(t, app, "/mcp")
347+ result, isError = mcpToolResult(t, response)
348+ unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
349+ tags, _ = json.Marshal(unsigned["tags"])
350+ if w.Code != http.StatusOK || isError || !strings.HasSuffix(string(tags), `["file","README"],["line","2","new"]]`) {
351+ t.Fatalf("comment anchor template: %d %s", w.Code, w.Body.String())
352+ }
353+ w, response = mcpCall{method: "tools/call", name: "comment", arguments: map[string]any{"event": sign(1111, [][]string{{"a", "30617:" + owner + ":tiny"}, {"E", strings.Repeat("d", 64), "", owner}, {"K", "1618"}, {"P", owner}, {"e", strings.Repeat("d", 64), "", owner}, {"k", "1618"}, {"p", owner}, {"file", "README"}, {"line", "0", "new"}}, "Here.")}, sign: true}.do(t, app, "/mcp")
354+ result, isError = mcpToolResult(t, response)
355+ if w.Code != http.StatusOK || !isError || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "positive integer") {
356+ t.Fatalf("comment anchor check: %d %s", w.Code, w.Body.String())
357+ }
358 w, response = mcpCall{method: "tools/call", name: "set_status", arguments: map[string]any{"owner": owner, "repo": "tiny", "root": rootID, "root_pubkey": owner, "status": "resolved"}, sign: true}.do(t, app, "/mcp")
359 result, isError = mcpToolResult(t, response)
360 unsigned, _ = result["structuredContent"].(map[string]any)["unsigned"].(map[string]any)
361diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
362index ad18c1d..ab37be5 100644
363--- a/internal/daemon/mcp_tools.go
364+++ b/internal/daemon/mcp_tools.go
365@@ -60,7 +60,7 @@ var (
366 const (
367 mcpIssueShape = `Expected a signed kind 1621 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"] and optional ["t","<label>"] tags, with the Markdown description in content.`
368 mcpPullShape = `Expected a signed kind 1618 event with tags ["a","30617:<owner>:<repo>"], ["p","<owner>"], ["subject","<title>"], ["c","<40-hex commit>"], ["clone","<http or https URL>"], optional ["merge-base","<40-hex commit>"] and optional ["t","<label>"] tags, with the description in content.`
369- mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment.`
370+ mcpCommentShape = `Expected a signed kind 1111 event with NIP-22 tags ["a","30617:<owner>:<repo>"], ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<parent id>","","<parent pubkey>"], ["k","<parent kind>"], ["p","<parent pubkey>"], with the comment in content. The parent is the root for a top-level comment. A review comment under a pull request or patch may add ["file","<path>"] and ["line","<n>","old" or "new"] to name one diff line.`
371 mcpStatusShape = `Expected a signed event of kind 1630 (open), 1631 (resolved or merged), 1632 (closed) or 1633 (draft) with tags ["a","30617:<owner>:<repo>"], ["e","<root id>","","root"] and ["p","<root pubkey>"].`
372 mcpMessageShape = `Expected a signed kind 9 event with tag ["h","<room id>"] and optional ["p","<pubkey>"] mentions, with the message in content.`
373 mcpThreadShape = `Expected a signed kind 11 event with tag ["h","<room id>"] and optional ["subject","<title>"], with the opening post in content.`
374@@ -172,7 +172,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
375 add("publish_event", "Publish a signed Nostr event to this relay. The relay's access rules apply as they do for POST /events.", mcp.Object(map[string]any{"event": mcpEvent}, "event"), mcpPublishes, t.mcpWrite(nil, nil, "Expected a signed Nostr event."))
376 add("create_issue", "Open an issue on a hosted repository. Pass owner, repo, title and content to receive the unsigned kind 1621 event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "title": mcpText, "content": mcpText, "labels": map[string]any{"type": "array", "items": mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildIssue, mcpCheckIssue, mcpIssueShape))
377 add("create_pull_request", "Open a pull request on a hosted repository. Pass owner, repo, title, commit and clone (plus optional content, merge_base and labels) to receive the unsigned kind 1618 event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "title": mcpText, "content": mcpText, "commit": mcpCommit, "clone": map[string]any{"type": "string", "description": "HTTP or HTTPS clone URL without credentials."}, "merge_base": mcpCommit, "labels": map[string]any{"type": "array", "items": mcpText}}), mcpPublishes, t.mcpWrite(mcpBuildPull, mcpCheckPull, mcpPullShape))
378- add("comment", "Reply to an issue, pull request or comment with a NIP-22 kind 1111 event. Pass owner, repo, root, root_kind, root_pubkey and content (plus parent, parent_kind and parent_pubkey to answer a comment) to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_kind": mcpRootKind, "root_pubkey": mcpPubKey, "parent": mcpHash, "parent_kind": map[string]any{"type": "integer", "enum": []int{1111, 1617, 1618, 1621}}, "parent_pubkey": mcpPubKey, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildComment, mcpCheckComment, mcpCommentShape))
379+ add("comment", "Reply to an issue, pull request or comment with a NIP-22 kind 1111 event. Pass owner, repo, root, root_kind, root_pubkey and content (plus parent, parent_kind and parent_pubkey to answer a comment) to receive the unsigned event, sign it, then call again with the signed event. To review one line of a pull request or patch diff, add file, line and side (old for the base, new for the change).", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_kind": mcpRootKind, "root_pubkey": mcpPubKey, "parent": mcpHash, "parent_kind": map[string]any{"type": "integer", "enum": []int{1111, 1617, 1618, 1621}}, "parent_pubkey": mcpPubKey, "content": mcpText, "file": map[string]any{"type": "string", "minLength": 1, "description": "Path of the diff file the comment is about."}, "line": map[string]any{"type": "integer", "minimum": 1, "description": "Line number on the chosen side of the diff."}, "side": map[string]any{"type": "string", "enum": []string{"old", "new"}}}), mcpPublishes, t.mcpWrite(mcpBuildComment, mcpCheckComment, mcpCommentShape))
380 add("set_status", "Change the status of an issue or pull request. The author, repository owner and maintainers may do this. Pass owner, repo, root, root_pubkey and status to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "owner": mcpPubKey, "repo": mcpID, "root": mcpHash, "root_pubkey": mcpPubKey, "status": map[string]any{"type": "string", "enum": []string{"open", "resolved", "merged", "closed", "draft"}}}), mcpPublishes, t.mcpWrite(mcpBuildStatus, mcpCheckStatus, mcpStatusShape))
381 add("post_message", "Post a kind 9 chat message in a room. Pass room and content (plus mentions, a list of public keys) to receive the unsigned event, sign it, then call again with the signed event. Posting in an open room joins it.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "content": mcpText, "mentions": map[string]any{"type": "array", "items": mcpPubKey}}), mcpPublishes, t.mcpWrite(mcpBuildMessage, mcpCheckMessage, mcpMessageShape))
382 add("start_thread", "Start a kind 11 thread in a room. Pass room and content (plus an optional title) to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "title": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildThread, mcpCheckThread, mcpThreadShape))
383@@ -338,6 +338,16 @@ func mcpBuildComment(call mcp.Call) (mcpUnsigned, error) {
384 return mcpUnsigned{}, errors.New("parent needs parent_kind and parent_pubkey")
385 }
386 tags = append(tags, []string{"E", root, "", rootPubKey}, []string{"K", strconv.Itoa(rootKind)}, []string{"P", rootPubKey}, []string{"e", parent, "", parentPubKey}, []string{"k", strconv.Itoa(parentKind)}, []string{"p", parentPubKey})
387+ file, line, side := call.String("file"), call.Int("line"), call.String("side")
388+ if file != "" || line != 0 || side != "" {
389+ if strings.TrimSpace(file) == "" || line < 1 || (side != "old" && side != "new") {
390+ return mcpUnsigned{}, errors.New("file, line (a positive integer) and side (old or new) go together")
391+ }
392+ if rootKind != event.KIND_GIT_PR && rootKind != event.KIND_GIT_PATCH {
393+ return mcpUnsigned{}, errors.New("file and line anchor a comment to a pull request or patch diff")
394+ }
395+ tags = append(tags, []string{"file", file}, []string{"line", strconv.Itoa(line), side})
396+ }
397 return mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: content}, nil
398 }
399
400@@ -420,6 +430,9 @@ func mcpCheckComment(e event.Event) error {
401 if strings.TrimSpace(e.Content) == "" {
402 return errors.New("content must not be empty")
403 }
404+ if err := validateReviewAnchor(e); err != nil {
405+ return errors.New(strings.TrimPrefix(err.Error(), "blocked: "))
406+ }
407 return nil
408 }
409
410diff --git a/internal/daemon/services.go b/internal/daemon/services.go
411index f8ed719..b3b4520 100644
412--- a/internal/daemon/services.go
413+++ b/internal/daemon/services.go
414@@ -631,6 +631,9 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli
415 if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {
416 return err
417 }
418+ if err := validateReviewAnchor(e); err != nil {
419+ return err
420+ }
421 if t.sites != nil {
422 if err := sites.ValidateManifest(e); err != nil {
423 return err
424diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go
425index 979b8ed..d3fcadc 100644
426--- a/internal/daemon/tenant.go
427+++ b/internal/daemon/tenant.go
428@@ -178,6 +178,9 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s
429 if err := t.validatePrivateRepositoryPlacement(ctx, e); err != nil {
430 return "", err
431 }
432+ if err := validateReviewAnchor(e); err != nil {
433+ return "", err
434+ }
435 if err := t.validatePushRegistration(ctx, e, s); err != nil {
436 return "", err
437 }
438diff --git a/internal/webui/collaboration.html b/internal/webui/collaboration.html
439index ff09316..3530c47 100644
440--- a/internal/webui/collaboration.html
441+++ b/internal/webui/collaboration.html
442@@ -18,20 +18,23 @@
443 <label>Description <textarea name="content" required></textarea></label><button>Sign and publish</button>
444 </nostr-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript></details>{{else}}<p><a href="/signin">Sign in</a> to start a conversation.</p>{{end}}
445 </section>
446-{{else}}{{$item := asMap (index $data "item")}}<section id="collaboration-detail">
447+{{else}}{{$item := asMap (index $data "item")}}{{$diff := reviewDiff .Event .Query .Actor}}{{$anchor := reviewAnchor .Query}}<section id="collaboration-detail">
448 <p><a href="{{if $issues}}{{repoURL .Query "issues" ""}}{{else}}{{repoURL .Query "prs" ""}}{{end}}">{{if $issues}}All issues{{else}}All pull requests{{end}}</a></p>
449 <h1>{{index $item "title"}}</h1>
450 <p>{{index $item "status"}} by {{template "nostrKey" (str (index $item "author"))}} <time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></p>
451 {{with collabLabels $item}}<p>Labels: {{join . ", "}}</p>{{end}}
452 <article id="collaboration-body">{{markdown (str (index $item "content"))}}</article>
453-{{if not $issues}}<h2>Changes</h2>{{if index $data "diff_unavailable"}}<p>{{index $data "diff_unavailable"}}. The diff will be available after the required commits have been synchronized.</p>{{else}}{{diffHTML .Event}}{{if index $data "diff_truncated"}}<p>This diff is truncated. Clone the repository to review the complete change.</p>{{end}}{{end}}{{end}}
454+{{if not $issues}}<h2>Changes</h2>{{if index $data "diff_unavailable"}}<p>{{index $data "diff_unavailable"}}. The diff will be available after the required commits have been synchronized.</p>{{else}}<diff-view><table><tbody>
455+{{range $line := $diff.Lines}}<tr id="D{{$line.Number}}"><td><a href="#D{{$line.Number}}">{{$line.Number}}</a></td><td><code>{{$line.HTML}}</code>{{with $line.CommentURL}} <small><a href="{{.}}">comment</a></small>{{end}}</td></tr>
456+{{range $c := $line.Comments}}{{$r := asMap $c}}<tr><td></td><td>{{template "nostrEvent" $c}}{{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</td></tr>
457+{{end}}{{end}}</tbody>{{if $diff.Truncated}}<tfoot><tr><td colspan="2">Diff preview truncated (256 KiB or 10,000 lines).</td></tr></tfoot>{{end}}</table></diff-view>{{if index $data "diff_truncated"}}<p>This diff is truncated. Clone the repository to review the complete change.</p>{{end}}{{end}}{{end}}
458 <h2>Conversation</h2>
459-{{$replies := collabReplies .Event}}{{if $replies}}<ol id="events">
460-{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "nostrEvent" $reply}}
461+{{$replies := $diff.Thread}}{{if $replies}}<ol id="events">
462+{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "nostrEvent" $reply}}{{with reviewLabel $reply}}<p><small>on {{.}}</small></p>{{end}}
463 {{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</li>
464 {{end}}</ol>{{else}}<p>No replies yet.</p>{{end}}
465 {{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}
466-{{if .Actor}}<h2>Reply</h2><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Comment <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose>
467+{{if .Actor}}<h2>Reply</h2>{{with $anchor}}<p id="reply-anchor">Commenting on <code>{{.File}}</code> line {{.Line}} ({{.Side}} side). <a href="{{repoURL $.Query "pr" (str (index $item "id"))}}">Comment on the whole change instead</a></p>{{end}}<nostr-compose id="reply-compose" kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}">{{with $anchor}}<input type="hidden" name="file" value="{{.File}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}<label>Comment <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose>
468 {{if index $data "can_status"}}<details id="status-controls"><summary>Change status</summary><nostr-compose kind="status" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Status <select name="status"><option value="1630">Reopen</option><option value="1631">{{if $issues}}Resolve{{else}}Mark merged{{end}}</option><option value="1632">Close</option><option value="1633">Draft</option></select></label><label>Comment <textarea name="content"></textarea></label><button>Sign and update</button></nostr-compose></details>{{end}}
469 <noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="/signin">Sign in</a> to reply.</p>{{end}}
470 </section>{{end}}{{end}}
471diff --git a/internal/webui/components.js b/internal/webui/components.js
472index 1857ab6..14e43dc 100644
473--- a/internal/webui/components.js
474+++ b/internal/webui/components.js
475@@ -27,9 +27,11 @@
476 // <file-tools hash="…" type="…" size="…">
477 // Shares one stored file: copies its Blossom URI or share link, decrypts
478 // a share link fragment and sends a random-key file as a NIP-17 message.
479-// <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
480+// <nostr-compose kind="…" [coordinate="30617:…"] [root="…" root-pubkey="…" root-kind="…"] [parent="…" …] [file="…" line="…" side="old|new"]>
481 // Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
482 // posts it to the relay. A reply outside a repository omits the coordinate.
483+// A reply under a pull request or patch may name one diff line through
484+// the file, line and side attributes or hidden inputs of those names.
485 // <wiki-compose name="…" [author="…" coordinate="30818:…" event="…"]>
486 // Signs a NIP-54 article and posts it. Someone other than the author
487 // publishes a fork of the version in view; a "propose" button also signs
488@@ -349,6 +351,15 @@
489 // The relay receives the signed event directly, so the same control works
490 // with NIP-07 and the existing NIP-46 bridge.
491 class NostrCompose extends FormElement {
492+ connectedCallback() {
493+ super.connectedCallback();
494+ // A comment link on a diff line opens the page at this compose.
495+ if (this.id && globalThis.location?.hash === "#" + this.id) {
496+ this.scrollIntoView?.({block: "center"});
497+ this.form?.elements.content?.focus?.();
498+ }
499+ }
500+
501 async submit(form) {
502 if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
503 const value = name => form.elements[name]?.value.trim() || "";
504@@ -370,6 +381,13 @@
505 const parentKind = this.getAttribute("parent-kind") || rootKind;
506 if (!isHex64(parent) || !isHex64(parentPubkey) || !["1111", rootKind].includes(parentKind)) throw Error("The reply address is invalid.");
507 tags.push(["E", root, "", pubkey], ["K", rootKind], ["P", pubkey], ["e", parent, "", parentPubkey], ["k", parentKind], ["p", parentPubkey]);
508+ const anchor = name => value(name) || this.getAttribute(name) || "";
509+ const file = anchor("file"), line = anchor("line"), side = anchor("side");
510+ if (file || line || side) {
511+ if (!["1617", "1618"].includes(rootKind)) throw Error("Line comments belong to a pull request or patch.");
512+ if (!file || !/^[1-9]\d*$/.test(line) || !["old", "new"].includes(side)) throw Error("The line reference is incomplete: it needs a file, a line number and a side.");
513+ tags.push(["file", file], ["line", line, side]);
514+ }
515 } else {
516 tags.push(["e", root, "", "root"], ["p", pubkey]);
517 if (coordinate.split(":")[1] !== pubkey) tags.push(["p", coordinate.split(":")[1]]);
518diff --git a/internal/webui/review.go b/internal/webui/review.go
519new file mode 100644
520index 0000000..cb5e300
521--- /dev/null
522+++ b/internal/webui/review.go
523@@ -0,0 +1,240 @@
524+package webui
525+
526+import (
527+ "html/template"
528+ "net/url"
529+ "strconv"
530+ "strings"
531+)
532+
533+// Review rendering for the pull request page. The diff is split into rows
534+// that know their file and their line number on each side, so a comment
535+// carrying file and line tags can be placed under the row it names and every
536+// row can offer a link that opens the compose with that line preset.
537+
538+// diffLine is one row of a unified diff. Old and New are the line numbers on
539+// each side of the hunk the row belongs to; a row outside a hunk, or on the
540+// side it does not touch, has 0 there.
541+type diffLine struct {
542+ Number int
543+ Text string
544+ Wrapper string
545+ File string
546+ Old int
547+ New int
548+}
549+
550+// diffLines parses rendered diff text into rows. Header lines keep the ins
551+// and del markers of the plain renderer; only hunk bodies are numbered.
552+func diffLines(lines []string) []diffLine {
553+ out := make([]diffLine, 0, len(lines))
554+ file := ""
555+ old, next := 0, 0
556+ inHunk := false
557+ for index, line := range lines {
558+ row := diffLine{Number: index + 1, Text: line}
559+ switch {
560+ case strings.HasPrefix(line, "diff --git "):
561+ inHunk, file = false, ""
562+ case strings.HasPrefix(line, "@@"):
563+ inHunk = true
564+ old, next = hunkStarts(line)
565+ row.Wrapper = "b"
566+ case !inHunk && strings.HasPrefix(line, "+++ "):
567+ row.Wrapper = "ins"
568+ if path := diffPath(line[4:], "b/"); path != "" {
569+ file = path
570+ }
571+ case !inHunk && strings.HasPrefix(line, "--- "):
572+ row.Wrapper = "del"
573+ if path := diffPath(line[4:], "a/"); path != "" && file == "" {
574+ file = path
575+ }
576+ case inHunk && strings.HasPrefix(line, "+"):
577+ row.Wrapper, row.New = "ins", next
578+ next++
579+ case inHunk && strings.HasPrefix(line, "-"):
580+ row.Wrapper, row.Old = "del", old
581+ old++
582+ case inHunk && strings.HasPrefix(line, "\\"):
583+ // "\ No newline at end of file" belongs to no line.
584+ case inHunk:
585+ row.Old, row.New = old, next
586+ old++
587+ next++
588+ case strings.HasPrefix(line, "+"):
589+ row.Wrapper = "ins"
590+ case strings.HasPrefix(line, "-"):
591+ row.Wrapper = "del"
592+ }
593+ row.File = file
594+ out = append(out, row)
595+ }
596+ return out
597+}
598+
599+// hunkStarts reads the old and new starting line numbers of a hunk header
600+// such as "@@ -12,7 +12,8 @@".
601+func hunkStarts(header string) (int, int) {
602+ fields := strings.Fields(header)
603+ if len(fields) < 3 {
604+ return 0, 0
605+ }
606+ start := func(value, prefix string) int {
607+ value = strings.TrimPrefix(value, prefix)
608+ if comma := strings.IndexByte(value, ','); comma >= 0 {
609+ value = value[:comma]
610+ }
611+ n, err := strconv.Atoi(value)
612+ if err != nil || n < 0 {
613+ return 0
614+ }
615+ return n
616+ }
617+ return start(fields[1], "-"), start(fields[2], "+")
618+}
619+
620+// diffPath reads the path from a "--- a/path" or "+++ b/path" header. Git
621+// quotes paths with unusual characters; /dev/null means no file on that side.
622+func diffPath(value, prefix string) string {
623+ value = strings.TrimSpace(value)
624+ if tab := strings.IndexByte(value, '\t'); tab >= 0 {
625+ value = value[:tab]
626+ }
627+ if strings.HasPrefix(value, `"`) {
628+ if unquoted, err := strconv.Unquote(value); err == nil {
629+ value = unquoted
630+ }
631+ }
632+ if value == "/dev/null" {
633+ return ""
634+ }
635+ return strings.TrimPrefix(value, prefix)
636+}
637+
638+// reviewLine is one diff row ready for the template: its escaped markup,
639+// the link that starts a comment on it and the comments already anchored to
640+// it, oldest first.
641+type reviewLine struct {
642+ diffLine
643+ HTML template.HTML
644+ CommentURL string
645+ Comments []any
646+}
647+
648+// reviewDiff is the pull request diff with its anchored comments placed.
649+// Thread holds the replies that are not shown inline, so the conversation
650+// below the diff lists each comment once.
651+type reviewDiff struct {
652+ Available bool
653+ Truncated bool
654+ Lines []reviewLine
655+ Thread []any
656+}
657+
658+// reviewDiffView builds the diff for a pull request page. A signed-in
659+// viewer gets a comment link on each numbered line. Anchored comments whose
660+// line is not in the diff stay in the thread with their anchor shown.
661+func reviewDiffView(value any, query url.Values, actor string) reviewDiff {
662+ m := valueMap(value)
663+ replies := collaborationReplies(value)
664+ view := reviewDiff{Thread: replies}
665+ diff, _ := m["diff"].(string)
666+ if diff == "" {
667+ return view
668+ }
669+ view.Available = true
670+ providedTruncated, _ := m["diff_truncated"].(bool)
671+ view.Truncated = providedTruncated || len(diff) > sourcePreviewLimit
672+ if view.Truncated {
673+ diff = boundedContent(diff)
674+ }
675+ lines, linesTruncated := previewLines(diff)
676+ view.Truncated = view.Truncated || linesTruncated
677+ rows := diffLines(lines)
678+ view.Lines = make([]reviewLine, 0, len(rows))
679+ index := make(map[string]int, len(rows))
680+ for _, row := range rows {
681+ var markup strings.Builder
682+ writeWrapped(&markup, row.Wrapper, row.Text)
683+ line := reviewLine{diffLine: row, HTML: template.HTML(markup.String())}
684+ if row.File != "" {
685+ if row.New > 0 {
686+ index[reviewKey(row.File, "new", row.New)] = len(view.Lines)
687+ }
688+ if row.Old > 0 {
689+ index[reviewKey(row.File, "old", row.Old)] = len(view.Lines)
690+ }
691+ if actor != "" && (row.New > 0 || row.Old > 0) {
692+ line.CommentURL = reviewCommentURL(query, row)
693+ }
694+ }
695+ view.Lines = append(view.Lines, line)
696+ }
697+ view.Thread = view.Thread[:0]
698+ placed := make(map[int][]any)
699+ for _, reply := range replies {
700+ r := valueMap(reply)
701+ file := plainString(r["file"])
702+ lineNumber := int(unixSeconds(r["line"]))
703+ side := plainString(r["side"])
704+ at, ok := index[reviewKey(file, side, lineNumber)]
705+ if file == "" || lineNumber < 1 || !ok {
706+ view.Thread = append(view.Thread, reply)
707+ continue
708+ }
709+ placed[at] = append(placed[at], reply)
710+ }
711+ for at, comments := range placed {
712+ // Replies arrive newest first; read a line's comments in order.
713+ for left, right := 0, len(comments)-1; left < right; left, right = left+1, right-1 {
714+ comments[left], comments[right] = comments[right], comments[left]
715+ }
716+ view.Lines[at].Comments = comments
717+ }
718+ return view
719+}
720+
721+func reviewKey(file, side string, line int) string {
722+ return side + ":" + strconv.Itoa(line) + ":" + file
723+}
724+
725+// reviewCommentURL opens the pull request page with the compose preset to
726+// one line. Added and context lines name the new side; removed lines the old.
727+func reviewCommentURL(query url.Values, row diffLine) string {
728+ id := query.Get("id")
729+ if id == "" {
730+ id = query.Get("path")
731+ }
732+ side, line := "new", row.New
733+ if line == 0 {
734+ side, line = "old", row.Old
735+ }
736+ values := url.Values{"file": {row.File}, "line": {strconv.Itoa(line)}, "side": {side}}
737+ return repoURL(query, "pr", id) + "&" + values.Encode() + "#reply-compose"
738+}
739+
740+// reviewAnchorQuery reads the line a compose should be preset to from the
741+// page query. It returns nil unless every part is valid.
742+func reviewAnchorQuery(query url.Values) map[string]any {
743+ if repoView(query) != "pr" {
744+ return nil
745+ }
746+ file, side := query.Get("file"), query.Get("side")
747+ line, err := strconv.Atoi(query.Get("line"))
748+ if strings.TrimSpace(file) == "" || err != nil || line < 1 || (side != "old" && side != "new") {
749+ return nil
750+ }
751+ return map[string]any{"File": file, "Line": line, "Side": side}
752+}
753+
754+// reviewAnchorLabel describes a reply's anchor for the thread, or returns
755+// an empty string for an unanchored reply.
756+func reviewAnchorLabel(reply any) string {
757+ r := valueMap(reply)
758+ file := plainString(r["file"])
759+ if file == "" {
760+ return ""
761+ }
762+ return file + " line " + plainString(r["line"]) + " (" + plainString(r["side"]) + ")"
763+}
764diff --git a/internal/webui/review_test.go b/internal/webui/review_test.go
765new file mode 100644
766index 0000000..95fd881
767--- /dev/null
768+++ b/internal/webui/review_test.go
769@@ -0,0 +1,122 @@
770+package webui
771+
772+import (
773+ "context"
774+ "encoding/json"
775+ "net/http"
776+ "net/http/httptest"
777+ "net/url"
778+ "strings"
779+ "testing"
780+
781+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
782+)
783+
784+const reviewTestDiff = "diff --git a/README b/README\nindex 1111111..2222222 100644\n--- a/README\n+++ b/README\n@@ -1,3 +1,4 @@\n first\n-second\n+two\n+three\n fourth\ndiff --git a/docs/new.md b/docs/new.md\nnew file mode 100644\n--- /dev/null\n+++ b/docs/new.md\n@@ -0,0 +1,2 @@\n+# New\n+body\n\\ No newline at end of file\n"
785+
786+func TestDiffLinesNumberBothSidesAndTrackFiles(t *testing.T) {
787+ lines, _ := previewLines(reviewTestDiff)
788+ rows := diffLines(lines)
789+ want := map[int]diffLine{
790+ 3: {Wrapper: "del", File: "README"},
791+ 5: {Wrapper: "b", File: "README"},
792+ 6: {File: "README", Old: 1, New: 1},
793+ 7: {Wrapper: "del", File: "README", Old: 2},
794+ 8: {Wrapper: "ins", File: "README", New: 2},
795+ 9: {Wrapper: "ins", File: "README", New: 3},
796+ 10: {File: "README", Old: 3, New: 4},
797+ 14: {Wrapper: "ins", File: "docs/new.md"},
798+ 16: {Wrapper: "ins", File: "docs/new.md", New: 1},
799+ 17: {Wrapper: "ins", File: "docs/new.md", New: 2},
800+ 18: {File: "docs/new.md"},
801+ }
802+ for number, expected := range want {
803+ got := rows[number-1]
804+ expected.Number, expected.Text = number, got.Text
805+ if got != expected {
806+ t.Fatalf("row %d = %+v, want %+v", number, got, expected)
807+ }
808+ }
809+ if got := rows[11]; got.File != "" || got.Old != 0 || got.New != 0 {
810+ t.Fatalf("a new diff header kept the previous file: %+v", got)
811+ }
812+}
813+
814+type reviewBackend struct{ *fakeBackend }
815+
816+func (b *reviewBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
817+ b.call = method
818+ b.params = params
819+ owner := b.policy.Owner
820+ root := map[string]any{"id": strings.Repeat("b", 64), "title": "Rename second", "kind": 1618, "author": owner, "content": "Why", "status": "open"}
821+ reply := func(id, content string, extra ...[]string) map[string]any {
822+ tags := append([][]string{{"a", "30617:" + owner + ":test"}, {"E", strings.Repeat("b", 64), "", owner}, {"K", "1618"}, {"P", owner}}, extra...)
823+ return map[string]any{"id": id, "kind": 1111, "pubkey": strings.Repeat("c", 64), "created_at": 1700000000, "content": content, "tags": tags}
824+ }
825+ anchored := reply(strings.Repeat("1", 64), "Prefer a longer word.", []string{"file", "README"}, []string{"line", "2", "new"})
826+ anchored["file"], anchored["line"], anchored["side"] = "README", 2, "new"
827+ lost := reply(strings.Repeat("2", 64), "Outside the diff.", []string{"file", "missing.txt"}, []string{"line", "9", "old"})
828+ lost["file"], lost["line"], lost["side"] = "missing.txt", 9, "old"
829+ plain := reply(strings.Repeat("3", 64), "Looks good overall.")
830+ return map[string]any{"item": root, "replies": []any{plain, lost, anchored}, "can_status": true, "diff": reviewTestDiff, "repository": map[string]any{"owner": owner, "clone": []string{"https://git.example/test.git"}}}, nil
831+}
832+
833+func TestPullRequestPagePlacesAnchoredCommentsUnderDiffLines(t *testing.T) {
834+ for _, viewer := range []string{"", strings.Repeat("a", 64)} {
835+ backend := &reviewBackend{&fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
836+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return viewer, nil }})
837+ if err != nil {
838+ t.Fatal(err)
839+ }
840+ query := url.Values{"owner": {backend.policy.Owner}, "repo": {"test"}}
841+ link := repoURL(query, "pr", strings.Repeat("b", 64))
842+ w := httptest.NewRecorder()
843+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link, nil))
844+ body := w.Body.String()
845+ if w.Code != http.StatusOK || backend.call != "browsepull" {
846+ t.Fatalf("viewer %q: %d %s", viewer, w.Code, body)
847+ }
848+ if strings.Contains(body, "class=") {
849+ t.Fatalf("viewer %q: markup carries class attributes", viewer)
850+ }
851+ // The anchored comment sits in the row after new line 2 of README,
852+ // which is rendered diff row 8; the plain and unplaced comments stay
853+ // in the thread, the unplaced one with its anchor shown.
854+ row := strings.Index(body, `<tr id="D8">`)
855+ nextRow := strings.Index(body, `<tr id="D9">`)
856+ if row < 0 || nextRow < row || !strings.Contains(body[row:nextRow], "Prefer a longer word.") || !strings.Contains(body[row:nextRow], `<nostr-event id="event-`+strings.Repeat("1", 64)+`">`) {
857+ t.Fatalf("viewer %q: anchored comment not placed under its line: %s", viewer, body[row:nextRow])
858+ }
859+ thread := body[strings.Index(body, `<ol id="events">`):]
860+ if strings.Contains(thread, "Prefer a longer word.") || !strings.Contains(thread, "Looks good overall.") || !strings.Contains(thread, "on missing.txt line 9 (old)") {
861+ t.Fatalf("viewer %q: thread = %s", viewer, thread)
862+ }
863+ commentLink := strings.Contains(body, `file=README&amp;line=2&amp;side=new#reply-compose">comment</a>`)
864+ removedLink := strings.Contains(body, `file=README&amp;line=2&amp;side=old#reply-compose">comment</a>`)
865+ if viewer == "" {
866+ if commentLink || strings.Contains(body, "<nostr-compose") {
867+ t.Fatal("guest received comment links or a compose")
868+ }
869+ continue
870+ }
871+ if !commentLink || !removedLink || !strings.Contains(body, `&amp;file=docs%2Fnew.md&amp;line=1&amp;side=new#reply-compose">comment</a>`) {
872+ t.Fatalf("comment links missing: %s", body)
873+ }
874+ if strings.Contains(body, `<input type="hidden" name="file"`) {
875+ t.Fatal("compose was preset without a query anchor")
876+ }
877+ w = httptest.NewRecorder()
878+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"&file=README&line=2&side=new", nil))
879+ body = w.Body.String()
880+ for _, want := range []string{`<nostr-compose id="reply-compose" kind="1111"`, `<input type="hidden" name="file" value="README">`, `<input type="hidden" name="line" value="2">`, `<input type="hidden" name="side" value="new">`, `<p id="reply-anchor">Commenting on <code>README</code> line 2 (new side).`} {
881+ if !strings.Contains(body, want) {
882+ t.Fatalf("preset compose missing %s: %s", want, body)
883+ }
884+ }
885+ w = httptest.NewRecorder()
886+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link+"&file=README&line=0&side=left", nil))
887+ if strings.Contains(w.Body.String(), `name="file"`) {
888+ t.Fatal("an invalid query anchor preset the compose")
889+ }
890+ }
891+}
892diff --git a/internal/webui/style.css b/internal/webui/style.css
893index 9785c97..03e01d0 100644
894--- a/internal/webui/style.css
895+++ b/internal/webui/style.css
896@@ -214,6 +214,10 @@ source-view b{font-weight:600}source-view i{font-style:normal;color:var(--muted)
897 source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);font-family:var(--sans)}
898 diff-view ins,diff-view del{text-decoration:none}diff-view tr:has(ins){background:var(--add)}diff-view tr:has(del){background:var(--del)}diff-view b{color:var(--muted);font-weight:600}
899 diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top}
900+/* Review comments: the link that starts one on a diff line, and the comments placed under that line. */
901+diff-view td>small{margin-left:12px;font:11px var(--sans)}diff-view td>small a{color:var(--muted);text-decoration:none}diff-view tr:hover td>small a,diff-view td>small a:focus{color:var(--fg);text-decoration:underline}
902+diff-view td>nostr-event{white-space:normal;max-width:720px;border:0;padding:6px 0 4px}diff-view td>details{white-space:normal;font:12px var(--mono);margin:0 0 8px}diff-view td>details>summary{cursor:pointer;color:var(--muted)}diff-view td>details nostr-compose{border-bottom:0;padding:6px 0 0}
903+#reply-anchor{font:13px var(--sans);color:var(--muted)}#events li>p{margin:-6px 0 8px;font:12px var(--mono);color:var(--muted)}
904
905 /* Wiki: the page list, one article, the merge request bar and the editor. */
906 #wiki-list #rows th{white-space:nowrap}#wiki-list #rows td:first-child{width:auto;font:14px var(--sans)}#wiki-list #rows td:nth-child(3),#wiki-list #rows td:nth-child(4){width:110px;font:12px var(--mono);color:var(--muted)}
907diff --git a/internal/webui/templates.go b/internal/webui/templates.go
908index c8a6c20..f82f12e 100644
909--- a/internal/webui/templates.go
910+++ b/internal/webui/templates.go
911@@ -250,6 +250,9 @@ func parseTemplates() (*template.Template, error) {
912 "collabItems": collaborationItems,
913 "collabReplies": collaborationReplies,
914 "collabLabels": collaborationLabels,
915+ "reviewDiff": reviewDiffView,
916+ "reviewAnchor": reviewAnchorQuery,
917+ "reviewLabel": reviewAnchorLabel,
918 "agentState": agentState,
919 "agentSince": agentSince,
920 "agentScope": agentScope,
921diff --git a/scripts/collaboration-compose.test.mjs b/scripts/collaboration-compose.test.mjs
922index a8c4ced..837f860 100644
923--- a/scripts/collaboration-compose.test.mjs
924+++ b/scripts/collaboration-compose.test.mjs
925@@ -47,6 +47,35 @@ test("nested NIP-22 replies preserve the root and address the immediate parent",
926 assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["E", root, "", author], ["K", "1621"], ["P", author], ["e", parent, "", parentAuthor], ["k", "1111"], ["p", parentAuthor]]);
927 });
928
929+test("review comments carry file and line tags from hidden inputs or attributes", async () => {
930+ const pullAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1618"};
931+ const fromInputs = setup({...pullAttrs, kind: "1111"}, {content: "Prefer two", file: "README", line: "2", side: "new"});
932+ await fromInputs.component.submit(fromInputs.form);
933+ assert.deepEqual(fromInputs.sent[0].event.tags.slice(-2), [["file", "README"], ["line", "2", "new"]]);
934+ const fromAttributes = setup({...pullAttrs, kind: "1111", file: "src/app.js", line: "14", side: "old"}, {content: "Keep this"});
935+ await fromAttributes.component.submit(fromAttributes.form);
936+ assert.deepEqual(fromAttributes.sent[0].event.tags.slice(-2), [["file", "src/app.js"], ["line", "14", "old"]]);
937+ const plain = setup({...pullAttrs, kind: "1111"}, {content: "General note"});
938+ await plain.component.submit(plain.form);
939+ assert.equal(plain.sent[0].event.tags.some(tag => tag[0] === "file" || tag[0] === "line"), false);
940+});
941+
942+test("incomplete or misplaced line references are rejected before signing", async () => {
943+ const pullAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1618"};
944+ for (const [attrs, values] of [
945+ [{...pullAttrs, kind: "1111"}, {content: "x", file: "README", line: "0", side: "new"}],
946+ [{...pullAttrs, kind: "1111"}, {content: "x", file: "README", line: "2", side: "left"}],
947+ [{...pullAttrs, kind: "1111"}, {content: "x", line: "2", side: "new"}],
948+ [{...pullAttrs, kind: "1111"}, {content: "x", file: "README"}],
949+ [{...rootAttrs, kind: "1111"}, {content: "x", file: "README", line: "2", side: "new"}]
950+ ]) {
951+ const s = setup(attrs, values);
952+ await assert.rejects(s.component.submit(s.form), /line|pull request/);
953+ assert.equal(s.signed.length, 0);
954+ assert.equal(s.sent.length, 0);
955+ }
956+});
957+
958 test("status selector signs a NIP-34 root marker with an optional empty body", async () => {
959 const s = setup({...rootAttrs, kind: "status"}, {status: "1632", content: ""});
960 await s.component.submit(s.form);

About

ownerc01503e8035f
visibilitypublic
headfb3940d02247

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs