Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
update
  • Loading branch information
dsyme committed Jun 10, 2026
commit 7876fe557eca0375a48ae1ee1ed5d30f0346d00e
14 changes: 12 additions & 2 deletions actions/setup/js/apply_samples.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ async function preStagePatch(entry, index, workspace) {
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, ` +
`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.`
);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
Expand Down Expand Up @@ -532,4 +532,14 @@ if (require.main === module) {
});
}

module.exports = { main, loadSamples, preStagePatch, resolveMcpServerPath, selectTokenForRepo, sendJsonRpc };
module.exports = {
main,
loadSamples,
preStagePatch,
resolveMcpServerPath,
selectTokenForRepo,
sendJsonRpc,
// Exported for unit testing of the 3-tier PR head ref resolution logic.
derivePrHeadRef,
fetchPullRequestHeadRef,
};
57 changes: 49 additions & 8 deletions actions/setup/js/apply_samples.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// Tests intentionally use the simplest safe-output tool (`create_issue`) so we
// do not need to set up a git working tree for patch sidecars.

import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeAll, vi } from "vitest";
import { spawnSync } from "child_process";
import { createRequire } from "module";
import fs from "fs";
Expand Down Expand Up @@ -379,12 +379,13 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
})
);

// Stub global fetch to simulate the GitHub REST API response.
const realFetch = global.fetch;
global.fetch = async url => {
expect(String(url)).toContain("/repos/owner/repo/pulls/42");
return { ok: true, status: 200, json: async () => ({ head: { ref: headRef } }) };
};
// Spy on global fetch so the call-URL assertion happens on the test side
// (an assertion failure inside a global-mutating stub is hard to attribute).
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ head: { ref: headRef } }),
});

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
Expand All @@ -400,8 +401,9 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/owner/repo/pulls/42"), expect.anything());
} finally {
global.fetch = realFetch;
fetchSpy.mockRestore();
if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH;
else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase;
if (prevEvent === undefined) delete process.env.GITHUB_EVENT_PATH;
Expand Down Expand Up @@ -429,6 +431,45 @@ describe("apply_samples.cjs preStagePatch (create_pull_request / push_to_pull_re
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Resolution path 3 — an explicit arguments.pull_request_number on the sample entry — is not tested. The "fails fast" test (just above) deletes GITHUB_EVENT_PATH but leaves arguments.pull_request_number absent, so it falls straight to the "cannot derive" error without exercising path 3 at all.

💡 Suggested test

Add a test that sets GITHUB_REPOSITORY (for owner/repo decomposition) and stubs global.fetch to verify path 3:

it("derives branch from explicit pull_request_number on the sample when no event payload exists", async () => {
  const workspace = makeTempDir("gh-aw-prestage-push-argpr-");
  initRepo(workspace, "main");

  const headRef = "feat/explicit-pr-number";
  const realFetch = global.fetch;
  global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ head: { ref: headRef } }) });

  const prevEvent = process.env.GITHUB_EVENT_PATH;
  const prevRepo = process.env.GITHUB_REPOSITORY;
  const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
  delete process.env.GITHUB_EVENT_PATH;  // no event payload — force path 3
  process.env.GITHUB_REPOSITORY = "owner/repo";
  process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
  try {
    const entry = {
      tool: "push_to_pull_request_branch",
      arguments: { message: "Push update", pull_request_number: 99 },
      sidecars: { patch: newFileDiff("arg-pr.txt", "via arg pr\n") },
    };
    await preStagePatch(entry, 0, workspace);
    expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
  } finally {
    global.fetch = realFetch;
    // restore env
  }
});


it("derives push_to_pull_request_branch branch from explicit arguments.pull_request_number when no event payload exists", async () => {
// Resolution path 3: no GITHUB_EVENT_PATH, but the sample entry carries an
// explicit `arguments.pull_request_number`. The driver should hit the PR
// API directly rather than fail fast.
const workspace = makeTempDir("gh-aw-prestage-push-argpr-");
initRepo(workspace, "main");

const headRef = "feat/explicit-pr-number";
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ head: { ref: headRef } }),
});

const prevBase = process.env.GH_AW_CUSTOM_BASE_BRANCH;
const prevEvent = process.env.GITHUB_EVENT_PATH;
const prevRepo = process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_EVENT_PATH; // force path 3
process.env.GH_AW_CUSTOM_BASE_BRANCH = "main";
process.env.GITHUB_REPOSITORY = "owner/repo";
try {
const entry = {
tool: "push_to_pull_request_branch",
arguments: { message: "Push update", pull_request_number: 99 },
sidecars: { patch: newFileDiff("arg-pr.txt", "via arg pr\n") },
};
await preStagePatch(entry, 0, workspace);
expect(git(["rev-parse", "--abbrev-ref", "HEAD"], workspace).trim()).toBe(headRef);
expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("/repos/owner/repo/pulls/99"), expect.anything());
} finally {
fetchSpy.mockRestore();
if (prevBase === undefined) delete process.env.GH_AW_CUSTOM_BASE_BRANCH;
else process.env.GH_AW_CUSTOM_BASE_BRANCH = prevBase;
if (prevEvent !== undefined) process.env.GITHUB_EVENT_PATH = prevEvent;
if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY;
else process.env.GITHUB_REPOSITORY = prevRepo;
}
});

it("is a no-op when the sample tool isn't in the patch-sidecar set", async () => {
// We assert this at the driver level (PATCH_SIDECAR_TOOLS gate in main()),
// but preStagePatch itself should also be a no-op when called with an
Expand Down
13 changes: 6 additions & 7 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -919,13 +919,12 @@ function createHandlers(server, appendSafeOutput, config = {}) {
// like issue_comment on PRs targeting non-default branches.
entry.base_branch = baseBranch;

// Always derive the source branch from the current checkout (the agent does not supply one).
// The agent never supplies a branch. Always derive it from the current
// checkout: the working tree must already 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>.
// 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);
server.debug(`Using current branch for push_to_pull_request_branch: ${detectedBranch}`);
Expand Down
17 changes: 17 additions & 0 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,23 @@ describe("safe_outputs_handlers", () => {
delete process.env.GITHUB_BASE_REF;
}
});

it("returns error when getCurrentBranch cannot resolve a branch", async () => {
// Override the beforeEach seed so every getCurrentBranch resolution path
// fails (no GITHUB_REF_NAME, no GITHUB_HEAD_REF, no git repo in the empty
// test workspace) and the handler hits its try/catch.
delete process.env.GITHUB_REF_NAME;
delete process.env.GITHUB_HEAD_REF;
try {
const result = await handlers.pushToPullRequestBranchHandler({ message: "test" });
expect(result.isError).toBe(true);
const data = JSON.parse(result.content[0].text);
expect(data.result).toBe("error");
expect(data.error).toContain("Failed to determine source branch");
} finally {
process.env.GITHUB_REF_NAME = "feature-branch"; // restore for sibling tests
}
});
});

describe("handler structure", () => {
Expand Down
2 changes: 1 addition & 1 deletion actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2075,7 +2075,7 @@ async function sendJobConclusionSpan(spanName, options = {}) {
// 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 ?? 0;
const aiCreditsFromFile = agentUsage.ai_credits;
const aiCreditsFromMetrics = runtimeMetrics.tokenUsage?.ai_credits;
const aiCredits = jobEmitsOwnTokenUsage ? (aiCreditsFromEnv ?? ((aiCreditsFromFile ?? 0) > 0 ? aiCreditsFromFile : (aiCreditsFromMetrics ?? aiCreditsFromFile ?? 0))) : undefined;
if (typeof aiCredits === "number") {
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/safe_outputs_validation_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ var ValidationConfig = map[string]TypeValidationConfig{
Fields: map[string]FieldValidation{
"message": {Required: true, Type: "string", Sanitize: true, MaxLength: MaxBodyLength},
"pull_request_number": {IssueOrPRNumber: true},
"branch": {Type: "string", Sanitize: true, MaxLength: 256}, // Optional: stripped before MCP call; validated for type/length when present.
},
Comment thread
dsyme marked this conversation as resolved.
Resolved
},
"create_pull_request_review_comment": {
Expand Down
38 changes: 12 additions & 26 deletions pkg/workflow/samples_replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ func collectSampleRepoTokens(configs []*CheckoutConfig) map[string]string {
for i, entry := range cm.ordered {
repo := entry.key.repository
if repo == "" {
// Empty repository targets `github.repository` at runtime. Emit a
// substituted key so the runtime JSON has the actual slug.
// The repo slug is unknown at compile time; emit a GitHub Actions
// expression. The Actions runner expands ${{ github.repository }} to
// "owner/repo" before apply_samples.cjs reads GH_AW_REPO_TOKENS, so
// the runtime key matches the slug the driver looks up.
repo = "${{ github.repository }}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] The comment "Emit a substituted key so the runtime JSON has the actual slug" may mislead readers: the Go code does not substitute anything. The literal string ${{ github.repository }} is written into the YAML, and GitHub Actions expression evaluation replaces it at workflow runtime — not at compile time. The comment would be clearer as:

// The repo slug is unknown at compile time; emit a GitHub Actions expression.
// The Actions runner expands ${{ github.repository }} → "owner/repo" before
// apply_samples.cjs reads GH_AW_REPO_TOKENS, so the key matches correctly.
repo = "${{ github.repository }}"

This is a doc-only nit, but the coupling to GitHub Actions expression evaluation is a non-obvious implicit contract worth calling out explicitly.

}
var token string
Expand All @@ -116,32 +118,16 @@ func collectSampleRepoTokens(configs []*CheckoutConfig) map[string]string {
return tokens
}

// marshalStringMapDeterministic returns a compact JSON object encoding of m
// with keys sorted lexicographically so the emitted YAML is byte-stable
// across runs. Returns nil for empty/nil maps so callers can skip emission.
func marshalStringMapDeterministic(m map[string]string) []byte {
// marshalRepoTokens returns a compact JSON object encoding of m, or nil for
// empty/nil maps so callers can skip emission. encoding/json sorts string-
// keyed map entries lexicographically, so the output is byte-stable across
// runs without an explicit sort step.
func marshalRepoTokens(m map[string]string) []byte {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var buf strings.Builder
buf.WriteString("{")
for i, k := range keys {
if i > 0 {
buf.WriteString(",")
}
kEnc, _ := json.Marshal(k) //nolint:jsonmarshalignoredeerror // marshaling a string cannot fail
vEnc, _ := json.Marshal(m[k]) //nolint:jsonmarshalignoredeerror // marshaling a string cannot fail
buf.Write(kEnc)
buf.WriteString(":")
buf.Write(vEnc)
}
buf.WriteString("}")
return []byte(buf.String())
out, _ := json.Marshal(m) //nolint:jsonmarshalignoredeerror // marshaling a string map cannot fail
return out
}

// generateSamplesReplayStep emits the YAML that replaces the agentic
Expand Down Expand Up @@ -177,7 +163,7 @@ func (c *Compiler) generateSamplesReplayStep(yaml *strings.Builder, data *Workfl
// require a non-default token (cross-repo `checkout:` entries with their
// own `github-token:` or `github-app:`).
repoTokens := collectSampleRepoTokens(data.CheckoutConfigs)
repoTokensPayload := marshalStringMapDeterministic(repoTokens)
repoTokensPayload := marshalRepoTokens(repoTokens)

yaml.WriteString(" - name: Replay safe-outputs samples (deterministic)\n")
yaml.WriteString(" id: agentic_execution\n")
Expand Down
Loading