diff --git a/trios/agent-server/apps/server/node_modules b/trios/agent-server/apps/server/node_modules new file mode 120000 index 0000000000..d9dd43591a --- /dev/null +++ b/trios/agent-server/apps/server/node_modules @@ -0,0 +1 @@ +/Users/playom/queen-patches/work/browseros-deploy/trios/agent-server/apps/server/node_modules \ No newline at end of file diff --git a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts index 0aaba4880c..b5cc563a1a 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-leaderboard.ts @@ -22,6 +22,17 @@ * * TRIOS_KEY_OWNERS="0=Dmitrii,1=@alex,4=Trinity community" * + * A NAME THAT STARTS WITH `@` IS A GITHUB LOGIN. This project lives on GitHub - + * the issues, the branches, the pull requests a bee opens are all there - so the + * person who lent the lane is nearly always someone with a GitHub account, and + * saying `1=@alex` gives the board a face and a profile to link instead of a + * bare string. The login is validated against GitHub's own rule (alphanumerics + * and single hyphens, up to 39 characters); anything else stays a plain name, + * so `4=Trinity community` and an `@` in a display name cannot become a link to + * a profile that is not theirs. Nothing is fetched from GitHub here: the handle + * travels as text and the page builds the avatar URL, so a leaderboard read + * never depends on GitHub being up or on a rate limit. + * * A lane nobody claimed is shown as `key #N` rather than dropped - the work is * real and the board must not imply the swarm ran on four keys when it ran on * fourteen. Nothing here reads a key, and nothing here can: the value never @@ -46,6 +57,8 @@ export interface Contributor { name: string /** Whether a person claimed this lane in TRIOS_KEY_OWNERS. */ claimed: boolean + /** Their GitHub login, when the name was written as `@login`. */ + github?: string keys: number[] accepted: number finished: number @@ -74,6 +87,22 @@ export function xpFor(work: Pick): number { return Math.round(work.accepted * ACCEPTED_XP + work.hours * HOUR_XP) } +/** + * GitHub's own rule for a login: alphanumerics and single hyphens, never at + * either end, at most 39 characters. Kept strict on purpose - this string + * becomes a link to a person's profile and the URL of their avatar, so a name + * that merely contains an `@` must not be able to point the board at a + * stranger's account. + */ +const GITHUB_LOGIN = /^[a-zA-Z\d](?:[a-zA-Z\d]|-(?=[a-zA-Z\d])){0,38}$/ + +/** `@alex` -> 'alex'. Anything that is not a login is not one. */ +export function githubLoginOf(name: string): string | undefined { + if (!name.startsWith('@')) return undefined + const login = name.slice(1) + return GITHUB_LOGIN.test(login) ? login : undefined +} + /** * The work of each lane, gathered by lender and ranked. Pure: the rows are the * database's, the ranking is this function's, and the test drives it directly. @@ -90,6 +119,7 @@ export function rank( const into: Contributor = seen ?? { name, claimed, + ...(claimed ? { github: githubLoginOf(name) } : {}), keys: [], accepted: 0, finished: 0, diff --git a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts index e72b9c03c8..726df20f1c 100644 --- a/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-leaderboard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' import { ACCEPTED_XP, + githubLoginOf, HOUR_XP, type KeyWork, parseOwners, @@ -74,4 +75,47 @@ describe('the score', () => { ) expect(ranked.map((r) => r.name)).toEqual(['Zoe', 'Ann', 'Ada', 'Bob']) }) + + it('reads a GitHub login out of an @name, and carries it to the row', () => { + expect(githubLoginOf('@alex')).toBe('alex') + expect(githubLoginOf('@torvalds')).toBe('torvalds') + expect(githubLoginOf('@gHashTag')).toBe('gHashTag') + expect(githubLoginOf('@a-b-c9')).toBe('a-b-c9') + const [row] = rank([lane(0, 1, 1, 0)], { 0: '@alex' }) + expect(row.github).toBe('alex') + expect(row.name).toBe('@alex') + }) + + /** + * The handle becomes a link to a person's profile and the URL of their + * avatar, so anything that is not a login must not become one. A plain name + * is shown as itself; it is never pointed at somebody else's account. + */ + it('refuses a name that is not a GitHub login', () => { + for (const name of [ + 'Dmitrii', + 'Trinity community', + '@', + '@-alex', + '@alex-', + '@al--ex', + '@alex/../torvalds', + '@alex bob', + '@alex.png', + '@' + 'a'.repeat(40), + 'mail@example.com', + ]) { + expect(githubLoginOf(name)).toBeUndefined() + } + const [row] = rank([lane(0, 1, 1, 0)], { 0: 'Trinity community' }) + expect(row.github).toBeUndefined() + expect(row.claimed).toBe(true) + }) + + it('gives an unclaimed lane no handle at all', () => { + const [row] = rank([lane(7, 1, 1, 0)], {}) + expect(row.name).toBe('key #7') + expect(row.github).toBeUndefined() + expect(row.claimed).toBe(false) + }) })