Convert action to TypeScript

This commit is contained in:
Nick Alteen
2025-02-19 13:47:20 -05:00
parent cbfc30b14e
commit ee654d9b4e
22 changed files with 47408 additions and 443 deletions

View File

@@ -1,7 +0,0 @@
FROM node:20.10-buster-slim
COPY . .
RUN npm install --production
ENTRYPOINT ["node", "/lib/main.js"]

View File

@@ -1,6 +1,18 @@
# First Interaction
An action for filtering pull requests and issues from first-time contributors.
[![Super-Linter](https://github.com/actions/first-interaction/actions/workflows/linter.yml/badge.svg)](https://github.com/super-linter/super-linter)
![CI](https://github.com/actions/first-interaction/actions/workflows/ci.yml/badge.svg)
[![Check dist/](https://github.com/actions/first-interaction/actions/workflows/check-dist.yml/badge.svg)](https://github.com/actions/first-interaction/actions/workflows/check-dist.yml)
[![CodeQL](https://github.com/actions/first-interaction/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/actions/first-interaction/actions/workflows/codeql-analysis.yml)
[![Coverage](./badges/coverage.svg)](./badges/coverage.svg)
An action for filtering pull requests (PRs) and issues from first-time
contributors.
When a first-time contributor opens a PR or issue, this action will add a
comment to the PR or issue with a message of your choice. This action is useful
for welcoming first-time contributors to your project and providing them with
information about how to contribute effectively.
## Usage
@@ -9,23 +21,32 @@ See [action.yml](action.yml)
```yaml
name: Greetings
on: [pull_request, issues]
on:
pull_request:
types:
- opened
issues:
types:
- opened
permissions:
issues: write
pull-requests: write
jobs:
greeting:
name: Greet First-Time Contributors
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
# Message with markdown.
This is the message that will be displayed on users' first issue.
pr-message: |
Message that will be displayed on users' first pr.
Look, a `code block` for markdown.
- uses: actions/first-interaction@vX.Y.Z # Set this to the latest release
with:
issue-message: |
# Issue Message with Markdown
This is the message that will be displayed!
pr-message: |
# PR Message with Markdown
This is the message that will be displayed!
```
## License
The scripts and documentation in this project are released under the [MIT License](LICENSE)

View File

@@ -0,0 +1,10 @@
import type * as core from '@actions/core'
import { jest } from '@jest/globals'
export const debug = jest.fn<typeof core.debug>()
export const error = jest.fn<typeof core.error>()
export const info = jest.fn<typeof core.info>()
export const getInput = jest.fn<typeof core.getInput>()
export const setOutput = jest.fn<typeof core.setOutput>()
export const setFailed = jest.fn<typeof core.setFailed>()
export const warning = jest.fn<typeof core.warning>()

View File

@@ -0,0 +1,27 @@
import * as octokit from '../@octokit/rest.js'
export const getOctokit = () => octokit
export const context = {
eventName: 'pull_request',
issue: {
number: 10
},
payload: {
number: 10,
issue: {
number: 10
},
pull_request: {
number: 10
},
sender: {
login: 'mona'
}
},
action: 'opened',
repo: {
owner: 'actions',
repo: 'first-interaction'
}
}

View File

@@ -0,0 +1,20 @@
import { jest } from '@jest/globals'
import { Endpoints } from '@octokit/types'
export const graphql = jest.fn()
export const paginate = jest.fn()
export const rest = {
issues: {
createComment:
jest.fn<
() => Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']
>(),
listForRepo:
jest.fn<() => Endpoints['GET /repos/{owner}/{repo}/issues']['response']>()
},
pulls: {
list: jest.fn<
() => Endpoints['GET /repos/{owner}/{repo}/pulls']['response']
>()
}
}

304
__tests__/main.test.ts Normal file
View File

@@ -0,0 +1,304 @@
import { jest } from '@jest/globals'
import * as core from '../__fixtures__/@actions/core.js'
import * as github from '../__fixtures__/@actions/github.js'
import * as octokit from '../__fixtures__/@octokit/rest.js'
jest.unstable_mockModule('@actions/core', () => core)
jest.unstable_mockModule('@actions/github', () => github)
jest.unstable_mockModule('@octokit/rest', async () => {
class Octokit {
constructor() {
return octokit
}
}
return {
Octokit
}
})
const main = await import('../src/main.js')
const { Octokit } = await import('@octokit/rest')
const mocktokit = jest.mocked(new Octokit())
describe('main.ts', () => {
afterEach(() => {
jest.resetAllMocks()
})
beforeEach(() => {
// "Reset" the github context.
github.context.eventName = 'pull_request'
github.context.action = 'opened'
github.context.payload.issue = undefined as any
github.context.payload.pull_request = {
number: 10
}
github.context.payload.sender = {
login: 'mona'
}
// Set the action's inputs as return values from core.getInput().
core.getInput
.mockReturnValueOnce('ISSUE_MESSAGE')
.mockReturnValueOnce('PR_MESSAGE')
.mockReturnValueOnce('REPO_TOKEN')
})
describe('run()', () => {
it('Skips invalid events', async () => {
github.context.eventName = 'push'
await main.run()
expect(core.info).toHaveBeenCalledWith('Skipping...Not an Issue/PR Event')
})
it('Skips invalid actions', async () => {
github.context.action = 'edited'
await main.run()
expect(core.info).toHaveBeenCalledWith('Skipping...Not an Opened Event')
})
it('Fails if no sender is present', async () => {
github.context.payload.sender = undefined as any
await main.run()
expect(core.setFailed).toHaveBeenCalledWith(
'Internal Error...No Sender Provided by GitHub'
)
})
it('Fails if neither PR nor issue are provided', async () => {
github.context.payload.issue = undefined as any
github.context.payload.pull_request = undefined as any
await main.run()
expect(core.setFailed).toHaveBeenCalledWith(
'Internal Error...No Issue or PR Provided by GitHub'
)
})
it('Fails if both PR and issue are provided', async () => {
github.context.payload.issue = {
number: 20
}
github.context.payload.pull_request = {
number: 10
}
await main.run()
expect(core.setFailed).toHaveBeenCalledWith(
'Internal Error...Both Issue and PR Provided by GitHub'
)
})
it('Skips adding a message if this is not the first contribution', async () => {
mocktokit.paginate
// Issues
.mockResolvedValueOnce([
{
number: 10
},
{
number: 5
}
])
// PRs
.mockResolvedValueOnce([
{
number: 3
}
])
await main.run()
expect(core.info).toHaveBeenCalledWith(
'Skipping...Not First Contribution'
)
expect(mocktokit.rest.issues.createComment).not.toHaveBeenCalled()
})
it('Adds an issue message if this is the first contribution', async () => {
github.context.payload.issue = {
number: 10
}
github.context.payload.pull_request = undefined as any
mocktokit.paginate
// Issues
.mockResolvedValueOnce([
{
number: 10
}
])
// PRs
.mockResolvedValueOnce([])
await main.run()
expect(mocktokit.rest.issues.createComment).toHaveBeenCalled()
})
it('Adds a PR message if this is the first contribution', async () => {
github.context.payload.issue = undefined as any
github.context.payload.pull_request = {
number: 10
}
mocktokit.paginate
// Issues
.mockResolvedValueOnce([])
// PRs
.mockResolvedValueOnce([
{
number: 10
}
])
await main.run()
expect(mocktokit.rest.issues.createComment).toHaveBeenCalled()
})
})
describe('isFirstIssue()', () => {
beforeEach(() => {
github.context.payload.issue = {
number: 10
}
})
it('Returns true if no issues are present', async () => {
mocktokit.paginate.mockResolvedValueOnce([])
const result = await main.isFirstIssue(mocktokit)
expect(result).toBe(true)
})
it('Returns true if only the current issue is present', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
}
])
const result = await main.isFirstIssue(mocktokit)
expect(result).toBe(true)
})
it('Returns false if older issues are present', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
},
{
number: 5
}
])
const result = await main.isFirstIssue(mocktokit)
expect(result).toBe(false)
})
it('Ignores pull requests', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
},
{
number: 5,
pull_request: {}
}
])
const result = await main.isFirstIssue(mocktokit)
expect(result).toBe(true)
})
it('Returns false if there is an error', async () => {
mocktokit.paginate.mockRejectedValueOnce(new Error('Error'))
const result = await main.isFirstIssue(mocktokit)
expect(result).toBe(false)
})
})
describe('isFirstPullRequest()', () => {
beforeEach(() => {
github.context.payload.pull_request = {
number: 10
}
})
it('Returns true if no PRs are present', async () => {
mocktokit.paginate.mockResolvedValueOnce([])
const result = await main.isFirstPullRequest(mocktokit)
expect(result).toBe(true)
})
it('Returns true if only the current PR is present', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
}
])
const result = await main.isFirstPullRequest(mocktokit)
expect(result).toBe(true)
})
it('Returns false if older PRs are present', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
},
{
number: 5
}
])
const result = await main.isFirstPullRequest(mocktokit)
expect(result).toBe(false)
})
it('Does not ignore pull requests', async () => {
mocktokit.paginate.mockResolvedValueOnce([
{
number: 10
},
{
number: 5,
pull_request: {}
}
])
const result = await main.isFirstPullRequest(mocktokit)
expect(result).toBe(false)
})
it('Returns false if there is an error', async () => {
mocktokit.paginate.mockRejectedValueOnce(new Error('Error'))
const result = await main.isFirstPullRequest(mocktokit)
expect(result).toBe(false)
})
})
})

