Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/aw/compat.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"min-gh-aw": "0.72.0",
"max-gh-aw": "*",
"min-agent": "1.0.21",
"max-agent": "1.0.75",
"max-agent": "1.0.77",
"open": true
},
{
Expand Down
16 changes: 12 additions & 4 deletions setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ function normalizeWorkflowIdList(ids) {
];
}

/**
* Normalize a list of mention aliases: trim, strip leading "@" characters, and drop empty entries.
* @param {unknown} aliases
* @returns {string[]}
*/
function normalizeMentionAliases(aliases) {
if (!Array.isArray(aliases)) return [];
return aliases.map(alias => (typeof alias === "string" ? alias.trim().replace(/^@+/, "") : "")).filter(alias => alias.length > 0);
}

/**
* Resolve effective event name/payload for native and forwarded contexts.
* Supports:
Expand Down Expand Up @@ -408,10 +418,8 @@ async function main(config = {}) {
const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : [];
const requiredTitlePrefix = config.required_title_prefix || "";
const mentionsDisabled = config.mentions === false || config.mentions?.enabled === false;
const preResolvedMentionAliases =
!mentionsDisabled && Array.isArray(config.allowedMentionAliases) ? config.allowedMentionAliases.map(alias => (typeof alias === "string" ? alias.trim().replace(/^@+/, "") : "")).filter(alias => alias.length > 0) : [];
const configuredMentionAliases =
!mentionsDisabled && Array.isArray(config.mentions?.allowed) ? config.mentions.allowed.map(alias => (typeof alias === "string" ? alias.trim().replace(/^@+/, "") : "")).filter(alias => alias.length > 0) : [];
const preResolvedMentionAliases = !mentionsDisabled ? normalizeMentionAliases(config.allowedMentionAliases) : [];
const configuredMentionAliases = !mentionsDisabled ? normalizeMentionAliases(config.mentions?.allowed) : [];

// Create an authenticated GitHub client. Uses config["github-token"] when set
// (for cross-repository operations), otherwise falls back to the step-level github.
Expand Down
2 changes: 1 addition & 1 deletion setup/js/add_reaction.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async function main() {
core.info(`Adding reaction: ${reaction}`);

// Validate reaction type
if (!Object.prototype.hasOwnProperty.call(REACTION_MAP, reaction)) {
if (!Object.hasOwn(REACTION_MAP, reaction)) {
core.setFailed(`${ERR_VALIDATION}: Invalid reaction type: ${reaction}. Valid reactions are: ${Object.keys(REACTION_MAP).join(", ")}`);
return;
}
Expand Down
39 changes: 4 additions & 35 deletions setup/js/ai_credits_context.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,12 @@ function parsePositiveNumberString(value) {
return "";
}

/**
* @param {string} left
* @param {string} right
* @returns {boolean}
*/
function isNumberStringGreaterThanOrEqual(left, right) {
if (!left || !right) return false;
const leftNumber = Number.parseFloat(left);
const rightNumber = Number.parseFloat(right);
return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && leftNumber >= rightNumber;
}

