├── .tool-versions ├── .prettierignore ├── script ├── cibuild ├── lint ├── bootstrap └── update-readme ├── .eslintignore ├── .gitignore ├── .yamllint ├── jest.config.js ├── src ├── main.ts └── update-project.ts ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── build.yml ├── tsconfig.json ├── LICENSE.md ├── package.json ├── action.yml ├── README.md └── __test__ └── main.test.ts /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 16.17.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | npm install -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | coverage/* 3 | node_modules/ 4 | dist/*.js 5 | !dist/index.js 6 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | extends: default 2 | 3 | rules: 4 | line-length: disable 5 | document-start: disable -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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/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 | permissions: 12 | contents: read 13 | 14 | steps: 15 | - name: checkout 16 | uses: actions/checkout@v5 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 16 21 | cache: 'npm' 22 | 23 | - name: bootstrap 24 | run: script/bootstrap 25 | 26 | - name: test 27 | run: script/cibuild 28 | 29 | - name: lint 30 | run: script/lint -------------------------------------------------------------------------------- /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) -------------------------------------------------------------------------------- /.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 | permissions: 16 | contents: write 17 | pull-requests: write 18 | 19 | steps: 20 | - uses: actions/checkout@v5 21 | 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 16 25 | cache: 'npm' 26 | 27 | - name: script/bootstrap 28 | run: script/bootstrap 29 | 30 | - name: script/cibuild 31 | run: script/cibuild 32 | 33 | - name: Update README 34 | run: script/update-readme 35 | 36 | - name: Create Pull Request 37 | uses: peter-evans/create-pull-request@cb4d3bfce175d44325c6b7697f81e0afe8a79bdf 38 | with: 39 | commit-message: Update build 40 | title: Update build -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.11.1", 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 | -------------------------------------------------------------------------------- /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, read, or clear) 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 | -------------------------------------------------------------------------------- /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 | - name: Clear due date 53 | id: clear_due_date 54 | uses: github/update-project-action@v3 55 | with: 56 | github_token: ${{ secrets.STATUS_UPDATE_TOKEN }} 57 | organization: github 58 | project_number: 1234 59 | content_id: ${{ github.event.client_payload.command.resource.id }} 60 | field: "Due Date" 61 | operation: clear 62 | ``` 63 | 64 | *Note: The above step can be repeated multiple times in a given job to update multiple fields on the same or different projects.* 65 | 66 | ### Roadmap 67 | 68 | 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! 69 | 70 | ### Inputs 71 | 72 | * `content_id` - The global ID of the issue or pull request within the project 73 | * `field` - The field on the project to set the value of 74 | * `github_token` - A GitHub Token with access to both the source issue and the destination project (`repo` and `write:org` scopes) 75 | * `operation` - Operation type (update, read, or clear) 76 | * `organization` - The organization that contains the project, defaults to the current repository owner 77 | * `project_number` - The project number from the project's URL 78 | * `value` - The value to set the project field to. Only required for operation type `update`; not required for `read` or `clear`. 79 | 80 | ### Outputs 81 | 82 | * `field_id` - The global ID of the field 83 | * `field_read_value` - The value of the field before the update 84 | * `field_type` - The updated field's ProjectV2FieldType (text, single_select, number, date, or iteration) 85 | * `field_updated_value` - The value of the field after the update 86 | * `item_id` - The global ID of the issue or pull request 87 | * `item_title` - The title of the issue or pull request 88 | * `option_id` - The global ID of the selected option 89 | * `project_id` - The global ID of the project 90 | 91 | ### V1 vs V2 92 | 93 | 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. 94 | -------------------------------------------------------------------------------- /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 | * Converts a string value to the appropriate type for the field 217 | * @param {string} value - the string value from the action input 218 | * @param {string} fieldType - the field type from the project metadata 219 | * @returns {string | number} - the converted value 220 | */ 221 | export function convertValueToFieldType( 222 | value: string, 223 | fieldType: string 224 | ): string | number { 225 | if (fieldType === "NUMBER") { 226 | const numValue = parseFloat(value); 227 | if (isNaN(numValue)) { 228 | throw new Error(`Invalid number value: ${value}`); 229 | } 230 | return numValue; 231 | } 232 | return value; 233 | } 234 | 235 | /** 236 | * Updates the field value for the content item 237 | * @param {GraphQlQueryResponseData} projectMetadata - The project metadata returned from fetchProjectMetadata() 238 | * @param {GraphQlQueryResponseData} contentMetadata - The content metadata returned from fetchContentMetadata() 239 | * @return {Promise} - The updated content metadata 240 | */ 241 | export async function updateField( 242 | projectMetadata: GraphQlQueryResponseData, 243 | contentMetadata: GraphQlQueryResponseData, 244 | value: string 245 | ): Promise { 246 | let valueType: string; 247 | let valueToSet: string | number; 248 | 249 | if (projectMetadata.field.fieldType === "single_select") { 250 | valueToSet = projectMetadata.field.optionId; 251 | valueType = "singleSelectOptionId"; 252 | } else { 253 | valueToSet = convertValueToFieldType( 254 | value, 255 | projectMetadata.field.fieldType 256 | ); 257 | valueType = projectMetadata.field.fieldType; 258 | } 259 | 260 | const result: GraphQlQueryResponseData = await octokit.graphql( 261 | ` 262 | mutation($project: ID!, $item: ID!, $field: ID!, $value: ${valueGraphqlType( 263 | projectMetadata.field.fieldType 264 | )}) { 265 | updateProjectV2ItemFieldValue( 266 | input: { 267 | projectId: $project 268 | itemId: $item 269 | fieldId: $field 270 | value: { 271 | ${valueType}: $value 272 | } 273 | } 274 | ) { 275 | projectV2Item { 276 | id 277 | } 278 | } 279 | } 280 | `, 281 | { 282 | project: projectMetadata.projectId, 283 | item: contentMetadata.id, 284 | field: projectMetadata.field.fieldId, 285 | value: valueToSet, 286 | } 287 | ); 288 | 289 | return result; 290 | } 291 | 292 | /** 293 | * Clears the field value for the content item 294 | * @param {GraphQlQueryResponseData} projectMetadata - The project metadata returned from fetchProjectMetadata() 295 | * @param {GraphQlQueryResponseData} contentMetadata - The content metadata returned from fetchContentMetadata() 296 | * @return {Promise} - The updated content metadata 297 | */ 298 | export async function clearField( 299 | projectMetadata: GraphQlQueryResponseData, 300 | contentMetadata: GraphQlQueryResponseData 301 | ): Promise { 302 | const result: GraphQlQueryResponseData = await octokit.graphql( 303 | ` 304 | mutation($project: ID!, $item: ID!, $field: ID!) { 305 | clearProjectV2ItemFieldValue( 306 | input: { 307 | projectId: $project 308 | itemId: $item 309 | fieldId: $field 310 | } 311 | ) { 312 | projectV2Item { 313 | id 314 | } 315 | } 316 | } 317 | `, 318 | { 319 | project: projectMetadata.projectId, 320 | item: contentMetadata.id, 321 | field: projectMetadata.field.fieldId, 322 | } 323 | ); 324 | 325 | return result; 326 | } 327 | 328 | /** 329 | * Returns the validated and normalized inputs for the action 330 | * 331 | * @returns {object} - The inputs for the action 332 | */ 333 | export function getInputs(): { [key: string]: any } { 334 | let operation = getInput("operation"); 335 | if (operation === "") operation = "update"; 336 | 337 | if (!["read", "update", "clear"].includes(operation)) { 338 | setFailed( 339 | `Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update, clear)` 340 | ); 341 | 342 | return {}; 343 | } 344 | 345 | const inputs = { 346 | contentId: getInput("content_id", { required: true }), 347 | fieldName: getInput("field", { required: true }), 348 | projectNumber: parseInt(getInput("project_number", { required: true })), 349 | owner: getInput("organization", { required: true }), 350 | value: getInput("value", { required: operation === "update" }), 351 | operation, 352 | }; 353 | 354 | info(`Inputs: ${JSON.stringify(inputs)}`); 355 | 356 | return inputs; 357 | } 358 | 359 | /** 360 | * Setups up a shared Octokit instance hydrated with Actions information 361 | * 362 | * @param options - Octokit options 363 | */ 364 | export function setupOctokit(options?: { [key: string]: any }): void { 365 | const token = getInput("github_token", { required: true }); 366 | octokit = getOctokit(token, options); 367 | } 368 | 369 | /** 370 | * The main event: Updates the selected field with the given value 371 | */ 372 | export async function run(): Promise { 373 | const inputs = getInputs(); 374 | if (Object.entries(inputs).length === 0) return; 375 | 376 | const contentMetadata = await fetchContentMetadata( 377 | inputs.contentId, 378 | inputs.fieldName, 379 | inputs.projectNumber, 380 | inputs.owner 381 | ); 382 | if (Object.entries(contentMetadata).length === 0) return; 383 | 384 | const projectMetadata = await fetchProjectMetadata( 385 | inputs.owner, 386 | inputs.projectNumber, 387 | inputs.fieldName, 388 | inputs.value, 389 | inputs.operation 390 | ); 391 | if (Object.entries(projectMetadata).length === 0) return; 392 | 393 | setOutput("field_read_value", contentMetadata.field?.value); 394 | if (inputs.operation === "update") { 395 | await updateField(projectMetadata, contentMetadata, inputs.value); 396 | setOutput("field_updated_value", inputs.value); 397 | info( 398 | `Updated field ${inputs.fieldName} on ${contentMetadata.title} to ${inputs.value}` 399 | ); 400 | } else if (inputs.operation === "clear") { 401 | await clearField(projectMetadata, contentMetadata); 402 | setOutput("field_updated_value", null); 403 | info(`Cleared field ${inputs.fieldName} on ${contentMetadata.title}`); 404 | } else { 405 | setOutput("field_updated_value", contentMetadata.field?.value); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /__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 | test("convertValueToFieldType converts string to number for number field", () => { 31 | const result = updateProject.convertValueToFieldType("42", "NUMBER"); 32 | expect(result).toBe(42); 33 | }); 34 | 35 | test("convertValueToFieldType converts string to float for number field", () => { 36 | const result = updateProject.convertValueToFieldType("3.14", "NUMBER"); 37 | expect(result).toBe(3.14); 38 | }); 39 | 40 | test("convertValueToFieldType converts zero string to number for number field", () => { 41 | const result = updateProject.convertValueToFieldType("0", "NUMBER"); 42 | expect(result).toBe(0); 43 | }); 44 | 45 | test("convertValueToFieldType throws error for invalid number", () => { 46 | expect(() => { 47 | updateProject.convertValueToFieldType("not-a-number", "NUMBER"); 48 | }).toThrow("Invalid number value: not-a-number"); 49 | }); 50 | 51 | test("convertValueToFieldType returns string for text field", () => { 52 | const result = updateProject.convertValueToFieldType("hello", "text"); 53 | expect(result).toBe("hello"); 54 | }); 55 | 56 | test("convertValueToFieldType returns string for date field", () => { 57 | const result = updateProject.convertValueToFieldType("2023-01-01", "date"); 58 | expect(result).toBe("2023-01-01"); 59 | }); 60 | 61 | describe("with environmental variables", () => { 62 | const OLD_ENV = process.env; 63 | const INPUTS = { 64 | INPUT_CONTENT_ID: "1", 65 | INPUT_FIELD: "test", 66 | INPUT_VALUE: "test", 67 | INPUT_PROJECT_NUMBER: "1", 68 | INPUT_ORGANIZATION: "github", 69 | }; 70 | 71 | beforeEach(() => { 72 | jest.resetModules(); 73 | process.env = { ...OLD_ENV, ...INPUTS }; 74 | }); 75 | 76 | afterAll(() => { 77 | process.env = OLD_ENV; 78 | }); 79 | 80 | test("getInputs returns inputs", () => { 81 | const result = updateProject.getInputs(); 82 | expect(result.contentId).toEqual(INPUTS.INPUT_CONTENT_ID); 83 | }); 84 | 85 | test("getInputs defaults to update", () => { 86 | const result = updateProject.getInputs(); 87 | expect(result.operation).toEqual("update"); 88 | }); 89 | 90 | test("getInputs accepts read", () => { 91 | process.env = { ...process.env, ...{ INPUT_OPERATION: "read" } }; 92 | const result = updateProject.getInputs(); 93 | expect(result.operation).toEqual("read"); 94 | }); 95 | 96 | test("getInputs accepts clear", () => { 97 | process.env = { ...process.env, ...{ INPUT_OPERATION: "clear" } }; 98 | const result = updateProject.getInputs(); 99 | expect(result.operation).toEqual("clear"); 100 | }); 101 | 102 | test("getInputs doesn't accept other operations", () => { 103 | process.env = { ...process.env, ...{ INPUT_OPERATION: "foo" } }; 104 | const result = updateProject.getInputs(); 105 | expect(result).toEqual({}); 106 | }); 107 | }); 108 | 109 | describe("with Octokit setup", () => { 110 | const OLD_ENV = process.env; 111 | const INPUTS = { 112 | INPUT_CONTENT_ID: "1", 113 | INPUT_FIELD: "testField", 114 | INPUT_VALUE: "testValue", 115 | INPUT_PROJECT_NUMBER: "1", 116 | INPUT_ORGANIZATION: "github", 117 | INPUT_GITHUB_TOKEN: "testToken", 118 | }; 119 | let mock: typeof fetchMock; 120 | 121 | /** 122 | * Mocks a GraphQL request 123 | * 124 | * @param data a JSON object to return from the mock 125 | * @param name a unique string identifier for the mock 126 | * @param body a string to match against the request body since all GraphQL calls go to the same endpoint 127 | */ 128 | const mockGraphQL = ( 129 | data: { [key: string]: any }, 130 | name: string, 131 | body?: String 132 | ) => { 133 | const response = { status: 200, body: data }; 134 | const matcher = (_: string, options: { [key: string]: any }): boolean => { 135 | if (!body) { 136 | return true; 137 | } 138 | const haystack = options.body || ""; 139 | return haystack.toString().includes(body); 140 | }; 141 | mock.once( 142 | { 143 | method: "POST", 144 | url: "https://api.github.com/graphql", 145 | name: name, 146 | functionMatcher: matcher, 147 | }, 148 | response 149 | ); 150 | }; 151 | 152 | /** 153 | * Mocks a ContentMetadata GraphQL call 154 | * 155 | * @param title The title of the content 156 | * @param item Object with the content metadata 157 | */ 158 | const mockContentMetadata = ( 159 | title: String, 160 | item: { 161 | field?: { value?: string }; 162 | project: { number: number; owner: { login: string } }; 163 | } 164 | ) => { 165 | const data = { 166 | data: { 167 | node: { 168 | title: title, 169 | projectItems: { 170 | nodes: [item, { project: { number: 2, owner: { login: "foo" } } }], 171 | }, 172 | }, 173 | }, 174 | }; 175 | mockGraphQL(data, "contentMetadata", "projectItems"); 176 | }; 177 | 178 | /** 179 | * Mocks a projectMetadata GraphQL call 180 | * 181 | * @param projectId the numeric project number 182 | * @param field Field metadata object 183 | */ 184 | const mockProjectMetadata = ( 185 | projectId: number, 186 | field: { [key: string]: any } 187 | ) => { 188 | const data = { 189 | data: { 190 | organization: { 191 | projectV2: { 192 | id: projectId, 193 | fields: { 194 | nodes: [field], 195 | }, 196 | }, 197 | }, 198 | }, 199 | }; 200 | mockGraphQL(data, "projectMetadata", "projectV2"); 201 | }; 202 | 203 | beforeEach(() => { 204 | jest.resetModules(); 205 | process.env = { ...OLD_ENV, ...INPUTS }; 206 | fetchMock.config.sendAsJson = true; 207 | mock = fetchMock.sandbox(); 208 | let options = { request: { fetch: mock } }; 209 | updateProject.setupOctokit(options); 210 | }); 211 | 212 | afterAll(() => { 213 | process.env = OLD_ENV; 214 | }); 215 | 216 | test("fetchContentMetadata fetches content metadata", async () => { 217 | const item = { project: { number: 1, owner: { login: "github" } } }; 218 | mockContentMetadata("test", item); 219 | 220 | const result = await updateProject.fetchContentMetadata( 221 | "1", 222 | "test", 223 | 1, 224 | "github" 225 | ); 226 | expect(result).toEqual({ ...item, ...{ title: "test" } }); 227 | expect(mock.done()).toBe(true); 228 | }); 229 | 230 | test("fetchContentMetadata returns empty object if not found", async () => { 231 | const item = { project: { number: 1, owner: { login: "github" } } }; 232 | mockContentMetadata("test", item); 233 | 234 | const result = await updateProject.fetchContentMetadata( 235 | "2", 236 | "test", 237 | 2, 238 | "github" 239 | ); 240 | expect(result).toEqual({}); 241 | expect(mock.done()).toBe(true); 242 | }); 243 | 244 | test("fetchProjectMetadata fetches project metadata", async () => { 245 | const expected = { 246 | projectId: 1, 247 | field: { 248 | fieldId: 1, 249 | fieldType: "single_select", 250 | optionId: 1, 251 | }, 252 | }; 253 | 254 | const field = { 255 | id: 1, 256 | name: "testField", 257 | dataType: "single_select", 258 | options: [ 259 | { 260 | id: 1, 261 | name: "testValue", 262 | }, 263 | ], 264 | }; 265 | mockProjectMetadata(1, field); 266 | 267 | const result = await updateProject.fetchProjectMetadata( 268 | "github", 269 | 1, 270 | "testField", 271 | "testValue", 272 | "update" 273 | ); 274 | expect(result).toEqual(expected); 275 | expect(mock.done()).toBe(true); 276 | }); 277 | 278 | test("fetchProjectMetadata returns empty object if field is not found", async () => { 279 | const field = { 280 | id: 1, 281 | name: "testField", 282 | dataType: "single_select", 283 | options: [ 284 | { 285 | id: 1, 286 | name: "testValue", 287 | }, 288 | ], 289 | }; 290 | mockProjectMetadata(1, field); 291 | 292 | const missingField = await updateProject.fetchProjectMetadata( 293 | "github", 294 | 1, 295 | "missingField", 296 | "testValue", 297 | "update" 298 | ); 299 | expect(missingField).toEqual({}); 300 | expect(mock.done()).toBe(true); 301 | }); 302 | 303 | test("fetchProjectMetadata returns empty object if value is not found", async () => { 304 | const field = { 305 | id: 1, 306 | name: "testField", 307 | dataType: "single_select", 308 | options: [ 309 | { 310 | id: 1, 311 | name: "testValue", 312 | }, 313 | ], 314 | }; 315 | mockProjectMetadata(1, field); 316 | 317 | const missingValue = await updateProject.fetchProjectMetadata( 318 | "github", 319 | 1, 320 | "testField", 321 | "missingValue", 322 | "update" 323 | ); 324 | expect(missingValue).toEqual({}); 325 | expect(mock.done()).toBe(true); 326 | }); 327 | 328 | test("fetchProjectMetadata returns empty object if project is not found", async () => { 329 | const data = { 330 | data: { 331 | organization: { 332 | projectV2: null, 333 | }, 334 | }, 335 | }; 336 | 337 | mockGraphQL(data, "missingProjectMetadata", "projectV2"); 338 | 339 | const missingValue = await updateProject.fetchProjectMetadata( 340 | "github", 341 | 1, 342 | "testField", 343 | "missingValue", 344 | "update" 345 | ); 346 | expect(missingValue).toEqual({}); 347 | expect(mock.done()).toBe(true); 348 | }); 349 | 350 | test("updateField with single_select", async () => { 351 | const item = { project: { number: 1, owner: { login: "github" } } }; 352 | mockContentMetadata("test", item); 353 | 354 | const field = { 355 | id: 1, 356 | name: "testField", 357 | dataType: "single_select", 358 | options: [ 359 | { 360 | id: 1, 361 | name: "testValue", 362 | }, 363 | ], 364 | }; 365 | mockProjectMetadata(1, field); 366 | 367 | const data = { data: { projectV2Item: { id: 1 } } }; 368 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 369 | 370 | const projectMetadata = await updateProject.fetchProjectMetadata( 371 | "github", 372 | 1, 373 | "testField", 374 | "testValue", 375 | "update" 376 | ); 377 | const contentMetadata = await updateProject.fetchContentMetadata( 378 | "1", 379 | "test", 380 | 1, 381 | "github" 382 | ); 383 | const result = await updateProject.updateField( 384 | projectMetadata, 385 | contentMetadata, 386 | "new value" 387 | ); 388 | expect(result).toEqual(data.data); 389 | expect(mock.done()).toBe(true); 390 | }); 391 | 392 | test("fetchProjectMetadata returns empty object if project is not found", async () => { 393 | const data = { 394 | data: { 395 | organization: { 396 | projectV2: null, 397 | }, 398 | }, 399 | }; 400 | 401 | mockGraphQL(data, "missingProjectMetadata", "projectV2"); 402 | 403 | const missingValue = await updateProject.fetchProjectMetadata( 404 | "github", 405 | 1, 406 | "testField", 407 | "missingValue", 408 | "update" 409 | ); 410 | expect(missingValue).toEqual({}); 411 | expect(mock.done()).toBe(true); 412 | }); 413 | 414 | test("updateField with text", async () => { 415 | const item = { project: { number: 1, owner: { login: "github" } } }; 416 | mockContentMetadata("test", item); 417 | 418 | const field = { 419 | id: 1, 420 | name: "testField", 421 | dataType: "text", 422 | value: "testValue", 423 | }; 424 | mockProjectMetadata(1, field); 425 | 426 | const data = { data: { projectV2Item: { id: 1 } } }; 427 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 428 | 429 | const projectMetadata = await updateProject.fetchProjectMetadata( 430 | "github", 431 | 1, 432 | "testField", 433 | "testValue", 434 | "update" 435 | ); 436 | const contentMetadata = await updateProject.fetchContentMetadata( 437 | "1", 438 | "test", 439 | 1, 440 | "github" 441 | ); 442 | const result = await updateProject.updateField( 443 | projectMetadata, 444 | contentMetadata, 445 | "new value" 446 | ); 447 | expect(result).toEqual(data.data); 448 | expect(mock.done()).toBe(true); 449 | }); 450 | 451 | test("updateField with number", async () => { 452 | const item = { project: { number: 1, owner: { login: "github" } } }; 453 | mockContentMetadata("test", item); 454 | 455 | const field = { 456 | id: 1, 457 | name: "testField", 458 | dataType: "number", 459 | }; 460 | mockProjectMetadata(1, field); 461 | 462 | const data = { data: { projectV2Item: { id: 1 } } }; 463 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 464 | 465 | const projectMetadata = await updateProject.fetchProjectMetadata( 466 | "github", 467 | 1, 468 | "testField", 469 | "42", 470 | "update" 471 | ); 472 | const contentMetadata = await updateProject.fetchContentMetadata( 473 | "1", 474 | "test", 475 | 1, 476 | "github" 477 | ); 478 | const result = await updateProject.updateField( 479 | projectMetadata, 480 | contentMetadata, 481 | "42" 482 | ); 483 | expect(result).toEqual(data.data); 484 | expect(mock.done()).toBe(true); 485 | }); 486 | 487 | test("updateField with number field value zero", async () => { 488 | const item = { project: { number: 1, owner: { login: "github" } } }; 489 | mockContentMetadata("test", item); 490 | 491 | const field = { 492 | id: 1, 493 | name: "testField", 494 | dataType: "number", 495 | }; 496 | mockProjectMetadata(1, field); 497 | 498 | const data = { data: { projectV2Item: { id: 1 } } }; 499 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 500 | 501 | const projectMetadata = await updateProject.fetchProjectMetadata( 502 | "github", 503 | 1, 504 | "testField", 505 | "0", 506 | "update" 507 | ); 508 | const contentMetadata = await updateProject.fetchContentMetadata( 509 | "1", 510 | "test", 511 | 1, 512 | "github" 513 | ); 514 | const result = await updateProject.updateField( 515 | projectMetadata, 516 | contentMetadata, 517 | "0" 518 | ); 519 | expect(result).toEqual(data.data); 520 | expect(mock.done()).toBe(true); 521 | }); 522 | 523 | test("clearField clears a field", async () => { 524 | const item = { project: { number: 1, owner: { login: "github" } } }; 525 | mockContentMetadata("test", item); 526 | 527 | const field = { 528 | id: 1, 529 | name: "testField", 530 | dataType: "date", 531 | }; 532 | mockProjectMetadata(1, field); 533 | 534 | const data = { data: { projectV2Item: { id: 1 } } }; 535 | mockGraphQL(data, "clearField", "clearProjectV2ItemFieldValue"); 536 | 537 | const projectMetadata = await updateProject.fetchProjectMetadata( 538 | "github", 539 | 1, 540 | "testField", 541 | "", 542 | "clear" 543 | ); 544 | const contentMetadata = await updateProject.fetchContentMetadata( 545 | "1", 546 | "test", 547 | 1, 548 | "github" 549 | ); 550 | const result = await updateProject.clearField( 551 | projectMetadata, 552 | contentMetadata 553 | ); 554 | expect(result).toEqual(data.data); 555 | expect(mock.done()).toBe(true); 556 | }); 557 | 558 | test("run updates a field that was not empty", async () => { 559 | const item = { 560 | field: { value: "testValue" }, 561 | project: { number: 1, owner: { login: "github" } }, 562 | }; 563 | mockContentMetadata("testField", item); 564 | 565 | const field = { 566 | id: 1, 567 | name: "testField", 568 | dataType: "single_select", 569 | options: [ 570 | { 571 | id: 1, 572 | name: "testValue", 573 | }, 574 | ], 575 | }; 576 | mockProjectMetadata(1, field); 577 | 578 | const data = { data: { projectV2Item: { id: 1 } } }; 579 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 580 | 581 | await updateProject.run(); 582 | expect(mock.done()).toBe(true); 583 | }); 584 | 585 | test("run updates a field that was empty", async () => { 586 | const item = { 587 | project: { number: 1, owner: { login: "github" } }, 588 | }; 589 | mockContentMetadata("testField", item); 590 | 591 | const field = { 592 | id: 1, 593 | name: "testField", 594 | dataType: "single_select", 595 | options: [ 596 | { 597 | id: 1, 598 | name: "testValue", 599 | }, 600 | ], 601 | }; 602 | mockProjectMetadata(1, field); 603 | 604 | const data = { data: { projectV2Item: { id: 1 } } }; 605 | mockGraphQL(data, "updateField", "updateProjectV2ItemFieldValue"); 606 | 607 | await updateProject.run(); 608 | expect(mock.done()).toBe(true); 609 | }); 610 | 611 | test("run reads a field that is not empty", async () => { 612 | process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "read" } }; 613 | 614 | const item = { 615 | field: { value: "testValue" }, 616 | project: { number: 1, owner: { login: "github" } }, 617 | }; 618 | mockContentMetadata("testField", item); 619 | 620 | const field = { 621 | id: 1, 622 | name: "testField", 623 | dataType: "single_select", 624 | options: [ 625 | { 626 | id: 1, 627 | name: "testValue", 628 | }, 629 | ], 630 | }; 631 | mockProjectMetadata(1, field); 632 | 633 | await updateProject.run(); 634 | expect(mock.done()).toBe(true); 635 | }); 636 | 637 | test("run reads a field that is empty", async () => { 638 | process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "read" } }; 639 | 640 | const item = { 641 | project: { number: 1, owner: { login: "github" } }, 642 | }; 643 | mockContentMetadata("testField", item); 644 | 645 | const field = { 646 | id: 1, 647 | name: "testField", 648 | dataType: "single_select", 649 | options: [ 650 | { 651 | id: 1, 652 | name: "testValue", 653 | }, 654 | ], 655 | }; 656 | mockProjectMetadata(1, field); 657 | 658 | await updateProject.run(); 659 | expect(mock.done()).toBe(true); 660 | }); 661 | 662 | test("run clears a field", async () => { 663 | process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "clear" } }; 664 | 665 | const item = { 666 | field: { value: "2023-01-01" }, 667 | project: { number: 1, owner: { login: "github" } }, 668 | }; 669 | mockContentMetadata("testField", item); 670 | 671 | const field = { 672 | id: 1, 673 | name: "testField", 674 | dataType: "date", 675 | }; 676 | mockProjectMetadata(1, field); 677 | 678 | const data = { data: { projectV2Item: { id: 1 } } }; 679 | mockGraphQL(data, "clearField", "clearProjectV2ItemFieldValue"); 680 | 681 | await updateProject.run(); 682 | expect(mock.done()).toBe(true); 683 | }); 684 | }); 685 | --------------------------------------------------------------------------------