Skip to content

Commit cc8d007

Browse files
Enhance response transformation for environment listing (#2)
* Enhance response transformation for environment listing * Add debug logging for environment status checks * Add debug logging for API response in environment listing * Add validation for API response in environment listing * Rename pagination token property for consistency * fix * Add sleep function and handle rate limits during environment deletion * Implement retry logic for environment deletion with rate limit handling * Add filtering for stopped environments in listEnvironments function * Remove redundant runner_kinds filter from listEnvironments function * Add retry logic and rate limit handling for environment listing and deletion * Update README and main.ts to enhance environment deletion criteria and logging
1 parent d503af6 commit cc8d007

File tree

2 files changed

+169
-73
lines changed

2 files changed

+169
-73
lines changed

README.md

+7-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ Automatically clean up stale Gitpod environments that haven't been started for a
1919
- Have no unpushed commits
2020
- Haven't been started for X days
2121
- 📄 Optional summary report of deleted environments
22+
- 🔒 Only deletes environments that are running in remote runners (not local runners)
2223
- 🔄 Handles pagination for organizations with many environments
24+
- ⚡ Smart rate limiting with exponential backoff
25+
- 🔍 Detailed operation logging for better troubleshooting
2326

2427
## Usage
2528

@@ -40,7 +43,7 @@ jobs:
4043
runs-on: ubuntu-latest
4144
steps:
4245
- name: Cleanup Old Environments
43-
uses: Siddhant-K-code/cleanup-gitpod-environments@v1
46+
uses: Siddhant-K-code/cleanup-gitpod-environments@v1.1
4447
with:
4548
GITPOD_TOKEN: ${{ secrets.GITPOD_TOKEN }}
4649
ORGANIZATION_ID: ${{ secrets.GITPOD_ORGANIZATION_ID }}
@@ -61,7 +64,7 @@ jobs:
6164
runs-on: ubuntu-latest
6265
steps:
6366
- name: Cleanup Old Environments
64-
uses: Siddhant-K-code/cleanup-gitpod-environments@v1
67+
uses: Siddhant-K-code/cleanup-gitpod-environments@v1.1
6568
with:
6669
GITPOD_TOKEN: ${{ secrets.GITPOD_TOKEN }}
6770
ORGANIZATION_ID: ${{ secrets.GITPOD_ORGANIZATION_ID }}
@@ -121,8 +124,9 @@ jobs:
121124
## Cleanup Criteria 🔍
122125

123126
An environment is deleted only if ALL conditions are met:
127+
- It is a environment running in Remote runner.
128+
- Currently in STOPPED or UNSPECIFIED phase
124129
- Not started for X days (configurable)
125-
- Currently in STOPPED phase
126130
- No uncommitted changes
127131
- No unpushed commits
128132

src/main.ts

+162-70
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import axios from "axios";
44
import * as core from "@actions/core";
55

66
interface PaginationResponse {
7-
next_page_token?: string;
7+
nextToken?: string;
88
}
99

1010
interface GitStatus {
@@ -83,6 +83,11 @@ interface DeletedEnvironmentInfo {
8383
inactiveDays: number;
8484
}
8585

86+
/**
87+
* Sleep function to add delay between API calls
88+
*/
89+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
90+
8691
/**
8792
* Formats a date difference in days
8893
*/
@@ -116,6 +121,30 @@ function isStale(lastStartedAt: string, days: number): boolean {
116121
return lastStarted < cutoffDate;
117122
}
118123

124+
async function getRunner(runnerId: string, gitpodToken: string): Promise<boolean> {
125+
const baseDelay = 2000;
126+
try {
127+
const response = await axios.post(
128+
"https://app.gitpod.io/api/gitpod.v1.RunnerService/GetRunner",
129+
{
130+
runner_id: runnerId
131+
},
132+
{
133+
headers: {
134+
"Content-Type": "application/json",
135+
Authorization: `Bearer ${gitpodToken}`,
136+
},
137+
}
138+
);
139+
140+
await sleep(baseDelay);
141+
return response.data.runner.kind === "RUNNER_KIND_REMOTE";
142+
} catch (error) {
143+
core.debug(`Error getting runner ${runnerId}: ${error}`);
144+
return false;
145+
}
146+
}
147+
119148
/**
120149
* Lists and filters environments that should be deleted
121150
*/
@@ -126,59 +155,87 @@ async function listEnvironments(
126155
): Promise<DeletedEnvironmentInfo[]> {
127156
const toDelete: DeletedEnvironmentInfo[] = [];
128157
let pageToken: string | undefined = undefined;
158+
const baseDelay = 2000;
159+
let retryCount = 0;
160+
const maxRetries = 3;
161+
let totalEnvironmentsChecked = 0;
129162

130163
try {
131164
do {
132-
const response: { data: ListEnvironmentsResponse } = await axios.post<ListEnvironmentsResponse>(
133-
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/ListEnvironments",
134-
{
135-
organization_id: organizationId,
136-
pagination: {
137-
page_size: 100,
138-
page_token: pageToken
139-
}
140-
},
141-
{
142-
headers: {
143-
"Content-Type": "application/json",
144-
Authorization: `Bearer ${gitpodToken}`,
165+
try {
166+
const response: { data: ListEnvironmentsResponse } = await axios.post<ListEnvironmentsResponse>(
167+
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/ListEnvironments",
168+
{
169+
organization_id: organizationId,
170+
pagination: {
171+
page_size: 100,
172+
page_token: pageToken
173+
},
174+
filter: {
175+
status_phases: ["ENVIRONMENT_PHASE_STOPPED", "ENVIRONMENT_PHASE_UNSPECIFIED"]
176+
}
145177
},
146-
}
147-
);
178+
{
179+
headers: {
180+
"Content-Type": "application/json",
181+
Authorization: `Bearer ${gitpodToken}`,
182+
},
183+
}
184+
);
185+
186+
core.debug(`ListEnvironments API Response: ${JSON.stringify(response.data)}`);
187+
await sleep(baseDelay);
188+
189+
const environments = response.data.environments;
190+
totalEnvironmentsChecked += environments.length;
191+
core.debug(`Fetched ${environments.length} stopped environments`);
192+
193+
for (const env of environments) {
194+
core.debug(`Checking environment ${env.id}:`);
195+
196+
const isRemoteRunner = await getRunner(env.metadata.runnerId, gitpodToken);
197+
core.debug(`- Is remote runner: ${isRemoteRunner}`);
198+
199+
const hasNoChangedFiles = !(env.status.content?.git?.totalChangedFiles);
200+
core.debug(`- Has no changed files: ${hasNoChangedFiles}`);
148201

149-
core.debug(`Fetched ${response.data.environments.length} environments`);
150-
151-
const environments = response.data.environments;
152-
153-
environments.forEach((env) => {
154-
const isStopped = env.status.phase === "ENVIRONMENT_PHASE_STOPPED";
155-
const hasNoChangedFiles = !(env.status.content?.git?.totalChangedFiles);
156-
const hasNoUnpushedCommits = !(env.status.content?.git?.totalUnpushedCommits);
157-
const isInactive = isStale(env.metadata.lastStartedAt, olderThanDays);
158-
159-
if (isStopped && hasNoChangedFiles && hasNoUnpushedCommits && isInactive) {
160-
toDelete.push({
161-
id: env.id,
162-
projectUrl: getProjectUrl(env),
163-
lastStarted: env.metadata.lastStartedAt,
164-
createdAt: env.metadata.createdAt,
165-
creator: env.metadata.creator.id,
166-
inactiveDays: getDaysSince(env.metadata.lastStartedAt)
167-
});
168-
169-
core.debug(
170-
`Marked for deletion: Environment ${env.id}\n` +
171-
`Project: ${getProjectUrl(env)}\n` +
172-
`Last Started: ${env.metadata.lastStartedAt}\n` +
173-
`Days Inactive: ${getDaysSince(env.metadata.lastStartedAt)}\n` +
174-
`Creator: ${env.metadata.creator.id}`
175-
);
202+
const hasNoUnpushedCommits = !(env.status.content?.git?.totalUnpushedCommits);
203+
core.debug(`- Has no unpushed commits: ${hasNoUnpushedCommits}`);
204+
205+
const isInactive = isStale(env.metadata.lastStartedAt, olderThanDays);
206+
core.debug(`- Is inactive: ${isInactive}`);
207+
208+
209+
210+
if (isRemoteRunner && hasNoChangedFiles && hasNoUnpushedCommits && isInactive) {
211+
toDelete.push({
212+
id: env.id,
213+
projectUrl: getProjectUrl(env),
214+
lastStarted: env.metadata.lastStartedAt,
215+
createdAt: env.metadata.createdAt,
216+
creator: env.metadata.creator.id,
217+
inactiveDays: getDaysSince(env.metadata.lastStartedAt)
218+
});
219+
}
176220
}
177-
});
178221

179-
pageToken = response.data.pagination.next_page_token;
222+
pageToken = response.data.pagination.nextToken;
223+
retryCount = 0;
224+
} catch (error) {
225+
if (axios.isAxiosError(error) && error.response?.status === 429 && retryCount < maxRetries) {
226+
const delay = baseDelay * Math.pow(2, retryCount);
227+
core.debug(`Rate limit hit in ListEnvironments, waiting ${delay}ms before retry ${retryCount + 1}...`);
228+
await sleep(delay);
229+
retryCount++;
230+
continue;
231+
}
232+
throw error;
233+
}
180234
} while (pageToken);
181235

236+
core.info(`Total environments checked: ${totalEnvironmentsChecked}`);
237+
core.info(`Environments matching deletion criteria: ${toDelete.length}`);
238+
182239
return toDelete;
183240
} catch (error) {
184241
core.error(`Error in listEnvironments: ${error}`);
@@ -194,24 +251,41 @@ async function deleteEnvironment(
194251
gitpodToken: string,
195252
organizationId: string
196253
) {
197-
try {
198-
await axios.post(
199-
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/DeleteEnvironment",
200-
{
201-
environment_id: environmentId,
202-
organization_id: organizationId
203-
},
204-
{
205-
headers: {
206-
"Content-Type": "application/json",
207-
Authorization: `Bearer ${gitpodToken}`,
254+
let retryCount = 0;
255+
const maxRetries = 3;
256+
const baseDelay = 2000;
257+
258+
while (retryCount <= maxRetries) {
259+
try {
260+
const response = await axios.post(
261+
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/DeleteEnvironment",
262+
{
263+
environment_id: environmentId,
264+
organization_id: organizationId
208265
},
266+
{
267+
headers: {
268+
"Content-Type": "application/json",
269+
Authorization: `Bearer ${gitpodToken}`,
270+
},
271+
}
272+
);
273+
274+
core.debug(`DeleteEnvironment API Response for ${environmentId}: ${JSON.stringify(response.data)}`);
275+
await sleep(baseDelay);
276+
core.debug(`Successfully deleted environment: ${environmentId}`);
277+
return;
278+
} catch (error) {
279+
if (axios.isAxiosError(error) && error.response?.status === 429 && retryCount < maxRetries) {
280+
const delay = baseDelay * Math.pow(2, retryCount);
281+
core.debug(`Rate limit hit in DeleteEnvironment, waiting ${delay}ms before retry ${retryCount + 1}...`);
282+
await sleep(delay);
283+
retryCount++;
284+
} else {
285+
core.error(`Error deleting environment ${environmentId}: ${error}`);
286+
throw error;
209287
}
210-
);
211-
core.debug(`Deleted environment: ${environmentId}`);
212-
} catch (error) {
213-
core.error(`Error deleting environment ${environmentId}: ${error}`);
214-
throw error;
288+
}
215289
}
216290
}
217291

@@ -248,15 +322,33 @@ async function run() {
248322

249323
// Process deletions
250324
for (const envInfo of environmentsToDelete) {
251-
try {
252-
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
253-
deletedEnvironments.push(envInfo);
254-
totalDaysInactive += envInfo.inactiveDays;
255-
256-
core.debug(`Successfully deleted environment: ${envInfo.id}`);
257-
} catch (error) {
258-
core.warning(`Failed to delete environment ${envInfo.id}: ${error}`);
259-
// Continue with other deletions even if one fails
325+
let retryCount = 0;
326+
const maxRetries = 5;
327+
const baseDelay = 2000;
328+
while (retryCount <= maxRetries) {
329+
try {
330+
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
331+
await sleep(baseDelay);
332+
deletedEnvironments.push(envInfo);
333+
totalDaysInactive += envInfo.inactiveDays;
334+
core.debug(`Successfully deleted environment: ${envInfo.id}`);
335+
} catch (error) {
336+
if (axios.isAxiosError(error) && error.response?.status === 429) {
337+
// If we hit rate limit, wait 5 seconds before retrying
338+
core.debug('Rate limit hit, waiting 5 seconds...');
339+
await sleep(5000);
340+
// Retry the deletion
341+
try {
342+
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
343+
deletedEnvironments.push(envInfo);
344+
totalDaysInactive += envInfo.inactiveDays;
345+
} catch (retryError) {
346+
core.warning(`Failed to delete environment ${envInfo.id} after retry: ${retryError}`);
347+
}
348+
} else {
349+
core.warning(`Failed to delete environment ${envInfo.id}: ${error}`);
350+
}
351+
}
260352
}
261353
}
262354

0 commit comments

Comments
 (0)