Skip to content

Commit 3733cf3

Browse files
authored
Merge pull request #15 from foo-software/fix/workflow-run-fallbacks
fix: timestamp fallback in workflow_run mode, PR SHA validation strictness
2 parents 5378683 + 6a322a9 commit 3733cf3

5 files changed

Lines changed: 92 additions & 8 deletions

File tree

__tests__/workflow-run.test.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@ describe('resolveWorkflowRun', () => {
153153
pull_requests: [],
154154
},
155155
});
156-
octokit.rest.pulls.list = vi.fn().mockResolvedValue({ data: [{ number: 7 }] });
156+
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
157+
data: [{ number: 7, head: { sha: '' } }],
158+
});
157159
mockGetOctokit.mockReturnValue(octokit as never);
158160

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

167+
it('selects the SHA-matching PR when multiple PRs share the same branch name', async () => {
168+
const octokit = makeOctokit({
169+
runData: {
170+
run_started_at: '2026-03-09T10:00:00Z',
171+
updated_at: '2026-03-09T10:05:00Z',
172+
head_branch: 'feat/my-feature',
173+
head_sha: 'abc123',
174+
event: 'pull_request',
175+
pull_requests: [],
176+
},
177+
});
178+
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
179+
data: [
180+
{ number: 10, head: { sha: 'wrong-sha' } },
181+
{ number: 20, head: { sha: 'abc123' } },
182+
],
183+
});
184+
mockGetOctokit.mockReturnValue(octokit as never);
185+
186+
const result = await resolveWorkflowRun(baseArgs);
187+
188+
expect(result.triggerNumber).toBe(20);
189+
});
190+
191+
it('returns null triggerNumber when headSha is set but no PR head matches', async () => {
192+
const octokit = makeOctokit({
193+
runData: {
194+
run_started_at: '2026-03-09T10:00:00Z',
195+
updated_at: '2026-03-09T10:05:00Z',
196+
head_branch: 'feat/my-feature',
197+
head_sha: 'abc123',
198+
event: 'pull_request',
199+
pull_requests: [],
200+
},
201+
});
202+
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
203+
data: [
204+
{ number: 10, head: { sha: 'wrong-1' } },
205+
{ number: 20, head: { sha: 'wrong-2' } },
206+
],
207+
});
208+
mockGetOctokit.mockReturnValue(octokit as never);
209+
210+
const result = await resolveWorkflowRun(baseArgs);
211+
212+
expect(result.triggerNumber).toBeNull();
213+
});
214+
215+
it('falls back to prs[0] when no headSha is available', async () => {
216+
const octokit = makeOctokit({
217+
runData: {
218+
run_started_at: '2026-03-09T10:00:00Z',
219+
updated_at: '2026-03-09T10:05:00Z',
220+
head_branch: 'feat/my-feature',
221+
event: 'pull_request',
222+
pull_requests: [],
223+
},
224+
});
225+
octokit.rest.pulls.list = vi.fn().mockResolvedValue({
226+
data: [
227+
{ number: 10, head: { sha: 'sha-1' } },
228+
{ number: 20, head: { sha: 'sha-2' } },
229+
],
230+
});
231+
mockGetOctokit.mockReturnValue(octokit as never);
232+
233+
const result = await resolveWorkflowRun(baseArgs);
234+
235+
expect(result.triggerNumber).toBe(10);
236+
});
237+
165238
it('resolves trigger number from branch name when no pull_requests', async () => {
166239
const octokit = makeOctokit({
167240
runData: {

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/agentmeter-action-spec.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ When set, `resolveWorkflowRun` does four things:
9999

100100
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.
101101
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.
102-
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`.
102+
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.
103103
4. **Token artifact** — downloads and unzips the `agent-tokens` artifact using `fflate`.
104104

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

126126
`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.
127127

128-
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.
128+
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.
129129

130130
---
131131

src/run.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,18 @@ export async function run(): Promise<void> {
8282
// Use normalized status from the run data
8383
inputs = { ...inputs, status: runData.normalizedStatus };
8484

85-
// Only override with resolved values when explicit inputs aren't set
86-
if (!inputs.startedAt && runData.startedAt) resolvedStartedAt = runData.startedAt;
87-
if (!inputs.completedAt && runData.completedAt) resolvedCompletedAt = runData.completedAt;
85+
// Only override with resolved values when explicit inputs aren't set.
86+
// In workflow_run mode never fall back to selfStartedAt/now — those are the companion
87+
// workflow's times, not the agent run's times. Use empty string so durationSeconds
88+
// safely resolves to 0 rather than silently recording the wrong run's duration.
89+
resolvedStartedAt = inputs.startedAt || runData.startedAt || '';
90+
resolvedCompletedAt = inputs.completedAt || runData.completedAt || '';
91+
if (
92+
(!inputs.startedAt && !runData.startedAt) ||
93+
(!inputs.completedAt && !runData.completedAt)
94+
) {
95+
core.warning('AgentMeter: workflow run timestamps unavailable — duration will be omitted.');
96+
}
8897
if (inputs.triggerNumber === null) resolvedTriggerNumber = runData.triggerNumber;
8998
if (!inputs.triggerEvent) resolvedTriggerEvent = runData.triggerEvent;
9099
resolvedTriggerRef = runData.triggerRef;

src/workflow-run.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,9 @@ async function resolveTrigger({
314314
});
315315
// Validate by head SHA when available to avoid matching the wrong PR when multiple
316316
// PRs share the same branch name (e.g. reused or fork branches).
317-
const match = headSha ? (prs.find((pr) => pr.head.sha === headSha) ?? prs[0]) : prs[0];
317+
// When headSha is provided but no PR matches, return null rather than guessing.
318+
const shaMatch = headSha ? prs.find((pr) => pr.head.sha === headSha) : null;
319+
const match = shaMatch ?? (headSha ? null : prs[0]);
318320
if (match) {
319321
return {
320322
triggerNumber: match.number,

0 commit comments

Comments
 (0)