Skip to content
24 changes: 24 additions & 0 deletions __fixtures__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ const sampleGithubOrg = {
secret_scanning_push_protection_custom_link_enabled: false
}

const sampleGithubOrgMembers = [
{
login: 'octocat',
id: 1,
node_id: 'MDQ6VXNlcjE=',
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
gravatar_id: '',
url: 'https://api.github.com/users/octocat',
html_url: 'https://github.com/octocat',
followers_url: 'https://api.github.com/users/octocat/followers',
following_url: 'https://api.github.com/users/octocat/following{/other_user}',
gists_url: 'https://api.github.com/users/octocat/gists{/gist_id}',
starred_url: 'https://api.github.com/users/octocat/starred{/owner}{/repo}',
subscriptions_url: 'https://api.github.com/users/octocat/subscriptions',
organizations_url: 'https://api.github.com/users/octocat/orgs',
repos_url: 'https://api.github.com/users/octocat/repos',
events_url: 'https://api.github.com/users/octocat/events{/privacy}',
received_events_url: 'https://api.github.com/users/octocat/received_events',
type: 'User',
site_admin: false
}
]

// https://docs.github.com/en/rest/reference/repos#list-organization-repositories
const sampleGithubListOrgRepos = [
{
Expand Down Expand Up @@ -922,6 +945,7 @@ const sampleBulkImportFileContent = [{

module.exports = {
sampleGithubOrg,
sampleGithubOrgMembers,
sampleGithubListOrgRepos,
sampleGithubRepository,
sampleOSSFScorecardResult,
Expand Down
53 changes: 52 additions & 1 deletion __tests__/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const {
sampleGithubOrg,
sampleGithubRepository,
sampleOSSFScorecardResult,
sampleGithubListOrgRepos
sampleGithubListOrgRepos,
sampleGithubOrgMembers
} = require('../__fixtures__')
const nock = require('nock')

Expand All @@ -15,6 +16,13 @@ const largeSampleGithubListOrgRepos = Array.from({ length: 150 }, (_, i) => ({
full_name: `org/repo-${i + 1}`
}))

// Create a larger sample data set for pagination testing
const largeSampleGithubListOrgMembers = Array.from({ length: 150 }, (_, i) => ({
...sampleGithubOrgMembers[0],
id: i + 1,
node_id: `repo-${i + 1}`
}))

describe('GitHub Provider', () => {
beforeEach(() => {
process.env.GITHUB_TOKEN = 'github_pat_mock_token'
Expand Down Expand Up @@ -95,6 +103,49 @@ describe('GitHub Provider', () => {
})
})

describe('fetchOrgMembersByLogin', () => {
it.each([undefined, null, ''])('Should throw when no login are provided', async (login) => {
await expect(github.fetchOrgMembersByLogin(login)).rejects.toThrow('Organization name is required')
})

it('Should fetch organization members by login', async () => {
nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(200, sampleGithubOrgMembers)

await expect(github.fetchOrgMembersByLogin('github')).resolves.toEqual(sampleGithubOrgMembers)
})

it('Should handle pagination correctly', async () => {
const firstPageRepos = largeSampleGithubListOrgMembers.slice(0, 100)
const secondPageRepos = largeSampleGithubListOrgMembers.slice(100)

nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(200, firstPageRepos, {
link: '<https://api.github.com/orgs/github/members?per_page=100&page=2>; rel="next"'
})
.get('/orgs/github/members?per_page=100&page=2')
.reply(200, secondPageRepos, {
link: null
})

await expect(github.fetchOrgMembersByLogin('github')).resolves.toEqual([...firstPageRepos, ...secondPageRepos])
})

it('Should throw an error if the organization does not exist', async () => {
nock('https://api.github.com')
.get('/orgs/github/members?per_page=100')
.reply(404, {
message: 'Not Found',
documentation_url: 'https://docs.github.com/rest/orgs/members#list-organization-members',
status: '404'
})

await expect(github.fetchOrgMembersByLogin('github')).rejects.toThrow('Not Found - https://docs.github.com/rest/orgs/members#list-organization-members')
})
})

describe('fetchRepoByFullName', () => {
it.each([undefined, null, ''])('Should throw when no full name are provided', async (fullName) => {
await expect(github.fetchRepoByFullName(fullName)).rejects.toThrow('The full name is required')
Expand Down
27 changes: 25 additions & 2 deletions __tests__/schemas.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, sampleBulkImportFileContent } = require('../__fixtures__')
const { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, validateBulkImport } = require('../src/schemas')
const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, sampleBulkImportFileContent, sampleGithubOrgMembers } = require('../__fixtures__')
const { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, validateBulkImport, validateGithubListOrgMembers } = require('../src/schemas')

describe('schemas', () => {
describe('validateGithubOrg', () => {
Expand Down Expand Up @@ -38,6 +38,29 @@ describe('schemas', () => {
expect(() => validateGithubListOrgRepos(invalidData)).toThrow()
})
})

describe('validateGithubListOrgMembers', () => {
test('Should not throw an error with valid data', () => {
expect(() => validateGithubListOrgMembers(sampleGithubOrgMembers)).not.toThrow()
})

test('Should not throw an error with additional data', () => {
const additionalData = [
...sampleGithubOrgMembers,
{ ...sampleGithubOrgMembers[0], additionalKey: 'value' }
]
expect(() => validateGithubListOrgMembers(additionalData)).not.toThrow()
})

test('Should throw an error with invalid data', () => {
const invalidData = [
...sampleGithubOrgMembers,
{ ...sampleGithubOrgMembers[0], id: '123' }
]
expect(() => validateGithubListOrgMembers(invalidData)).toThrow()
})
})

describe('validateGithubRepository', () => {
test('Should not throw an error with valid data', () => {
expect(() => validateGithubRepository(sampleGithubRepository)).not.toThrow()
Expand Down
7 changes: 6 additions & 1 deletion src/cli/workflows.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const inquirer = require('inquirer').default
const debug = require('debug')('cli:workflows')
const { updateGithubOrgs, upsertGithubRepositories, runAllTheComplianceChecks, upsertOSSFScorecardAnalysis } = require('../workflows')
const { updateGithubOrgs, upsertGithubRepositories, upsertGithubOrganizationMembers, runAllTheComplianceChecks, upsertOSSFScorecardAnalysis } = require('../workflows')
const { generateReports } = require('../reports')
const { bulkImport } = require('../importers')
const { logger } = require('../utils')
Expand All @@ -14,6 +14,11 @@ const commandList = [{
description: 'Check the organizations stored and update/create the information related to the repositories with the GitHub API.',
workflow: upsertGithubRepositories
}, {
name: 'upsert-github-organization-members',
description: 'Check the organizations stored and update/create the information related to the organization members with the GitHub API.',
workflow: upsertGithubOrganizationMembers
},
{
name: 'run-all-checks',
description: 'Run all the compliance checks for the stored data.',
workflow: runAllTheComplianceChecks
Expand Down
52 changes: 52 additions & 0 deletions src/database/migrations/20250224195123_github_users.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async (knex) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This table is being created with the response from https://docs.github.com/en/rest/orgs/members?apiVersion=2022-11-28#list-organization-members, I think it makes the most sense. I don't think we need all the user information.

await knex.schema.createTable('github_users', (table) => {
table.increments('id').primary() // Primary key
table.string('name')
table.string('email')
table.string('login').notNullable()
table.integer('github_user_id').unique().notNullable()
table.string('node_id').notNullable()
table.string('avatar_url')
table.string('gravatar_id')
table.string('url')
table.string('html_url')
table.string('gists_url')
table.string('followers_url')
table.string('following_url')
table.string('starred_url')
table.string('subscriptions_url')
table.string('organizations_url')
table.string('repos_url')
table.string('events_url')
table.string('received_events_url')
table.string('type')
table.boolean('site_admin').notNullable()
table.string('starred_at')
table.string('user_view_type')
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable()
table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable()
})

// Add trigger to 'github_organizations' table
await knex.raw(`
CREATE TRIGGER set_updated_at_github_users
BEFORE UPDATE ON github_users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
`)
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async (knex) => {
// Drop triggers
await knex.raw('DROP TRIGGER IF EXISTS set_updated_at_github_users ON github_users;')
// Drop table
await knex.schema.dropTableIfExists('github_users')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async (knex) => {
await knex.schema.createTable('github_organization_members', (table) => {
table.increments('id').primary() // Primary key
// Foreign key to 'github_organizations' table
table
.integer('github_organization_id')
.notNullable()
.unsigned()
.references('id')
.inTable('github_organizations')
.onDelete('CASCADE') // Deletes repository if the organization is deleted
.onUpdate('CASCADE') // Updates repository if the organization ID is updated

// Foreign key to 'github_organizations' table
table
.integer('github_user_id')
.notNullable()
.unsigned()
.references('id')
.inTable('github_users')
.onDelete('CASCADE') // Deletes repository if the organization is deleted
.onUpdate('CASCADE') // Updates repository if the organization ID is updated
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async (knex) => {
// Drop table
await knex.schema.dropTableIfExists('github_organization_members')
}
Loading
Loading