This commit is contained in:
Colin McDonnell
2026-01-16 18:43:09 +00:00
committed by pullfrog[bot]
parent 101c666610
commit 69b9b96ddd
37 changed files with 1005 additions and 1005 deletions
+1 -11
View File
@@ -1,10 +1,5 @@
import type { Agent } from "../agents/index.ts";
export interface ApiKeySetup {
apiKey: string;
apiKeys: Record<string, string>;
}
/**
* Build a helpful error message for missing API key with links to repo settings
*/
@@ -61,7 +56,7 @@ function collectApiKeys(agent: Agent): Record<string, string> {
return apiKeys;
}
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): void {
const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) {
@@ -73,9 +68,4 @@ export function validateApiKey(params: { agent: Agent; owner: string; name: stri
})
);
}
return {
apiKey: Object.values(apiKeys)[0],
apiKeys,
};
}
+1 -1
View File
@@ -142,7 +142,7 @@ ${getShellInstructions(input.bash)}
### Available modes
${[...computeModes({ disableProgressComment: false }), ...input.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
${[...computeModes({ hasProgressComment: true }), ...input.modes].map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
### Following the mode instructions
+24
View File
@@ -1,11 +1,27 @@
import { log } from "./cli.ts";
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
function isSensitive(key: string): boolean {
return SENSITIVE_PATTERNS.some((p) => p.test(key));
}
function maskValue(value: string | undefined) {
if (value && typeof value === "string" && value.trim().length > 0) {
// ::add-mask::value tells GitHub Actions to mask this value in logs
console.log(`::add-mask::${value}`);
}
}
/**
* Normalize environment variables to uppercase.
* This handles case-insensitive env var names (e.g., `anthropic_api_key` -> `ANTHROPIC_API_KEY`).
*
* If there are conflicts (same key with different capitalizations but different values),
* logs a warning and keeps the uppercase version.
*
* Also registers sensitive values as masks in GitHub Actions.
*/
export function normalizeEnv(): void {
const upperKeys = new Map<string, string[]>();
@@ -20,6 +36,14 @@ export function normalizeEnv(): void {
// process each group
for (const [upperKey, keys] of upperKeys) {
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
if (isSensitive(upperKey)) {
// mask all values associated with this key group
for (const key of keys) {
maskValue(process.env[key]);
}
}
if (keys.length === 1) {
const key = keys[0];
if (key !== upperKey) {
+10 -16
View File
@@ -1,4 +1,5 @@
import { isAbsolute, resolve } from "node:path";
import * as core from "@actions/core";
import { type } from "arktype";
import {
AgentName,
@@ -6,6 +7,7 @@ import {
Effort,
type PayloadEvent,
} from "../external.ts";
import type { RepoSettings } from "./repoSettings.ts";
// tool permission enum types for inputs
const ToolPermissionInput = type.enumerated("disabled", "enabled");
@@ -22,10 +24,6 @@ const JsonPayload = type({
"search?": ToolPermissionInput,
"write?": ToolPermissionInput,
"bash?": BashPermissionInput,
"disableProgressComment?": "true",
"comment_id?": "number|null",
"issue_id?": "number|null",
"pr_id?": "number|null",
});
// inputs schema - action inputs from core.getInput()
@@ -61,9 +59,7 @@ function resolveCwd(cwd: string | null | undefined): string | null {
return workspace ? resolve(workspace, cwd) : cwd;
}
export function resolvePayload(core: {
getInput: (name: string, options?: { required?: boolean }) => string;
}) {
export function resolvePayload(repoSettings: RepoSettings) {
const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }),
effort: core.getInput("effort") || "auto",
@@ -107,7 +103,7 @@ export function resolvePayload(core: {
agent ??
(jsonAgent !== undefined && jsonAgent !== "null" && isAgentName(jsonAgent) ? jsonAgent : null);
// build payload - precedence: inputs > jsonPayload > defaults
// build payload - precedence: inputs > jsonPayload > repoSettings > fallbacks
// note: modes are NOT in payload - they come from repoSettings in main()
return {
"~pullfrog": true as const,
@@ -115,15 +111,13 @@ export function resolvePayload(core: {
prompt: inputs.prompt ?? jsonPayload?.prompt,
event,
effort: inputs.effort ?? jsonPayload?.effort ?? "auto",
web: inputs.web ?? jsonPayload?.web,
search: inputs.search ?? jsonPayload?.search,
write: inputs.write ?? jsonPayload?.write,
bash: inputs.bash ?? jsonPayload?.bash,
disableProgressComment: jsonPayload?.disableProgressComment === true,
comment_id: jsonPayload?.comment_id ?? null,
issue_id: jsonPayload?.issue_id ?? null,
pr_id: jsonPayload?.pr_id ?? null,
cwd: resolveCwd(inputs.cwd),
// permissions: inputs > jsonPayload > repoSettings > fallbacks
web: inputs.web ?? jsonPayload?.web ?? repoSettings.web ?? "enabled",
search: inputs.search ?? jsonPayload?.search ?? repoSettings.search ?? "enabled",
write: inputs.write ?? jsonPayload?.write ?? repoSettings.write ?? "enabled",
bash: inputs.bash ?? jsonPayload?.bash ?? repoSettings.bash ?? "restricted",
};
}
+13 -9
View File
@@ -1,38 +1,42 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "./github.ts";
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
export interface RepoData {
owner: string;
name: string;
octokit: Octokit;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
interface ResolveRepoDataParams {
octokit: OctokitWithPlugins;
token: string;
}
/**
* Initialize GitHub connection: token, octokit, repo data, settings
* Initialize repo data: parse context, fetch repo info and settings
*/
export async function resolveRepoData(token: string): Promise<RepoData> {
export async function resolveRepoData(params: ResolveRepoDataParams): Promise<RepoData> {
log.info(`» running Pullfrog v${packageJson.version}...`);
const { owner, name } = parseRepoContext();
const octokit = createOctokit(token);
// fetch repo data and settings in parallel
const [repoResponse, repoSettings] = await Promise.all([
octokit.repos.get({ owner, repo: name }),
fetchRepoSettings({ token, repoContext: { owner, name } }),
params.octokit.repos.get({ owner, repo: name }),
fetchRepoSettings({ token: params.token, repoContext: { owner, name } }),
]);
return {
owner,
name,
octokit,
repo: repoResponse.data,
repoSettings,
};
}
// re-export for convenience
export { createOctokit };
+25 -13
View File
@@ -10,22 +10,13 @@ export interface Mode {
export interface RepoSettings {
defaultAgent: AgentName | null;
modes: Mode[];
web: ToolPermission;
search: ToolPermission;
write: ToolPermission;
bash: BashPermission;
modes: Mode[];
}
export const DEFAULT_REPO_SETTINGS: RepoSettings = {
defaultAgent: null,
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
modes: [],
};
/**
* Fetch repository settings from the Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails
@@ -55,17 +46,38 @@ export async function fetchRepoSettings(params: {
clearTimeout(timeoutId);
if (!response.ok) {
return DEFAULT_REPO_SETTINGS;
return {
defaultAgent: null,
modes: [],
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
}
const settings = (await response.json()) as RepoSettings | null;
if (settings === null) {
return DEFAULT_REPO_SETTINGS;
return {
defaultAgent: null,
modes: [],
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
}
return settings;
} catch {
clearTimeout(timeoutId);
return DEFAULT_REPO_SETTINGS;
return {
defaultAgent: null,
modes: [],
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
}
}
+1 -18
View File
@@ -1,23 +1,6 @@
import type { AgentResult, ToolPermissions } from "../agents/shared.ts";
import type { AgentResult } from "../agents/shared.ts";
import type { MainResult } from "../main.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
/**
* Compute tool permissions from inputs.
* For run action, bash defaults to restricted for public repos when unset.
*/
export function resolvePermissions(params: {
payload: ResolvedPayload;
isPublicRepo: boolean;
}): ToolPermissions {
return {
web: params.payload.web ?? "enabled",
search: params.payload.search ?? "enabled",
write: params.payload.write ?? "enabled",
bash: params.payload.bash ?? (params.isPublicRepo ? "restricted" : "enabled"),
};
}
export async function handleAgentResult(result: AgentResult): Promise<MainResult> {
if (!result.success) {
+29 -16
View File
@@ -1,29 +1,42 @@
import { log } from "./cli.ts";
import type { RepoData } from "./repoData.ts";
import { fetchWorkflowRunInfo } from "./workflowRun.ts";
import type { OctokitWithPlugins } from "./github.ts";
import { fetchWorkflowRunInfo, type WorkflowRunInfo } from "./workflowRun.ts";
interface ResolveRunParams {
octokit: OctokitWithPlugins;
}
export interface ResolveRunResult {
runId: string;
jobId: string | undefined;
workflowRunInfo: WorkflowRunInfo;
}
/**
* Resolve GitHub Actions workflow run context (runId, jobId, progress comment)
* Resolve GitHub Actions workflow run context.
* Uses GITHUB_REPOSITORY and GITHUB_RUN_ID env vars.
*/
export async function resolveRunId(
repoData: RepoData
): Promise<{ runId: string; jobId: string | undefined }> {
export async function resolveRun(params: ResolveRunParams): Promise<ResolveRunResult> {
const runId = process.env.GITHUB_RUN_ID || "";
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo || !githubRepo.includes("/")) {
throw new Error(`GITHUB_REPOSITORY env var must be set to "owner/repo", got: ${githubRepo}`);
}
const [owner, repo] = githubRepo.split("/");
if (runId) {
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
const workflowRunInfo = runId ? await fetchWorkflowRunInfo(runId) : { progressCommentId: null };
if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
let jobId: string | undefined;
const jobName = process.env.GITHUB_JOB;
if (jobName && runId) {
const jobs = await repoData.octokit.rest.actions.listJobsForWorkflowRun({
owner: repoData.owner,
repo: repoData.name,
const jobs = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: parseInt(runId, 10),
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
@@ -33,5 +46,5 @@ export async function resolveRunId(
}
}
return { runId, jobId };
return { runId, jobId, workflowRunInfo };
}
+4 -5
View File
@@ -1,11 +1,10 @@
export interface WorkflowRunInfo {
progressCommentId: string | null;
issueNumber: number | null;
}
/**
* Fetch workflow run info from the Pullfrog API
* Returns the pre-created progress comment ID if one exists
* Fetch workflow run info from the Pullfrog API.
* Returns the pre-created progress comment ID if one exists.
*/
export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunInfo> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
@@ -25,13 +24,13 @@ export async function fetchWorkflowRunInfo(runId: string): Promise<WorkflowRunIn
clearTimeout(timeoutId);
if (!response.ok) {
return { progressCommentId: null, issueNumber: null };
return { progressCommentId: null };
}
const data = (await response.json()) as WorkflowRunInfo;
return data;
} catch {
clearTimeout(timeoutId);
return { progressCommentId: null, issueNumber: null };
return { progressCommentId: null };
}
}