Skip to content

Enhance response transformation for environment listing #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jan 10, 2025
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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ Automatically clean up stale Gitpod environments that haven't been started for a
- Have no unpushed commits
- Haven't been started for X days
- 📄 Optional summary report of deleted environments
- 🔒 Only deletes environments that are running in remote runners (not local runners)
- 🔄 Handles pagination for organizations with many environments
- ⚡ Smart rate limiting with exponential backoff
- 🔍 Detailed operation logging for better troubleshooting

## Usage

Expand All @@ -40,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Cleanup Old Environments
uses: Siddhant-K-code/cleanup-gitpod-environments@v1
uses: Siddhant-K-code/cleanup-gitpod-environments@v1.1
with:
GITPOD_TOKEN: ${{ secrets.GITPOD_TOKEN }}
ORGANIZATION_ID: ${{ secrets.GITPOD_ORGANIZATION_ID }}
Expand All @@ -61,7 +64,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Cleanup Old Environments
uses: Siddhant-K-code/cleanup-gitpod-environments@v1
uses: Siddhant-K-code/cleanup-gitpod-environments@v1.1
with:
GITPOD_TOKEN: ${{ secrets.GITPOD_TOKEN }}
ORGANIZATION_ID: ${{ secrets.GITPOD_ORGANIZATION_ID }}
Expand Down Expand Up @@ -121,8 +124,9 @@ jobs:
## Cleanup Criteria 🔍

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

Expand Down
232 changes: 162 additions & 70 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import axios from "axios";
import * as core from "@actions/core";

interface PaginationResponse {
next_page_token?: string;
nextToken?: string;
}