/**
* @param {boolean} hasRateLimitSignal
* @param {string} aiCredits
* @param {string} maxAICredits
* @returns {boolean}
*/
function shouldReportAICreditsRateLimitError(hasRateLimitSignal, aiCredits, maxAICredits) {
if (!hasRateLimitSignal) return false;
if (!aiCredits || !maxAICredits) return true;
return isNumberStringGreaterThanOrEqual(aiCredits, maxAICredits);
function shouldReportAICreditsRateLimitError(hasRateLimitSignal) {
return hasRateLimitSignal;
}

/**
Expand Down Expand Up @@ -222,22 +206,7 @@ function parseAICreditsErrorInfoFromAuditEntry(entry) {
function iterateAuditEntries(auditJsonlPathOverride, defaultValue, contentGuard, accumulate) {
try {
const auditJsonlPath = resolveFirewallAuditLogPath(auditJsonlPathOverride);
if (!fs.existsSync(auditJsonlPath)) return defaultValue;
const content = fs.readFileSync(auditJsonlPath, "utf8");
if (!content.trim()) return defaultValue;
if (contentGuard && !contentGuard(content)) return defaultValue;
let result = defaultValue;
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed[0] !== "{") continue;
try {
const nextResult = accumulate(result, JSON.parse(trimmed));
if (nextResult !== undefined) result = nextResult;
} catch {
// ignore malformed lines
}
}
return result;
return iterateJSONLFiles([auditJsonlPath], defaultValue, contentGuard, accumulate);
} catch {
return defaultValue;
}
Expand Down Expand Up @@ -525,7 +494,7 @@ function resolveAICreditsFailureState({ logProvenance = true } = {}) {
const aiCredits = auditAICredits || stdioSignals.aiCredits || envAICredits || "";
const maxAICredits = auditMaxAICredits || stdioSignals.maxAICredits || envMaxAICredits || "";
const rawAICreditsRateLimitError = auditRateLimitError || stdioSignals.rateLimitError || envRateLimitSignalHasEvidence;
const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError, aiCredits, maxAICredits);
const aiCreditsRateLimitError = shouldReportAICreditsRateLimitError(rawAICreditsRateLimitError);
return { aiCredits, maxAICredits, aiCreditsRateLimitError, maxAICreditsExceeded: auditMaxAICreditsExceeded || stdioSignals.maxAICreditsExceeded };
}

Expand Down
42 changes: 38 additions & 4 deletions setup/js/create_pull_request.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,39 @@ async function tryRecoverGitAmAddAddConflict(execApi) {
}
}

/**
* Resolves auto-merge enablement and merge method from the handler config.
*
* Supported values:
* - false / "false" / empty => disabled
* - true / "true" => enabled with SQUASH as the default merge strategy
* - "squash" | "merge" | "rebase" => enabled with explicit strategy
* - any other value => disabled with a warning (fail-closed)
*
* @param {any} value
* @returns {{ enabled: boolean, mergeMethod?: "SQUASH" | "MERGE" | "REBASE" }}
*/
function parseAutoMergeConfig(value) {
const normalized = String(value ?? "")
.trim()
.toLowerCase();
if (!normalized || normalized === "false") {
return { enabled: false };
}
switch (normalized) {
case "squash":
case "true":
return { enabled: true, mergeMethod: "SQUASH" };
case "merge":
return { enabled: true, mergeMethod: "MERGE" };
case "rebase":
return { enabled: true, mergeMethod: "REBASE" };
default:
core.warning(`Unrecognized auto-merge value "${value}". Expected true, false, "squash", "merge", or "rebase". Auto-merge will be disabled.`);
return { enabled: false };
}
}

/**
* Apply a git bundle to a local branch without fetching directly into the branch ref.
* Fetching directly into refs/heads/<branch> fails when that branch is currently checked out.
Expand Down Expand Up @@ -724,7 +757,7 @@ async function main(config = {}) {
const draftDefault = parseBoolTemplatable(config.draft, true);
const ifNoChanges = config.if_no_changes || "warn";
const allowEmpty = parseBoolTemplatable(config.allow_empty, false);
const autoMerge = parseBoolTemplatable(config.auto_merge, false);
const { enabled: autoMerge, mergeMethod: autoMergeMethod } = parseAutoMergeConfig(config.auto_merge);
const preserveBranchName = config.preserve_branch_name === true;
const recreateRef = config.recreate_ref === true;
const signedCommits = config.signed_commits !== false;
Expand Down Expand Up @@ -2559,15 +2592,16 @@ ${patchPreview}`;
if (autoMerge) {
try {
await githubClient.graphql(
`mutation($prId: ID!) {
enablePullRequestAutoMerge(input: {pullRequestId: $prId}) {
`mutation($prId: ID!, $mergeMethod: PullRequestMergeMethod) {
enablePullRequestAutoMerge(input: {pullRequestId: $prId, mergeMethod: $mergeMethod}) {
pullRequest {
id
}
}
}`,
{
prId: pullRequest.node_id,
mergeMethod: autoMergeMethod,
}
);
core.info(`Enabled auto-merge for pull request #${pullRequest.number}`);
Expand Down Expand Up @@ -2786,4 +2820,4 @@ ${patchPreview}`;
}; // End of handleCreatePullRequest
} // End of main

module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch, rewriteBundleBranchAsSingleCommit };
module.exports = { main, enforcePullRequestLimits, countUniquePatchFiles, parseDiffGitHeader, applyBundleToBranch, rewriteBundleBranchAsSingleCommit, parseAutoMergeConfig };
92 changes: 84 additions & 8 deletions setup/js/dispatch_workflow.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
const HANDLER_TYPE = "dispatch_workflow";

const { getErrorMessage } = require("./error_helpers.cjs");
const { globPatternToRegex } = require("./glob_pattern_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { resolveTargetRepoConfig, parseRepoSlug, validateTargetRepo } = require("./repo_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
Expand All @@ -29,6 +30,8 @@ async function main(config = {}) {
const awContextWorkflows = new Set(config.aw_context_workflows || []); // Workflows that accept aw_context input
const githubClient = await createAuthenticatedGitHubClient(config);
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const allowedRefPatterns = parseAllowedRefPatterns(config.allowed_refs);
const allowedRefRegexes = allowedRefPatterns.map(pattern => globPatternToRegex(pattern, { pathMode: true, caseSensitive: true }));

// Resolve the dispatch destination repository from target-repo config, falling back to context.repo
const contextRepoSlug = `${context.repo.owner}/${context.repo.repo}`;
Expand Down Expand Up @@ -107,23 +110,23 @@ async function main(config = {}) {
// GITHUB_HEAD_REF which contains the actual PR branch name.
// For cross-repo dispatch (workflow_call relay), the caller's GITHUB_REF has no meaning on
// the target repository, so we use the compiler-injected target-ref instead.
let ref;
let defaultRef;
if (config["target-ref"]) {
// Compiler-injected target ref for cross-repo dispatch (workflow_call relay pattern).
// Takes precedence over all environment variables to avoid using the caller's ref.
ref = config["target-ref"];
core.info(`Using configured target-ref: ${ref}`);
defaultRef = config["target-ref"];
core.info(`Using configured target-ref: ${defaultRef}`);
} else if (process.env.GITHUB_HEAD_REF) {
// We're in a pull_request event, use the PR branch ref
ref = `refs/heads/${process.env.GITHUB_HEAD_REF}`;
core.info(`Using PR branch ref: ${ref}`);
defaultRef = `refs/heads/${process.env.GITHUB_HEAD_REF}`;
core.info(`Using PR branch ref: ${defaultRef}`);
} else if (process.env.GITHUB_REF || context.ref) {
// Use GITHUB_REF for non-PR contexts (push, workflow_dispatch, etc.)
ref = process.env.GITHUB_REF || context.ref;
defaultRef = process.env.GITHUB_REF || context.ref;
} else {
// Last resort: fetch the repository's default branch
ref = await getDefaultBranchRef();
core.info(`Using default branch ref: ${ref}`);
defaultRef = await getDefaultBranchRef();
core.info(`Using default branch ref: ${defaultRef}`);
}

/**
Expand Down Expand Up @@ -177,6 +180,31 @@ async function main(config = {}) {

core.info(`Dispatching workflow: ${workflowName}`);

if (message.ref !== undefined && message.ref !== null && typeof message.ref !== "string") {
core.warning(`message.ref must be a string; ignoring non-string value (type: ${typeof message.ref})`);
}
const outputRef = typeof message.ref === "string" ? message.ref.trim() : "";
let ref = defaultRef;
if (outputRef) {
ref = normalizeRef(outputRef);
if (allowedRefRegexes.length === 0) {
const error = "message.ref is not allowed unless 'allowed-refs' is configured in safe-outputs.dispatch-workflow";
core.warning(error);
return {
success: false,
error,
};
}
if (!allowedRefRegexes.some(pattern => pattern.test(ref))) {
const error = `Ref '${ref}' is not in allowed-refs: ${allowedRefPatterns.join(", ")}`;
core.warning(error);
return {
success: false,
error,
};
}
}

// Prepare inputs - convert all values to strings as required by workflow_dispatch
// and resolve any #temporary_id references before dispatching
/** @type {Record<string, string>} */
Expand Down Expand Up @@ -320,4 +348,52 @@ async function main(config = {}) {
};
}

