Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
6 changes: 0 additions & 6 deletions .github/workflows/changeset.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/craft.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/design-decision-gate.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/mergefest.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/necromancer.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/poem-bot.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/pr-sous-chef.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/smoke-claude.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/smoke-update-cross-repo-pr.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions .github/workflows/tidy.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

139 changes: 132 additions & 7 deletions actions/setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,144 @@ 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;
}
}

/**
* Fetch a pull request via the REST API and return its head ref. Uses the
* runner's GITHUB_TOKEN when present; falls back to anonymous (works for
* public repositories). 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 = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
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 sample.pull_request_number, ` +
`or provide GITHUB_TOKEN so the PR can be fetched.`
);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
// 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 @@ -268,11 +392,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
Loading
Loading