View File

@@ -1,15 +1,17 @@
name: 'First interaction'
description: 'Greet new contributors when they create their first issue or open their first pull request'
author: 'GitHub'
name: First Interaction
description: Greet first-time contributors when they open an issue or PR
author: GitHub
inputs:
repo-token:
description: 'Token for the repository. Can be passed in using {{ secrets.GITHUB_TOKEN }}'
issue_message:
description: Comment to post on an individual's first issue
pr_message:
description: Comment to post on an individual's first pull request
repo_token:
description: Token with permissions to post issue and PR comments
required: true
issue-message:
description: 'Comment to post on an individual''s first issue'
pr-message:
description: 'Comment to post on an individual''s first pull request'
default: ${{ github.token }}
runs:
using: 'docker'
image: 'Dockerfile'
using: node20
main: dist/index.js

1
badges/coverage.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="106" height="20" role="img" aria-label="Coverage: 100%"><title>Coverage: 100%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="106" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="63" height="20" fill="#555"/><rect x="63" width="43" height="20" fill="#4c1"/><rect width="106" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="325" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="530">Coverage</text><text x="325" y="140" transform="scale(.1)" fill="#fff" textLength="530">Coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="330">100%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="330">100%</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
dist/index.d.ts generated vendored Normal file
View File

@@ -0,0 +1 @@
export {};

