[Bug]: GitHub's Moderate repository role cannot change pull request labels

Open Beginner friendly
#35,297 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
67/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
github, typescript

Research direction

Start with toCanTriage in packages/source-control-github/src/server/gitHubPullRequestJson.ts and follow its use in toViewerPermissions; the reproduction script in the issue shows the failing MODERATE and TRIAGE_PLUS cases. Check the relevant permission tests and add coverage for both roles while confirming canWrite remains false and labels are allowed. Done means the permission mapper and tests agree with the issue’s expected behavior.

Written by the indexing model from the issue text.

Description

Before submitting
  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
Area

apps/server

Steps to reproduce

Use a checkout of the commit under Version or commit with its repository-pinned dependencies installed and Node available. Save the script below outside the checkout and run it with sh from anywhere inside the checkout. It needs no GitHub credential and makes no remote changes. It writes one probe file to a mktemp directory with a private HOME, TMPDIR and T3CODE_HOME, removes it on exit, and leaves the checkout unchanged.

  1. The probe decodes a structurally complete GraphQL result through the generated production ViewerPermissions result schema, the same decoder GitHubPullRequestApi.getViewerAccess uses, for each viewerPermission value READ, TRIAGE, TRIAGE_PLUS, WRITE, MAINTAIN, ADMIN, null and MODERATE.
  2. It passes each decoded result to the exported production toViewerPermissions and then to the production gitHubViewerPermissions mapper, and prints canWrite, canTriage, canDeleteBranch, labels and actions per value.
  3. Compare the TRIAGE, TRIAGE_PLUS and MODERATE rows. The script exits 0 only when TRIAGE_PLUS and MODERATE decode but get labels: false, and the controls hold: READ is refused labels, TRIAGE, WRITE, MAINTAIN, ADMIN and null are granted labels, and toCanWrite(undefined) is false.

The corresponding user path is to open a pull request in a project whose server GitHub credential holds the Moderate repository role and use the label control. That path was not exercised: no account assigned that role was used, and no browser, mobile client or complete server RPC ran.

Reproduction script: repro.sh
#!/bin/sh
set -eu
checkout=$(git rev-parse --show-toplevel)
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
mkdir -p "$tmp/fakehome" "$tmp/tmp" "$tmp/t3home"
export HOME="$tmp/fakehome" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3home"
export T3_VERIFY_CHECKOUT="$checkout"
cat > "$tmp/probe.mts" <<'TS'
import { pathToFileURL } from "node:url";
const root = process.env.T3_VERIFY_CHECKOUT!;
const load = (path: string) => import(pathToFileURL(`${root}/${path}`).href);
const { createRequire } = await import("node:module");
const require = createRequire(`${root}/packages/source-control-github/package.json`);
const Schema = await import(pathToFileURL(require.resolve("effect/Schema")).href);
const { ViewerPermissions } = await load("packages/source-control-github/src/server/pullRequestReads.graphql.ts");
const { toViewerPermissions, toCanWrite } = await load("packages/source-control-github/src/server/gitHubPullRequestJson.ts");
const { gitHubViewerPermissions } = await load("packages/source-control-github/src/server/GitHubPullRequestProvider.ts");
function fixture(viewerPermission: string | null) {
  return { repository: {
    __typename: "Repository", viewerPermission,
    mergeCommitAllowed: true, squashMergeAllowed: true,
    rebaseMergeAllowed: true, autoMergeAllowed: false,
    pullRequest: { __typename: "PullRequest", viewerCanUpdate: false,
      viewerDidAuthor: false, headRepository: { __typename: "Repository", viewerPermission } },
  } };
}
const rows = ["READ", "TRIAGE", "TRIAGE_PLUS", "WRITE", "MAINTAIN", "ADMIN", null, "MODERATE"].map(permission => {
  const payload = fixture(permission);
  let decoded;
  try { decoded = Schema.decodeUnknownSync(ViewerPermissions.result)(payload); }
  catch (e) { return { permission, decode: "rejected", error: String(e) }; }
  const access = toViewerPermissions(decoded);
  if (access === null) throw new Error("repository unexpectedly absent");
  const viewer = gitHubViewerPermissions(access);
  return { permission, decode: "accepted", canWrite: access.canWrite,
    canTriage: access.canTriage, canDeleteBranch: access.canDeleteBranch,
    labels: viewer.labels, actions: viewer.actions,
    webLabelEditorAllowed: viewer.labels !== false,
    mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed: viewer.labels !== false };
});
for (const row of rows) console.log(JSON.stringify(row));
console.log("toCanWrite(undefined)=" + toCanWrite(undefined));
const row = (p: string | null) => rows.find(r => r.permission === p)!;
const controls = row("READ").labels === false && row("TRIAGE").labels === true
  && ["WRITE", "MAINTAIN", "ADMIN", null].every(p => row(p).labels === true)
  && toCanWrite(undefined) === false;
