Compare commits

...

16 Commits

Author SHA1 Message Date
PJ Quirk
71d46bfe23 Fix for logging error types instead of strings in the main try/catch (#66)
* Bump actions core for more lenient command inputs
* Fix issue with issues that are old and unlabeled
2020-05-12 15:23:00 -04:00
Ross Brodbeck
e611bf905b Close stale issues even if they were just marked stale (#64) 2020-05-11 11:15:05 -04:00
Ross Brodbeck
1e900bc060 Update docs and version so we can move to v3 (#63)
* Update docs and version

* Fix readme typo
2020-05-11 10:54:55 -04:00
Ross Brodbeck
3838b887be Remove stale labels and ignore stale issues with comments (#58)
* Ignore issues that have recent comments and ignore the stale label. Defaulting to this behavior but added an option to turn it off.
* Fix up tests a bit to make this runnable. Add a test for the logic.
* Add vscode debugging configuration.
2020-05-11 10:46:03 -04:00
micnncim
4f9b6a7a5c Fix typo in IssueProcessor.ts (#54) 2020-05-04 13:19:08 -04:00
Farhad Safarov
8743296016 Correct example (#47) 2020-05-04 13:18:22 -04:00
dependabot[bot]
7328f2e6e5 Bump @actions/http-client from 1.0.7 to 1.0.8 (#55)
Bumps [@actions/http-client](https://github.com/actions/http-client) from 1.0.7 to 1.0.8.
- [Release notes](https://github.com/actions/http-client/releases)
- [Changelog](https://github.com/actions/http-client/blob/master/RELEASES.md)
- [Commits](https://github.com/actions/http-client/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-05-04 13:17:26 -04:00
eric sciple
2b063ba4d4 Update readme to reference major version channel (#49) 2020-05-04 13:16:31 -04:00
James M. Greene
ae2c5c5308 Prevent processing of locked/closed issues/PRs (#52)
Fixes #50
Fixes #51
2020-04-27 07:53:58 -04:00
Ross Brodbeck
a23bda33c4 Update the readme, add a test (#46)
* Add a test to days = -1
* Update the readme on how to build it
2020-04-16 14:23:28 -04:00
Ross Brodbeck
78921b6863 one more fix to the actions yml 2020-04-16 14:05:24 -04:00
Ross Brodbeck
29c3838f9a Fix parsing issues with action.yml 2020-04-16 14:03:58 -04:00
Ross Brodbeck
ae12f32ff1 Create test.yml 2020-04-16 13:58:47 -04:00
Ross Brodbeck
aad6ffa865 Refactor issue processor (#45)
* Refctor into an issue processor, add debug mode
* Move processor to use its own types so testing is easier
* Add more tests
2020-04-16 13:57:59 -04:00
Ryan Wilson-Perkin
6127f8ef7a Allow multiple exempt labels (#19)
* Update description to clarify exempt labels

* [breaking change] Use array for exempt labels

* Switch to a comma separated string for exempt labels

* Update descriptions for multiple labels

* Update examples for exempt labels

* lint: apply fixes

* Re-generate transpiled output
2020-04-15 21:33:09 -04:00
Ross Brodbeck
e78f171ed1 Fix main entrypoint in action yml (#43) 2020-04-14 14:02:29 -04:00
12 changed files with 2413 additions and 1282 deletions

View File

@@ -23,7 +23,7 @@
"@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"],
"@typescript-eslint/no-array-constructor": "error",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-extraneous-class": "error",
"@typescript-eslint/no-for-in-array": "error",
"@typescript-eslint/no-inferrable-types": "error",
@@ -45,7 +45,7 @@
"@typescript-eslint/restrict-plus-operands": "error",
"semi": "off",
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unbound-method": "error"
"@typescript-eslint/unbound-method": "off"
},
"env": {
"node": true,

26
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: "Build"
on: # rebuild any PRs and main branch changes
pull_request:
push:
branches:
- master
- 'releases/*'
jobs:
build: # make sure build/ci work properly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: |
npm install
npm run all
test: # make sure the action works on a clean machine without building
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale'
stale-pr-message: 'This PR is stale'
debug-only: true

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Jest Tests",
"type": "node",
"request": "launch",
"program": "${workspaceRoot}/node_modules/jest/bin/jest",
"args": [
"-i"
],
"preLaunchTask": "tsc: build - tsconfig.json",
"internalConsoleOptions": "openOnSessionStart",
"console": "integratedTerminal",
"outFiles": [
"${workspaceRoot}/build/dist/**/*"
]
}
]
}

View File

@@ -2,6 +2,23 @@
Warns and then closes issues and PRs that have had no activity for a specified amount of time.
### Building and testing
Install the dependencies
```bash
$ npm install
```
Build the typescript and package it for distribution
```bash
$ npm run build && npm run pack
```
Run the tests :heavy_check_mark:
```bash
$ npm test
```
### Usage
See [action.yml](./action.yml) For comprehensive list of options.
@@ -17,7 +34,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v1.1.0
- uses: actions/stale@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Message to comment on stale issues. If none provided, will not mark issues stale'
@@ -35,7 +52,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v1.1.0
- uses: actions/stale@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days'
@@ -54,13 +71,17 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v1.1.0
- uses: actions/stale@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Stale issue message'
stale-pr-message: 'Stale issue message'
stale-pr-message: 'Stale pull request message'
stale-issue-label: 'no-issue-activity'
exempt-issue-label: 'awaiting-approval'
exempt-issue-labels: 'awaiting-approval,work-in-progress'
stale-pr-label: 'no-pr-activity'
exempt-pr-label: 'awaiting-approval'
exempt-pr-labels: 'awaiting-approval,work-in-progress'
```
### Debugging
To see debug output from this action, you must set the secret `ACTIONS_STEP_DEBUG` to `true` in your repository. You can run this action in debug only mode (no actions will be taken on your issues) by passing `debug-only` `true` as an argument to the action.

View File

@@ -1,3 +1,506 @@
describe('TODO - Add a test suite', () => {
it('TODO - Add a test', async () => {});
import * as core from '@actions/core';
import * as github from '@actions/github';
import {Octokit} from '@octokit/rest';
import {
IssueProcessor,
Issue,
Label,
IssueProcessorOptions
} from '../src/IssueProcessor';
function generateIssue(
id: number,
title: string,
updatedAt: string,
isPullRequest: boolean = false,
labels: string[] = [],
isClosed: boolean = false,
isLocked: boolean = false
): Issue {
return {
number: id,
labels: labels.map(l => {
return {name: l};
}),
title: title,
updated_at: updatedAt,
pull_request: isPullRequest ? {} : null,
state: isClosed ? 'closed' : 'open',
locked: isLocked
};
}
const DefaultProcessorOptions: IssueProcessorOptions = {
repoToken: 'none',
staleIssueMessage: 'This issue is stale',
stalePrMessage: 'This PR is stale',
daysBeforeStale: 1,
daysBeforeClose: 30,
staleIssueLabel: 'Stale',
exemptIssueLabels: '',
stalePrLabel: 'Stale',
exemptPrLabels: '',
onlyLabels: '',
operationsPerRun: 100,
debugOnly: true,
removeStaleWhenUpdated: false
};
test('empty issue list results in 1 operation', async () => {
const processor = new IssueProcessor(
DefaultProcessorOptions,
async () => [],
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
const operationsLeft = await processor.processIssues(1);
// processing an empty issue list should result in 1 operation
expect(operationsLeft).toEqual(99);
});
test('processing an issue with no label will make it stale and close it, if it is old enough', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z')
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(1);
expect(processor.closedIssues.length).toEqual(1);
});
test('processing an issue with no label will make it stale but not close it', async () => {
// issue should be from 2 days ago so it will be
// stale but not close-able, based on default settings
let issueDate = new Date();
issueDate.setDate(issueDate.getDate() - 2);
const TestIssueList: Issue[] = [
generateIssue(1, 'An issue with no label', issueDate.toDateString())
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(1);
expect(processor.closedIssues.length).toEqual(0);
});
test('processing a stale issue will close it', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale issue that should be closed',
'2020-01-01T17:00:00Z',
false,
['Stale']
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(1);
});
test('processing a stale PR will close it', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale PR that should be closed',
'2020-01-01T17:00:00Z',
true,
['Stale']
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(1);
});
test('closed issues will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A closed issue that will not be marked',
'2020-01-01T17:00:00Z',
false,
[],
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => []
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('stale closed issues will not be closed', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale closed issue',
'2020-01-01T17:00:00Z',
false,
['Stale'],
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('closed prs will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A closed PR that will not be marked',
'2020-01-01T17:00:00Z',
true,
[],
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('stale closed prs will not be closed', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale closed PR that will not be closed again',
'2020-01-01T17:00:00Z',
true,
['Stale'],
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('locked issues will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A locked issue that will not be stale',
'2020-01-01T17:00:00Z',
false,
[],
false,
true
)
];
const processor = new IssueProcessor(DefaultProcessorOptions, async p =>
p == 1 ? TestIssueList : []
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('stale locked issues will not be closed', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale locked issue that will not be closed',
'2020-01-01T17:00:00Z',
false,
['Stale'],
false,
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('locked prs will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A locked PR that will not be marked stale',
'2020-01-01T17:00:00Z',
true,
[],
false,
true
)
];
const processor = new IssueProcessor(DefaultProcessorOptions, async p =>
p == 1 ? TestIssueList : []
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('stale locked prs will not be closed', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'A stale locked PR that will not be closed',
'2020-01-01T17:00:00Z',
true,
['Stale'],
false,
true
)
];
const processor = new IssueProcessor(
DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('exempt issue labels will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, [
'Exempt'
])
];
const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Exempt';
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('exempt issue labels will not be marked stale (multi issue label with spaces)', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool'])
];
const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Exempt, Cool, None';
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('exempt issue labels will not be marked stale (multi issue label)', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool'])
];
const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Exempt,Cool,None';
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.closedIssues.length).toEqual(0);
});
test('exempt pr labels will not be marked stale', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool']),
generateIssue(2, 'My first PR', '2020-01-01T17:00:00Z', true, ['Cool']),
generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false)
];
const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Cool';
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.staleIssues.length).toEqual(2); // PR should get processed even though it has an exempt **issue** label
});
test('stale issues should not be closed if days is set to -1', async () => {
const TestIssueList: Issue[] = [
generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, [
'Stale'
]),
generateIssue(2, 'My first PR', '2020-01-01T17:00:00Z', true, ['Stale']),
generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false, ['Stale'])
];
const opts = {...DefaultProcessorOptions};
opts.daysBeforeClose = -1;
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.closedIssues.length).toEqual(0);
});
test('stale label should be removed if a comment was added to a stale issue', async () => {
const TestIssueList: Issue[] = [
generateIssue(
1,
'An issue that should un-stale',
'2020-01-01T17:00:00Z',
false,
['Stale']
)
];
const opts = DefaultProcessorOptions;
opts.removeStaleWhenUpdated = true;
const processor = new IssueProcessor(
opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [{user: {type: 'User'}}], // return a fake comment so indicate there was an update
async (issue, label) => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.closedIssues.length).toEqual(0);
expect(processor.staleIssues.length).toEqual(0);
expect(processor.removedLabelIssues.length).toEqual(1);
});

View File

@@ -18,19 +18,27 @@ inputs:
stale-issue-label:
description: 'The label to apply when an issue is stale.'
default: 'Stale'
exempt-issue-label:
description: 'The label to apply when an issue is exempt from being marked stale.'
exempt-issue-labels:
description: 'The labels to apply when an issue is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")'
default: ''
stale-pr-label:
description: 'The label to apply when a pull request is stale.'
default: 'Stale'
exempt-pr-label:
description: 'The label to apply when a pull request is exempt from being marked stale.'
exempt-pr-labels:
description: 'The labels to apply when a pull request is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")'
default: ''
only-labels:
description: 'Only issues or pull requests with all of these labels are checked if stale. Defaults to `[]` (disabled) and can be a comma-separated list of labels.'
default: ''
operations-per-run:
description: 'The maximum number of operations per run, used to control rate limiting.'
default: 30
remove-stale-when-updated:
description: 'Remove stale labels from issues when they are updated or commented on.'
default: true
debug-only:
description: 'Run the processor in debug mode without actually performing any operations on live issues.'
default: false
runs:
using: 'node12'
main: 'lib/main.js'
main: 'dist/index.js'

2448
dist/index.js vendored

File diff suppressed because it is too large Load Diff

71
package-lock.json generated
View File

@@ -5,59 +5,24 @@
"requires": true,
"dependencies": {
"@actions/core": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.3.tgz",
"integrity": "sha512-Wp4xnyokakM45Uuj4WLUxdsa8fJjKVl1fDTsPbTEcTcuu0Nb26IPQbOtjmnfaCPGcaoPOOqId8H9NapZ8gii4w=="
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.2.4.tgz",
"integrity": "sha512-YJCEq8BE3CdN8+7HPZ/4DxJjk/OkZV2FFIf+DlZTC/4iBlzYCD5yjRR6eiOS5llO11zbRltIRuKAjMKaWTE6cg=="
},
"@actions/github": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-2.1.1.tgz",
"integrity": "sha512-kAgTGUx7yf5KQCndVeHSwCNZuDBvPyxm5xKTswW2lofugeuC1AZX73nUUVDNaysnM9aKFMHv9YCdVJbg7syEyA==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@actions/github/-/github-2.2.0.tgz",
"integrity": "sha512-9UAZqn8ywdR70n3GwVle4N8ALosQs4z50N7XMXrSTUVOmVpaBC5kE3TRTT7qQdi3OaQV24mjGuJZsHUmhD+ZXw==",
"requires": {
"@actions/http-client": "^1.0.3",
"@octokit/graphql": "^4.3.1",
"@octokit/rest": "^16.43.1"
},
"dependencies": {
"@octokit/request-error": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz",
"integrity": "sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==",
"requires": {
"@octokit/types": "^2.0.0",
"deprecation": "^2.0.0",
"once": "^1.4.0"
}
},
"@octokit/rest": {
"version": "16.43.1",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.1.tgz",
"integrity": "sha512-gfFKwRT/wFxq5qlNjnW2dh+qh74XgTQ2B179UX5K1HYCluioWj8Ndbgqw2PVqa1NnVJkGHp2ovMpVn/DImlmkw==",
"requires": {
"@octokit/auth-token": "^2.4.0",
"@octokit/plugin-paginate-rest": "^1.1.1",
"@octokit/plugin-request-log": "^1.0.0",
"@octokit/plugin-rest-endpoint-methods": "2.4.0",
"@octokit/request": "^5.2.0",
"@octokit/request-error": "^1.0.2",
"atob-lite": "^2.0.0",
"before-after-hook": "^2.0.0",
"btoa-lite": "^1.0.0",
"deprecation": "^2.0.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"lodash.uniq": "^4.5.0",
"octokit-pagination-methods": "^1.1.0",
"once": "^1.4.0",
"universal-user-agent": "^4.0.0"
}
}
}
},
"@actions/http-client": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.7.tgz",
"integrity": "sha512-PY3ys/XH5WMekkHyZhYSa/scYvlE5T/TV/T++vABHuY5ZRgtiBZkn2L2tV5Pv/xDCl59lSZb9WwRuWExDyAsSg==",
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.8.tgz",
"integrity": "sha512-G4JjJ6f9Hb3Zvejj+ewLLKLf99ZC+9v+yCxoYf9vSyH+WkzPLB2LuUtRMGNkooMqdugGBFStIKXOuvH1W+EctA==",
"requires": {
"tunnel": "0.0.6"
}
@@ -557,13 +522,23 @@
}
},
"@octokit/graphql": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.3.1.tgz",
"integrity": "sha512-hCdTjfvrK+ilU2keAdqNBWOk+gm1kai1ZcdjRfB30oA3/T6n53UVJb7w0L5cR3/rhU91xT3HSqCd+qbvH06yxA==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.4.0.tgz",
"integrity": "sha512-Du3hAaSROQ8EatmYoSAJjzAz3t79t9Opj/WY1zUgxVUGfIKn0AEjg+hlOLscF6fv6i/4y/CeUvsWgIfwMkTccw==",
"requires": {
"@octokit/request": "^5.3.0",
"@octokit/types": "^2.0.0",
"universal-user-agent": "^4.0.0"
"universal-user-agent": "^5.0.0"
},
"dependencies": {
"universal-user-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-5.0.0.tgz",
"integrity": "sha512-B5TPtzZleXyPrUMKCpEHFmVhMN6EhmJYjG5PQna9s7mXeSqGTLap4OpqLl5FCEFUI3UBmllkETwKf/db66Y54Q==",
"requires": {
"os-name": "^3.1.0"
}
}
}
},
"@octokit/plugin-paginate-rest": {

View File

@@ -25,8 +25,8 @@
"author": "GitHub",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.2.3",
"@actions/github": "^2.1.1",
"@actions/core": "^1.2.4",
"@actions/github": "^2.2.0",
"@octokit/rest": "^16.43.1",
"semver": "^6.1.1"
},

407
src/IssueProcessor.ts Normal file
View File

@@ -0,0 +1,407 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import {Octokit} from '@octokit/rest';
type OctoKitIssueList = Octokit.Response<Octokit.IssuesListForRepoResponse>;
export interface Issue {
title: string;
number: number;
updated_at: string;
labels: Label[];
pull_request: any;
state: string;
locked: boolean;
}
export interface User {
type: string;
}
export interface Comment {
user: User;
}
export interface IssueEvent {
created_at: string;
event: string;
label: Label;
}
export interface Label {
name: string;
}
export interface IssueProcessorOptions {
repoToken: string;
staleIssueMessage: string;
stalePrMessage: string;
daysBeforeStale: number;
daysBeforeClose: number;
staleIssueLabel: string;
exemptIssueLabels: string;
stalePrLabel: string;
exemptPrLabels: string;
onlyLabels: string;
operationsPerRun: number;
removeStaleWhenUpdated: boolean;
debugOnly: boolean;
}
/***
* Handle processing of issues for staleness/closure.
*/
export class IssueProcessor {
readonly client: github.GitHub;
readonly options: IssueProcessorOptions;
private operationsLeft: number = 0;
readonly staleIssues: Issue[] = [];
readonly closedIssues: Issue[] = [];
readonly removedLabelIssues: Issue[] = [];
constructor(
options: IssueProcessorOptions,
getIssues?: (page: number) => Promise<Issue[]>,
listIssueComments?: (
issueNumber: number,
sinceDate: string
) => Promise<Comment[]>,
getLabelCreationDate?: (issue: Issue, label: string) => Promise<string | undefined>
) {
this.options = options;
this.operationsLeft = options.operationsPerRun;
this.client = new github.GitHub(options.repoToken);
if (getIssues) {
this.getIssues = getIssues;
}
if (listIssueComments) {
this.listIssueComments = listIssueComments;
}
if (getLabelCreationDate) {
this.getLabelCreationDate = getLabelCreationDate;
}
if (this.options.debugOnly) {
core.warning(
'Executing in debug mode. Debug output will be written but no issues will be processed.'
);
}
}
async processIssues(page: number = 1): Promise<number> {
if (this.operationsLeft <= 0) {
core.warning('Reached max number of operations to process. Exiting.');
return 0;
}
// get the next batch of issues
const issues: Issue[] = await this.getIssues(page);
this.operationsLeft -= 1;
if (issues.length <= 0) {
core.debug('No more issues found to process. Exiting.');
return this.operationsLeft;
}
for (const issue of issues.values()) {
const isPr = !!issue.pull_request;
core.debug(
`Found issue: issue #${issue.number} - ${issue.title} last updated ${issue.updated_at} (is pr? ${isPr})`
);
// calculate string based messages for this issue
const staleMessage: string = isPr
? this.options.stalePrMessage
: this.options.staleIssueMessage;
const staleLabel: string = isPr
? this.options.stalePrLabel
: this.options.staleIssueLabel;
const exemptLabels = IssueProcessor.parseCommaSeparatedString(
isPr ? this.options.exemptPrLabels : this.options.exemptIssueLabels
);
const issueType: string = isPr ? 'pr' : 'issue';
if (!staleMessage) {
core.debug(`Skipping ${issueType} due to empty stale message`);
continue;
}
if (issue.state === 'closed') {
core.debug(`Skipping ${issueType} because it is closed`);
continue; // don't process closed issues
}
if (issue.locked) {
core.debug(`Skipping ${issueType} because it is locked`);
continue; // don't process locked issues
}
if (
exemptLabels.some((exemptLabel: string) =>
IssueProcessor.isLabeled(issue, exemptLabel)
)
) {
core.debug(`Skipping ${issueType} because it has an exempt label`);
continue; // don't process exempt issues
}
// does this issue have a stale label?
let isStale = IssueProcessor.isLabeled(issue, staleLabel);
// determine if this issue needs to be marked stale first
if (
!isStale &&
!IssueProcessor.updatedSince(
issue.updated_at,
this.options.daysBeforeStale
)
) {
core.debug(
`Marking ${issueType} stale because it was last updated on ${issue.updated_at} and it does not have a stale label`
);
await this.markStale(issue, staleMessage, staleLabel);
this.operationsLeft -= 2;
isStale = true; // this issue is now considered stale
}
// process any issues marked stale (including the issue above, if it was marked)
if (isStale) {
core.debug(`Found a stale ${issueType}`);
await this.processStaleIssue(issue, issueType, staleLabel);
}
}
// do the next batch
return this.processIssues(page + 1);
}
// handle all of the stale issue logic when we find a stale issue
private async processStaleIssue(
issue: Issue,
issueType: string,
staleLabel: string
) {
if (this.options.daysBeforeClose < 0) {
return; // nothing to do because we aren't closing stale issues
}
const markedStaleOn: string | undefined = await this.getLabelCreationDate(
issue,
staleLabel
);
const issueHasComments: boolean = await this.isIssueStillStale(
issue,
markedStaleOn || issue.updated_at
);
const issueHasUpdate: boolean = IssueProcessor.updatedSince(
issue.updated_at,
this.options.daysBeforeClose
);
if (markedStaleOn) {
core.debug(`Issue #${issue.number} marked stale on: ${markedStaleOn}`);
}
else {
core.debug(`Issue #${issue.number} is not marked stale, but last update of ${issue.updated_at} is older than ${this.options.daysBeforeStale} days`);
}
core.debug(`Issue #${issue.number} has been updated: ${issueHasUpdate}`);
core.debug(`Issue #${issue.number} has been commented on: ${issueHasComments}`);
if (!issueHasComments && !issueHasUpdate) {
core.debug(
`Closing ${issueType} because it was last updated on ${issue.updated_at}`
);
await this.closeIssue(issue);
} else {
if (this.options.removeStaleWhenUpdated) {
await this.removeLabel(issue, staleLabel);
}
core.debug(`Ignoring stale ${issueType} because it was updated recently`);
}
}
// checks to see if a given issue is still stale (has had activity on it)
private async isIssueStillStale(
issue: Issue,
sinceDate: string
): Promise<boolean> {
core.debug(
`Checking for comments on issue #${issue.number} since ${sinceDate} to see if it is still stale`
);
if (!sinceDate) {
return true; // if no date was provided then the issue was marked stale a long time ago
}
this.operationsLeft -= 1;
// find any comments since the stale label
const comments = await this.listIssueComments(issue.number, sinceDate);
// if there are any user comments returned, issue is not stale anymore
return comments.filter(comment => comment.user.type === 'User').length > 0;
}
// grab comments for an issue since a given date
private async listIssueComments(
issueNumber: number,
sinceDate: string
): Promise<Comment[]> {
// find any comments since date on the given issue
const comments = await this.client.issues.listComments({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issueNumber,
since: sinceDate
});
return comments.data;
}
// grab issues from github in baches of 100
private async getIssues(page: number): Promise<Issue[]> {
const issueResult: OctoKitIssueList = await this.client.issues.listForRepo({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
state: 'open',
labels: this.options.onlyLabels,
per_page: 100,
page
});
return issueResult.data;
}
// Mark an issue as stale with a comment and a label
private async markStale(
issue: Issue,
staleMessage: string,
staleLabel: string
): Promise<void> {
core.debug(`Marking issue #${issue.number} - ${issue.title} as stale`);
this.staleIssues.push(issue);
this.operationsLeft -= 2;
if (this.options.debugOnly) {
return;
}
await this.client.issues.createComment({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
body: staleMessage
});
await this.client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
labels: [staleLabel]
});
}
// Close an issue based on staleness
private async closeIssue(issue: Issue): Promise<void> {
core.debug(
`Closing issue #${issue.number} - ${issue.title} for being stale`
);
this.closedIssues.push(issue);
this.operationsLeft -= 1;
if (this.options.debugOnly) {
return;
}
await this.client.issues.update({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
state: 'closed'
});
}
// Remove a label from an issue
private async removeLabel(issue: Issue, label: string): Promise<void> {
core.debug(
`Removing label ${label} from issue #${issue.number} - ${issue.title}`
);
this.removedLabelIssues.push(issue);
this.operationsLeft -= 1;
if (this.options.debugOnly) {
return;
}
await this.client.issues.removeLabel({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
name: encodeURIComponent(label) // A label can have a "?" in the name
});
}
// returns the creation date of a given label on an issue (or nothing if no label existed)
///see https://developer.github.com/v3/activity/events/
private async getLabelCreationDate(
issue: Issue,
label: string
): Promise<string | undefined> {
core.debug(`Checking for label ${label} on issue #${issue.number}`);
this.operationsLeft -= 1;
const options = this.client.issues.listEvents.endpoint.merge({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
per_page: 100,
issue_number: issue.number
});
const events: IssueEvent[] = await this.client.paginate(options);
const reversedEvents = events.reverse();
const staleLabeledEvent = reversedEvents.find(
event => event.event === 'labeled' && event.label.name === label
);
if (!staleLabeledEvent) {
// Must be old rather than labeled
return undefined;
}
return staleLabeledEvent.created_at;
}
private static isLabeled(issue: Issue, label: string): boolean {
const labelComparer: (l: Label) => boolean = l =>
label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0;
return issue.labels.filter(labelComparer).length > 0;
}
private static updatedSince(timestamp: string, num_days: number): boolean {
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated =
new Date().getTime() - new Date(timestamp).getTime();
return millisSinceLastUpdated < daysInMillis;
}
private static parseCommaSeparatedString(s: string): string[] {
// String.prototype.split defaults to [''] when called on an empty string
// In this case, we'd prefer to just return an empty array indicating no labels
if (!s.length) return [];
return s.split(',').map(l => l.trim());
}
}

View File

@@ -1,155 +1,19 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import {Octokit} from '@octokit/rest';
type Issue = Octokit.IssuesListForRepoResponseItem;
type IssueLabel = Octokit.IssuesListForRepoResponseItemLabelsItem;
interface Args {
repoToken: string;
staleIssueMessage: string;
stalePrMessage: string;
daysBeforeStale: number;
daysBeforeClose: number;
staleIssueLabel: string;
exemptIssueLabel: string;
stalePrLabel: string;
exemptPrLabel: string;
onlyLabels: string;
operationsPerRun: number;
}
import {IssueProcessor, IssueProcessorOptions} from './IssueProcessor';
async function run(): Promise<void> {
try {
const args = getAndValidateArgs();
const client = new github.GitHub(args.repoToken);
await processIssues(client, args, args.operationsPerRun);
const processor: IssueProcessor = new IssueProcessor(args);
await processor.processIssues();
} catch (error) {
core.error(error);
core.setFailed(error.message);
}
}
async function processIssues(
client: github.GitHub,
args: Args,
operationsLeft: number,
page: number = 1
): Promise<number> {
const issues = await client.issues.listForRepo({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
state: 'open',
labels: args.onlyLabels,
per_page: 100,
page
});
operationsLeft -= 1;
if (issues.data.length === 0 || operationsLeft === 0) {
return operationsLeft;
}
for (const issue of issues.data.values()) {
core.debug(`found issue: ${issue.title} last updated ${issue.updated_at}`);
const isPr = !!issue.pull_request;
const staleMessage = isPr ? args.stalePrMessage : args.staleIssueMessage;
if (!staleMessage) {
core.debug(`skipping ${isPr ? 'pr' : 'issue'} due to empty message`);
continue;
}
const staleLabel = isPr ? args.stalePrLabel : args.staleIssueLabel;
const exemptLabel = isPr ? args.exemptPrLabel : args.exemptIssueLabel;
if (exemptLabel && isLabeled(issue, exemptLabel)) {
continue;
} else if (isLabeled(issue, staleLabel)) {
if (
args.daysBeforeClose >= 0 &&
wasLastUpdatedBefore(issue, args.daysBeforeClose)
) {
operationsLeft -= await closeIssue(client, issue);
} else {
continue;
}
} else if (wasLastUpdatedBefore(issue, args.daysBeforeStale)) {
operationsLeft -= await markStale(
client,
issue,
staleMessage,
staleLabel
);
}
if (operationsLeft <= 0) {
core.warning(
`performed ${args.operationsPerRun} operations, exiting to avoid rate limit`
);
return 0;
}
}
return await processIssues(client, args, operationsLeft, page + 1);
}
function isLabeled(issue: Issue, label: string): boolean {
const labelComparer: (l: IssueLabel) => boolean = l =>
label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0;
return issue.labels.filter(labelComparer).length > 0;
}
function wasLastUpdatedBefore(issue: Issue, num_days: number): boolean {
const daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated =
new Date().getTime() - new Date(issue.updated_at).getTime();
return millisSinceLastUpdated >= daysInMillis;
}
async function markStale(
client: github.GitHub,
issue: Issue,
staleMessage: string,
staleLabel: string
): Promise<number> {
core.debug(`marking issue${issue.title} as stale`);
await client.issues.createComment({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
body: staleMessage
});
await client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
labels: [staleLabel]
});
return 2; // operations performed
}
async function closeIssue(
client: github.GitHub,
issue: Issue
): Promise<number> {
core.debug(`closing issue ${issue.title} for being stale`);
await client.issues.update({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: issue.number,
state: 'closed'
});
return 1; // operations performed
}
function getAndValidateArgs(): Args {
function getAndValidateArgs(): IssueProcessorOptions {
const args = {
repoToken: core.getInput('repo-token', {required: true}),
staleIssueMessage: core.getInput('stale-issue-message'),
@@ -161,13 +25,17 @@ function getAndValidateArgs(): Args {
core.getInput('days-before-close', {required: true})
),
staleIssueLabel: core.getInput('stale-issue-label', {required: true}),
exemptIssueLabel: core.getInput('exempt-issue-label'),
exemptIssueLabels: core.getInput('exempt-issue-labels'),
stalePrLabel: core.getInput('stale-pr-label', {required: true}),
exemptPrLabel: core.getInput('exempt-pr-label'),
exemptPrLabels: core.getInput('exempt-pr-labels'),
onlyLabels: core.getInput('only-labels'),
operationsPerRun: parseInt(
core.getInput('operations-per-run', {required: true})
)
),
removeStaleWhenUpdated: !(
core.getInput('remove-stale-when-updated') === 'false'
),
debugOnly: core.getInput('debug-only') === 'true'
};
for (const numberInput of [

View File

@@ -6,7 +6,8 @@
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
//"sourceMap": true
},
"exclude": ["node_modules", "**/*.test.ts"]
}