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
7 changes: 2 additions & 5 deletions setup/js/add_comment.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -773,12 +773,9 @@ async function main(config = {}) {
// accepted for compatibility with forwarded/legacy payload variants.
const explicitCommentIdRaw = message.comment_id ?? message.commentId ?? message["comment-id"];
const rawTarget = message.target;
const allowedTargets = ["status"];
const allowedTargets = ["status", "issue", "discussion"];
if (rawTarget !== undefined && !allowedTargets.includes(rawTarget)) {
return {
success: false,
error: `target must be one of: [${allowedTargets.join(", ")}]`,
};
core.warning(`Ignoring unrecognized message-level target value "${rawTarget}": only "status", "issue", or "discussion" are supported. Proceeding without comment reuse.`);
}
const isStatusCommentTarget = rawTarget === "status";
const statusCommentIdRaw = process.env.GH_AW_COMMENT_ID || "";
Expand Down
183 changes: 175 additions & 8 deletions setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,176 @@ function ensureGitIdentity(cwd) {
}
}

/**
* Read and parse the GitHub Actions event payload from GITHUB_EVENT_PATH.
* Returns null when the file is unset, missing, or not parseable.
* @returns {Record<string, any>|null}
*/
function readEventPayload() {
const p = process.env.GITHUB_EVENT_PATH;
if (!p) return null;
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}

/**
* Resolve the best token for a `owner/repo` API call.
*
* Multi-checkout workflows often need different tokens for different
* repositories (e.g. a workflow runs in `owner/automation` but reaches into
* `owner/product` via a separate `checkout:` entry that supplies its own
* `github-token:` or `github-app:`). The compiler emits that mapping as
* `GH_AW_REPO_TOKENS` (a JSON object keyed by `owner/repo`); this helper
* looks the requested slug up there and falls back to GITHUB_TOKEN /
* GH_TOKEN.
*
* @param {string} owner
* @param {string} repo
* @returns {string|undefined}
*/
function selectTokenForRepo(owner, repo) {
const slug = `${owner}/${repo}`;
const raw = process.env.GH_AW_REPO_TOKENS;
if (raw && raw.trim()) {
try {
const map = JSON.parse(raw);
if (map && typeof map === "object" && typeof map[slug] === "string" && map[slug].trim()) {
return map[slug].trim();
}
} catch (err) {
core.warning(`apply_samples: GH_AW_REPO_TOKENS is not valid JSON, ignoring: ${getErrorMessage(err)}`);
}
}
return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || undefined;
}

/**
* Fetch a pull request via the REST API and return its head ref. Uses the
* per-repo token from GH_AW_REPO_TOKENS when present, falling back to
* GITHUB_TOKEN; falls back to anonymous (works for public repositories) when
* no token is available. Returns null on any failure so the caller can decide
* how to recover.
* @param {{owner: string, repo: string, pullNumber: number}} args
* @returns {Promise<string|null>}
*/
async function fetchPullRequestHeadRef({ owner, repo, pullNumber }) {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
const url = `${apiUrl}/repos/${owner}/${repo}/pulls/${pullNumber}`;
/** @type {Record<string, string>} */
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "gh-aw-apply-samples",
};
const token = selectTokenForRepo(owner, repo);
if (token) headers["Authorization"] = `Bearer ${token}`;
try {
const resp = await fetch(url, { headers });
if (!resp.ok) {
core.warning(`apply_samples: GET ${url} returned HTTP ${resp.status}`);
return null;
}
const body = await resp.json();
// @ts-ignore - REST API response shape is well-defined; trust GitHub PR endpoint contract
const ref = body && body.head && typeof body.head.ref === "string" ? body.head.ref : null;
return ref || null;
} catch (err) {
core.warning(`apply_samples: failed to fetch PR ${owner}/${repo}#${pullNumber}: ${getErrorMessage(err)}`);
return null;
}
}

