Skip to content

Commit 79cd238

Browse files
committed
generic label/action interface
1 parent 1e9625a commit 79cd238

3 files changed

Lines changed: 115 additions & 24 deletions

File tree

action.yml

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ branding:
55
icon: 'cloud'
66
color: 'orange'
77
inputs:
8+
repo-token:
9+
description: 'Token for the repository. Can be passed in using `{{ secrets.GITHUB_TOKEN }}`'
10+
default: ${{ github.token }}
811
minimum-upvotes-to-exempt:
912
description: 'The minimum number of "upvotes" that an issue needs to have before never closing it.'
1013
default: 0
1114
dry-run:
1215
description: 'Set to true to not perform repository changes'
1316
default: false
17+
expiration-label-map:
18+
description: 'A multiline input mapping labels with actions and destination labels, applied after an expiration time. See README.md'
19+
default: |-
20+
''
21+
update-remove-labels:
22+
description: 'A comma-seperated list of labels that should be removed on issue update. See README.md'
23+
default: ''
24+
1425

1526
#name: "'Stale Issue Cleanup' Action for GitHub Actions"
1627
#description: 'Close issues and pull requests with no recent activity'
@@ -65,22 +76,3 @@ inputs:
6576
runs:
6677
using: 'node16'
6778
main: 'dist/index.js'
68-
env:
69-
REPO_TOKEN: ${{ github.token }}
70-
# ISSUE_TYPES: ${{ inputs.issue-types }}
71-
# ANCIENT_ISSUE_MESSAGE: ${{ inputs.ancient-issue-message }}
72-
# ANCIENT_PR_MESSAGE: ${{ inputs.ancient-pr-message }}
73-
# STALE_ISSUE_MESSAGE: ${{ inputs.stale-issue-message }}
74-
# STALE_PR_MESSAGE: ${{ inputs.stale-pr-message }}
75-
# DAYS_BEFORE_STALE: ${{ inputs.days-before-stale }}
76-
# DAYS_BEFORE_CLOSE: ${{ inputs.days-before-close }}
77-
# DAYS_BEFORE_ANCIENT: ${{ inputs.days-before-ancient }}
78-
# STALE_ISSUE_LABEL: ${{ inputs.stale-issue-label }}
79-
# EXEMPT_ISSUE_LABELS: ${{ inputs.exempt-issue-labels }}
80-
# STALE_PR_LABEL: ${{ inputs.stale-pr-label }}
81-
# EXEMPT_PR_LABELS: ${{ inputs.exempt-pr-labels }}
82-
# RESPONSE_REQUESTED_LABEL: ${{ inputs.response-requested-label }}
83-
# CFS_LABEL: ${{ inputs.closed-for-staleness-label }}
84-
MINIMUM_UPVOTES_TO_EXEMPT: ${{ inputs.minimum-upvotes-to-exempt }}
85-
# LOGLEVEL: ${{ inputs.loglevel }}
86-
DRYRUN: ${{ inputs.dry-run }}

src/github.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
import * as github from '@actions/github';
2+
import * as core from '@actions/core';
3+
import { args } from './input';
4+
5+
export const labelActions = ['add', 'remove', 'close'] as const;
6+
7+
type Element<T extends unknown[]> = T extends readonly (infer ElementType)[] ? ElementType : never;
8+
export type Issue = Element<Awaited<ReturnType<typeof getIssues>>>;
9+
export type Timeline = Awaited<ReturnType<typeof getIssueLabelTimeline>>;
210

