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
5 changes: 5 additions & 0 deletions src/github/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export type CreateInstallationIssueInput = {
title: string;
body: string;
labels?: string[] | undefined;
/** GitHub milestone NUMBER (not title) to assign on create (#7427). Resolving a title to a number is the
* caller's job (see src/github/milestones.ts + the service-layer resolve-or-create heuristic) -- this
* primitive just passes through whatever number it's given. */
milestone?: number | undefined;
};

export type CreatedInstallationIssue = { number: number; url: string };
Expand Down Expand Up @@ -52,6 +56,7 @@ export async function createInstallationIssue(
title: issue.title,
body: issue.body,
...(issue.labels && issue.labels.length > 0 ? { labels: issue.labels } : {}),
...(issue.milestone !== undefined ? { milestone: issue.milestone } : {}),
});
const data = response.data as { number?: number; html_url?: string; dryRunSuppressed?: boolean };
if (data.dryRunSuppressed) return null;
Expand Down
61 changes: 61 additions & 0 deletions src/github/milestones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { withInstallationTokenRetry } from "./app";
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "./client";
import type { AgentActionMode } from "../settings/agent-execution";

// Mirrors parseRepoFullName in labels.ts / assignees.ts / issues.ts (#7427): each GitHub-write module keeps its
// own copy rather than importing a shared one, matching the existing house convention for this tiny pure check.
function parseRepoFullName(repoFullName: string): { owner: string; repo: string } {
const parts = repoFullName.split("/");
const owner = parts[0];
const repo = parts[1];
if (parts.length !== 2 || !owner || !repo || /\s/.test(repoFullName)) {
throw new Error(`Invalid repository full name: ${repoFullName}`);
}
return { owner, repo };
}

export type InstallationMilestone = { number: number; title: string; description: string | null; dueOn: string | null };

export type CreateInstallationMilestoneInput = { title: string; description?: string | undefined; dueOn?: string | undefined };

/**
* List OPEN milestones via the installation-token/Orb-broker path (#7427) -- the same auth path createInstallationIssue
* (issues.ts, #7425) uses. Capped to GitHub's max per_page (100) with no further pagination: unlike e.g.
* cancelInFlightWorkflowRunsForHeadSha's bounded multi-page workflow-run loop, there is no realistic scenario
* where a repo has more than 100 OPEN milestones, so a second page is not worth the added complexity here.
*/
export async function listOpenInstallationMilestones(env: Env, installationId: number, repoFullName: string, mode: AgentActionMode = "live"): Promise<InstallationMilestone[]> {
const { owner, repo } = parseRepoFullName(repoFullName);
return withInstallationTokenRetry(env, installationId, async (token) => {
const octokit = makeInstallationOctokit(env, token, mode, githubRateLimitAdmissionKeyForInstallation(installationId));
const response = await octokit.request("GET /repos/{owner}/{repo}/milestones", { owner, repo, state: "open", per_page: 100 });
const data = response.data as Array<{ number?: number; title?: string; description?: string | null; due_on?: string | null }>;
return data
.filter((entry): entry is { number: number; title: string; description?: string | null; due_on?: string | null } => typeof entry.number === "number" && typeof entry.title === "string")
.map((entry) => ({ number: entry.number, title: entry.title, description: entry.description ?? null, dueOn: entry.due_on ?? null }));
});
}

/**
* Create a milestone via the same installation-token/Orb-broker path. Returns null (never throws for a
* suppressed/malformed write) when the write is suppressed by a non-live mode or GitHub's response omits the
* fields a caller needs -- mirrors createInstallationIssue's identical contract (issues.ts). A genuine GitHub
* API failure (permission gap, 5xx, rate limit) is NOT swallowed here; it propagates via Octokit's
* throw-on-non-2xx, matching that same contract.
*/
export async function createInstallationMilestone(env: Env, installationId: number, repoFullName: string, input: CreateInstallationMilestoneInput, mode: AgentActionMode = "live"): Promise<InstallationMilestone | null> {
const { owner, repo } = parseRepoFullName(repoFullName);
return withInstallationTokenRetry(env, installationId, async (token) => {
const octokit = makeInstallationOctokit(env, token, mode, githubRateLimitAdmissionKeyForInstallation(installationId));
const response = await octokit.request("POST /repos/{owner}/{repo}/milestones", {
owner,
repo,
title: input.title,
...(input.description ? { description: input.description } : {}),
...(input.dueOn ? { due_on: input.dueOn } : {}),
});
const data = response.data as { number?: number; title?: string; description?: string | null; due_on?: string | null; dryRunSuppressed?: boolean };
if (data.dryRunSuppressed) return null;
return data.number && data.title ? { number: data.number, title: data.title, description: data.description ?? null, dueOn: data.due_on ?? null } : null;
});
}
17 changes: 16 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,13 +666,23 @@ const generateContributorIssueDraftsOutputSchema = {
// #7426: dryRun/create/limit mirror generateContributorIssueDraftsShape's own bounds/defaults (create alone is
// rejected -- the handler re-applies the explicit_create_requires_dry_run_false guard). `limit` is capped lower
// (10, not 20): every draft here costs real LLM spend, unlike that tool's zero-cost static signals.
// #7427: title/description/dueOn are the CALLER's own input, never model-generated -- milestone metadata is
// maintainer-authored/approved by design. Only ever consulted when actually creating; a dry-run preview makes
// no milestone-related GitHub calls at all.
const planRepoIssuesMilestoneShape = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
dueOn: z.string().datetime({ offset: true }).optional(),
});

