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
75 changes: 74 additions & 1 deletion __tests__/workflow-run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ describe('resolveWorkflowRun', () => {
pull_requests: [],
},
});
octokit.rest.pulls.list = vi.fn().mockResolvedValue({ data: [{ number: 7 }] });
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
data: [{ number: 7, head: { sha: '' } }],
});
mockGetOctokit.mockReturnValue(octokit as never);

const result = await resolveWorkflowRun(baseArgs);
Expand All @@ -162,6 +164,77 @@ describe('resolveWorkflowRun', () => {
expect(result.triggerEvent).toBe('pull_request');
});

it('selects the SHA-matching PR when multiple PRs share the same branch name', async () => {
const octokit = makeOctokit({
runData: {
run_started_at: '2026-03-09T10:00:00Z',
updated_at: '2026-03-09T10:05:00Z',
head_branch: 'feat/my-feature',
head_sha: 'abc123',
event: 'pull_request',
pull_requests: [],
},
});
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
data: [
{ number: 10, head: { sha: 'wrong-sha' } },
{ number: 20, head: { sha: 'abc123' } },
],
});
mockGetOctokit.mockReturnValue(octokit as never);

const result = await resolveWorkflowRun(baseArgs);

expect(result.triggerNumber).toBe(20);
});

it('returns null triggerNumber when headSha is set but no PR head matches', async () => {
const octokit = makeOctokit({
runData: {
run_started_at: '2026-03-09T10:00:00Z',
updated_at: '2026-03-09T10:05:00Z',
head_branch: 'feat/my-feature',
head_sha: 'abc123',
event: 'pull_request',
pull_requests: [],
},
});
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
data: [
{ number: 10, head: { sha: 'wrong-1' } },
{ number: 20, head: { sha: 'wrong-2' } },
],
});
mockGetOctokit.mockReturnValue(octokit as never);

const result = await resolveWorkflowRun(baseArgs);

expect(result.triggerNumber).toBeNull();
});

it('falls back to prs[0] when no headSha is available', async () => {
const octokit = makeOctokit({
runData: {
run_started_at: '2026-03-09T10:00:00Z',
updated_at: '2026-03-09T10:05:00Z',
head_branch: 'feat/my-feature',
event: 'pull_request',
pull_requests: [],
},
});
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
data: [
{ number: 10, head: { sha: 'sha-1' } },
{ number: 20, head: { sha: 'sha-2' } },
],
});
mockGetOctokit.mockReturnValue(octokit as never);

const result = await resolveWorkflowRun(baseArgs);

expect(result.triggerNumber).toBe(10);
});

it('resolves trigger number from branch name when no pull_requests', async () => {
const octokit = makeOctokit({
runData: {
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/agentmeter-action-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ When set, `resolveWorkflowRun` does four things:

1. **Gate** — calls `listJobsForWorkflowRun` and exits early unless a job named `conclusion` has completed. Prevents ~5 duplicate ingests from gh-aw's multi-job structure. Single-job workflows pass through unconditionally.
2. **Status normalization** — maps GitHub conclusions (`failure` → `failed`, `skipped` → skip entirely) to the AgentMeter API enum. Unrecognized statuses (e.g. custom `needs_human`) pass through unchanged.
3. **Trigger resolution** — reads `pull_requests[]` from the run object; falls back to a `pulls.list` lookup by head branch if empty (GitHub API quirk for some PR-triggered runs). Works for fork PRs. Issue branches are matched only when the branch name contains `agent/issue-N` (the gh-aw convention) — bare `issue-N` patterns are intentionally not matched to avoid misattributing unrelated branches like `feature/fix-issue-12-auth`.
3. **Trigger resolution** — reads `pull_requests[]` from the run object; falls back to a `pulls.list` lookup by head branch if empty (GitHub API quirk for some PR-triggered runs). When `head_sha` is available, the fallback validates each candidate PR's head SHA against it — if a match is found it is used; if no match is found `triggerNumber` returns `null` rather than guessing. When `head_sha` is absent (API failure), falls back to `prs[0]`. Issue branches are matched only when the branch name contains `agent/issue-N` (the gh-aw convention) to avoid misattributing unrelated branches.
4. **Token artifact** — downloads and unzips the `agent-tokens` artifact using `fflate`.

### Pricing (`src/pricing.ts`)
Expand All @@ -125,7 +125,7 @@ When `workflow_run_id` is provided, `githubRunId` in the ingest payload is set t

`context.ts` maps GitHub event names to AgentMeter trigger types. `issue_comment` is correctly classified as `pr_comment` when `payload.issue.pull_request` is present, and `issue_comment` otherwise.

In companion `workflow_run` mode, `resolveTrigger` in `workflow-run.ts` returns a `triggerType` and `triggerRef` that reflect the original triggering event, not the companion workflow's own `workflow_run` event.
In companion `workflow_run` mode, timestamps resolve from the agent run API (`run_started_at`, `updated_at`). If the API call fails, timestamps are left empty and `durationSeconds` resolves to 0 — the companion workflow's own start time is never substituted to avoid recording a misleading duration.

---

Expand Down
15 changes: 12 additions & 3 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,18 @@ export async function run(): Promise<void> {
// Use normalized status from the run data
inputs = { ...inputs, status: runData.normalizedStatus };

// Only override with resolved values when explicit inputs aren't set
if (!inputs.startedAt && runData.startedAt) resolvedStartedAt = runData.startedAt;
if (!inputs.completedAt && runData.completedAt) resolvedCompletedAt = runData.completedAt;
// Only override with resolved values when explicit inputs aren't set.
// In workflow_run mode never fall back to selfStartedAt/now — those are the companion
// workflow's times, not the agent run's times. Use empty string so durationSeconds
// safely resolves to 0 rather than silently recording the wrong run's duration.
resolvedStartedAt = inputs.startedAt || runData.startedAt || '';
resolvedCompletedAt = inputs.completedAt || runData.completedAt || '';
if (
(!inputs.startedAt && !runData.startedAt) ||
(!inputs.completedAt && !runData.completedAt)
) {
core.warning('AgentMeter: workflow run timestamps unavailable — duration will be omitted.');
}
if (inputs.triggerNumber === null) resolvedTriggerNumber = runData.triggerNumber;
if (!inputs.triggerEvent) resolvedTriggerEvent = runData.triggerEvent;
resolvedTriggerRef = runData.triggerRef;
Expand Down
4 changes: 3 additions & 1 deletion src/workflow-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,9 @@ async function resolveTrigger({
});
// Validate by head SHA when available to avoid matching the wrong PR when multiple
// PRs share the same branch name (e.g. reused or fork branches).
const match = headSha ? (prs.find((pr) => pr.head.sha === headSha) ?? prs[0]) : prs[0];
// When headSha is provided but no PR matches, return null rather than guessing.
const shaMatch = headSha ? prs.find((pr) => pr.head.sha === headSha) : null;
const match = shaMatch ?? (headSha ? null : prs[0]);
if (match) {
return {
triggerNumber: match.number,
Expand Down
Loading