Skip to content

Commit e8f6912

Browse files
authored
Merge pull request #4 from OpenPathfinder/faet/projects
Changes: - New Features - Introduced a new CLI command to add a project with optional GitHub organization URLs. - Bug Fixes - Improved error messages for API failures, providing more detailed feedback. - Tests - Added comprehensive tests for project creation and GitHub organization association scenarios. - Chores - Added a new runtime dependency for string-to-array conversion. - Introduced reusable test fixtures for consistent API response mocking. - Documentation - Expanded type definitions for API responses and error handling.
2 parents 068d122 + 8666b1a commit e8f6912

File tree

9 files changed

+389
-14
lines changed

9 files changed

+389
-14
lines changed

.github/workflows/pr-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
strategy:
1515
matrix:
1616
os: [ubuntu-latest, windows-latest, macOS-latest]
17-
node-version: [18.x, 20.x, 22.x, 24.x]
17+
node-version: [20.x, 22.x, 24.x]
1818
fail-fast: false
1919

2020
steps:

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"typescript": "5.8.3"
3939
},
4040
"dependencies": {
41+
"@ulisesgascon/string-to-array": "2.0.0",
4142
"commander": "14.0.0",
4243
"got": "14.4.7",
4344
"nock": "14.0.5",

src/__tests__/cli-commands.test.ts

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/* eslint-env jest */
22

3-
import { getVersion, runDoctor } from '../cli-commands.js'
3+
import { getVersion, runDoctor, addProjectWithGithubOrgs } from '../cli-commands.js'
44
import { getPackageJson } from '../utils.js'
5-
import { APIHealthResponse } from '../types.js'
5+
import { APIHealthResponse, APIProjectDetails, APIGithubOrgDetails, APIErrorResponse } from '../types.js'
6+
import { mockApiHealthResponse, mockAPIProjectResponse, mockAPIGithubOrgResponse } from './fixtures.js'
67
import nock from 'nock'
78

89
const pkg = getPackageJson()
@@ -32,12 +33,7 @@ describe('CLI Commands', () => {
3233
let apiHealthResponse: APIHealthResponse
3334
beforeEach(() => {
3435
nock.cleanAll()
35-
apiHealthResponse = {
36-
status: 'ok',
37-
timestamp: new Date().toISOString(),
38-
version: '0.1.0-beta3',
39-
name: 'visionBoard'
40-
}
36+
apiHealthResponse = mockApiHealthResponse
4137
})
4238

4339
it('should return success when API is available and compatible', async () => {
@@ -88,4 +84,95 @@ describe('CLI Commands', () => {
8884
expect(result.messages).toHaveLength(1)
8985
})
9086
})
87+
88+
describe('addProjectWithGithubOrgs', () => {
89+
let mockProject: APIProjectDetails
90+
let mockGithubOrg1: APIGithubOrgDetails
91+
let mockGithubOrg2: APIGithubOrgDetails
92+
93+
beforeEach(() => {
94+
nock.cleanAll()
95+
96+
// Setup mock project data using fixtures
97+
mockProject = { ...mockAPIProjectResponse }
98+
99+
// Create simplified GitHub org responses for tests
100+
mockGithubOrg1 = { ...mockAPIGithubOrgResponse }
101+
102+
mockGithubOrg2 = {
103+
...mockAPIGithubOrgResponse,
104+
id: 789,
105+
name: 'org2',
106+
login: 'org2'
107+
}
108+
})
109+
110+
it('should create a project and add GitHub organizations successfully', async () => {
111+
// Mock API calls
112+
nock('http://localhost:3000')
113+
.post('/api/v1/project', { name: 'Test Project' })
114+
.reply(201, mockProject)
115+
116+
nock('http://localhost:3000')
117+
.post('/api/v1/project/123/gh-org', { githubOrgUrl: 'https://github.com/org1' })
118+
.reply(201, mockGithubOrg1)
119+
120+
nock('http://localhost:3000')
121+
.post('/api/v1/project/123/gh-org', { githubOrgUrl: 'https://github.com/org2' })
122+
.reply(201, mockGithubOrg2)
123+
124+
// Execute the function
125+
const result = await addProjectWithGithubOrgs('Test Project', [
126+
'https://github.com/org1',
127+
'https://github.com/org2'
128+
])
129+
130+
// Verify the result
131+
expect(result.success).toBe(true)
132+
expect(result.messages).toContain('✅ Project created successfully')
133+
expect(result.messages).toHaveLength(1)
134+
expect(nock.isDone()).toBe(true) // Verify all mocked endpoints were called
135+
})
136+
137+
it('should handle failure when project creation fails', async () => {
138+
// Mock failed project creation
139+
nock('http://localhost:3000')
140+
.post('/api/v1/project', { name: 'Existing Project' })
141+
.reply(409, { errors: [{ message: 'Project already exists' }] } as APIErrorResponse)
142+
143+
// Execute the function
144+
const result = await addProjectWithGithubOrgs('Existing Project', [
145+
'https://github.com/org1'
146+
])
147+
148+
// Verify the result
149+
expect(result.success).toBe(false)
150+
expect(result.messages[0]).toContain('❌ Failed to create the project')
151+
expect(result.messages[0]).toContain('Project (Existing Project) already exists')
152+
expect(result.messages).toHaveLength(1)
153+
})
154+
155+
it('should handle failure when adding GitHub organization fails', async () => {
156+
// Mock API calls
157+
nock('http://localhost:3000')
158+
.post('/api/v1/project', { name: 'Test Project' })
159+
.reply(201, mockProject)
160+
161+
// Mock failed GitHub org addition (already exists)
162+
nock('http://localhost:3000')
163+
.post('/api/v1/project/123/gh-org', { githubOrgUrl: 'https://github.com/existing-org' })
164+
.reply(409, { errors: [{ message: 'GitHub organization already exists in the project' }] } as APIErrorResponse)
165+
166+
// Execute the function
167+
const result = await addProjectWithGithubOrgs('Test Project', [
168+
'https://github.com/existing-org'
169+
])
170+
171+
// Verify the result
172+
expect(result.success).toBe(false)
173+
expect(result.messages[0]).toContain('❌ Failed to create the project')
174+
expect(result.messages[0]).toContain('GitHub organization (https://github.com/existing-org) already exists in the project')
175+
expect(result.messages).toHaveLength(1)
176+
})
177+
})
91178
})

src/__tests__/fixtures.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { APIHealthResponse, APIProjectDetails, APIGithubOrgDetails } from '../types.js'
2+
3+
export const mockApiHealthResponse: APIHealthResponse = {
4+
status: 'ok',
5+
timestamp: new Date().toISOString(),
6+
version: '0.1.0-beta3',
7+
name: 'visionBoard'
8+
}
9+
10+
export const mockAPIProjectResponse: APIProjectDetails = {
11+
id: 123,
12+
name: 'Test Project',
13+
created_at: new Date().toISOString(),
14+
updated_at: new Date().toISOString(),
15+
has_defineFunctionalRoles_policy: null,
16+
has_orgToolingMFA_policy: null,
17+
has_softwareArchitectureDocs_policy: null,
18+
has_MFAImpersonationDefense_policy: null,
19+
has_includeCVEInReleaseNotes_policy: null,
20+
has_assignCVEForKnownVulns_policy: null,
21+
has_incidentResponsePlan_policy: null,
22+
has_regressionTestsForVulns_policy: null,
23+
has_vulnResponse14Days_policy: null,
24+
has_useCVDToolForVulns_policy: null,
25+
has_securityMdMeetsOpenJSCVD_policy: null,
26+
has_consistentBuildProcessDocs_policy: null,
27+
has_machineReadableDependencies_policy: null,
28+
has_identifyModifiedDependencies_policy: null,
29+
has_ciAndCdPipelineAsCode_policy: null,
30+
has_npmOrgMFA_policy: null,
31+
has_npmPublicationMFA_policy: null,
32+
has_upgradePathDocs_policy: null,
33+
has_patchNonCriticalVulns90Days_policy: null,
34+
has_patchCriticalVulns30Days_policy: null,
35+
has_twoOrMoreOwnersForAccess_policy: null,
36+
has_injectedSecretsAtRuntime_policy: null,
37+
has_preventScriptInjection_policy: null,
38+
has_resolveLinterWarnings_policy: null,
39+
has_annualDependencyRefresh_policy: null
40+
}
41+
42+
export const mockAPIGithubOrgResponse: APIGithubOrgDetails = {
43+
id: 456,
44+
login: 'test-org',
45+
github_org_id: 789,
46+
node_id: 'O_kgDOBjYYyw',
47+
url: 'https://api.github.com/orgs/test-org',
48+
avatar_url: 'https://avatars.githubusercontent.com/u/12345678?v=4',
49+
description: 'Test organization for OpenPathfinder',
50+
name: 'Test Organization',
51+
company: null,
52+
blog: 'https://test-org.github.io',
53+
location: 'Worldwide',
54+
twitter_username: 'testorg',
55+
is_verified: true,
56+
has_organization_projects: true,
57+
has_repository_projects: true,
58+
public_repos: 42,
59+
public_gists: 0,
60+
followers: 100,
61+
following: 0,
62+
html_url: 'https://github.com/test-org',
63+
total_private_repos: 10,
64+
owned_private_repos: 10,
65+
private_gists: 0,
66+
disk_usage: 1000,
67+
collaborators: 5,
68+
default_repository_permission: 'read',
69+
members_can_create_repositories: true,
70+
two_factor_requirement_enabled: true,
71+
members_allowed_repository_creation_type: 'all',
72+
members_can_create_public_repositories: true,
73+
members_can_create_private_repositories: true,
74+
members_can_create_internal_repositories: false,
75+
members_can_create_pages: true,
76+
members_can_create_public_pages: true,
77+
members_can_create_private_pages: true,
78+
members_can_fork_private_repositories: false,
79+
web_commit_signoff_required: true,
80+
deploy_keys_enabled_for_repositories: true,
81+
dependency_graph_enabled_for_new_repositories: true,
82+
dependabot_alerts_enabled_for_new_repositories: true,
83+
dependabot_security_updates_enabled_for_new_repositories: true,
84+
advanced_security_enabled_for_new_repositories: false,
85+
secret_scanning_enabled_for_new_repositories: true,
86+
secret_scanning_push_protection_enabled_for_new_repositories: true,
87+
secret_scanning_push_protection_custom_link: null,
88+
secret_scanning_push_protection_custom_link_enabled: false,
89+
github_created_at: '2020-01-01T00:00:00Z',
90+
github_updated_at: '2025-06-19T13:48:29Z',
91+
github_archived_at: null,
92+
created_at: new Date().toISOString(),
93+
updated_at: new Date().toISOString(),
94+
project_id: 123
95+
}

src/api-client.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getConfig } from './utils.js'
22
import { got } from 'got'
3-
import { APIHealthResponse } from './types.js'
3+
import { APIHealthResponse, APIProjectDetails, APIGithubOrgDetails } from './types.js'
44

