5bcfae990a
* add mode instructions and restructure dashboard sidebar
- add modeInstructions JSONB field to Repo model for per-mode user instructions
- thread modeInstructions through settings API, run-context API, RepoSettings, ToolContext, and selectMode runtime
- merge user-defined mode instructions with hardcoded orchestrator guidance, with IncrementalReview inheriting from Review
- reduce visible built-in modes from 7 to 4 (Build, Review, Plan, Fix) with editable Instructions textareas
- add TRIGGERS group header to sidebar above Mentions, Pull requests, Issues
- add wiki/modes.md documenting triggers and modes conceptual model
Made-with: Cursor
* fix leaping comment deletion and address review feedback
- wrap post-createReview operations in try/finally so deleteProgressComment
runs even when updateReview or reportReviewNodeId throws
- add parseModeInstructions runtime guard to filter non-string values
from the JSONB field before passing to buildOrchestratorGuidance
- add useEffect sync for localInstructions when props change
- guard onBlur to skip save when instructions haven't changed
- update wiki/modes.md to reflect V2 is implemented (no longer "proposed")
Made-with: Cursor
* harden review cleanup, fix type cast, stabilize mode instructions state
- wrap deleteProgressComment in try/catch inside finally to prevent masking original errors
- replace `as Record<string,string>` cast with runtime parseModeInstructions + useMemo
- fix wiki dual-prompt table to reflect mode.prompt fallback status
Made-with: Cursor
* fix wiki tense and heading ambiguity from PR review
Made-with: Cursor
* fix review "edited" badge by using pending review + submit flow
create review as PENDING first (no event/body), build the footer with
the now-known review ID, then submitReview with the full body. single
atomic publish — no updateReview edit needed.
Made-with: Cursor
* add post-agent follow-up re-review dispatch
After the agent exits, check if PR HEAD moved past the reviewed commit
and dispatch a follow-up re-review. This closes the gap where push
webhooks are suppressed during in-flight reviews.
Made-with: Cursor
* add silent flag to follow-up re-review dispatch
Made-with: Cursor
* restructure dashboard for consistency and clarity
- consolidate tools into single grouped card (was 4 separate cards)
- merge coding + autofix CI into one section
- remove redundant trigger section descriptions
- add bidirectional crosslinks between modes and triggers
- inline instruction links (review/plan/build) into descriptions
- add save status indicators to all sections
- restructure flags with grouped built-in/custom cards
- flatten sidebar (remove dividers and group headers)
- tighten all descriptions
Made-with: Cursor
* update PR screenshots for new dashboard layout
Made-with: Cursor
* extend review context inline instead of dispatching new workflow
when commits are pushed during a review, the agent now handles them
inline: create_pull_request_review detects HEAD movement, returns
instructions to pull and review the incremental diff, and the agent
submits a second review covering only the new changes. this avoids
the cost of spinning up a full new workflow run.
also fixes a bug where reviewedSha was set to the submission HEAD
(current) rather than the checkout HEAD (what was actually reviewed),
which caused commits pushed between checkout and submission to be
silently missed by postReviewCleanup.
the workflow dispatch is kept as a safety net for agent timeout/error.
Made-with: Cursor
* polish dashboard UI: fix debug markers, crosslinks, title consistency, descriptions
- remove all red debug borders/labels and CM component
- remove all inline style={{}} debug outlines from crosslinks
- fix ambiguous crosslinks: Build→"Coding ↓", Plan→"Enrich issues ↓"
- add missing "Edit build instructions ↑" backlink on Auto-address reviews
- normalize card title weight to text-sm font-semibold across all cards
- rename "Default" subcard to "Setup" with broader description
- fix Mentions description to imperative tone
- broaden Flags section description to cover built-in and custom
- remove useless fragments in ModesSection and ToolsSettings
- restructure Agent section: remove ConsoleSection wrappers, add sidebar indent support
Made-with: Cursor
* extract PR quick links as standalone card, consistent with issues
- PR quick links is now its own card under Reviews (was a sub-toggle inside Review PRs disabled state)
- Review PRs OFF sets prCreated="none" instead of auto-falling back to "links"
- Review PRs card hides sub-toggles when disabled (re-review/approve don't apply)
- Both PRs and Issues now have identical Quick links card structure
Made-with: Cursor
* update reviews screenshot with standalone quick links card
Made-with: Cursor
* polish dashboard UI: revert quick links to inline toggles, fix fonts and spacing
- revert standalone PR/issue Quick Links cards back to inline toggles inside
Review PRs and Enrich Issues cards (fixes prCreated state coupling bug)
- restore original font-medium card titles across all trigger/settings cards
- fix sidebar: add CONSOLE heading, remove nested indentation, remove truncation
- right-justify Enrich Issues mode dropdown, group description with label
- move instructions links inline with behavior descriptions
- replace text save indicators with icon spinner/checkmark
- standardize section title spacing, move footer below danger zone
Made-with: Cursor
* fix formatting for biome lint
Made-with: Cursor
* address PR review feedback: cleanup guard, shared util, wiki update
- clear ctx.toolState.review after read to prevent double-execution of postReviewCleanup
- forward authorPermission in safety-net re-review dispatch
- extract parseModeInstructions to utils/schemas/modeInstructions.ts
- update wiki/modes.md: remove stale v1/v2 language, fix dashboard layout
- add typecheck to pre-push hook
Made-with: Cursor
* add action typecheck to pre-push, fix exactOptionalPropertyTypes errors
Made-with: Cursor
* fix duplicate actuallyReviewedSha from rebase
Made-with: Cursor
* remove PR screenshots
Made-with: Cursor
* add label/textarea association for mode instruction accessibility
Made-with: Cursor
---------
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
192 lines
7.2 KiB
TypeScript
192 lines
7.2 KiB
TypeScript
import { isAbsolute, resolve } from "node:path";
|
|
import * as core from "@actions/core";
|
|
import { type } from "arktype";
|
|
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
|
|
import packageJson from "../package.json" with { type: "json" };
|
|
import type { RepoSettings } from "./runContext.ts";
|
|
import { validateCompatibility } from "./versioning.ts";
|
|
|
|
// tool permission enum types for inputs
|
|
const ToolPermissionInput = type.enumerated("disabled", "enabled");
|
|
const ShellPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
const PushPermissionInput = type.enumerated("disabled", "restricted", "enabled");
|
|
|
|
// schema for JSON payload passed via prompt (internal dispatch invocation)
|
|
// note: permissions are intentionally NOT included here to prevent injection attacks
|
|
// permissions are derived from event.authorPermission instead
|
|
export const JsonPayload = type({
|
|
"~pullfrog": "true",
|
|
version: "string",
|
|
"agent?": AgentName.or("undefined"),
|
|
prompt: "string",
|
|
"triggerer?": "string | undefined",
|
|
|
|
"eventInstructions?": "string",
|
|
"event?": "object",
|
|
"effort?": Effort.or("undefined"),
|
|
"timeout?": "string | undefined",
|
|
"progressCommentId?": "string | undefined",
|
|
"debug?": "boolean | undefined",
|
|
});
|
|
|
|
// permission levels that indicate collaborator status (have push access)
|
|
const COLLABORATOR_PERMISSIONS: AuthorPermission[] = ["admin", "maintain", "write"];
|
|
|
|
// check if the event author has collaborator-level permissions
|
|
function isCollaborator(event: PayloadEvent): boolean {
|
|
const perm = event.authorPermission;
|
|
return perm !== undefined && COLLABORATOR_PERMISSIONS.includes(perm);
|
|
}
|
|
|
|
// inputs schema - action inputs from core.getInput()
|
|
// note: tool permissions use .or("undefined") because getInput() || undefined
|
|
// explicitly sets the property to undefined when empty, which is different from
|
|
// the property being absent. arktype's "prop?" means "optional to include" but
|
|
// if included, must match the type - so we need to explicitly allow undefined.
|
|
export const Inputs = type({
|
|
prompt: "string",
|
|
"effort?": Effort.or("undefined"),
|
|
"timeout?": type.string.or("undefined"),
|
|
"agent?": AgentName.or("undefined"),
|
|
"web?": ToolPermissionInput.or("undefined"),
|
|
"search?": ToolPermissionInput.or("undefined"),
|
|
"push?": PushPermissionInput.or("undefined"),
|
|
"shell?": ShellPermissionInput.or("undefined"),
|
|
"cwd?": type.string.or("undefined"),
|
|
"output_schema?": type.string.or("undefined"),
|
|
});
|
|
|
|
export type Inputs = typeof Inputs.infer;
|
|
|
|
function isAgentName(value: unknown): value is AgentName {
|
|
return typeof value === "string" && AgentName(value) instanceof type.errors === false;
|
|
}
|
|
|
|
function isPayloadEvent(value: unknown): value is PayloadEvent {
|
|
return typeof value === "object" && value !== null && "trigger" in value;
|
|
}
|
|
|
|
function resolveCwd(cwd: string | undefined): string | undefined {
|
|
const workspace = process.env.GITHUB_WORKSPACE;
|
|
if (!cwd) return workspace;
|
|
if (isAbsolute(cwd)) return cwd;
|
|
return workspace ? resolve(workspace, cwd) : cwd;
|
|
}
|
|
|
|
export type ResolvedPromptInput = string | typeof JsonPayload.infer;
|
|
|
|
export function resolvePromptInput(): ResolvedPromptInput {
|
|
const prompt = core.getInput("prompt", { required: true });
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(prompt);
|
|
} catch {
|
|
// JSON parse error is fine (plain text prompt)
|
|
return prompt;
|
|
}
|
|
|
|
if (!parsed || typeof parsed !== "object" || !("~pullfrog" in parsed)) {
|
|
// if it doesn't look like a pullfrog payload, return the plain text prompt
|
|
return prompt;
|
|
}
|
|
|
|
// validation errors should propagate
|
|
const jsonPayload = JsonPayload.assert(parsed);
|
|
validateCompatibility(jsonPayload.version, packageJson.version);
|
|
return jsonPayload;
|
|
}
|
|
|
|
function resolveNonPromptInputs() {
|
|
return Inputs.omit("prompt").assert({
|
|
effort: core.getInput("effort") || undefined,
|
|
timeout: core.getInput("timeout") || undefined,
|
|
agent: core.getInput("agent") || undefined,
|
|
cwd: core.getInput("cwd") || undefined,
|
|
web: core.getInput("web") || undefined,
|
|
search: core.getInput("search") || undefined,
|
|
push: core.getInput("push") || undefined,
|
|
shell: core.getInput("shell") || undefined,
|
|
});
|
|
}
|
|
|
|
const isPullfrog = (actor: string | null | undefined): boolean => {
|
|
actor = actor?.replace("[bot]", "");
|
|
return !!actor && (actor === "pullfrog" || actor === "pullfrogdev");
|
|
};
|
|
|
|
export function resolvePayload(
|
|
resolvedPromptInput: ResolvedPromptInput,
|
|
repoSettings: RepoSettings
|
|
) {
|
|
const [prompt, jsonPayload] =
|
|
typeof resolvedPromptInput !== "string"
|
|
? [resolvedPromptInput.prompt, resolvedPromptInput]
|
|
: [resolvedPromptInput, undefined];
|
|
|
|
const inputs = resolveNonPromptInputs();
|
|
|
|
// validate agent name
|
|
const agent: AgentName | undefined =
|
|
inputs.agent !== undefined && isAgentName(inputs.agent) ? inputs.agent : undefined;
|
|
|
|
// resolve event - use type guard for jsonPayload.event, fallback to unknown trigger
|
|
const rawEvent = jsonPayload?.event;
|
|
const event: PayloadEvent = isPayloadEvent(rawEvent) ? rawEvent : { trigger: "unknown" };
|
|
|
|
// resolve agent from jsonPayload with type guard
|
|
const jsonAgent = jsonPayload?.agent;
|
|
const resolvedAgent: AgentName | undefined =
|
|
agent ?? (jsonAgent !== undefined && isAgentName(jsonAgent) ? jsonAgent : undefined);
|
|
|
|
// determine shell permission - strictest setting wins
|
|
// precedence: disabled > restricted > enabled
|
|
// non-collaborators always get at least "restricted"
|
|
const isNonCollaborator = !isCollaborator(event);
|
|
const repoShell = repoSettings.shell ?? "restricted";
|
|
const inputShell = inputs.shell;
|
|
|
|
// resolve shell: start with repo setting, then apply restrictions
|
|
let resolvedShell = repoShell;
|
|
|
|
// input can only make it stricter (disabled > restricted > enabled)
|
|
if (inputShell === "disabled") {
|
|
resolvedShell = "disabled";
|
|
} else if (inputShell === "restricted" && resolvedShell === "enabled") {
|
|
resolvedShell = "restricted";
|
|
}
|
|
|
|
// non-collaborators get at least "restricted" (can't have "enabled")
|
|
if (isNonCollaborator && resolvedShell === "enabled") {
|
|
resolvedShell = "restricted";
|
|
}
|
|
|
|
// build payload - precedence: inputs > repoSettings > fallbacks
|
|
// note: modes are NOT in payload - they come from repoSettings in main()
|
|
return {
|
|
"~pullfrog": true as const,
|
|
version: jsonPayload?.version ?? packageJson.version,
|
|
agent: resolvedAgent,
|
|
prompt,
|
|
triggerer:
|
|
jsonPayload?.triggerer ??
|
|
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
|
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
|
eventInstructions: jsonPayload?.eventInstructions,
|
|
event,
|
|
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
|
|
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
|
cwd: resolveCwd(inputs.cwd),
|
|
progressCommentId: jsonPayload?.progressCommentId,
|
|
debug: jsonPayload?.debug,
|
|
|
|
// permissions: inputs > repoSettings > fallbacks
|
|
web: inputs.web ?? repoSettings.web ?? "enabled",
|
|
search: inputs.search ?? repoSettings.search ?? "enabled",
|
|
push: inputs.push ?? repoSettings.push ?? "restricted",
|
|
shell: resolvedShell,
|
|
};
|
|
}
|
|
|
|
export type ResolvedPayload = ReturnType<typeof resolvePayload>;
|