Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .gittensory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ gate:
# relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) |
# openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult

# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership): a PR
# that closes/fixes/resolves an issue inherits that issue's point-bearing gittensor:* label onto the PR
# itself, instead of the PR's own label being decided purely by its commit-title prefix. bug/feature are
# `trustMaintainerAuthoredIssue: true` (routine categorization, no reward at stake, and the title-based
# fallback already has zero equivalent verification) so they propagate even when the PR author isn't a
# formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and
# rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked
# reward label, and must still require the PR author to be the issue's actual author/assignee.
settings:
linkedIssueLabelPropagation:
enabled: true
mode: exclusive_type_label
mappings:
- issueLabel: "gittensor:bug"
prLabel: "gittensor:bug"
removeOtherTypeLabels: true
trustMaintainerAuthoredIssue: true
- issueLabel: "gittensor:feature"
prLabel: "gittensor:feature"
removeOtherTypeLabels: true
trustMaintainerAuthoredIssue: true
- issueLabel: "gittensor:priority"
prLabel: "gittensor:priority"
removeOtherTypeLabels: true

# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.
# repoDocGeneration:
Expand Down
3 changes: 3 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8760,6 +8760,9 @@
},
"removeOtherTypeLabels": {
"type": "boolean"
},
"trustMaintainerAuthoredIssue": {
"type": "boolean"
}
},
"required": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,19 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li
warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].removeOtherTypeLabels must be a boolean; ignoring this mapping.`);
return null;
}
return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true };
// Unlike `removeOtherTypeLabels`, a malformed value here can only ever be warned-and-defaulted (never
// dropped) -- defaulting to `undefined`/strict is always the SAFE direction, so there is no silent-flip
// risk that would justify discarding an otherwise-valid mapping over it. Mirrors `src/review/linked-issue-
// label-propagation.ts`'s copy of this normalizer.
let trustMaintainerAuthoredIssue: boolean | undefined;
if (record.trustMaintainerAuthoredIssue !== undefined) {
if (typeof record.trustMaintainerAuthoredIssue === "boolean") {
trustMaintainerAuthoredIssue = record.trustMaintainerAuthoredIssue;
} else {
warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`);
}
}
return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue };
}

/** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config —
Expand Down
7 changes: 7 additions & 0 deletions packages/gittensory-engine/src/types/manifest-deps-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export type LinkedIssueLabelPropagationMapping = {
issueLabel: string;
prLabel: string;
removeOtherTypeLabels: boolean;
/** Allow this mapping to fire off a linked issue authored by the repo's owner/admin/write-collaborator
* even when the PR author neither opened nor is assigned to that issue (#priority-linked-issue-gate-
* ownership). Defaults to `false`/unset (strict author-or-assignee-only behavior) -- a maintainer-reward
* mapping like `gittensor:priority` should never set this. Mirrors `src/types.ts`'s copy of this type;
* see `review/linked-issue-label-propagation-fetch.ts`'s `isRepoMaintainerLogin` (app-side only, not
* duplicated into this engine package since it needs GitHub/fetch/Env access). */
trustMaintainerAuthoredIssue?: boolean | undefined;
};

export type LinkedIssueLabelPropagationMode = "exclusive_type_label";
Expand Down
27 changes: 26 additions & 1 deletion src/config/gittensory-repo-focus-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ gate:
duplicates: block # block | advisory | off — block obvious duplicate PRs
readiness:
mode: advisory # advisory | off — readiness score is informational and never blocks the Gate
minScore: 60
minScore: 40 # lowered from 60: 73% false-positive rate showed PRs scoring 40-59 merge freely
# aiReview: # opt-in AI maintainer review (off by default; needs the AI flags enabled)
# mode: advisory # block | advisory | off — block only blocks on a dual-model consensus defect
# byok: false # use a maintainer Anthropic/OpenAI key for the write-up; consensus stays on the free/default reviewer
Expand All @@ -59,6 +59,31 @@ gate:
# relatedWork: false # linkedIssue | relatedWork | reviewLoad (Change scope) |
# openPrQueue: false # validationEvidence (Validation posture) | openPrQueue (Contributor workload) | contributorContext | gateResult

# Linked-issue label propagation (#priority-linked-issue-gate, #priority-linked-issue-gate-ownership): a PR
# that closes/fixes/resolves an issue inherits that issue's point-bearing gittensor:* label onto the PR
# itself, instead of the PR's own label being decided purely by its commit-title prefix. bug/feature are
# \`trustMaintainerAuthoredIssue: true\` (routine categorization, no reward at stake, and the title-based
# fallback already has zero equivalent verification) so they propagate even when the PR author isn't a
# formal GitHub assignee of the issue — our issues are almost always maintainer-authored for open pickup and
# rarely formally assigned. priority intentionally omits the flag: it is the scarce, maintainer-hand-picked
# reward label, and must still require the PR author to be the issue's actual author/assignee.
settings:
linkedIssueLabelPropagation:
enabled: true
mode: exclusive_type_label
mappings:
- issueLabel: "gittensor:bug"
prLabel: "gittensor:bug"
removeOtherTypeLabels: true
trustMaintainerAuthoredIssue: true
- issueLabel: "gittensor:feature"
prLabel: "gittensor:feature"
removeOtherTypeLabels: true
trustMaintainerAuthoredIssue: true
- issueLabel: "gittensor:priority"
prLabel: "gittensor:priority"
removeOtherTypeLabels: true

# Repo-doc generation roadmap (#2993/#3002) — opt-in only, off by default. Uncomment to let Gittensory open a
# PR generating AGENTS.md/CLAUDE.md from this repo's own profile.
# repoDocGeneration:
Expand Down
9 changes: 8 additions & 1 deletion src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,14 @@ export const RepositorySettingsSchema = z
.object({
enabled: z.boolean(),
mode: z.enum(["exclusive_type_label"]),
mappings: z.array(z.object({ issueLabel: z.string(), prLabel: z.string(), removeOtherTypeLabels: z.boolean() })),
mappings: z.array(
z.object({
issueLabel: z.string(),
prLabel: z.string(),
removeOtherTypeLabels: z.boolean(),
trustMaintainerAuthoredIssue: z.boolean().optional(),
}),
),
})
.optional(),
linkedIssueHardRules: z
Expand Down
1 change: 1 addition & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7920,6 +7920,7 @@ async function maybePublishPrPublicSurface(
linkedIssues: pr.linkedIssues,
installationId,
prAuthorLogin: pr.authorLogin,
mappings: propagation.mappings,
})
: [];
const decisionResult = resolvePrTypeLabel({
Expand Down
110 changes: 94 additions & 16 deletions src/review/linked-issue-label-propagation-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { fetchLinkedIssueFacts } from "../github/backfill";
import { createInstallationToken } from "../github/app";
import { fetchLinkedIssueFacts, type LinkedIssueFactsFetch } from "../github/backfill";
import { createInstallationToken, getRepositoryCollaboratorPermission } from "../github/app";
import { githubRateLimitAdmissionKeyForToken } from "../github/client";
import { parseGitHubLoginList } from "../auth/security";
import type { LinkedIssueLabelPropagationMapping } from "../types";

// The GitHub-fetch orchestrator for linked-issue label propagation (#priority-linked-issue-gate), kept
// deliberately OUT of `linked-issue-label-propagation.ts` (the pure config types + normalizer, imported by
Expand All @@ -17,6 +19,72 @@ import { githubRateLimitAdmissionKeyForToken } from "../github/client";
// directly, without needing to trust every call site to have gone through the capped extractor first.
const MAX_LINKED_ISSUES_TO_FETCH = 50;

/** Whether `login` holds a maintainer-equivalent permission on `repoFullName` -- the literal repo owner,
* a fleet-operator in the global `ADMIN_GITHUB_LOGINS` allowlist, or a live GitHub collaborator with
* admin/maintain/write access (#priority-linked-issue-gate-ownership). Mirrors
* `hasMaintainerOrOwnerPermission` in `src/queue/processors.ts` (kept as its own copy here rather than
* imported, since that one is private to a file this module's header comment explicitly must NOT pull
* into its import graph -- see the file-level comment above). Fail-CLOSED: a collaborator-permission
* fetch error resolves to `null` inside `getRepositoryCollaboratorPermission` itself, which this treats
* the same as "not a maintainer" -- consistent with this whole file's bias toward denying an
* unverifiable trust claim rather than granting one. */
async function isRepoMaintainerLogin(env: Env, installationId: number, repoFullName: string, login: string): Promise<boolean> {
// The ": \"\"" fallback is unreachable via the real webhook path: repoFullName is always the
// "owner/repo"-formatted payload.repository.full_name, and the surrounding pipeline already requires a
// repository match on that exact format before this function's caller runs (mirrors the identical
// pattern + rationale in `hasMaintainerOrOwnerPermission`, `src/queue/processors.ts`).
/* v8 ignore next */
const repoOwner = repoFullName.includes("/") ? repoFullName.slice(0, repoFullName.indexOf("/")).toLowerCase() : "";
if (login === repoOwner || parseGitHubLoginList(env.ADMIN_GITHUB_LOGINS).has(login)) return true;
const permission = await getRepositoryCollaboratorPermission(env, installationId, repoFullName, login).catch(() => null);
return permission != null && new Set(["admin", "maintain", "write"]).has(permission);
}

/** Per-issue label resolution for {@link fetchLinkedIssueLabelsForPropagation}: a direct PR-author-is-
* issue-author-or-assignee match unlocks EVERY label the issue carries (today's original behavior,
* unchanged). Failing that, a mapping explicitly opted into `trustMaintainerAuthoredIssue`
* (#priority-linked-issue-gate-ownership) unlocks JUST that mapping's `issueLabel` when the issue's
* author independently checks out as a repo maintainer/operator via {@link isRepoMaintainerLogin} --
* built so routine bug/feature mirroring doesn't require formal GitHub issue assignment (our own repos
* rarely assign issues), while a scarce, maintainer-hand-picked reward label like `gittensor:priority`
* (which should never set the flag) still requires the contributor to be the actual author/assignee.
* `relaxableLabels` is empty whenever the caller passed no mappings or none opted in, which skips the
* maintainer-permission check (and its GitHub API call) entirely -- byte-identical to the pre-fix
* behavior for any caller that hasn't opted in. Logs once per issue when the returned set is smaller
* than what the issue actually carries, so a future "why didn't my PR inherit the label" report is
* diagnosable from structured logs instead of a source read. */
async function resolveIssueLabelsForPropagation(
args: { env: Env; repoFullName: string; installationId: number },
result: LinkedIssueFactsFetch,
prAuthorLogin: string | undefined,
relaxableLabels: ReadonlySet<string>,
): Promise<string[]> {
if (result.status !== "found" || result.facts.state !== "open" || !prAuthorLogin) return [];
const allLabels = result.facts.labels;
const issueAuthorLogin = result.facts.authorLogin?.toLowerCase();
const assignees = result.facts.assignees.map((login) => login.toLowerCase());
if (issueAuthorLogin === prAuthorLogin || assignees.includes(prAuthorLogin)) return allLabels;

const maintainerAuthored =
relaxableLabels.size > 0 &&
!!issueAuthorLogin &&
(await isRepoMaintainerLogin(args.env, args.installationId, args.repoFullName, issueAuthorLogin));
const kept = maintainerAuthored ? allLabels.filter((label) => relaxableLabels.has(label.toLowerCase())) : [];

if (kept.length < allLabels.length && allLabels.length > 0) {
console.log(
JSON.stringify({
ev: "linked_issue_label_propagation_filtered",
repoFullName: args.repoFullName,
issueNumber: result.facts.number,
reason: maintainerAuthored ? "strict_label_requires_direct_ownership" : "no_direct_ownership_match",
droppedCount: allLabels.length - kept.length,
}),
);
}
return kept;
}

/** FETCH every linked issue's labels (fail-open) and flatten into one label list for
* `resolvePrTypeLabel` (`src/settings/pr-type-label.ts`) to match against. Only verified OPEN issues
* can contribute labels; closing-keyword text in a PR body is author-controlled and is not authority by
Expand All @@ -32,13 +100,19 @@ const MAX_LINKED_ISSUES_TO_FETCH = 50;
* which is a single try/catch in `src/queue/processors.ts`'s type-label block (`type_label_error`).
* Callers should gate this behind `config.enabled` themselves before calling (mirrors
* `shouldCollectLinkedIssueEvidence`'s cheap-check-before-fetch precedent) — this function only
* short-circuits the zero-linked-issues case, since it has no visibility into the caller's enabled flag. */
* short-circuits the zero-linked-issues case, since it has no visibility into the caller's enabled flag.
*
* `mappings` (optional, #priority-linked-issue-gate-ownership) is the propagation config's own mapping
* list, used ONLY to know which `issueLabel`s are allowed to unlock via `resolveIssueLabelsForPropagation`'s
* relaxed maintainer-authored-issue path -- omitting it (or a mapping never setting the flag) reproduces
* today's strict author-or-assignee-only behavior exactly. */
export async function fetchLinkedIssueLabelsForPropagation(args: {
env: Env;
repoFullName: string;
linkedIssues: number[];
installationId: number;
prAuthorLogin: string | null | undefined;
mappings?: readonly LinkedIssueLabelPropagationMapping[] | undefined;
}): Promise<string[]> {
if (args.linkedIssues.length === 0) return [];
const linkedIssues = args.linkedIssues.slice(0, MAX_LINKED_ISSUES_TO_FETCH);
Expand All @@ -51,6 +125,12 @@ export async function fetchLinkedIssueLabelsForPropagation(args: {
token,
args.installationId,
);
const prAuthorLogin = args.prAuthorLogin?.toLowerCase();
const relaxableLabels = new Set(
(args.mappings ?? [])
.filter((mapping) => mapping.trustMaintainerAuthoredIssue === true)
.map((mapping) => mapping.issueLabel.toLowerCase()),
);
const results = await Promise.all(
linkedIssues.map((issueNumber) =>
fetchLinkedIssueFacts(
Expand All @@ -62,17 +142,15 @@ export async function fetchLinkedIssueLabelsForPropagation(args: {
),
),
);
return results.flatMap((result) => {
if (result.status !== "found" || result.facts.state !== "open") return [];
const prAuthorLogin = args.prAuthorLogin?.toLowerCase();
if (!prAuthorLogin) return [];
const issueAuthorLogin = result.facts.authorLogin?.toLowerCase();
const assignees = result.facts.assignees.map((login) =>
login.toLowerCase(),
);
return issueAuthorLogin === prAuthorLogin ||
assignees.includes(prAuthorLogin)
? result.facts.labels
: [];
});
const perIssueLabels = await Promise.all(
results.map((result) =>
resolveIssueLabelsForPropagation(
{ env: args.env, repoFullName: args.repoFullName, installationId: args.installationId },
result,
prAuthorLogin,
relaxableLabels,
),
),
);
return perIssueLabels.flat();
}
14 changes: 13 additions & 1 deletion src/review/linked-issue-label-propagation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,19 @@ function normalizeMapping(input: unknown, index: number, warnings: string[]): Li
warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].removeOtherTypeLabels must be a boolean; ignoring this mapping.`);
return null;
}
return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true };
// Unlike `removeOtherTypeLabels`, a malformed value here can only ever be warned-and-defaulted (never
// dropped) -- defaulting to `undefined`/strict is always the SAFE direction (no mapping accidentally
// starts trusting maintainer-authored issues), so there is no silent-flip risk that would justify
// discarding an otherwise-valid mapping over it.
let trustMaintainerAuthoredIssue: boolean | undefined;
if (record.trustMaintainerAuthoredIssue !== undefined) {
if (typeof record.trustMaintainerAuthoredIssue === "boolean") {
trustMaintainerAuthoredIssue = record.trustMaintainerAuthoredIssue;
} else {
warnings.push(`settings.linkedIssueLabelPropagation.mappings[${index}].trustMaintainerAuthoredIssue must be a boolean; ignoring it.`);
}
}
return { issueLabel, prLabel, removeOtherTypeLabels: record.removeOtherTypeLabels === true, trustMaintainerAuthoredIssue };
}

/** Defaults-fill a per-repo `linkedIssueLabelPropagation` override into an always-complete, safe config —
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,13 @@ export type LinkedIssueLabelPropagationMapping = {
issueLabel: string;
prLabel: string;
removeOtherTypeLabels: boolean;
/** Allow this mapping to fire off a linked issue authored by the repo's owner/admin/write-collaborator
* even when the PR author neither opened nor is assigned to that issue (#priority-linked-issue-gate-
* ownership). Defaults to `false`/unset (today's strict author-or-assignee-only behavior) -- a
* maintainer-reward mapping like `gittensor:priority` should never set this, since it is exactly the
* scarce, hand-picked label a contributor could otherwise farm by citing an unrelated issue they had no
* part in. See `review/linked-issue-label-propagation-fetch.ts`'s `isRepoMaintainerLogin`. */
trustMaintainerAuthoredIssue?: boolean | undefined;
};

export type LinkedIssueLabelPropagationMode = "exclusive_type_label";
Expand Down
Loading
Loading