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
112 changes: 112 additions & 0 deletions __tests__/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,95 @@ describe('app', () => {
_setConfigForTesting({});
});

// =========================================================================
// pull_request.opened — Dependabot auto-merge is gated on semver (temper#71)
// =========================================================================
describe('pull_request.opened Dependabot auto-merge', () => {
function prOpenedContext({ sender, title, body = '' }) {
const octokit = createMockOctokit();
// The handler arms auto-merge through a GraphQL mutation; record it.
octokit.graphql = jest.fn().mockResolvedValue({});
return {
id: 'delivery-pr-1',
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
octokit,
payload: {
pull_request: { number: 157, node_id: 'PR_node', title, body },
repository: {
name: 'varve',
default_branch: 'main',
owner: { login: 'pulseengine' }
},
sender: { login: sender }
}
};
}

beforeEach(() => {
_setConfigForTesting({
auto_merge: { enabled: true, on_dependabot: true, on_bot_users: ['thrum'], merge_method: 'squash' }
});
});

it('arms auto-merge for a non-breaking Dependabot update', async () => {
const { handlers } = setupApp();
const ctx = prOpenedContext({
sender: 'dependabot[bot]',
title: 'build(deps): bump flate2 from 1.1.9 to 1.1.10'
});
await handlers['pull_request.opened'](ctx);
expect(ctx.octokit.graphql).toHaveBeenCalledTimes(1);
expect(ctx.octokit.graphql.mock.calls[0][0]).toContain('enablePullRequestAutoMerge');
});

it('does NOT arm auto-merge for a breaking Dependabot update, and says why', async () => {
const { handlers } = setupApp();
const ctx = prOpenedContext({
sender: 'dependabot[bot]',
title: 'build(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.2'
});
await handlers['pull_request.opened'](ctx);

expect(ctx.octokit.graphql).not.toHaveBeenCalled();

// Asserted on the ROUTE, not on octokit.issues.createComment: under
// Probot v14 that namespace is undefined for pull_request.opened, so a
// call through it throws, is swallowed, and the explanation silently
// never posts. The route is what production actually uses.
const commentCalls = ctx.octokit.request.mock.calls.filter(
([route]) => route === 'POST /repos/{owner}/{repo}/issues/{issue_number}/comments'
);
expect(commentCalls).toHaveLength(1);
expect(commentCalls[0][1].issue_number).toBe(157);
expect(commentCalls[0][1].body).toMatch(/did not enable auto-merge/);
expect(commentCalls[0][1].body).toContain('sigstore/cosign-installer');
});

it('does NOT arm a grouped PR that carries one breaking update', async () => {
const { handlers } = setupApp();
const ctx = prOpenedContext({
sender: 'dependabot[bot]',
title: 'chore(deps): Bump the wasmtime group across 1 directory with 2 updates',
body: 'Updates `wasmtime` from 47.0.3 to 48.0.1\nUpdates `wasmtime-wasi` from 47.0.3 to 48.0.1'
});
await handlers['pull_request.opened'](ctx);
expect(ctx.octokit.graphql).not.toHaveBeenCalled();
});

it('leaves configured bot users on their existing policy', async () => {
// The decision was about Dependabot majors. A configured bot is not
// Dependabot, and a title that happens to look like a major must not
// change how it is treated.
const { handlers } = setupApp();
const ctx = prOpenedContext({
sender: 'thrum',
title: 'bump something from 1.0.0 to 2.0.0'
});
await handlers['pull_request.opened'](ctx);
expect(ctx.octokit.graphql).toHaveBeenCalledTimes(1);
});
});

// =========================================================================
// chatops_repo enforcement — slash commands only honoured in admin repo
// =========================================================================
Expand Down Expand Up @@ -1355,6 +1444,29 @@ describe('app', () => {
expect(context.octokit.issues.createComment).toHaveBeenCalledTimes(2);
});

