diff --git a/src/github/commands.ts b/src/github/commands.ts index 8c7a0d10be..b714d049db 100644 --- a/src/github/commands.ts +++ b/src/github/commands.ts @@ -4,7 +4,7 @@ import { suggestCommand as suggestCommandFromCatalog, type CommandSuggestCatalog, } from "./command-suggest"; -import { gittensoryFooter, GITTENSORY_SITE_URL, type GittensoryFooterEnv } from "./footer"; +import { commandReferenceUrl, gittensoryFooter, type GittensoryFooterEnv } from "./footer"; import type { AgentRunBundle } from "../services/agent-orchestrator"; import type { ChatQaResult } from "../services/ai-chat-qa"; import type { GittensorContributorSnapshot, OfficialGittensorMinerDetection } from "../gittensor/api"; @@ -930,20 +930,6 @@ function actionCommandHelpSections(): string[] { ]; } -/** The public command-reference doc link for `@gittensory help` (#4670). `new URL(path, origin)` -- same - * idiom as the sibling `maintainerControlPanelUrl` in footer.ts -- so a `PUBLIC_SITE_ORIGIN` with or - * without a trailing slash both resolve correctly instead of risking a double slash from naive - * concatenation. Falls back to the literal path string only if origin resolution itself throws (an - * operator-misconfigured PUBLIC_SITE_ORIGIN should degrade the link, not crash comment rendering). */ -function commandReferenceUrl(env: GittensoryFooterEnv): string { - const origin = env.PUBLIC_SITE_ORIGIN ?? GITTENSORY_SITE_URL; - try { - return new URL("/docs/gittensory-commands", origin).toString(); - } catch { - return `${GITTENSORY_SITE_URL}/docs/gittensory-commands`; - } -} - function helpSections(env: GittensoryFooterEnv, unknownVerb?: string | undefined): string[] { return [ "**Commands**", diff --git a/src/github/footer.ts b/src/github/footer.ts index 317ac60819..79c3335580 100644 --- a/src/github/footer.ts +++ b/src/github/footer.ts @@ -33,6 +33,23 @@ export function maintainerControlPanelUrl(env: { PUBLIC_SITE_ORIGIN?: string | u return null; } } +/** The public command-reference doc link for `@gittensory help` (#4670) and the "[BETA] Chat with + * Gittensory" public-safe collapsible (#5078). Lives here (not commands.ts/engine.ts) so both can import + * the SAME resolved URL without a circular module edge -- commands.ts already imports from signals/engine.ts, + * so engine.ts importing back from commands.ts would cycle. `new URL(path, origin)` -- same idiom as the + * sibling `maintainerControlPanelUrl` above -- so a `PUBLIC_SITE_ORIGIN` with or without a trailing slash + * both resolve correctly instead of risking a double slash from naive concatenation. Falls back to the + * literal path string only if origin resolution itself throws (an operator-misconfigured PUBLIC_SITE_ORIGIN + * should degrade the link, not crash comment rendering). */ +export function commandReferenceUrl(env: GittensoryFooterEnv): string { + const origin = env.PUBLIC_SITE_ORIGIN ?? GITTENSORY_SITE_URL; + try { + return new URL("/docs/gittensory-commands", origin).toString(); + } catch { + return `${GITTENSORY_SITE_URL}/docs/gittensory-commands`; + } +} + /** The Gittensor network — where GitHub contributors register to earn for their contributions. */ export const GITTENSOR_HOME_URL = "https://gittensor.io"; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 50069b9f93..7f8d921d30 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -10022,6 +10022,7 @@ async function maybePublishPrPublicSurface( // function's own e2eTestGenAvailable const), rather than a second detection pass. ...(missingTestsFinding !== undefined ? { missingTestsFinding } : {}), e2eTestGenAvailable, + env, }), footerMarkdown: gittensoryFooter(env, { earnUrl: repo?.isRegistered diff --git a/src/signals/engine.ts b/src/signals/engine.ts index f9ece57a49..1b233adb57 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -20,7 +20,7 @@ import type { ScoringModelSnapshotRecord, } from "../types"; import type { PublicContributorProfile } from "../github/public"; -import { gittensoryFooter, gittensorRepoEarnUrl, type GittensoryFooterEnv } from "../github/footer"; +import { commandReferenceUrl, gittensoryFooter, gittensorRepoEarnUrl, type GittensoryFooterEnv } from "../github/footer"; import type { FocusManifestReviewConfig, ReviewFieldKey } from "./focus-manifest"; import type { GittensorContributorSnapshot } from "../gittensor/api"; import { nowIso } from "../utils/json"; @@ -4154,6 +4154,10 @@ type PublicSafeCollapsibleArgs = { * resolveConvergedFeature("e2eTests") check the checkbox itself is gated on) -- controls whether the * collapsible below points the reader at the checkbox, or just states the gap with no next step. */ e2eTestGenAvailable?: boolean | undefined; + /** #5078: resolved by the caller from `env.PUBLIC_SITE_ORIGIN`, same as `buildPublicPrIntelligenceComment`'s + * own `env` param -- lets the "[BETA] Chat with Gittensory" collapsible link to a self-hoster's own + * command-reference doc page instead of always the canonical gittensory.aethereal.dev. */ + env: GittensoryFooterEnv; }; /** "Signal definitions" body — a static legend for the readiness signals. No inputs. */ @@ -4184,6 +4188,28 @@ function testCoverageBody(args: PublicSafeCollapsibleArgs): string[] { ]; } +/** "[BETA] Chat with Gittensory" body (#5078) — empty (thus invisible, same convention as "Test coverage" + * above) unless `chatQa` or `intentRouting` is enabled for this repo, so a repo that hasn't opted into + * either capability never advertises a command that would just decline. Descriptions are byte-identical + * to `@gittensory help`'s own listing (helpSections in github/commands.ts) so the two surfaces never + * describe the same commands differently. */ +function chatBetaBody(args: PublicSafeCollapsibleArgs): string[] { + const chatQaEnabled = args.settings.advisoryAiRouting?.chatQa === true; + const intentRoutingEnabled = args.settings.advisoryAiRouting?.intentRouting === true; + if (!chatQaEnabled && !intentRoutingEnabled) return []; + return [ + "Ask Gittensory a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.", + "", + "- `@gittensory ask ` answers contribution-quality Q&A with source citations and freshness.", + "- `@gittensory chat ` answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).", + ...(intentRoutingEnabled + ? ["- A plain-language `@gittensory` mention with a real question is routed to the closest matching read-only command automatically -- no exact syntax required."] + : []), + "", + `Full command reference: ${commandReferenceUrl(args.env)}`, + ]; +} + /** "Review context" body — public author/role/lane/profile context plus any PR-specific overlap detail. */ function reviewContextBody(args: PublicSafeCollapsibleArgs): string[] { const roleContext = buildRoleContext({ @@ -4227,9 +4253,11 @@ export function buildPublicSafeCollapsibles(args: PublicSafeCollapsibleArgs): Un { title: "Review context", body: reviewContextBody(args).join("\n") }, { title: "Contributor next steps", body: contributorNextStepsBody(publicSafeNextSteps(args)).join("\n") }, { title: "Signal definitions", body: signalDefinitionsBody().join("\n") }, - // #4589: last, after Signal definitions -- empty (thus invisible, per the caller's empty-body skip) unless + // #4589: after Signal definitions -- empty (thus invisible, per the caller's empty-body skip) unless // there's an actual coverage gap AND the generate-tests checkbox is available for this repo. { title: "Test coverage", body: testCoverageBody(args).join("\n") }, + // #5078: last -- empty (thus invisible) unless chatQa or intentRouting is enabled for this repo. + { title: "[BETA] Chat with Gittensory", body: chatBetaBody(args).join("\n") }, ]; } diff --git a/test/unit/unified-comment-parity.test.ts b/test/unit/unified-comment-parity.test.ts index b3cc5850a5..b496f0d2a2 100644 --- a/test/unit/unified-comment-parity.test.ts +++ b/test/unit/unified-comment-parity.test.ts @@ -10,6 +10,7 @@ import { detectGittensorContributor, } from "../../src/signals/engine"; import { buildUnifiedCommentBody } from "../../src/review/unified-comment-bridge"; +import { GITTENSORY_SITE_URL } from "../../src/github/footer"; import type { GateCheckEvaluation } from "../../src/rules/advisory"; import type { IssueRecord, PullRequestRecord, RepositoryRecord, RepositorySettings } from "../../src/types"; @@ -119,7 +120,7 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { reviewerCount: aiReview.reviewerCount, footerMarkdown: "💰 Earn for open-source contributions like this. Checked by Gittensory.", reRunLabel: "gittensory-pr-panel:retrigger Re-run Gittensory review", - extraCollapsibles: buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth }), + extraCollapsibles: buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {} }), }); // The three public-safe sections the legacy panel carried must survive into the converged comment. @@ -132,15 +133,19 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { expect(body).not.toContain("Maintainer notes"); // #4589: no coverage gap was supplied here, so "Test coverage" stays an empty (thus invisible) collapsible. expect(body).not.toContain("Test coverage"); + // #5078: advisoryAiRouting isn't set in the base `settings` fixture, so the beta chat collapsible stays empty too. + expect(body).not.toContain("[BETA] Chat with Gittensory"); }); it("never includes a duplicate AI 'Review details' collapsible", () => { const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); - const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth }); - // #4589: "Test coverage" is always present (title-wise) after Signal definitions, but its body is empty - // (thus invisible when rendered) whenever missingTestsFinding/e2eTestGenAvailable aren't supplied, as here. - expect(collapsibles.map((section) => section.title)).toEqual(["Review context", "Contributor next steps", "Signal definitions", "Test coverage"]); + const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {} }); + // #4589/#5078: "Test coverage" and "[BETA] Chat with Gittensory" are always present (title-wise) after + // Signal definitions, but their bodies are empty (thus invisible when rendered) whenever their respective + // gating inputs aren't supplied, as here. + expect(collapsibles.map((section) => section.title)).toEqual(["Review context", "Contributor next steps", "Signal definitions", "Test coverage", "[BETA] Chat with Gittensory"]); expect(collapsibles.find((section) => section.title === "Test coverage")?.body).toBe(""); + expect(collapsibles.find((section) => section.title === "[BETA] Chat with Gittensory")?.body).toBe(""); expect(collapsibles.map((section) => section.title)).not.toContain("Review details"); // No section may carry the private maintainer-notes content. expect(collapsibles.map((section) => section.title)).not.toContain("Maintainer notes"); @@ -151,7 +156,7 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); const aiReview = { notes: "Looks reasonable. Add a regression test for reconnect.", reviewerCount: 2 }; const legacy = buildPublicPrIntelligenceComment({env: {}, repo, pr: currentPr, profile, detection, queueHealth, collisions, preflight, settings, aiReview }); - const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth }); + const collapsibles = buildPublicSafeCollapsibles({ repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {} }); // Each shared collapsible body's individual lines must appear verbatim in the legacy panel so the two // renderers can never diverge on the public-safe content. @@ -179,7 +184,7 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { it("renders the gap detail + a pointer to the checkbox when a coverage gap exists AND e2eTests is available", () => { const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); const collapsibles = buildPublicSafeCollapsibles({ - repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, + repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {}, missingTestsFinding: { detail: "No changed test files or passing validation evidence were detected for this PR." }, e2eTestGenAvailable: true, }); @@ -192,7 +197,7 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { it("stays empty when a coverage gap exists but e2eTests is NOT available for this repo", () => { const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); const collapsibles = buildPublicSafeCollapsibles({ - repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, + repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {}, missingTestsFinding: { detail: "No changed test files or passing validation evidence were detected for this PR." }, e2eTestGenAvailable: false, }); @@ -202,10 +207,55 @@ describe("converged comment ↔ legacy panel parity (#unified-comment)", () => { it("stays empty when e2eTests is available but there is no coverage gap to report", () => { const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); const collapsibles = buildPublicSafeCollapsibles({ - repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, + repo, pr: currentPr, profile, detection, settings, collisions, preflight, queueHealth, env: {}, e2eTestGenAvailable: true, }); expect(collapsibles.find((section) => section.title === "Test coverage")?.body).toBe(""); }); }); + + // #5078: the "[BETA] Chat with Gittensory" collapsible points readers at the ask/chat commands -- empty + // (thus invisible) unless the repo has opted into chatQa or intentRouting, mirroring #4589's own + // "never mention a command that would bounce" principle. + describe("[BETA] Chat with Gittensory collapsible (#5078)", () => { + const advisoryAiRoutingAllOff = { + slop: false, e2eTestGen: false, planner: false, summaries: false, + chatQa: false, chatQaFrontierFallback: false, intentRouting: false, + }; + + it("stays empty when neither chatQa nor intentRouting is enabled", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const collapsibles = buildPublicSafeCollapsibles({ + repo, pr: currentPr, profile, detection, collisions, preflight, queueHealth, env: {}, + settings: { ...settings, advisoryAiRouting: advisoryAiRoutingAllOff }, + }); + expect(collapsibles.find((section) => section.title === "[BETA] Chat with Gittensory")?.body).toBe(""); + }); + + it("renders ask/chat usage + the docs link when chatQa is enabled", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const collapsibles = buildPublicSafeCollapsibles({ + repo, pr: currentPr, profile, detection, collisions, preflight, queueHealth, + env: { PUBLIC_SITE_ORIGIN: "https://example-selfhost.test" }, + settings: { ...settings, advisoryAiRouting: { ...advisoryAiRoutingAllOff, chatQa: true } }, + }); + const beta = collapsibles.find((section) => section.title === "[BETA] Chat with Gittensory"); + expect(beta?.body).toContain("`@gittensory ask `"); + expect(beta?.body).toContain("`@gittensory chat `"); + expect(beta?.body).toContain("https://example-selfhost.test/docs/gittensory-commands"); + // Intent routing is off in this fixture, so its plain-language line must not appear. + expect(beta?.body).not.toContain("Plain-language"); + }); + + it("renders + mentions plain-language routing when intentRouting is enabled, even with chatQa off", () => { + const { currentPr, detection, collisions, queueHealth, preflight, profile } = buildFixtures(); + const collapsibles = buildPublicSafeCollapsibles({ + repo, pr: currentPr, profile, detection, collisions, preflight, queueHealth, env: {}, + settings: { ...settings, advisoryAiRouting: { ...advisoryAiRoutingAllOff, intentRouting: true } }, + }); + const beta = collapsibles.find((section) => section.title === "[BETA] Chat with Gittensory"); + expect(beta?.body).toContain("routed to the closest matching read-only command"); + expect(beta?.body).toContain(GITTENSORY_SITE_URL); + }); + }); });