Summary
In src/signals/engine.ts, buildRoleContext derives a contributor's maintainer association (OWNER / MEMBER / COLLABORATOR) by filtering the cached pull requests and issues with a case-sensitive === on repoFullName:
// src/signals/engine.ts:1354-1357
const authoredAssociations = [
...(args.pullRequests ?? []).filter((pr) => pr.repoFullName === args.repoFullName && sameLogin(pr.authorLogin, args.login)).map((pr) => pr.authorAssociation),
...(args.issues ?? []).filter((issue) => issue.repoFullName === args.repoFullName && sameLogin(issue.authorLogin, args.login)).map((issue) => issue.authorAssociation),
].filter(Boolean) as string[];
authoredAssociations is the only input to strongestAssociation (engine.ts:3557), which is the only signal that promotes a contributor to owner / org_member / collaborator / repo_maintainer (engine.ts:1369-1385) and therefore sets maintainerLane (engine.ts:1394).
Everywhere else in the codebase repo names are compared case-insensitively — that is the explicit convention:
sameRepo (engine.ts:3572) → left.toLowerCase() === right.toLowerCase().
- Inside this very function, the official-repo lookup (
engine.ts:1358) and the reposTouched check (engine.ts:1361) both use .toLowerCase().
- The caller that builds the
repoFullName passed in here filters the same args.pullRequests with sameRepo (engine.ts:1452-1453).
The authoredAssociations filter (and the sibling touchedByCache PR/issue filters at engine.ts:1362,1364) are the lone case-sensitive repoFullName comparisons. GitHub treats Owner/Repo and owner/repo as the same repository, and the codebase normalizes everywhere else — so when the casing differs, the maintainer association is silently dropped.
Why the casing mismatch is reachable (not hypothetical)
The primary caller, buildContributorOutcomeHistory, passes a canonical, priority-merged repoFullName into buildRoleContext together with the raw, un-normalized PR list:
// src/signals/engine.ts:1431-1443 — highest-priority source wins the casing
const addRepoName = (repoFullName, priority) => {
const key = repoFullName.toLowerCase();
const current = repoNamesByKey.get(key);
if (!current || priority >= current.priority) repoNamesByKey.set(key, { repoFullName, priority });
};
for (const repo of args.repositories) addRepoName(repo.fullName, 1);
...
for (const pr of args.pullRequests.filter(...)) addRepoName(pr.repoFullName, 3);
for (const repo of args.profile.gittensor?.repositories ?? []) addRepoName(repo.repoFullName, 4); // official source — highest priority
// src/signals/engine.ts:1452-1453 — the SAME data is filtered case-INSENSITIVELY here
const cachedPrs = args.pullRequests.filter((pr) => sameRepo(pr.repoFullName, repoFullName) && sameLogin(pr.authorLogin, args.login));
...
// src/signals/engine.ts:1462 — but the canonical name + raw PRs go into buildRoleContext, which uses ===
const roleContext = buildRoleContext({ login: args.login, repo, repoFullName, pullRequests: args.pullRequests, issues: args.issues, profile: args.profile });
The canonical repoFullName takes the official Gittensor source's casing (priority 4, :1442) when present. The official API and the GitHub webhook/backfill cache are independent ingestion paths that routinely disagree on owner/name casing. The outer function already compensates with sameRepo at :1452-1453 — proving the maintainers of this code know the casing can differ — but the canonical name is then handed to buildRoleContext, which re-filters the same PRs with ===.
Failure mode (concrete example)
A contributor alice is a COLLABORATOR on a repo. The official Gittensor snapshot reports it as Acme/Widget (priority 4 → canonical repoFullName = "Acme/Widget"). Her cached PullRequestRecords (from the GitHub webhook/backfill) carry repoFullName: "acme/widget", authorAssociation: "COLLABORATOR".
authoredAssociations filter (:1355): "acme/widget" === "Acme/Widget" → false for every PR → authoredAssociations = [].
strongestAssociation([]) → undefined → none of the association branches fire (:1373-1385).
touchedByOfficial (:1359, which does match case-insensitively via :1358) is true → role = "outside_contributor", source = "gittensor_api".
- Result:
maintainerLane = false (:1394).
- Correct (case-insensitive): association
COLLABORATOR → role = "collaborator" → maintainerLane = true.
Downstream impact
maintainerLane is the codebase's central "treat maintainers separately from outside contributors" switch. With it wrongly false, the maintainer's own repo is scored as normal contributor reward evidence:
successLevel is computed from raw merge counts (strong/emerging/weak) instead of "maintainer_context" (engine.ts:1479-1480), and the maintainer-lane risk note is dropped (engine.ts:1466).
buildContributorRewardRiskStrategy emits outside-contributor actions (cleanup_existing_prs, open_new_direct_pr, etc.) instead of maintainer-lane guidance, and personalFit/reviewability lose their maintainer branches (reward-risk.ts:515-560,463,611,638).
buildDecisionPack drops the maintainer_lane recommendation and maintainer_lane blocker, and omits the repo from maintainerLaneRepos (decision-pack.ts:480,649,659).
In short, a maintainer's stewardship work is mis-counted as outside-contributor reward evidence — exactly the invariant the engine repeatedly enforces (pending-pr-scenarios.ts:167-174, the public-surface maintainer-author skip, etc.).
Steps to reproduce
- Build a
ContributorProfile whose official gittensor.repositories lists a repo as Acme/Widget, and supply cached pullRequests for the same repo as acme/widget with authorAssociation: "COLLABORATOR" (and authorLogin = the contributor).
- Call
buildContributorOutcomeHistory(...) (or buildRoleContext({ repoFullName: "Acme/Widget", pullRequests: [...lowercased...] }) directly).
- Read
roleContext.maintainerLane for that repo.
- Observe
false (role outside_contributor) instead of the correct true (role collaborator).
Expected behavior
Maintainer-association detection compares repoFullName case-insensitively, consistent with sameRepo and with every other repo comparison in the file, so a maintainer is recognized regardless of which ingestion path's casing won the canonical name.
Actual behavior
buildRoleContext filters PRs/issues for the author's association with case-sensitive === on repoFullName. When the canonical name's casing (often the official source) differs from the cached PR/issue casing, authoredAssociations is empty, the maintainer association is lost, and maintainerLane is silently false.
Suggested fix
-
In src/signals/engine.ts, replace the case-sensitive repoFullName comparisons in buildRoleContext with sameRepo, matching the rest of the file:
const authoredAssociations = [
...(args.pullRequests ?? []).filter((pr) => sameRepo(pr.repoFullName, args.repoFullName) && sameLogin(pr.authorLogin, args.login)).map((pr) => pr.authorAssociation),
...(args.issues ?? []).filter((issue) => sameRepo(issue.repoFullName, args.repoFullName) && sameLogin(issue.authorLogin, args.login)).map((issue) => issue.authorAssociation),
].filter(Boolean) as string[];
Apply the same change to the touchedByCache PR/issue filters (engine.ts:1362,1364).
-
Add fail-on-revert coverage: a contributor with authorAssociation: "COLLABORATOR" on pr.repoFullName = "acme/widget" and a canonical repoFullName = "Acme/Widget" must yield role = "collaborator" and maintainerLane = true. The existing role/maintainer tests all use matching casing, so they ratify the case-sensitive path rather than catching it.
Summary
In
src/signals/engine.ts,buildRoleContextderives a contributor's maintainer association (OWNER / MEMBER / COLLABORATOR) by filtering the cached pull requests and issues with a case-sensitive===onrepoFullName:authoredAssociationsis the only input tostrongestAssociation(engine.ts:3557), which is the only signal that promotes a contributor toowner/org_member/collaborator/repo_maintainer(engine.ts:1369-1385) and therefore setsmaintainerLane(engine.ts:1394).Everywhere else in the codebase repo names are compared case-insensitively — that is the explicit convention:
sameRepo(engine.ts:3572) →left.toLowerCase() === right.toLowerCase().engine.ts:1358) and thereposTouchedcheck (engine.ts:1361) both use.toLowerCase().repoFullNamepassed in here filters the sameargs.pullRequestswithsameRepo(engine.ts:1452-1453).The
authoredAssociationsfilter (and the siblingtouchedByCachePR/issue filters atengine.ts:1362,1364) are the lone case-sensitiverepoFullNamecomparisons. GitHub treatsOwner/Repoandowner/repoas the same repository, and the codebase normalizes everywhere else — so when the casing differs, the maintainer association is silently dropped.Why the casing mismatch is reachable (not hypothetical)
The primary caller,
buildContributorOutcomeHistory, passes a canonical, priority-mergedrepoFullNameintobuildRoleContexttogether with the raw, un-normalized PR list:The canonical
repoFullNametakes the official Gittensor source's casing (priority 4,:1442) when present. The official API and the GitHub webhook/backfill cache are independent ingestion paths that routinely disagree on owner/name casing. The outer function already compensates withsameRepoat:1452-1453— proving the maintainers of this code know the casing can differ — but the canonical name is then handed tobuildRoleContext, which re-filters the same PRs with===.Failure mode (concrete example)
A contributor
aliceis a COLLABORATOR on a repo. The official Gittensor snapshot reports it asAcme/Widget(priority 4 → canonicalrepoFullName = "Acme/Widget"). Her cachedPullRequestRecords (from the GitHub webhook/backfill) carryrepoFullName: "acme/widget",authorAssociation: "COLLABORATOR".authoredAssociationsfilter (:1355):"acme/widget" === "Acme/Widget"→falsefor every PR →authoredAssociations = [].strongestAssociation([])→undefined→ none of the association branches fire (:1373-1385).touchedByOfficial(:1359, which does match case-insensitively via:1358) istrue→role = "outside_contributor",source = "gittensor_api".maintainerLane = false(:1394).COLLABORATOR→role = "collaborator"→maintainerLane = true.Downstream impact
maintainerLaneis the codebase's central "treat maintainers separately from outside contributors" switch. With it wronglyfalse, the maintainer's own repo is scored as normal contributor reward evidence:successLevelis computed from raw merge counts (strong/emerging/weak) instead of"maintainer_context"(engine.ts:1479-1480), and the maintainer-lane risk note is dropped (engine.ts:1466).buildContributorRewardRiskStrategyemits outside-contributor actions (cleanup_existing_prs,open_new_direct_pr, etc.) instead of maintainer-lane guidance, andpersonalFit/reviewability lose their maintainer branches (reward-risk.ts:515-560,463,611,638).buildDecisionPackdrops themaintainer_lanerecommendation andmaintainer_laneblocker, and omits the repo frommaintainerLaneRepos(decision-pack.ts:480,649,659).In short, a maintainer's stewardship work is mis-counted as outside-contributor reward evidence — exactly the invariant the engine repeatedly enforces (
pending-pr-scenarios.ts:167-174, the public-surface maintainer-author skip, etc.).Steps to reproduce
ContributorProfilewhose officialgittensor.repositorieslists a repo asAcme/Widget, and supply cachedpullRequestsfor the same repo asacme/widgetwithauthorAssociation: "COLLABORATOR"(andauthorLogin= the contributor).buildContributorOutcomeHistory(...)(orbuildRoleContext({ repoFullName: "Acme/Widget", pullRequests: [...lowercased...] })directly).roleContext.maintainerLanefor that repo.false(roleoutside_contributor) instead of the correcttrue(rolecollaborator).Expected behavior
Maintainer-association detection compares
repoFullNamecase-insensitively, consistent withsameRepoand with every other repo comparison in the file, so a maintainer is recognized regardless of which ingestion path's casing won the canonical name.Actual behavior
buildRoleContextfilters PRs/issues for the author's association with case-sensitive===onrepoFullName. When the canonical name's casing (often the official source) differs from the cached PR/issue casing,authoredAssociationsis empty, the maintainer association is lost, andmaintainerLaneis silentlyfalse.Suggested fix
In
src/signals/engine.ts, replace the case-sensitiverepoFullNamecomparisons inbuildRoleContextwithsameRepo, matching the rest of the file:Apply the same change to the
touchedByCachePR/issue filters (engine.ts:1362,1364).Add fail-on-revert coverage: a contributor with
authorAssociation: "COLLABORATOR"onpr.repoFullName = "acme/widget"and a canonicalrepoFullName = "Acme/Widget"must yieldrole = "collaborator"andmaintainerLane = true. The existing role/maintainer tests all use matching casing, so they ratify the case-sensitive path rather than catching it.