/**
* @param {string[]|string|undefined} allowedRefsValue
* @returns {string[]}
*/
function parseAllowedRefPatterns(allowedRefsValue) {
/** @type {string[]} */
const refs = [];
if (Array.isArray(allowedRefsValue)) {
for (const pattern of allowedRefsValue) {
if (typeof pattern === "string") {
const trimmed = pattern.trim();
if (trimmed) {
refs.push(normalizeRefPattern(trimmed));
}
}
}
return refs;
}
if (typeof allowedRefsValue === "string") {
return allowedRefsValue
.split(",")
.map(pattern => pattern.trim())
.filter(Boolean)
.map(normalizeRefPattern);
}
return refs;
}

/**
* @param {string} refOrBranch
* @returns {string}
*/
function normalizeRef(refOrBranch) {
if (refOrBranch.startsWith("refs/")) return refOrBranch;
if (refOrBranch.startsWith("tags/")) return `refs/${refOrBranch}`;
return `refs/heads/${refOrBranch}`;
}

/**
* @param {string} pattern
* @returns {string}
*/
function normalizeRefPattern(pattern) {
if (pattern.startsWith("refs/")) return pattern;
if (pattern.startsWith("tags/")) return `refs/${pattern}`;
return `refs/heads/${pattern}`;
}