const defect = row("TRIAGE_PLUS").decode === "accepted" && row("TRIAGE_PLUS").labels === false;
const moderateDefect = row("MODERATE").decode === "accepted" && row("MODERATE").labels === false;
console.log(`controls=${controls}; TRIAGE_PLUS label defect=${defect}; MODERATE label defect=${moderateDefect}`);
process.exit(controls && defect && moderateDefect ? 0 : 1);
TS
cd "$checkout/packages/source-control-github"
env -u NODE_ENV node "$tmp/probe.mts"
Expected behavior

A repository role that includes triage abilities keeps permission to apply and dismiss PR labels in T3 Code, as TRIAGE already does. GitHub's repository roles table grants "Apply/dismiss labels" at the triage level and above. The repository's vendored GitHub schema describes TRIAGE_PLUS as triage plus additional abilities. GitHub's currently served GraphQL schema lists MODERATE, describes it as able to "moderate and edit issues and manage pull requests without write access", and marks TRIAGE_PLUS deprecated with the reason "TRIAGE_PLUS has been renamed to MODERATE. Use MODERATE instead. Removal on 2027-04-01 UTC." GitHub's REST API for adding a repository collaborator accepts moderate alongside pull, triage, push, maintain and admin on organization-owned repositories, separately from custom repository role names, so this is a documented built-in repository permission, not necessarily an Enterprise custom role. With the script's input, the MODERATE and TRIAGE_PLUS rows should report canTriage: true and labels: true while keeping canWrite: false.

Actual behavior

Summary. T3 Code decides whether the GitHub credential can apply and remove pull request labels from the repository's viewerPermission. That mapping grants label editing to TRIAGE and to the write-level roles, but not to MODERATE, GitHub's current repository permission that includes triage abilities, nor to TRIAGE_PLUS, its deprecated former name. Both values decode successfully and both end with canTriage: false and labels: false, while TRIAGE gets true. As a result, when the server's GitHub credential holds the Moderate role, users see the PR label control as unavailable with a triage-access explanation (web) or notice (mobile), and the server refuses label candidate reads and label writes. The effect is limited to PR labels: merging, branch deletion and reviewer requests use other permissions, and canWrite: false is correct for both roles. This is a gap in the original triage comparison from the labels feature, not a regression.

In the script's output, TRIAGE produces canTriage: true and labels: true, while TRIAGE_PLUS and MODERATE produce false for both; all three correctly report canWrite: false. The toCanTriage comparison accepts only the exact TRIAGE spelling or a writing role. toViewerPermissions passes that result on, and the provider maps it to labels: access.canTriage. The PR detail read reaches the same comparison through toPullRequestCore.

The downstream effects are established by reading the consumers, not by running them. Web passes the false flag to the label picker, which renders an unavailable button with a triage-access tooltip. Mobile disables label toggles and shows a triage-access notice. The server refuses label candidate reads and refuses label writes when the mapped permission is false.

Evidence
  • Expected source: GitHub's repository roles table (data/tables/repository-roles.yml, "Apply/dismiss labels" granted at triage and above), GitHub's served GraphQL RepositoryPermission enum (MODERATE, with TRIAGE_PLUS deprecated as renamed to it), the vendored schema at packages/source-control-github/schema/github.graphql.ts lines 190-199, the REST collaborator and team repository-permission schemas that accept moderate, and the passing TRIAGE control in the reproduction.
  • Failure source: the observed reproduction output under Logs or stack traces, and the comparison in toCanTriage at packages/source-control-github/src/server/gitHubPullRequestJson.ts lines 1524-1538, which does not match either role name.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: incomplete
  • Missing fact: an actual repository.viewerPermission response from a GitHub account assigned the Moderate role; without it, whether github.com still emits the deprecated TRIAGE_PLUS spelling and the plan or rollout limits on Moderate are unconfirmed.

