diff --git a/README.md b/README.md index 0183d21..58d6714 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. ```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. ```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: diff --git a/action.yml b/action.yml index 01c2e3c..16894f2 100644 --- a/action.yml +++ b/action.yml @@ -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' diff --git a/package.json b/package.json index b450542..c6578d9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/data.js b/src/data.js new file mode 100644 index 0000000..1a37388 --- /dev/null +++ b/src/data.js @@ -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 +}; diff --git a/src/index.js b/src/index.js index 8f1291f..a4ccb1b 100644 --- a/src/index.js +++ b/src/index.js @@ -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 (newLabels.length) { - core.debug('Labeling'); - await this.client.rest.issues.addLabels({ - ...issue, - labels: newLabels - }); + 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 (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 (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.reopen && threadData.state === 'closed' && !threadData.merged) { - core.debug('Reopening'); - await this.client.rest.issues.update({...issue, state: 'open'}); + 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.close && threadData.state === 'open') { - core.debug('Closing'); - await this.client.rest.issues.update({...issue, state: 'closed'}); + 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.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; diff --git a/src/schema.js b/src/schema.js index 6a5407c..d4f2bf5 100644 --- a/src/schema.js +++ b/src/schema.js @@ -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(); + + 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}; } - - 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)