diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index de0562f3be..a7ef9da9d5 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -30,6 +30,7 @@ import { resolveExecutionWorkspaceReuseProvisioningPolicy, resolveNextSessionState, resolveTaskSessionConfigFreshness, + issueTextImpliesPrDeliverable, requiresPushCapabilityPreflight, resolveWorkspaceAfterLowTrustPreflight, resolveRuntimeSessionParamsForWorkspace, @@ -702,7 +703,58 @@ describe("assertPushCapabilityCheckoutValid", () => { }); }); +describe("issueTextImpliesPrDeliverable", () => { + it("matches verb-anchored PR deliverables", () => { + expect(issueTextImpliesPrDeliverable("Review and open PR for the CI shard split")).toBe(true); + expect(issueTextImpliesPrDeliverable("Push the branch and open a pull request")).toBe(true); + expect(issueTextImpliesPrDeliverable("Each run: make the change and open a draft PR")).toBe(true); + expect(issueTextImpliesPrDeliverable("push feature work to origin when done")).toBe(true); + }); + + it("ignores passing mentions and unrelated text", () => { + expect(issueTextImpliesPrDeliverable("The PR merged yesterday; investigate the regression")).toBe(false); + expect(issueTextImpliesPrDeliverable("PR feedback addressed")).toBe(false); + expect(issueTextImpliesPrDeliverable("Update the pricing page copy")).toBe(false); + expect(issueTextImpliesPrDeliverable("a proper approach to pushing back on scope")).toBe(false); + expect(issueTextImpliesPrDeliverable(null)).toBe(false); + expect(issueTextImpliesPrDeliverable("")).toBe(false); + }); + + it("ignores non-git uses of push", () => { + expect(issueTextImpliesPrDeliverable("push back on the upstream dependency change")).toBe(false); + expect(issueTextImpliesPrDeliverable("push back the branch cut date")).toBe(false); + expect(issueTextImpliesPrDeliverable("push notifications for mobile")).toBe(false); + // Git shapes still match. + expect(issueTextImpliesPrDeliverable("pushing the release branch")).toBe(true); + expect(issueTextImpliesPrDeliverable("push feature work to origin when done")).toBe(true); + }); +}); + describe("requiresPushCapabilityPreflight", () => { + it("enables the guard when the issue text states the PR deliverable", () => { + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: "issue-1", + explicitRunScopedSkillKeys: [], + issueText: "Push ci/shard-split and open PR", + })).toBe(true); + + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: "issue-1", + explicitRunScopedSkillKeys: [], + issueText: "Investigate why the PR checks were slow", + })).toBe(false); + + // Without an issue there is nothing to preflight. + expect(requiresPushCapabilityPreflight({ + adapterType: "codex_local", + issueId: null, + explicitRunScopedSkillKeys: [], + issueText: "open a PR", + })).toBe(false); + }); + it("only enables the guard when the issue explicitly mentions the GitHub PR workflow skill", () => { expect(requiresPushCapabilityPreflight({ adapterType: "codex_local", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4ae1857aaa..b535b0d1ce 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -640,14 +640,44 @@ function hasGithubPrWorkflowSkill(desiredSkills: string[]) { }); } +/** + * Conservative, verb-anchored patterns for an issue whose deliverable is a + * pushed branch or opened pull request. Verb anchoring keeps passing mentions + * ("the PR merged yesterday") from triggering the credential preflight. + */ +const PR_DELIVERABLE_TEXT_PATTERNS = [ + /\bopen(?:s|ed|ing)?\s+(?:a\s+|the\s+|an?\s+draft\s+)?(?:pull\s+request|pr)\b/i, + /\b(?:create|creates|created|creating|raise|raises|raised|raising|submit|submits|submitted|submitting)\s+(?:a\s+|the\s+|an?\s+draft\s+)?(?:pull\s+request|pr)\b/i, + // "push back" (an objection or a date) is never a git push, and bare + // proximity to words like "upstream" over-matches ("push back on the + // upstream dependency change"); require the git object shape instead. + /\bpush(?:es|ed|ing)?\b(?!\s+back\b)[^.\n]{0,40}\bbranch(?:es)?\b/i, + /\bpush(?:es|ed|ing)?\s+(?:[^.\n]{0,30}\s)?to\s+(?:origin|remote|upstream|github)\b/i, +]; + +export function issueTextImpliesPrDeliverable(text: string | null | undefined): boolean { + if (!text) return false; + return PR_DELIVERABLE_TEXT_PATTERNS.some((pattern) => pattern.test(text)); +} + export function requiresPushCapabilityPreflight(input: { adapterType: string; issueId: string | null | undefined; explicitRunScopedSkillKeys: string[]; + /** + * Issue title + description. Routine-created issues and agent-to-agent + * handoffs rarely mention the GitHub PR workflow skill explicitly, yet + * state the PR deliverable in plain text — without this, the credential + * gap only surfaces after the implementation and review work is done. + */ + issueText?: string | null; }) { return Boolean(input.issueId) && GIT_SENSITIVE_LOCAL_ADAPTER_TYPES.has(input.adapterType) - && hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys); + && ( + hasGithubPrWorkflowSkill(input.explicitRunScopedSkillKeys) + || issueTextImpliesPrDeliverable(input.issueText) + ); } const LOW_TRUST_SENSITIVE_ENV_KEY_RE = @@ -13294,6 +13324,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) adapterType: agent.adapterType, issueId, explicitRunScopedSkillKeys: runScopedMentionedSkillKeys, + issueText: issueRef ? `${issueRef.title ?? ""}\n${issueRef.description ?? ""}` : null, }); const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({ companyId: agent.companyId,