diff --git a/src/commands/list.ts b/src/commands/list.ts index 1dac6e3..0cae97e 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -53,7 +53,9 @@ export async function run(flags: { latencyLabel(k), k.local ? dim("—") : k.managed ? "managed" : "self-hosted", k.owners[0] - ? `@${k.owners[0].handle}${k.owners.length > 1 ? dim(` +${k.owners.length - 1}`) : ""}` + ? k.ownersInferred + ? dim(`~@${k.owners[0].handle}`) + : `@${k.owners[0].handle}${k.owners.length > 1 ? dim(` +${k.owners.length - 1}`) : ""}` : dim("—"), ]); diff --git a/src/core/network.ts b/src/core/network.ts index aa00e93..a1b9210 100644 --- a/src/core/network.ts +++ b/src/core/network.ts @@ -16,6 +16,7 @@ import { cached, pLimit } from "../util.js"; export type Knot = { host: string; owners: { did: string; handle: string }[]; + ownersInferred: boolean; // no sh.tangled.knot record — majority repo-owner guess repoCount: number; managed: boolean; local: boolean; // localhost/private junk record, not probeable @@ -62,6 +63,7 @@ export function groupNetwork(input: { k = { host, owners: [], + ownersInferred: false, repoCount: 0, managed: isManaged(host), local, @@ -86,6 +88,29 @@ export function groupNetwork(input: { } for (const repo of input.repos) knot(repo.knot).repoCount++; + // no verified owner but repos live there → attribute to the majority + // repo-owner, flagged so a guess never reads as fact + for (const k of byHost.values()) { + if (k.owners.length > 0 || k.repoCount === 0) continue; + const tally = new Map< + string, + { owner: Knot["owners"][number]; n: number } + >(); + for (const repo of input.repos) { + if (repo.knot !== k.host) continue; + const t = tally.get(repo.owner.did) ?? { owner: repo.owner, n: 0 }; + t.n++; + tally.set(repo.owner.did, t); + } + const top = [...tally.values()].sort( + (a, b) => b.n - a.n || a.owner.handle.localeCompare(b.owner.handle), + )[0]; + if (top) { + k.owners = [top.owner]; + k.ownersInferred = true; + } + } + const bySpindle = new Map(); const spindle = (host: string): Spindle => { let s = bySpindle.get(host); diff --git a/src/tui/board.ts b/src/tui/board.ts index f4df323..32c95ec 100644 --- a/src/tui/board.ts +++ b/src/tui/board.ts @@ -7,6 +7,7 @@ export type KnotRow = { repoCount: number; managed: boolean; owners: { did: string; handle: string }[]; + ownersInferred: boolean; probe: KnotProbe | null; status: "healthy" | "stale" | "down"; }; @@ -59,6 +60,7 @@ export function deriveRows( repoCount: k.repoCount, managed: k.managed, owners: k.owners, + ownersInferred: k.ownersInferred, probe, status: statusOf(probe), }; @@ -236,12 +238,14 @@ export function buildOps( ...(row.owners.length ? [ text( - `owned by ${row.owners - .slice(0, 5) - .map((o) => `@${o.handle}`) - .join( - ", ", - )}${row.owners.length > 5 ? ` +${row.owners.length - 5}` : ""}`, + row.ownersInferred + ? `probably @${row.owners[0].handle} (inferred from repos)` + : `owned by ${row.owners + .slice(0, 5) + .map((o) => `@${o.handle}`) + .join( + ", ", + )}${row.owners.length > 5 ? ` +${row.owners.length - 5}` : ""}`, { color: DIM }, ), ] diff --git a/tests/network.test.ts b/tests/network.test.ts index 9391da4..0c9b7fd 100644 --- a/tests/network.test.ts +++ b/tests/network.test.ts @@ -38,6 +38,9 @@ test("groupNetwork groups repos by knot host and classifies versions", () => { repo("beta", "knot1.tangled.sh"), repo("gamma", "knot.krasovs.ky", "someone", "spindle.tangled.sh"), repo("delta", "ghost.example.com"), + repo("one", "shared.example.com", "alice"), + repo("two", "shared.example.com", "alice"), + repo("three", "shared.example.com", "bob"), ], repoOwners: 3, probes: new Map([ @@ -54,22 +57,28 @@ test("groupNetwork groups repos by knot host and classifies versions", () => { }); expect(network.knots.map((k) => k.host)).toEqual([ - "knot1.tangled.sh", // 2 repos, sorted first + "shared.example.com", // 3 repos, sorted first + "knot1.tangled.sh", "ghost.example.com", "knot.krasovs.ky", "localhost:5555", ]); - const [knot1, ghost, krasovsky, local] = network.knots; + const [shared, knot1, ghost, krasovsky, local] = network.knots; expect(knot1).toMatchObject({ repoCount: 2, managed: true, version: "v1.15+", + ownersInferred: false, }); expect(knot1.owners).toEqual([{ did: "did:plc:a", handle: "oppi.li" }]); expect(krasovsky).toMatchObject({ managed: false, version: "pre-v1.15" }); - expect(ghost).toMatchObject({ owners: [], version: "unknown" }); + // no knot record → majority repo-owner, flagged as a guess + expect(ghost).toMatchObject({ ownersInferred: true, version: "unknown" }); + expect(ghost.owners).toEqual([{ did: "did:plc:someone", handle: "someone" }]); + expect(shared).toMatchObject({ ownersInferred: true }); + expect(shared.owners).toEqual([{ did: "did:plc:alice", handle: "alice" }]); expect(local).toMatchObject({ local: true, probe: null }); - expect(network.totalRepos).toBe(4); + expect(network.totalRepos).toBe(7); expect(network.repos.map((r) => r.name)).toContain("alpha"); expect(network.spindles).toHaveLength(1); expect(network.spindles[0]).toMatchObject({ diff --git a/tests/stats.test.ts b/tests/stats.test.ts index 68278ca..bc9cd1d 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -7,6 +7,7 @@ const knot = ( ): Network["knots"][number] => ({ host: "x", owners: [], + ownersInferred: false, repoCount: 0, managed: false, local: false, diff --git a/tests/watch.test.ts b/tests/watch.test.ts index 7dba76e..411623b 100644 --- a/tests/watch.test.ts +++ b/tests/watch.test.ts @@ -20,6 +20,7 @@ const network: Network = { { host: "knot1.tangled.sh", owners: [{ did: "did:plc:a", handle: "oppi.li" }], + ownersInferred: false, repoCount: 42, managed: true, local: false, @@ -29,6 +30,7 @@ const network: Network = { { host: "localhost:5555", owners: [], + ownersInferred: false, repoCount: 1, managed: false, local: true,