Skip to content

Commit 52db8e9

Browse files
committed
feat: find-project action
1 parent 62650d5 commit 52db8e9

File tree

12 files changed

+314
-43
lines changed

12 files changed

+314
-43
lines changed

.github/workflows/integration-tests.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,24 @@ jobs:
266266
owner: ${{ steps.copy-project.outputs.owner }}
267267
project-number: ${{ steps.copy-project.outputs.number }}
268268
readme: This is the readme
269-
title: New Title
269+
title: New Title ${{ steps.copy-project.outputs.number }}
270+
token: ${{ steps.get-auth-token.outputs.token }}
271+
272+
- name: Find Project
273+
uses: ./find-project/
274+
id: find-project
275+
with:
276+
owner: ${{ matrix.owner }}
277+
title: New Title ${{ steps.copy-project.outputs.number }}
278+
token: ${{ steps.get-auth-token.outputs.token }}
279+
280+
- name: Confirm Project Found
281+
uses: ./github-script/
282+
with:
283+
script: |
284+
if ("${{ steps.copy-project.outputs.number }}" !== "${{ steps.find-project.outputs.number }}") {
285+
throw new Error("Could not find project by title")
286+
}
270287
token: ${{ steps.get-auth-token.outputs.token }}
271288

272289
- name: Get Project

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ private repositories the PAT must also have the `repo` scope.
3131
| [`project-actions/delete-project`](delete-project) | Delete a project |
3232
| [`project-actions/edit-item`](edit-item) | Edit an item on a project |
3333
| [`project-actions/edit-project`](edit-project) | Edit a project |
34+
| [`project-actions/find-project`](find-project) | Find a project |
3435
| [`project-actions/get-item`](get-item) | Get an item on a project |
3536
| [`project-actions/get-project`](get-project) | Get a project |
3637
| [`project-actions/github-script`](github-script) | Modify projects programmatically |

__tests__/find-project.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import * as core from '@actions/core';
2+
3+
import * as index from '../src/find-project';
4+
import { findProject } from '../src/lib';
5+
import { mockGetInput } from './utils';
6+
7+
jest.mock('@actions/core');
8+
jest.mock('../src/lib');
9+
10+
// Spy the action's entrypoint
11+
const findProjectActionSpy = jest.spyOn(index, 'findProjectAction');
12+
13+
const owner = 'dsanders11';
14+
const projectNumber = '94';
15+
const projectId = 'project-id';
16+
const fieldCount = 4;
17+
const itemCount = 50;
18+
const shortDescription = 'Description';
19+
const title = 'My Title';
20+
const readme = 'README';
21+
const url = 'url';
22+
23+
describe('findProjectAction', () => {
24+
beforeEach(() => {
25+
jest.clearAllMocks();
26+
});
27+
28+
it('requires the title input', async () => {
29+
mockGetInput({ owner });
30+
31+
await index.findProjectAction();
32+
expect(findProjectActionSpy).toHaveReturned();
33+
34+
expect(core.setFailed).toHaveBeenCalledTimes(1);
35+
expect(core.setFailed).toHaveBeenLastCalledWith(
36+
'Input required and not supplied: title'
37+
);
38+
});
39+
40+
it('handles project not found', async () => {
41+
mockGetInput({ owner, title });
42+
jest.mocked(findProject).mockResolvedValue(null);
43+
44+
await index.findProjectAction();
45+
expect(findProjectActionSpy).toHaveReturned();
46+
47+
expect(core.setFailed).toHaveBeenCalledTimes(1);
48+
expect(core.setFailed).toHaveBeenLastCalledWith(
49+
`Project not found: ${title}`
50+
);
51+
});
52+
53+
it('handles generic errors', async () => {
54+
mockGetInput({ owner, title });
55+
jest.mocked(findProject).mockImplementation(() => {
56+
throw new Error('Server error');
57+
});
58+
59+
await index.findProjectAction();
60+
expect(findProjectActionSpy).toHaveReturned();
61+
62+
expect(core.setFailed).toHaveBeenCalledTimes(1);
63+
expect(core.setFailed).toHaveBeenLastCalledWith('Server error');
64+
});
65+
66+
it('stringifies non-errors', async () => {
67+
mockGetInput({ owner, title });
68+
jest.mocked(findProject).mockImplementation(() => {
69+
throw 42; // eslint-disable-line no-throw-literal
70+
});
71+
72+
await index.findProjectAction();
73+
expect(findProjectActionSpy).toHaveReturned();
74+
75+
expect(core.setFailed).toHaveBeenCalledTimes(1);
76+
expect(core.setFailed).toHaveBeenLastCalledWith('42');
77+
});
78+
79+
it('sets output', async () => {
80+
mockGetInput({ owner, title });
81+
jest.mocked(findProject).mockResolvedValue({
82+
id: projectId,
83+
number: parseInt(projectNumber),
84+
fields: {
85+
totalCount: fieldCount
86+
},
87+
items: {
88+
totalCount: itemCount
89+
},
90+
url,
91+
title,
92+
readme,
93+
shortDescription,
94+
public: true,
95+
closed: false,
96+
owner: {
97+
type: 'Organization',
98+
login: owner
99+
}
100+
});
101+
102+
await index.findProjectAction();
103+
expect(findProjectActionSpy).toHaveReturned();
104+
105+
expect(core.setOutput).toHaveBeenCalledTimes(10);
106+
expect(core.setOutput).toHaveBeenCalledWith('id', projectId);
107+
expect(core.setOutput).toHaveBeenCalledWith('url', url);
108+
expect(core.setOutput).toHaveBeenCalledWith('closed', false);
109+
expect(core.setOutput).toHaveBeenCalledWith('public', true);
110+
expect(core.setOutput).toHaveBeenCalledWith('field-count', fieldCount);
111+
expect(core.setOutput).toHaveBeenCalledWith('item-count', itemCount);
112+
expect(core.setOutput).toHaveBeenCalledWith(
113+
'number',
114+
parseInt(projectNumber)
115+
);
116+
expect(core.setOutput).toHaveBeenCalledWith('readme', readme);
117+
expect(core.setOutput).toHaveBeenCalledWith(
118+
'description',
119+
shortDescription
120+
);
121+
expect(core.setOutput).toHaveBeenCalledWith('title', title);
122+
});
123+
});

