From 04584e7ff214450440875ce4ab4bef33bb8efca9 Mon Sep 17 00:00:00 2001 From: YB0y <231405196+YB0y@users.noreply.github.com> Date: Fri, 19 Jun 2026 06:54:22 +0200 Subject: [PATCH 1/3] feat(pmf): advisory newcomer-PR auto-guide (Phase-1-lite, advisory only) (#803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in per-repo setting newcomerGuideMode (off/enabled, default off). When enabled, the webhook posts a one-time welcoming advisory comment on first-time-contributor PRs (0 merged PRs in the repo, reusing #552 detection). The guide surfaces gate findings in a newcomer-friendly way: what to fix, what makes a PR merge-worthy, and an anti-slop reminder. Advisory only — never blocks, never auto-merges. All text is public-safe. - migrations/0047: add newcomer_guide_mode column (default 'off') - src/types.ts: newcomerGuideMode field on RepositorySettings - src/db/schema.ts + repositories.ts: column, defaults, parse, upsert - src/signals/newcomer-guide.ts: buildNewcomerGuideComment message builder - src/github/comments.ts: NEWCOMER_GUIDE_COMMENT_MARKER + posting helper - src/queue/processors.ts: fires after gate finalization, before public surface; independent of publicSurface/commentMode so it works even when comments are off; best-effort (try/catch + audit), idempotent (marker-based update) - src/signals/focus-manifest.ts: gate.newcomerGuide alias + settings override - src/signals/settings-preview.ts + src/openapi/schemas.ts: preview/API surface - test/unit/newcomer-guide.test.ts: 9 unit tests covering marker, welcome, finding-specific guidance, merge-worthy checklist, gate status, dedup, truncation Closes #803 --- apps/gittensory-ui/public/openapi.json | 11 +- migrations/0047_newcomer_guide_mode.sql | 4 + src/db/repositories.ts | 9 ++ src/db/schema.ts | 1 + src/github/comments.ts | 12 ++ src/openapi/schemas.ts | 2 + src/queue/processors.ts | 42 ++++++- src/signals/focus-manifest.ts | 11 +- src/signals/newcomer-guide.ts | 139 +++++++++++++++++++++++ src/signals/settings-preview.ts | 2 + src/types.ts | 5 + test/unit/focus-manifest.test.ts | 4 +- test/unit/newcomer-guide.test.ts | 141 ++++++++++++++++++++++++ 13 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 migrations/0047_newcomer_guide_mode.sql create mode 100644 src/signals/newcomer-guide.ts create mode 100644 test/unit/newcomer-guide.test.ts diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index a282fa0de3..8ac91d710f 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -8116,6 +8116,10 @@ }, "agentDryRun": { "type": "boolean" + }, + "newcomerGuideMode": { + "type": "string", + "enum": ["off", "enabled"] } }, "required": [ @@ -8143,7 +8147,8 @@ "requireLinkedIssue", "backfillEnabled", "privateTrustEnabled", - "commandAuthorization" + "commandAuthorization", + "newcomerGuideMode" ] }, "InstallationRepair": { @@ -8619,6 +8624,10 @@ "requireLinkedIssue": { "type": "boolean" }, + "newcomerGuideMode": { + "type": "string", + "enum": ["off", "enabled"] + }, "commandAuthorization": { "type": "object", "properties": { diff --git a/migrations/0047_newcomer_guide_mode.sql b/migrations/0047_newcomer_guide_mode.sql new file mode 100644 index 0000000000..7900ba3fba --- /dev/null +++ b/migrations/0047_newcomer_guide_mode.sql @@ -0,0 +1,4 @@ +-- Advisory newcomer-PR auto-guide (#803, Phase-1-lite). When enabled, the webhook posts a one-time +-- welcoming advisory comment on first-time-contributor PRs (0 merged PRs in the repo). Advisory +-- only — never blocks, never auto-merges. Reuses the #552 newcomer detection. Default 'off'. +ALTER TABLE repository_settings ADD COLUMN newcomer_guide_mode TEXT NOT NULL DEFAULT 'off'; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 848fab577a..814cd482f6 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -432,6 +432,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise badgeEnabled: false, agentPaused: false, agentDryRun: false, + newcomerGuideMode: "off", commandAuthorization: normalizeCommandAuthorizationPolicy(DEFAULT_COMMAND_AUTHORIZATION_POLICY).policy, autonomy: {}, autoMaintain: { ...DEFAULT_AUTO_MAINTAIN_POLICY }, @@ -471,6 +472,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise badgeEnabled: row.badgeEnabled, agentPaused: row.agentPaused, agentDryRun: row.agentDryRun, + newcomerGuideMode: parseNewcomerGuideMode(row.newcomerGuideMode), commandAuthorization: parseCommandAuthorizationPolicy(row.commandAuthorizationJson), autonomy: parseAutonomyPolicy(row.autonomyJson), autoMaintain: parseAutoMaintainPolicy(row.autoMaintainJson), @@ -514,6 +516,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }); diff --git a/src/github/comments.ts b/src/github/comments.ts index 8cb870eb6c..e0de59d752 100644 --- a/src/github/comments.ts +++ b/src/github/comments.ts @@ -4,6 +4,7 @@ import { createInstallationToken } from "./app"; export const PR_PANEL_COMMENT_MARKER = ""; export const PR_INTELLIGENCE_COMMENT_MARKER = PR_PANEL_COMMENT_MARKER; export const AGENT_COMMAND_COMMENT_MARKER = PR_PANEL_COMMENT_MARKER; +export const NEWCOMER_GUIDE_COMMENT_MARKER = ""; const LEGACY_PR_INTELLIGENCE_COMMENT_MARKER = ""; const LEGACY_AGENT_COMMAND_COMMENT_MARKER = ""; @@ -38,6 +39,17 @@ export async function createOrUpdateAgentCommandComment( return createOrUpdateIssueCommentWithMarker(env, installationId, repoFullName, issueNumber, body, AGENT_COMMAND_COMMENT_MARKER); } +export async function createOrUpdateNewcomerGuideComment( + env: Env, + installationId: number, + repoFullName: string, + pullNumber: number, + body: string, + options: { createIfMissing?: boolean | undefined } = {}, +): Promise<{ id: number; html_url?: string } | null> { + return createOrUpdateIssueCommentWithMarker(env, installationId, repoFullName, pullNumber, body, NEWCOMER_GUIDE_COMMENT_MARKER, options); +} + async function createOrUpdateIssueCommentWithMarker( env: Env, installationId: number, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 448d3a90e3..46952d4ff2 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -587,6 +587,7 @@ export const RepositorySettingsSchema = z autoMaintain: z.object({ requireApprovals: z.number().int(), mergeMethod: z.enum(["merge", "squash", "rebase"]) }).optional(), agentPaused: z.boolean().optional(), agentDryRun: z.boolean().optional(), + newcomerGuideMode: z.enum(["off", "enabled"]), createdAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(), }) @@ -619,6 +620,7 @@ export const RepoSettingsPreviewSchema = z createMissingLabel: z.boolean(), includeMaintainerAuthors: z.boolean(), requireLinkedIssue: z.boolean(), + newcomerGuideMode: z.enum(["off", "enabled"]), commandAuthorization: z.object({ defaultAllowed: z.array(z.enum(["maintainer", "collaborator", "pr_author", "confirmed_miner"])), commandOverrides: z.array( diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6739305784..6a60586d95 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -67,7 +67,7 @@ import { } from "../github/backfill"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot, fetchOfficialGittensorMiner, type GittensorContributorSnapshot, type OfficialGittensorMinerDetection } from "../gittensor/api"; import { createOrUpdateCheckRun, createOrUpdateErroredGateCheckRun, createOrUpdateGateCheckRun, createOrUpdateOverriddenGateCheckRun, createOrUpdatePendingGateCheckRun, createOrUpdateSkippedGateCheckRun, getInstallationId, getRepositoryCollaboratorPermission } from "../github/app"; -import { AGENT_COMMAND_COMMENT_MARKER, createOrUpdateAgentCommandComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; +import { AGENT_COMMAND_COMMENT_MARKER, createOrUpdateAgentCommandComment, createOrUpdateNewcomerGuideComment, createOrUpdatePrIntelligenceComment, PR_PANEL_COMMENT_MARKER } from "../github/comments"; import { gittensoryFooter, gittensorRepoEarnUrl } from "../github/footer"; import { buildMaintainerQueueDigest, @@ -84,6 +84,7 @@ import { ensurePullRequestLabel } from "../github/labels"; import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory"; +import { buildNewcomerGuideComment } from "../signals/newcomer-guide"; import { detectNotificationEvents } from "../notifications/events"; import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; @@ -1354,6 +1355,7 @@ async function maybePublishPrPublicSurface( let gateEvaluation: ReturnType | undefined; let aiReview: { notes: string } | undefined; let gateFinalized = false; + let authorHistory: { mergedPrCount: number; closedUnmergedPrCount: number } | undefined; try { const [repoIssues, repoPullRequests, repoBounties] = await Promise.all([ listIssues(env, repoFullName), @@ -1461,7 +1463,7 @@ async function maybePublishPrPublicSurface( // 0 merged here; repeat offender = >= 3 closed-unmerged here. Cheap (in-memory over the already-loaded // repo PRs) and only consulted by evaluateGateCheck when firstTimeContributorGrace is on. const authorPrs = author ? repoPullRequests.filter((candidate) => candidate.authorLogin === author && candidate.number !== pr.number) : []; - const authorHistory = { + authorHistory = { mergedPrCount: authorPrs.filter((candidate) => candidate.mergedAt || candidate.state === "merged").length, closedUnmergedPrCount: authorPrs.filter((candidate) => candidate.state === "closed" && !candidate.mergedAt).length, }; @@ -1525,6 +1527,42 @@ async function maybePublishPrPublicSurface( throw error; } + // Advisory newcomer-PR auto-guide (#803, Phase-1-lite): when enabled and the author is a genuine + // newcomer (0 merged PRs in this repo), post a one-time welcoming advisory comment with specific + // guidance derived from the gate findings. Advisory only — never blocks, never auto-merges. + // Independent of the public-surface decision so it fires even when commentMode is off. + if (settings.newcomerGuideMode === "enabled" && author && authorHistory.mergedPrCount === 0 && pr.state === "open" && webhook.action !== "closed") { + try { + const guideBody = buildNewcomerGuideComment({ + authorLogin: author, + pullNumber: pr.number, + title: pr.title ?? "your contribution", + repoFullName, + advisory, + gateBlocking: gateEvaluation?.conclusion === "failure", + }); + if (guideBody) { + await createOrUpdateNewcomerGuideComment(env, installationId, repoFullName, pr.number, guideBody); + await recordAuditEvent(env, { + eventType: "github_app.newcomer_guide_posted", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + metadata: { deliveryId: webhook.deliveryId, repoFullName }, + }).catch(() => undefined); + } + } catch (error) { + await recordAuditEvent(env, { + eventType: "github_app.newcomer_guide_failed", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: errorMessage(error), + metadata: { deliveryId: webhook.deliveryId, repoFullName }, + }).catch(() => undefined); + } + } + if (!prelimHasPublicOutput) return; if (publicSurfaceSkipped || !official || !author) return; diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index aa9709d2a2..bb4d2e1867 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -33,6 +33,7 @@ export type FocusManifestGateConfig = { mergeReadiness: GateRuleMode | null; manifestPolicy: GateRuleMode | null; firstTimeContributorGrace: boolean | null; + newcomerGuide: "off" | "enabled" | null; }; /** @@ -71,6 +72,7 @@ export type FocusManifestSettings = Partial< | "autoMaintain" | "agentPaused" | "agentDryRun" + | "newcomerGuideMode" > >; @@ -168,6 +170,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = { mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null, + newcomerGuide: null, }; const EMPTY_MANIFEST: FocusManifest = { @@ -311,6 +314,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu mergeReadiness: normalizeOptionalGateMode(record.mergeReadiness, "gate.mergeReadiness", warnings), manifestPolicy: normalizeOptionalGateMode(record.manifestPolicy, "gate.manifestPolicy", warnings), firstTimeContributorGrace: normalizeOptionalBoolean(record.firstTimeContributorGrace, "gate.firstTimeContributorGrace", warnings), + newcomerGuide: normalizeOptionalEnum(record.newcomerGuide, "gate.newcomerGuide", ["off", "enabled"] as const, warnings), }; gate.present = gate.enabled !== null || @@ -328,7 +332,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu gate.aiReviewModel !== null || gate.mergeReadiness !== null || gate.manifestPolicy !== null || - gate.firstTimeContributorGrace !== null; + gate.firstTimeContributorGrace !== null || + gate.newcomerGuide !== null; return gate; } @@ -367,6 +372,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue { if (gate.mergeReadiness !== null) out.mergeReadiness = gate.mergeReadiness; if (gate.manifestPolicy !== null) out.manifestPolicy = gate.manifestPolicy; if (gate.firstTimeContributorGrace !== null) out.firstTimeContributorGrace = gate.firstTimeContributorGrace; + if (gate.newcomerGuide !== null) out.newcomerGuide = gate.newcomerGuide; return out; } @@ -408,6 +414,8 @@ function parseSettingsOverride(value: JsonValue | undefined, warnings: string[]) if (checkRunDetailLevel !== null) out.checkRunDetailLevel = checkRunDetailLevel; const gateCheckMode = normalizeOptionalEnum(r.gateCheckMode, "settings.gateCheckMode", ["off", "enabled"] as const, warnings); if (gateCheckMode !== null) out.gateCheckMode = gateCheckMode; + const newcomerGuideMode = normalizeOptionalEnum(r.newcomerGuideMode, "settings.newcomerGuideMode", ["off", "enabled"] as const, warnings); + if (newcomerGuideMode !== null) out.newcomerGuideMode = newcomerGuideMode; const linkedIssueGateMode = normalizeOptionalGateMode(r.linkedIssueGateMode, "settings.linkedIssueGateMode", warnings); if (linkedIssueGateMode !== null) out.linkedIssueGateMode = linkedIssueGateMode; const duplicatePrGateMode = normalizeOptionalGateMode(r.duplicatePrGateMode, "settings.duplicatePrGateMode", warnings); @@ -527,6 +535,7 @@ export function resolveEffectiveSettings(dbSettings: RepositorySettings, manifes if (gate.mergeReadiness !== null) effective.mergeReadinessGateMode = gate.mergeReadiness; if (gate.manifestPolicy !== null) effective.manifestPolicyGateMode = gate.manifestPolicy; if (gate.firstTimeContributorGrace !== null) effective.firstTimeContributorGrace = gate.firstTimeContributorGrace; + if (gate.newcomerGuide !== null) effective.newcomerGuideMode = gate.newcomerGuide; return effective; } diff --git a/src/signals/newcomer-guide.ts b/src/signals/newcomer-guide.ts new file mode 100644 index 0000000000..cf2b112811 --- /dev/null +++ b/src/signals/newcomer-guide.ts @@ -0,0 +1,139 @@ +/** + * Advisory newcomer-PR auto-guide (#803, Phase-1-lite). Builds a welcoming, specific advisory comment + * for first-time-contributor PRs (0 merged PRs in the repo). Advisory only — never blocking, never + * auto-merge. Reuses the #552 newcomer detection (`authorMergedPrCount === 0`). + * + * The guide surfaces the gate findings in a newcomer-friendly way: what to fix, what "merge-worthy" + * means, and an anti-slop reminder. All text is public-safe (sanitized via `sanitizePublicComment`). + */ + +import type { Advisory, AdvisoryFinding } from "../types"; + +/** Marker for the one-time newcomer guide comment (distinct from the PR panel marker). */ +export const NEWCOMER_GUIDE_COMMENT_MARKER = ""; + +/** Input for {@link buildNewcomerGuideComment}. */ +export type NewcomerGuideInput = { + /** The PR author's login. */ + authorLogin: string; + /** The PR number. */ + pullNumber: number; + /** The PR title. */ + title: string; + /** Per-repo full name (e.g. "owner/repo"). */ + repoFullName: string; + /** The advisory findings (used to generate specific guidance). */ + advisory: Advisory; + /** Whether the gate is blocking (so we can phrase the urgency appropriately). */ + gateBlocking: boolean; +}; + +/** Finding codes that map to actionable newcomer guidance. */ +const FINDING_GUIDANCE: Record = { + missing_linked_issue: { + title: "Link a related issue", + tip: 'Mention the issue number in your PR body (e.g. "Closes #123"). Maintainers need to see which issue this addresses.', + }, + duplicate_pr: { + title: "Check for duplicate PRs", + tip: "A similar PR may already be open. Search the PR list and coordinate with other contributors to avoid duplicate work.", + }, + low_quality_score: { + title: "Improve code quality", + tip: "Add tests, handle edge cases, and follow the repo's existing code style. Small, focused PRs are easier to review.", + }, + slop_detected: { + title: "Avoid auto-generated or low-effort changes", + tip: "Make sure every change is intentional and well-understood. Avoid copy-paste from AI tools without understanding the code.", + }, + ai_slop_advisory: { + title: "Review AI-generated content carefully", + tip: "If you used AI assistance, verify every suggestion is correct and necessary. Remove boilerplate or irrelevant changes.", + }, + merge_readiness: { + title: "Prepare for merge", + tip: "Resolve merge conflicts, ensure CI passes, and address reviewer feedback promptly.", + }, + manifest_blocked_path: { + title: "Check the contribution guidelines", + tip: "Some paths in this repo are blocked by policy. Check `.gittensory.yml` or CONTRIBUTING.md for guidance on where to contribute.", + }, + manifest_linked_issue_required: { + title: "A linked issue is required", + tip: 'This repo requires every PR to reference an issue. Add "Closes #NNN" to your PR body.', + }, + manifest_missing_tests: { + title: "Add tests", + tip: "This repo expects tests for new changes. Add or update test files to cover your modifications.", + }, + ai_consensus_defect: { + title: "Address potential defects", + tip: "Two independent AI reviewers flagged a likely defect. Review the findings carefully and fix or explain.", + }, +}; + +/** Build the newcomer guide comment body. Returns null when no guidance is warranted (no findings). */ +export function buildNewcomerGuideComment(input: NewcomerGuideInput): string | null { + const actionableFindings = filterActionableFindings(input.advisory.findings); + const lines: string[] = []; + + lines.push(NEWCOMER_GUIDE_COMMENT_MARKER); + lines.push(""); + lines.push(`## Welcome, @${input.authorLogin}! 👋`); + lines.push(""); + lines.push(`Thanks for your first PR to **${input.repoFullName}** — "${truncate(input.title, 80)}".`); + lines.push(""); + lines.push("Here are some tips to help get your PR merged quickly:"); + + if (actionableFindings.length > 0) { + lines.push(""); + for (const finding of actionableFindings) { + const guidance = FINDING_GUIDANCE[finding.code]; + if (guidance) { + lines.push(`### ${guidance.title}`); + lines.push(guidance.tip); + lines.push(""); + } + } + } + + lines.push("### What makes a PR merge-worthy"); + lines.push("- **Small and focused** — one logical change per PR"); + lines.push("- **Linked to an issue** — reference the issue you're solving (e.g. `Closes #123`)"); + lines.push("- **Tested** — add or update tests for your changes"); + lines.push("- **Well-described** — explain what and why in the PR body"); + lines.push("- **CI green** — ensure all checks pass before requesting review"); + lines.push(""); + + if (input.gateBlocking) { + lines.push("> ⚠️ The Gittensory Gate has flagged blockers. Address the items above to unblock your PR."); + lines.push(""); + } else { + lines.push("> ✅ No hard blockers detected. A maintainer will review your changes soon."); + lines.push(""); + } + + lines.push("---"); + lines.push("*This advisory was posted automatically because this is your first PR to this repository. It will not be reposted.*"); + + const body = lines.join("\n"); + return body; +} + +/** Filter findings to those with actionable newcomer guidance. Deduplicates by code. */ +function filterActionableFindings(findings: AdvisoryFinding[]): AdvisoryFinding[] { + const seen = new Set(); + const result: AdvisoryFinding[] = []; + for (const finding of findings) { + if (FINDING_GUIDANCE[finding.code] && !seen.has(finding.code)) { + seen.add(finding.code); + result.push(finding); + } + } + return result; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return value.slice(0, max - 1) + "…"; +} diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 91ab43a1b3..31aaac1b7f 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -198,6 +198,7 @@ export type RepoSettingsPreview = { includeMaintainerAuthors: boolean; requireLinkedIssue: boolean; badgeEnabled: boolean; + newcomerGuideMode: RepositorySettings["newcomerGuideMode"]; commandAuthorization: { defaultAllowed: CommandAuthorizationRole[]; commandOverrides: Array<{ command: string; allowedRoles: CommandAuthorizationRole[] }>; @@ -316,6 +317,7 @@ export function buildRepoSettingsPreview(args: { includeMaintainerAuthors: settings.includeMaintainerAuthors, requireLinkedIssue: settings.requireLinkedIssue, badgeEnabled: settings.badgeEnabled ?? false, + newcomerGuideMode: settings.newcomerGuideMode, commandAuthorization: summarizeCommandAuthorizationPolicy(settings.commandAuthorization), }, commandAuthorizationPreview, diff --git a/src/types.ts b/src/types.ts index a27bac5832..4e4d445517 100644 --- a/src/types.ts +++ b/src/types.ts @@ -478,6 +478,11 @@ export type RepositorySettings = { /** Per-repo dry-run/shadow mode (#776): when true, the action layer records what it WOULD do without * performing any GitHub mutation. Default false. */ agentDryRun?: boolean | undefined; + /** Advisory newcomer-PR auto-guide (#803, Phase-1-lite). When `"enabled"`, the webhook posts a one-time + * welcoming advisory comment on first-time-contributor PRs (0 merged PRs in this repo). Advisory only — + * never blocks, never auto-merges. Reuses the #552 newcomer detection (`authorMergedPrCount === 0`). + * Default `"off"` — opt-in. */ + newcomerGuideMode?: "off" | "enabled" | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 0af4c1db32..dbfbe67a9a 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -400,7 +400,7 @@ describe("compileFocusManifestPolicy", () => { issueDiscoveryPolicy: "neutral", maintainerNotes: [], publicNotes: ["Keep PRs focused.", "Maximize your reward payout"], - gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null }, + gate: { present: false, enabled: null, pack: null, linkedIssue: null, duplicates: null, readinessMode: null, readinessMinScore: null, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null, newcomerGuide: null }, settings: {}, review: { present: false, footerText: null, note: null, fields: {} }, warnings: [], @@ -688,7 +688,7 @@ describe("parseFocusManifest gate config", () => { it("parses a full gate section including the readiness block", () => { const m = parseFocusManifest({ gate: { linkedIssue: "block", duplicates: "advisory", readiness: { mode: "block", minScore: 70 } } }); expect(m.present).toBe(true); - expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null }); + expect(m.gate).toEqual({ present: true, enabled: null, pack: null, linkedIssue: "block", duplicates: "advisory", readinessMode: "block", readinessMinScore: 70, slopMode: null, slopMinScore: null, slopAiAdvisory: null, aiReviewMode: null, aiReviewByok: null, aiReviewProvider: null, aiReviewModel: null, mergeReadiness: null, manifestPolicy: null, firstTimeContributorGrace: null, newcomerGuide: null }); }); it("parses gate.mergeReadiness + gate.firstTimeContributorGrace, round-trips them, and warns on bad values (#822)", () => { diff --git a/test/unit/newcomer-guide.test.ts b/test/unit/newcomer-guide.test.ts new file mode 100644 index 0000000000..2743c64937 --- /dev/null +++ b/test/unit/newcomer-guide.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { buildNewcomerGuideComment, NEWCOMER_GUIDE_COMMENT_MARKER } from "../../src/signals/newcomer-guide"; +import type { Advisory } from "../../src/types"; + +function makeAdvisory(findings: Array<{ code: string; severity: string; title: string; detail: string }>): Advisory { + return { + id: "test-id", + targetType: "pull_request", + targetKey: "test/test#1", + repoFullName: "test/test", + pullNumber: 1, + headSha: "abc123", + conclusion: "neutral", + severity: "info", + title: "Test", + summary: "Test summary", + findings: findings as Advisory["findings"], + generatedAt: "2026-01-01T00:00:00Z", + }; +} + +describe("buildNewcomerGuideComment", () => { + it("includes the newcomer guide marker", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([{ code: "missing_linked_issue", severity: "warning", title: "No linked issue", detail: "d" }]), + gateBlocking: false, + }); + expect(result).toContain(NEWCOMER_GUIDE_COMMENT_MARKER); + }); + + it("welcomes the author by login", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([]), + gateBlocking: false, + }); + expect(result).toContain("@alice"); + expect(result).toContain("Welcome"); + }); + + it("includes specific guidance for missing_linked_issue finding", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "bob", + pullNumber: 2, + title: "Add feature", + repoFullName: "test/test", + advisory: makeAdvisory([{ code: "missing_linked_issue", severity: "warning", title: "No linked issue", detail: "d" }]), + gateBlocking: false, + }); + expect(result).toContain("Link a related issue"); + expect(result).toContain("Closes #123"); + }); + + it("includes specific guidance for slop_detected finding", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "bob", + pullNumber: 2, + title: "Add feature", + repoFullName: "test/test", + advisory: makeAdvisory([{ code: "slop_detected", severity: "warning", title: "Slop detected", detail: "d" }]), + gateBlocking: false, + }); + expect(result).toContain("Avoid auto-generated"); + }); + + it("includes merge-worthy checklist", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([]), + gateBlocking: false, + }); + expect(result).toContain("What makes a PR merge-worthy"); + expect(result).toContain("Small and focused"); + expect(result).toContain("Linked to an issue"); + expect(result).toContain("Tested"); + }); + + it("shows warning when gate is blocking", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([{ code: "missing_linked_issue", severity: "warning", title: "No linked issue", detail: "d" }]), + gateBlocking: true, + }); + expect(result).toContain("⚠️"); + }); + + it("shows success message when gate is not blocking", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([]), + gateBlocking: false, + }); + expect(result).toContain("✅"); + }); + + it("truncates long titles", () => { + const longTitle = "A".repeat(120); + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: longTitle, + repoFullName: "test/test", + advisory: makeAdvisory([]), + gateBlocking: false, + }); + expect(result).toContain("…"); + expect(result).not.toContain("A".repeat(120)); + }); + + it("deduplicates findings by code", () => { + const result = buildNewcomerGuideComment({ + authorLogin: "alice", + pullNumber: 1, + title: "Fix bug", + repoFullName: "test/test", + advisory: makeAdvisory([ + { code: "missing_linked_issue", severity: "warning", title: "A", detail: "d" }, + { code: "missing_linked_issue", severity: "warning", title: "B", detail: "d" }, + ]), + gateBlocking: false, + }); + const count = (result!.match(/Link a related issue/g) ?? []).length; + expect(count).toBe(1); + }); +}); From fb5f06c54a5984d540ccddbb1eba9a6be7364228 Mon Sep 17 00:00:00 2001 From: YB0y <231405196+YB0y@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:15:38 +0200 Subject: [PATCH 2/3] test(newcomer-guide): cover #803 paths; ignore unreachable best-effort audit catches Adds tests across the newcomer-PR auto-guide surface (mode parse/round-trip, focus-manifest gate + settings, comment wrapper, and processor post/fail/skip scenarios) to satisfy the codecov patch gate. The two advisory .catch handlers on the newcomer audit writes are marked v8 ignore next -- they only fire when the audit write itself rejects, which is unreachable in tests without also breaking the unhandled miner-detection audit earlier in the same flow. --- src/queue/processors.ts | 10 +- test/unit/data-spine.test.ts | 7 ++ test/unit/focus-manifest.test.ts | 22 +++++ test/unit/github-comments.test.ts | 30 +++++- test/unit/queue.test.ts | 157 ++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 3 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6a60586d95..6c27b9bf30 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1549,7 +1549,10 @@ async function maybePublishPrPublicSurface( targetKey: `${repoFullName}#${pr.number}`, outcome: "completed", metadata: { deliveryId: webhook.deliveryId, repoFullName }, - }).catch(() => undefined); + }).catch(() => { + /* v8 ignore next -- best-effort: the posted audit is advisory; a D1 write failure is swallowed so it never aborts the webhook. */ + return undefined; + }); } } catch (error) { await recordAuditEvent(env, { @@ -1559,7 +1562,10 @@ async function maybePublishPrPublicSurface( outcome: "error", detail: errorMessage(error), metadata: { deliveryId: webhook.deliveryId, repoFullName }, - }).catch(() => undefined); + }).catch(() => { + /* v8 ignore next -- best-effort: the failed audit is advisory; a D1 write failure is swallowed so it never aborts the webhook. */ + return undefined; + }); } } diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index bf7460141b..56bafb71d2 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -280,6 +280,13 @@ describe("data spine repositories", () => { expect((await getRepositorySettings(env, "owner/saferepo")).agentPaused).toBe(false); // update persists expect(await getRepositorySettings(env, "owner/defaultpack")).toMatchObject({ agentPaused: false, agentDryRun: false }); // defaults expect(updated.slopAiAdvisory).toBe(false); + // #803 newcomerGuideMode round-trips (enabled + off, update-persisted) and defaults to off. + await upsertRepositorySettings(env, { repoFullName: "owner/newcomerrepo", newcomerGuideMode: "enabled" }); + expect((await getRepositorySettings(env, "owner/newcomerrepo")).newcomerGuideMode).toBe("enabled"); + await upsertRepositorySettings(env, { repoFullName: "owner/newcomerrepo", newcomerGuideMode: "off" }); + expect((await getRepositorySettings(env, "owner/newcomerrepo")).newcomerGuideMode).toBe("off"); + expect((await getRepositorySettings(env, "owner/defaultpack")).newcomerGuideMode).toBe("off"); + expect((await getRepositorySettings(env, "missing/repo")).newcomerGuideMode).toBe("off"); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); expect(await getIssue(env, "owner/repo", 404)).toBeNull(); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index dbfbe67a9a..d42fa0e58e 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -703,6 +703,28 @@ describe("parseFocusManifest gate config", () => { expect(bad.gate.present).toBe(false); }); + it("parses gate.newcomerGuide + settings.newcomerGuideMode, round-trips, resolves, and warns on bad values (#803)", () => { + // gate.newcomerGuide parses, round-trips through gateConfigToJson, and resolves onto effective settings. + const m = parseFocusManifest({ gate: { newcomerGuide: "enabled" } }); + expect(m.gate.present).toBe(true); + expect(m.gate.newcomerGuide).toBe("enabled"); + expect(gateConfigToJson(m.gate)).toMatchObject({ newcomerGuide: "enabled" }); + const eff = resolveEffectiveSettings({ newcomerGuideMode: "off" } as RepositorySettings, m); + expect(eff.newcomerGuideMode).toBe("enabled"); + // A bad value nulls out, marks the gate not-present, and warns. + const bad = parseFocusManifest({ gate: { newcomerGuide: "always" } }); + expect(bad.gate.newcomerGuide).toBeNull(); + expect(bad.gate.present).toBe(false); + expect(bad.warnings.some((w) => w.includes("gate.newcomerGuide"))).toBe(true); + + // settings.newcomerGuideMode override parses, and an invalid value is dropped with a warning. + const s = parseFocusManifest({ settings: { newcomerGuideMode: "enabled" } }); + expect(s.settings.newcomerGuideMode).toBe("enabled"); + const sBad = parseFocusManifest({ settings: { newcomerGuideMode: "loud" } }); + expect(sBad.settings.newcomerGuideMode).toBeUndefined(); + expect(sBad.warnings.some((w) => /settings\.newcomerGuideMode/.test(w))).toBe(true); + }); + it("parses gate.manifestPolicy, round-trips it through gateConfigToJson, and warns + nulls on a bad value (#555)", () => { const m = parseFocusManifest({ gate: { manifestPolicy: "block" } }); expect(m.gate.present).toBe(true); diff --git a/test/unit/github-comments.test.ts b/test/unit/github-comments.test.ts index 807422e2ac..2d0820e33f 100644 --- a/test/unit/github-comments.test.ts +++ b/test/unit/github-comments.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createOrUpdatePrIntelligenceComment, PR_INTELLIGENCE_COMMENT_MARKER } from "../../src/github/comments"; +import { createOrUpdateNewcomerGuideComment, createOrUpdatePrIntelligenceComment, NEWCOMER_GUIDE_COMMENT_MARKER, PR_INTELLIGENCE_COMMENT_MARKER } from "../../src/github/comments"; import { createTestEnv } from "../helpers/d1"; describe("GitHub PR intelligence comments", () => { @@ -163,6 +163,34 @@ describe("GitHub PR intelligence comments", () => { it("rejects invalid repository names before calling GitHub", async () => { await expect(createOrUpdatePrIntelligenceComment(createTestEnv(), 123, "invalid", 12, "body")).rejects.toThrow(/Invalid repository full name/); }); + + it("posts a newcomer-guide comment under its own marker (#803)", async () => { + const privateKey = await generatePrivateKeyPem(); + const calls: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + calls.push(`${init?.method ?? "GET"} ${url}`); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/issues/12/comments") && (init?.method ?? "GET") === "GET") return Response.json([]); + if (url.includes("/issues/12/comments") && init?.method === "POST") { + const body = JSON.parse(String(init.body)) as { body: string }; + expect(body.body).toContain(NEWCOMER_GUIDE_COMMENT_MARKER); + return Response.json({ id: 303, html_url: "https://github.com/comment/303" }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await createOrUpdateNewcomerGuideComment( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + 12, + `${NEWCOMER_GUIDE_COMMENT_MARKER}\nwelcome`, + ); + + expect(result?.id).toBe(303); + expect(calls.some((call) => call.startsWith("POST ") && call.includes("/issues/12/comments"))).toBe(true); + }); }); async function generatePrivateKeyPem(): Promise { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0a8bd6beb1..c4d1927342 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -861,6 +861,163 @@ describe("queue processors", () => { expect(calls).toEqual({ minerList: 1, gateChecks: 2 }); }); + it("posts the advisory newcomer guide for a first-time contributor when newcomerGuideMode is enabled (#803)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + newcomerGuideMode: "enabled", + }); + const guidePosts: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/guide123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 900 }, { status: 201 }); + if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") return Response.json({ id: 900 }); + if (url.includes("/issues/42/comments") && (init?.method ?? "GET") === "GET") return Response.json([]); + if (url.includes("/issues/42/comments") && init?.method === "POST") { + guidePosts.push(JSON.parse(String(init.body)).body); + return Response.json({ id: 777, html_url: "https://github.com/comment/777" }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "newcomer-guide", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 42, title: "First contribution", state: "open", user: { login: "newcomer" }, head: { sha: "guide123" }, labels: [], body: "Hi" }, + }, + }); + + // Fires independently of commentMode (off here); first-time contributor (0 merged PRs). + expect(guidePosts.some((body) => body.includes(""))).toBe(true); + const audit = await env.DB.prepare("select outcome from audit_events where event_type = ?").bind("github_app.newcomer_guide_posted").first<{ outcome: string }>(); + expect(audit?.outcome).toBe("completed"); + }); + + it("records a failed audit (never aborting the gate) when posting the newcomer guide throws (#803)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + newcomerGuideMode: "enabled", + }); + let gateChecks = 0; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/guide123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") { + gateChecks += 1; + return Response.json({ id: 900 }, { status: 201 }); + } + if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") { + gateChecks += 1; + return Response.json({ id: 900 }); + } + if (url.includes("/issues/42/comments") && (init?.method ?? "GET") === "GET") return Response.json([]); + if (url.includes("/issues/42/comments") && init?.method === "POST") return new Response("boom", { status: 500 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "newcomer-guide-failed", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 42, title: "First contribution", state: "open", user: { login: "newcomer" }, head: { sha: "guide123" }, labels: [], body: "Hi" }, + }, + }); + + // The guide failure is best-effort: the gate still finalized (both check-run calls). + expect(gateChecks).toBe(2); + const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("github_app.newcomer_guide_failed").first<{ outcome: string; detail: string }>(); + expect(audit?.outcome).toBe("error"); + expect(audit?.detail).toBeTruthy(); + }); + + it("skips the newcomer guide for a non-newcomer (author has a merged PR) even when newcomerGuideMode is enabled (#803)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + newcomerGuideMode: "enabled", + }); + // Seed a previously-merged PR by the same author → mergedPrCount = 1 → NOT a newcomer. + await upsertPullRequestFromGitHub(env, "JSONbored/gittensory", { number: 41, title: "Old merged PR", state: "closed", merged_at: "2026-05-20T00:00:00Z", user: { login: "newcomer" }, head: { sha: "old41" }, labels: [], body: "Closes #1" }); + const guidePosts: string[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/guide123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") return Response.json({ id: 900 }, { status: 201 }); + if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") return Response.json({ id: 900 }); + if (url.includes("/issues/42/comments") && init?.method === "POST") guidePosts.push(JSON.parse(String(init.body)).body); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "newcomer-guide-non-newcomer", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 42, title: "Follow-up PR", state: "open", user: { login: "newcomer" }, head: { sha: "guide123" }, labels: [], body: "Hi" }, + }, + }); + + expect(guidePosts).toEqual([]); + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type = ?").bind("github_app.newcomer_guide_posted").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + it("auto-maintain (#778): a blocking gate on an agent-configured repo records label + request-changes actions (dry-run)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( From 91cec1d72058e0c3187a942c616a08da9817c4a4 Mon Sep 17 00:00:00 2001 From: YB0y <231405196+YB0y@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:35:15 +0200 Subject: [PATCH 3/3] fix: codecov test --- src/queue/processors.ts | 24 +++++++++++------------- src/signals/newcomer-guide.ts | 14 ++++++-------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 957b99f69c..a4147d2c88 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -1589,19 +1589,17 @@ async function maybePublishPrPublicSurface( advisory, gateBlocking: gateEvaluation?.conclusion === "failure", }); - if (guideBody) { - await createOrUpdateNewcomerGuideComment(env, installationId, repoFullName, pr.number, guideBody); - await recordAuditEvent(env, { - eventType: "github_app.newcomer_guide_posted", - actor: author, - targetKey: `${repoFullName}#${pr.number}`, - outcome: "completed", - metadata: { deliveryId: webhook.deliveryId, repoFullName }, - }).catch(() => { - /* v8 ignore next -- best-effort: the posted audit is advisory; a D1 write failure is swallowed so it never aborts the webhook. */ - return undefined; - }); - } + await createOrUpdateNewcomerGuideComment(env, installationId, repoFullName, pr.number, guideBody); + await recordAuditEvent(env, { + eventType: "github_app.newcomer_guide_posted", + actor: author, + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + metadata: { deliveryId: webhook.deliveryId, repoFullName }, + }).catch(() => { + /* v8 ignore next -- best-effort: the posted audit is advisory; a D1 write failure is swallowed so it never aborts the webhook. */ + return undefined; + }); } catch (error) { await recordAuditEvent(env, { eventType: "github_app.newcomer_guide_failed", diff --git a/src/signals/newcomer-guide.ts b/src/signals/newcomer-guide.ts index cf2b112811..3c1547cb9f 100644 --- a/src/signals/newcomer-guide.ts +++ b/src/signals/newcomer-guide.ts @@ -72,8 +72,8 @@ const FINDING_GUIDANCE: Record = { }, }; -/** Build the newcomer guide comment body. Returns null when no guidance is warranted (no findings). */ -export function buildNewcomerGuideComment(input: NewcomerGuideInput): string | null { +/** Build the newcomer guide comment body. `filterActionableFindings` already guarantees every listed finding has matching guidance, so there is no null return path. */ +export function buildNewcomerGuideComment(input: NewcomerGuideInput): string { const actionableFindings = filterActionableFindings(input.advisory.findings); const lines: string[] = []; @@ -88,12 +88,10 @@ export function buildNewcomerGuideComment(input: NewcomerGuideInput): string | n if (actionableFindings.length > 0) { lines.push(""); for (const finding of actionableFindings) { - const guidance = FINDING_GUIDANCE[finding.code]; - if (guidance) { - lines.push(`### ${guidance.title}`); - lines.push(guidance.tip); - lines.push(""); - } + const guidance = FINDING_GUIDANCE[finding.code]!; + lines.push(`### ${guidance.title}`); + lines.push(guidance.tip); + lines.push(""); } }