/**
* Derive the pull request head ref for the triggering event.
*
* Resolution order:
* 1. `pull_request.head.ref` from the event payload (pull_request and
* pull_request_target events).
* 2. PR API lookup using `issue.number` when the payload describes an
* issue_comment on a pull request.
* 3. PR API lookup using an explicit `pull_request_number` argument on the
* sample (covers workflow_dispatch driven by the agent).
*
* @param {SampleEntry} entry
* @returns {Promise<string|null>}
*/
async function derivePrHeadRef(entry) {
const payload = readEventPayload();

// 1. pull_request* events expose the head ref directly.
const directRef = payload?.pull_request?.head?.ref;
if (typeof directRef === "string" && directRef.trim()) {
return directRef.trim();
}

// Determine the target repo for any API lookups. Prefer the entry's repo if
// the sample sets one (cross-repo workflows), otherwise fall back to
// GITHUB_REPOSITORY.
const repoSlug = (typeof entry.arguments.repo === "string" && entry.arguments.repo.trim()) || process.env.GITHUB_REPOSITORY || "";
const [owner, repo] = repoSlug.split("/");
if (!owner || !repo) return null;

// 2. issue_comment / pull_request_review_comment with a PR-linked issue.
if (payload?.issue?.pull_request && typeof payload.issue.number === "number") {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: payload.issue.number });
if (ref) return ref;
}

// 3. Explicit pull_request_number on the sample arguments.
const argNumber = Number(entry.arguments.pull_request_number);
if (Number.isFinite(argNumber) && argNumber > 0) {
const ref = await fetchPullRequestHeadRef({ owner, repo, pullNumber: argNumber });
if (ref) return ref;
}

return null;
}

/**
* Pre-stage a branch + patch for samples whose tool reads the workspace diff.
* Mutates `entry.arguments.branch` to the actual checked-out branch.
*
* - For `create_pull_request`, the sample creates a brand-new branch, so we
* accept the sample's declared `branch` or synthesize `gh-aw-sample-<i+1>`.
* - For `push_to_pull_request_branch`, the destination is the triggering PR's
* head branch — we derive it from event/PR context and refuse to invent a
* synthetic branch (which would never exist on origin and would break the
* MCP server's incremental-patch generation, per issue #37835).
*
* @param {SampleEntry} entry
* @param {number} index
* @param {string} workspace
* @returns {Promise<void>}
*/
function preStagePatch(entry, index, workspace) {
async function preStagePatch(entry, index, workspace) {
const patch = entry.sidecars && entry.sidecars.patch;
if (typeof patch !== "string" || !patch.trim()) {
return;
}
const branch = typeof entry.arguments.branch === "string" && entry.arguments.branch.trim() ? entry.arguments.branch.trim() : `gh-aw-sample-${index + 1}`;
entry.arguments.branch = branch;

let branch;
if (entry.tool === "push_to_pull_request_branch") {
// Source ref MUST match the PR's head ref so that
// `git diff origin/<branch>..<branch>` in the MCP server resolves the
// correct baseline. Synthetic `gh-aw-sample-N` names produce
// `fatal: couldn't find remote ref` failures (issue #37835).
branch = await derivePrHeadRef(entry);
if (!branch) {
throw new Error(
`apply_samples: cannot derive pull-request head branch for sample[${index}] (tool=${entry.tool}). ` +
`Trigger the workflow from a pull_request event, or set arguments.pull_request_number on the sample entry, ` +
`or provide GITHUB_TOKEN so the PR can be fetched.`
);
}
// The agent input no longer carries `branch`; ensure stray sample-supplied
// values do not leak through to the MCP tools/call payload.
delete entry.arguments.branch;
} else {
branch = typeof entry.arguments.branch === "string" && entry.arguments.branch.trim() ? entry.arguments.branch.trim() : `gh-aw-sample-${index + 1}`;
entry.arguments.branch = branch;
}

ensureGitIdentity(workspace);

Expand Down Expand Up @@ -269,11 +425,12 @@ async function main() {
const logPath = process.env.GH_AW_AGENT_STDIO_LOG || "";

// Pre-stage branches/patches.
samples.forEach((sample, i) => {
for (let i = 0; i < samples.length; i++) {
const sample = samples[i];
if (PATCH_SIDECAR_TOOLS.has(sample.tool)) {
preStagePatch(sample, i, workspace);
await preStagePatch(sample, i, workspace);
}
});
}

if (samples.length === 0) {
core.info("apply_samples: nothing to replay; exiting cleanly.");
Expand Down Expand Up @@ -375,4 +532,14 @@ if (require.main === module) {
});
}

module.exports = { main, loadSamples, preStagePatch, resolveMcpServerPath, sendJsonRpc };
module.exports = {
main,
loadSamples,
preStagePatch,
resolveMcpServerPath,
selectTokenForRepo,
sendJsonRpc,
// Exported for unit testing of the 3-tier PR head ref resolution logic.
derivePrHeadRef,
fetchPullRequestHeadRef,
};
4 changes: 4 additions & 0 deletions setup/js/comment_memory.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ async function main(config = {}) {
});
if (!targetResult.success) {
core.warning(`comment_memory: target resolution failed: ${targetResult.error}`);
if (!targetResult.shouldFail) {
// No triggering context (e.g. schedule/workflow_dispatch run) — skip rather than fail
return { success: false, skipped: true, error: targetResult.error };
}
return { success: false, error: targetResult.error };
}
core.info(`comment_memory: resolved target item_number=${targetResult.number}`);
Expand Down
13 changes: 11 additions & 2 deletions setup/js/get_current_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ function getCurrentBranch(customCwd) {
cwd: cwd,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return branch;
// "HEAD" means the repo is in a detached-HEAD state (common with the
// default actions/checkout behaviour). It is not a valid branch name;
// fall through to the GITHUB_HEAD_REF / GITHUB_REF_NAME env-var
// fallbacks so callers get a real ref rather than the literal "HEAD",
// which would later produce a misleading "remote ref not present" error.
if (branch && branch !== "HEAD") {
return branch;
}
} catch (error) {
// Ignore error and try fallback
}
Expand All @@ -39,7 +46,9 @@ function getCurrentBranch(customCwd) {
return ghRefName;
}

throw new Error(`${ERR_CONFIG}: Failed to determine current branch: git command failed and no GitHub environment variables available`);
throw new Error(
`${ERR_CONFIG}: Failed to determine current branch: git command returned a detached-HEAD state and no GitHub environment variables (GITHUB_HEAD_REF / GITHUB_REF_NAME) are available. Ensure the workflow checks out the pull request's head ref before calling this step.`
);
}