__tests__/lib.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ describe('lib', () => {
5454
const owner = 'dsanders11';
5555
const projectId = 'project-id';
5656
const projectNumber = '41';
57+
const projectTitle = 'My Cool Project';
5758

5859
beforeEach(() => {
5960
jest.clearAllMocks();
@@ -1166,6 +1167,28 @@ describe('lib', () => {
11661167
});
11671168
});
11681169

1170+
describe('findProject', () => {
1171+
const project = { id: projectId, title: projectTitle };
1172+
1173+
it('handles project not found', async () => {
1174+
jest
1175+
.mocked(execCliCommand)
1176+
.mockResolvedValue(JSON.stringify({ projects: [project] }));
1177+
await expect(
1178+
lib.findProject(owner, 'A Different Title')
1179+
).resolves.toEqual(null);
1180+
});
1181+
1182+
it('returns project details', async () => {
1183+
jest
1184+
.mocked(execCliCommand)
1185+
.mockResolvedValue(JSON.stringify({ projects: [project] }));
1186+
await expect(lib.findProject(owner, projectTitle)).resolves.toEqual(
1187+
project
1188+
);
1189+
});
1190+
});
1191+
11691192
describe('getProject', () => {
11701193
it('handles project not found', async () => {
11711194
mockProjectNotFoundError();

dist/find-project.js

Lines changed: 4 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/github-script/index.js

Lines changed: 8 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

find-project/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# `project-actions/find-project`
2+
3+
[![Release](https://img.shields.io/github/v/release/dsanders11/project-actions?color=blue)](https://github.com/dsanders11/project-actions/releases)
4+
5+
Find a GitHub project
6+
7+
## Inputs
8+
9+
| Name | Description | Required | Default |
10+
|-------------------|----------------------------------------------------|----------|----------------------------------------------|
11+
| `token` | A GitHub access token - either a classic PAT or a GitHub app installation token. | Yes | |
12+
| `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 }}` |
13+
| `title` | The title of the project to find. | Yes | |
14+
15+
## Outputs
16+
17+
| Name | Description |
18+
|-------------------|----------------------------------------------------|
19+
| `id` | The global ID for the project. |
20+
| `closed` | The closed state of the project. |
21+
| `field-count` | The number of fields on the project. |
22+
| `item-count` | The number of items in the project. |
23+
| `number` | The project number of the project. |
24+
| `public` | The public visibility of the project. |
25+
| `readme` | The readme description of the project. |
26+
| `description` | The short description of the project. |
27+
| `title` | The title of the project. |
28+
| `url` | The URL of the project. |
29+
30+
## License
31+
32+
MIT

find-project/action.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Find Project
2+
description: Find a GitHub project
3+
author: David Sanders
4+
5+
inputs:
6+
token:
7+
description: A GitHub access token - either a classic PAT or a GitHub app installation token
8+
required: true
9+
owner:
10+
description: The owner of the project - either an organization or a user
11+
required: false
12+
default: ${{ github.repository_owner }}
13+
title:
14+
description: The title of the project to find
15+
required: true
16+
17+
outputs:
18+
id:
19+
description: The global ID for the project
20+
closed:
21+
description: The closed state of the project
22+
field-count:
23+
description: The number of fields on the project
24+
item-count:
25+
description: The number of items in the project
26+
number:
27+
description: The project number of the project
28+
public:
29+
description: The public visibility of the project
30+
readme:
31+
description: The readme description of the project
32+
description:
33+
description: The short description of the project
34+
title:
35+
description: The title of the project
36+
url:
37+
description: The URL of the project
38+
39+
runs:
40+
using: node20
41+
main: ../dist/find-project.js

find-project/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { findProjectAction } from '../src/find-project';
2+
3+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
4+
findProjectAction();

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"package:delete-project": "esbuild delete-project/index.ts --bundle --outfile=dist/delete-project.js --platform=node --target=node20.2",
3131
"package:edit-item": "esbuild edit-item/index.ts --bundle --outfile=dist/edit-item.js --platform=node --target=node20.2",
3232
"package:edit-project": "esbuild edit-project/index.ts --bundle --outfile=dist/edit-project.js --platform=node --target=node20.2",
33+
"package:find-project": "esbuild find-project/index.ts --bundle --outfile=dist/find-project.js --platform=node --target=node20.2",
3334
"package:get-item": "esbuild get-item/index.ts --bundle --outfile=dist/get-item.js --platform=node --target=node20.2",
3435
"package:get-project": "esbuild get-project/index.ts --bundle --outfile=dist/get-project.js --platform=node --target=node20.2",
3536
"package:link-project": "esbuild link-project/index.ts --bundle --outfile=dist/link-project.js --platform=node --target=node20.2",

src/find-project.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as core from '@actions/core';
2+
3+
import { findProject } from './lib';
4+
5+
export async function findProjectAction(): Promise<void> {
6+
try {
7+
// Required inputs
8+
const owner = core.getInput('owner', { required: true });
9+
const title = core.getInput('title', { required: true });
10+
11+
const project = await findProject(owner, title);
12+
13+
if (!project) {
14+
core.setFailed(`Project not found: ${title}`);
15+
return;
16+
}
17+
18+
core.setOutput('closed', project.closed);
19+
core.setOutput('field-count', project.fields.totalCount);
20+
core.setOutput('id', project.id);
21+
core.setOutput('item-count', project.items.totalCount);
22+
core.setOutput('number', project.number);
23+
core.setOutput('public', project.public);
24+
core.setOutput('readme', project.readme);
25+
core.setOutput('description', project.shortDescription);
26+
core.setOutput('title', project.title);
27+
core.setOutput('url', project.url);
28+
} catch (error) {
29+
// Fail the workflow run if an error occurs
30+
if (error instanceof Error && error.stack) core.debug(error.stack);
31+
core.setFailed(
32+
error instanceof Error ? error.message : JSON.stringify(error)
33+
);
34+
}
35+
}

0 commit comments

Comments
 (0)