diff --git a/src/support/slack/commands/add-repo.js b/src/support/slack/commands/add-repo.js index a82bed3a7..0afaefd9a 100644 --- a/src/support/slack/commands/add-repo.js +++ b/src/support/slack/commands/add-repo.js @@ -38,7 +38,7 @@ function AddRepoCommand(context) { name: 'Add GitHub Repo', description: 'Adds a Github repository to previously added site.', phrases: PHRASES, - usageText: `${PHRASES.join(' or ')} {site} {githubRepoURL}`, + usageText: `${PHRASES.join(' or ')} {site} {githubRepoURL} [branch]`, }); const { dataAccess, log } = context; @@ -68,6 +68,25 @@ function AddRepoCommand(context) { } } + async function isOnboardedWithAemy(owner, repo) { + const { AEMY_BASE_URL } = process.env; + if (!AEMY_BASE_URL) { + throw new Error('AEMY_BASE_URL is not set'); + } + const AEMY_ENDPOINT = `${AEMY_BASE_URL}/api/fn-ghapp/functions/get_installation_token/${owner}/${repo}`; + try { + const response = await fetch(AEMY_ENDPOINT, { headers: { 'x-api-key': process.env.AEMY_API_KEY } }); + if (response.ok) { + const data = await response.json(); + return !!data.token; + } else { + throw new Error('Failed to check if repository is onboarded with Aemy'); + } + } catch (error) { + throw new Error(`Failed to check if repository is onboarded with Aemy: ${error.message}`); + } + } + /** * Execute function for AddRepoCommand. This function validates the input, fetches the repository * information from the GitHub API, and saves it as a site in the database. @@ -81,7 +100,7 @@ function AddRepoCommand(context) { const { say } = slackContext; try { - const [baseURLInput, repoUrlInput] = args; + const [baseURLInput, repoUrlInput, branchInput] = args; const baseURL = extractURLFromSlackInput(baseURLInput); let repoUrl = extractURLFromSlackInput(repoUrlInput, false, false); @@ -106,22 +125,38 @@ function AddRepoCommand(context) { const repoInfo = await fetchRepoInfo(repoUrl); + let owner; + let repoName; + let branch; + if (repoInfo === null) { - await say(`:warning: The GitHub repository '${repoUrl}' could not be found (private repo?).`); - return; + [owner, repoName] = repoUrl.split('github.com/')[1].split('/'); + branch = branchInput || 'main'; + + await say(`:warning: GitHub API returned 404 for ${repoUrl}. Adding as private repo with branch: ${branch}`); + } else { + if (repoInfo.archived) { + await say(`:warning: The GitHub repository '${repoUrl}' is archived. Please unarchive it before adding it to a site.`); + return; + } + + owner = repoInfo.owner.login; + repoName = repoInfo.name; + branch = branchInput || repoInfo.default_branch; } - if (repoInfo.archived) { - await say(`:warning: The GitHub repository '${repoUrl}' is archived. Please unarchive it before adding it to a site.`); + const isOnboarded = await isOnboardedWithAemy(owner, repoName); + if (!isOnboarded) { + await say(`:warning: The repository '${repoUrl}' is not onboarded with Aemy. Please onboard it with Aemy before adding it to a site.`); return; } site.setGitHubURL(repoUrl); const codeConfig = { type: 'github', - owner: repoInfo.owner.login, - repo: repoInfo.name, - ref: repoInfo.default_branch, + owner, + repo: repoName, + ref: branch, url: repoUrl, }; site.setCode(codeConfig); diff --git a/test/support/slack/commands/add-repo.test.js b/test/support/slack/commands/add-repo.test.js index 6febd553a..d789aba56 100644 --- a/test/support/slack/commands/add-repo.test.js +++ b/test/support/slack/commands/add-repo.test.js @@ -24,8 +24,15 @@ describe('AddRepoCommand', () => { let dataAccessStub; let sqsStub; let siteStub; + const AEMY_BASE_URL = 'https://test-aemy-base-url.net'; + + // Helper function to construct Aemy API endpoint path + const getAemyEndpointPath = (owner, repo) => `/api/fn-ghapp/functions/get_installation_token/${owner}/${repo}`; beforeEach(() => { + process.env.AEMY_API_KEY = 'test-api-key'; + process.env.AEMY_BASE_URL = AEMY_BASE_URL; + sqsStub = { sendMessage: sinon.stub().resolves(), }; @@ -94,11 +101,16 @@ describe('AddRepoCommand', () => { default_branch: 'main', }); + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: 'some-token' }); + const args = ['validSite.com', 'https://github.com/valid/repo']; const command = AddRepoCommand(context); await command.handleExecution(args, slackContext); + expect(aemyScope.isDone()).to.be.true; expect(slackContext.say.called).to.be.true; expect(siteStub.setGitHubURL).to.have.been.calledWith('https://github.com/valid/repo'); expect(siteStub.setCode).to.have.been.calledWith({ @@ -134,11 +146,17 @@ describe('AddRepoCommand', () => { default_branch: 'main', }); + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: 'some-token' }); + const args = ['validSite.com', 'github.com/valid/repo']; const command = AddRepoCommand(context); await command.handleExecution(args, slackContext); + expect(aemyScope.isDone()).to.be.true; + expect(slackContext.say).calledWith('\n' + ' :white_check_mark: *GitHub repo added for *\n' + '\n' @@ -221,9 +239,14 @@ describe('AddRepoCommand', () => { default_branch: 'main', }); + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: 'some-token' }); + await command.handleExecution(args, slackContext); // Assertions to confirm repo info was fetched and handled correctly + expect(aemyScope.isDone()).to.be.true; expect(slackContext.say).calledWithMatch(/GitHub repo added/); expect(siteStub.setCode).to.have.been.calledWith({ type: 'github', @@ -234,16 +257,36 @@ describe('AddRepoCommand', () => { }); }); - it('handles non-existent repository (404 error)', async () => { + it('handles private repo', async () => { nock('https://api.github.com') - .get('/repos/invalid/repo') + .get('/repos/private/repo') .reply(404); - args[1] = 'https://github.com/invalid/repo'; + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('private', 'repo')) + .reply(200, { token: 'some-token' }); + + args[1] = 'https://github.com/private/repo'; await command.handleExecution(args, slackContext); - // Assertions to confirm handling of non-existent repository - expect(slackContext.say.calledWith(':warning: The GitHub repository \'https://github.com/invalid/repo\' could not be found (private repo?).')).to.be.true; + // Should verify Aemy check was made + expect(aemyScope.isDone()).to.be.true; + + // Should parse URL manually and add as private repo with 'main' branch + expect(siteStub.setCode).to.have.been.calledWith({ + type: 'github', + owner: 'private', + repo: 'repo', + ref: 'main', // defaults to 'main' for private repos + url: 'https://github.com/private/repo', + }); + expect(siteStub.save).to.have.been.called; + + // Should send warning about adding as private repo + expect(slackContext.say.calledWithMatch(/GitHub API returned 404/)).to.be.true; + expect(slackContext.say.calledWithMatch(/Adding as private repo/)).to.be.true; + // Should send success message + expect(slackContext.say.calledWithMatch(/GitHub repo added/)).to.be.true; }); it('handles errors other than 404 from GitHub API', async () => { @@ -270,4 +313,238 @@ describe('AddRepoCommand', () => { expect(slackContext.say.calledWithMatch(/Network error occurred/)).to.be.true; }); }); + + describe('Branch Support', () => { + it('handles custom branch for public repository', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: 'some-token' }); + + const args = ['validSite.com', 'https://github.com/valid/repo', 'develop']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyScope.isDone()).to.be.true; + expect(siteStub.setCode).to.have.been.calledWith({ + type: 'github', + owner: 'valid', + repo: 'repo', + ref: 'develop', // Should use custom branch + url: 'https://github.com/valid/repo', + }); + expect(siteStub.save).to.have.been.called; + }); + + it('handles custom branch for private repository', async () => { + nock('https://api.github.com') + .get('/repos/private/repo') + .reply(404); + + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('private', 'repo')) + .reply(200, { token: 'some-token' }); + + const args = ['validSite.com', 'https://github.com/private/repo', 'feature-branch']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyScope.isDone()).to.be.true; + expect(slackContext.say.calledWithMatch(/GitHub API returned 404/)).to.be.true; + expect(slackContext.say.calledWithMatch(/Adding as private repo/)).to.be.true; + expect(slackContext.say.calledWithMatch(/feature-branch/)).to.be.true; + expect(siteStub.setCode).to.have.been.calledWith({ + type: 'github', + owner: 'private', + repo: 'repo', + ref: 'feature-branch', // Should use custom branch + url: 'https://github.com/private/repo', + }); + expect(siteStub.save).to.have.been.called; + + // Should send success message + expect(slackContext.say.calledWithMatch(/GitHub repo added/)).to.be.true; + }); + + it('handles private repository without custom branch (defaults to main)', async () => { + nock('https://api.github.com') + .get('/repos/private/repo') + .reply(404); + + const aemyScope = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('private', 'repo')) + .reply(200, { token: 'some-token' }); + + const args = ['validSite.com', 'https://github.com/private/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyScope.isDone()).to.be.true; + expect(siteStub.setCode).to.have.been.calledWith({ + type: 'github', + owner: 'private', + repo: 'repo', + ref: 'main', // Should default to 'main' for private repos + url: 'https://github.com/private/repo', + }); + expect(siteStub.save).to.have.been.called; + }); + }); + + describe('Aemy Integration', () => { + it('throws error when AEMY_BASE_URL is not set', async () => { + delete process.env.AEMY_BASE_URL; + + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(slackContext.say.calledWithMatch(/AEMY_BASE_URL is not set/)).to.be.true; + expect(siteStub.save).to.not.have.been.called; + + // Restore for subsequent tests + process.env.AEMY_BASE_URL = AEMY_BASE_URL; + }); + + it('blocks repository not onboarded with Aemy (token is null)', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyNock = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: null }); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyNock.isDone()).to.be.true; + expect(slackContext.say.calledWith(':warning: The repository \'https://github.com/valid/repo\' is not onboarded with Aemy. Please onboard it with Aemy before adding it to a site.')).to.be.true; + expect(siteStub.save).to.not.have.been.called; + }); + + it('blocks repository not onboarded with Aemy (token is empty string)', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyNock = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, { token: '' }); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyNock.isDone()).to.be.true; + expect(slackContext.say.calledWith(':warning: The repository \'https://github.com/valid/repo\' is not onboarded with Aemy. Please onboard it with Aemy before adding it to a site.')).to.be.true; + expect(siteStub.save).to.not.have.been.called; + }); + + it('blocks repository not onboarded with Aemy (token is undefined)', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyNock = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(200, {}); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyNock.isDone()).to.be.true; + expect(slackContext.say.calledWith(':warning: The repository \'https://github.com/valid/repo\' is not onboarded with Aemy. Please onboard it with Aemy before adding it to a site.')).to.be.true; + expect(siteStub.save).to.not.have.been.called; + }); + + it('handles Aemy API error responses', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyNock = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .reply(500, { error: 'Internal Server Error' }); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyNock.isDone()).to.be.true; + expect(slackContext.say.calledWithMatch(/Failed to check if repository is onboarded with Aemy/)).to.be.true; + expect(siteStub.save).to.not.have.been.called; + }); + + it('handles Aemy API network errors', async () => { + nock('https://api.github.com') + .get('/repos/valid/repo') + .reply(200, { + archived: false, + name: 'repo', + owner: { login: 'valid' }, + default_branch: 'main', + }); + + const aemyNock = nock(AEMY_BASE_URL) + .get(getAemyEndpointPath('valid', 'repo')) + .replyWithError('Network error'); + + const args = ['validSite.com', 'https://github.com/valid/repo']; + const command = AddRepoCommand(context); + + await command.handleExecution(args, slackContext); + + expect(aemyNock.isDone()).to.be.true; + expect(slackContext.say.calledWithMatch(/Network error/)).to.be.true; + expect(siteStub.save).to.not.have.been.called; + }); + }); });