const planRepoIssuesShape = {
owner: z.string().min(1),
repo: z.string().min(1),
goal: z.string().min(1).max(2000),
dryRun: z.boolean().optional().default(true),
create: z.boolean().optional().default(false),
limit: z.number().int().min(1).max(10).optional().default(5),
milestone: planRepoIssuesMilestoneShape.optional(),
};

const planRepoIssuesOutputSchema = {
Expand Down Expand Up @@ -703,6 +713,9 @@ const planRepoIssuesOutputSchema = {
}),
)
.optional(),
// Set only when a milestone target was given AND creation actually ran AND resolution succeeded (#7427) --
// absent on a dry run, no milestone requested, or a degraded (failed) resolution.
milestoneNumber: z.number().optional(),
};

// #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo.
Expand Down Expand Up @@ -2624,7 +2637,7 @@ export class LoopoverMcp {
"loopover_plan_repo_issues",
{
description:
"AI-plan a small set of concrete GitHub issues from a maintainer-supplied free-form goal, for ANY repo the caller's App/Orb is installed on -- repo-agnostic and gittensor-optional (#7426). Dry-run BY DEFAULT: only PREVIEWS drafts (full title/body/labels) unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues. Creates exclusively via the installation-token/Orb-broker path (#7425), never a flat PAT. Makes a real LLM call subject to the shared daily AI budget and the fleet AI_SUMMARIES_ENABLED/AI_PUBLIC_COMMENTS_ENABLED switches. Maintainer access required.",
"AI-plan a small set of concrete GitHub issues from a maintainer-supplied free-form goal, for ANY repo the caller's App/Orb is installed on -- repo-agnostic and gittensor-optional (#7426). Dry-run BY DEFAULT: only PREVIEWS drafts (full title/body/labels) unless the caller passes BOTH create:true and dryRun:false, so it can never silently open issues. Creates exclusively via the installation-token/Orb-broker path (#7425), never a flat PAT. An optional `milestone` (title/description/dueOn, all maintainer-supplied -- never model-generated) is resolved against existing OPEN milestones by exact normalized title before creating a new one, and assigned to every created issue (#7427). Makes a real LLM call subject to the shared daily AI budget and the fleet AI_SUMMARIES_ENABLED/AI_PUBLIC_COMMENTS_ENABLED switches. Maintainer access required.",
inputSchema: planRepoIssuesShape,
outputSchema: planRepoIssuesOutputSchema,
},
Expand Down Expand Up @@ -4321,6 +4334,7 @@ export class LoopoverMcp {
create: input.create,
limit: input.limit,
requestedBy: this.identity.kind === "session" ? this.identity.actor : "mcp",
milestone: input.milestone,
});
return {
summary: `Issue plan for ${fullName} (status=${result.status}, dryRun=${result.dryRun}): ${result.proposed} proposed, ${result.created} created, ${result.skippedDuplicate} duplicate, ${result.skippedDeclined} declined, ${result.skippedUnsafe} unsafe.`,
Expand All @@ -4335,6 +4349,7 @@ export class LoopoverMcp {
skippedDeclined: result.skippedDeclined,
skippedUnsafe: result.skippedUnsafe,
created: result.created,
...(result.milestoneNumber !== undefined ? { milestoneNumber: result.milestoneNumber } : {}),
skippedCreateFailed: result.skippedCreateFailed,
drafts: result.drafts.map((draft) => ({
title: draft.title,
Expand Down
58 changes: 55 additions & 3 deletions src/services/issue-plan-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
utcDayStartIso,
} from "./ai-review";
import { createInstallationIssue } from "../github/issues";
import { createInstallationMilestone, listOpenInstallationMilestones } from "../github/milestones";
import { isMaintainerAssociation } from "../github/commands";
import {
getRepository,
Expand Down Expand Up @@ -242,10 +243,11 @@ async function createIssuePlanDraftIssue(
repoFullName: string,
draft: Pick<IssuePlanDraft, "title" | "body" | "labels">,
installationId: number | null | undefined,
milestoneNumber: number | undefined,
): Promise<{ number: number; url: string } | null> {
if (!installationId) return null;
try {
return await createInstallationIssue(env, installationId, repoFullName, { title: draft.title, body: draft.body, labels: draft.labels });
return await createInstallationIssue(env, installationId, repoFullName, { title: draft.title, body: draft.body, labels: draft.labels, milestone: milestoneNumber });
} catch (error) {
console.warn(
JSON.stringify({ level: "warn", event: "issue_plan_draft_create_failed", repoFullName, message: errorMessage(error).slice(0, 200) }),
Expand All @@ -254,11 +256,52 @@ async function createIssuePlanDraftIssue(
}
}

export type IssuePlanMilestoneTarget = { title: string; description?: string | undefined; dueOn?: string | undefined };

/**
* Resolve-or-create a milestone for the batch about to be created (#7427). Reuse is an EXACT normalized-title
* match against existing OPEN milestones only (normalizeIssueTitleKey, already generic despite living on the
* issue-draft side) -- deliberately not fuzzy: a fuzzy match risks silently grouping issues under the wrong
* milestone, which is worse than creating a new, differently-named one. Mirrors the manual convention
* `.claude/skills/contributor-pipeline-gardening` already follows (reusing one milestone across recurring
* rounds) instead of spawning a fresh one every planning run under the same title.
*
* Degrades to null on ANY failure (including a malformed/suppressed create) rather than aborting the batch --
* the maintainer still gets their issues, just ungrouped, which is preferable to filing nothing at all over a
* milestone-specific glitch. Titles/descriptions/due dates are the CALLER's own input, never model-generated
* (see the epic's #7427 boundary: milestone metadata is maintainer-authored/approved, not invented by the AI).
*/
async function resolveOrCreateIssuePlanMilestone(
env: Env,
installationId: number,
repoFullName: string,
target: IssuePlanMilestoneTarget,
): Promise<number | undefined> {
try {
const titleKey = normalizeIssueTitleKey(target.title);
if (titleKey) {
const existing = await listOpenInstallationMilestones(env, installationId, repoFullName);
const match = existing.find((milestone) => normalizeIssueTitleKey(milestone.title) === titleKey);
if (match) return match.number;
}
const created = await createInstallationMilestone(env, installationId, repoFullName, { title: target.title, description: target.description, dueOn: target.dueOn });
return created?.number;
} catch (error) {
console.warn(
JSON.stringify({ level: "warn", event: "issue_plan_milestone_resolve_failed", repoFullName, message: errorMessage(error).slice(0, 200) }),
);
return undefined;
}
}

export type IssuePlanDraftOptions = {
dryRun?: boolean | undefined;
create?: boolean | undefined;
limit?: number | undefined;
requestedBy?: string | undefined;
/** Optional milestone to resolve-or-create and assign created issues to (#7427). Only ever consulted when
* actually creating ({create:true, dryRun:false}) -- a dry-run preview makes no GitHub calls for it at all. */
milestone?: IssuePlanMilestoneTarget | undefined;
};

export type IssuePlanGenerationStatus = "ok" | "disabled" | "unavailable" | "quota_exceeded" | "no_output";
Expand All @@ -276,6 +319,10 @@ export type IssuePlanGenerationResult = {
created: number;
skippedCreateFailed: number;
drafts: IssuePlanDraft[];
/** Set only when a milestone was actually resolved-or-created this call (i.e. a milestone target was given
* AND creation actually ran AND resolution succeeded). Absent on a dry run, a disabled/unavailable/quota
* short-circuit, no milestone requested, or a degraded (failed) resolution. */
milestoneNumber?: number | undefined;
};

function emptyIssuePlanResult(
Expand Down Expand Up @@ -342,6 +389,11 @@ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, go
});
if (rawCandidates.length === 0) return empty("no_output");

const milestoneNumber =
!dryRun && createRequested && options.milestone && repo?.installationId
? await resolveOrCreateIssuePlanMilestone(env, repo.installationId, repoFullName, options.milestone)
: undefined;

const drafts: IssuePlanDraft[] = [];
let proposed = 0;
let skippedDuplicate = 0;
Expand Down Expand Up @@ -389,7 +441,7 @@ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, go
}

if (!dryRun && createRequested) {
const issue = await createIssuePlanDraftIssue(env, repoFullName, draft, repo?.installationId);
const issue = await createIssuePlanDraftIssue(env, repoFullName, draft, repo?.installationId, milestoneNumber);
if (issue) {
draft.status = "created";
draft.issue = issue;
Expand All @@ -413,5 +465,5 @@ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, go
});
}

return { repoFullName, generatedAt, status: "ok", dryRun, createRequested, proposed, skippedDuplicate, skippedDeclined, skippedUnsafe, created, skippedCreateFailed, drafts };
return { repoFullName, generatedAt, status: "ok", dryRun, createRequested, proposed, skippedDuplicate, skippedDeclined, skippedUnsafe, created, skippedCreateFailed, drafts, milestoneNumber };
}
Loading