Skip to content
Open
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
19 changes: 15 additions & 4 deletions lib/modules/platform/gitlab/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,13 +643,24 @@ async function tryPrAutomerge(
// returns a 405 Method Not Allowed. It seems to be a timing issue within Gitlab.
for (let attempt = 1; attempt <= retryTimes; attempt += 1) {
try {
const requestBody: Record<string, unknown> = {
should_remove_source_branch: true,
merge_when_pipeline_succeeds: true,
};

// Check if merge_after should be set based on platform options
if (platformPrOptions?.merge_after) {
requestBody.merge_after = platformPrOptions.merge_after;
logger.debug(
{ merge_after: requestBody.merge_after },
'Setting merge_after from platform options',
);
}

await gitlabApi.putJson(
`projects/${config.repository}/merge_requests/${pr}/merge`,
{
body: {
should_remove_source_branch: true,
merge_when_pipeline_succeeds: true,
},
body: requestBody,
},
);
break;
Expand Down
1 change: 1 addition & 0 deletions lib/modules/platform/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export interface PlatformPrOptions {
gitLabIgnoreApprovals?: boolean;
usePlatformAutomerge?: boolean;
forkModeDisallowMaintainerEdits?: boolean;
merge_after?: string;
}

export interface CreatePRConfig {
Expand Down
77 changes: 77 additions & 0 deletions lib/workers/repository/update/branch/schedule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,83 @@ describe('workers/repository/update/branch/schedule', () => {
});
});

describe('getNextScheduleTime(config)', () => {
let config: RenovateConfig;

beforeAll(() => {
vi.useFakeTimers();
});

beforeEach(() => {
vi.setSystemTime(new Date('2017-06-30T10:50:00.000'));
config = {};
});

it('returns null if no schedule defined', () => {
expect(schedule.getNextScheduleTime(config)).toBeNull();
});

it('returns null for empty schedule array', () => {
config.automergeSchedule = [];
expect(schedule.getNextScheduleTime(config)).toBeNull();
});

it('returns null for "at any time"', () => {
config.automergeSchedule = ['at any time'];
expect(schedule.getNextScheduleTime(config)).toBeNull();
});

it('returns null for invalid schedule', () => {
config.automergeSchedule = ['this is not a valid schedule'];
expect(schedule.getNextScheduleTime(config)).toBeNull();
});

it('returns null for invalid timezone', () => {
config.automergeSchedule = ['after 4:00pm'];
config.timezone = 'Not/ATimezone';
expect(schedule.getNextScheduleTime(config)).toBeNull();
});

it('returns a future date for a later.js schedule', () => {
config.automergeSchedule = ['after 4:00pm'];
const res = schedule.getNextScheduleTime(config);
expect(res).not.toBeNull();
expect(res!.getTime()).toBeGreaterThan(Date.now());
});

it('returns a future date for a cron schedule', () => {
config.automergeSchedule = ['* 22 * * *'];
const res = schedule.getNextScheduleTime(config);
expect(res).not.toBeNull();
expect(res!.getTime()).toBeGreaterThan(Date.now());
});

it('returns the earliest of multiple schedules', () => {
const resLater = schedule.getNextScheduleTime({
automergeSchedule: ['after 11:00pm'],
});
const resEarliest = schedule.getNextScheduleTime({
automergeSchedule: ['after 11:00pm', 'after 4:00pm'],
});
expect(resEarliest!.getTime()).toBeLessThan(resLater!.getTime());
});

it('respects timezone', () => {
config.automergeSchedule = ['after 4:00pm'];
config.timezone = 'Asia/Singapore';
const res = schedule.getNextScheduleTime(config);
expect(res).not.toBeNull();
expect(res!.getTime()).toBeGreaterThan(Date.now());
});

it('supports the schedule key parameter', () => {
config.schedule = ['after 4:00pm'];
const res = schedule.getNextScheduleTime(config, 'schedule');
expect(res).not.toBeNull();
expect(res!.getTime()).toBeGreaterThan(Date.now());
});
});

describe('log cron schedules', () => {
it('should correctly convert "* 22 4 * *" to human-readable format', () => {
const result = cronstrue.toString('* 22 4 * *');
Expand Down
92 changes: 92 additions & 0 deletions lib/workers/repository/update/branch/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,98 @@ export function cronMatches(
);
}

export function getNextScheduleTime(
config: RenovateConfig,
scheduleKey: 'schedule' | 'automergeSchedule' = 'automergeSchedule',
): Date | null {
let configSchedule = config[scheduleKey];
logger.debug(
`Getting next schedule time for (schedule=${String(configSchedule)}, tz=${config.timezone!})`,
);

if (
!configSchedule ||
configSchedule.length === 0 ||
configSchedule[0] === '' ||
configSchedule[0] === 'at any time'
) {
logger.debug('No schedule defined - returning null');
return null;
}

if (!isArray(configSchedule)) {
logger.warn(
{ schedule: configSchedule },
'config schedule is not an array',
);
configSchedule = [configSchedule];
}

const validSchedule = hasValidSchedule(configSchedule);
if (!validSchedule[0]) {
logger.warn(validSchedule[1]);
return null;
}

let now: DateTime = DateTime.local();
logger.trace(`now=${now.toISO()!}`);

// Adjust the time if repo is in a different timezone to renovate
if (config.timezone) {
logger.debug(`Found timezone: ${config.timezone}`);
const validTimezone = hasValidTimezone(config.timezone);
if (!validTimezone[0]) {
logger.warn(validTimezone[1]);
return null;
}
logger.debug('Adjusting now for timezone');
now = now.setZone(config.timezone);
logger.trace(`now=${now.toISO()!}`);
}

// later is timezone agnostic (as in, it purely relies on the underlying UTC date/time that is stored in the Date),
// which means we have to pass it a Date that has an underlying UTC date/time in the same timezone as the schedule
const jsNow = now.setZone('utc', { keepLocalTime: true }).toJSDate();

// Find the earliest next run time from all schedules
let earliestNextRun: Date | null = null;

for (const scheduleText of configSchedule) {
const cronSchedule = parseCron(scheduleText);
if (cronSchedule) {
// We have Cron syntax - create a temporary Cron instance to get next run
const tempCron = new Cron(scheduleText);
const nextRun = tempCron.nextRun();
tempCron.stop(); // Clean up
if (nextRun && (!earliestNextRun || nextRun < earliestNextRun)) {
earliestNextRun = nextRun;
}
} else {
// We have Later syntax
const massagedText = scheduleMappings[scheduleText] || scheduleText;
const parsedSchedule = later.parse.text(fixShortHours(massagedText));
logger.debug({ parsedSchedule }, `Checking schedule "${scheduleText}"`);

const nextOccurrence = later.schedule(parsedSchedule).next(1, jsNow) as
| Date
| 0;
if (nextOccurrence instanceof Date) {
if (!earliestNextRun || nextOccurrence < earliestNextRun) {
earliestNextRun = nextOccurrence;
}
}
}
}

if (earliestNextRun) {
logger.debug(`Next schedule time: ${earliestNextRun.toISOString()}`);
return earliestNextRun;
}

logger.debug('No next schedule time found');
return null;
}

export function isScheduledNow(
config: RenovateConfig,
scheduleKey: 'schedule' | 'automergeSchedule' = 'schedule',
Expand Down
19 changes: 18 additions & 1 deletion lib/workers/repository/update/pr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
PrBlockedBy,
} from '../../../types.ts';
import { embedChangelogs } from '../../changelog/index.ts';
import { getNextScheduleTime, isScheduledNow } from '../branch/schedule.ts';
import { resolveBranchStatus } from '../branch/status-checks.ts';
import { getPrBody } from './body/index.ts';
import {
Expand All @@ -58,7 +59,7 @@ export function getPlatformPrOptions(
config.platformAutomerge,
);

return {
const options: PlatformPrOptions = {
autoApprove: !!config.autoApprove,
automergeStrategy: config.automergeStrategy,
azureWorkItemId: config.azureWorkItemId ?? 0,
Expand All @@ -68,6 +69,22 @@ export function getPlatformPrOptions(
forkModeDisallowMaintainerEdits: !!config.forkModeDisallowMaintainerEdits,
usePlatformAutomerge,
};

// Handle automergeSchedule for GitLab merge_after parameter
if (
usePlatformAutomerge &&
config.automergeSchedule &&
config.automergeSchedule.length > 0
) {
if (!isScheduledNow(config)) {
const nextScheduleTime = getNextScheduleTime(config);
if (nextScheduleTime) {
options.merge_after = nextScheduleTime.toISOString();
}
}
}

return options;
}

export interface ResultWithPr {
Expand Down
Loading