34859
dist/index.js generated vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/index.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

16
dist/main.d.ts generated vendored Normal file
View File

@@ -0,0 +1,16 @@
import { Octokit } from '@octokit/rest';
export declare function run(): Promise<void>;
/**
* Checks if this is the user's first issue.
*
* @param octokit Octokit instance
* @returns true if this is the user's first issue
*/
export declare function isFirstIssue(octokit: Octokit): Promise<boolean>;
/**
* Checks if this is the user's first pull request.
*
* @param octokit Octokit instance
* @returns true if this is the user's first pull request
*/
export declare function isFirstPullRequest(octokit: Octokit): Promise<boolean>;

View File

@@ -1,155 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function () { return m[k]; } });
}) : (function (o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function (o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function (o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
const core = __importStar(require("@actions/core"));
const github = __importStar(require("@actions/github"));
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
const issueMessage = core.getInput('issue-message');
const prMessage = core.getInput('pr-message');
if (!issueMessage && !prMessage) {
throw new Error('Action must have at least one of issue-message or pr-message set');
}
// Get client and context
const client = github.getOctokit(core.getInput('repo-token', { required: true }));
const context = github.context;
if (context.payload.action !== 'opened') {
console.log('No issue or PR was opened, skipping');
return;
}
// Do nothing if its not a pr or issue
const isIssue = !!context.payload.issue;
if (!isIssue && !context.payload.pull_request) {
console.log('The event that triggered this action was not a pull request or issue, skipping.');
return;
}
// Do nothing if its not their first contribution
console.log('Checking if its the users first contribution');
if (!context.payload.sender) {
throw new Error('Internal error, no sender provided by GitHub');
}
const sender = context.payload.sender.login;
const issue = context.issue;
let firstContribution = false;
if (isIssue) {
firstContribution = yield isFirstIssue(client, issue.owner, issue.repo, sender, issue.number);
}
else {
firstContribution = yield isFirstPull(client, issue.owner, issue.repo, sender, issue.number);
}
if (!firstContribution) {
console.log('Not the users first contribution');
return;
}
// Do nothing if no message set for this type of contribution
const message = isIssue ? issueMessage : prMessage;
if (!message) {
console.log('No message provided for this type of contribution');
return;
}
const issueType = isIssue ? 'issue' : 'pull request';
// Add a comment to the appropriate place
console.log(`Adding message: ${message} to ${issueType} ${issue.number}`);
if (isIssue) {
yield client.rest.issues.createComment({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
body: message
});
}
else {
yield client.rest.pulls.createReview({
owner: issue.owner,
repo: issue.repo,
pull_number: issue.number,
body: message,
event: 'COMMENT'
});
}
}
catch (error) {
core.setFailed(error.message);
return;
}
});
}
function isFirstIssue(client, owner, repo, sender, curIssueNumber) {
return __awaiter(this, void 0, void 0, function* () {
const { status, data: issues } = yield client.rest.issues.listForRepo({
owner: owner,
repo: repo,
creator: sender,
state: 'all'
});
if (status !== 200) {
throw new Error(`Received unexpected API status code ${status}`);
}
if (issues.length === 0) {
return true;
}
for (const issue of issues) {
if (issue.number < curIssueNumber && !issue.pull_request) {
return false;
}
}
return true;
});
}
// No way to filter pulls by creator
function isFirstPull(client, owner, repo, sender, curPullNumber, page = 1) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
// Provide console output if we loop for a while.
console.log('Checking...');
const { status, data: pulls } = yield client.rest.pulls.list({
owner: owner,
repo: repo,
per_page: 100,
page: page,
state: 'all'
});
if (status !== 200) {
throw new Error(`Received unexpected API status code ${status}`);
}
if (pulls.length === 0) {
return true;
}
for (const pull of pulls) {
const login = (_a = pull.user) === null || _a === void 0 ? void 0 : _a.login;
if (login === sender && pull.number < curPullNumber) {
return false;
}
}
return yield isFirstPull(client, owner, repo, sender, curPullNumber, page + 1);
});
}
run();