55
export const apiClient = () => {
66
const config = getConfig()
@@ -13,7 +13,42 @@ export const getAPIDetails = async (): Promise<APIHealthResponse> => {
1313
const client = apiClient()
1414
const response = await client.get('__health', { responseType: 'json' })
1515
if (response.statusCode !== 200) {
16-
throw new Error('Failed to get the data from the API')
16+
throw new Error(`Failed to get the data from the API: ${response.statusCode} ${response.body}`)
1717
}
1818
return response.body as APIHealthResponse
1919
}
20+
21+
export const createProject = async (name: string): Promise<APIProjectDetails> => {
22+
const client = apiClient()
23+
const response = await client.post('project', {
24+
json: { name },
25+
responseType: 'json',
26+
throwHttpErrors: false
27+
})
28+
if (response.statusCode === 409) {
29+
throw new Error(`Project (${name}) already exists`)
30+
}
31+
if (response.statusCode !== 201) {
32+
throw new Error(`Failed to create the project: ${response.statusCode} ${response.body}`)
33+
}
34+
return response.body as APIProjectDetails
35+
}
36+
37+
export const addGithubOrgToProject = async (projectId: number, githubOrgUrl: string): Promise<APIGithubOrgDetails> => {
38+
const client = apiClient()
39+
const response = await client.post(`project/${projectId}/gh-org`, {
40+
json: { githubOrgUrl },
41+
responseType: 'json',
42+
throwHttpErrors: false
43+
})
44+
if (response.statusCode === 409) {
45+
throw new Error(`GitHub organization (${githubOrgUrl}) already exists in the project`)
46+
}
47+
if (response.statusCode === 404) {
48+
throw new Error(`Project (${projectId}) not found`)
49+
}
50+
if (response.statusCode !== 201) {
51+
throw new Error(`Failed to add the GitHub organization (${githubOrgUrl}) to the project: ${response.statusCode} ${response.body}`)
52+
}
53+
return response.body as APIGithubOrgDetails
54+
}

src/cli-commands.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CommandResult } from './types.js'
22
import { isApiAvailable, isApiCompatible, getPackageJson } from './utils.js'
3-
import { getAPIDetails } from './api-client.js'
3+
import { getAPIDetails, createProject, addGithubOrgToProject } from './api-client.js'
44