module.exports = {
Expand Down
55 changes: 36 additions & 19 deletions setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -797,14 +797,20 @@ function createHandlers(server, appendSafeOutput, config = {}) {
/**
* Handler for push_to_pull_request_branch tool
* Spec cross-reference: Safe Output Outcome Evaluation §17 (`push_to_pull_request_branch`).
* Resolves the current branch if branch is not provided or is the base branch
* Generates git patch for the changes
* The agent does NOT supply a branch. The source branch is derived from the
* current working checkout (the agent must already be on the PR head ref to
* have committed onto it). The destination branch is independently derived
* by the apply-time push handler from pulls.get(pull_number).head.ref.
*
* Note: Fork PR detection is handled by push_to_pull_request_branch.cjs handler
* which fetches the PR and calls detectForkPR() with full PR data.
*/
const pushToPullRequestBranchHandler = async args => {
const entry = { ...args, type: "push_to_pull_request_branch" };
// Defensive strip: the input schema no longer declares a `branch` property,
// but an older or non-conforming client could still attempt to pass one.
// Drop it so the agent cannot override the derived source branch.
const { branch: _agentBranch, ...sanitizedArgs } = args || {};
const entry = { ...sanitizedArgs, type: "push_to_pull_request_branch" };

// Resolve target repo configuration and validate the target repo early
// This is needed before getBaseBranch to ensure we resolve the base branch
Expand Down Expand Up @@ -913,33 +919,44 @@ function createHandlers(server, appendSafeOutput, config = {}) {
// like issue_comment on PRs targeting non-default branches.
entry.base_branch = baseBranch;

// If branch is not provided, is empty, or equals the base branch, use the current branch from git
// This handles cases where the agent incorrectly passes the base branch instead of the working branch
if (!entry.branch || entry.branch.trim() === "" || entry.branch === baseBranch) {
// The agent never supplies a branch; the validator already strips it from
// args. Derive it from the current checkout: the working tree must be on
// the PR head ref because that's what the agent committed onto. The
// apply-time push job independently re-derives the destination from
// pulls.get(pull_number), so this branch name is used only as the source
// ref for the incremental diff against origin/<branch>.
try {
const detectedBranch = getCurrentBranch(repoCwd);

if (entry.branch === baseBranch) {
server.debug(`Branch equals base branch (${baseBranch}), detecting actual working branch: ${detectedBranch}`);
} else {
server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`);
}

server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`);
entry.branch = detectedBranch;
} catch (branchErr) {
return {
content: [
{
type: "text",
text: JSON.stringify({
result: "error",
error: `Failed to determine source branch for push_to_pull_request_branch: ${getErrorMessage(branchErr)}. The working tree must be on the pull request's head ref before this tool is called.`,
}),
},
],
isError: true,
};
}

// Reject if branch still equals base_branch after detection.
// This means the base branch was incorrectly resolved (e.g., resolved to the
// feature branch itself due to a confused event context). Writing a safe output
// in this state would cause a cryptic git exit-1 in the safe_outputs job when
// it tries to fetch a non-existent remote ref.
// Reject if the detected branch equals base_branch. This means the workspace
// is checked out on the PR's base (e.g. main) rather than the PR's head ref,
// so there is nothing to push. Writing a safe output in this state would
// cause a cryptic git exit-1 in the safe_outputs job when it tries to fetch
// a non-existent remote ref.
if (entry.branch === entry.base_branch) {
return {
content: [
{
type: "text",
text: JSON.stringify({
result: "error",
error: `Branch '${entry.branch}' equals base_branch '${entry.base_branch}'. Cannot push to a pull request branch that targets itself. Ensure 'branch' is your feature branch and that the base branch resolves to the target (e.g., 'main' or 'master').`,
error: `Detected branch '${entry.branch}' equals base_branch '${entry.base_branch}'. The workspace is checked out on the base branch, not the pull request's head branch — there is nothing to push. Check out the PR's head ref and commit your changes there before calling push_to_pull_request_branch.`,
}),
},
],
Expand Down
6 changes: 1 addition & 5 deletions setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -952,15 +952,11 @@
},
{
"name": "push_to_pull_request_branch",
"description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite.",
"description": "Push committed changes to a pull request's branch. APPEND-ONLY: this tool adds new commits on top of the existing PR branch \u2014 force-push is NOT supported and will be rejected. Use this to add follow-up commits to an existing PR, such as addressing review feedback or fixing issues. This is a write-once declaration for a real intended PR branch update, not a sandbox or probe: do not call it with probe branches, placeholder commit messages, or auth experiments. If you are not ready to push the real update, use noop or report_incomplete instead. Changes must be committed locally before calling this tool. The destination branch is always derived from the pull request's head ref \u2014 you do not specify it. IMPORTANT: do NOT use 'git merge' to update the branch against another branch \u2014 merge commits cannot be signed; the action will attempt to squash them into a single linear commit before pushing, but this rewrites history. Use 'git rebase' instead to avoid the rewrite.",
"inputSchema": {
"type": "object",
"required": ["message"],
"properties": {
"branch": {
"type": "string",
"description": "Branch name to push changes from. If omitted, uses the current working branch. Only specify if you need to push from a different branch."
},
"message": {
"type": "string",
"description": "Commit message describing the changes. Follow repository commit message conventions (e.g., conventional commits). This field is named message, NOT commit_message.",
Expand Down
10 changes: 6 additions & 4 deletions setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2069,13 +2069,15 @@ async function sendJobConclusionSpan(spanName, options = {}) {
// Ranked AIC sources: env var → non-zero file → engine metrics → file (may be zero).
// When the firewall proxy writes ai_credits=0 to agent_usage.json, the engine result
// event (from agent-stdio.log) is tried next so its non-zero value is not lost.
// The final fallback to aiCreditsFromFile (zero) makes observability gaps visible
// (zero != no-data) and lets the Sentry EAP schema infer the attribute as numeric
// so sum()/avg()/percentile() aggregations work without manual schema configuration.
// For jobs that own token usage (agent, detection, engine), gh-aw.aic is ALWAYS
// emitted as a numeric attribute — defaulting to 0 when no data is available.
// This guarantees Sentry EAP infers the field as numeric (not string) so that
// sum()/avg()/percentile() aggregations work without manual schema configuration,
// and Tempo indexes it so { span."gh-aw.aic" > 0 } is queryable immediately.
const aiCreditsFromEnv = normalizeNonNegativeNumber(process.env.GH_AW_AIC);
const aiCreditsFromFile = agentUsage.ai_credits;
const aiCreditsFromMetrics = runtimeMetrics.tokenUsage?.ai_credits;
const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile))) : undefined;
const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile ?? 0))) : undefined;
if (typeof aiCredits === "number") {
attributes.push(buildAttr("gh-aw.aic", aiCredits));
}
Expand Down
Loading
Loading