11879
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,77 @@
{
"name": "first-interaction-action",
"version": "1.3.0",
"description": "An action for greeting first time contributors.",
"main": "lib/main.js",
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"test": "jest"
},
"version": "2.0.0",
"author": "GitHub",
"type": "module",
"private": true,
"homepage": "https://github.com/actions/first-interaction",
"repository": {
"type": "git",
"url": "git+https://github.com/actions/first-interaction.git"
},
"keywords": [
"actions",
"container",
"toolkit",
"first",
"interaction"
],
"author": "GitHub",
"license": "ISC",
"bugs": {
"url": "https://github.com/actions/first-interaction/issues"
},
"homepage": "https://github.com/actions/first-interaction#readme",
"keywords": [
"actions",
"first",
"interaction"
],
"exports": {
".": "./dist/index.js"
},
"engines": {
"node": ">=20"
},
"scripts": {
"bundle": "npm run format:write && npm run package",
"ci-test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
"coverage": "npx make-coverage-badge --output-path ./badges/coverage.svg",
"format:write": "npx prettier --write .",
"format:check": "npx prettier --check .",
"lint": "npx eslint .",
"local-action": "npx local-action . src/main.ts .env",
"package": "npx rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
"package:watch": "npm run package -- --watch",
"test": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 npx jest",
"all": "npm run format:write && npm run lint && npm run test && npm run coverage && npm run package"
},
"license": "MIT",
"dependencies": {
"@actions/core": "file:toolkit/actions-core-1.10.0.tgz",
"@actions/exec": "file:toolkit/actions-exec-1.1.1.tgz",
"@actions/github": "file:toolkit/actions-github-5.1.1.tgz",
"@actions/http-client": "^2.2.0",
"@actions/io": "file:toolkit/actions-io-1.1.2.tgz",
"@actions/tool-cache": "file:toolkit/actions-tool-cache-2.0.1.tgz",
"@octokit/rest": "file:toolkit/octokit-rest.js-20.0.2.tgz"
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.0",
"@octokit/rest": "^21.1.1"
},
"devDependencies": {
"@types/jest": "^24.0.13",
"@types/node": "^12.0.4",
"jest": "^24.8.0",
"jest-circus": "^24.7.1",
"prettier": "^1.17.1",
"ts-jest": "^24.0.2",
"typescript": "^3.5.1"
"@eslint/compat": "^1.2.6",
"@github/local-action": "^2.6.2",
"@jest/globals": "^29.7.0",
"@octokit/types": "^13.8.0",
"@rollup/plugin-commonjs": "^28.0.2",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@typescript-eslint/eslint-plugin": "^8.24.1",
"@typescript-eslint/parser": "^8.24.1",
"eslint": "^9.20.1",
"eslint-config-prettier": "^10.0.1",
"eslint-import-resolver-typescript": "^3.8.2",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-prettier": "^5.2.3",
"jest": "^29.7.0",
"make-coverage-badge": "^1.2.0",
"prettier": "^3.5.1",
"prettier-eslint": "^16.3.0",
"rollup": "^4.34.8",
"ts-jest": "^29.2.5",
"ts-jest-resolver": "^2.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "*"
}
}
}