it('says so, rather than claiming success, when the repository already has its own config', async () => {
generateDependabotConfig.mockResolvedValue({
config: { version: 2, updates: [{ 'package-ecosystem': 'npm', directory: '/' }] },
ecosystems: [{ ecosystem: 'npm', directory: '/' }],
report: 'Detected 1 ecosystem(s): npm (/)'
});
applyDependabotConfig.mockResolvedValue({
applied: false,
reason: 'myorg/myrepo already has .github/dependabot.yml; it is the repository\'s own and temper does not overwrite it'
});

const { handlers } = setupApp();
const context = createIssueCommentContext('/generate-dependabot');
context.octokit.request.mockImplementationOnce(() =>
Promise.resolve({ data: { default_branch: 'main' } })
);
await handlers['issue_comment.created'](context);

const bodies = context.octokit.issues.createComment.mock.calls.map((c) => c[0].body);
expect(bodies.some((b) => b.includes('applied!'))).toBe(false);
expect(bodies[bodies.length - 1]).toContain('does not overwrite');
});

it('posts message when no ecosystems detected', async () => {
generateDependabotConfig.mockResolvedValue({
config: null,
Expand Down
55 changes: 55 additions & 0 deletions __tests__/integration/dependabot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,61 @@ describe('dependabot', () => {
describe('applyDependabotConfig', () => {
const depConfig = { version: 2, updates: [{ labels: ['dependencies'] }] };

// The repository has no dependabot.yml yet: the only case temper writes one.
beforeEach(() => {
octokit.request.mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 }));
});

it('does NOT overwrite a dependabot.yml the repository already has', async () => {
// A repository's own file encodes what temper cannot know: which updates
// to group, which paths PR CI never exercises, which majors to hold.
// pulseengine/varve groups minor+patch; a generic config would undo it.
_setConfigForTesting({});
octokit.request.mockResolvedValue({ status: 200, data: { content: encodeConfig(depConfig) } });

const result = await applyDependabotConfig(octokit, 'owner', 'repo', depConfig);

expect(upsertRepoFile).not.toHaveBeenCalled();
expect(createConfigurationPR).not.toHaveBeenCalled();
expect(result.applied).toBe(false);
expect(result.reason).toMatch(/already has/);
expect(octokit.request).toHaveBeenCalledWith(
'GET /repos/{owner}/{repo}/contents/{path}',
expect.objectContaining({ owner: 'owner', repo: 'repo', path: '.github/dependabot.yml' })
);
});

it('does NOT overwrite an existing file via a PR either', async () => {
_setConfigForTesting({ change_strategy: { use_pull_requests: true } });
octokit.request.mockResolvedValue({ status: 200, data: {} });

const result = await applyDependabotConfig(octokit, 'owner', 'repo', depConfig);

expect(createConfigurationPR).not.toHaveBeenCalled();
expect(result.applied).toBe(false);
});

it('reports that it applied the config when the file was absent', async () => {
_setConfigForTesting({});

const result = await applyDependabotConfig(octokit, 'owner', 'repo', depConfig);

expect(upsertRepoFile).toHaveBeenCalled();
expect(result.applied).toBe(true);
});

it('does not assume the file is absent when the existence check fails for another reason', async () => {
// A 403 or 500 says nothing about whether the file exists. Treating it as
// absence would overwrite a repository's config on a transient error.
_setConfigForTesting({});
octokit.request.mockRejectedValue(Object.assign(new Error('Server Error'), { status: 500 }));

await expect(
applyDependabotConfig(octokit, 'owner', 'repo', depConfig)
).rejects.toThrow('Server Error');
expect(upsertRepoFile).not.toHaveBeenCalled();
});

it('applies config via direct commit when use_pull_requests is false', async () => {
_setConfigForTesting({
change_strategy: { use_pull_requests: false }
Expand Down
184 changes: 184 additions & 0 deletions __tests__/unit/dependabot-update-type.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {
parseVersion,
isBreakingUpdate,
extractUpdates,
dependabotAutoMergeDecision
} from '../../src/dependabot-update-type.js';

// Every title and body below is copied from a real Dependabot PR in the
// pulseengine org. Synthetic strings would test the parser against what we
// expected Dependabot to write; these test it against what it did write.

describe('dependabot-update-type', () => {
describe('parseVersion', () => {
it('reads a plain semver', () => {
expect(parseVersion('4.4.0')).toEqual([4, 4, 0]);
});

it('drops build metadata (toml writes 0.9.12+spec-1.1.0)', () => {
expect(parseVersion('0.9.12+spec-1.1.0')).toEqual([0, 9, 12]);
});

it('pads a one-component version (actions/deploy-pages from 4 to 5)', () => {
expect(parseVersion('4')).toEqual([4, 0, 0]);
});

it('strips requirement operators (sphinxcontrib-rust from >=1.2.0)', () => {
expect(parseVersion('>=1.2.0')).toEqual([1, 2, 0]);
});

it('strips a trailing full stop from a Bumps summary line', () => {
expect(parseVersion('0.8.0.')).toEqual([0, 8, 0]);
});

it('returns null for something that is not a version (a commit SHA)', () => {
expect(parseVersion('3d3c42e5aac5ba805825da76410c181273ba90b1')).toBeNull();
});
});

describe('isBreakingUpdate', () => {
it('treats a major change as breaking', () => {
expect(isBreakingUpdate([3, 9, 1], [4, 1, 2])).toBe(true);
});

it('treats a 0.x minor change as breaking, as Cargo and npm caret ranges do', () => {
// criterion 0.7 -> 0.8, wsc-attestation 0.10 -> 0.11
expect(isBreakingUpdate([0, 7, 0], [0, 8, 2])).toBe(true);
expect(isBreakingUpdate([0, 10, 0], [0, 11, 0])).toBe(true);
});

it('treats a 0.0.x patch change as breaking', () => {
expect(isBreakingUpdate([0, 0, 3], [0, 0, 4])).toBe(true);
});

it('does not treat a minor or patch on a 1.x+ line as breaking', () => {
expect(isBreakingUpdate([3, 3, 0], [3, 4, 2])).toBe(false); // ureq
expect(isBreakingUpdate([1, 1, 9], [1, 1, 10])).toBe(false); // flate2
});

it('does not treat a 0.x patch change as breaking', () => {
expect(isBreakingUpdate([0, 7, 3], [0, 7, 4])).toBe(false);
});
});

describe('extractUpdates', () => {
it('reads a single-update title', () => {
expect(extractUpdates('build(deps): bump actions/checkout from 4.4.0 to 7.0.1', '')).toEqual([
{ name: 'actions/checkout', from: '4.4.0', to: '7.0.1' }
]);
});

it('ignores a trailing "in the <group> group" suffix', () => {
const u = extractUpdates(
'chore(deps): Bump wasmtime-wasi from 48.0.1 to 48.0.2 in the wasmtime group',
''
);
expect(u).toEqual([{ name: 'wasmtime-wasi', from: '48.0.1', to: '48.0.2' }]);
});

it('reads a "requirement" update with a directory suffix', () => {
const u = extractUpdates(
'build(deps): update wit-bindgen requirement from 0.60.0 to 0.62.0 in /fuzz',
''
);
expect(u).toEqual([{ name: 'wit-bindgen', from: '0.60.0', to: '0.62.0' }]);
});

it('reads EVERY update from a grouped PR body, whose title names no versions', () => {
const body = [
'Bumps the wasmtime group with 2 updates in the / directory: [wasmtime](https://github.com/bytecodealliance/wasmtime) and [wasmtime-wasi](https://github.com/bytecodealliance/wasmtime).',
'Updates `wasmtime` from 47.0.3 to 48.0.1',
'Updates `wasmtime-wasi` from 47.0.3 to 48.0.1'
].join('\n');
const u = extractUpdates('chore(deps): Bump the wasmtime group across 1 directory with 2 updates', body);
// Two DIFFERENT packages moving from the same version to the same
// version. Keying on versions alone collapsed these into one.
expect(u).toEqual([
{ name: 'wasmtime', from: '47.0.3', to: '48.0.1' },
{ name: 'wasmtime-wasi', from: '47.0.3', to: '48.0.1' }
]);
});

it('does NOT read "from X to Y" out of embedded release notes', () => {
// Dependabot bodies quote upstream changelogs, which say things like
// "migrate from v1 to v2". Reading those would invent updates.
const body = [
'Bumps [ureq](https://github.com/algesten/ureq) from 3.3.0 to 3.4.2.',
'<details><summary>Changelog</summary>',
'<li>Upgrade path from 2.0.0 to 9.0.0 documented</li>',
'</details>'
].join('\n');
const u = extractUpdates('build(deps): bump ureq from 3.3.0 to 3.4.2', body);
expect(u).toEqual([{ name: 'ureq', from: '3.3.0', to: '3.4.2' }]);
});
});

describe('dependabotAutoMergeDecision', () => {
it('allows a patch bump', () => {
const d = dependabotAutoMergeDecision('build(deps): bump flate2 from 1.1.9 to 1.1.10', '');
expect(d.allow).toBe(true);
});

it('allows a minor bump on a stable line', () => {
const d = dependabotAutoMergeDecision('build(deps): bump ureq from 3.3.0 to 3.4.2', '');
expect(d.allow).toBe(true);
});

it('REFUSES a major bump to a signing toolchain', () => {
const d = dependabotAutoMergeDecision(
'build(deps): bump sigstore/cosign-installer from 3.9.1 to 4.1.2',
''
);
expect(d.allow).toBe(false);
expect(d.reason).toMatch(/breaking/i);
expect(d.reason).toContain('sigstore/cosign-installer');
expect(d.reason).toContain('3.9.1');
expect(d.reason).toContain('4.1.2');
});

it('REFUSES a 0.x minor bump, which is breaking under Cargo semver', () => {
const d = dependabotAutoMergeDecision('build(deps): bump criterion from 0.7.0 to 0.8.2', '');
expect(d.allow).toBe(false);
});

it('REFUSES a grouped PR when any one update inside it is breaking', () => {
// witness#211: the group is called "wasmtime" and says nothing about
// semver, but it carries 47 -> 48.
const body = [
'Updates `wasmtime` from 47.0.3 to 48.0.1',
'Updates `wasmtime-wasi` from 47.0.3 to 48.0.1'
].join('\n');
const d = dependabotAutoMergeDecision(
'chore(deps): Bump the wasmtime group across 1 directory with 2 updates',
body
);
expect(d.allow).toBe(false);
});

it('allows a grouped PR whose every update is non-breaking', () => {
const body = [
'Updates `ureq` from 3.3.0 to 3.4.2',
'Updates `flate2` from 1.1.9 to 1.1.10'
].join('\n');
const d = dependabotAutoMergeDecision(
'build(deps): bump the cargo-minor-and-patch group with 2 updates',
body
);
expect(d.allow).toBe(true);
});

it('REFUSES when no update can be read at all, rather than assuming it is safe', () => {
const d = dependabotAutoMergeDecision('build(deps): something unrecognised', '');
expect(d.allow).toBe(false);
expect(d.reason).toMatch(/could not/i);
});

it('REFUSES when a version is not semver, e.g. a SHA-only action update', () => {
const d = dependabotAutoMergeDecision(
'build(deps): bump actions/checkout from 3d3c42e5aac5ba805825da76410c181273ba90b1 to 11d5960a326750d5838078e36cf38b85af677262',
''
);
expect(d.allow).toBe(false);
});
});
});
Loading
Loading