module.exports = { main };
13 changes: 10 additions & 3 deletions setup/js/file_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ function listFilesRecursively(dirPath, relativeTo) {
* @param {string} artifactDir - The artifact directory to list if file not found
* @param {string} fileDescription - Description of the file (e.g., "Prompt file", "Agent output file")
* @param {boolean} required - Whether the file is required
* @param {boolean} [continueOnError=false] - Whether missing required files should warn instead of failing
* @returns {boolean} True if file exists (or not required), false otherwise
*/
function checkFileExists(filePath, artifactDir, fileDescription, required) {
function checkFileExists(filePath, artifactDir, fileDescription, required, continueOnError = false) {
if (fs.existsSync(filePath)) {
try {
const stats = fs.statSync(filePath);
Expand All @@ -63,7 +64,9 @@ function checkFileExists(filePath, artifactDir, fileDescription, required) {
}
} else {
if (required) {
core.error("❌ " + fileDescription + " not found at: " + filePath);
if (!continueOnError) {
core.error("❌ " + fileDescription + " not found at: " + filePath);
}
// List all files in artifact directory for debugging
core.info("📁 Listing all files in artifact directory: " + artifactDir);
const files = listFilesRecursively(artifactDir, artifactDir);
Expand All @@ -73,7 +76,11 @@ function checkFileExists(filePath, artifactDir, fileDescription, required) {
core.info(" Found " + files.length + " file(s):");
files.forEach(file => core.info(" - " + file));
}
core.setFailed(`${ERR_SYSTEM}: ❌ ${fileDescription} not found at: ${filePath}`);
if (continueOnError) {
core.warning(`${ERR_SYSTEM}: ❌ ${fileDescription} not found at: ${filePath}. Continuing because GH_AW_DETECTION_CONTINUE_ON_ERROR=true`);
} else {
core.setFailed(`${ERR_SYSTEM}: ❌ ${fileDescription} not found at: ${filePath}`);
}
return false;
} else {
core.info("No " + fileDescription.toLowerCase() + " found at: " + filePath);
Expand Down
Loading
Loading