Skip to content

Commit 37715f9

Browse files
Handle free tier limits (#21)
* Add usage limit handling * Sticky comment * Gruntwork Pipelines in comment * Add logs * Fix tests
1 parent 60cc796 commit 37715f9

File tree

3 files changed

+210
-7
lines changed

3 files changed

+210
-7
lines changed

action.yml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,38 @@ runs:
7070
}
7171
}
7272
73+
const handleLimitExceeded = async (body) => {
74+
const limit = body?.detail?.limits?.[0]?.limit
75+
const limitText = limit ? ` of **${limit}** infrastructure units` : ''
76+
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.`
77+
78+
await core.summary
79+
.addHeading('Your Pipelines have been paused')
80+
.addEOL()
81+
.addRaw(message)
82+
.write({ overwrite: true })
83+
84+
if (context.payload.pull_request) {
85+
try {
86+
const marker = '<!-- pipelines-limit-exceeded -->'
87+
const commentBody = `${marker}\n## Your Gruntwork Pipelines have been paused\n\n${message}`
88+
const { owner, repo } = context.repo
89+
const issue_number = context.payload.pull_request.number
90+
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number })
91+
const existing = comments.find(c => c.body.startsWith(marker))
92+
if (existing) {
93+
const { data } = await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody })
94+
console.log(`Updated comment: ${data.html_url}`)
95+
} else {
96+
const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body: commentBody })
97+
console.log(`Created comment: ${data.html_url}`)
98+
}
99+
} catch (e) {
100+
console.log(`Failed to post PR comment: ${e.message}`)
101+
}
102+
}
103+
}
104+
73105
try {
74106
const apiBaseURL = process.env.API_BASE_URL
75107
@@ -83,7 +115,17 @@ runs:
83115
method: "POST",
84116
headers: { "Authorization": `Bearer ${idToken}` }
85117
})
86-
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
118+
if (!res.ok) {
119+
if (res.status === 403) {
120+
try {
121+
const body = await res.json()
122+
if (body.error === "LIMIT_EXCEEDED") {
123+
await handleLimitExceeded(body)
124+
}
125+
} catch (_) {}
126+
}
127+
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
128+
}
87129
return (await res.json()).token
88130
}, "login")
89131

test/action.test.js

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@ function createCoreMock() {
55
getIDToken: jest.fn().mockResolvedValue('mock-id-token'),
66
setOutput: jest.fn(),
77
setFailed: jest.fn(),
8+
summary: {
9+
addHeading: jest.fn().mockReturnThis(),
10+
addEOL: jest.fn().mockReturnThis(),
11+
addRaw: jest.fn().mockReturnThis(),
12+
write: jest.fn().mockResolvedValue(undefined),
13+
},
814
};
915
}
1016

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

