Skip to content
Closed
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
2 changes: 1 addition & 1 deletion dist/artifact-DZtNN4TE.js → dist/artifact-CEdOJK06.js

Large diffs are not rendered by default.

32 changes: 17 additions & 15 deletions dist/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/post.js

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

54 changes: 53 additions & 1 deletion src/harness/phases/dedup.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import type {TriggerContext, TriggerTarget} from '../../features/triggers/types.js'
import type {DeduplicationMarker} from '../../services/cache/dedup.js'
import type {EventType, GitHubContext} from '../../services/github/types.js'
import * as core from '@actions/core'
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
import {restoreDeduplicationMarker, saveDeduplicationMarker} from '../../services/cache/dedup.js'
import {createMockLogger} from '../../shared/test-helpers.js'
import {setActionOutputs} from '../config/outputs.js'
import {extractDedupEntity, runDedup, saveDedupMarker} from './dedup.js'

vi.mock('@actions/core', () => ({
summary: {
addHeading: vi.fn().mockReturnThis(),
addTable: vi.fn().mockReturnThis(),
addRaw: vi.fn().mockReturnThis(),
write: vi.fn().mockResolvedValue(undefined),
},
warning: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
setOutput: vi.fn(),
getInput: vi.fn().mockReturnValue(''),
saveState: vi.fn(),
getState: vi.fn().mockReturnValue(''),
setFailed: vi.fn(),
}))

