refactor: replace narrow parameter types with context objects (#519)
* refactor: replace narrow parameter types with context objects across action/ pass broader context objects (ToolContext, PromptContext, PostCleanupContext) to utility functions instead of cherry-picking fields into single-use interfaces. deletes 8 narrow types, simplifies call sites, and makes buildCommentFooter synchronous by reading ctx.runId/ctx.jobId directly instead of re-deriving from env vars and making an extra API call. Made-with: Cursor * fix: replace non-null assertion with local guard in validatePushDestination addresses review feedback — the function now validates pushUrl itself instead of relying on the caller's check, eliminating the ! assertion. Made-with: Cursor * revert: remove GH_TOKEN injection from restricted shell the original change exposed the git token in restricted-mode shell so `gh` CLI would work. this is a security regression for public repos: MCP tools are deliberately constrained (no merge, no release, no arbitrary API calls), but `gh api` with the token gives full GitHub API access to any prompt-injected agent. Made-with: Cursor
This commit is contained in:
committed by
pullfrog[bot]
parent
ab76a4ad04
commit
2ea447a780
+43
-75
@@ -15,6 +15,14 @@ interface InstructionsContext {
|
||||
learnings: string | null;
|
||||
}
|
||||
|
||||
interface PromptContext extends InstructionsContext {
|
||||
t: (name: string) => string;
|
||||
eventTitle: string;
|
||||
eventMetadata: string;
|
||||
runtime: string;
|
||||
userQuoted: string;
|
||||
}
|
||||
|
||||
function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||
// extract payload fields excluding prompt/instructions/event (those are rendered separately)
|
||||
const {
|
||||
@@ -136,24 +144,25 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers
|
||||
function buildTaskSection(ctx: { userQuoted: string; eventInstructions: string }): string {
|
||||
function buildTaskSection(ctx: PromptContext): string {
|
||||
if (ctx.userQuoted) {
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${ctx.userQuoted}`;
|
||||
}
|
||||
|
||||
if (ctx.eventInstructions) {
|
||||
const eventInstructions = ctx.payload.eventInstructions ?? "";
|
||||
if (eventInstructions) {
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${ctx.eventInstructions}`;
|
||||
${eventInstructions}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// mode selection and execution steps
|
||||
function buildProcedure(ctx: { modes: Mode[]; t: (name: string) => string }): string {
|
||||
function buildProcedure(ctx: PromptContext): string {
|
||||
const t = ctx.t;
|
||||
return `************* PROCEDURE *************
|
||||
|
||||
@@ -180,11 +189,7 @@ Eagerly inspect the MCP tools available to you via the \`${pullfrogMcpName}\` MC
|
||||
}
|
||||
|
||||
// event title + metadata (omitted when empty, e.g. workflow_dispatch)
|
||||
function buildEventContext(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
eventTitle: string;
|
||||
eventMetadata: string;
|
||||
}): string {
|
||||
function buildEventContext(ctx: PromptContext): string {
|
||||
const isPr = ctx.payload.event.is_pr === true;
|
||||
const relatedLabel = isPr ? "--- related PR ---" : "--- related issue ---";
|
||||
|
||||
@@ -200,12 +205,7 @@ ${content}`;
|
||||
}
|
||||
|
||||
// persona, environment, priority, security, tools, workflow
|
||||
function buildSystemBody(ctx: {
|
||||
shell: ResolvedPayload["shell"];
|
||||
trigger: string;
|
||||
t: (name: string) => string;
|
||||
outputSchema?: Record<string, unknown> | undefined;
|
||||
}): string {
|
||||
function buildSystemBody(ctx: PromptContext): string {
|
||||
const t = ctx.t;
|
||||
return `************* SYSTEM *************
|
||||
|
||||
@@ -257,11 +257,11 @@ Rules:
|
||||
|
||||
Use MCP tools from ${pullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication and enforce permissions.
|
||||
|
||||
${getShellInstructions(ctx.shell, t)}
|
||||
${getShellInstructions(ctx.payload.shell, t)}
|
||||
|
||||
${getFileInstructions()}
|
||||
|
||||
${getStandaloneModeInstructions(ctx.trigger, t, ctx.outputSchema)}
|
||||
${getStandaloneModeInstructions(ctx.payload.event.trigger, t, ctx.outputSchema)}
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -312,38 +312,20 @@ function buildToc(entries: TocEntry[]): string {
|
||||
${entries.map((e) => `- ${e.label} — ${e.description}`).join("\n")}`;
|
||||
}
|
||||
|
||||
// shared computation for all instruction builders
|
||||
interface CommonInputs {
|
||||
eventTitle: string;
|
||||
eventMetadata: string;
|
||||
runtime: string;
|
||||
user: string;
|
||||
eventInstructions: string;
|
||||
event: string;
|
||||
userQuoted: string;
|
||||
}
|
||||
|
||||
function buildCommonInputs(ctx: InstructionsContext): CommonInputs {
|
||||
const eventTitle = buildEventTitle(ctx.payload.event);
|
||||
const eventMetadata = buildEventMetadata(ctx.payload.event);
|
||||
const runtime = buildRuntimeContext(ctx);
|
||||
function buildPromptContext(ctx: InstructionsContext): PromptContext {
|
||||
const user = ctx.payload.prompt;
|
||||
const eventInstructions = ctx.payload.eventInstructions ?? "";
|
||||
const event = [eventTitle, eventMetadata].filter(Boolean).join("\n\n---\n\n");
|
||||
const userQuoted = user
|
||||
? user
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")
|
||||
: "";
|
||||
return {
|
||||
eventTitle,
|
||||
eventMetadata,
|
||||
runtime,
|
||||
user,
|
||||
eventInstructions,
|
||||
event,
|
||||
userQuoted,
|
||||
...ctx,
|
||||
t: (toolName: string) => formatMcpToolRef(ctx.agentId, toolName),
|
||||
eventTitle: buildEventTitle(ctx.payload.event),
|
||||
eventMetadata: buildEventMetadata(ctx.payload.event),
|
||||
runtime: buildRuntimeContext(ctx),
|
||||
userQuoted: user
|
||||
? user
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -387,28 +369,12 @@ function assembleFullPrompt(ctx: {
|
||||
}
|
||||
|
||||
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
const t = (toolName: string) => formatMcpToolRef(ctx.agentId, toolName);
|
||||
const pctx = buildPromptContext(ctx);
|
||||
|
||||
const task = buildTaskSection({
|
||||
userQuoted: inputs.userQuoted,
|
||||
eventInstructions: inputs.eventInstructions,
|
||||
});
|
||||
|
||||
const procedure = buildProcedure({ modes: ctx.modes, t });
|
||||
|
||||
const eventContext = buildEventContext({
|
||||
payload: ctx.payload,
|
||||
eventTitle: inputs.eventTitle,
|
||||
eventMetadata: inputs.eventMetadata,
|
||||
});
|
||||
|
||||
const system = buildSystemBody({
|
||||
shell: ctx.payload.shell,
|
||||
trigger: ctx.payload.event.trigger,
|
||||
t,
|
||||
outputSchema: ctx.outputSchema,
|
||||
});
|
||||
const task = buildTaskSection(pctx);
|
||||
const procedure = buildProcedure(pctx);
|
||||
const eventContext = buildEventContext(pctx);
|
||||
const system = buildSystemBody(pctx);
|
||||
|
||||
// build TOC from present sections (PROCEDURE, SYSTEM, RUNTIME are always present)
|
||||
const tocEntries: TocEntry[] = [];
|
||||
@@ -417,7 +383,7 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
if (eventContext)
|
||||
tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" });
|
||||
tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" });
|
||||
if (ctx.learnings)
|
||||
if (pctx.learnings)
|
||||
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" });
|
||||
tocEntries.push({ label: "RUNTIME", description: "environment metadata" });
|
||||
|
||||
@@ -429,16 +395,18 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
procedure,
|
||||
eventContext,
|
||||
system,
|
||||
learnings: ctx.learnings,
|
||||
runtime: inputs.runtime,
|
||||
learnings: pctx.learnings,
|
||||
runtime: pctx.runtime,
|
||||
});
|
||||
|
||||
const event = [pctx.eventTitle, pctx.eventMetadata].filter(Boolean).join("\n\n---\n\n");
|
||||
|
||||
return {
|
||||
full,
|
||||
system,
|
||||
user: inputs.user,
|
||||
eventInstructions: inputs.eventInstructions,
|
||||
event: inputs.event,
|
||||
runtime: inputs.runtime,
|
||||
user: pctx.payload.prompt,
|
||||
eventInstructions: pctx.payload.eventInstructions ?? "",
|
||||
event,
|
||||
runtime: pctx.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
+40
-57
@@ -8,65 +8,61 @@ import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
|
||||
interface PostCleanupContext {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runId: number | undefined;
|
||||
promptInput: JsonPromptInput | null;
|
||||
}
|
||||
|
||||
// controls whether the script should check the reason for the workflow termination.
|
||||
// it can be either canceled or failed.
|
||||
// YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
const SHOULD_CHECK_REASON = true;
|
||||
|
||||
type BuildErrorCommentBodyParams = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: number | undefined;
|
||||
isCancellation: boolean;
|
||||
};
|
||||
|
||||
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
|
||||
let errorMessage = params.isCancellation
|
||||
function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean): string {
|
||||
let errorMessage = isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
|
||||
|
||||
if (params.runId) {
|
||||
if (ctx.runId) {
|
||||
errorMessage += " Please check the link below for details.";
|
||||
}
|
||||
|
||||
const customParts: string[] = [];
|
||||
if (!params.isCancellation && params.runId) {
|
||||
if (!isCancellation && ctx.runId) {
|
||||
const apiUrl = getApiUrl();
|
||||
customParts.push(
|
||||
`[Rerun failed job ➔](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
|
||||
`[Rerun failed job ➔](${apiUrl}/trigger/${ctx.repoContext.owner}/${ctx.repoContext.name}/${ctx.runId}?action=rerun)`
|
||||
);
|
||||
}
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId
|
||||
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||
workflowRun: ctx.runId
|
||||
? {
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
runId: ctx.runId,
|
||||
}
|
||||
: undefined,
|
||||
customParts,
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
type ValidateStuckCommentParams = {
|
||||
promptInput: JsonPromptInput | null;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
async function validateStuckProgressComment(
|
||||
params: ValidateStuckCommentParams
|
||||
): Promise<number | null> {
|
||||
if (!params.promptInput?.progressCommentId) {
|
||||
async function validateStuckProgressComment(ctx: PostCleanupContext): Promise<number | null> {
|
||||
if (!ctx.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(params.promptInput.progressCommentId, 10);
|
||||
const commentId = parseInt(ctx.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
|
||||
try {
|
||||
const commentResult = await params.octokit.rest.issues.getComment({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
const commentResult = await ctx.octokit.rest.issues.getComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
@@ -93,19 +89,13 @@ async function validateStuckProgressComment(
|
||||
}
|
||||
}
|
||||
|
||||
type GetIsCancelledParams = {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runId: number | undefined;
|
||||
};
|
||||
|
||||
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
|
||||
if (!params.runId) return false; // can't check without a run ID — assume failure
|
||||
async function getIsCancelled(ctx: PostCleanupContext): Promise<boolean> {
|
||||
if (!ctx.runId) return false; // can't check without a run ID — assume failure
|
||||
try {
|
||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: params.runId,
|
||||
const jobsResult = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
run_id: ctx.runId,
|
||||
});
|
||||
|
||||
// find current job by matching GITHUB_JOB env var.
|
||||
@@ -166,30 +156,23 @@ export async function runPostCleanup(): Promise<void> {
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
const commentId = await validateStuckProgressComment({
|
||||
promptInput,
|
||||
octokit,
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
});
|
||||
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
|
||||
|
||||
const commentId = await validateStuckProgressComment(ctx);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId,
|
||||
isCancellation: SHOULD_CHECK_REASON
|
||||
? await getIsCancelled({ octokit, repoContext, runId })
|
||||
: false,
|
||||
});
|
||||
const body = buildErrorCommentBody(
|
||||
ctx,
|
||||
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
|
||||
);
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
await ctx.octokit.rest.issues.updateComment({
|
||||
owner: ctx.repoContext.owner,
|
||||
repo: ctx.repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user