15-
function errorResponse(status, statusText) {
16-
return { ok: false, status, statusText };
21+
function errorResponse(status, statusText, body) {
22+
return { ok: false, status, statusText, body };
1723
}
1824

1925
function createFetchMock(responses) {
@@ -28,7 +34,10 @@ function createFetchMock(responses) {
2834
ok: resp.ok,
2935
status: resp.status,
3036
statusText: resp.statusText,
31-
json: async () => resp.body,
37+
json: async () => {
38+
if (resp.body === undefined) throw new SyntaxError('Unexpected end of JSON input');
39+
return resp.body;
40+
},
3241
};
3342
});
3443
}
@@ -42,6 +51,36 @@ const DEFAULT_ENV = {
4251
const LOGIN_URL = 'https://api.example.com/api/v1/tokens/auth/login';
4352
const TOKEN_URL = 'https://api.example.com/api/v1/tokens/pat/org/repo';
4453

54+
function createGithubMock(existingComments = []) {
55+
return {
56+
rest: {
57+
issues: {
58+
listComments: jest.fn().mockResolvedValue({ data: existingComments }),
59+
createComment: jest.fn().mockResolvedValue({ data: { html_url: 'https://github.com/test-owner/test-repo/issues/1#issuecomment-new' } }),
60+
updateComment: jest.fn().mockResolvedValue({ data: { html_url: 'https://github.com/test-owner/test-repo/issues/1#issuecomment-updated' } }),
61+
},
62+
},
63+
};
64+
}
65+
66+
function createContextMock(prNumber) {
67+
return {
68+
payload: {
69+
pull_request: prNumber ? { number: prNumber } : undefined,
70+
},
71+
repo: { owner: 'test-owner', repo: 'test-repo' },
72+
};
73+
}
74+
75+
function limitExceededResponse(limit, used) {
76+
return errorResponse(403, 'Forbidden', {
77+
error: 'LIMIT_EXCEEDED',
78+
detail: {
79+
limits: [{ limit, used }],
80+
},
81+
});
82+
}
83+
4584
describe('pipelines-credentials action', () => {
4685
describe('happy path', () => {
4786
test('OIDC login and token fetch', async () => {
@@ -111,6 +150,128 @@ describe('pipelines-credentials action', () => {
111150
});
112151
});
113152

153+
describe('LIMIT_EXCEEDED behavior', () => {
154+
test('writes job summary and falls back to FALLBACK_TOKEN', async () => {
155+
const core = createCoreMock();
156+
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
157+
158+
await runAction({
159+
coreMock: core,
160+
fetchMock: fetch,
161+
env: DEFAULT_ENV,
162+
contextMock: createContextMock(),
163+
});
164+
165+
expect(core.summary.addHeading).toHaveBeenCalledWith('Your Pipelines have been paused');
166+
expect(core.summary.addRaw).toHaveBeenCalledWith(
167+
expect.stringContaining('**100** infrastructure units')
168+
);
169+
expect(core.summary.write).toHaveBeenCalled();
170+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
171+
});
172+
173+
test('creates PR comment when no existing comment', async () => {
174+
const core = createCoreMock();
175+
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
176+
const githubMock = createGithubMock();
177+
const contextMock = createContextMock(42);
178+
179+
await runAction({
180+
coreMock: core,
181+
fetchMock: fetch,
182+
env: DEFAULT_ENV,
183+
githubMock,
184+
contextMock,
185+
});
186+
187+
expect(githubMock.rest.issues.createComment).toHaveBeenCalledWith({
188+
owner: 'test-owner',
189+
repo: 'test-repo',
190+
issue_number: 42,
191+
body: expect.stringContaining('Your Gruntwork Pipelines have been paused'),
192+
});
193+
expect(githubMock.rest.issues.updateComment).not.toHaveBeenCalled();
194+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
195+
});
196+
197+
test('updates existing PR comment instead of creating a new one', async () => {
198+
const core = createCoreMock();
199+
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
200+
const existingComment = { id: 999, body: '<!-- pipelines-limit-exceeded -->\n## old content' };
201+
const githubMock = createGithubMock([existingComment]);
202+
const contextMock = createContextMock(42);
203+
204+
await runAction({
205+
coreMock: core,
206+
fetchMock: fetch,
207+
env: DEFAULT_ENV,
208+
githubMock,
209+
contextMock,
210+
});
211+
212+
expect(githubMock.rest.issues.updateComment).toHaveBeenCalledWith({
213+
owner: 'test-owner',
214+
repo: 'test-repo',
215+
comment_id: 999,
216+
body: expect.stringContaining('Your Gruntwork Pipelines have been paused'),
217+
});
218+
expect(githubMock.rest.issues.createComment).not.toHaveBeenCalled();
219+
});
220+
221+
test('does NOT write summary or comment for a normal 403', async () => {
222+
const core = createCoreMock();
223+
const fetch = createFetchMock([errorResponse(403, 'Forbidden')]);
224+
const githubMock = createGithubMock();
225+
const contextMock = createContextMock(42);
226+
227+
await runAction({
228+
coreMock: core,
229+
fetchMock: fetch,
230+
env: DEFAULT_ENV,
231+
githubMock,
232+
contextMock,
233+
});
234+
235+
expect(core.summary.write).not.toHaveBeenCalled();
236+
expect(githubMock.rest.issues.createComment).not.toHaveBeenCalled();
237+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
238+
});
239+
240+
test('handles PR comment failure gracefully', async () => {
241+
const core = createCoreMock();
242+
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
243+
const githubMock = createGithubMock();
244+
githubMock.rest.issues.listComments.mockRejectedValue(new Error('Resource not accessible by integration'));
245+
const contextMock = createContextMock(42);
246+
247+
await runAction({
248+
coreMock: core,
249+
fetchMock: fetch,
250+
env: DEFAULT_ENV,
251+
githubMock,
252+
contextMock,
253+
});
254+
255+
expect(core.summary.write).toHaveBeenCalled();
256+
expect(core.setOutput).toHaveBeenCalledWith('PIPELINES_TOKEN', 'fallback-pat');
257+
});
258+
259+
test('calls setFailed when LIMIT_EXCEEDED and no FALLBACK_TOKEN', async () => {
260+
const core = createCoreMock();
261+
const fetch = createFetchMock([limitExceededResponse(100, 120)]);
262+
263+
await runAction({
264+
coreMock: core,
265+
fetchMock: fetch,
266+
env: { ...DEFAULT_ENV, FALLBACK_TOKEN: '' },
267+
contextMock: createContextMock(),
268+
});
269+
270+
expect(core.summary.write).toHaveBeenCalled();
271+
expect(core.setFailed).toHaveBeenCalled();
272+
});
273+
});
274+
114275
describe('retry behavior', () => {
115276
test.each([
116277
{

test/helpers/create-sandbox.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,22 @@ function getSandboxedFn() {
77
if (!cachedFn) {
88
const script = extractScript();
99
cachedFn = new AsyncFunction(
10-
'core', 'fetch', 'process', 'console', 'setTimeout', 'Math',
10+
'core', 'fetch', 'process', 'console', 'setTimeout', 'Math', 'github', 'context',
1111
script
1212
);
1313
}
1414
return cachedFn;
1515
}
1616

17-
async function runAction({ coreMock, fetchMock, env = {} }) {
17+
async function runAction({ coreMock, fetchMock, env = {}, githubMock = {}, contextMock = {} }) {
1818
const fn = getSandboxedFn();
1919

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

25-
await fn(coreMock, fetchMock, processShim, consoleShim, setTimeoutShim, mathShim);
25+
await fn(coreMock, fetchMock, processShim, consoleShim, setTimeoutShim, mathShim, githubMock, contextMock);
2626
}
2727

2828
module.exports = { runAction };

0 commit comments

Comments
 (0)