blob: d22de5c7381d887b87a9f5d52a02d62fd733757c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { planLifecycle, STALE_REOPEN_MARKER, STALE_WARNING_MARKER } from './issue-pr-lifecycle.mjs';
const NOW = '2026-09-01T12:00:00.000Z';
function daysAgo(days) {
return new Date(Date.parse(NOW) - days * 24 * 60 * 60 * 1000).toISOString();
}
function humanComment(days, body = 'Still relevant') {
return {
createdAt: daysAgo(days),
body,
author: { __typename: 'User', login: 'contributor' },
};
}
function botComment(days, body = 'Automated note') {
return {
createdAt: daysAgo(days),
body,
author: { __typename: 'Bot', login: 'github-actions[bot]' },
};
}
function warning(days) {
return botComment(days, STALE_WARNING_MARKER);
}
function issue(overrides = {}) {
return {
kind: 'issue',
createdAt: daysAgo(60),
labels: [],
assigneeCount: 0,
comments: [],
...overrides,
};
}
function pullRequest(overrides = {}) {
return {
kind: 'pull_request',
createdAt: daysAgo(60),
lastCommitAt: daysAgo(60),
labels: [],
comments: [],
...overrides,
};
}
describe('issue lifecycle', () => {
it('warns at the 30-day inactivity boundary', () => {
assert.equal(planLifecycle(issue({ createdAt: daysAgo(30) }), NOW).action, 'warn');
assert.equal(planLifecycle(issue({ createdAt: daysAgo(29) }), NOW).action, 'none');
});
it('uses the latest human comment as activity', () => {
const plan = planLifecycle(issue({ comments: [humanComment(10)] }), NOW);
assert.deepEqual(plan, { action: 'none', reason: 'recent qualifying activity' });
});
it('ignores bot comments when measuring activity', () => {
const plan = planLifecycle(issue({ comments: [botComment(1)] }), NOW);
assert.equal(plan.action, 'warn');
});
it('treats a lifecycle reopen marker as a fresh activity timestamp', () => {
const plan = planLifecycle(issue({ comments: [botComment(2, STALE_REOPEN_MARKER)] }), NOW);
assert.deepEqual(plan, { action: 'none', reason: 'recent qualifying activity' });
});
it('closes only after the full 30-day grace period', () => {
const pending = issue({ labels: ['stale'], comments: [warning(29)] });
const expired = issue({ labels: ['stale'], comments: [warning(30)] });
assert.deepEqual(planLifecycle(pending, NOW), {
action: 'none',
reason: 'within grace period',
});
assert.equal(planLifecycle(expired, NOW).action, 'close');
});
it('removes stale after a human response to the warning', () => {
const plan = planLifecycle(
issue({ labels: ['stale'], comments: [warning(20), humanComment(2)] }),
NOW,
);
assert.deepEqual(plan, {
action: 'unstale',
reason: 'qualifying activity after warning',
});
});
it('exempts assigned issues and removes an existing stale label', () => {
assert.deepEqual(planLifecycle(issue({ assigneeCount: 1 }), NOW), {
action: 'none',
reason: 'assigned',
});
assert.deepEqual(planLifecycle(issue({ assigneeCount: 1, labels: ['stale'] }), NOW), {
action: 'unstale',
reason: 'assigned',
});
});
});
describe('pull request lifecycle', () => {
it('warns based on the latest commit instead of creation or comments', () => {
const active = pullRequest({ lastCommitAt: daysAgo(5) });
const inactive = pullRequest({
lastCommitAt: daysAgo(30),
comments: [humanComment(1)],
});
assert.equal(planLifecycle(active, NOW).action, 'none');
assert.equal(planLifecycle(inactive, NOW).action, 'warn');
});
it('closes after seven days without a new commit', () => {
const plan = planLifecycle(
pullRequest({ labels: ['stale'], comments: [warning(7), humanComment(1)] }),
NOW,
);
assert.equal(plan.action, 'close');
});
it('removes stale after a commit newer than the warning', () => {
const plan = planLifecycle(
pullRequest({ labels: ['stale'], comments: [warning(7)], lastCommitAt: daysAgo(1) }),
NOW,
);
assert.deepEqual(plan, {
action: 'unstale',
reason: 'qualifying activity after warning',
});
});
it('ignores a lifecycle reopen marker for PR activity timing', () => {
const plan = planLifecycle(
pullRequest({ comments: [botComment(2, STALE_REOPEN_MARKER)] }),
NOW,
);
assert.equal(plan.action, 'warn');
});
it('still finds human activity when more than 100 comments are present', () => {
const comments = [
humanComment(1),
...Array.from({ length: 100 }, (_, index) => botComment(index + 2)),
];
assert.deepEqual(planLifecycle(issue({ comments }), NOW), {
action: 'none',
reason: 'recent qualifying activity',
});
});
});
describe('state repair and exemptions', () => {
it('starts a fresh grace period when stale has no workflow warning', () => {
const plan = planLifecycle(issue({ labels: ['stale'] }), NOW);
assert.equal(plan.action, 'warn');
assert.match(plan.message, new RegExp(STALE_WARNING_MARKER));
});
it('does not trust a human-authored warning marker', () => {
const plan = planLifecycle(
issue({ labels: ['stale'], comments: [humanComment(40, STALE_WARNING_MARKER)] }),
NOW,
);
assert.equal(plan.action, 'warn');
});
it('exempts pinned issues and pull requests', () => {
assert.deepEqual(planLifecycle(issue({ labels: ['pinned', 'stale'] }), NOW), {
action: 'unstale',
reason: 'pinned',
});
assert.deepEqual(planLifecycle(pullRequest({ labels: ['pinned'] }), NOW), {
action: 'none',
reason: 'pinned',
});
});
});