diff --git a/README.md b/README.md index 23a3ad3..77d5122 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ jobs: content_id: ${{ github.event.client_payload.command.resource.id }} field: Status value: ${{ github.event.client_payload.data.status }} + - name: Clear due date + id: clear_due_date + uses: github/update-project-action@v3 + with: + github_token: ${{ secrets.STATUS_UPDATE_TOKEN }} + organization: github + project_number: 1234 + content_id: ${{ github.event.client_payload.command.resource.id }} + field: "Due Date" + operation: clear ``` *Note: The above step can be repeated multiple times in a given job to update multiple fields on the same or different projects.* @@ -62,10 +72,10 @@ The Action is largely feature complete with regards to its initial goals. Find a * `content_id` - The global ID of the issue or pull request within the project * `field` - The field on the project to set the value of * `github_token` - A GitHub Token with access to both the source issue and the destination project (`repo` and `write:org` scopes) -* `operation` - Operation type (update or read) +* `operation` - Operation type (update, read, or clear) * `organization` - The organization that contains the project, defaults to the current repository owner * `project_number` - The project number from the project's URL -* `value` - The value to set the project field to. Only required for operation type read +* `value` - The value to set the project field to. Only required for operation type `update`; not required for `read` or `clear`. ### Outputs diff --git a/__test__/main.test.ts b/__test__/main.test.ts index b225238..2dfae3a 100644 --- a/__test__/main.test.ts +++ b/__test__/main.test.ts @@ -93,6 +93,12 @@ describe("with environmental variables", () => { expect(result.operation).toEqual("read"); }); + test("getInputs accepts clear", () => { + process.env = { ...process.env, ...{ INPUT_OPERATION: "clear" } }; + const result = updateProject.getInputs(); + expect(result.operation).toEqual("clear"); + }); + test("getInputs doesn't accept other operations", () => { process.env = { ...process.env, ...{ INPUT_OPERATION: "foo" } }; const result = updateProject.getInputs(); @@ -514,6 +520,41 @@ describe("with Octokit setup", () => { expect(mock.done()).toBe(true); }); + test("clearField clears a field", async () => { + const item = { project: { number: 1, owner: { login: "github" } } }; + mockContentMetadata("test", item); + + const field = { + id: 1, + name: "testField", + dataType: "date", + }; + mockProjectMetadata(1, field); + + const data = { data: { projectV2Item: { id: 1 } } }; + mockGraphQL(data, "clearField", "clearProjectV2ItemFieldValue"); + + const projectMetadata = await updateProject.fetchProjectMetadata( + "github", + 1, + "testField", + "", + "clear" + ); + const contentMetadata = await updateProject.fetchContentMetadata( + "1", + "test", + 1, + "github" + ); + const result = await updateProject.clearField( + projectMetadata, + contentMetadata + ); + expect(result).toEqual(data.data); + expect(mock.done()).toBe(true); + }); + test("run updates a field that was not empty", async () => { const item = { field: { value: "testValue" }, @@ -617,4 +658,27 @@ describe("with Octokit setup", () => { await updateProject.run(); expect(mock.done()).toBe(true); }); + + test("run clears a field", async () => { + process.env = { ...OLD_ENV, ...INPUTS, ...{ INPUT_OPERATION: "clear" } }; + + const item = { + field: { value: "2023-01-01" }, + project: { number: 1, owner: { login: "github" } }, + }; + mockContentMetadata("testField", item); + + const field = { + id: 1, + name: "testField", + dataType: "date", + }; + mockProjectMetadata(1, field); + + const data = { data: { projectV2Item: { id: 1 } } }; + mockGraphQL(data, "clearField", "clearProjectV2ItemFieldValue"); + + await updateProject.run(); + expect(mock.done()).toBe(true); + }); }); diff --git a/action.yml b/action.yml index 018051b..4ad786f 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,7 @@ inputs: description: The project number from the project's URL required: true operation: - description: Operation type (update or read) + description: Operation type (update, read, or clear) default: update required: false content_id: diff --git a/dist/index.js b/dist/index.js index 38c535a..c4f5fad 100644 --- a/dist/index.js +++ b/dist/index.js @@ -10408,7 +10408,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.run = exports.setupOctokit = exports.getInputs = exports.updateField = exports.convertValueToFieldType = exports.valueGraphqlType = exports.ensureExists = exports.fetchProjectMetadata = exports.fetchContentMetadata = void 0; +exports.run = exports.setupOctokit = exports.getInputs = exports.clearField = exports.updateField = exports.convertValueToFieldType = exports.valueGraphqlType = exports.ensureExists = exports.fetchProjectMetadata = exports.fetchContentMetadata = void 0; const core_1 = __nccwpck_require__(2186); const github_1 = __nccwpck_require__(5438); let octokit; @@ -10648,6 +10648,37 @@ function updateField(projectMetadata, contentMetadata, value) { }); } exports.updateField = updateField; +/** + * Clears the field value for the content item + * @param {GraphQlQueryResponseData} projectMetadata - The project metadata returned from fetchProjectMetadata() + * @param {GraphQlQueryResponseData} contentMetadata - The content metadata returned from fetchContentMetadata() + * @return {Promise} - The updated content metadata + */ +function clearField(projectMetadata, contentMetadata) { + return __awaiter(this, void 0, void 0, function* () { + const result = yield octokit.graphql(` + mutation($project: ID!, $item: ID!, $field: ID!) { + clearProjectV2ItemFieldValue( + input: { + projectId: $project + itemId: $item + fieldId: $field + } + ) { + projectV2Item { + id + } + } + } + `, { + project: projectMetadata.projectId, + item: contentMetadata.id, + field: projectMetadata.field.fieldId, + }); + return result; + }); +} +exports.clearField = clearField; /** * Returns the validated and normalized inputs for the action * @@ -10657,8 +10688,8 @@ function getInputs() { let operation = (0, core_1.getInput)("operation"); if (operation === "") operation = "update"; - if (!["read", "update"].includes(operation)) { - (0, core_1.setFailed)(`Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update)`); + if (!["read", "update", "clear"].includes(operation)) { + (0, core_1.setFailed)(`Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update, clear)`); return {}; } const inputs = { @@ -10704,6 +10735,11 @@ function run() { (0, core_1.setOutput)("field_updated_value", inputs.value); (0, core_1.info)(`Updated field ${inputs.fieldName} on ${contentMetadata.title} to ${inputs.value}`); } + else if (inputs.operation === "clear") { + yield clearField(projectMetadata, contentMetadata); + (0, core_1.setOutput)("field_updated_value", null); + (0, core_1.info)(`Cleared field ${inputs.fieldName} on ${contentMetadata.title}`); + } else { (0, core_1.setOutput)("field_updated_value", (_b = contentMetadata.field) === null || _b === void 0 ? void 0 : _b.value); } diff --git a/src/update-project.ts b/src/update-project.ts index e10a7b1..cdd0d69 100644 --- a/src/update-project.ts +++ b/src/update-project.ts @@ -289,6 +289,42 @@ export async function updateField( return result; } +/** + * Clears the field value for the content item + * @param {GraphQlQueryResponseData} projectMetadata - The project metadata returned from fetchProjectMetadata() + * @param {GraphQlQueryResponseData} contentMetadata - The content metadata returned from fetchContentMetadata() + * @return {Promise} - The updated content metadata + */ +export async function clearField( + projectMetadata: GraphQlQueryResponseData, + contentMetadata: GraphQlQueryResponseData +): Promise { + const result: GraphQlQueryResponseData = await octokit.graphql( + ` + mutation($project: ID!, $item: ID!, $field: ID!) { + clearProjectV2ItemFieldValue( + input: { + projectId: $project + itemId: $item + fieldId: $field + } + ) { + projectV2Item { + id + } + } + } + `, + { + project: projectMetadata.projectId, + item: contentMetadata.id, + field: projectMetadata.field.fieldId, + } + ); + + return result; +} + /** * Returns the validated and normalized inputs for the action * @@ -298,9 +334,9 @@ export function getInputs(): { [key: string]: any } { let operation = getInput("operation"); if (operation === "") operation = "update"; - if (!["read", "update"].includes(operation)) { + if (!["read", "update", "clear"].includes(operation)) { setFailed( - `Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update)` + `Invalid value passed for the 'operation' parameter (passed: ${operation}, allowed: read, update, clear)` ); return {}; @@ -361,6 +397,10 @@ export async function run(): Promise { info( `Updated field ${inputs.fieldName} on ${contentMetadata.title} to ${inputs.value}` ); + } else if (inputs.operation === "clear") { + await clearField(projectMetadata, contentMetadata); + setOutput("field_updated_value", null); + info(`Cleared field ${inputs.fieldName} on ${contentMetadata.title}`); } else { setOutput("field_updated_value", contentMetadata.field?.value); }