interface GitStatus {
Expand Down Expand Up @@ -83,6 +83,11 @@ interface DeletedEnvironmentInfo {
inactiveDays: number;
}

/**
* Sleep function to add delay between API calls
*/
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

/**
* Formats a date difference in days
*/
Expand Down Expand Up @@ -116,6 +121,30 @@ function isStale(lastStartedAt: string, days: number): boolean {
return lastStarted < cutoffDate;
}

async function getRunner(runnerId: string, gitpodToken: string): Promise<boolean> {
const baseDelay = 2000;
try {
const response = await axios.post(
"https://app.gitpod.io/api/gitpod.v1.RunnerService/GetRunner",
{
runner_id: runnerId
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gitpodToken}`,
},
}
);

await sleep(baseDelay);
return response.data.runner.kind === "RUNNER_KIND_REMOTE";
} catch (error) {
core.debug(`Error getting runner ${runnerId}: ${error}`);
return false;
}
}

/**
* Lists and filters environments that should be deleted
*/
Expand All @@ -126,59 +155,87 @@ async function listEnvironments(
): Promise<DeletedEnvironmentInfo[]> {
const toDelete: DeletedEnvironmentInfo[] = [];
let pageToken: string | undefined = undefined;
const baseDelay = 2000;
let retryCount = 0;
const maxRetries = 3;
let totalEnvironmentsChecked = 0;

try {
do {
const response: { data: ListEnvironmentsResponse } = await axios.post<ListEnvironmentsResponse>(
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/ListEnvironments",
{
organization_id: organizationId,
pagination: {
page_size: 100,
page_token: pageToken
}
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gitpodToken}`,
try {
const response: { data: ListEnvironmentsResponse } = await axios.post<ListEnvironmentsResponse>(
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/ListEnvironments",
{
organization_id: organizationId,
pagination: {
page_size: 100,
page_token: pageToken
},
filter: {
status_phases: ["ENVIRONMENT_PHASE_STOPPED", "ENVIRONMENT_PHASE_UNSPECIFIED"]
}
},
}
);
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gitpodToken}`,
},
}
);

core.debug(`ListEnvironments API Response: ${JSON.stringify(response.data)}`);
await sleep(baseDelay);

const environments = response.data.environments;
totalEnvironmentsChecked += environments.length;
core.debug(`Fetched ${environments.length} stopped environments`);

for (const env of environments) {
core.debug(`Checking environment ${env.id}:`);

const isRemoteRunner = await getRunner(env.metadata.runnerId, gitpodToken);
core.debug(`- Is remote runner: ${isRemoteRunner}`);

const hasNoChangedFiles = !(env.status.content?.git?.totalChangedFiles);
core.debug(`- Has no changed files: ${hasNoChangedFiles}`);

core.debug(`Fetched ${response.data.environments.length} environments`);

const environments = response.data.environments;

environments.forEach((env) => {
const isStopped = env.status.phase === "ENVIRONMENT_PHASE_STOPPED";
const hasNoChangedFiles = !(env.status.content?.git?.totalChangedFiles);
const hasNoUnpushedCommits = !(env.status.content?.git?.totalUnpushedCommits);
const isInactive = isStale(env.metadata.lastStartedAt, olderThanDays);

if (isStopped && hasNoChangedFiles && hasNoUnpushedCommits && isInactive) {
toDelete.push({
id: env.id,
projectUrl: getProjectUrl(env),
lastStarted: env.metadata.lastStartedAt,
createdAt: env.metadata.createdAt,
creator: env.metadata.creator.id,
inactiveDays: getDaysSince(env.metadata.lastStartedAt)
});

core.debug(
`Marked for deletion: Environment ${env.id}\n` +
`Project: ${getProjectUrl(env)}\n` +
`Last Started: ${env.metadata.lastStartedAt}\n` +
`Days Inactive: ${getDaysSince(env.metadata.lastStartedAt)}\n` +
`Creator: ${env.metadata.creator.id}`
);
const hasNoUnpushedCommits = !(env.status.content?.git?.totalUnpushedCommits);
core.debug(`- Has no unpushed commits: ${hasNoUnpushedCommits}`);

const isInactive = isStale(env.metadata.lastStartedAt, olderThanDays);
core.debug(`- Is inactive: ${isInactive}`);



if (isRemoteRunner && hasNoChangedFiles && hasNoUnpushedCommits && isInactive) {
toDelete.push({
id: env.id,
projectUrl: getProjectUrl(env),
lastStarted: env.metadata.lastStartedAt,
createdAt: env.metadata.createdAt,
creator: env.metadata.creator.id,
inactiveDays: getDaysSince(env.metadata.lastStartedAt)
});
}
}
});

pageToken = response.data.pagination.next_page_token;
pageToken = response.data.pagination.nextToken;
retryCount = 0;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
core.debug(`Rate limit hit in ListEnvironments, waiting ${delay}ms before retry ${retryCount + 1}...`);
await sleep(delay);
retryCount++;
continue;
}
throw error;
}
} while (pageToken);

core.info(`Total environments checked: ${totalEnvironmentsChecked}`);
core.info(`Environments matching deletion criteria: ${toDelete.length}`);

return toDelete;
} catch (error) {
core.error(`Error in listEnvironments: ${error}`);
Expand All @@ -194,24 +251,41 @@ async function deleteEnvironment(
gitpodToken: string,
organizationId: string
) {
try {
await axios.post(
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/DeleteEnvironment",
{
environment_id: environmentId,
organization_id: organizationId
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gitpodToken}`,
let retryCount = 0;
const maxRetries = 3;
const baseDelay = 2000;

while (retryCount <= maxRetries) {
try {
const response = await axios.post(
"https://app.gitpod.io/api/gitpod.v1.EnvironmentService/DeleteEnvironment",
{
environment_id: environmentId,
organization_id: organizationId
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gitpodToken}`,
},
}
);

core.debug(`DeleteEnvironment API Response for ${environmentId}: ${JSON.stringify(response.data)}`);
await sleep(baseDelay);
core.debug(`Successfully deleted environment: ${environmentId}`);
return;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
core.debug(`Rate limit hit in DeleteEnvironment, waiting ${delay}ms before retry ${retryCount + 1}...`);
await sleep(delay);
retryCount++;
} else {
core.error(`Error deleting environment ${environmentId}: ${error}`);
throw error;
}
);
core.debug(`Deleted environment: ${environmentId}`);
} catch (error) {
core.error(`Error deleting environment ${environmentId}: ${error}`);
throw error;
}
}
}

Expand Down Expand Up @@ -248,15 +322,33 @@ async function run() {

// Process deletions
for (const envInfo of environmentsToDelete) {
try {
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
deletedEnvironments.push(envInfo);
totalDaysInactive += envInfo.inactiveDays;

core.debug(`Successfully deleted environment: ${envInfo.id}`);
} catch (error) {
core.warning(`Failed to delete environment ${envInfo.id}: ${error}`);
// Continue with other deletions even if one fails
let retryCount = 0;
const maxRetries = 5;
const baseDelay = 2000;
while (retryCount <= maxRetries) {
try {
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
await sleep(baseDelay);
deletedEnvironments.push(envInfo);
totalDaysInactive += envInfo.inactiveDays;
core.debug(`Successfully deleted environment: ${envInfo.id}`);
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 429) {
// If we hit rate limit, wait 5 seconds before retrying
core.debug('Rate limit hit, waiting 5 seconds...');
await sleep(5000);
// Retry the deletion
try {
await deleteEnvironment(envInfo.id, gitpodToken, organizationId);
deletedEnvironments.push(envInfo);
totalDaysInactive += envInfo.inactiveDays;
} catch (retryError) {
core.warning(`Failed to delete environment ${envInfo.id} after retry: ${retryError}`);
}
} else {
core.warning(`Failed to delete environment ${envInfo.id}: ${error}`);
}
}
}
}

Expand Down