55
const pkg = getPackageJson()
66

@@ -36,3 +36,24 @@ export const runDoctor = async (): Promise<CommandResult> => {
3636
success
3737
}
3838
}
39+
40+
export const addProjectWithGithubOrgs = async (name: string, githubOrgUrls: string[]): Promise<CommandResult> => {
41+
const messages: string[] = []
42+
let success = true
43+
try {
44+
const project = await createProject(name)
45+
// Add GitHub organizations sequentially to avoid race conditions
46+
for (const githubOrgUrl of githubOrgUrls) {
47+
await addGithubOrgToProject(project.id, githubOrgUrl)
48+
}
49+
messages.push('✅ Project created successfully')
50+
} catch (error) {
51+
messages.push(`❌ Failed to create the project: ${error instanceof Error ? error.message : 'Unknown error'}`)
52+
success = false
53+
}
54+
55+
return {
56+
messages,
57+
success
58+
}
59+
}

src/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env node
22

33
import { Command } from 'commander'
4-
4+
// @ts-ignore
5+
import { stringToArray } from '@ulisesgascon/string-to-array'
56
import { handleCommandResult } from './utils.js'
6-
import { getVersion, runDoctor } from './cli-commands.js'
7+
import { getVersion, runDoctor, addProjectWithGithubOrgs } from './cli-commands.js'
78

89
const program = new Command()
910

@@ -22,4 +23,15 @@ program
2223
handleCommandResult(result)
2324
})
2425

26+
program
27+
.command('add-project')
28+
.description('Add a project with GitHub organizations')
29+
.requiredOption('-n, --name <name>', 'Project name')
30+
.option('-g, --github-orgs <githubOrgUrls...>', 'GitHub organization URLs')
31+
.action(async (options) => {
32+
const githubOrgs = options.githubOrgs ? stringToArray(options.githubOrgs[0]) : []
33+
const result = await addProjectWithGithubOrgs(options.name, githubOrgs)
34+
handleCommandResult(result)
35+
})
36+
2537
program.parse(process.argv)

0 commit comments

Comments
 (0)