Skip to content

[Bug]: buildRoleContext detects maintainer association with a case-SENSITIVE === on repoFullName, so a casing mismatch silently disables maintainer-lane handling #223

Description

@galuis116

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 truerole = "outside_contributor", source = "gittensor_api".
  • Result: maintainerLane = false (:1394).
  • Correct (case-insensitive): association COLLABORATORrole = "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

  1. 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).
  2. Call buildContributorOutcomeHistory(...) (or buildRoleContext({ repoFullName: "Acme/Widget", pullRequests: [...lowercased...] }) directly).
  3. Read roleContext.maintainerLane for that repo.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    slopAI slop and/or attempts to game additional points via manipulation or alt profiles.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions