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
36 changes: 34 additions & 2 deletions src/services/issue-plan-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ import {
recordAuditEvent,
sumAiEstimatedNeuronsSince,
} from "../db/repositories";
import { isGittensorPluginEnabled, shouldEnableGittensorForRepo } from "../review/gittensor-wire";
import { isGlobalAgentPause } from "../settings/agent-execution";
import { DEFAULT_TYPE_LABELS } from "../settings/pr-type-label";
import { resolveRepositorySettings } from "../settings/repository-settings";
import { isFocusManifestPublicSafe } from "../signals/focus-manifest";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { normalizeIssueTitleKey } from "./contributor-issue-draft";
import type { IssueRecord } from "../types";
import { sha256Hex } from "../utils/crypto";
Expand Down Expand Up @@ -224,6 +228,32 @@ function buildIssuePlanUserPrompt(goal: string, existingLabelNames: string[]): s
return `Planning goal:\n${goal}\n\nRepository's existing labels (prefer reusing these):\n${labelsLine}`;
}

/**
* Gittensor-enrollment-aware enrichment (#7428): when this repo has opted into the gittensor plugin
* (LOOPOVER_EXPERIMENTAL_GITTENSOR AND the repo's own `.loopover.yml experimental.gittensor: true` --
* shouldEnableGittensorForRepo, gittensor-wire.ts), this repo's configured bug/feature/priority type-label
* taxonomy is merged into the "existing labels" the prompt already prefers reusing (buildIssuePlanUserPrompt) --
* purely additive, no new prompt section, so a plan generated for a gittensor repo just sees a few more labels
* to choose from. Label NAMES only, never any scoring weight/multiplier -- those never leave the private
* scoring pipeline.
*
* ZERO FOOTPRINT when the plugin is off (the fleet-wide default, matching gittensor-wire.ts's own contract): the
* global flag check short-circuits before this ever loads a manifest or repository settings, so a plain
* self-host instance with no gittensor affiliation makes no additional reads for this at all.
*
* Uses resolveRepositorySettings (DB overlaid with `.loopover.yml`), NOT the raw DB-only getRepositorySettings:
* typeLabels/typeLabelsEnabled are config-as-code only now (no DB column backs them, #6443) -- reading the raw
* DB row would silently ignore a repo's actual configured taxonomy and always see the built-in default.
*/
async function resolveGittensorLabelEnrichment(env: Env, repoFullName: string): Promise<string[]> {
if (!isGittensorPluginEnabled(env)) return [];
const manifest = await loadRepoFocusManifest(env, repoFullName);
if (!shouldEnableGittensorForRepo(env, manifest.experimental.gittensor)) return [];
const settings = await resolveRepositorySettings(env, repoFullName);
if (settings.typeLabelsEnabled !== true) return [];
return Object.values(settings.typeLabels ?? DEFAULT_TYPE_LABELS);
}

function normalizeIssuePlanCandidate(raw: RawIssuePlanCandidate): { title: string; body: string; labels: string[] } | null {
const title = typeof raw.title === "string" ? raw.title.trim().slice(0, MAX_TITLE_CHARS) : "";
const body = typeof raw.body === "string" ? raw.body.trim().slice(0, MAX_BODY_CHARS) : "";
Expand Down Expand Up @@ -362,15 +392,17 @@ export async function generateIssuePlanDrafts(env: Env, repoFullName: string, go
const trimmedGoal = goal.trim().slice(0, MAX_GOAL_CHARS);
if (!trimmedGoal) return empty("no_output");

const [repo, openIssues, declinedIssues, labels] = await Promise.all([
const [repo, openIssues, declinedIssues, labels, gittensorTypeLabels] = await Promise.all([
getRepository(env, repoFullName),
listOpenIssues(env, repoFullName),
listClosedContributorDraftIssues(env, repoFullName, `<!-- ${ISSUE_PLAN_DRAFT_MARKER_PREFIX}`),
listRepoLabels(env, repoFullName),
resolveGittensorLabelEnrichment(env, repoFullName),
]);

const system = ISSUE_PLAN_SYSTEM_PROMPT;
const user = buildIssuePlanUserPrompt(trimmedGoal, labels.map((label) => label.name));
const promptLabelNames = [...new Set([...labels.map((label) => label.name), ...gittensorTypeLabels])];
const user = buildIssuePlanUserPrompt(trimmedGoal, promptLabelNames);
const estimatedNeurons = estimateNeurons(system.length + user.length, ISSUE_PLAN_MAX_TOKENS, ISSUE_PLAN_MODEL_COUNT);
const remainingBudget = Math.max(0, issuePlanDailyBudget(env) - (await sumAiEstimatedNeuronsSince(env, utcDayStartIso())));
if (estimatedNeurons > remainingBudget) {
Expand Down
78 changes: 78 additions & 0 deletions test/unit/issue-plan-draft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { generateKeyPairSync } from "node:crypto";
import { createTestEnv } from "../helpers/d1";
import { clearInstallationTokenCacheForTest } from "../../src/github/app";
import * as repositories from "../../src/db/repositories";
import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader";
import * as repositorySettings from "../../src/settings/repository-settings";
import type { IssueRecord, RepositoryRecord } from "../../src/types";
import {
findDeclinedIssuePlanDraft,
Expand Down Expand Up @@ -456,6 +458,82 @@ describe("generateIssuePlanDrafts (#7426)", () => {
});
});

function capturedUserMessage(run: ReturnType<typeof vi.fn>): string {
const opts = (run.mock.calls[0] as unknown as [string, { messages?: Array<{ role: string; content: string }> }])[1];
return opts?.messages?.find((message) => message.role === "user")?.content ?? "";
}

describe("gittensor label-taxonomy enrichment (#7428)", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
clearInstallationTokenCacheForTest();
});

it("makes zero additional reads and enriches nothing when the plugin is off fleet-wide (default), even if the repo's own manifest opts in", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai }); // LOOPOVER_EXPERIMENTAL_GITTENSOR unset
const manifestSpy = vi.spyOn(repositories, "getRepositorySettings");
await upsertRepoFocusManifest(env, "acme/widgets", { experimental: { gittensor: true } });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
expect(capturedUserMessage(run)).not.toContain("gittensor:bug");
expect(manifestSpy).not.toHaveBeenCalled(); // short-circuits before even reading repository settings
});

