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
7 changes: 7 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,7 @@ export function createApp() {
]);
const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats);
const scoringProfile = buildContributorScoringProfile({ login: parsed.data.login, fit, scoringSnapshot: snapshot });
const checkSummaries = await loadCheckSummariesForPullRequests(c.env, parsed.data.repoFullName, pullRequests);
const analysis = buildLocalBranchAnalysis({
input: parsed.data,
repo,
Expand All @@ -795,6 +796,7 @@ export function createApp() {
contributorPullRequests: context.contributorPullRequests,
recentMergedPullRequests,
repositories: context.repositories,
checkSummaries,
profile: context.profile,
outcomeHistory: context.outcomeHistory,
scoringSnapshot: snapshot,
Expand Down Expand Up @@ -1314,6 +1316,11 @@ async function loadContributorFastContext(env: Env, login: string) {
};
}

async function loadCheckSummariesForPullRequests(env: Env, repoFullName: string, pullRequests: Array<{ number: number; state?: string | null | undefined }>) {
const openPulls = pullRequests.filter((pr) => pr.state === "open");
return (await Promise.all(openPulls.map((pr) => listCheckSummaries(env, repoFullName, pr.number)))).flat();
}

async function loadRepoDataQuality(env: Env, fullName: string) {
const [syncStates, syncSegments] = await Promise.all([listRepoSyncStates(env), listRepoSyncSegments(env, fullName)]);
return buildRepoDataQuality(
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getLatestRepoGithubTotalsSnapshot,
getIssue,
getRepository,
listCheckSummaries,
listContributorRepoStats,
listContributorIssues,
listContributorPullRequests,
Expand Down Expand Up @@ -831,6 +832,7 @@ export class GittensoryMcp {
]);
const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats);
const scoringProfile = buildContributorScoringProfile({ login: input.login, fit, scoringSnapshot: snapshot });
const checkSummaries = await this.loadCheckSummariesForPullRequests(input.repoFullName, pullRequests);
return {
...buildLocalBranchAnalysis({
input,
Expand All @@ -840,6 +842,7 @@ export class GittensoryMcp {
contributorPullRequests: context.contributorPullRequests,
recentMergedPullRequests,
repositories: context.repositories,
checkSummaries,
profile: context.profile,
outcomeHistory: context.outcomeHistory,
scoringSnapshot: snapshot,
Expand All @@ -850,6 +853,11 @@ export class GittensoryMcp {
};
}

private async loadCheckSummariesForPullRequests(repoFullName: string, pullRequests: Array<{ number: number; state?: string | null | undefined }>) {
const openPulls = pullRequests.filter((pr) => pr.state === "open");
return (await Promise.all(openPulls.map((pr) => listCheckSummaries(this.env, repoFullName, pr.number)))).flat();
}

private async getBountyAdvisory(id: string): Promise<ToolPayload> {
const bounty = await getBounty(this.env, id);
if (!bounty) throw new Error("Bounty not found.");
Expand Down
9 changes: 9 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1276,6 +1276,15 @@ export const LocalBranchAnalysisSchema = z
maintainerLane: z.number(),
notes: z.array(z.string()),
}),
githubBranchStatus: z.object({
source: z.literal("cached_github_data"),
status: z.enum(["approved", "failing_checks", "needs_author", "blocked", "pending_review", "no_pr", "unknown"]),
pullNumber: z.number().optional(),
title: z.string().optional(),
reviewDecision: z.string().nullable().optional(),
mergeableState: z.string().nullable().optional(),
notes: z.array(z.string()),
}),
rewardRisk: RepoRewardRiskSchema,
scoreBlockers: z.array(z.string()),
branchQualityBlockers: z.array(z.string()),
Expand Down
8 changes: 8 additions & 0 deletions src/services/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createAgentRun,
getAgentRun,
getRepository,
listCheckSummaries,
listAgentActions,
listAgentContextSnapshots,
listContributorIssues,
Expand Down Expand Up @@ -304,6 +305,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr
const outcomeHistory = buildContributorOutcomeHistory({ login: input.login, profile, repositories, pullRequests: contributorPullRequests, issues: contributorIssues, repoStats });
const fit = buildContributorFit(profile, repositories, [], [], syncStates, repoStats);
const scoringProfile = buildContributorScoringProfile({ login: input.login, fit, scoringSnapshot });
const checkSummaries = await loadCheckSummariesForPullRequests(env, input.repoFullName, pullRequests);
return buildLocalBranchAnalysis({
input,
repo,
Expand All @@ -312,6 +314,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr
contributorPullRequests,
recentMergedPullRequests,
repositories,
checkSummaries,
profile,
outcomeHistory,
scoringSnapshot,
Expand All @@ -320,6 +323,11 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr
});
}

async function loadCheckSummariesForPullRequests(env: Env, repoFullName: string, pullRequests: Array<{ number: number; state?: string | null | undefined }>) {
const openPulls = pullRequests.filter((pr) => pr.state === "open");
return (await Promise.all(openPulls.map((pr) => listCheckSummaries(env, repoFullName, pr.number)))).flat();
}

function buildDecisionActions(run: AgentRunRecord, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentActionRecord[] {
const decisionByRepo = new Map(decisions.map((decision) => [decision.repoFullName, decision]));
const candidateActions = pack.topActions
Expand Down
136 changes: 134 additions & 2 deletions src/signals/local-branch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ScorePreviewInput, ScorePreviewResult } from "../scoring/preview";
import { buildScorePreview } from "../scoring/preview";
import type { IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types";
import type { CheckSummaryRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types";
import { nowIso } from "../utils/json";
import {
buildLaneAdvice,
Expand Down Expand Up @@ -80,6 +80,16 @@ type ObservedPullRequestScenarios = {
notes: string[];
};

type GitHubBranchStatus = {
source: "cached_github_data";
status: "approved" | "failing_checks" | "needs_author" | "blocked" | "pending_review" | "no_pr" | "unknown";
pullNumber?: number | undefined;
title?: string | undefined;
reviewDecision?: string | null | undefined;
mergeableState?: string | null | undefined;
notes: string[];
};

export type LocalBranchAnalysis = {
login: string;
repoFullName: string;
Expand Down Expand Up @@ -114,6 +124,7 @@ export type LocalBranchAnalysis = {
blockedBy: ScorePreviewResult["blockedBy"];
};
observedPullRequestScenarios: ObservedPullRequestScenarios;
githubBranchStatus: GitHubBranchStatus;
Comment thread
oktofeesh1 marked this conversation as resolved.
rewardRisk: RepoRewardRisk;
scoreBlockers: string[];
branchQualityBlockers: string[];
Expand Down Expand Up @@ -159,6 +170,7 @@ export function buildLocalBranchAnalysis(args: {
contributorPullRequests?: PullRequestRecord[] | undefined;
recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined;
repositories?: RepositoryRecord[] | undefined;
checkSummaries?: CheckSummaryRecord[] | undefined;
profile: ContributorProfile;
outcomeHistory: ContributorOutcomeHistory;
scoringSnapshot: ScoringModelSnapshotRecord;
Expand Down Expand Up @@ -206,6 +218,7 @@ export function buildLocalBranchAnalysis(args: {
pullRequests: args.contributorPullRequests ?? args.pullRequests,
repositories: args.repositories,
});
const githubBranchStatus = buildGitHubBranchStatus(args.input, args.pullRequests, args.checkSummaries ?? []);
const scoreInput = buildLocalScoreInput({
input: args.input,
changedFiles,
Expand Down Expand Up @@ -245,7 +258,7 @@ export function buildLocalBranchAnalysis(args: {
issues: args.issues,
pullRequests: args.pullRequests,
});
const localFindings = buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness);
const localFindings = buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness, githubBranchStatus);
const branchQualityBlockers = branchQualityBlockersFor(preflight, localFindings);
const accountStateBlockers = accountStateBlockersFor(scorePreview);
const currentScenario = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "current") ?? scorePreview.scenarioPreviews[0]!;
Expand All @@ -269,6 +282,7 @@ export function buildLocalBranchAnalysis(args: {
laneSummary: lane.summary,
localFindings,
baseFreshness,
githubBranchStatus,
recommendedRerunCondition,
});
const scoreBlockers = [
Expand All @@ -290,6 +304,7 @@ export function buildLocalBranchAnalysis(args: {
scorePreview,
scenarioScorePreview,
observedPullRequestScenarios,
githubBranchStatus,
rewardRisk,
scoreBlockers: [...new Set(scoreBlockers)],
branchQualityBlockers,
Expand Down Expand Up @@ -421,6 +436,88 @@ function observedPullRequestNotes(scenarios: Omit<ObservedPullRequestScenarios,
];
}

function buildGitHubBranchStatus(input: LocalBranchAnalysisInput, pullRequests: PullRequestRecord[], checkSummaries: CheckSummaryRecord[]): GitHubBranchStatus {
const branchKeys = new Set([input.headRef, input.branchName].filter((value): value is string => Boolean(value)).map((value) => value.toLowerCase()));
const inputBaseRef = normalizeRefForMatch(input.baseRef);
const match = pullRequests.find(
(pr) =>
pr.state === "open" &&
sameLogin(pr.authorLogin, input.login) &&
sameBaseRef(inputBaseRef, pr.baseRef) &&
(Boolean(input.headSha && pr.headSha === input.headSha) || Boolean(pr.headRef && branchKeys.has(pr.headRef.toLowerCase()))),
);
if (!match) return { source: "cached_github_data", status: "no_pr", notes: ["No open GitHub PR was matched to the current branch metadata."] };
const reviewDecision = (match.reviewDecision ?? "").toLowerCase();
const mergeableState = (match.mergeableState ?? "").toLowerCase();
const matchedChecks = matchingCheckSummaries(match, checkSummaries);
const status =
reviewDecision === "changes_requested"
? "needs_author"
: mergeableState === "behind"
? "needs_author"
: match.isDraft
? "pending_review"
: ["dirty", "blocked", "conflicting", "unstable"].includes(mergeableState) || hasFailingCheck(matchedChecks)
? "failing_checks"
: hasPendingCheck(matchedChecks)
? "pending_review"
: mergeableState === "unknown"
? "unknown"
: reviewDecision === "approved" || isApprovedOrMergeableOpenPr(match)
? "approved"
Comment thread
oktofeesh1 marked this conversation as resolved.
: "pending_review";
return {
source: "cached_github_data",
status,
pullNumber: match.number,
title: match.title,
reviewDecision: match.reviewDecision,
mergeableState: match.mergeableState,
notes: githubBranchStatusNotes(status, match),
};
}

function githubBranchStatusNotes(status: GitHubBranchStatus["status"], pr: PullRequestRecord): string[] {
if (status === "approved") return [`PR #${pr.number} is approved or mergeable in cached GitHub metadata.`];
if (status === "needs_author" && (pr.mergeableState ?? "").toLowerCase() === "behind") return [`PR #${pr.number} is behind its base branch in cached GitHub metadata.`];
if (status === "needs_author") return [`PR #${pr.number} has requested changes in cached GitHub metadata.`];
if (status === "failing_checks") return [`PR #${pr.number} has failing, blocked, or conflicting GitHub status metadata.`];
if (status === "pending_review" && pr.isDraft) return [`PR #${pr.number} is still a draft in cached GitHub metadata.`];
if (status === "unknown") return [`PR #${pr.number} has incomplete GitHub status metadata; refresh checks before relying on it.`];
return [`PR #${pr.number} is open but not yet approved or clearly blocked in cached GitHub metadata.`];
}

function normalizeRefForMatch(ref: string | null | undefined): string | undefined {
const value = ref?.trim().toLowerCase();
if (!value) return undefined;
return value.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\/[^/]+\//, "").replace(/^(origin|upstream)\//, "");
}

function sameBaseRef(inputBaseRef: string | undefined, prBaseRef: string | null | undefined): boolean {
if (!inputBaseRef) return true;
return normalizeRefForMatch(prBaseRef) === inputBaseRef;
}

function matchingCheckSummaries(pr: PullRequestRecord, checkSummaries: CheckSummaryRecord[]): CheckSummaryRecord[] {
return checkSummaries.filter(
(check) =>
(check.pullNumber !== undefined && check.pullNumber !== null && check.pullNumber === pr.number) ||
(check.pullNumber === undefined || check.pullNumber === null ? Boolean(pr.headSha && check.headSha === pr.headSha) : false),
);
}

function hasFailingCheck(checks: CheckSummaryRecord[]): boolean {
return checks.some((check) => ["failure", "failed", "timed_out", "cancelled", "action_required", "startup_failure"].includes((check.conclusion ?? check.status).toLowerCase()));
}

function hasPendingCheck(checks: CheckSummaryRecord[]): boolean {
return checks.some((check) => {
const status = check.status.toLowerCase();
const conclusion = check.conclusion?.toLowerCase();
return !conclusion && !["completed", "success"].includes(status);
});
}

function isMaintainerAuthoredPr(pr: PullRequestRecord, repo: RepositoryRecord | undefined, login: string): boolean {
return sameLogin(repo?.owner, login) || ["owner", "member", "collaborator"].includes((pr.authorAssociation ?? "").toLowerCase());
}
Expand Down Expand Up @@ -448,6 +545,7 @@ function buildLocalFindings(
preflight: LocalDiffPreflightResult,
scorePreview: ScorePreviewResult,
baseFreshness: LocalBranchAnalysis["baseFreshness"],
githubBranchStatus: GitHubBranchStatus,
): LocalBranchAnalysis["localFindings"] {
const failedValidation = (input.validation ?? []).filter((entry) => entry.status === "failed");
return [
Expand Down Expand Up @@ -500,6 +598,7 @@ function buildLocalFindings(
},
]
: []),
...githubBranchFindings(githubBranchStatus),
...scorePreview.warnings.map((warning) => ({
code: "score_preview_warning",
severity: /not registered|no active|exceeds|credibility/i.test(warning) ? ("warning" as const) : ("info" as const),
Expand All @@ -516,6 +615,32 @@ function buildLocalFindings(
];
}

function githubBranchFindings(status: GitHubBranchStatus): LocalBranchAnalysis["localFindings"] {
if (status.status === "failing_checks" || status.status === "needs_author") {
return [
{
code: "github_status_needs_work",
severity: "warning" as const,
title: status.status === "needs_author" ? "GitHub review needs author" : "GitHub checks need attention",
detail: status.notes.join(" "),
action: "Resolve GitHub review/check blockers before asking for maintainer review.",
},
];
}
if (status.status === "unknown") {
return [
{
code: "github_status_unknown",
severity: "info" as const,
title: "GitHub status is incomplete",
detail: status.notes.join(" "),
action: "Refresh GitHub checks and reviews before final submission.",
},
];
}
return [];
}

function buildBaseFreshness(
input: LocalBranchAnalysisInput,
changedFileCount: number,
Expand Down Expand Up @@ -625,6 +750,7 @@ function buildPublicSafePrPacket(args: {
laneSummary: string;
localFindings: LocalBranchAnalysis["localFindings"];
baseFreshness: LocalBranchAnalysis["baseFreshness"];
githubBranchStatus: GitHubBranchStatus;
recommendedRerunCondition: string;
}): LocalBranchAnalysis["prPacket"] {
const topPaths = args.changedFiles.slice(0, 8).map(changedFileSummary);
Expand Down Expand Up @@ -654,6 +780,7 @@ function buildPublicSafePrPacket(args: {
lines: args.preflight.linkedIssues.length > 0 ? args.preflight.linkedIssues.map((issue) => `- Closes #${issue}`) : ["- No linked issue detected; explain why this is a no-issue PR."],
},
{ heading: "Branch Freshness", lines: branchFreshnessLines(args.baseFreshness) },
{ heading: "GitHub Status", lines: githubStatusLines(args.githubBranchStatus) },
{ heading: "Overlap/WIP Check", lines: overlapCautionLines(args.preflight.collisions) },
{
heading: "Changed Paths",
Expand Down Expand Up @@ -683,6 +810,11 @@ function branchFreshnessLines(freshness: LocalBranchAnalysis["baseFreshness"]):
return [`- Base freshness: ${freshness.status}.`, ...freshness.warnings.filter(isPublicSafeText).map((warning) => `- ${warning}`), freshness.passedValidationCount > 0 ? `- Validation evidence supplied: ${freshness.passedValidationCount} passed command(s).` : "- No passed validation evidence was supplied."];
}

function githubStatusLines(status: GitHubBranchStatus): string[] {
if (status.status === "no_pr") return ["- No open GitHub PR was matched to this branch."];
return [`- PR #${status.pullNumber}: ${status.status.replace(/_/g, " ")}.`, ...status.notes.map((note) => `- ${note}`)].filter(isPublicSafeText);
}

function overlapCautionLines(collisions: LocalDiffPreflightResult["collisions"]): string[] {
if (collisions.length === 0) return ["- No active overlap or WIP was detected from cached issue/PR metadata."];
return collisions
Expand Down
Loading