├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── workflows │ └── test-on-push.yml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── package-lock.json ├── package.json ├── src ├── generate-mutation-query.js ├── generate-project-query.js ├── get-action-data.js └── index.js └── tests ├── generate-mutation-query.js ├── generate-project-query.js └── get-action-data.js /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at alex@alexpage.com.au. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Local development 4 | 5 | To set up the action for local development and testing: 6 | 7 | 1. Create a fork of the github-project-automation-plus 8 | 2. Create a new repository with a project 9 | 3. Add a workflow file that changes the `uses` to your forked repository: `uses: my-fork/github-project-automation-plus@main` 10 | 3. Make changes to your action and deploy them to GitHub 11 | 12 | ## Release a new version 13 | 14 | 1. Run `yarn build` 15 | 2. Push the changes to the `main` branch 16 | 3. Create a [new release](https://github.com/alex-page/github-project-automation-plus/releases/new) 17 | -------------------------------------------------------------------------------- /.github/workflows/test-on-push.yml: -------------------------------------------------------------------------------- 1 | name: Run workflow test on push 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@main 10 | - run: npm i 11 | - run: npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Page 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Project Automation+ 2 | 3 | > 🤖 Automate GitHub Project cards with any webhook event 4 | 5 | This action allows you to use any of the [pull_request](https://help.github.com/en/articles/events-that-trigger-workflows#pull-request-event-pull_request) and [issue](https://help.github.com/en/articles/events-that-trigger-workflows#issues-event-issues) webhook events to automate your project cards. For example when an `issue` is `opened` create a card in the Backlog project, Triage column. 6 | 7 | If the `pull_request` or `issue` card already exists it will be moved to the column provided. Otherwise the card will be created in the column. 8 | 9 | 10 | ## Usage 11 | 12 | Create a [project](https://help.github.com/en/articles/about-project-boards) with columns in your repository, user profile or organisation. 13 | 14 | Create a new workflow `.yml` file in the `.github/workflows/` directory. In the `.yml`file you have to decide what webhook events going move or create a card in a column. For more detailed explanation of the workflow file, check out the [GitHub documentation](https://help.github.com/en/articles/configuring-a-workflow#creating-a-workflow-file). See the examples below to get started quickly. 15 | 16 | 17 | ### .github/workflows/opened-issues-triage.yml 18 | 19 | Move opened issues into the Triage column of the Backlog project 20 | 21 | ```yml 22 | name: Move new issues into Triage 23 | 24 | on: 25 | issues: 26 | types: [opened] 27 | 28 | jobs: 29 | automate-project-columns: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: alex-page/github-project-automation-plus@v0.8.3 33 | with: 34 | project: Backlog 35 | column: Triage 36 | repo-token: ${{ secrets.GITHUB_TOKEN }} 37 | ``` 38 | 39 | ### .github/workflows/assigned-pulls-todo.yml 40 | 41 | Add assigned pull requests into the To Do column of the Backlog project 42 | 43 | ```yml 44 | name: Move assigned pull requests into To do 45 | 46 | on: 47 | pull_request: 48 | types: [assigned] 49 | 50 | jobs: 51 | automate-project-columns: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: alex-page/github-project-automation-plus@v0.8.3 55 | with: 56 | project: Backlog 57 | column: To do 58 | repo-token: ${{ secrets.GITHUB_TOKEN }} 59 | ``` 60 | 61 | ## Workflow options 62 | 63 | Change these options in the workflow `.yml` file to meet your GitHub project needs. 64 | 65 | | Inputs | Description | Values | 66 | | --- | --- | --- | 67 | | `on` | When the automation is ran | `issues` `pull_request` `issue_comment` `pull_request_target` `pull_request_review` | 68 | | `types` | The types of activity that will trigger a workflow run. | `opened`, `assigned`, `edited`: [See GitHub docs](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request) for more | 69 | | `project` | The name of the project | `Backlog` | 70 | | `column` | The column to create or move the card to | `Triage` | 71 | | `repo-token` | The personal access token | `${{ secrets.GITHUB_TOKEN }}` | 72 | | `action` | This determines the type of the action to be performed on the card, Default: `update` | `update`, `delete`, `archive`, `add` | 73 | 74 | ## Personal access token 75 | 76 | Most of the time [`GITHUB_TOKEN`](https://help.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token) will work as your `repo-token`. This requires no set up. If you have a public project board and public repository this is the option for you. 77 | 78 | **Repository project, private repository or organisation projects** 79 | 80 | You will need a personal access token to send events from your issues and pull requests. 81 | 82 | 1. Create a personal access token 83 | 1. [Public repository and repository project](https://github.com/settings/tokens/new?scopes=repo&description=GHPROJECT_TOKEN) 84 | 1. [Private repository or private project](https://github.com/settings/tokens/new?scopes=repo&description=GHPROJECT_TOKEN) 85 | 1. [Organisation project board or organisation repository](https://github.com/settings/tokens/new?scopes=repo,write:org&description=GHPROJECT_TOKEN) 86 | 87 | 1. [Add a secret](https://docs.github.com/en/actions/reference/encrypted-secrets#creating-encrypted-secrets-for-a-repository) `GHPROJECT_TOKEN` with the personal access token. 88 | 1. Update the `repo-token` in the workflow `.yml` to reference your new token name: 89 | ```yaml 90 | repo-token: ${{ secrets.GHPROJECT_TOKEN }} 91 | ``` 92 | 93 | ## Troubleshooting 94 | 95 | **GraphqlError: Resource not accessible by integration** or **Secrets are not currently available to forks.** 96 | 97 | This error happens on repository projects and forked repositories because [`GITHUB_TOKEN` only has read permissions](https://help.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token). Create a personal access token following the instructions above. 98 | 99 | **Parameter token or opts.auth is required** 100 | 101 | This error happens when using a personal access token to run the workflow on PRs from forked repositories. This is because [GitHub secrets are not populated for workflows triggered by forks](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-events-for-forked-repositories-2). Use `pull_request_target` as the webhook event instead to [enable access to secrets](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target). 102 | 103 | **SAML enforcement** 104 | 105 | With certain organisations there may be SAML enforcement. This means you will need to `Enable SSO` when you create the personal access token. 106 | ``` 107 | GraphqlError: Resource protected by organization SAML enforcement. You must grant your personal token access to this organization 108 | ``` 109 | 110 | **Can't read repository null** 111 | 112 | Make sure your permissions for your personal access token are correctly configured. Follow the above [guide on permissions](#personal-access-token). 113 | 114 | **Private repositories** 115 | 116 | You may need to enable policy settings to allow running workflows from forks. Please refer to GitHub's documentation to learn about enabling these settings at [enterprise](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-enterprise-account/enforcing-github-actions-policies-in-your-enterprise-account#enabling-workflows-for-private-repository-forks), [organization](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-organizations-and-teams/disabling-or-limiting-github-actions-for-your-organization?algolia-query=private+repositor#enabling-workflows-for-private-repository-forks), or [repository](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/disabling-or-limiting-github-actions-for-a-repository#enabling-workflows-for-private-repository-forks) level. 117 | 118 | 119 | ## Release History 120 | 121 | - v0.8.3 - Update documentation so users get latest version 122 | - v0.8.2 - Update NodeJS support to use latest version 123 | - v0.8.1 - Fix `issue_coment` error with automation 124 | - v0.8.0 - Add new action type `add` 125 | - v0.7.1 - Move Node.js version back to v12 126 | - v0.7.0 - Update documentation and dependencies 127 | - v0.6.0 - Add support for `pull_request_target` and `pull_request_review` 128 | - v0.5.1 - Fix get event data from `issue_coment` 129 | - v0.5.0 - Add option to `delete` card 130 | - v0.4.0 - Add `issue_comment` event 131 | - v0.3.0 - Add `pull_request_target` event 132 | - v0.2.4 - Update dependencies 133 | - v0.2.3 - Replace reserved secret `GITHUB_TOKEN` with `GITHUB_TOKEN` in documentation 134 | - v0.2.2 - Refactor add and move card logic ✨ 135 | - v0.2.1 - Fix bug with move logic when card is already in project 136 | - v0.2.0 - Restructure project, add tests, change add and move logic 137 | - v0.1.3 - Exact match for project names 138 | - v0.1.2 - Fix action not running for a card that exists in multiple projects 139 | - v0.1.1 - Document type filter so action runs once 140 | - v0.1.0 - Add support for user projects 141 | - v0.0.3 - Automatic build before commit 142 | - v0.0.2 - Error handling using GitHub actions 143 | - v0.0.1 - Update icon and color for GitHub actions 144 | - v0.0.0 - Initial release 145 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'GitHub Project Automation+' 2 | description: '🤖 Automate GitHub Project cards with any webhook event' 3 | author: 'Alex Page ' 4 | inputs: 5 | repo-token: 6 | description: 'The GH_TOKEN secret can be passed in using {{ secrets.GH_TOKEN }}' 7 | required: true 8 | project: 9 | description: 'The name of the GitHub Project' 10 | required: true 11 | column: 12 | description: 'The name of the column to move the issue or pull request to' 13 | required: true 14 | action: 15 | description: | 16 | Can be `update`, `delete` or `archive` or `add`. This determines the type of the action 17 | to be performed on the card 18 | required: false 19 | default: "update" 20 | runs: 21 | using: 'node20' 22 | main: 'dist/index.js' 23 | branding: 24 | icon: 'cpu' 25 | color: 'gray-dark' 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-project-automation-plus", 3 | "version": "0.8.1", 4 | "description": "🤖 Automate GitHub Project cards with any webhook event", 5 | "private": true, 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "watch": "ncc build src/index.js --watch --minify", 9 | "build": "ncc build src/index.js --minify", 10 | "test": "ava && xo" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/alex-page/github-project-automation-plus.git" 15 | }, 16 | "keywords": [ 17 | "github-actions", 18 | "github-projects", 19 | "issues", 20 | "pulls", 21 | "automation", 22 | "columns" 23 | ], 24 | "author": "Alex Page ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/alex-page/github-project-automation-plus/issues" 28 | }, 29 | "homepage": "https://github.com/alex-page/github-project-automation-plus#readme", 30 | "dependencies": { 31 | "@actions/core": "^1.2.7", 32 | "@actions/github": "^2.1.1" 33 | }, 34 | "devDependencies": { 35 | "@vercel/ncc": "^0.28.4", 36 | "ava": "^3.15.0", 37 | "xo": "^0.39.1" 38 | }, 39 | "files": [ 40 | "dist/index.js" 41 | ] 42 | } -------------------------------------------------------------------------------- /src/generate-mutation-query.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get a list of columns for the matching project and columns names 3 | * 4 | * @param {object} data - The graphQL data 5 | * @param {string} projectName - The user inputted project name 6 | * @param {string} columnName - The user inputted column name 7 | * @param {string} contentId - The id of the issue or pull request 8 | * @param {"delete"|"archive"|"update"} action - the action to be performed on the card 9 | */ 10 | // if this is important, we will need to refactor the function 11 | // eslint-disable-next-line max-params 12 | const generateMutationQuery = (data, projectName, columnName, contentId, action) => { 13 | // All the projects found in organisation and repositories 14 | const repoProjects = data.repository.projects.nodes || []; 15 | const orgProjects = (data.repository.owner && 16 | data.repository.owner.projects && 17 | data.repository.owner.projects.nodes) || 18 | []; 19 | 20 | // Find matching projects and columns for the card to move to 21 | const endLocation = [...repoProjects, ...orgProjects] 22 | .filter(project => project.name === projectName) 23 | .flatMap(project => project) 24 | .filter(project => { 25 | const matchingColumns = project.columns.nodes 26 | .filter(column => column.name === columnName); 27 | return matchingColumns.length > 0; 28 | }); 29 | 30 | // There are no locations for the card to move to 31 | if (endLocation.length === 0) { 32 | throw new Error(`Could not find the column "${columnName}" or project "${projectName}"`); 33 | } 34 | 35 | const cardLocations = {}; 36 | 37 | // Get the ids of the end card location 38 | for (const project of endLocation) { 39 | cardLocations[project.id] = { 40 | columnId: project.columns.nodes 41 | .filter(column => column.name === columnName) 42 | .map(column => column.id)[0] 43 | }; 44 | } 45 | 46 | // See if the card exists in the provided project 47 | const currentLocation = data.projectCards.nodes 48 | .filter(card => card.project.name === projectName); 49 | 50 | for (const card of currentLocation) { 51 | cardLocations[card.project.id].cardId = card.id; 52 | cardLocations[card.project.id].isArchived = card.isArchived; 53 | } 54 | 55 | // If the card already exists in the project move it otherwise add a new card 56 | const mutations = Object.keys(cardLocations).map(mutation => { 57 | if (action === 'update') { 58 | // Othervise keep default procedure 59 | return cardLocations[mutation].cardId ? 60 | `mutation { 61 | moveProjectCard( input: { 62 | cardId: "${cardLocations[mutation].cardId}", 63 | columnId: "${cardLocations[mutation].columnId}" 64 | }) { clientMutationId } }` : 65 | 66 | `mutation { 67 | addProjectCard( input: { 68 | contentId: "${contentId}", 69 | projectColumnId: "${cardLocations[mutation].columnId}" 70 | }) { clientMutationId } }`; 71 | } 72 | 73 | if (action === 'delete' && cardLocations[mutation].cardId) { 74 | // Delete issue from all boards, this if block 75 | // prevents adding issue in case it has no card yet 76 | return `mutation { 77 | deleteProjectCard( input: { 78 | cardId: "${cardLocations[mutation].cardId}" 79 | }) { clientMutationId } }`; 80 | } 81 | 82 | if (action === 'archive' && !cardLocations[mutation].isArchived) { 83 | // Archive issue if not already archived 84 | return `mutation { 85 | updateProjectCard(input: { 86 | projectCardId: "${cardLocations[mutation].cardId}", 87 | isArchived: true 88 | }) { clientMutationId } }`; 89 | } 90 | 91 | if (action === 'add' && !cardLocations[mutation].cardId) { 92 | // Add issue if the card does not exist in the project 93 | return `mutation { 94 | addProjectCard( input: { 95 | contentId: "${contentId}", 96 | projectColumnId: "${cardLocations[mutation].columnId}" 97 | }) { clientMutationId } }`; 98 | } 99 | 100 | return undefined; 101 | }); 102 | 103 | return mutations.filter(m => m !== undefined); 104 | }; 105 | 106 | module.exports = generateMutationQuery; 107 | -------------------------------------------------------------------------------- /src/generate-project-query.js: -------------------------------------------------------------------------------- 1 | /** 2 | * GraphQl query to get project and column information 3 | * 4 | * @param {string} url - Issue or Pull request url 5 | * @param {string} eventName - The current event name 6 | * @param {string} project - The project to find 7 | */ 8 | const projectQuery = (url, eventName, project) => 9 | `query { 10 | resource( url: "${url}" ) { 11 | ... on ${eventName.startsWith('issue') ? 'Issue' : 'PullRequest'} { 12 | projectCards { 13 | nodes { 14 | id 15 | isArchived 16 | project { 17 | name 18 | id 19 | } 20 | } 21 | } 22 | repository { 23 | projects( search: "${project}", first: 10, states: [OPEN] ) { 24 | nodes { 25 | name 26 | id 27 | columns( first: 100 ) { 28 | nodes { 29 | id 30 | name 31 | } 32 | } 33 | } 34 | } 35 | owner { 36 | ... on ProjectOwner { 37 | projects( search: "${project}", first: 10, states: [OPEN] ) { 38 | nodes { 39 | name 40 | id 41 | columns( first: 100 ) { 42 | nodes { 43 | id 44 | name 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | }`; 55 | 56 | module.exports = projectQuery; 57 | -------------------------------------------------------------------------------- /src/get-action-data.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch the relevant data from GitHub 3 | * 4 | * @param {object} githubContext - The current issue or pull request data 5 | */ 6 | const ACCEPTED_EVENT_TYPES = new Set([ 7 | 'pull_request', 8 | 'pull_request_target', 9 | 'pull_request_review', 10 | 'issues', 11 | 'issue_comment' 12 | ]); 13 | 14 | const getActionData = githubContext => { 15 | const {eventName, payload} = githubContext; 16 | if (!ACCEPTED_EVENT_TYPES.has(eventName)) { 17 | throw new Error(`Only pull requests, reviews, issues, or comments allowed. Received:\n${eventName}`); 18 | } 19 | 20 | const githubData = eventName === 'issues' || eventName === 'issue_comment' ? 21 | payload.issue : 22 | payload.pull_request; 23 | 24 | return { 25 | eventName, 26 | action: payload.action, 27 | nodeId: githubData.node_id, 28 | url: githubData.html_url 29 | }; 30 | }; 31 | 32 | module.exports = getActionData; 33 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const github = require('@actions/github'); 3 | 4 | const getActionData = require('./get-action-data'); 5 | const generateProjectQuery = require('./generate-project-query'); 6 | const generateMutationQuery = require('./generate-mutation-query'); 7 | 8 | (async () => { 9 | try { 10 | const token = core.getInput('repo-token'); 11 | const project = core.getInput('project'); 12 | const column = core.getInput('column'); 13 | const action = core.getInput('action') || 'update'; 14 | 15 | // Get data from the current action 16 | const {eventName, nodeId, url} = getActionData(github.context); 17 | 18 | // Create a method to query GitHub 19 | const octokit = new github.GitHub(token); 20 | 21 | // Get the column ID from searching for the project and card Id if it exists 22 | const projectQuery = generateProjectQuery(url, eventName, project); 23 | 24 | core.debug(projectQuery); 25 | 26 | const {resource} = await octokit.graphql(projectQuery); 27 | 28 | core.debug(JSON.stringify(resource)); 29 | 30 | // A list of columns that line up with the user entered project and column 31 | const mutationQueries = generateMutationQuery(resource, project, column, nodeId, action); 32 | if ((action === 'delete' || action === 'archive' || action === 'add') && mutationQueries.length === 0) { 33 | console.log('✅ There is nothing to do with card'); 34 | return; 35 | } 36 | 37 | core.debug(mutationQueries.join('\n')); 38 | 39 | // Run the graphql queries 40 | await Promise.all(mutationQueries.map(query => octokit.graphql(query))); 41 | 42 | if (mutationQueries.length > 1) { 43 | console.log(`✅ Card materialised into to ${column} in ${mutationQueries.length} projects called ${project}`); 44 | } else { 45 | console.log(`✅ Card materialised into ${column} in ${project}`); 46 | } 47 | } catch (error) { 48 | core.setFailed(error.message); 49 | } 50 | })(); 51 | -------------------------------------------------------------------------------- /tests/generate-mutation-query.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const generateMutationQuery = require('../src/generate-mutation-query'); 4 | 5 | const project = 'Backlog'; 6 | const column = 'To do'; 7 | const nodeId = 'MDU6SXNzdWU1ODc4NzU1Mjk='; 8 | 9 | const moveData = { 10 | projectCards: { 11 | nodes: [ 12 | { 13 | id: 'MDExOlByb2plY3RDYXJkMzUxNzI2MjM=', 14 | project: { 15 | name: project, 16 | id: 'MDc6UHJvamVjdDQwNzU5MDI=' 17 | } 18 | } 19 | ] 20 | }, 21 | repository: { 22 | projects: { 23 | nodes: [ 24 | { 25 | id: 'MDc6UHJvamVjdDQwNzU5MDI=', 26 | name: project, 27 | columns: { 28 | nodes: [ 29 | { 30 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ6', 31 | name: 'Icebox' 32 | }, 33 | { 34 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5', 35 | name: column 36 | } 37 | ] 38 | } 39 | } 40 | ] 41 | }, 42 | owner: { 43 | projects: { 44 | nodes: [] 45 | } 46 | } 47 | } 48 | }; 49 | 50 | test('generateMutationQuery move the card when in the correct project and wrong column', t => { 51 | t.deepEqual(generateMutationQuery(moveData, project, column, nodeId, 'update'), [ 52 | `mutation { 53 | moveProjectCard( input: { 54 | cardId: "MDExOlByb2plY3RDYXJkMzUxNzI2MjM=", 55 | columnId: "MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5" 56 | }) { clientMutationId } }` 57 | ]); 58 | }); 59 | 60 | test('generateMutationQuery delete the card when it is in the project already', t => { 61 | t.deepEqual(generateMutationQuery(moveData, project, column, nodeId, 'delete'), [ 62 | `mutation { 63 | deleteProjectCard( input: { 64 | cardId: "MDExOlByb2plY3RDYXJkMzUxNzI2MjM=" 65 | }) { clientMutationId } }` 66 | ]); 67 | }); 68 | 69 | test('generateMutationQuery skip issue addition when the card already exists in the project', t => { 70 | t.deepEqual(generateMutationQuery(moveData, project, column, nodeId, 'add'), []); 71 | }); 72 | 73 | const addData = { 74 | projectCards: { 75 | nodes: [] 76 | }, 77 | repository: { 78 | projects: { 79 | nodes: [ 80 | { 81 | name: project, 82 | id: 'MDc6UHJvamVjdDQwNzU5MDI=', 83 | columns: { 84 | nodes: [ 85 | { 86 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5', 87 | name: column 88 | }, 89 | { 90 | id: 'MDEzOlByb2plY3RDb2x1bW44MjUxOTAz', 91 | name: 'In progress' 92 | } 93 | ] 94 | } 95 | } 96 | ] 97 | }, 98 | owner: { 99 | projects: { 100 | nodes: [] 101 | } 102 | } 103 | } 104 | }; 105 | 106 | test('generateMutationQuery add the card when the card does not exist in the project', t => { 107 | t.deepEqual(generateMutationQuery(addData, project, column, nodeId, 'update'), [ 108 | `mutation { 109 | addProjectCard( input: { 110 | contentId: "MDU6SXNzdWU1ODc4NzU1Mjk=", 111 | projectColumnId: "MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5" 112 | }) { clientMutationId } }` 113 | ]); 114 | }); 115 | 116 | test('generateMutationQuery skip issue deletion when the card does not exist in the project', t => { 117 | t.deepEqual(generateMutationQuery(addData, project, column, nodeId, 'delete'), []); 118 | }); 119 | 120 | const archiveData = { 121 | projectCards: { 122 | nodes: [ 123 | { 124 | id: 'MDExOlByb2plY3RDYXJkMzUxNzI2MjM=', 125 | isArchived: true, 126 | project: { 127 | name: project, 128 | id: 'MDc6UHJvamVjdDQwNzU5MDI=' 129 | } 130 | } 131 | ] 132 | }, 133 | repository: { 134 | projects: { 135 | nodes: [ 136 | { 137 | name: project, 138 | id: 'MDc6UHJvamVjdDQwNzU5MDI=', 139 | columns: { 140 | nodes: [ 141 | { 142 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5', 143 | name: column 144 | }, 145 | { 146 | id: 'MDEzOlByb2plY3RDb2x1bW44MjUxOTAz', 147 | name: 'In progress' 148 | } 149 | ] 150 | } 151 | } 152 | ] 153 | }, 154 | owner: { 155 | projects: { 156 | nodes: [] 157 | } 158 | } 159 | } 160 | }; 161 | 162 | test('generateMutationQuery skip issue archive when the card is already archived', t => { 163 | t.deepEqual(generateMutationQuery(archiveData, project, column, nodeId, 'archive'), []); 164 | }); 165 | 166 | const dataNoColumn = { 167 | projectCards: { 168 | nodes: [] 169 | }, 170 | repository: { 171 | projects: { 172 | nodes: [ 173 | { 174 | id: 'MDc6UHJvamVjdDQwNzU5MDI=', 175 | name: project, 176 | columns: { 177 | nodes: [ 178 | { 179 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5', 180 | name: 'No project column' 181 | } 182 | ] 183 | } 184 | } 185 | ] 186 | }, 187 | owner: { 188 | projects: { 189 | nodes: [] 190 | } 191 | } 192 | } 193 | }; 194 | 195 | test('generateMutationQuery should fail if it cannot find a matching column', t => { 196 | const error = t.throws(() => generateMutationQuery(dataNoColumn, project, column, nodeId)); 197 | 198 | t.is(error.message, `Could not find the column "${column}" or project "${project}"`); 199 | }); 200 | 201 | const dataNoProject = { 202 | projectCards: { 203 | nodes: [] 204 | }, 205 | repository: { 206 | projects: { 207 | nodes: [ 208 | { 209 | id: 'MDc6UHJvamVjdDQwNzU5MDI=', 210 | name: 'No project name', 211 | columns: { 212 | nodes: [ 213 | { 214 | id: 'MDEzOlByb2plY3RDb2x1bW44NDU0MzQ5', 215 | name: column 216 | } 217 | ] 218 | } 219 | } 220 | ] 221 | }, 222 | owner: { 223 | projects: { 224 | nodes: [] 225 | } 226 | } 227 | } 228 | }; 229 | 230 | test('generateMutationQuery should fail if it cannot find a matching project', t => { 231 | const error = t.throws(() => generateMutationQuery(dataNoProject, project, column, nodeId)); 232 | 233 | t.is(error.message, `Could not find the column "${column}" or project "${project}"`); 234 | }); 235 | -------------------------------------------------------------------------------- /tests/generate-project-query.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const generateProjectQuery = require('../src/generate-project-query'); 4 | 5 | const issueQuery = `query { 6 | resource( url: "https://github.com/alex-page/test-actions/issues/52" ) { 7 | ... on Issue { 8 | projectCards { 9 | nodes { 10 | id 11 | isArchived 12 | project { 13 | name 14 | id 15 | } 16 | } 17 | } 18 | repository { 19 | projects( search: "Backlog", first: 10, states: [OPEN] ) { 20 | nodes { 21 | name 22 | id 23 | columns( first: 100 ) { 24 | nodes { 25 | id 26 | name 27 | } 28 | } 29 | } 30 | } 31 | owner { 32 | ... on ProjectOwner { 33 | projects( search: "Backlog", first: 10, states: [OPEN] ) { 34 | nodes { 35 | name 36 | id 37 | columns( first: 100 ) { 38 | nodes { 39 | id 40 | name 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | }`; 51 | 52 | const pullrequestQuery = `query { 53 | resource( url: "https://github.com/alex-page/test-actions/pulls/1" ) { 54 | ... on PullRequest { 55 | projectCards { 56 | nodes { 57 | id 58 | isArchived 59 | project { 60 | name 61 | id 62 | } 63 | } 64 | } 65 | repository { 66 | projects( search: "Backlogg", first: 10, states: [OPEN] ) { 67 | nodes { 68 | name 69 | id 70 | columns( first: 100 ) { 71 | nodes { 72 | id 73 | name 74 | } 75 | } 76 | } 77 | } 78 | owner { 79 | ... on ProjectOwner { 80 | projects( search: "Backlogg", first: 10, states: [OPEN] ) { 81 | nodes { 82 | name 83 | id 84 | columns( first: 100 ) { 85 | nodes { 86 | id 87 | name 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | }`; 98 | 99 | test('generateProjectQuery should create a query for issues', t => { 100 | const url = 'https://github.com/alex-page/test-actions/issues/52'; 101 | const eventName = 'issues'; 102 | const project = 'Backlog'; 103 | 104 | t.is(generateProjectQuery(url, eventName, project), issueQuery); 105 | }); 106 | 107 | test('generateProjectQuery should create a query for pull requests', t => { 108 | const url = 'https://github.com/alex-page/test-actions/pulls/1'; 109 | const eventName = 'pull_request'; 110 | const project = 'Backlogg'; 111 | 112 | t.is(generateProjectQuery(url, eventName, project), pullrequestQuery); 113 | }); 114 | -------------------------------------------------------------------------------- /tests/get-action-data.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | 3 | const getActionData = require('../src/get-action-data'); 4 | 5 | /* eslint-disable camelcase */ 6 | const mockGithubContext = { 7 | payload: { 8 | action: 'opened', 9 | issue: { 10 | assignee: null, 11 | assignees: [], 12 | author_association: 'OWNER', 13 | body: '', 14 | closed_at: null, 15 | comments: 0, 16 | comments_url: 'https://api.github.com/repos/alex-page/test-actions/issues/52/comments', 17 | created_at: '2020-03-25T17:45:13Z', 18 | events_url: 'https://api.github.com/repos/alex-page/test-actions/issues/52/events', 19 | html_url: 'https://github.com/alex-page/test-actions/issues/52', 20 | id: 587875529, 21 | labels: [], 22 | labels_url: 'https://api.github.com/repos/alex-page/test-actions/issues/52/labels{/name}', 23 | locked: false, 24 | milestone: null, 25 | node_id: 'MDU6SXNzdWU1ODc4NzU1Mjk=', 26 | number: 52, 27 | repository_url: 'https://api.github.com/repos/alex-page/test-actions', 28 | state: 'open', 29 | title: '52', 30 | updated_at: '2020-03-25T17:45:13Z', 31 | url: 'https://api.github.com/repos/alex-page/test-actions/issues/52', 32 | user: { 33 | avatar_url: 'https://avatars1.githubusercontent.com/u/19199063?v=4', 34 | events_url: 'https://api.github.com/users/alex-page/events{/privacy}', 35 | followers_url: 'https://api.github.com/users/alex-page/followers', 36 | following_url: 'https://api.github.com/users/alex-page/following{/other_user}', 37 | gists_url: 'https://api.github.com/users/alex-page/gists{/gist_id}', 38 | gravatar_id: '', 39 | html_url: 'https://github.com/alex-page', 40 | id: 19199063, 41 | login: 'alex-page', 42 | node_id: 'MDQ6VXNlcjE5MTk5MDYz', 43 | organizations_url: 'https://api.github.com/users/alex-page/orgs', 44 | received_events_url: 'https://api.github.com/users/alex-page/received_events', 45 | repos_url: 'https://api.github.com/users/alex-page/repos', 46 | site_admin: false, 47 | starred_url: 'https://api.github.com/users/alex-page/starred{/owner}{/repo}', 48 | subscriptions_url: 'https://api.github.com/users/alex-page/subscriptions', 49 | type: 'User', 50 | url: 'https://api.github.com/users/alex-page' 51 | } 52 | }, 53 | repository: { 54 | archive_url: 'https://api.github.com/repos/alex-page/test-actions/{archive_format}{/ref}', 55 | archived: false, 56 | assignees_url: 'https://api.github.com/repos/alex-page/test-actions/assignees{/user}', 57 | blobs_url: 'https://api.github.com/repos/alex-page/test-actions/git/blobs{/sha}', 58 | branches_url: 'https://api.github.com/repos/alex-page/test-actions/branches{/branch}', 59 | clone_url: 'https://github.com/alex-page/test-actions.git', 60 | collaborators_url: 'https://api.github.com/repos/alex-page/test-actions/collaborators{/collaborator}', 61 | comments_url: 'https://api.github.com/repos/alex-page/test-actions/comments{/number}', 62 | commits_url: 'https://api.github.com/repos/alex-page/test-actions/commits{/sha}', 63 | compare_url: 'https://api.github.com/repos/alex-page/test-actions/compare/{base}...{head}', 64 | contents_url: 'https://api.github.com/repos/alex-page/test-actions/contents/{+path}', 65 | contributors_url: 'https://api.github.com/repos/alex-page/test-actions/contributors', 66 | created_at: '2020-03-08T00:03:14Z', 67 | default_branch: 'main', 68 | deployments_url: 'https://api.github.com/repos/alex-page/test-actions/deployments', 69 | description: 'Repo to test actions', 70 | disabled: false, 71 | downloads_url: 'https://api.github.com/repos/alex-page/test-actions/downloads', 72 | events_url: 'https://api.github.com/repos/alex-page/test-actions/events', 73 | fork: false, 74 | forks: 0, 75 | forks_count: 0, 76 | forks_url: 'https://api.github.com/repos/alex-page/test-actions/forks', 77 | full_name: 'alex-page/test-actions', 78 | git_commits_url: 'https://api.github.com/repos/alex-page/test-actions/git/commits{/sha}', 79 | git_refs_url: 'https://api.github.com/repos/alex-page/test-actions/git/refs{/sha}', 80 | git_tags_url: 'https://api.github.com/repos/alex-page/test-actions/git/tags{/sha}', 81 | git_url: 'git://github.com/alex-page/test-actions.git', 82 | has_downloads: true, 83 | has_issues: true, 84 | has_pages: false, 85 | has_projects: true, 86 | has_wiki: true, 87 | homepage: null, 88 | hooks_url: 'https://api.github.com/repos/alex-page/test-actions/hooks', 89 | html_url: 'https://github.com/alex-page/test-actions', 90 | id: 245724936, 91 | issue_comment_url: 'https://api.github.com/repos/alex-page/test-actions/ issues/comments{/number}', 92 | issue_events_url: 'https://api.github.com/repos/alex-page/test-actions/issues/events{/number}', 93 | issues_url: 'https://api.github.com/repos/alex-page/test-actions/issues{/number}', 94 | keys_url: 'https://api.github.com/repos/alex-page/test-actions/keys{/key_id}', 95 | labels_url: 'https://api.github.com/repos/alex-page/test-actions/labels{/name}', 96 | language: null, 97 | languages_url: 'https://api.github.com/repos/alex-page/test-actions/languages', 98 | license: null, 99 | merges_url: 'https://api.github.com/repos/alex-page/test-actions/merges', 100 | milestones_url: 'https://api.github.com/repos/alex-page/test-actions/milestones{/number}', 101 | mirror_url: null, 102 | name: 'test-actions', 103 | node_id: 'MDEwOlJlcG9zaXRvcnkyNDU3MjQ5MzY=', 104 | notifications_url: 'https://api.github.com/repos/alex-page/test-actions/notifications{?since,all,participating}', 105 | open_issues: 12, 106 | open_issues_count: 12, 107 | owner: { 108 | avatar_url: 'https://avatars1.githubusercontent.com/u/19199063?v=4', 109 | events_url: 'https://api.github.com/users/alex-page/events{/privacy}', 110 | followers_url: 'https://api.github.com/users/alex-page/followers', 111 | following_url: 'https://api.github.com/users/alex-page/following{/other_user}', 112 | gists_url: 'https://api.github.com/users/alex-page/gists{/gist_id}', 113 | gravatar_id: '', 114 | html_url: 'https://github.com/alex-page', 115 | id: 19199063, 116 | login: 'alex-page', 117 | node_id: 'MDQ6VXNlcjE5MTk5MDYz', 118 | organizations_url: 'https://api.github.com/users/alex-page/orgs', 119 | received_events_url: 'https://api.github.com/users/alex-page/received_events', 120 | repos_url: 'https://api.github.com/users/alex-page/repos', 121 | site_admin: false, 122 | starred_url: 'https://api.github.com/users/alex-page/starred{/owner}{/repo}', 123 | subscriptions_url: 'https://api.github.com/users/alex-page/subscriptions', 124 | type: 'User', 125 | url: 'https://api.github.com/users/alex-page' 126 | }, 127 | private: false, 128 | pulls_url: 'https://api.github.com/repos/alex-page/test-actions/pulls{/number}', 129 | pushed_at: '2020-03-25T14:52:00Z', 130 | releases_url: 'https://api.github.com/repos/alex-page/test-actions/releases{/id}', 131 | size: 18, 132 | ssh_url: 'git@github.com:alex-page/test-actions.git', 133 | stargazers_count: 0, 134 | stargazers_url: 'https://api.github.com/repos/alex-page/test-actions/stargazers', 135 | statuses_url: 'https://api.github.com/repos/alex-page/test-actions/statuses/{sha}', 136 | subscribers_url: 'https://api.github.com/repos/alex-page/test-actions/subscribers', 137 | subscription_url: 'https://api.github.com/repos/alex-page/test-actions/subscription', 138 | svn_url: 'https://github.com/alex-page/test-actions', 139 | tags_url: 'https://api.github.com/repos/alex-page/test-actions/tags', 140 | teams_url: 'https://api.github.com/repos/alex-page/test-actions/teams', 141 | trees_url: 'https://api.github.com/repos/alex-page/test-actions/git/trees{/sha}', 142 | updated_at: '2020-03-25T14:52:02Z', 143 | url: 'https://api.github.com/repos/alex-page/test-actions', 144 | watchers: 0, 145 | watchers_count: 0 146 | }, 147 | sender: { 148 | avatar_url: 'https://avatars1.githubusercontent.com/u/19199063?v=4', 149 | events_url: 'https://api.github.com/users/alex-page/events{/privacy}', 150 | followers_url: 'https://api.github.com/users/alex-page/followers', 151 | following_url: 'https://api.github.com/users/alex-page/following{/other_user}', 152 | gists_url: 'https://api.github.com/users/alex-page/gists{/gist_id}', 153 | gravatar_id: '', 154 | html_url: 'https://github.com/alex-page', 155 | id: 19199063, 156 | login: 'alex-page', 157 | node_id: 'MDQ6VXNlcjE5MTk5MDYz', 158 | organizations_url: 'https://api.github.com/users/alex-page/orgs', 159 | received_events_url: 'https://api.github.com/users/alex-page/received_events', 160 | repos_url: 'https://api.github.com/users/alex-page/repos', 161 | site_admin: false, 162 | starred_url: 'https://api.github.com/users/alex-page/starred{/owner}{/repo}', 163 | subscriptions_url: 'https://api.github.com/users/alex-page/subscriptions', 164 | type: 'User', 165 | url: 'https://api.github.com/users/alex-page' 166 | } 167 | }, 168 | eventName: 'issues', 169 | sha: '526d81e24203000f49d90eb530707b141ae64c89', 170 | ref: 'refs/heads/main', 171 | workflow: 'Move new issues into "Triage"', 172 | action: 'alex-pagegithub-project-automation-plus', 173 | actor: 'alex-page' 174 | }; 175 | 176 | /* eslint-enable camelcase */ 177 | test('getActionData should return a formatted object from issue', t => { 178 | t.deepEqual(getActionData(mockGithubContext), { 179 | action: 'opened', 180 | eventName: 'issues', 181 | nodeId: 'MDU6SXNzdWU1ODc4NzU1Mjk=', 182 | url: 'https://github.com/alex-page/test-actions/issues/52' 183 | }); 184 | }); 185 | 186 | test('getActionData should return a formatted object from comment', t => { 187 | /* eslint-disable camelcase */ 188 | const context = { 189 | eventName: 'issue_comment', 190 | payload: { 191 | action: 'created', 192 | issue: { 193 | node_id: 'MDFooBar45', 194 | html_url: 'https://github.com/alex-page/test-actions/issues/52' 195 | } 196 | } 197 | }; 198 | 199 | /* eslint-enable camelcase */ 200 | t.deepEqual(getActionData(context), { 201 | action: 'created', 202 | eventName: 'issue_comment', 203 | nodeId: 'MDFooBar45', 204 | url: 'https://github.com/alex-page/test-actions/issues/52' 205 | }); 206 | }); 207 | 208 | test('getActionData should fail when eventName is not covered by action', t => { 209 | const failingMockGithubContext = Object.assign({}, mockGithubContext); 210 | const eventName = 'label'; 211 | failingMockGithubContext.eventName = eventName; 212 | 213 | const error = t.throws(() => getActionData(failingMockGithubContext)); 214 | 215 | t.is(error.message, `Only pull requests, reviews, issues, or comments allowed. Received:\n${eventName}`); 216 | }); 217 | --------------------------------------------------------------------------------