Files
shockbot/external.ts
T
David Blass 8c6cd2bda2 cancel + restart workflow run when @pullfrog mention is edited (#612)
* cancel + restart workflow run when @pullfrog mention is edited

- add `WorkflowRun.triggeringCommentId` (BigInt?, indexed) so the webhook
  handler can find the run that was fired by a given comment
- thread `triggeringCommentId` through `reserveRun` / `triggerWorkflow`
- factor `dispatchMentionRun` out of `issue_comment_created` so the same
  shape is reused on edit
- replace the `issue_comment_edited` stub: re-evaluates the trigger gate,
  cancels prior runs (`octokit.rest.actions.cancelWorkflowRun` + DB
  status='cancelled'), then re-dispatches with a `previousRunsNote`
  appended to `eventInstructions` so the agent acknowledges the prior
  run/PR/artifacts in its summary
- if the edit removes `@pullfrog`, cancel only (no restart)

Co-authored-by: Cursor <cursoragent@cursor.com>

* thread previousRunsNote via dedicated payload field

user prompt has precedence over eventInstructions, so stuffing the
prior-runs note into eventInstructions made it vanish whenever the
trigger comment contained an @pullfrog mention (which is always for the
edit path). pass it as its own payload field and render it alongside the
user's task so the agent actually sees it.

* delete cancelled run's progress comment on edit-restart

so the issue thread doesn't accumulate "This run was cancelled" stubs
on every edit. only deletes for runs we actively cancel; runs that were
already terminal (e.g. completed before the edit) keep their summary
comment in the thread, and `previousRunsNote` links to it so the new
agent can reference prior work.

post-cleanup is race-safe: the action's `validateStuckProgressComment`
swallows the 404 from the deleted comment and exits cleanly, so the
old run's post step cannot clobber the new run's leaping comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* also cancel + delete progress comment when triggering comment is deleted

mirrors the edit-removes-@pullfrog path: when an @pullfrog comment that
fired a run is hard-deleted, look up any prior runs by triggeringCommentId,
GH-cancel running ones, and delete their leaping progress comments.

skips trigger-gate re-eval (we're tearing down a run, not firing one) and
performs no restart. reuses the existing cancelRunsForTriggeringComment
helper; the returned previousRunsNote is discarded since no dispatch
follows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: move cancellation before trigger gate in issue_comment_edited

cancelRunsForTriggeringComment now runs before the triggerEnabled check,
so edits that remove @pullfrog still cancel in-flight runs even when the
repo mention trigger is currently disabled (e.g. for non-collaborators).

* anneal: scope cancel updates per-row + simplify edit gate

- replace blanket updateMany on (triggeringCommentId, repoId) with per-row, status-guarded updates so a parallel handler's freshly-reserved run cannot be clobbered into cancelled by a racing edit delivery.
- drop wasMention/isMention early-break in issue_comment_edited; always run cancelRunsForTriggeringComment (DB is the canonical "did this comment ever trigger a run" source). closes the missing-changes.body.from edge and lets us tear down a still-running prior run even if the admin disabled the mention trigger mid-flight.
- buildPreviousRunsNote returns undefined (not "") when no link lines materialize.
- doc cleanups + wiki/modes.md addendum noting issue_comment_edited / _deleted now drive cancel + restart.

Co-authored-by: Cursor <cursoragent@cursor.com>

* address review feedback on cancel/restart semantics

- guard workflow_run.completed update against status='cancelled' so a
  successful-but-uncancellable GH Actions job can't resurrect a cancelled
  row (and re-bill it) via the completed webhook.
- bucket only status='completed' runs into `preserved` in
  cancelRunsForTriggeringComment; cancelled/failed prior runs have stubs
  as their progress comment, not summaries worth referencing.
- emit previousRunsNote for the runId-null cancel case so the restarted
  agent always knows when it's superseding a prior dispatch.
- drop the agent-forbidden `gh pr list` hint and soften 'was cancelled'
  to 'was signalled to cancel' in the note body.
- post a fallback comment when the edit-path dispatch fails (prior run
  already torn down and progress comment already deleted).
- symmetrize the delete-handler's pullfrog guard with the edit handler
  (key off hook.comment.user, not hook.sender).
- trim misleading comments on the per-row DB update guard.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
2026-05-11 23:20:44 +00:00

297 lines
9.0 KiB
TypeScript

/**
* ⚠️ LIMITED IMPORTS - this file is imported by Next.js and must avoid pulling in backend code.
* All shared constants, types, and data used by both the Next.js app and the action runtime live here.
* Other files in action/ re-export from this file for backward compatibility.
*/
// mcp name constant
export const pullfrogMcpName = "pullfrog";
/** @see {@link file://./agents/shared.ts} Agent interface that uses this type */
export type AgentId = "claude" | "opencode";
/**
* format a tool name the way each agent's MCP client presents it to the model.
* claude code: mcp__pullfrog__select_mode
* opencode: pullfrog_select_mode
*/
export function formatMcpToolRef(agentId: AgentId, toolName: string): string {
switch (agentId) {
case "claude":
return `mcp__${pullfrogMcpName}__${toolName}`;
case "opencode":
return `${pullfrogMcpName}_${toolName}`;
default:
return agentId satisfies never;
}
}
// model alias registry lives in models.ts — re-exported here for shared access
export type { ModelAlias, ModelProvider, ProviderConfig } from "./models.ts";
export {
getModelEnvVars,
getModelProvider,
getProviderDisplayName,
modelAliases,
parseModel,
providers,
resolveCliModel,
resolveDisplayAlias,
resolveModelSlug,
resolveOpenRouterModel,
} from "./models.ts";
// tool permission types shared with server dispatch
export type ToolPermission = "disabled" | "enabled";
export type ShellPermission = "disabled" | "restricted" | "enabled";
export type PushPermission = "disabled" | "restricted" | "enabled";
// workflow yml permissions for GITHUB_TOKEN
export type WorkflowPermissionValue = "read" | "write" | "none";
export type WorkflowIdTokenPermissionValue = "write" | "none";
export interface WorkflowPermissions {
actions?: WorkflowPermissionValue;
attestations?: WorkflowPermissionValue;
checks?: WorkflowPermissionValue;
contents?: WorkflowPermissionValue;
deployments?: WorkflowPermissionValue;
discussions?: WorkflowPermissionValue;
"id-token"?: WorkflowIdTokenPermissionValue;
issues?: WorkflowPermissionValue;
models?: WorkflowPermissionValue;
packages?: WorkflowPermissionValue;
pages?: WorkflowPermissionValue;
"pull-requests"?: WorkflowPermissionValue;
"repository-projects"?: WorkflowPermissionValue;
"security-events"?: WorkflowPermissionValue;
statuses?: WorkflowPermissionValue;
}
// permission level for the author who triggered the event
// matches GitHub's permission levels: admin > write > maintain > triage > read > none
export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none";
// base interface for common payload event fields
interface BasePayloadEvent {
issue_number?: number;
is_pr?: boolean;
branch?: string;
/** title of the issue/PR (or contextual title for comments) */
title?: string;
/** primary content for this trigger (issue body, PR body, comment body, review body, etc.) */
body?: string | null;
comment_id?: number;
review_id?: number;
review_state?: string;
thread?: any;
pull_request?: any;
check_suite?: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
comment_ids?: number[] | "all";
/** permission level of the user who triggered this event */
authorPermission?: AuthorPermission;
/** when true, runs silently without progress comments (e.g., auto-labeling) */
silent?: boolean;
[key: string]: any;
}
interface PullRequestOpenedEvent extends BasePayloadEvent {
trigger: "pull_request_opened";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReadyForReviewEvent extends BasePayloadEvent {
trigger: "pull_request_ready_for_review";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReviewRequestedEvent extends BasePayloadEvent {
trigger: "pull_request_review_requested";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
}
interface PullRequestReviewSubmittedEvent extends BasePayloadEvent {
trigger: "pull_request_review_submitted";
issue_number: number;
is_pr: true;
review_id: number;
/** review body is the primary content */
body: string | null;
review_state: string;
branch: string;
}
interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
trigger: "pull_request_review_comment_created";
issue_number: number;
is_pr: true;
title: string;
comment_id: number;
/** comment body is the primary content (null if already in prompt) */
body: string | null;
thread?: any;
branch: string;
}
interface IssuesOpenedEvent extends BasePayloadEvent {
trigger: "issues_opened";
issue_number: number;
title: string;
body: string | null;
}
interface IssuesAssignedEvent extends BasePayloadEvent {
trigger: "issues_assigned";
issue_number: number;
title: string;
body: string | null;
}
interface IssuesLabeledEvent extends BasePayloadEvent {
trigger: "issues_labeled";
issue_number: number;
title: string;
body: string | null;
}
interface IssueCommentCreatedEvent extends BasePayloadEvent {
trigger: "issue_comment_created";
comment_id: number;
/** distinguishes this from PR review comments (which use pull_request_review_comment_created) */
comment_type: "issue";
/** comment body is the primary content (null if already in prompt) */
body: string | null;
issue_number: number;
// PR-specific fields (only present when is_pr is true)
is_pr?: true;
branch?: string;
title?: string;
}
interface CheckSuiteCompletedEvent extends BasePayloadEvent {
trigger: "check_suite_completed";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
pull_request: any;
branch: string;
check_suite: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
}
interface WorkflowDispatchEvent extends BasePayloadEvent {
trigger: "workflow_dispatch";
}
interface FixReviewEvent extends BasePayloadEvent {
trigger: "fix_review";
issue_number: number;
is_pr: true;
review_id: number;
/** when true, only address comments the triggerer approved with 👍 (vs all comments) */
approved_only?: boolean | undefined;
}
interface ImplementPlanEvent extends BasePayloadEvent {
trigger: "implement_plan";
issue_number: number;
plan_comment_id: number;
/** plan content is the primary content (null if already in prompt) */
body: string | null;
}
interface PullRequestSynchronizeEvent extends BasePayloadEvent {
trigger: "pull_request_synchronize";
issue_number: number;
is_pr: true;
title: string;
body: string | null;
branch: string;
/** SHA before the push -- used to compute incremental range-diff between PR versions */
before_sha: string;
}
interface UnknownEvent extends BasePayloadEvent {
trigger: "unknown";
}
// discriminated union for payload event based on trigger
// note: all events use issue_number for consistency (PRs are issues in GitHub's API)
export type PayloadEvent =
| PullRequestOpenedEvent
| PullRequestReadyForReviewEvent
| PullRequestSynchronizeEvent
| PullRequestReviewRequestedEvent
| PullRequestReviewSubmittedEvent
| PullRequestReviewCommentCreatedEvent
| IssuesOpenedEvent
| IssuesAssignedEvent
| IssuesLabeledEvent
| IssueCommentCreatedEvent
| CheckSuiteCompletedEvent
| WorkflowDispatchEvent
| FixReviewEvent
| ImplementPlanEvent
| UnknownEvent;
// writeable payload type for building payloads
export interface WriteablePayload {
"~pullfrog": true;
/** semantic version of the payload to ensure compatibility */
version: string;
/** provider/model slug (e.g. "anthropic/claude-opus") */
model?: string | undefined;
/** the user's actual request (body if @pullfrog tagged) */
prompt: string;
/** github username of the human who triggered this workflow run */
triggerer?: string | undefined;
/** event-level instructions for this trigger type (flag-expanded server-side) */
eventInstructions?: string | undefined;
/**
* system-injected note about prior superseded runs (e.g. when the
* triggering @pullfrog comment is edited). rendered alongside the user's
* prompt rather than via eventInstructions so it survives user-prompt
* precedence.
*/
previousRunsNote?: string | undefined;
/** event data from webhook payload - discriminated union based on trigger field */
event: PayloadEvent;
/** timeout for agent run (e.g., "10m", "1h30m") - defaults to "1h" */
timeout?: string | undefined;
/** working directory for the agent */
cwd?: string | undefined;
/** pre-created progress comment (ID + type) for updating status */
progressComment?: { id: string; type: "issue" | "review" } | undefined;
/** when true, seed the PR summary tmpfile + persist edits at run end */
generateSummary?: boolean | undefined;
}
// immutable payload type for agent execution
export type Payload = Readonly<WriteablePayload>;