Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,38 @@ runs:
}
}

const handleLimitExceeded = async (body) => {
const limit = body?.detail?.limits?.[0]?.limit
const limitText = limit ? ` of **${limit}** infrastructure units` : ''
const message = `You've exceeded your plan's usage limit${limitText}. To continue using Pipelines, contact sales to upgrade your plan or request a one-time restoration of service during which you may reduce your usage.`

await core.summary
.addHeading('Your Pipelines have been paused')
.addEOL()
.addRaw(message)
.write({ overwrite: true })

if (context.payload.pull_request) {
try {
const marker = '<!-- pipelines-limit-exceeded -->'
const commentBody = `${marker}\n## Your Gruntwork Pipelines have been paused\n\n${message}`
const { owner, repo } = context.repo
const issue_number = context.payload.pull_request.number
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number })
const existing = comments.find(c => c.body.startsWith(marker))
if (existing) {
const { data } = await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody })
console.log(`Updated comment: ${data.html_url}`)
} else {
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body: commentBody })
console.log(`Created comment: ${data.html_url}`)
}
} catch (e) {
console.log(`Failed to post PR comment: ${e.message}`)
}
}
}

try {
const apiBaseURL = process.env.API_BASE_URL

Expand All @@ -83,7 +115,17 @@ runs:
method: "POST",
headers: { "Authorization": `Bearer ${idToken}` }
})
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
if (!res.ok) {
if (res.status === 403) {
try {
const body = await res.json()
if (body.error === "LIMIT_EXCEEDED") {
await handleLimitExceeded(body)
}
} catch (_) {}
}
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
return (await res.json()).token
}, "login")

Expand Down
167 changes: 164 additions & 3 deletions test/action.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ function createCoreMock() {
getIDToken: jest.fn().mockResolvedValue('mock-id-token'),
setOutput: jest.fn(),
setFailed: jest.fn(),
summary: {
addHeading: jest.fn().mockReturnThis(),
addEOL: jest.fn().mockReturnThis(),
addRaw: jest.fn().mockReturnThis(),
write: jest.fn().mockResolvedValue(undefined),
},
};
}

function okResponse(token) {
return { ok: true, status: 200, statusText: 'OK', body: { token } };
}

function errorResponse(status, statusText) {
return { ok: false, status, statusText };
function errorResponse(status, statusText, body) {
return { ok: false, status, statusText, body };
}

function createFetchMock(responses) {
Expand All @@ -28,7 +34,10 @@ function createFetchMock(responses) {
ok: resp.ok,
status: resp.status,
statusText: resp.statusText,
json: async () => resp.body,
json: async () => {
if (resp.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
return resp.body;
},
};
});
}
Expand All @@ -42,6 +51,36 @@ const DEFAULT_ENV = {
const LOGIN_URL = 'https://api.example.com/api/v1/tokens/auth/login';
const TOKEN_URL = 'https://api.example.com/api/v1/tokens/pat/org/repo';

function createGithubMock(existingComments = []) {
return {
rest: {
issues: {
listComments: jest.fn().mockResolvedValue({ data: existingComments }),
createComment: jest.fn().mockResolvedValue({ data: { html_url: 'https://github.com/test-owner/test-repo/issues/1#issuecomment-new' } }),
updateComment: jest.fn().mockResolvedValue({ data: { html_url: 'https://github.com/test-owner/test-repo/issues/1#issuecomment-updated' } }),
},
},
};
}

function createContextMock(prNumber) {
return {
payload: {
pull_request: prNumber ? { number: prNumber } : undefined,
},
repo: { owner: 'test-owner', repo: 'test-repo' },
};
}

function limitExceededResponse(limit, used) {
return errorResponse(403, 'Forbidden', {
error: 'LIMIT_EXCEEDED',
detail: {
limits: [{ limit, used }],
},
});
}

describe('pipelines-credentials action', () => {
describe('happy path', () => {
test('OIDC login and token fetch', async () => {
Expand Down Expand Up @@ -111,6 +150,128 @@ describe('pipelines-credentials action', () => {
});
});

describe('LIMIT_EXCEEDED behavior', () => {
test('writes job summary and falls back to FALLBACK_TOKEN', async () => {
const core = createCoreMock();
const fetch = createFetchMock([limitExceededResponse(100, 120)]);

await runAction({
coreMock: core,
fetchMock: fetch,
env: DEFAULT_ENV,
contextMock: createContextMock(),
});

expect(core.summary.addHeading).toHaveBeenCalledWith('Your Pipelines have been paused');
expect(core.summary.addRaw).toHaveBeenCalledWith(
expect.stringContaining('**100** infrastructure units')
);
expect(core.summary.write).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
});

test('creates PR comment when no existing comment', async () => {
const core = createCoreMock();
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
const githubMock = createGithubMock();
const contextMock = createContextMock(42);

await runAction({
coreMock: core,
fetchMock: fetch,
env: DEFAULT_ENV,
githubMock,
contextMock,
});

expect(githubMock.rest.issues.createComment).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
issue_number: 42,
body: expect.stringContaining('Your Gruntwork Pipelines have been paused'),
});
expect(githubMock.rest.issues.updateComment).not.toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
});

test('updates existing PR comment instead of creating a new one', async () => {
const core = createCoreMock();
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
const existingComment = { id: 999, body: '<!-- pipelines-limit-exceeded -->\n## old content' };
const githubMock = createGithubMock([existingComment]);
const contextMock = createContextMock(42);

await runAction({
coreMock: core,
fetchMock: fetch,
env: DEFAULT_ENV,
githubMock,
contextMock,
});

expect(githubMock.rest.issues.updateComment).toHaveBeenCalledWith({
owner: 'test-owner',
repo: 'test-repo',
comment_id: 999,
body: expect.stringContaining('Your Gruntwork Pipelines have been paused'),
});
expect(githubMock.rest.issues.createComment).not.toHaveBeenCalled();
});

test('does NOT write summary or comment for a normal 403', async () => {
const core = createCoreMock();
const fetch = createFetchMock([errorResponse(403, 'Forbidden')]);
const githubMock = createGithubMock();
const contextMock = createContextMock(42);

await runAction({
coreMock: core,
fetchMock: fetch,
env: DEFAULT_ENV,
githubMock,
contextMock,
});

expect(core.summary.write).not.toHaveBeenCalled();
expect(githubMock.rest.issues.createComment).not.toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
});

test('handles PR comment failure gracefully', async () => {
const core = createCoreMock();
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
const githubMock = createGithubMock();
githubMock.rest.issues.listComments.mockRejectedValue(new Error('Resource not accessible by integration'));
const contextMock = createContextMock(42);

await runAction({
coreMock: core,
fetchMock: fetch,
env: DEFAULT_ENV,
githubMock,
contextMock,
});

expect(core.summary.write).toHaveBeenCalled();
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
});

test('calls setFailed when LIMIT_EXCEEDED and no FALLBACK_TOKEN', async () => {
const core = createCoreMock();
const fetch = createFetchMock([limitExceededResponse(100, 120)]);

await runAction({
coreMock: core,
fetchMock: fetch,
env: { ...DEFAULT_ENV, FALLBACK_TOKEN: '' },
contextMock: createContextMock(),
});

expect(core.summary.write).toHaveBeenCalled();
expect(core.setFailed).toHaveBeenCalled();
});
});

describe('retry behavior', () => {
test.each([
{
Expand Down
6 changes: 3 additions & 3 deletions test/helpers/create-sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,22 @@ function getSandboxedFn() {
if (!cachedFn) {
const script = extractScript();
cachedFn = new AsyncFunction(
'core', 'fetch', 'process', 'console', 'setTimeout', 'Math',
'core', 'fetch', 'process', 'console', 'setTimeout', 'Math', 'github', 'context',
script
);
}
return cachedFn;
}

async function runAction({ coreMock, fetchMock, env = {} }) {
async function runAction({ coreMock, fetchMock, env = {}, githubMock = {}, contextMock = {} }) {
const fn = getSandboxedFn();

const processShim = { env: { ...env } };
const consoleShim = { log: jest.fn() };
const setTimeoutShim = (fn) => fn();
const mathShim = { ...Math, random: () => 0.5, pow: Math.pow, round: Math.round };

await fn(coreMock, fetchMock, processShim, consoleShim, setTimeoutShim, mathShim);
await fn(coreMock, fetchMock, processShim, consoleShim, setTimeoutShim, mathShim, githubMock, contextMock);
}

module.exports = { runAction };