311
export async function getIssues(labels: string[], token: string) {
412
const octokit = github.getOctokit(token);
@@ -9,3 +17,80 @@ export async function getIssues(labels: string[], token: string) {
917
labels: labels.join(),
1018
});
1119
}
20+
21+
export async function processIssues(issues: Issue[], args: args) {
22+
issues.forEach(async issue => {
23+
const timeline = await getIssueLabelTimeline(issue.number, args.token);
24+
// Enumerate labels in issue and check if each matches our action list
25+
issue.labels.forEach(label => {
26+
const issueLabel = typeof label === 'string' ? label : label.name;
27+
if (issueLabel) {
28+
if (args.expirationLabelMap) {
29+
// These are labels that we apply if an issue hasn't been updated in a specified timeframe
30+
args.expirationLabelMap.forEach(async lam => {
31+
const sourceLabelList = lam.split(':')[0].split(',');
32+
const configuredAction = lam.split(':')[1];
33+
const configuredTime = parseInt(lam.split(':')[2]);
34+
35+
if (sourceLabelList.includes(issueLabel) && issueDateCompare(issue.updated_at, configuredTime)) {
36+
// Issue contains label specified and configured time has elapsed
37+
switch (configuredAction) {
38+
case 'add':
39+
await addLabelToIssue(issue.number, lam.split(':')[3]);
40+
break;
41+
case 'remove':
42+
await removeLabelFromIssue(issue.number, lam.split(':')[3]);
43+
break;
44+
case 'close':
45+
await closeIssue(issue.number);
46+
break;
47+
default:
48+
core.error(`Unknown action ${configuredAction} for issue #${issue.number}, doing nothing`);
49+
}
50+
}
51+
});
52+
}
53+
if (args.updateRemoveLabels) {
54+
// These are labels that need removed if an issue has been updated after they were applied
55+
args.updateRemoveLabels.forEach(async removeMe => {
56+
if (Date.parse(issue.updated_at) > getIssueLabelDate(timeline, removeMe)) {
57+
removeLabelFromIssue(issue.number, removeMe);
58+
}
59+
});
60+
}
61+
}
62+
});
63+
});
64+
}
65+
66+
async function getIssueLabelTimeline(issueNumber: number, token: string) {
67+
const octokit = github.getOctokit(token);
68+
return (
69+
await octokit.paginate(octokit.rest.issues.listEventsForTimeline, {
70+
owner: github.context.repo.owner,
71+
repo: github.context.repo.repo,
72+
issue_number: issueNumber,
73+
})
74+
).filter(event => event.event === 'labeled');
75+
}
76+
77+
function getIssueLabelDate(timeline: Timeline, label: string) {
78+
// Return when the label was last applied
79+
return timeline.reduce((p, c) => {
80+
if (c.updated_at && c.label?.name === label) {
81+
if (Date.parse(c.updated_at) > p) {
82+
return Date.parse(c.updated_at);
83+
} else {
84+
return p;
85+
}
86+
} else {
87+
return p;
88+
}
89+
}, 0);
90+
}
91+
92+
function issueDateCompare(issueDate: string, configuredDays: number) {
93+
const d = new Date(Date.parse(issueDate));
94+
d.setDate(d.getDate() + configuredDays);
95+
return d.valueOf() < Date.now();
96+
}

src/input.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
import * as core from '@actions/core';
2-
2+
import { labelActions } from './github';
33
export interface args {
44
dryrun: boolean;
55
minimumUpvotesToExempt: number;
66
token: string;
7+
expirationLabelMap?: string[];
8+
updateRemoveLabels?: string[];
79
}
810

911
export function getAndValidateInputs(): args {
10-
const minUpvotes = parseInt(core.getInput('minimumUpvotesToExempt', { required: false }));
12+
// Number inputs
13+
const minUpvotes = parseInt(core.getInput('minimum-upvotes-to-exempt', { required: false }));
1114
for (const numberInput of [minUpvotes]) {
1215
if (isNaN(numberInput)) {
13-
throw Error(`Input ${numberInput} did not parse to a valid integar`);
16+
throw Error(`Input ${numberInput} did not parse to a valid integer`);
1417
}
1518
}
19+
20+
// Action map
21+
const labelValidationRegex = new RegExp(`^[A-Za-z0-9_.-,]+:(${labelActions.join('|')}):\\d+(:[A-Za-z0-9_.-,]+)?/i`);
22+
const expirationLabelMap = core
23+
.getMultilineInput('expiration-label-map', { required: false })
24+
.filter(m => labelValidationRegex.test(m));
25+
core.debug(`Parsed label mapping: ${expirationLabelMap}`);
26+
const updateRemoveLabels = core.getInput('update-remove-labels', { required: false }).split(',');
27+
1628
return {
17-
dryrun: core.getBooleanInput('dryrun', { required: false }),
29+
dryrun: core.getBooleanInput('dry-run', { required: false }),
1830
minimumUpvotesToExempt: minUpvotes,
19-
token: process.env.REPO_TOKEN!,
31+
token: core.getInput('repo-token'),
32+
expirationLabelMap,
33+
updateRemoveLabels,
2034
};
2135
}

0 commit comments

Comments
 (0)