From 4d5bb690e07f3a459ca42cef2d6af446322b8874 Mon Sep 17 00:00:00 2001 From: Varun-Kolanu Date: Sat, 8 Jun 2024 23:46:31 +0530 Subject: [PATCH] feat: generalized the app to be installed by anyone --- .dockerignore | 12 --- Dockerfile | 8 -- README.md | 113 ++++++++++++++++---- app.yml | 6 +- docs/privacy-policy.md | 45 ++++++++ index.js | 146 -------------------------- package.json | 14 +-- src/app.js | 15 +++ src/handlers/issue_comment_created.js | 138 ++++++++++++++++++++++++ src/handlers/issue_opened.js | 35 ++++++ src/helpers/get_assigned_issues.js | 15 +++ src/helpers/pluralize.js | 9 ++ main.js => src/server.js | 2 +- src/utils/config.js | 36 +++++++ 14 files changed, 397 insertions(+), 197 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile create mode 100644 docs/privacy-policy.md delete mode 100644 index.js create mode 100644 src/app.js create mode 100644 src/handlers/issue_comment_created.js create mode 100644 src/handlers/issue_opened.js create mode 100644 src/helpers/get_assigned_issues.js create mode 100644 src/helpers/pluralize.js rename main.js => src/server.js (71%) create mode 100644 src/utils/config.js diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c961acb..0000000 --- a/.dockerignore +++ /dev/null @@ -1,12 +0,0 @@ -**/node_modules/ -**/.git -**/README.md -**/LICENSE -**/.vscode -**/npm-debug.log -**/coverage -**/.env -**/.editorconfig -**/dist -**/*.pem -Dockerfile diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index bb1a3aa..0000000 --- a/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM node:20-slim -WORKDIR /usr/src/app -COPY package.json package-lock.json ./ -RUN npm ci --production -RUN npm cache clean --force -ENV NODE_ENV="production" -COPY . . -CMD [ "npm", "start" ] diff --git a/README.md b/README.md index cbfb197..a1c4848 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,104 @@ -# csoc-bot +# GitHub Issue Assigner Bot -> A GitHub App built with [Probot](https://github.com/probot/probot) that A bot for managing workflow of CSOC projects +This GitHub bot helps manage issue assignments in a repository by automatically assigning or unassigning issues based on predefined rules and user commands in comments. The bot is implemented using Probot, a framework for building GitHub Apps. -## Setup +# Features + +- Automatic Assignment: Users can request assignment to an issue by commenting with a specific command. +- Limit Assignees: Configure maximum number of assignees allowed per issue. +- Limit Assigned Issues: Set a limit on the number of issues a user can be assigned to at a time in the repository. +- Automatic unassignment: Users can request unassignment to an issue by commenting with a specific command. +- Issue opened comments: Bot greets the user when issue is opened +- Customizable Responses: Customize the bot's responses and prompts using a YAML configuration file. + +# Usage + +1. [Install](https://github.com/apps/issue-assigner) the bot in your account. +2. After installing the bot, create a file `.github/issue-assigner.yml` in the repo and paste the following content: + + ```yml + # Remove or comment the line from yml if you don't need that feature + + # The name of bot you would like to be mentioned by users. {name} will be replaced by the below name + name: "issue-assigner" + + ######################## Issue assignment ######################## + + # Prompt entered by user to request assign the issue to him/her + assign-prompt: "@{name} claim" # For example, @issue-assigner claim + + # Comment from bot if the issue got already assigned to the user requesting + issue-already-assigned: "You have already been assigned to this issue." + + # Maximum number of assignees for an issue + max-assignees: 1 + + # Maximum number of assignees reached for the requested issue + max-assignees-reached: "Sorry, maximum limit for assignees in this issue has reached. Please check other issues or contact a maintainer." + + # Maximum number of open issues a user can have assigned at a time in the repo + max-issues-for-user: 1 + + # If all OK, the comment from bot to tell that issue got assigned + assigned-comment: "This issue has been successfully assigned to you! 🚀" -```sh -# Install dependencies -npm install + ######################## Issue un-assignment ######################## + + # Prompt entered by user to request un-assignment of the issue to him/her + unassign-prompt: "@{name} abandon" + + # If the issue was already not assigned to the user + issue-was-not-assigned: "You were not assigned to this issue." + + # If criteria is matched, the issue will get un-assigned + unassigned-comment: "You have been unassigned to this issue successfully." + + ######################## Issue Opened ######################## + + # If the user who opened issue is NOT a maintainer of the repo + issue-opener-not-maintainer: "Thank you for opening this issue. Maintainers will check and approve if seems to be useful." + + # If the user who opened issue IS a maintainer of the repo + issue-opener-is-maintainer: "Comment '@{name} claim' to get this issue assigned or '@{name} abandon' to get this issue unassigned." + ``` + +3. You can remove a line from yml if you don't need that feature. +4. You can edit the values in the yml to customize the comments from the bot. + +# Contributing + +If you have any suggestions or want to report a bug, open an issue or make a pull request. + +## Prerequisites + +1. Git installed +2. Node installed + +## Setup -# Run the bot -npm start -``` +1. Clone the repository -## Docker + ```bash + git clone https://github.com/Varun-Kolanu/issue-assigner.git + ``` -```sh -# 1. Build container -docker build -t csoc-bot . +2. Open the repo -# 2. Start container -docker run -e APP_ID= -e PRIVATE_KEY= csoc-bot -``` + ```bash + cd issue-assigner + ``` -## Contributing +3. Install dependencies -If you have suggestions for how csoc-bot could be improved, or want to report a bug, open an issue! We'd love all and any contributions. + ```bash + npm i + ``` -For more, check out the [Contributing Guide](CONTRIBUTING.md). +4. Run the app + ```bash + npm start + ``` -## License +# License -[ISC](LICENSE) © 2024 Varun Kolanu +[ISC](https://github.com/Varun-Kolanu/issue-assigner/blob/main/LICENSE) © 2024 Varun Kolanu diff --git a/app.yml b/app.yml index f812025..cc171cf 100644 --- a/app.yml +++ b/app.yml @@ -62,7 +62,7 @@ default_permissions: # Repository contents, commits, branches, downloads, releases, and merges. # https://developer.github.com/v3/apps/permissions/#permission-on-contents - # contents: read + contents: read # Deployments and deployment statuses. # https://developer.github.com/v3/apps/permissions/#permission-on-deployments @@ -124,12 +124,12 @@ default_permissions: # https://developer.github.com/v3/apps/permissions/ # organization_administration: read # The name of the GitHub App. Defaults to the name specified in package.json -name: CSOC Bot +name: Issue Assigner # The homepage of your GitHub App. # url: https://example.com/ # A description of the GitHub App. -description: Bot for CSOC +description: A bot for managing issue assignments # Set to true when your GitHub App is available to the public or false when it is only accessible to the owner of the app. # Default: true # public: false diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md new file mode 100644 index 0000000..68c15f0 --- /dev/null +++ b/docs/privacy-policy.md @@ -0,0 +1,45 @@ +# Privacy Policy for Issue Assigner GitHub App + +## Introduction + +Thank you for using the Issue Assigner GitHub App. This Privacy Policy describes how the App collects, uses, and protects your personal information when you use the App. + +## Data Collection + +The App collects the following data: + +- GitHub usernames of collaborators and issue openers, commentors +- Issue content and comments + +## Data Usage + +The collected data is used to: + +- Automate issue assignments +- Manage issue comments +- Data Sharing + +We do not share your data with third parties, except: + +- With GitHub as necessary to operate the App +- To comply with legal obligations + +## Data Security + +We implement security measures to protect your data, but please note that no method of transmission over the internet or electronic storage is 100% secure. + +## User Rights + +You have the right to: + +- Access your data +- Modify your data +- Delete your data + +## Changes to this Privacy Policy + +We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page. + +## Contact Us + +If you have any questions about this Privacy Policy, please contact us at this [email](mailto:kolanuvarun739@gmail.com). diff --git a/index.js b/index.js deleted file mode 100644 index 50ac38b..0000000 --- a/index.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * This is the main entrypoint to your Probot app - * @param {import('probot').Probot} app - */ - -export default (app, { getRouter }) => { - const router = getRouter("/"); - router.get("/", (_, res) => { - res.send("Welcome to CSOC Bot"); - }); - - app.on("issues.opened", async (context) => { - const { issue, repository } = context.payload; - const owner = repository.owner.login; - const username = issue.user.login; // User who commented - - if (username !== owner) { - const issueComment = context.issue({ - body: `Thank you @${username} for opening this issue. Maintainers will check the issue and approve if issue seems to be useful.`, - }); - return await context.octokit.issues.createComment(issueComment); - } else { - const issueComment = context.issue({ - body: `Comment "@csoc-bot claim" to get this issue assigned or "@csoc-bot abandon" to get this issue unassigned`, - }); - return await context.octokit.issues.createComment(issueComment); - } - }); - - app.on("issue_comment.created", async (context) => { - const { issue, repository, comment } = context.payload; - const owner = repository.owner.login; - const username = comment.user.login; // User who commented - - if (username.includes("csoc-bot")) return; - if (owner === username) return; - - const assignees = issue.assignees.map((assigneeJson) => assigneeJson.login); - const assigneeCount = assignees.length; - - // Check if the comment is requesting assignment - const assignRequest = comment.body - .replace(/\s+/g, " ") // trim whitespace - .toLowerCase() - .includes("@csoc-bot claim"); // Case-insensitive - - const unassignRequest = comment.body - .replace(/\s+/g, " ") - .toLowerCase() - .includes("@csoc-bot abandon"); - - if (unassignRequest) { - if (assignees.includes(username)) { - await context.octokit.issues.removeAssignees( - context.issue({ - assignees: [username], - }) - ); - - await context.octokit.issues.createComment( - context.issue({ - body: `@${username}, You have been unassigned to this issue successfully.`, - }) - ); - } else { - await context.octokit.issues.createComment( - context.issue({ - body: `@${username}, You were not assigned to this issue.`, - }) - ); - } - return; - } - - if (!assignRequest) { - return; // Skip if not requesting assignment - } - - try { - if (assignees.includes(username)) { - return await context.octokit.issues.createComment( - context.issue({ - body: `@${username}, You have already been assigned to this issue.`, - }) - ); - } - - if (assigneeCount >= 1) { - return await context.octokit.issues.createComment( - context.issue({ - body: `Sorry @${username}!, this issue got already assigned. Please check other issues or contact a maintainer.`, - }) - ); - } - - // Get the user's existing assigned issues - const assignedIssues = await getAssignedIssues(context, username); - - // Check if the user has any other assigned issues - if (assignedIssues.length === 0) { - await context.octokit.issues.addAssignees( - context.issue({ - assignees: [username], - }) - ); - return await context.octokit.issues.createComment( - context.issue({ - body: `@${username}, This issue has been successfully assigned to you! 🚀`, - }) - ); - } else { - await context.octokit.issues.createComment( - context.issue({ - body: `@${username}, you already have ${ - assignedIssues.length != 1 ? "these" : "this" - } issue${ - assignedIssues.length != 1 ? "s" : "" - } assigned: ${assignedIssues.map( - (issue) => `[ issue#${issue.number} ](${issue.html_url})` - )} .Abandon your existing issue${ - assignedIssues.length != 1 ? "s" : "" - } or contact a maintainer if you want to get this issue assigned.`, - }) - ); - } - } catch (error) { - console.error(error); - } - }); -}; - -// Helper function to retrieve assigned issues -async function getAssignedIssues(context, username) { - try { - const response = await context.octokit.issues.listForRepo( - context.issue({ - assignee: username, - state: "open", - }) - ); - return response.data; - } catch (error) { - console.error(error); - return []; // Handle errors gracefully, return empty array - } -} diff --git a/package.json b/package.json index 6c1005f..1b6cb43 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,21 @@ { - "name": "csoc-bot", + "name": "issue-assigner", "version": "1.0.0", "private": true, - "description": "A bot for managing workflow of CSOC projects", + "description": "A bot for managing issue assignments", "author": "Varun Kolanu", "license": "ISC", - "homepage": "https://github.com//", + "homepage": "https://github.com/Varun-Kolanu/issue-assigner", "keywords": [ "probot", "github", - "probot-app" + "probot-app", + "issue", + "issue-assigner" ], "scripts": { - "start": "node ./main.js", - "dev": "nodemon ./main.js", + "start": "node ./src/server.js", + "dev": "nodemon ./src/server.js", "test": "node --test" }, "dependencies": { diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..3557e63 --- /dev/null +++ b/src/app.js @@ -0,0 +1,15 @@ +import issue_comment_created from "./handlers/issue_comment_created.js"; +import issue_opened from "./handlers/issue_opened.js"; + +export default (app, { getRouter }) => { + const router = getRouter("/"); + router.get("/", (_, res) => { + res.send("Welcome to Issue Assigner"); + }); + + // Issue opened by a user + app.on("issues.opened", issue_opened); + + // Comment is created in an issue by a user + app.on("issue_comment.created", issue_comment_created); +}; diff --git a/src/handlers/issue_comment_created.js b/src/handlers/issue_comment_created.js new file mode 100644 index 0000000..6bdc2be --- /dev/null +++ b/src/handlers/issue_comment_created.js @@ -0,0 +1,138 @@ +import getAssignedIssues from "../helpers/get_assigned_issues.js"; +import { issueOrIssues, thisOrThese } from "../helpers/pluralize.js"; +import { getConfig } from "../utils/config.js"; + +export default async (context) => { + const { issue, comment } = context.payload; + const commenter = comment.user.login; + const collaborators = ( + await context.octokit.repos.listCollaborators(context.issue({})) + ).data.map((coll) => coll.login); + + if (commenter.includes("[bot]")) return; + if (collaborators.includes(commenter)) return; + + const config = await getConfig(context); + const assignPromptKey = "assign-prompt"; + const unassignPromptKey = "unassign-prompt"; + + const assignees = issue.assignees.map((assigneeJson) => assigneeJson.login); + const assigneeCount = assignees.length; + + // Feature: Self assigning an issue is present + if (assignPromptKey in config) { + // Check if the user is requesting assignment + const assignRequest = comment.body + .replace(/\s+/g, " ") // trim whitespace + .toLowerCase() // Case-insensitive + .includes(config[assignPromptKey]); + + // User is requesting assignment + if (assignRequest) { + const issueAlreadyAssignedKey = "issue-already-assigned"; + // The issue is already assigned to the user requesting + if (issueAlreadyAssignedKey in config && assignees.includes(commenter)) { + return await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} ` + config[issueAlreadyAssignedKey], + }) + ); + } + + const maxAssigneesKey = "max-assignees"; + // Maximum number of assignments to issue reached + if (maxAssigneesKey in config) { + if (assigneeCount >= config[maxAssigneesKey]) { + const maxAssigneesReachedKey = "max-assignees-reached"; + return await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} ` + config[maxAssigneesReachedKey], + }) + ); + } + } + + // Get the user's existing assigned issues + const assignedIssues = await getAssignedIssues(context, commenter); + const numAssignedIssues = assignedIssues.length; + + const maxIssuesForUserKey = "max-issues-for-user"; + // Maximum number of assigned issues at a time for the user reached + if ( + maxIssuesForUserKey in config && + numAssignedIssues >= config[maxIssuesForUserKey] + ) { + return await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} You already have ${thisOrThese( + numAssignedIssues + )} ${issueOrIssues( + numAssignedIssues + )} assigned: ${assignedIssues.map( + (issue) => `[ issue#${issue.number} ](${issue.html_url})` + )}. Abandon your existing ${issueOrIssues( + numAssignedIssues + )} or contact a maintainer if you want to get this issue assigned.`, + }) + ); + } + + // If all OK, Assign issue to user + await context.octokit.issues.addAssignees( + context.issue({ + assignees: [commenter], + }) + ); + + // Bot comments that issue has been assigned + const assignedSuccessKey = "assigned-comment"; + if (assignedSuccessKey in config) { + return await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} ` + config[assignedSuccessKey], + }) + ); + } + } + } + + // Feature: Self un-assigning an issue is present + if (unassignPromptKey in config) { + // Check if the user is requesting to un-assign + const unassignRequest = comment.body + .replace(/\s+/g, " ") + .toLowerCase() + .includes(config[unassignPromptKey]); + + // User is requesting to unassign + if (unassignRequest) { + if (assignees.includes(commenter)) { + await context.octokit.issues.removeAssignees( + context.issue({ + assignees: [commenter], + }) + ); + + const unassignedPromptKey = "unassigned-comment"; + if (unassignedPromptKey in config) { + await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} ` + config[unassignedPromptKey], + }) + ); + } + } else { + // The issue was not assigned to user already + const issueWasNotAssignedKey = "issue-was-not-assigned"; + if (issueWasNotAssignedKey in config) { + await context.octokit.issues.createComment( + context.issue({ + body: `@${commenter} ` + config[issueWasNotAssignedKey], + }) + ); + } + } + return; + } + } +}; diff --git a/src/handlers/issue_opened.js b/src/handlers/issue_opened.js new file mode 100644 index 0000000..1ff6125 --- /dev/null +++ b/src/handlers/issue_opened.js @@ -0,0 +1,35 @@ +import { getConfig } from "../utils/config.js"; + +export default async (context) => { + const { issue } = context.payload; + const collaborators = ( + await context.octokit.repos.listCollaborators(context.issue({})) + ).data.map((coll) => coll.login); + const issue_opener = issue.user.login; + + const config = await getConfig(context); + const issueOpenerNotMaintainer = "issue-opener-not-maintainer"; + const issueOpenerIsMaintainer = "issue-opener-is-maintainer"; + + // Issue opener is not a maintainer + if ( + issueOpenerNotMaintainer in config && + !collaborators.includes(issue_opener) + ) { + const issueComment = context.issue({ + body: `@${issue_opener} ` + config[issueOpenerNotMaintainer], + }); + return await context.octokit.issues.createComment(issueComment); + } + + // Issue opener is a maintainer + if ( + issueOpenerIsMaintainer in config && + collaborators.includes(issue_opener) + ) { + const issueComment = context.issue({ + body: config[issueOpenerIsMaintainer], + }); + return await context.octokit.issues.createComment(issueComment); + } +}; diff --git a/src/helpers/get_assigned_issues.js b/src/helpers/get_assigned_issues.js new file mode 100644 index 0000000..ef68c7c --- /dev/null +++ b/src/helpers/get_assigned_issues.js @@ -0,0 +1,15 @@ +// Helper function to retrieve assigned issues +export default async function getAssignedIssues(context, username) { + try { + const response = await context.octokit.issues.listForRepo( + context.issue({ + assignee: username, + state: "open", + }) + ); + return response.data; + } catch (error) { + console.error(error); + return []; // Handle errors gracefully, return empty array + } +} diff --git a/src/helpers/pluralize.js b/src/helpers/pluralize.js new file mode 100644 index 0000000..828056a --- /dev/null +++ b/src/helpers/pluralize.js @@ -0,0 +1,9 @@ +export const thisOrThese = (value) => { + if (value > 1) return "these"; + else return "this"; +}; + +export const issueOrIssues = (value) => { + if (value > 1) return "issues"; + else return "issue"; +}; diff --git a/main.js b/src/server.js similarity index 71% rename from main.js rename to src/server.js index e5b9100..1b242b6 100644 --- a/main.js +++ b/src/server.js @@ -1,4 +1,4 @@ import { createNodeMiddleware, createProbot, run } from "probot"; -import app from "./index.js"; +import app from "./app.js"; run(app); diff --git a/src/utils/config.js b/src/utils/config.js new file mode 100644 index 0000000..d0e5847 --- /dev/null +++ b/src/utils/config.js @@ -0,0 +1,36 @@ +export const getConfig = async function (context) { + // Load the configuration + const config = await context.config("issue-assigner.yml"); + + // Check if config and name key exists + if (!config || !config.name) { + throw new Error( + "Configuration or 'name' key is missing in issue-assigner.yml" + ); + } + + const name = config.name; + + // Helper function to recursively replace {name} in all string values in an object + const replacePlaceholders = (obj, name) => { + const replacer = (str) => str.replace(/{name}/g, name); + + if (typeof obj === "string") { + return replacer(obj); + } else if (Array.isArray(obj)) { + return obj.map((item) => replacePlaceholders(item, name)); + } else if (typeof obj === "object" && obj !== null) { + const newObj = {}; + for (const key in obj) { + newObj[key] = replacePlaceholders(obj[key], name); + } + return newObj; + } + return obj; + }; + + // Perform the replacement in the config object + const updatedConfig = replacePlaceholders(config, name); + + return updatedConfig; +};