From 4ec05f4d14a8cfddf83a0e2049f3bf8f57b07045 Mon Sep 17 00:00:00 2001 From: David Sanders Date: Wed, 1 Nov 2023 17:33:39 -0700 Subject: [PATCH] feat: find-project action --- .github/workflows/integration-tests.yml | 21 +++- README.md | 1 + __tests__/find-project.test.ts | 123 ++++++++++++++++++++++++ __tests__/lib.test.ts | 23 +++++ dist/find-project.js | 31 +----- dist/github-script/index.js | 23 ++--- find-project/README.md | 32 ++++++ find-project/action.yml | 41 ++++++++ find-project/index.ts | 4 + package.json | 1 + src/find-project.ts | 35 +++++++ src/lib.ts | 24 +++++ 12 files changed, 315 insertions(+), 44 deletions(-) create mode 100644 __tests__/find-project.test.ts create mode 100644 find-project/README.md create mode 100644 find-project/action.yml create mode 100644 find-project/index.ts create mode 100644 src/find-project.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c1b6f8c..532c47e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -266,7 +266,24 @@ jobs: owner: ${{ steps.copy-project.outputs.owner }} project-number: ${{ steps.copy-project.outputs.number }} readme: This is the readme - title: New Title + title: New Title ${{ steps.copy-project.outputs.number }} + token: ${{ steps.get-auth-token.outputs.token }} + + - name: Find Project + uses: ./find-project/ + id: find-project + with: + owner: ${{ matrix.owner }} + title: New Title ${{ steps.copy-project.outputs.number }} + token: ${{ steps.get-auth-token.outputs.token }} + + - name: Confirm Project Found + uses: ./github-script/ + with: + script: | + if ("${{ steps.copy-project.outputs.number }}" !== "${{ steps.find-project.outputs.number }}") { + throw new Error("Could not find project by title") + } token: ${{ steps.get-auth-token.outputs.token }} - name: Get Project @@ -281,7 +298,7 @@ jobs: uses: ./github-script/ with: script: | - if ("${{ steps.get-project.outputs.title }}" !== "New Title") { + if ("${{ steps.get-project.outputs.title }}" !== "New Title ${{ steps.copy-project.outputs.number }}") { throw new Error("Project title is not correct") } diff --git a/README.md b/README.md index 882b4d8..2119be2 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ private repositories the PAT must also have the `repo` scope. | [`project-actions/delete-project`](delete-project) | Delete a project | | [`project-actions/edit-item`](edit-item) | Edit an item on a project | | [`project-actions/edit-project`](edit-project) | Edit a project | +| [`project-actions/find-project`](find-project) | Find a project | | [`project-actions/get-item`](get-item) | Get an item on a project | | [`project-actions/get-project`](get-project) | Get a project | | [`project-actions/github-script`](github-script) | Modify projects programmatically | diff --git a/__tests__/find-project.test.ts b/__tests__/find-project.test.ts new file mode 100644 index 0000000..65c6dc8 --- /dev/null +++ b/__tests__/find-project.test.ts @@ -0,0 +1,123 @@ +import * as core from '@actions/core'; + +import * as index from '../src/find-project'; +import { findProject } from '../src/lib'; +import { mockGetInput } from './utils'; + +jest.mock('@actions/core'); +jest.mock('../src/lib'); + +// Spy the action's entrypoint +const findProjectActionSpy = jest.spyOn(index, 'findProjectAction'); + +const owner = 'dsanders11'; +const projectNumber = '94'; +const projectId = 'project-id'; +const fieldCount = 4; +const itemCount = 50; +const shortDescription = 'Description'; +const title = 'My Title'; +const readme = 'README'; +const url = 'url'; + +describe('findProjectAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requires the title input', async () => { + mockGetInput({ owner }); + + await index.findProjectAction(); + expect(findProjectActionSpy).toHaveReturned(); + + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenLastCalledWith( + 'Input required and not supplied: title' + ); + }); + + it('handles project not found', async () => { + mockGetInput({ owner, title }); + jest.mocked(findProject).mockResolvedValue(null); + + await index.findProjectAction(); + expect(findProjectActionSpy).toHaveReturned(); + + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenLastCalledWith( + `Project not found: ${title}` + ); + }); + + it('handles generic errors', async () => { + mockGetInput({ owner, title }); + jest.mocked(findProject).mockImplementation(() => { + throw new Error('Server error'); + }); + + await index.findProjectAction(); + expect(findProjectActionSpy).toHaveReturned(); + + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenLastCalledWith('Server error'); + }); + + it('stringifies non-errors', async () => { + mockGetInput({ owner, title }); + jest.mocked(findProject).mockImplementation(() => { + throw 42; // eslint-disable-line no-throw-literal + }); + + await index.findProjectAction(); + expect(findProjectActionSpy).toHaveReturned(); + + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenLastCalledWith('42'); + }); + + it('sets output', async () => { + mockGetInput({ owner, title }); + jest.mocked(findProject).mockResolvedValue({ + id: projectId, + number: parseInt(projectNumber), + fields: { + totalCount: fieldCount + }, + items: { + totalCount: itemCount + }, + url, + title, + readme, + shortDescription, + public: true, + closed: false, + owner: { + type: 'Organization', + login: owner + } + }); + + await index.findProjectAction(); + expect(findProjectActionSpy).toHaveReturned(); + + expect(core.setOutput).toHaveBeenCalledTimes(10); + expect(core.setOutput).toHaveBeenCalledWith('id', projectId); + expect(core.setOutput).toHaveBeenCalledWith('url', url); + expect(core.setOutput).toHaveBeenCalledWith('closed', false); + expect(core.setOutput).toHaveBeenCalledWith('public', true); + expect(core.setOutput).toHaveBeenCalledWith('field-count', fieldCount); + expect(core.setOutput).toHaveBeenCalledWith('item-count', itemCount); + expect(core.setOutput).toHaveBeenCalledWith( + 'number', + parseInt(projectNumber) + ); + expect(core.setOutput).toHaveBeenCalledWith('readme', readme); + expect(core.setOutput).toHaveBeenCalledWith( + 'description', + shortDescription + ); + expect(core.setOutput).toHaveBeenCalledWith('title', title); + }); +}); diff --git a/__tests__/lib.test.ts b/__tests__/lib.test.ts index 84fb7cf..4aa90e8 100644 --- a/__tests__/lib.test.ts +++ b/__tests__/lib.test.ts @@ -54,6 +54,7 @@ describe('lib', () => { const owner = 'dsanders11'; const projectId = 'project-id'; const projectNumber = '41'; + const projectTitle = 'My Cool Project'; beforeEach(() => { jest.clearAllMocks(); @@ -1166,6 +1167,28 @@ describe('lib', () => { }); }); + describe('findProject', () => { + const project = { id: projectId, title: projectTitle }; + + it('handles project not found', async () => { + jest + .mocked(execCliCommand) + .mockResolvedValue(JSON.stringify({ projects: [project] })); + await expect( + lib.findProject(owner, 'A Different Title') + ).resolves.toEqual(null); + }); + + it('returns project details', async () => { + jest + .mocked(execCliCommand) + .mockResolvedValue(JSON.stringify({ projects: [project] })); + await expect(lib.findProject(owner, projectTitle)).resolves.toEqual( + project + ); + }); + }); + describe('getProject', () => { it('handles project not found', async () => { mockProjectNotFoundError(); diff --git a/dist/find-project.js b/dist/find-project.js index 5830ec7..3695ed4 100644 --- a/dist/find-project.js +++ b/dist/find-project.js @@ -5320,40 +5320,17 @@ async function execCliCommand(args) { } // src/lib.ts -var ProjectNotFoundError = class extends Error { - constructor(cause) { - super("Project not found", { cause }); - } -}; -var RepositoryNotFoundError = class extends Error { - constructor(cause) { - super("Repository not found", { cause }); - } -}; -function handleCliError(error) { - if (error instanceof Error && error.message.includes("Could not resolve to a ProjectV2")) { - throw new ProjectNotFoundError(error); - } else if (error instanceof Error && error.message.includes("Could not resolve to a Repository")) { - throw new RepositoryNotFoundError(error); - } else { - throw error; - } -} async function findProject(owner, title) { - let details; - try { - details = await execCliCommand([ + const { projects } = JSON.parse( + await execCliCommand([ "project", "list", "--owner", owner, "--format", "json" - ]); - } catch (error) { - handleCliError(error); - } - const { projects } = JSON.parse(details); + ]) + ); for (const project of projects) { if (project.title === title) { return project; diff --git a/dist/github-script/index.js b/dist/github-script/index.js index aa1aedc..ad7cfdc 100644 --- a/dist/github-script/index.js +++ b/dist/github-script/index.js @@ -19888,21 +19888,14 @@ async function editProject(owner, projectNumber, edit) { } exports.editProject = editProject; async function findProject(owner, title) { - let details; - try { - details = await (0, helpers_1.execCliCommand)([ - 'project', - 'list', - '--owner', - owner, - '--format', - 'json' - ]); - } - catch (error) { - handleCliError(error); - } - const { projects } = JSON.parse(details); + const { projects } = JSON.parse(await (0, helpers_1.execCliCommand)([ + 'project', + 'list', + '--owner', + owner, + '--format', + 'json' + ])); for (const project of projects) { if (project.title === title) { return project; diff --git a/find-project/README.md b/find-project/README.md new file mode 100644 index 0000000..e3ec008 --- /dev/null +++ b/find-project/README.md @@ -0,0 +1,32 @@ +# `project-actions/find-project` + +[![Release](https://img.shields.io/github/v/release/dsanders11/project-actions?color=blue)](https://github.com/dsanders11/project-actions/releases) + +Find a GitHub project + +## Inputs + +| Name | Description | Required | Default | +|-------------------|----------------------------------------------------|----------|----------------------------------------------| +| `token` | A GitHub access token - either a classic PAT or a GitHub app installation token. | Yes | | +| `owner` | The owner of the project - either an organization or a user. If not provided, it defaults to the repository owner. | No | `${{ github.repository_owner }}` | +| `title` | The title of the project to find. | Yes | | + +## Outputs + +| Name | Description | +|-------------------|----------------------------------------------------| +| `id` | The global ID for the project. | +| `closed` | The closed state of the project. | +| `field-count` | The number of fields on the project. | +| `item-count` | The number of items in the project. | +| `number` | The project number of the project. | +| `public` | The public visibility of the project. | +| `readme` | The readme description of the project. | +| `description` | The short description of the project. | +| `title` | The title of the project. | +| `url` | The URL of the project. | + +## License + +MIT diff --git a/find-project/action.yml b/find-project/action.yml new file mode 100644 index 0000000..d75e613 --- /dev/null +++ b/find-project/action.yml @@ -0,0 +1,41 @@ +name: Find Project +description: Find a GitHub project +author: David Sanders + +inputs: + token: + description: A GitHub access token - either a classic PAT or a GitHub app installation token + required: true + owner: + description: The owner of the project - either an organization or a user + required: false + default: ${{ github.repository_owner }} + title: + description: The title of the project to find + required: true + +outputs: + id: + description: The global ID for the project + closed: + description: The closed state of the project + field-count: + description: The number of fields on the project + item-count: + description: The number of items in the project + number: + description: The project number of the project + public: + description: The public visibility of the project + readme: + description: The readme description of the project + description: + description: The short description of the project + title: + description: The title of the project + url: + description: The URL of the project + +runs: + using: node20 + main: ../dist/find-project.js diff --git a/find-project/index.ts b/find-project/index.ts new file mode 100644 index 0000000..e0ceb89 --- /dev/null +++ b/find-project/index.ts @@ -0,0 +1,4 @@ +import { findProjectAction } from '../src/find-project'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +findProjectAction(); diff --git a/package.json b/package.json index 9c64cde..a7acb95 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "package:delete-project": "esbuild delete-project/index.ts --bundle --outfile=dist/delete-project.js --platform=node --target=node20.2", "package:edit-item": "esbuild edit-item/index.ts --bundle --outfile=dist/edit-item.js --platform=node --target=node20.2", "package:edit-project": "esbuild edit-project/index.ts --bundle --outfile=dist/edit-project.js --platform=node --target=node20.2", + "package:find-project": "esbuild find-project/index.ts --bundle --outfile=dist/find-project.js --platform=node --target=node20.2", "package:get-item": "esbuild get-item/index.ts --bundle --outfile=dist/get-item.js --platform=node --target=node20.2", "package:get-project": "esbuild get-project/index.ts --bundle --outfile=dist/get-project.js --platform=node --target=node20.2", "package:link-project": "esbuild link-project/index.ts --bundle --outfile=dist/link-project.js --platform=node --target=node20.2", diff --git a/src/find-project.ts b/src/find-project.ts new file mode 100644 index 0000000..14391c0 --- /dev/null +++ b/src/find-project.ts @@ -0,0 +1,35 @@ +import * as core from '@actions/core'; + +import { findProject } from './lib'; + +export async function findProjectAction(): Promise { + try { + // Required inputs + const owner = core.getInput('owner', { required: true }); + const title = core.getInput('title', { required: true }); + + const project = await findProject(owner, title); + + if (!project) { + core.setFailed(`Project not found: ${title}`); + return; + } + + core.setOutput('closed', project.closed); + core.setOutput('field-count', project.fields.totalCount); + core.setOutput('id', project.id); + core.setOutput('item-count', project.items.totalCount); + core.setOutput('number', project.number); + core.setOutput('public', project.public); + core.setOutput('readme', project.readme); + core.setOutput('description', project.shortDescription); + core.setOutput('title', project.title); + core.setOutput('url', project.url); + } catch (error) { + // Fail the workflow run if an error occurs + if (error instanceof Error && error.stack) core.debug(error.stack); + core.setFailed( + error instanceof Error ? error.message : JSON.stringify(error) + ); + } +} diff --git a/src/lib.ts b/src/lib.ts index ec003ee..500f7a6 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -823,6 +823,30 @@ export async function editProject( return JSON.parse(output).id; } +export async function findProject( + owner: string, + title: string +): Promise { + const { projects } = JSON.parse( + await execCliCommand([ + 'project', + 'list', + '--owner', + owner, + '--format', + 'json' + ]) + ); + + for (const project of projects) { + if (project.title === title) { + return project; + } + } + + return null; +} + /** * @throws ProjectNotFoundError */