it("does not enrich when the global flag is on but the repo's own manifest does not opt in", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai, LOOPOVER_EXPERIMENTAL_GITTENSOR: "true" });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
expect(capturedUserMessage(run)).not.toContain("gittensor:bug");
});

it("merges the repo's default type-label taxonomy into the prompt when both the global flag and the repo's manifest are enabled", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai, LOOPOVER_EXPERIMENTAL_GITTENSOR: "true" });
await upsertRepoFocusManifest(env, "acme/widgets", { experimental: { gittensor: true } });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
const userMessage = capturedUserMessage(run);
expect(userMessage).toContain("gittensor:bug");
expect(userMessage).toContain("gittensor:feature");
expect(userMessage).toContain("gittensor:priority");
});

it("does not enrich when typeLabelsEnabled is explicitly false for the repo (config-as-code, #6443)", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai, LOOPOVER_EXPERIMENTAL_GITTENSOR: "true" });
// typeLabels/typeLabelsEnabled have no DB column anymore -- only `.loopover.yml settings.*` can set them.
await upsertRepoFocusManifest(env, "acme/widgets", { experimental: { gittensor: true }, settings: { typeLabelsEnabled: false } });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
expect(capturedUserMessage(run)).not.toContain("gittensor:bug");
});

it("falls back to DEFAULT_TYPE_LABELS when resolved settings carry no typeLabels value at all", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai, LOOPOVER_EXPERIMENTAL_GITTENSOR: "true" });
await upsertRepoFocusManifest(env, "acme/widgets", { experimental: { gittensor: true } });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
vi.spyOn(repositorySettings, "resolveRepositorySettings").mockResolvedValue({ repoFullName: "acme/widgets", typeLabelsEnabled: true, typeLabels: undefined } as never);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
expect(capturedUserMessage(run)).toContain("gittensor:bug");
});

it("honors a custom typeLabels override (config-as-code) instead of the gittensor:* defaults", async () => {
const run = vi.fn(async () => issuesResponse([{ title: "T", body: "B" }]));
const env = baseEnv({ AI: { run } as unknown as Ai, LOOPOVER_EXPERIMENTAL_GITTENSOR: "true" });
await upsertRepoFocusManifest(env, "acme/widgets", { experimental: { gittensor: true }, settings: { typeLabels: { bug: "kind:bug", feature: "kind:feature" } } });
vi.spyOn(repositories, "listOpenIssues").mockResolvedValue([]);
await generateIssuePlanDrafts(env, "acme/widgets", "goal");
const userMessage = capturedUserMessage(run);
expect(userMessage).toContain("kind:bug");
expect(userMessage).toContain("kind:feature");
expect(userMessage).not.toContain("gittensor:bug");
});
});

describe("findDuplicateIssuePlanDraft", () => {
it("returns null for an empty title key or no matches", () => {
expect(findDuplicateIssuePlanDraft([], { fingerprint: "fp", title: "!!!" })).toBeNull();
Expand Down