The defect itself does not depend on the missing fact: the production decoder accepts MODERATE and the mapper denies labels for it. The pinned Effect GraphQL enum decoding accepts unknown future strings, so MODERATE being absent from the vendored enum does not stop it before toCanTriage. To inspect the current external contract read-only, run gh api graphql -f 'query={ __type(name: "RepositoryPermission") { enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason } } }'.

History: #19095 and its fix #19103 made an explicit null permission (GitHub App tokens) grant write and triage; that change did not address either role name. The triage comparison dates from the original labels feature in #9313, which treated triage as the least role allowed to label. Open #30510 and #32774 change label-picker interaction and latency, not this mapping. GitLab and Forgejo use host-provided booleans rather than this role ladder, so this mapping does not apply to them.

The script's output on the tested commit is under Logs or stack traces.

Restoration check

With the script's input, the MODERATE and TRIAGE_PLUS rows report canTriage: true and labels: true while canWrite, canDeleteBranch and actions stay false, false and []. As controls, READ is still refused labels, TRIAGE, WRITE, MAINTAIN, ADMIN and null are still granted labels, and toCanWrite(undefined) stays false. The script asserts the current failing results, so its TRIAGE_PLUS and MODERATE expectations need flipping for this check.

Impact

Minor bug or occasional failure

A documented GitHub repository permission that includes triage abilities is consistently mapped to no PR label access, so label editing in T3 Code fails for every PR when the credential holds that role. It is limited to labels and to that role; real-world prevalence and rollout scope were not measured.

Version or commit

d9805b2ca667be7caec8c9035950564ab96704c3, rerun from a clean checkout; the same script also exits 0 on main at 3ffd38ef7f67439eaaf17d2ffc1a71ee7cf4f33f; the toCanTriage comparison is unchanged on main at 6807c3fb865d3f9fcc0679b2e283cfd6064a4ea7 by source inspection only

Environment

Darwin arm64, Node v26.12.1, repository-pinned dependencies; production GraphQL decoder and permission mappers invoked directly, with no live GitHub write, role assignment, browser, mobile client or server

Logs or stack traces
{"permission":"READ","decode":"accepted","canWrite":false,"canTriage":false,"canDeleteBranch":false,"labels":false,"actions":[],"webLabelEditorAllowed":false,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":false}
{"permission":"TRIAGE","decode":"accepted","canWrite":false,"canTriage":true,"canDeleteBranch":false,"labels":true,"actions":[],"webLabelEditorAllowed":true,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":true}
{"permission":"TRIAGE_PLUS","decode":"accepted","canWrite":false,"canTriage":false,"canDeleteBranch":false,"labels":false,"actions":[],"webLabelEditorAllowed":false,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":false}
{"permission":"WRITE","decode":"accepted","canWrite":true,"canTriage":true,"canDeleteBranch":true,"labels":true,"actions":["delete-branch","merge","disable-auto-merge","revert","approve-workflows"],"webLabelEditorAllowed":true,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":true}
{"permission":"MAINTAIN","decode":"accepted","canWrite":true,"canTriage":true,"canDeleteBranch":true,"labels":true,"actions":["delete-branch","merge","disable-auto-merge","revert","approve-workflows"],"webLabelEditorAllowed":true,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":true}
{"permission":"ADMIN","decode":"accepted","canWrite":true,"canTriage":true,"canDeleteBranch":true,"labels":true,"actions":["delete-branch","merge","disable-auto-merge","revert","approve-workflows"],"webLabelEditorAllowed":true,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":true}
{"permission":null,"decode":"accepted","canWrite":true,"canTriage":true,"canDeleteBranch":true,"labels":true,"actions":["delete-branch","merge","disable-auto-merge","revert","approve-workflows"],"webLabelEditorAllowed":true,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":true}
{"permission":"MODERATE","decode":"accepted","canWrite":false,"canTriage":false,"canDeleteBranch":false,"labels":false,"actions":[],"webLabelEditorAllowed":false,"mobileLabelToggleAllowedWhenConnectedAndEnvironmentAllowed":false}
toCanWrite(undefined)=false
controls=true; TRIAGE_PLUS label defect=true; MODERATE label defect=true
Screenshots, recordings, or supporting files

No response

Workaround

Apply or dismiss the labels on GitHub directly with the same role. GitHub's documented permission table supports this, but no live label change was made to confirm it.

Dominant language
TypeScript
Stars
24.8k
Forks
6.4k
Avg merge
4h 16m
Merged PRs (30d)
352

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from pingdotgg/t3code

All issues in pingdotgg/t3code

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.