From 852801bd2f892d60273f2816284a5c245666c96d Mon Sep 17 00:00:00 2001 From: Shojiro Yanagisawa Date: Sun, 14 Jan 2024 18:27:25 +0900 Subject: [PATCH 1/2] add query fetchProjectV2FieldByName --- __tests__/ex-octokit/index.test.ts | 80 +++++++++++++++++++++++++++++- src/ex-octokit/index.ts | 58 +++++++++++++++++++++- 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/__tests__/ex-octokit/index.test.ts b/__tests__/ex-octokit/index.test.ts index c22bd5c..3db9a1c 100644 --- a/__tests__/ex-octokit/index.test.ts +++ b/__tests__/ex-octokit/index.test.ts @@ -8,7 +8,7 @@ describe('fetchProjectV2Id', () => { it('fetches ProjectV2 ID from organization', async () => { mockGraphQL({ - test: /getProject/, + test: /fetchProjectV2Id/, return: { organization: { projectV2: { @@ -30,7 +30,7 @@ describe('fetchProjectV2Id', () => { it('fetches ProjectV2 ID from user', async () => { mockGraphQL({ - test: /getProject/, + test: /fetchProjectV2Id/, return: { user: { projectV2: { @@ -47,6 +47,82 @@ describe('fetchProjectV2Id', () => { }) }) +describe('fetchProjectV2FieldByName', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('fetches field on ProjectV2Field', async () => { + mockGraphQL({ + test: /fetchProjectV2FieldByName/, + return: { + node: { + field: { + __typename: 'ProjectV2Field', + id: 'field-id', + name: 'text-field', + dataType: 'TEXT', + } + } + } + }) + + const exOctokit = new ExOctokit('gh_token') + const projectV2Field = await exOctokit.fetchProjectV2FieldByName( + 'project-id', + 'text-field', + ) + + expect(projectV2Field).toEqual({ + __typename: 'ProjectV2Field', + id: 'field-id', + name: 'text-field', + dataType: 'TEXT', + }) + }) + + it('fetches field on ProjectV2SingleSelectField', async () => { + mockGraphQL({ + test: /fetchProjectV2FieldByName/, + return: { + node: { + field: { + __typename: 'ProjectV2SingleSelectField', + id: 'field-id', + name: 'select-field', + dataType: 'SINGLE_SELECT', + options: [ + { + id: 'option-id', + name: 'option-name', + } + ] + } + } + } + }) + + const exOctokit = new ExOctokit('gh_token') + const projectV2Field = await exOctokit.fetchProjectV2FieldByName( + 'project-id', + 'select-field', + ) + + expect(projectV2Field).toEqual({ + __typename: 'ProjectV2SingleSelectField', + id: 'field-id', + name: 'select-field', + dataType: 'SINGLE_SELECT', + options: [ + { + id: 'option-id', + name: 'option-name', + } + ] + }) + }) +}) + function mockGraphQL(...mocks: { test: RegExp; return: unknown }[]): jest.Mock { const mock = jest.fn().mockImplementation((query: string) => { const match = mocks.find(m => m.test.test(query)) diff --git a/src/ex-octokit/index.ts b/src/ex-octokit/index.ts index 3022396..6411100 100644 --- a/src/ex-octokit/index.ts +++ b/src/ex-octokit/index.ts @@ -14,6 +14,24 @@ interface ProjectV2IdResponse { } } +interface ProjectV2FieldResponse { + node: { + field: ProjectV2Field | null + } | null +} + +interface ProjectV2Field { + __typename: 'ProjectV2Field' | 'ProjectV2SingleSelectField' + id: string + name: string + dataType: string + + options?: Array<{ + id: string + name: string + }> +} + export class ExOctokit { octokit: ReturnType @@ -27,7 +45,7 @@ export class ExOctokit { projectNumber: number ): Promise { const projectV2IdResponse = await this.octokit.graphql( - `query getProject($projectOwnerName: String!, $projectNumber: Int!) { + `query fetchProjectV2Id($projectOwnerName: String!, $projectNumber: Int!) { ${ownerTypeQuery}(login: $projectOwnerName) { projectV2(number: $projectNumber) { id @@ -42,4 +60,42 @@ export class ExOctokit { return projectV2IdResponse[ownerTypeQuery]?.projectV2.id } + + // TODO: support 'ProjectV2IterationField' Type + async fetchProjectV2FieldByName( + projectV2Id: string, + fieldName: string + ): Promise { + const projectV2FieldResponse = await this.octokit.graphql( + `query fetchProjectV2FieldByName($projectOwnerName: String!, $fieldName: Int!) { + node(id: $projectV2Id) { + ... on ProjectV2 { + field(name: $fieldName) { + __typename + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { + id + name + } + } + } + } + } + }`, + { + projectV2Id, + fieldName + } + ) + + return projectV2FieldResponse.node?.field + } } From c8e3bcafd1d249feabacf65d441afbced337f721 Mon Sep 17 00:00:00 2001 From: Shojiro Yanagisawa Date: Sun, 14 Jan 2024 18:42:36 +0900 Subject: [PATCH 2/2] debug field id --- README.md | 1 + __tests__/ex-octokit/index.test.ts | 12 ++--- .../update-project-v2-item-field.test.ts | 16 ++++++ action.yml | 3 ++ badges/coverage.svg | 2 +- dist/index.js | 40 ++++++++++++++- src/ex-octokit/index.ts | 49 ++++++++++--------- src/update-project-v2-item-field.ts | 13 +++++ 8 files changed, 104 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 205e0ab..c155e7d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ TODO _eg: `https://github.com/orgs|users//projects/`_ - `github-token` **(required)** is a [personal access token](https://github.com/settings/tokens/new) with `repo` and `project` scopes. +- `field-name` **(required)** is a field name of the project v2 item to update. ## Outputs diff --git a/__tests__/ex-octokit/index.test.ts b/__tests__/ex-octokit/index.test.ts index 3db9a1c..a629599 100644 --- a/__tests__/ex-octokit/index.test.ts +++ b/__tests__/ex-octokit/index.test.ts @@ -61,7 +61,7 @@ describe('fetchProjectV2FieldByName', () => { __typename: 'ProjectV2Field', id: 'field-id', name: 'text-field', - dataType: 'TEXT', + dataType: 'TEXT' } } } @@ -70,14 +70,14 @@ describe('fetchProjectV2FieldByName', () => { const exOctokit = new ExOctokit('gh_token') const projectV2Field = await exOctokit.fetchProjectV2FieldByName( 'project-id', - 'text-field', + 'text-field' ) expect(projectV2Field).toEqual({ __typename: 'ProjectV2Field', id: 'field-id', name: 'text-field', - dataType: 'TEXT', + dataType: 'TEXT' }) }) @@ -94,7 +94,7 @@ describe('fetchProjectV2FieldByName', () => { options: [ { id: 'option-id', - name: 'option-name', + name: 'option-name' } ] } @@ -105,7 +105,7 @@ describe('fetchProjectV2FieldByName', () => { const exOctokit = new ExOctokit('gh_token') const projectV2Field = await exOctokit.fetchProjectV2FieldByName( 'project-id', - 'select-field', + 'select-field' ) expect(projectV2Field).toEqual({ @@ -116,7 +116,7 @@ describe('fetchProjectV2FieldByName', () => { options: [ { id: 'option-id', - name: 'option-name', + name: 'option-name' } ] }) diff --git a/__tests__/update-project-v2-item-field.test.ts b/__tests__/update-project-v2-item-field.test.ts index cb982b1..4f74d9e 100644 --- a/__tests__/update-project-v2-item-field.test.ts +++ b/__tests__/update-project-v2-item-field.test.ts @@ -6,6 +6,7 @@ import { ExOctokit } from '../src/ex-octokit' describe('updateProjectV2ItemField', () => { let outputs: Record + let debug: jest.SpyInstance beforeEach(() => { jest.spyOn(process.stdout, 'write').mockImplementation(() => true) @@ -17,6 +18,7 @@ describe('updateProjectV2ItemField', () => { 'github-token': 'gh_token' }) + debug = mockDebug() outputs = mockSetOutput() }) @@ -42,9 +44,12 @@ describe('updateProjectV2ItemField', () => { } mockFetchProjectV2Id().mockResolvedValue('project-id') + mockFetchProjectV2FieldByName().mockResolvedValue({ id: 'field-id' }) await updateProjectV2ItemField() + expect(debug).toHaveBeenCalledWith('ProjectV2 ID: project-id') + expect(debug).toHaveBeenCalledWith('Field ID: field-id') expect(outputs.projectV2Id).toEqual('project-id') }) @@ -95,9 +100,12 @@ describe('updateProjectV2ItemField', () => { } mockFetchProjectV2Id().mockResolvedValue('project-id') + mockFetchProjectV2FieldByName().mockResolvedValue({ id: 'field-id' }) await updateProjectV2ItemField() + expect(debug).toHaveBeenCalledWith('ProjectV2 ID: project-id') + expect(debug).toHaveBeenCalledWith('Field ID: field-id') expect(outputs.projectV2Id).toEqual('project-id') }) }) @@ -115,6 +123,14 @@ function mockSetOutput(): Record { return output } +function mockDebug(): jest.SpyInstance { + return jest.spyOn(core, 'debug').mockImplementation() +} + function mockFetchProjectV2Id(): jest.SpyInstance { return jest.spyOn(ExOctokit.prototype, 'fetchProjectV2Id') } + +function mockFetchProjectV2FieldByName(): jest.SpyInstance { + return jest.spyOn(ExOctokit.prototype, 'fetchProjectV2FieldByName') +} diff --git a/action.yml b/action.yml index 28f9234..5f67762 100644 --- a/action.yml +++ b/action.yml @@ -10,6 +10,9 @@ inputs: github-token: required: true description: A GitHub personal access token with write access to the project + field-name: + required: true + description: The name of the field to update outputs: projectId: diff --git a/badges/coverage.svg b/badges/coverage.svg index a9772d5..e4eab6c 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 97.87%Coverage97.87% \ No newline at end of file +Coverage: 96.36%Coverage96.36% \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 2fef6c9..4b80aeb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -28920,7 +28920,7 @@ class ExOctokit { this.octokit = (0, github_1.getOctokit)(ghToken); } async fetchProjectV2Id(ownerTypeQuery, projectOwnerName, projectNumber) { - const projectV2IdResponse = await this.octokit.graphql(`query getProject($projectOwnerName: String!, $projectNumber: Int!) { + const projectV2IdResponse = await this.octokit.graphql(`query fetchProjectV2Id($projectOwnerName: String!, $projectNumber: Int!) { ${ownerTypeQuery}(login: $projectOwnerName) { projectV2(number: $projectNumber) { id @@ -28932,6 +28932,36 @@ class ExOctokit { }); return projectV2IdResponse[ownerTypeQuery]?.projectV2.id; } + // TODO: support 'ProjectV2IterationField' Type + async fetchProjectV2FieldByName(projectV2Id, fieldName) { + const projectV2FieldResponse = await this.octokit.graphql(`query fetchProjectV2FieldByName($projectOwnerName: String!, $fieldName: Int!) { + node(id: $projectV2Id) { + ... on ProjectV2 { + field(name: $fieldName) { + __typename + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { + id + name + } + } + } + } + } + }`, { + projectV2Id, + fieldName + }); + return projectV2FieldResponse.node?.field; + } } exports.ExOctokit = ExOctokit; @@ -29028,6 +29058,7 @@ async function updateProjectV2ItemField() { // Get the action inputs const projectUrl = core.getInput('project-url', { required: true }); const ghToken = core.getInput('github-token', { required: true }); + const fieldName = core.getInput('field-name', { required: true }); // Get the issue/PR owner name and node ID from payload const issue = github.context.payload.issue ?? github.context.payload.pull_request; // Validate and parse the project URL @@ -29048,8 +29079,15 @@ async function updateProjectV2ItemField() { const exOctokit = new ex_octokit_1.ExOctokit(ghToken); const ownerTypeQuery = (0, utils_1.mustGetOwnerTypeQuery)(ownerType); const projectV2Id = await exOctokit.fetchProjectV2Id(ownerTypeQuery, projectOwnerName, projectNumber); + if (!projectV2Id) { + throw new Error(`ProjectV2 ID is undefined`); + } const contentId = issue?.node_id; + // Fetch the field node ID + const field = await exOctokit.fetchProjectV2FieldByName(projectV2Id, fieldName); + const fieldId = field?.id; core.debug(`ProjectV2 ID: ${projectV2Id}`); + core.debug(`Field ID: ${fieldId}`); core.debug(`Content ID: ${contentId}`); // Set outputs for other workflow steps to use core.setOutput('projectV2Id', projectV2Id); diff --git a/src/ex-octokit/index.ts b/src/ex-octokit/index.ts index 6411100..88d0d44 100644 --- a/src/ex-octokit/index.ts +++ b/src/ex-octokit/index.ts @@ -26,10 +26,10 @@ interface ProjectV2Field { name: string dataType: string - options?: Array<{ + options?: { id: string name: string - }> + }[] } export class ExOctokit { @@ -66,35 +66,36 @@ export class ExOctokit { projectV2Id: string, fieldName: string ): Promise { - const projectV2FieldResponse = await this.octokit.graphql( - `query fetchProjectV2FieldByName($projectOwnerName: String!, $fieldName: Int!) { - node(id: $projectV2Id) { - ... on ProjectV2 { - field(name: $fieldName) { - __typename - ... on ProjectV2Field { - id - name - dataType - } - ... on ProjectV2SingleSelectField { - id - name - dataType - options { + const projectV2FieldResponse = + await this.octokit.graphql( + `query fetchProjectV2FieldByName($projectOwnerName: String!, $fieldName: Int!) { + node(id: $projectV2Id) { + ... on ProjectV2 { + field(name: $fieldName) { + __typename + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { id name + dataType + options { + id + name + } } } } } + }`, + { + projectV2Id, + fieldName } - }`, - { - projectV2Id, - fieldName - } - ) + ) return projectV2FieldResponse.node?.field } diff --git a/src/update-project-v2-item-field.ts b/src/update-project-v2-item-field.ts index bfeae04..978743b 100644 --- a/src/update-project-v2-item-field.ts +++ b/src/update-project-v2-item-field.ts @@ -10,6 +10,7 @@ export async function updateProjectV2ItemField(): Promise { // Get the action inputs const projectUrl = core.getInput('project-url', { required: true }) const ghToken = core.getInput('github-token', { required: true }) + const fieldName = core.getInput('field-name', { required: true }) // Get the issue/PR owner name and node ID from payload const issue = @@ -40,9 +41,21 @@ export async function updateProjectV2ItemField(): Promise { projectOwnerName, projectNumber ) + if (!projectV2Id) { + throw new Error(`ProjectV2 ID is undefined`) + } + const contentId = issue?.node_id + // Fetch the field node ID + const field = await exOctokit.fetchProjectV2FieldByName( + projectV2Id, + fieldName + ) + const fieldId = field?.id + core.debug(`ProjectV2 ID: ${projectV2Id}`) + core.debug(`Field ID: ${fieldId}`) core.debug(`Content ID: ${contentId}`) // Set outputs for other workflow steps to use