vi.mock('../../services/cache/dedup.js', () => ({
restoreDeduplicationMarker: vi.fn(),
saveDeduplicationMarker: vi.fn(),
Expand Down Expand Up @@ -178,6 +196,38 @@ describe('runDedup', () => {
expect(vi.mocked(restoreDeduplicationMarker)).not.toHaveBeenCalled()
})

it('bypasses dedup for synchronize action', async () => {
// #given synchronize action on a PR (fires first, needed for required status checks)
const context = createTriggerContext({
eventType: 'pull_request',
action: 'synchronize',
target: createTarget('pr', 42),
})

// #when running dedup phase
const result = await runDedup(600_000, context, 'fro-bot/agent', 1, createMockLogger())

// #then dedup is bypassed — no cache lookup occurs
expect(result).toEqual({shouldProceed: true, entity: {entityType: 'pr', entityNumber: 42}})
expect(vi.mocked(restoreDeduplicationMarker)).not.toHaveBeenCalled()
})

it('bypasses dedup for reopened action', async () => {
// #given reopened PR (meaningful state change, breaks dedup lock)
const context = createTriggerContext({
eventType: 'pull_request',
action: 'reopened',
target: createTarget('pr', 42),
})

// #when running dedup phase
const result = await runDedup(600_000, context, 'fro-bot/agent', 1, createMockLogger())

// #then dedup is bypassed
expect(result).toEqual({shouldProceed: true, entity: {entityType: 'pr', entityNumber: 42}})
expect(vi.mocked(restoreDeduplicationMarker)).not.toHaveBeenCalled()
})

it('returns shouldProceed true when no sentinel is found', async () => {
// #given cache miss for dedup marker
vi.mocked(restoreDeduplicationMarker).mockResolvedValueOnce(null)
Expand Down Expand Up @@ -292,7 +342,7 @@ describe('runDedup', () => {
// #when running dedup phase
const result = await runDedup(10_000, context, 'fro-bot/agent', 5_000, createMockLogger())

// #then processing is skipped and outputs are set
// #then processing is skipped, outputs set, and job summary written
expect(result).toEqual({
shouldProceed: false,
entity: {entityType: 'pr', entityNumber: 42},
Expand All @@ -302,6 +352,8 @@ describe('runDedup', () => {
cacheStatus: 'miss',
duration: 25_000,
})
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(core.summary.write).toHaveBeenCalled()
})
})

Expand Down
55 changes: 49 additions & 6 deletions src/harness/phases/dedup.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import type {TriggerContext} from '../../features/triggers/types.js'
import type {DeduplicationEntity, DeduplicationMarker} from '../../services/cache/dedup.js'
import type {CacheAdapter} from '../../services/cache/types.js'
import type {Logger} from '../../shared/logger.js'
import {
restoreDeduplicationMarker,
saveDeduplicationMarker,
type DeduplicationEntity,
type DeduplicationMarker,
} from '../../services/cache/dedup.js'
import * as core from '@actions/core'
import {restoreDeduplicationMarker, saveDeduplicationMarker} from '../../services/cache/dedup.js'
import {toErrorMessage} from '../../shared/errors.js'
import {createLogger} from '../../shared/logger.js'
import {setActionOutputs} from '../config/outputs.js'

const DEDUP_EVENT_TYPES = new Set(['pull_request', 'issues'])

const DEDUP_BYPASS_ACTIONS = new Set(['synchronize', 'reopened'])

export interface DedupCheckResult {
readonly shouldProceed: boolean
readonly entity: DeduplicationEntity | null
Expand Down Expand Up @@ -55,6 +55,11 @@ export async function runDedup(
return {shouldProceed: true, entity: null}
}

if (triggerContext.action != null && DEDUP_BYPASS_ACTIONS.has(triggerContext.action)) {
logger.debug('Dedup bypassed for action', {action: triggerContext.action})
return {shouldProceed: true, entity}
}

const marker = await restoreDeduplicationMarker(repo, entity, logger, cacheAdapter)
if (marker == null) {
return {shouldProceed: true, entity}
Expand Down Expand Up @@ -104,6 +109,8 @@ export async function runDedup(
duration: Date.now() - startTime,
})

await writeDedupSkipSummary(triggerContext, entity, marker, effectiveAge, dedupWindow, logger)

return {shouldProceed: false, entity}
}

Expand All @@ -125,3 +132,39 @@ export async function saveDedupMarker(

await saveDeduplicationMarker(repo, entity, marker, logger, cacheAdapter)
}

async function writeDedupSkipSummary(
triggerContext: TriggerContext,
entity: DeduplicationEntity,
marker: DeduplicationMarker,
ageMs: number,
dedupWindow: number,
logger: Logger,
): Promise<void> {
try {
const ageSeconds = Math.round(ageMs / 1000)
const windowSeconds = Math.round(dedupWindow / 1000)
const entityLabel = `${entity.entityType} #${entity.entityNumber}`
const priorRunUrl = `https://github.com/${triggerContext.repo.owner}/${triggerContext.repo.repo}/actions/runs/${marker.runId}`

core.summary
.addHeading('Fro Bot Agent Run — Skipped (Dedup)', 2)
.addRaw(`Execution skipped because the agent already ran for **${entityLabel}** recently.\n\n`)
.addTable([
[
{data: 'Detail', header: true},
{data: 'Value', header: true},
],
['Current action', `\`${triggerContext.eventType}.${triggerContext.action ?? 'unknown'}\``],
['Prior run', `[${marker.runId}](${priorRunUrl})`],
['Prior action', `\`${marker.eventType}.${marker.action}\``],
['Time since prior run', `${ageSeconds}s`],
['Dedup window', `${windowSeconds}s`],
])
.addRaw('\n> Dedup is best-effort suppression. Use workflow concurrency groups to prevent overlapping runs.\n')

await core.summary.write()
} catch (error) {
logger.warning('Failed to write dedup skip summary', {error: toErrorMessage(error)})
}
}
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const DEFAULT_MODEL = {
} as const

// Setup consolidation defaults
export const DEFAULT_OPENCODE_VERSION = '1.3.3'
export const DEFAULT_OPENCODE_VERSION = '1.3.4'
export const DEFAULT_BUN_VERSION = '1.3.11'
export const DEFAULT_OMO_VERSION = '3.14.0'
export const DEFAULT_OMO_PROVIDERS = ''
Expand Down