vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Give every account a profile page and a directory of accounts
a6b42433c087ab0c936e34a1d05cf488db7a92d6 · Keenan Nemetz · 8d ago · parent 727df386
Give every account a profile page and a directory of accounts Clicking a name now opens /u/<name>: the issues and changes somebody opened, the reviews they gave, the comments they left, and the repositories that work is in. The numbers and the feed are grouped reads of the same event rows the activity feed pages through, so a profile cannot disagree with the feed, and every read is limited to the repositories the reader may open. A private repository's work is not a number an outsider gets to learn, so two readers can see different totals and the page says what its numbers cover. /u lists the roster. Account names are public on this instance; their activity is not. Both pages ask crawlers to leave them out of an index. @name in an issue, a comment, a change request, or a release note links to the account it addresses, using the mention scanner CI already evaluates: what reads as an address on the page is what a workflow sees. A name that is not an account stays text, and code samples are skipped. Git author names are not linked and commit counts are not shown. A commit author is a name somebody typed, not the credential that proves an account, and the index only keeps the top eight authors of each default branch anyway.
server/web/mentions_test.go +101
| @@ -0,0 +1,101 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // A mention on the page has to mean what a mention means to CI. The scanner is | ||
| 4 | // metadata.EachMention, the one the trigger server uses, and the renderer only | ||
| 5 | // links names that belong to an account. These tests hold both halves: what is | ||
| 6 | // linked, and what deliberately is not. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestMentionsBecomeProfileLinks(t *testing.T) { | ||
| 14 | t.Parallel() | ||
| 15 | s := newSite(t) | ||
| 16 | html := string(s.web.renderMarkdown("demo", "hi @alice, take a look")) | ||
| 17 | |||
| 18 | if !strings.Contains(html, `href="/u/alice"`) || !strings.Contains(html, ">@alice</a>") { | ||
| 19 | t.Fatalf("mention was not linked:\n%s", html) | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | func TestMentionLinksSkipWhatIsNotAnAddress(t *testing.T) { | ||
| 24 | t.Parallel() | ||
| 25 | s := newSite(t) | ||
| 26 | cases := []struct { | ||
| 27 | name string | ||
| 28 | src string | ||
| 29 | }{ | ||
| 30 | {"email", "mail alice@example.com about it"}, | ||
| 31 | {"url", "see https://example.test/@alice"}, | ||
| 32 | {"inline code", "use `@alice` in a sample"}, | ||
| 33 | {"fenced block", "```\n@alice\n```"}, | ||
| 34 | {"escaped", `write \@alice literally`}, | ||
| 35 | {"unknown name", "hi @nobody"}, | ||
| 36 | {"path", "look in docs/@alice/readme"}, | ||
| 37 | } | ||
| 38 | for _, c := range cases { | ||
| 39 | t.Run(c.name, func(t *testing.T) { | ||
| 40 | html := string(s.web.renderMarkdown("demo", c.src)) | ||
| 41 | if strings.Contains(html, "/u/alice") || strings.Contains(html, "/u/nobody") { | ||
| 42 | t.Fatalf("%s was linked:\n%s", c.name, html) | ||
| 43 | } | ||
| 44 | }) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | // A mention inside an existing link's label must not become a nested link: the | ||
| 49 | // browser would close the outer one and the markdown the author wrote would no | ||
| 50 | // longer say what the page shows. | ||
| 51 | func TestMentionInsideALinkIsLeftAlone(t *testing.T) { | ||
| 52 | t.Parallel() | ||
| 53 | s := newSite(t) | ||
| 54 | html := string(s.web.renderMarkdown("demo", "[ask @alice](https://example.test/ask)")) | ||
| 55 | |||
| 56 | if got := strings.Count(html, "<a "); got != 1 { | ||
| 57 | t.Fatalf("link count = %d, want 1:\n%s", got, html) | ||
| 58 | } | ||
| 59 | if !strings.Contains(html, `href="https://example.test/ask"`) { | ||
| 60 | t.Fatalf("the author's link was rewritten:\n%s", html) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | // A README is documentation, not somebody being addressed, so it is rendered | ||
| 65 | // without profile links even though the same account exists. | ||
| 66 | func TestDocumentationDoesNotLinkMentions(t *testing.T) { | ||
| 67 | t.Parallel() | ||
| 68 | s := newSite(t) | ||
| 69 | html := string(s.web.renderTreeMarkdown(treeDoc{Repo: "demo"}, "the @alice flag turns it on")) | ||
| 70 | |||
| 71 | if strings.Contains(html, "/u/alice") { | ||
| 72 | t.Fatalf("a README mention became a profile link:\n%s", html) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | // Mentions at the edge of prose still count: after a line break and after | ||
| 77 | // punctuation, which is how people actually write them. | ||
| 78 | func TestMentionAtLineStartAndAfterPunctuation(t *testing.T) { | ||
| 79 | t.Parallel() | ||
| 80 | s := newSite(t) | ||
| 81 | for _, src := range []string{ | ||
| 82 | "first line\n@alice please look", | ||
| 83 | "(@alice) please look", | ||
| 84 | } { | ||
| 85 | html := string(s.web.renderMarkdown("demo", src)) | ||
| 86 | if !strings.Contains(html, `href="/u/alice"`) { | ||
| 87 | t.Fatalf("%q was not linked:\n%s", src, html) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | // The link survives sanitisation: if bluemonday dropped it, the page would show | ||
| 93 | // plain text and the test above would still pass on the raw markdown. | ||
| 94 | func TestMentionLinkSurvivesTheSanitizer(t *testing.T) { | ||
| 95 | t.Parallel() | ||
| 96 | s := newSite(t) | ||
| 97 | html := string(s.web.renderMarkdown("demo", "@alice")) | ||
| 98 | if !strings.Contains(html, `href="/u/alice"`) { | ||
| 99 | t.Fatalf("the sanitised mention is not a link:\n%s", html) | ||
| 100 | } | ||
| 101 | } | ||