Skip to content

Commit d229606

Browse files
Refactor environment cleanup logic and enhance metadata handling (#1)
* use `lastStartedAt` * refactor: restructure environment interfaces and enhance deletion logic
1 parent 84a76a2 commit d229606

File tree

2 files changed

+191
-55
lines changed

2 files changed

+191
-55
lines changed

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@
33
44
# Gitpod Environment Cleanup Action
55

6-
Automatically clean up stopped Gitpod environments that are older than a specified number of days and have no pending changes. This action helps maintain a clean workspace and manage resource usage in your Gitpod Flex organization.
6+
Automatically clean up stale Gitpod environments that haven't been started for a specified number of days and have no pending changes. This action helps maintain a clean workspace and manage resource usage in your Gitpod Flex organization.
77

88
> [!IMPORTANT]
99
> `GITPOD_TOKEN`: Required. [Learn more](https://www.gitpod.io/docs/flex/integrations/personal-access-token) about how to create a Gitpod Personal Access Token in Gitpod Flex.
1010
1111

1212
## Features
1313

14-
- 🧹 Cleans up stopped environments automatically
15-
- ⏰ Configurable age threshold for environment deletion (default: 10 days)
16-
- ✅ Only deletes environments with no uncommitted changes or unpushed commits
14+
- 🧹 Cleans up stale environments automatically
15+
- ⏰ Configurable inactivity threshold (default: 10 days since last start)
16+
- ✅ Smart cleanup - only deletes environments that are:
17+
- In STOPPED phase
18+
- Have no uncommitted changes
19+
- Have no unpushed commits
20+
- Haven't been started for X days
1721
- 📄 Optional summary report of deleted environments
18-
- 📝 Detailed logging for debugging
1922
- 🔄 Handles pagination for organizations with many environments
2023

2124
## Usage
@@ -72,7 +75,7 @@ jobs:
7275
|-------|----------|---------|-------------|
7376
| `GITPOD_TOKEN` | Yes | - | Gitpod Personal Access Token with necessary permissions |
7477
| `ORGANIZATION_ID` | Yes | - | Your Gitpod Flex organization ID |
75-
| `OLDER_THAN_DAYS` | No | 10 | Delete environments older than this many days |
78+
| `OLDER_THAN_DAYS` | No | 10 | Delete environments not started for this many days |
7679
| `PRINT_SUMMARY` | No | false | Generate a summary of deleted environments |
7780

7881
## Outputs
@@ -85,7 +88,7 @@ jobs:
8588
## Prerequisites
8689

8790
1. **Gitpod Personal Access Token**:
88-
- Go to [Gitpod User Settings](https://gitpod.io/user/tokens)
91+
- Go to [Gitpod User Settings](https://app.gitpod.io/settings/personal-access-tokens)
8992
- Create a new token with necessary permissions
9093
- Add it as a GitHub secret named `GITPOD_TOKEN`
9194

src/main.ts

Lines changed: 181 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -12,54 +12,119 @@ interface GitStatus {
1212
totalUnpushedCommits?: number;
1313
}
1414

15-
interface Environment {
15+
interface Creator {
1616
id: string;
17-
metadata: {
18-
createdAt: string;
17+
principal: string;
18+
}
19+
20+
interface EnvironmentMetadata {
21+
organizationId: string;
22+
creator: Creator;
23+
createdAt: string;
24+
projectId: string;
25+
runnerId: string;
26+
lastStartedAt: string;
27+
}
28+
29+
interface EnvironmentSpec {
30+
specVersion: string;
31+
desiredPhase: string;
32+
machine: {
33+
session: string;
34+
class: string;
1935
};
20-
status: {
21-
phase: string;
22-
content?: {
23-
git?: GitStatus;
36+
content: {
37+
initializer: {
38+
specs: Array<{
39+
contextUrl: {
40+
url: string;
41+
};
42+
}>;
2443
};
2544
};
45+
ports?: Array<{
46+
port: number;
47+
admission: string;
48+
name: string;
49+
}>;
50+
timeout?: {
51+
disconnected: string;
52+
};
53+
}
54+
55+
interface EnvironmentStatus {
56+
statusVersion: string;
57+
phase: string;
58+
content?: {
59+
phase?: string;
60+
git?: GitStatus;
61+
contentLocationInMachine?: string;
62+
};
63+
}
64+
65+
interface Environment {
66+
id: string;
67+
metadata: EnvironmentMetadata;
68+
spec: EnvironmentSpec;
69+
status: EnvironmentStatus;
2670
}
2771

2872
interface ListEnvironmentsResponse {
2973
environments: Environment[];
3074
pagination: PaginationResponse;
3175
}
3276

77+
interface DeletedEnvironmentInfo {
78+
id: string;
79+
projectUrl: string;
80+
lastStarted: string;
81+
createdAt: string;
82+
creator: string;
83+
inactiveDays: number;
84+
}
85+
86+
/**
87+
* Formats a date difference in days
88+
*/
89+
function getDaysSince(date: string): number {
90+
const then = new Date(date);
91+
const now = new Date();
92+
const diffTime = Math.abs(now.getTime() - then.getTime());
93+
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
94+
}
95+
3396
/**
34-
* Checks if the given date is older than specified days
35-
*
36-
* @param {string} dateString - ISO date string to check
37-
* @param {number} days - Number of days to compare against
38-
* @returns {boolean} - True if the date is older than specified days
97+
* Extract project URL from context URL
3998
*/
40-
function isOlderThanDays(dateString: string, days: number): boolean {
41-
const date = new Date(dateString);
99+
function getProjectUrl(env: Environment): string {
100+
try {
101+
const contextUrl = env.spec.content?.initializer?.specs?.[0]?.contextUrl?.url;
102+
return contextUrl || 'N/A';
103+
} catch (error) {
104+
core.debug(`Error getting project URL for environment ${env.id}: ${error}`);
105+
return 'N/A';
106+
}
107+
}
108+
109+
/**
110+
* Checks if the environment is stale based on its last started time
111+
*/
112+
function isStale(lastStartedAt: string, days: number): boolean {
113+
const lastStarted = new Date(lastStartedAt);
42114
const daysInMs = days * 24 * 60 * 60 * 1000;
43115
const cutoffDate = new Date(Date.now() - daysInMs);
44-
return date < cutoffDate;
116+
return lastStarted < cutoffDate;
45117
}
46118

47119
/**
48-
* Lists the environments from the Gitpod API and identifies those that should be deleted.
49-
* Environments are selected for deletion if they are stopped, do not have changed files
50-
* or unpushed commits, and are older than the specified number of days.
51-
*
52-
* @param {string} gitpodToken - The access token for Gitpod API.
53-
* @param {string} organizationId - The organization ID.
54-
* @param {number} olderThanDays - Delete environments older than these many days
55-
* @returns {Promise<string[]>} - A promise that resolves to an array of environment IDs to be deleted.
120+
* Lists and filters environments that should be deleted
56121
*/
57122
async function listEnvironments(
58123
gitpodToken: string,
59124
organizationId: string,
60125
olderThanDays: number
61-
): Promise<string[]> {
62-
const toDelete: string[] = [];
126+
): Promise<DeletedEnvironmentInfo[]> {
127+
const toDelete: DeletedEnvironmentInfo[] = [];
63128
let pageToken: string | undefined = undefined;
64129

65130
try {
@@ -81,24 +146,38 @@ async function listEnvironments(
81146
}
82147
);
83148

84-
core.debug("API Response: " + JSON.stringify(response.data));
149+
core.debug(`Fetched ${response.data.environments.length} environments`);
85150

86151
const environments = response.data.environments;
87152

88153
environments.forEach((env) => {
89154
const isStopped = env.status.phase === "ENVIRONMENT_PHASE_STOPPED";
90155
const hasNoChangedFiles = !(env.status.content?.git?.totalChangedFiles);
91156
const hasNoUnpushedCommits = !(env.status.content?.git?.totalUnpushedCommits);
92-
const isOldEnough = isOlderThanDays(env.metadata.createdAt, olderThanDays);
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+
});
93168

94-
if (isStopped && hasNoChangedFiles && hasNoUnpushedCommits && isOldEnough) {
95-
toDelete.push(env.id);
96-
core.debug(`Environment ${env.id} created at ${env.metadata.createdAt} is ${olderThanDays} days old and marked for deletion`);
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+
);
97176
}
98177
});
99178

100179
pageToken = response.data.pagination.next_page_token;
101-
} while (pageToken); // Continue until no more pages
180+
} while (pageToken);
102181

103182
return toDelete;
104183
} catch (error) {
@@ -108,11 +187,7 @@ async function listEnvironments(
108187
}
109188

110189
/**
111-
* Deletes a specified environment using the Gitpod API.
112-
*
113-
* @param {string} environmentId - The ID of the environment to be deleted.
114-
* @param {string} gitpodToken - The access token for the Gitpod API.
115-
* @param {string} organizationId - The organization ID.
190+
* Deletes a specified environment
116191
*/
117192
async function deleteEnvironment(
118193
environmentId: string,
@@ -135,22 +210,20 @@ async function deleteEnvironment(
135210
);
136211
core.debug(`Deleted environment: ${environmentId}`);
137212
} catch (error) {
138-
core.error(`Error in deleteEnvironment: ${error}`);
213+
core.error(`Error deleting environment ${environmentId}: ${error}`);
139214
throw error;
140215
}
141216
}
142217

143218
/**
144-
* Main function to run the action. It retrieves the Gitpod access token, organization ID,
145-
* and age threshold, lists environments, deletes the selected environments, and outputs the result.
219+
* Main function to run the action.
146220
*/
147221
async function run() {
148222
try {
149223
const gitpodToken = core.getInput("GITPOD_TOKEN", { required: true });
150224
const organizationId = core.getInput("ORGANIZATION_ID", { required: true });
151225
const olderThanDays = parseInt(core.getInput("OLDER_THAN_DAYS", { required: false }) || "10");
152226
const printSummary = core.getBooleanInput("PRINT_SUMMARY", { required: false });
153-
const deletedEnvironments: string[] = [];
154227

155228
if (!gitpodToken) {
156229
throw new Error("Gitpod access token is required");
@@ -166,26 +239,86 @@ async function run() {
166239

167240
const environmentsToDelete = await listEnvironments(gitpodToken, organizationId, olderThanDays);
168241

169-
core.info(`Found ${environmentsToDelete.length} environments older than ${olderThanDays} days to delete`);
242+
core.info(`Found ${environmentsToDelete.length} environments to delete`);
243+
244+
let totalDaysInactive = 0;
245+
246+
// Track successfully deleted environments
247+
const deletedEnvironments: DeletedEnvironmentInfo[] = [];
170248

171-
for (const environmentId of environmentsToDelete) {
172-
await deleteEnvironment(environmentId, gitpodToken, organizationId);
173-
printSummary ? deletedEnvironments.push(environmentId) : null;
249+
// Process deletions
250+
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
260+
}
174261
}
175262

176263
if (deletedEnvironments.length > 0 && printSummary) {
177-
core.summary
178-
.addHeading(`Environments deleted (older than ${olderThanDays} days)`)
179-
.addList(deletedEnvironments)
180-
.write();
264+
const avgDaysInactive = totalDaysInactive / deletedEnvironments.length;
265+
266+
const summary = core.summary
267+
.addHeading(`Environment Cleanup Summary`)
268+
.addTable([
269+
[
270+
{ data: 'Metric', header: true },
271+
{ data: 'Value', header: true }
272+
],
273+
['Total Environments Cleaned', `${deletedEnvironments.length}`],
274+
['Average Days Inactive', `${avgDaysInactive.toFixed(1)} days`],
275+
['Oldest Last Start', `${Math.max(...deletedEnvironments.map(e => e.inactiveDays))} days ago`],
276+
['Newest Last Start', `${Math.min(...deletedEnvironments.map(e => e.inactiveDays))} days ago`]
277+
])
278+
.addHeading('Deleted Environments', 2);
279+
280+
// Create table header for environments
281+
const envTableHeader = [
282+
{ data: 'Environment ID', header: true },
283+
{ data: 'Project', header: true },
284+
{ data: 'Last Activity', header: true },
285+
{ data: 'Created', header: true },
286+
{ data: 'Creator', header: true },
287+
{ data: 'Days Inactive', header: true }
288+
];
289+
290+
// Create table rows for environments
291+
const envTableRows = deletedEnvironments.map(env => [
292+
env.id,
293+
env.projectUrl,
294+
new Date(env.lastStarted).toLocaleDateString(),
295+
new Date(env.createdAt).toLocaleDateString(),
296+
env.creator,
297+
`${env.inactiveDays} days`
298+
]);
299+
300+
// Add environments table
301+
summary.addTable([
302+
envTableHeader,
303+
...envTableRows
304+
]);
305+
306+
await summary.write();
181307
}
182308

309+
// Set outputs
183310
core.setOutput("success", "true");
184311
core.setOutput("deleted_count", deletedEnvironments.length);
312+
core.setOutput("avg_days_inactive", totalDaysInactive / deletedEnvironments.length);
313+
314+
// Log completion
315+
core.info(`Successfully deleted ${deletedEnvironments.length} environments`);
316+
185317
} catch (error) {
186318
core.error((error as Error).message);
187319
core.setOutput("success", "false");
188320
core.setOutput("deleted_count", 0);
321+
core.setOutput("avg_days_inactive", 0);
189322
}
190323
}
191324

0 commit comments

Comments
 (0)