22
rollup.config.ts Normal file
View File

@@ -0,0 +1,22 @@
// See: https://rollupjs.org/introduction/
import commonjs from '@rollup/plugin-commonjs'
import nodeResolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
const config = {
input: 'src/index.ts',
output: {
esModule: true,
file: 'dist/index.js',
format: 'es',
sourcemap: true
},
plugins: [
typescript({ sourceMap: true }),
nodeResolve({ preferBuiltins: true }),
commonjs()
]
}
export default config

8
src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* The entrypoint for the action. This file simply imports and runs the action's
* main logic.
*/
import { run } from './main.js'
/* istanbul ignore next */
run()

View File

@@ -1,170 +1,111 @@
import * as core from '@actions/core';
import * as github from '@actions/github';
import * as core from '@actions/core'
import * as github from '@actions/github'
import { Octokit } from '@octokit/rest'
async function run() {
export async function run() {
core.info('Running actions/first-interaction!')
// Skip if this is not an issue or PR event.
if (
github.context.eventName !== 'issues' &&
github.context.eventName !== 'pull_request'
)
return core.info('Skipping...Not an Issue/PR Event')
// Skip if this is not an issue/PR open event.
if (github.context.action !== 'opened')
return core.info('Skipping...Not an Opened Event')
// Confirm the sender data is present.
if (!github.context.payload.sender)
return core.setFailed('Internal Error...No Sender Provided by GitHub')
// Check if this is an issue or PR event.
const isIssue = github.context.payload.issue !== undefined
const isPullRequest = github.context.payload.pull_request !== undefined
// Confirm that only one of the two is present.
if (!isIssue && !isPullRequest)
return core.setFailed('Internal Error...No Issue or PR Provided by GitHub')
if (isIssue && isPullRequest)
return core.setFailed(
'Internal Error...Both Issue and PR Provided by GitHub'
)
// Get the action inputs.
const issueMessage: string = core.getInput('issue_message', {
required: true
})
const prMessage: string = core.getInput('pr_message', { required: true })
const octokit = new Octokit({
auth: core.getInput('repo_token', { required: true })
})
// Check if this is the user's first contribution.
if (!(await isFirstIssue(octokit)) && !(await isFirstPullRequest(octokit)))
return core.info('Skipping...Not First Contribution')
core.info(`Adding Message to #${github.context.issue.number}`)
await octokit.rest.issues.createComment({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: github.context.issue.number,
body: isIssue ? issueMessage : prMessage
})
}
/**
* Checks if this is the user's first issue.
*
* @param octokit Octokit instance
* @returns true if this is the user's first issue
*/
export async function isFirstIssue(octokit: Octokit): Promise<boolean> {
try {
const issueMessage: string = core.getInput('issue-message');
const prMessage: string = core.getInput('pr-message');
if (!issueMessage && !prMessage) {
throw new Error(
'Action must have at least one of issue-message or pr-message set'
);
}
// Get client and context
const client = github.getOctokit(
core.getInput('repo-token', {required: true})
);
const context = github.context;
const issues = await octokit.paginate(octokit.rest.issues.listForRepo, {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
creator: github.context.payload.sender!.login,
state: 'all'
})
if (context.payload.action !== 'opened') {
console.log('No issue or PR was opened, skipping');
return;
}
// Do nothing if its not a pr or issue
const isIssue: boolean = !!context.payload.issue;
if (!isIssue && !context.payload.pull_request) {
console.log(
'The event that triggered this action was not a pull request or issue, skipping.'
);
return;
}
// Do nothing if its not their first contribution
console.log('Checking if its the users first contribution');
if (!context.payload.sender) {
throw new Error('Internal error, no sender provided by GitHub');
}
const sender: string = context.payload.sender!.login;
const issue: {owner: string; repo: string; number: number} = context.issue;
let firstContribution: boolean = false;
if (isIssue) {
firstContribution = await isFirstIssue(
client,
issue.owner,
issue.repo,
sender,
issue.number
);
} else {
firstContribution = await isFirstPull(
client,
issue.owner,
issue.repo,
sender,
issue.number
);
}
if (!firstContribution) {
console.log('Not the users first contribution');
return;
}
// Do nothing if no message set for this type of contribution
const message: string = isIssue ? issueMessage : prMessage;
if (!message) {
console.log('No message provided for this type of contribution');
return;
}
const issueType: string = isIssue ? 'issue' : 'pull request';
// Add a comment to the appropriate place
console.log(`Adding message: ${message} to ${issueType} ${issue.number}`);
if (isIssue) {
await client.rest.issues.createComment({
owner: issue.owner,
repo: issue.repo,
issue_number: issue.number,
body: message
});
} else {
await client.rest.pulls.createReview({
owner: issue.owner,
repo: issue.repo,
pull_number: issue.number,
body: message,
event: 'COMMENT'
});
}
return (
issues
// Filter out PRs.
.filter((issue) => issue.pull_request === undefined)
// Filter out any issue that are newer than the current issue.
.filter((issue) => issue.number < github.context.issue.number)
.length === 0
)
} catch (error) {
core.setFailed((error as any).message);
return;
core.setFailed((error as any).message)
return false
}
}
async function isFirstIssue(
client: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
sender: string,
curIssueNumber: number
): Promise<boolean> {
const {status, data: issues} = await client.rest.issues.listForRepo({
owner: owner,
repo: repo,
creator: sender,
state: 'all'
});
/**
* Checks if this is the user's first pull request.
*
* @param octokit Octokit instance
* @returns true if this is the user's first pull request
*/
export async function isFirstPullRequest(octokit: Octokit): Promise<boolean> {
try {
const pulls = await octokit.paginate(octokit.rest.pulls.list, {
owner: github.context.repo.owner,
repo: github.context.repo.repo,
state: 'all'
})
if (status !== 200) {
throw new Error(`Received unexpected API status code ${status}`);
return (
// Filter out any PRs that are newer than the current one.
pulls.filter((pull) => pull.number < github.context.issue.number)
.length === 0
)
} catch (error) {
core.setFailed((error as any).message)
return false
}
if (issues.length === 0) {
return true;
}
for (const issue of issues) {
if (issue.number < curIssueNumber && !issue.pull_request) {
return false;
}
}
return true;
}
// No way to filter pulls by creator
async function isFirstPull(
client: ReturnType<typeof github.getOctokit>,
owner: string,
repo: string,
sender: string,
curPullNumber: number,
page: number = 1
): Promise<boolean> {
// Provide console output if we loop for a while.
console.log('Checking...');
const {status, data: pulls} = await client.rest.pulls.list({
owner: owner,
repo: repo,
per_page: 100,
page: page,
state: 'all'
});
if (status !== 200) {
throw new Error(`Received unexpected API status code ${status}`);
}
if (pulls.length === 0) {
return true;
}
for (const pull of pulls) {
const login = pull.user?.login;
if (login === sender && pull.number < curPullNumber) {
return false;
}
}
return await isFirstPull(
client,
owner,
repo,
sender,
curPullNumber,
page + 1
);
}
run();

Binary file not shown.

22
tsconfig.base.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": false,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"pretty": true,
"resolveJsonModule": true,
"strict": true,
"strictNullChecks": true,
"target": "ES2022"
}
}

17
tsconfig.eslint.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"noEmit": true
},
"exclude": ["dist", "node_modules"],
"include": [
"__fixtures__",
"__tests__",
"src",
"eslint.config.mjs",
"jest.config.js",
"rollup.config.ts"
]
}

View File

@@ -1,66 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.base.json",
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./lib", /* Redirect output structure to the directory. */
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist"
},
"exclude": [
"node_modules",
"**/*.test.ts"
]
"exclude": ["__fixtures__", "__tests__", "coverage", "dist", "node_modules"],
"include": ["src"]
}