├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── .prettierignore ├── .tool-versions ├── .yamllint ├── LICENSE.md ├── README.md ├── __test__ └── main.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── script ├── bootstrap ├── cibuild ├── lint └── update-readme ├── src ├── main.ts └── update-project.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "i18n-text/no-en": "off", 12 | "camelcase": "warn", 13 | "no-console": "warn" 14 | }, 15 | "env": { 16 | "node": true, 17 | "es6": true, 18 | "jest/globals": true 19 | } 20 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - src/** 8 | - package-lock.json 9 | - action.yml 10 | - .github/workflows/build.yml 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 16 22 | cache: 'npm' 23 | 24 | - name: script/bootstrap 25 | run: script/bootstrap 26 | 27 | - name: script/cibuild 28 | run: script/cibuild 29 | 30 | - name: Update README 31 | run: script/update-readme 32 | 33 | - name: Create Pull Request 34 | uses: peter-evans/create-pull-request@000e3c600203cb255d83817abb762ece92981dc9 35 | with: 36 | commit-message: Update build 37 | title: Update build -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | ci: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v4 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 16 19 | cache: 'npm' 20 | 21 | - name: bootstrap 22 | run: script/bootstrap 23 | 24 | - name: test 25 | run: script/cibuild 26 | 27 | - name: lint 28 | run: script/lint -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '25 13 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | coverage/* 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.17.0 2 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | 3 | rules: 4 | line-length: disable 5 | document-start: disable -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ben Balter 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 | # Update Project Action 2 | 3 | A composite GitHub action that updates or gets an item's fields on a GitHub Projects (beta) board based on a workflow dispatch (or other) event's input. 4 | 5 | [![CI](https://github.com/benbalter/update-project-action/actions/workflows/ci.yml/badge.svg)](https://github.com/benbalter/update-project-action/actions/workflows/ci.yml) 6 | 7 | ## Goals 8 | 9 | * To make it easier to update/read the fields of a GitHub Project board based on action taken elsewhere within the development process (e.g., status update comments) 10 | * Keep it simple - Prefer boring technology that others can understand, modify, and contribute to 11 | * Never force a human to do what a robot can 12 | 13 | ## Status 14 | 15 | Used to automate non-production workflows. 16 | 17 | ## Usage 18 | 19 | To use this composite GitHub Action, add the following to a YAML file in your repository's `.github/workflows/` directory, customizing the `with` section following [the instructions in the Inputs section](#inputs) below: 20 | 21 | ```yml 22 | name: Update status on project board 23 | on: 24 | repository_dispatch: 25 | types: [status_update] 26 | jobs: 27 | update_project: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Read status 31 | id: read_status 32 | uses: github/update-project-action@v3 33 | with: 34 | github_token: ${{ secrets.STATUS_UPDATE_TOKEN }} 35 | organization: github 36 | project_number: 1234 37 | operation: read 38 | content_id: ${{ github.event.client_payload.command.resource.id }} 39 | - name: Output status 40 | run: | 41 | echo "Current status value: ${{ steps.read_status.outputs.field_read_value }}" 42 | - name: Update status 43 | id: update_status 44 | uses: github/update-project-action@v3 45 | with: 46 | github_token: ${{ secrets.STATUS_UPDATE_TOKEN }} 47 | organization: github 48 | project_number: 1234 49 | content_id: ${{ github.event.client_payload.command.resource.id }} 50 | field: Status 51 | value: ${{ github.event.client_payload.data.status }} 52 | ``` 53 | 54 | *Note: The above step can be repeated multiple times in a given job to update multiple fields on the same or different projects.* 55 | 56 | ### Roadmap 57 | 58 | The Action is largely feature complete with regards to its initial goals. Find a bug or have a feature request? [Open an issue](https://github.com/benbalter/update-project-action/issues), or better yet, submit a pull request - contribution welcome! 59 | 60 | ### Inputs 61 | 62 | * `content_id` - The global ID of the issue or pull request within the project 63 | * `field` - The field on the project to set the value of 64 | * `github_token` - A GitHub Token with access to both the source issue and the destination project (`repo` and `write:org` scopes) 65 | * `operation` - Operation type (update or read) 66 | * `organization` - The organization that contains the project, defaults to the current repository owner 67 | * `project_number` - The project number from the project's URL 68 | * `value` - The value to set the project field to. Only required for operation type read 69 | 70 | ### Outputs 71 | 72 | * `field_id` - The global ID of the field 73 | * `field_read_value` - The value of the field before the update 74 | * `field_type` - The updated field's ProjectV2FieldType (text, single_select, number, date, or iteration) 75 | * `field_updated_value` - The value of the field after the update 76 | * `item_id` - The global ID of the issue or pull request 77 | * `item_title` - The title of the issue or pull request 78 | * `option_id` - The global ID of the selected option 79 | * `project_id` - The global ID of the project 80 | 81 | ### V1 vs V2 82 | 83 | In June 2022, [GitHub announced a breaking change to the Projects API](https://github.blog/changelog/2022-06-23-the-new-github-issues-june-23rd-update/). As such, the `@v1` tag of this action will ceased working on October 1st, 2022. You can upgrade to the `@v2` tag (by updating the reference in your Workflow file) at any time. 84 | -------------------------------------------------------------------------------- /__test__/main.test.ts: -------------------------------------------------------------------------------- 1 | import * as updateProject from "../src/update-project"; 2 | import fetchMock from "fetch-mock"; 3 | import { runInContext } from "vm"; 4 | 5 | test("ensureExists returns false", () => { 6 | const result = updateProject.ensureExists(undefined, "test", "test"); 7 | expect(result).toBe(false); 8 | }); 9 | 10 | test("ensureExists returns true", () => { 11 | const result = updateProject.ensureExists("test", "test", "test"); 12 | expect(result).toBe(true); 13 | }); 14 | 15 | test("valueGraphqlType returns Date for date", () => { 16 | const result = updateProject.valueGraphqlType("date"); 17 | expect(result).toBe("Date"); 18 | }); 19 | 20 | test("valueGraphqlType returns Float for number", () => { 21 | const result = updateProject.valueGraphqlType("number"); 22 | expect(result).toBe("Float"); 23 | }); 24 | 25 | test("valueGraphqlType returns String for text", () => { 26 | const result = updateProject.valueGraphqlType("text"); 27 | expect(result).toBe("String"); 28 | }); 29 | 30 | describe("with environmental variables", () => { 31 | const OLD_ENV = process.env; 32 | const INPUTS = { 33 | INPUT_CONTENT_ID: "1", 34 | INPUT_FIELD: "test", 35 | INPUT_VALUE: "test", 36 | INPUT_PROJECT_NUMBER: "1", 37 | INPUT_ORGANIZATION: "github", 38 | }; 39 | 40 | beforeEach(() => { 41 | jest.resetModules(); 42 | process.env = { ...OLD_ENV, ...INPUTS }; 43 | }); 44 | 45 | afterAll(() => { 46 | process.env = OLD_ENV; 47 | }); 48 | 49 | test("getInputs returns inputs", () => { 50 | const result = updateProject.getInputs(); 51 | expect(result.contentId).toEqual(INPUTS.INPUT_CONTENT_ID); 52 | }); 53 | 54 | test("getInputs defaults to update", () => { 55 | const result = updateProject.getInputs(); 56 | expect(result.operation).toEqual("update"); 57 | }); 58 | 59 | test("getInputs accepts read", () => { 60 | process.env = { ...process.env, ...{ INPUT_OPERATION: "read" } }; 61 | const result = updateProject.getInputs(); 62 | expect(result.operation).toEqual("read"); 63 | }); 64 | 65 | test("getInputs doesn't accept other operations", () => { 66 | process.env = { ...process.env, ...{ INPUT_OPERATION: "foo" } }; 67 | const result = updateProject.getInputs(); 68 | expect(result).toEqual({}); 69 | }); 70 | }); 71 | 72 | describe("with Octokit setup", () => { 73 | const OLD_ENV = process.env; 74 | const INPUTS = { 75 | INPUT_CONTENT_ID: "1", 76 | INPUT_FIELD: "testField", 77 | INPUT_VALUE: "testValue", 78 | INPUT_PROJECT_NUMBER: "1", 79 | INPUT_ORGANIZATION: "github", 80 | INPUT_GITHUB_TOKEN: "testToken", 81 | }; 82 | let mock: typeof fetchMock; 83 | 84 | /** 85 | * Mocks a GraphQL request 86 | * 87 | * @param data a JSON object to return from the mock 88 | * @param name a unique string identifier for the mock 89 | * @param body a string to match against the request body since all GraphQL calls go to the same endpoint 90 | */ 91 | const mockGraphQL = ( 92 | data: { [key: string]: any }, 93 | name: string, 94 | body?: String 95 | ) => { 96 | const response = { status: 200, body: data }; 97 | const matcher = (_: string, options: { [key: string]: any }): boolean => { 98 | if (!body) { 99 | return true; 100 | } 101 | const haystack = options.body || ""; 102 | return haystack.toString().includes(body); 103 | }; 104 | mock.once( 105 | { 106 | method: "POST", 107 | url: "https://api.github.com/graphql", 108 | name: name, 109 | functionMatcher: matcher, 110 | }, 111 | response 112 | ); 113 | }; 114 | 115 | /** 116 | * Mocks a ContentMetadata GraphQL call 117 | * 118 | * @param title The title of the content 119 | * @param item Object with the content metadata 120 | */ 121 | const mockContentMetadata = ( 122 | title: String, 123 | item: { 124 | field?: { value?: string }; 125 | project: { number: number; owner: { login: string } }; 126 | } 127 | ) => { 128 | const data = { 129 | data: { 130 | node: { 131 | title: title, 132 | projectItems: { 133 | nodes: [item, { project: { number: 2, owner: { login: "foo" } } }], 134 | }, 135 | }, 136 | }, 137 | }; 138 | mockGraphQL(data, "contentMetadata", "projectItems"); 139 | }; 140 | 141 | /** 142 | * Mocks a projectMetadata GraphQL call 143 | * 144 | * @param projectId the numeric project number 145 | * @param field Field metadata object 146 | */ 147 | const mockProjectMetadata = ( 148 | projectId: number, 149 | field: { [key: string]: any } 150 | ) => { 151 | const data = { 152 | data: { 153 | organization: { 154 | projectV2: { 155 | id: projectId, 156 | fields: { 157 | nodes: [field], 158 | }, 159 | }, 160 | }, 161 | }, 162 | }; 163 | mockGraphQL(data, "projectMetadata", "projectV2"); 164 | }; 165 | 166 | beforeEach(() => { 167 | jest.resetModules(); 168 | process.env = { ...OLD_ENV, ...INPUTS }; 169 | fetchMock.config.sendAsJson = true; 170 | mock = fetchMock.sandbox(); 171 | let options = { request: { fetch: mock } }; 172 | updateProject.setupOctokit(options); 173 | }); 174 | 175 | afterAll(() => { 176 | process.env = OLD_ENV; 177 | }); 178 | 179 | test("fetchContentMetadata fetches content metadata", async () => { 180 | const item = { project: { number: 1, owner: { login: "github" } } }; 181 | mockContentMetadata("test", item); 182 | 183 | const result = await updateProject.fetchContentMetadata( 184 | "1", 185 | "test", 186 | 1, 187 | "github" 188 | ); 189 | expect(result).toEqual({ ...item, ...{ title: "test" } }); 190 | expect(mock.done()).toBe(true); 191 | }); 192 | 193 | test("fetchContentMetadata returns empty object if not found", async () => { 194 | const item = { project: { number: 1, owner: { login: "github" } } }; 195 | mockContentMetadata("test", item); 196 | 197 | const result = await updateProject.fetchContentMetadata( 198 | "2", 199 | "test", 200 | 2, 201 | "github" 202 | ); 203 | expect(result).toEqual({}); 204 | expect(mock.done()).toBe(true); 205 | }); 206 | 207 | test("fetchProjectMetadata fetches project metadata", async () => { 208 | const expected = { 209 | projectId: 1, 210 | field: { 211 | fieldId: 1, 212 | fieldType: "single_select", 213 | optionId: 1, 214 | }, 215 | }; 216 | 217 | const field = { 218 | id: 1, 219 | name: "testField", 220 | dataType: "single_select", 221 | options: [ 222 | { 223 | id: 1, 224 | name: "testValue", 225 | }, 226 | ], 227 | }; 228 | mockProjectMetadata(1, field); 229 | 230 | const result = await updateProject.fetchProjectMetadata( 231 | "github", 232 | 1, 233 | "testField", 234 | "testValue", 235 | "update" 236 | ); 237 | expect(result).toEqual(expected); 238 | expect(mock.done()).toBe(true); 239 | }); 240 | 241 | test("fetchProjectMetadata returns empty object if field is not found", async () => { 242 | const field = { 243 | id: 1, 244 | name: "testField", 245 | dataType: "single_select", 246 | options: [ 247 | { 248 | id: 1, 249 | name: "testValue", 250 | }, 251 | ], 252 | }; 253 | mockProjectMetadata(1, field); 254 | 255 | const missingField = await updateProject.fetchProjectMetadata( 256 | "github", 257 | 1, 258 | "missingField", 259 | "testValue", 260 | "update" 261 | ); 262 | expect(missingField).toEqual({}); 263 | expect(mock.done()).toBe(true); 264 | }); 265 | 266 | test("fetchProjectMetadata returns empty object if value is not found", async () => { 267 | const field = { 268 | id: 1, 269 | name: "testField", 270 | dataType: "single_select", 271 | options: [ 272 | { 273 | id: 1, 274 | name: "testValue", 275 | }, 276 | ], 277 | }; 278 | mockProjectMetadata(1, field); 279 | 280 | const missingValue = await updateProject.fetchProjectMetadata( 281 | "github", 282 | 1, 283 | "testField", 284 | "missingValue", 285 | "update" 286 | ); 287 | expect(missingValue).toEqual({}); 288 | expect(mock.done()).toBe(true); 289 | }); 290 | 291 | test("fetchProjectMetadata returns empty object if project is not found", async () => { 292 | const data = { 293 | data: { 294 | organization: { 295 | projectV2: null, 296 | }, 297 | }, 298 | }; 299 | 300 | mockGraphQL(data, "missingProjectMetadata", "projectV2"); 301 | 302 | const missingValue = await updateProject.fetchProjectMetadata( 303 | "github", 304 | 1, 305 | "testField", 306 | "missingValue", 307 | "update" 308 | ); 309 | expect(missingValue).toEqual({}); 310 | expect(mock.done()).toBe(true); 311 | }); 312 | 313 | test("updateField with single_select", async () => { 314 | const item = { project: { number: 1, owner: { login: "github" } } }; 315 | mockContentMetadata("test", item); 316 | 317 | const field = { 318 | id: 1, 319 | name: "testField", 320 | dataType: "single_select", 321 | options: [ 322 | { 323 | id: 1, 324 | name: "testValue", 325 | }, 326 | ], 327 | }; 328 | mockProjectMetadata(1, field); 329 | 330 | const data = { data: { projectV2Item: { id: 1 } } }; 331 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 332 | 333 | const projectMetadata = await updateProject.fetchProjectMetadata( 334 | "github", 335 | 1, 336 | "testField", 337 | "testValue", 338 | "update" 339 | ); 340 | const contentMetadata = await updateProject.fetchContentMetadata( 341 | "1", 342 | "test", 343 | 1, 344 | "github" 345 | ); 346 | const result = await updateProject.updateField( 347 | projectMetadata, 348 | contentMetadata, 349 | "new value" 350 | ); 351 | expect(result).toEqual(data.data); 352 | expect(mock.done()).toBe(true); 353 | }); 354 | 355 | test("fetchProjectMetadata returns empty object if project is not found", async () => { 356 | const data = { 357 | data: { 358 | organization: { 359 | projectV2: null, 360 | }, 361 | }, 362 | }; 363 | 364 | mockGraphQL(data, "missingProjectMetadata", "projectV2"); 365 | 366 | const missingValue = await updateProject.fetchProjectMetadata( 367 | "github", 368 | 1, 369 | "testField", 370 | "missingValue", 371 | "update" 372 | ); 373 | expect(missingValue).toEqual({}); 374 | expect(mock.done()).toBe(true); 375 | }); 376 | 377 | test("updateField with text", async () => { 378 | const item = { project: { number: 1, owner: { login: "github" } } }; 379 | mockContentMetadata("test", item); 380 | 381 | const field = { 382 | id: 1, 383 | name: "testField", 384 | dataType: "text", 385 | value: "testValue", 386 | }; 387 | mockProjectMetadata(1, field); 388 | 389 | const data = { data: { projectV2Item: { id: 1 } } }; 390 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 391 | 392 | const projectMetadata = await updateProject.fetchProjectMetadata( 393 | "github", 394 | 1, 395 | "testField", 396 | "testValue", 397 | "update" 398 | ); 399 | const contentMetadata = await updateProject.fetchContentMetadata( 400 | "1", 401 | "test", 402 | 1, 403 | "github" 404 | ); 405 | const result = await updateProject.updateField( 406 | projectMetadata, 407 | contentMetadata, 408 | "new value" 409 | ); 410 | expect(result).toEqual(data.data); 411 | expect(mock.done()).toBe(true); 412 | }); 413 | 414 | test("run updates a field that was not empty", async () => { 415 | const item = { 416 | field: { value: "testValue" }, 417 | project: { number: 1, owner: { login: "github" } }, 418 | }; 419 | mockContentMetadata("testField", item); 420 | 421 | const field = { 422 | id: 1, 423 | name: "testField", 424 | dataType: "single_select", 425 | options: [ 426 | { 427 | id: 1, 428 | name: "testValue", 429 | }, 430 | ], 431 | }; 432 | mockProjectMetadata(1, field); 433 | 434 | const data = { data: { projectV2Item: { id: 1 } } }; 435 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 436 | 437 | await updateProject.run(); 438 | expect(mock.done()).toBe(true); 439 | }); 440 | 441 | test("run updates a field that was empty", async () => { 442 | const item = { 443 | project: { number: 1, owner: { login: "github" } }, 444 | }; 445 | mockContentMetadata("testField", item); 446 | 447 | const field = { 448 | id: 1, 449 | name: "testField", 450 | dataType: "single_select", 451 | options: [ 452 | { 453 | id: 1, 454 | name: "testValue", 455 | }, 456 | ], 457 | }; 458 | mockProjectMetadata(1, field); 459 | 460 | const data = { data: { projectV2Item: { id: 1 } } }; 461 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 462 | 463 | await updateProject.run(); 464 | expect(mock.done()).toBe(true); 465 | }); 466 | 467 | test("run reads a field that is not empty", async () => { 468 | process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "read" } }; 469 | 470 | const item = { 471 | field: { value: "testValue" }, 472 | project: { number: 1, owner: { login: "github" } }, 473 | }; 474 | mockContentMetadata("testField", item); 475 | 476 | const field = { 477 | id: 1, 478 | name: "testField", 479 | dataType: "single_select", 480 | options: [ 481 | { 482 | id: 1, 483 | name: "testValue", 484 | }, 485 | ], 486 | }; 487 | mockProjectMetadata(1, field); 488 | 489 | await updateProject.run(); 490 | expect(mock.done()).toBe(true); 491 | }); 492 | 493 | test("run reads a field that is empty", async () => { 494 | process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "read" } }; 495 | 496 | const item = { 497 | project: { number: 1, owner: { login: "github" } }, 498 | }; 499 | mockContentMetadata("testField", item); 500 | 501 | const field = { 502 | id: 1, 503 | name: "testField", 504 | dataType: "single_select", 505 | options: [ 506 | { 507 | id: 1, 508 | name: "testValue", 509 | }, 510 | ], 511 | }; 512 | mockProjectMetadata(1, field); 513 | 514 | await updateProject.run(); 515 | expect(mock.done()).toBe(true); 516 | }); 517 | }); 518 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Update project 2 | description: Updates an item's fields on a GitHub Projects (beta) board based on a workflow dispatch (or other) event's input. 3 | inputs: 4 | organization: 5 | description: The organization that contains the project, defaults to the current repository owner 6 | required: false 7 | default: ${{ github.repository_owner }} 8 | project_number: 9 | description: The project number from the project's URL 10 | required: true 11 | operation: 12 | description: Operation type (update or read) 13 | default: update 14 | required: false 15 | content_id: 16 | description: The global ID of the issue or pull request within the project 17 | required: true 18 | field: 19 | description: The field on the project to set the value of 20 | required: true 21 | value: 22 | description: The value to set the project field to. Only required for operation type read 23 | required: false 24 | github_token: 25 | description: A GitHub Token with access to both the source issue and the destination project (`repo` and `write:org` scopes) 26 | required: true 27 | outputs: 28 | project_id: 29 | description: "The global ID of the project" 30 | value: ${{ steps.parse_project_metadata.outputs.project_id }} 31 | item_id: 32 | description: "The global ID of the issue or pull request" 33 | value: ${{ steps.parse_project_metadata.outputs.item_id }} 34 | item_title: 35 | description: "The title of the issue or pull request" 36 | value: ${{ steps.parse_project_metadata.outputs.item_title }} 37 | field_id: 38 | description: "The global ID of the field" 39 | value: ${{ steps.parse_project_metadata.outputs.field_id }} 40 | field_read_value: 41 | description: "The value of the field before the update" 42 | value: ${{ steps.parse_content_metadata.outputs.item_value }} 43 | field_updated_value: 44 | description: "The value of the field after the update" 45 | value: ${{ steps.output_values.outputs.field_updated_value }} 46 | field_type: 47 | description: "The updated field's ProjectV2FieldType (text, single_select, number, date, or iteration)" 48 | value: ${{ steps.parse_project_metadata.outputs.field_type }} 49 | option_id: 50 | description: "The global ID of the selected option" 51 | value: ${{ steps.parse_project_metadata.outputs.option_id }} 52 | runs: 53 | using: 'node20' 54 | main: 'dist/index.js' 55 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest' 7 | }, 8 | verbose: true, 9 | "collectCoverage": true, 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "update-project-action", 3 | "version": "0.0.1", 4 | "description": "Updates an item's fields on a GitHub Projects (beta) board based on a workflow dispatch (or other) event's input.", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "format": "prettier --write '**/*.ts'", 9 | "format-check": "prettier --check '**/*.ts'", 10 | "lint": "eslint src/**/*.ts --fix", 11 | "package": "ncc build src/main.ts", 12 | "test": "jest", 13 | "all": "npm run build && npm run format && npm run lint && npm run package && npm test" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/github/update-project-action.git" 18 | }, 19 | "author": "", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/github/update-project-action/issues" 23 | }, 24 | "homepage": "https://github.com/github/update-project-action#readme", 25 | "dependencies": { 26 | "@actions/core": "^1.10.0", 27 | "@actions/github": "^5.0.3", 28 | "@octokit/core": "^4.1.0", 29 | "@octokit/graphql": "^5.0.5", 30 | "dotenv": "^16.0.1" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "^29.2.4", 34 | "@types/node": "^18.7.13", 35 | "@typescript-eslint/eslint-plugin": "^5.49.0", 36 | "@typescript-eslint/parser": "^5.49.0", 37 | "@vercel/ncc": "^0.38.0", 38 | "babel-jest": "^28.1.3", 39 | "eslint": "^8.32.0", 40 | "eslint-plugin-github": "^4.3.7", 41 | "eslint-plugin-jest": "^27.2.1", 42 | "fetch-mock": "^9.11.0", 43 | "jest": "^28.1.3", 44 | "js-yaml": "^4.1.0", 45 | "prettier": "2.8.3", 46 | "ts-jest": "^28.0.8", 47 | "ts-node": "^10.9.1", 48 | "typescript": ">=3.3.1, <4.10.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | npm install -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | npm run all -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | yamllint *.yml -------------------------------------------------------------------------------- /script/update-readme: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # Updates the Input and Output sections of the README based on the definition in the `actions.yml` file 3 | 4 | require 'yaml' 5 | 6 | yaml_path = File.expand_path '../action.yml', __dir__ 7 | yaml = YAML.load_file(yaml_path) 8 | 9 | readme_path = File.expand_path '../README.md', __dir__ 10 | readme = File.read(readme_path) 11 | 12 | %w[Inputs Outputs].each do |section| 13 | output = yaml[section.downcase].sort_by { |k,_v| k }.map do |k, v| 14 | "* `#{k}` - #{v["description"]}\n" 15 | end 16 | 17 | regex = /### #{section}\n(.*?)(?:\n\n|\n?\z)/m 18 | readme = readme.sub(regex, "### #{section}\n\n#{output.join}\n") 19 | end 20 | 21 | File.write(readme_path, readme) -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { setFailed } from "@actions/core"; 3 | import { run, setupOctokit } from "./update-project"; 4 | 5 | try { 6 | setupOctokit(); 7 | run(); 8 | } catch (e) { 9 | if (e instanceof Error) setFailed(e.message); 10 | } 11 | -------------------------------------------------------------------------------- /src/update-project.ts: -------------------------------------------------------------------------------- 1 | import { getInput, setFailed, info, setOutput } from "@actions/core"; 2 | import { getOctokit } from "@actions/github"; 3 | import type { GraphQlQueryResponseData } from "@octokit/graphql"; 4 | 5 | let octokit: ReturnType; 6 | 7 | /** 8 | * Fetch the metadata for the content item 9 | * 10 | * @param {string} contentId - The ID of the content to fetch 11 | * @param {string} fieldName - The name of the field to fetch 12 | * @param {number} projectNumber - The number of the project 13 | * @param {string} owner - The owner of the project 14 | * @returns {Promise} - The content metadata 15 | */ 16 | export async function fetchContentMetadata( 17 | contentId: string, 18 | fieldName: string, 19 | projectNumber: number, 20 | owner: string 21 | ): Promise { 22 | const result: GraphQlQueryResponseData = await octokit.graphql( 23 | ` 24 | fragment ProjectItemFields on ProjectV2Item { 25 | id 26 | project { 27 | number 28 | owner { 29 | ... on Organization { 30 | login 31 | } 32 | ... on User { 33 | login 34 | } 35 | } 36 | } 37 | field: fieldValueByName(name: $fieldName) { 38 | ... on ProjectV2ItemFieldSingleSelectValue { 39 | value: name 40 | } 41 | ... on ProjectV2ItemFieldNumberValue { 42 | value: number 43 | } 44 | ... on ProjectV2ItemFieldTextValue { 45 | value: text 46 | } 47 | ... on ProjectV2ItemFieldDateValue { 48 | value: date 49 | } 50 | } 51 | } 52 | 53 | query result($contentId: ID!, $fieldName: String!) { 54 | node(id: $contentId) { 55 | ... on Issue { 56 | id 57 | title 58 | projectItems(first: 100) { 59 | nodes { 60 | ...ProjectItemFields 61 | } 62 | } 63 | } 64 | ... on PullRequest { 65 | id 66 | title 67 | projectItems(first: 100) { 68 | nodes { 69 | ...ProjectItemFields 70 | } 71 | } 72 | } 73 | } 74 | } 75 | `, 76 | { contentId, fieldName } 77 | ); 78 | 79 | const item = result.node.projectItems.nodes.find( 80 | (node: GraphQlQueryResponseData) => { 81 | return ( 82 | node.project.number === projectNumber && 83 | node.project.owner.login === owner 84 | ); 85 | } 86 | ); 87 | const itemTitle = result.node.title; 88 | 89 | if (!ensureExists(item, "content", `ID ${contentId}`)) { 90 | return {}; 91 | } else { 92 | return { ...item, title: itemTitle }; 93 | } 94 | } 95 | 96 | /** 97 | * Fetch the metadata for the project 98 | * @param {string} owner - The owner of the project 99 | * @param {number} projectNumber - The number of the project 100 | * @returns {Promise} - The project metadata 101 | */ 102 | export async function fetchProjectMetadata( 103 | owner: string, 104 | projectNumber: number, 105 | fieldName: string, 106 | value: string, 107 | operation: string 108 | ): Promise { 109 | const result: GraphQlQueryResponseData = await octokit.graphql( 110 | ` 111 | query ($organization: String!, $projectNumber: Int!) { 112 | organization(login: $organization) { 113 | projectV2(number: $projectNumber) { 114 | id 115 | fields(first: 100) { 116 | nodes { 117 | ... on ProjectV2FieldCommon { 118 | id 119 | name 120 | dataType 121 | } 122 | ... on ProjectV2SingleSelectField { 123 | options { 124 | id 125 | name 126 | } 127 | } 128 | } 129 | } 130 | } 131 | } 132 | } 133 | `, 134 | { organization: owner, projectNumber } 135 | ); 136 | 137 | // Ensure project was found 138 | if ( 139 | !ensureExists( 140 | result.organization.projectV2?.id, 141 | "project", 142 | `Number ${projectNumber}, Owner ${owner}` 143 | ) 144 | ) { 145 | return {}; 146 | } 147 | 148 | const field = result.organization.projectV2.fields.nodes.find( 149 | (f: GraphQlQueryResponseData) => f.name === fieldName 150 | ); 151 | 152 | // Ensure field was found 153 | if (!ensureExists(field, "Field", `Name ${fieldName}`)) { 154 | return {}; 155 | } 156 | 157 | const option = field.options?.find( 158 | (o: GraphQlQueryResponseData) => o.name === value 159 | ); 160 | 161 | // Ensure option was found, if field is single select 162 | if (field.dataType === "single_select" && operation === "update") { 163 | if (!ensureExists(option, "Option", `Value ${value}`)) { 164 | return {}; 165 | } 166 | } 167 | 168 | return { 169 | projectId: result.organization.projectV2.id, 170 | field: { 171 | fieldId: field.id, 172 | fieldType: field.dataType.toLowerCase(), 173 | optionId: option?.id, 174 | }, 175 | }; 176 | } 177 | 178 | /** 179 | * Ensure a returned value exists 180 | * 181 | * @param {any} returnedValue - The value to check 182 | * @param {string} label - The label to use in the error message 183 | * @param {string} identifier - The identifier to use in the error message 184 | * @returns {bool} - True if the value exists, false otherwise 185 | */ 186 | export function ensureExists( 187 | returnedValue: any, 188 | label: string, 189 | identifier: string 190 | ) { 191 | if (returnedValue === undefined) { 192 | setFailed(`${label} not found with ${identifier}`); 193 | return false; 194 | } else { 195 | info(`Found ${label}: ${JSON.stringify(returnedValue)}`); 196 | return true; 197 | } 198 | } 199 | 200 | /** 201 | * Converts the field type to the GraphQL type 202 | * @param {string} fieldType - the field type returned from fetchProjectMetadata() 203 | * @returns {string} - the field type to use in the GraphQL query 204 | */ 205 | export function valueGraphqlType(fieldType: String): String { 206 | if (fieldType === "date") { 207 | return "Date"; 208 | } else if (fieldType === "number") { 209 | return "Float"; 210 | } else { 211 | return "String"; 212 | } 213 | } 214 | 215 | /** 216 | * Updates the field value for the content item 217 | * @param {GraphQlQueryResponseData} projectMetadata - The project metadata returned from fetchProjectMetadata() 218 | * @param {GraphQlQueryResponseData} contentMetadata - The content metadata returned from fetchContentMetadata() 219 | * @return {Promise} - The updated content metadata 220 | */ 221 | export async function updateField( 222 | projectMetadata: GraphQlQueryResponseData, 223 | contentMetadata: GraphQlQueryResponseData, 224 | value: string 225 | ): Promise { 226 | let valueType: string; 227 | let valueToSet: string; 228 | 229 | if (projectMetadata.field.fieldType === "single_select") { 230 | valueToSet = projectMetadata.field.optionId; 231 | valueType = "singleSelectOptionId"; 232 | } else { 233 | valueToSet = value; 234 | valueType = projectMetadata.field.fieldType; 235 | } 236 | 237 | const result: GraphQlQueryResponseData = await octokit.graphql( 238 | ` 239 | mutation($project: ID!, $item: ID!, $field: ID!, $value: ${valueGraphqlType( 240 | projectMetadata.field.fieldType 241 | )}) { 242 | updateProjectV2ItemFieldValue( 243 | input: { 244 | projectId: $project 245 | itemId: $item 246 | fieldId: $field 247 | value: { 248 | ${valueType}: $value 249 | } 250 | } 251 | ) { 252 | projectV2Item { 253 | id 254 | } 255 | } 256 | } 257 | `, 258 | { 259 | project: projectMetadata.projectId, 260 | item: contentMetadata.id, 261 | field: projectMetadata.field.fieldId, 262 | value: valueToSet, 263 | } 264 | ); 265 | 266 | return result; 267 | } 268 | 269 | /** 270 | * Returns the validated and normalized inputs for the action 271 | * 272 | * @returns {object} - The inputs for the action 273 | */ 274 | export function getInputs(): { [key: string]: any } { 275 | let operation = getInput("operation"); 276 | if (operation === "") operation = "update"; 277 | 278 | if (!["read", "update"].includes(operation)) { 279 | setFailed( 280 | `Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update)` 281 | ); 282 | 283 | return {}; 284 | } 285 | 286 | const inputs = { 287 | contentId: getInput("content_id", { required: true }), 288 | fieldName: getInput("field", { required: true }), 289 | projectNumber: parseInt(getInput("project_number", { required: true })), 290 | owner: getInput("organization", { required: true }), 291 | value: getInput("value", { required: operation === "update" }), 292 | operation, 293 | }; 294 | 295 | info(`Inputs: ${JSON.stringify(inputs)}`); 296 | 297 | return inputs; 298 | } 299 | 300 | /** 301 | * Setups up a shared Octokit instance hydrated with Actions information 302 | * 303 | * @param options - Octokit options 304 | */ 305 | export function setupOctokit(options?: { [key: string]: any }): void { 306 | const token = getInput("github_token", { required: true }); 307 | octokit = getOctokit(token, options); 308 | } 309 | 310 | /** 311 | * The main event: Updates the selected field with the given value 312 | */ 313 | export async function run(): Promise { 314 | const inputs = getInputs(); 315 | if (Object.entries(inputs).length === 0) return; 316 | 317 | const contentMetadata = await fetchContentMetadata( 318 | inputs.contentId, 319 | inputs.fieldName, 320 | inputs.projectNumber, 321 | inputs.owner 322 | ); 323 | if (Object.entries(contentMetadata).length === 0) return; 324 | 325 | const projectMetadata = await fetchProjectMetadata( 326 | inputs.owner, 327 | inputs.projectNumber, 328 | inputs.fieldName, 329 | inputs.value, 330 | inputs.operation 331 | ); 332 | if (Object.entries(projectMetadata).length === 0) return; 333 | 334 | setOutput("field_read_value", contentMetadata.field?.value); 335 | if (inputs.operation === "update") { 336 | await updateField(projectMetadata, contentMetadata, inputs.value); 337 | setOutput("field_updated_value", inputs.value); 338 | info( 339 | `Updated field ${inputs.fieldName} on ${contentMetadata.title} to ${inputs.value}` 340 | ); 341 | } else { 342 | setOutput("field_updated_value", contentMetadata.field?.value); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./dist", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "strict": true, /* Enable all strict type-checking options. */ 8 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | }, 11 | "exclude": ["node_modules", "**/*.test.ts"] 12 | } --------------------------------------------------------------------------------