feat: support discussions

Closes #8.
This commit is contained in:
dessant
2021-10-01 18:42:54 +03:00
parent 201c706f66
commit 27a5fe2b54
6 changed files with 391 additions and 112 deletions

View File

@@ -1,7 +1,7 @@
# Label Actions
Label Actions is a GitHub bot that performs certain actions when issues
or pull requests are labeled or unlabeled.
Label Actions is a GitHub bot that performs certain actions when issues,
pull requests or discussions are labeled or unlabeled.
<img width="800" src="https://raw.githubusercontent.com/dessant/label-actions/master/assets/screenshot.png">
@@ -16,7 +16,7 @@ please consider contributing with
## How It Works
The bot performs certain actions when an issue or pull request
The bot performs certain actions when an issue, pull request or discussion
is labeled or unlabeled. No action is taken by default and the bot
must be configured. The following actions are supported:
@@ -34,7 +34,7 @@ must be configured. The following actions are supported:
use one of the [example workflows](#examples) to get started
2. Create the `.github/label-actions.yml` configuration file
based on the [example](#configuring-labels-and-actions) below
3. Start labeling issues and pull requests
3. Start labeling issues, pull requests and discussions
### Inputs
@@ -49,8 +49,9 @@ The bot can be configured using [input parameters](https://docs.github.com/en/ac
- Configuration file path
- Optional, defaults to `.github/label-actions.yml`
- **`process-only`**
- Process label events only for issues or pull requests, value must be
either `issues` or `prs`
- Process label events only for issues, pull requests or discussions,
value must be a comma separated list, list items must be
one of `issues`, `prs` or `discussions`
- Optional, defaults to `''`
### Configuration
@@ -58,8 +59,9 @@ The bot can be configured using [input parameters](https://docs.github.com/en/ac
Labels and actions are specified in a configuration file.
Actions are grouped under label names, and a label name can be prepended
with a `-` sign to declare actions taken when a label is removed
from a thread. Actions can be overridden or declared only for issues
or pull requests by grouping them under the `issues` or `prs` key.
from a thread. Actions can be overridden or declared only for issues,
pull requests or discussions by grouping them under the
`issues`, `prs` or `discussions` key.
#### Actions
@@ -75,17 +77,20 @@ or pull requests by grouping them under the `issues` or `prs` key.
- Remove labels, value must be either a label or a list of labels
- Optional, defaults to `''`
- **`close`**
- Close threads, value must be either `true` or `false`
- Close threads, value must be either `true` or `false`,
ignored for discussions
- Optional, defaults to `false`
- **`reopen`**
- Reopen threads, value must be either `true` or `false`
- Reopen threads, value must be either `true` or `false`,
ignored for discussions
- Optional, defaults to `false`
- **`lock`**
- Lock threads, value must be either `true` or `false`
- Optional, defaults to `false`
- **`lock-reason`**
- Reason for locking threads, value must be one
of `resolved`, `off-topic`, `too heated` or `spam`
of `resolved`, `off-topic`, `too heated` or `spam`,
ignored for discussions
- Optional, defaults to `''`
- **`unlock`**
- Unlock threads, value must be either `true` or `false`
@@ -94,8 +99,8 @@ or pull requests by grouping them under the `issues` or `prs` key.
## Examples
The following workflow will perform the actions specified
in the `.github/label-actions.yml` configuration file when an issue
or pull request is labeled or unlabeled.
in the `.github/label-actions.yml` configuration file when an issue,
pull request or discussion is labeled or unlabeled.
<!-- prettier-ignore -->
```yaml
@@ -106,11 +111,14 @@ on:
types: [labeled, unlabeled]
pull_request:
types: [labeled, unlabeled]
discussion:
types: [labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
discussions: write
jobs:
action:
@@ -133,11 +141,14 @@ on:
types: [labeled, unlabeled]
pull_request:
types: [labeled, unlabeled]
discussion:
types: [labeled, unlabeled]
permissions:
contents: read
issues: write
pull-requests: write
discussions: write
jobs:
action:
@@ -162,14 +173,14 @@ This step will process label events only for issues.
process-only: 'issues'
```
This step will process label events only for pull requests.
This step will process label events only for pull requests and discussions.
<!-- prettier-ignore -->
```yaml
steps:
- uses: dessant/label-actions@v2
with:
process-only: 'prs'
process-only: 'prs, discussions'
```
Unnecessary workflow runs can be avoided by removing the events
@@ -191,7 +202,7 @@ The following example showcases how desired actions may be declared:
```yaml
# Configuration for Label Actions - https://github.com/dessant/label-actions
# Actions taken when the `heated` label is added to issues or pull requests
# The `heated` label is added to issues, pull requests or discussions
heated:
# Post a comment
comment: >
@@ -205,17 +216,18 @@ heated:
prs:
label: 'on hold'
# Actions taken when the `heated` label is removed from issues or pull requests
# The `heated` label is removed from issues, pull requests or discussions
-heated:
# Unlock the thread
unlock: true
# Actions taken when the `wontfix` label is removed from issues or pull requests
# The `wontfix` label is removed from issues
-wontfix:
# Reopen the thread
reopen: true
issues:
# Reopen the issue
reopen: true
# Actions taken when the `feature` label is added to issues
# The `feature` label is added to issues
feature:
issues:
# Post a comment, `{issue-author}` is an optional placeholder
@@ -224,7 +236,7 @@ feature:
# Close the issue
close: true
# Actions taken when the `wip` label is added to pull requests
# The `wip` label is added to pull requests
wip:
prs:
# Add labels
@@ -232,7 +244,7 @@ wip:
- 'on hold'
- 'needs feedback'
# Actions taken when the `wip` label is removed from pull requests
# The `wip` label is removed from pull requests
-wip:
prs:
# Add label
@@ -242,7 +254,13 @@ wip:
- 'on hold'
- 'needs feedback'
# Actions taken when the `pizzazz` label is added to issues or pull requests
# The `solved` label is added to discussions
solved:
discussions:
# Lock the discussion
lock: true
# The `pizzazz` label is added to issues, pull requests or discussions
pizzazz:
# Post comments
comment:

View File

@@ -1,5 +1,5 @@
name: 'Label Actions'
description: 'Perform actions when issues or pull requests are labeled or unlabeled'
description: 'Perform actions when issues, pull requests or discussions are labeled or unlabeled'
author: 'Armin Sebastian'
inputs:
github-token:
@@ -9,7 +9,7 @@ inputs:
description: 'Configuration file path'
default: '.github/label-actions.yml'
process-only:
description: 'Process label events only for issues or pull requests, value must be either `issues` or `prs`'
description: 'Process label events only for issues, pull requests or discussions, value must be a comma separated list, list items must be one of `issues`, `prs` or `discussions`'
default: ''
runs:
using: 'node12'

View File

@@ -1,7 +1,7 @@
{
"name": "label-actions",
"version": "2.1.3",
"description": "A GitHub Action that performs actions when issues or pull requests are labeled or unlabeled.",
"description": "A GitHub Action that performs actions when issues, pull requests or discussions are labeled or unlabeled.",
"author": "Armin Sebastian",
"license": "MIT",
"homepage": "https://github.com/dessant/label-actions",
@@ -38,6 +38,7 @@
"github",
"issues",
"pull requests",
"discussions",
"github labels",
"comment",
"close",

110
src/data.js Normal file
View File

@@ -0,0 +1,110 @@
const addDiscussionCommentQuery = `
mutation ($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
comment {
id
}
}
}
`;
const getLabelQuery = `
query ($owner: String!, $repo: String!, $label: String!) {
repository(owner: $owner, name: $repo) {
label(name: $label) {
id
name
}
}
}
`;
const createLabelQuery = `
mutation ($repositoryId: ID!, $name: String!, $color: String!) {
createLabel(input: {repositoryId: $repositoryId, name: $name, , color: $color}) {
label {
id
name
}
}
}
`;
const getDiscussionLabelsQuery = `
query ($owner: String!, $repo: String!, $discussion: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $discussion) {
number
labels(first: 100) {
nodes {
id
name
}
}
}
}
}
`;
const addLabelsToLabelableQuery = `
mutation ($labelableId: ID!, $labelIds: [ID!]!) {
addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) {
labelable {
labels(first: 0) {
edges {
node {
id
}
}
}
}
}
}
`;
const removeLabelsFromLabelableQuery = `
mutation ($labelableId: ID!, $labelIds: [ID!]!) {
removeLabelsFromLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) {
labelable {
labels(first: 0) {
edges {
node {
id
}
}
}
}
}
}
`;
const lockLockableQuery = `
mutation ($lockableId: ID!) {
lockLockable(input: {lockableId: $lockableId}) {
lockedRecord {
locked
}
}
}
`;
const unlockLockableQuery = `
mutation ($lockableId: ID!) {
unlockLockable(input: {lockableId: $lockableId}) {
unlockedRecord {
locked
}
}
}
`;
module.exports = {
addDiscussionCommentQuery,
getLabelQuery,
createLabelQuery,
getDiscussionLabelsQuery,
addLabelsToLabelableQuery,
removeLabelsFromLabelableQuery,
lockLockableQuery,
unlockLockableQuery
};

View File

@@ -3,6 +3,16 @@ const github = require('@actions/github');
const yaml = require('js-yaml');
const {configSchema, actionSchema} = require('./schema');
const {
addDiscussionCommentQuery,
getLabelQuery,
createLabelQuery,
getDiscussionLabelsQuery,
addLabelsToLabelableQuery,
removeLabelsFromLabelableQuery,
lockLockableQuery,
unlockLockableQuery
} = require('./data');
async function run() {
try {
@@ -33,10 +43,14 @@ class App {
return;
}
const threadType = payload.issue ? 'issue' : 'pr';
const [threadType, threadData] = payload.issue
? ['issue', payload.issue]
: payload.pull_request
? ['pr', payload.pull_request]
: ['discussion', payload.discussion];
const processOnly = this.config['process-only'];
if (processOnly && processOnly !== threadType) {
if (processOnly && !processOnly.includes(threadType)) {
return;
}
@@ -49,10 +63,20 @@ class App {
return;
}
const threadData = payload.issue || payload.pull_request;
const {owner, repo} = github.context.repo;
const issue = {owner, repo, issue_number: threadData.number};
let issue, discussion;
if (threadType === 'discussion') {
discussion = {
node_id: payload.discussion.node_id,
number: payload.discussion.number
};
} else {
issue = {
owner,
repo,
issue_number: threadData.number
};
}
const lock = {
active: threadData.locked,
@@ -61,79 +85,164 @@ class App {
if (actions.comment) {
core.debug('Commenting');
await this.ensureUnlock(issue, lock, async () => {
await this.ensureUnlock({issue, discussion}, lock, async () => {
for (let commentBody of actions.comment) {
commentBody = commentBody.replace(
/{issue-author}/,
threadData.user.login
);
await this.client.rest.issues.createComment({
...issue,
body: commentBody
});
if (threadType === 'discussion') {
await this.client.graphql(addDiscussionCommentQuery, {
discussionId: discussion.node_id,
body: commentBody
});
} else {
await this.client.rest.issues.createComment({
...issue,
body: commentBody
});
}
}
});
}
if (actions.label) {
const currentLabels = threadData.labels.map(label => label.name);
const newLabels = actions.label.filter(
label => !currentLabels.includes(label)
);
if (actions.label || actions.unlabel) {
let currentLabels;
if (threadType === 'discussion') {
({
repository: {
discussion: {
labels: {nodes: currentLabels}
}
}
} = await this.client.graphql(getDiscussionLabelsQuery, {
owner,
repo,
discussion: discussion.number
}));
} else {
currentLabels = threadData.labels;
}
if (newLabels.length) {
core.debug('Labeling');
await this.client.rest.issues.addLabels({
...issue,
labels: newLabels
});
if (actions.label) {
const currentLabelNames = currentLabels.map(label => label.name);
const newLabels = actions.label.filter(
label => !currentLabelNames.includes(label)
);
if (newLabels.length) {
core.debug('Labeling');
if (threadType === 'discussion') {
const labels = [];
for (const labelName of newLabels) {
let {
repository: {label}
} = await this.client.graphql(getLabelQuery, {
owner,
repo,
label: labelName
});
if (!label) {
({
createLabel: {label}
} = await this.client.graphql(createLabelQuery, {
repositoryId: payload.repository.node_id,
name: labelName,
color: 'ffffff',
headers: {Accept: 'application/vnd.github.bane-preview+json'}
}));
}
labels.push(label);
}
await this.client.graphql(addLabelsToLabelableQuery, {
labelableId: discussion.node_id,
labelIds: labels.map(label => label.id)
});
} else {
await this.client.rest.issues.addLabels({
...issue,
labels: newLabels
});
}
}
}
if (actions.unlabel) {
const matchingLabels = currentLabels.filter(label =>
actions.unlabel.includes(label.name)
);
if (matchingLabels.length) {
core.debug('Unlabeling');
if (threadType === 'discussion') {
await this.client.graphql(removeLabelsFromLabelableQuery, {
labelableId: discussion.node_id,
labelIds: matchingLabels.map(label => label.id)
});
} else {
for (const label of matchingLabels) {
await this.client.rest.issues.removeLabel({
...issue,
name: label.name
});
}
}
}
}
}
if (actions.unlabel) {
const currentLabels = threadData.labels.map(label => label.name);
const matchingLabels = currentLabels.filter(label =>
actions.unlabel.includes(label)
);
for (const label of matchingLabels) {
core.debug('Unlabeling');
await this.client.rest.issues.removeLabel({
...issue,
name: label
});
if (threadType !== 'discussion') {
if (
actions.reopen &&
threadData.state === 'closed' &&
!threadData.merged
) {
core.debug('Reopening');
await this.client.rest.issues.update({...issue, state: 'open'});
}
}
if (actions.reopen && threadData.state === 'closed' && !threadData.merged) {
core.debug('Reopening');
await this.client.rest.issues.update({...issue, state: 'open'});
}
if (actions.close && threadData.state === 'open') {
core.debug('Closing');
await this.client.rest.issues.update({...issue, state: 'closed'});
if (actions.close && threadData.state === 'open') {
core.debug('Closing');
await this.client.rest.issues.update({...issue, state: 'closed'});
}
}
if (actions.lock && !threadData.locked) {
core.debug('Locking');
const params = {...issue};
const lockReason = actions['lock-reason'];
if (lockReason) {
Object.assign(params, {
lock_reason: lockReason,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
if (threadType === 'discussion') {
await this.client.graphql(lockLockableQuery, {
lockableId: discussion.node_id
});
} else {
const params = {...issue};
const lockReason = actions['lock-reason'];
if (lockReason) {
Object.assign(params, {
lock_reason: lockReason,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
});
}
await this.client.rest.issues.lock(params);
}
await this.client.rest.issues.lock(params);
}
if (actions.unlock && threadData.locked) {
core.debug('Unlocking');
await this.client.rest.issues.unlock(issue);
if (threadType === 'discussion') {
await this.client.graphql(unlockLockableQuery, {
lockableId: discussion.node_id
});
} else {
await this.client.rest.issues.unlock(issue);
}
}
}
@@ -141,7 +250,13 @@ class App {
if (event === 'unlabeled') {
label = `-${label}`;
}
threadType = threadType === 'issue' ? 'issues' : 'prs';
threadType =
threadType === 'issue'
? 'issues'
: threadType === 'pr'
? 'prs'
: 'discussions';
const actions = this.actions[label];
@@ -155,18 +270,25 @@ class App {
}
}
async ensureUnlock(issue, lock, action) {
async ensureUnlock({issue, discussion}, lock, action) {
if (lock.active) {
if (!lock.hasOwnProperty('reason')) {
const {data: issueData} = await this.client.rest.issues.get({
...issue,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
if (issue) {
if (!lock.hasOwnProperty('reason')) {
const {data: issueData} = await this.client.rest.issues.get({
...issue,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
});
lock.reason = issueData.active_lock_reason;
}
await this.client.rest.issues.unlock(issue);
} else {
await this.client.graphql(unlockLockableQuery, {
lockableId: discussion.node_id
});
lock.reason = issueData.active_lock_reason;
}
await this.client.rest.issues.unlock(issue);
let actionError;
try {
@@ -175,16 +297,23 @@ class App {
actionError = err;
}
if (lock.reason) {
issue = {
...issue,
lock_reason: lock.reason,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
};
if (issue) {
if (lock.reason) {
issue = {
...issue,
lock_reason: lock.reason,
headers: {
Accept: 'application/vnd.github.sailor-v-preview+json'
}
};
}
await this.client.rest.issues.lock(issue);
} else {
await this.client.graphql(lockLockableQuery, {
lockableId: discussion.node_id
});
}
await this.client.rest.issues.lock(issue);
if (actionError) {
throw actionError;

View File

@@ -1,19 +1,31 @@
const Joi = require('joi');
const extendedJoi = Joi.extend({
type: 'processOnly',
base: Joi.string(),
coerce: {
from: 'string',
method(value) {
value = value.trim();
if (['issues', 'prs'].includes(value)) {
value = value.slice(0, -1);
}
const extendedJoi = Joi.extend(joi => {
return {
type: 'processOnly',
base: joi.array(),
coerce: {
from: 'string',
method(value) {
value = value.trim();
return {value};
if (value) {
value = value
.split(',')
.map(item => {
item = item.trim();
if (['issues', 'prs', 'discussions'].includes(item)) {
item = item.slice(0, -1);
}
return item;
})
.filter(Boolean);
}
return {value};
}
}
}
};
});
const configSchema = Joi.object({
@@ -24,7 +36,15 @@ const configSchema = Joi.object({
.max(200)
.default('.github/label-actions.yml'),
'process-only': extendedJoi.processOnly().valid('issue', 'pr', '').default('')
'process-only': Joi.alternatives().try(
extendedJoi
.processOnly()
.items(Joi.string().valid('issue', 'pr', 'discussion'))
.min(1)
.max(3)
.unique(),
Joi.string().trim().valid('')
)
});
const actions = {
@@ -84,7 +104,8 @@ const actionSchema = Joi.object()
unlabel: actions.unlabel.default(''),
issues: Joi.object().keys(actions),
prs: Joi.object().keys(actions)
prs: Joi.object().keys(actions),
discussions: Joi.object().keys(actions)
})
)
.min(1)