Skip to content

Commit

Permalink
Update action.yml and package.json, and improve README.md
Browse files Browse the repository at this point in the history
  • Loading branch information
austenstone committed Jan 4, 2024
1 parent 3c23592 commit d42f960
Show file tree
Hide file tree
Showing 6 changed files with 878 additions and 448 deletions.
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Create a workflow (eg: `.github/workflows/copilot-license-cleanup.yml`). See [Cr

### PAT(Personal Access Token)

You will need to [create a PAT(Personal Access Token)](https://github.com/settings/tokens/new?scopes=manage_billing:copilot) that has `manage_billing:copilot` access.
You will need to [create a PAT(Personal Access Token)](https://github.com/settings/tokens/new?scopes=manage_billing:copilot) that has `manage_billing:copilot` access. If you are specifying an 'enterprise' rather than individual organizations you must also include the `read:org` and `read:enterprise` scopes.

Add this PAT as a secret `TOKEN` so we can use it for input `github-token`, see [Creating encrypted secrets for a repository](https://docs.github.com/en/enterprise-cloud@latest/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository).
### Organizations
Expand Down Expand Up @@ -53,6 +53,38 @@ jobs:
inactive-days: 10
```
#### Example Specifying multiple organizations:
```yml
- uses: austenstone/copilot-license-cleanup@v1.1
with:
github-token: ${{ secrets.TOKEN }}
organization: exampleorg1, demoorg2, myorg3
```
#### Example specifying a GitHub Enterprise (to run on all organizations in the enterprise):
```yml
- uses: austenstone/copilot-license-cleanup@v1.1
with:
github-token: ${{ secrets.TOKEN }}
enterprise: octodemo
```
#### Example uploading inactive users JSON artifact
```yml
- uses: austenstone/copilot-license-cleanup@v1.1
id: copilot
with:
github-token: ${{ secrets.TOKEN }}
- name: Save inactive seats JSON to a file
run: |
echo '${{ steps.copilot.outputs.inactive-seats }}' | jq . > inactive-seats.json
- name: Upload inactive seats JSON as artifact
uses: actions/upload-artifact@v4
with:
name: inactive-seats-json
path: inactive-seats.json
```
<details>
<summary>Job summary example</summary>
Expand All @@ -67,7 +99,8 @@ Various inputs are defined in [`action.yml`](action.yml):
| Name | Description | Default |
| --- | - | - |
| **github&#x2011;token** | Token to use to authorize. | ${{&nbsp;github.token&nbsp;}} |
| organization | The organization to use for the action | ${{&nbsp;github.repository_owner&nbsp;}} |
| organization | The organization(s) to use for the action (comma separated)| ${{&nbsp;github.repository_owner&nbsp;}} |
| enterprise | (optional) All organizations in this enterprise (overrides organization) | null |
| remove | Whether to remove inactive users | false |
| remove-from-team | Whether to remove inactive users from their assigning team | false |
| inactive&#x2011;days | The number of days to consider a user inactive | 90 |
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ inputs:
description: The organization to use for the action
default: ${{ github.repository_owner }}
required: true
enterprise:
description: Search for all organizations in the enterprise (overrides organization)
default: null
required: false
github-token:
description: The GitHub token used to create an authenticated client
default: ${{ github.token }}
Expand Down
221 changes: 149 additions & 72 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25237,10 +25237,12 @@ const github = __importStar(__nccwpck_require__(5438));
const moment_1 = __importDefault(__nccwpck_require__(9623));
const fs_1 = __nccwpck_require__(7147);
const artifact = __importStar(__nccwpck_require__(2605));
const request_error_1 = __nccwpck_require__(537);
function getInputs() {
const result = {};
result.token = core.getInput('github-token');
result.org = core.getInput('organization');
result.enterprise = core.getInput('enterprise');
result.removeInactive = core.getBooleanInput('remove');
result.removefromTeam = core.getBooleanInput('remove-from-team');
result.inactiveDays = parseInt(core.getInput('inactive-days'));
Expand All @@ -25251,89 +25253,160 @@ function getInputs() {
exports.getInputs = getInputs;
const run = () => __awaiter(void 0, void 0, void 0, function* () {
const input = getInputs();
let organizations = [];
let hasNextPage = false;
let afterCursor = undefined;
let allInactiveSeats = [];
let allRemovedSeatsCount = 0;
let allSeatsCount = 0;
const octokit = github.getOctokit(input.token);
const seats = yield core.group('Fetching GitHub Copilot seats', () => __awaiter(void 0, void 0, void 0, function* () {
let _seats = [], totalSeats = 0, page = 1;
if (input.enterprise && input.enterprise !== null) {
core.info(`Fetching all organizations for ${input.enterprise}...`);
do {
const response = yield octokit.request(`GET /orgs/{org}/copilot/billing/seats?per_page=100&page=${page}`, {
org: input.org
});
totalSeats = response.data.total_seats;
_seats = _seats.concat(response.data.seats);
page++;
} while (_seats.length < totalSeats);
core.info(`Found ${_seats.length} seats`);
core.info(JSON.stringify(_seats, null, 2));
return _seats;
}));
const msToDays = (d) => Math.ceil(d / (1000 * 3600 * 24));
const now = new Date();
const inactiveSeats = seats.filter(seat => {
if (seat.last_activity_at === null || seat.last_activity_at === undefined) {
const created = new Date(seat.created_at);
const diff = now.getTime() - created.getTime();
const query = `
query ($enterprise: String!, $after: String) {
enterprise(slug: $enterprise) {
organizations(first: 100, after: $after) {
pageInfo {
endCursor
hasNextPage
}
nodes {
login
}
}
}
}
`;
const variables = { "enterprise": input.enterprise, "after": afterCursor };
const response = yield octokit.graphql(query, variables);
organizations = organizations.concat(response.enterprise.organizations.nodes.map(org => org.login));
hasNextPage = response.enterprise.organizations.pageInfo.hasNextPage;
afterCursor = response.enterprise.organizations.pageInfo.endCursor;
} while (hasNextPage);
core.info(`Found ${organizations.length} organizations: ${organizations.join(', ')}`);
}
else {
organizations = input.org.split(',').map(org => org.trim());
}
for (const org of organizations) {
const seats = yield core.group('Fetching GitHub Copilot seats for ' + org, () => __awaiter(void 0, void 0, void 0, function* () {
let _seats = [], totalSeats = 0, page = 1;
do {
try {
const response = yield octokit.request(`GET /orgs/{org}/copilot/billing/seats?per_page=100&page=${page}`, {
org: org
});
totalSeats = response.data.total_seats;
_seats = _seats.concat(response.data.seats);
page++;
}
catch (error) {
if (error instanceof request_error_1.RequestError && error.message === "Copilot Business is not enabled for this organization.") {
core.error(error.message + ` (${org})`);
break;
}
else if (error instanceof request_error_1.RequestError && error.status === 404) {
core.error(error.message + ` (${org}). Please ensure that the organization has GitHub Copilot enabled and you are an org owner.`);
break;
}
else {
throw error;
}
}
} while (_seats.length < totalSeats);
core.info(`Found ${_seats.length} seats`);
core.info(JSON.stringify(_seats, null, 2));
return _seats;
}));
const msToDays = (d) => Math.ceil(d / (1000 * 3600 * 24));
const now = new Date();
const inactiveSeats = seats.filter(seat => {
if (seat.last_activity_at === null || seat.last_activity_at === undefined) {
const created = new Date(seat.created_at);
const diff = now.getTime() - created.getTime();
return msToDays(diff) > input.inactiveDays;
}
const lastActive = new Date(seat.last_activity_at);
const diff = now.getTime() - lastActive.getTime();
return msToDays(diff) > input.inactiveDays;
}).sort((a, b) => (a.last_activity_at === null || a.last_activity_at === undefined || b.last_activity_at === null || b.last_activity_at === undefined ?
-1 : new Date(a.last_activity_at).getTime() - new Date(b.last_activity_at).getTime()));
const inactiveSeatsWithOrg = inactiveSeats.map(seat => (Object.assign(Object.assign({}, seat), { organization: org })));
allInactiveSeats = [...allInactiveSeats, ...inactiveSeatsWithOrg];
allSeatsCount += seats.length;
if (input.removeInactive) {
const inactiveSeatsAssignedIndividually = inactiveSeats.filter(seat => !seat.assigning_team);
if (inactiveSeatsAssignedIndividually.length > 0) {
core.group('Removing inactive seats', () => __awaiter(void 0, void 0, void 0, function* () {
const response = yield octokit.request(`DELETE /orgs/{org}/copilot/billing/selected_users`, {
org: org,
selected_usernames: inactiveSeatsAssignedIndividually.map(seat => seat.assignee.login),
});
core.info(`Removed ${response.data.seats_cancelled} seats`);
console.log(typeof response.data.seats_cancelled);
allRemovedSeatsCount += response.data.seats_cancelled;
}));
}
}
const lastActive = new Date(seat.last_activity_at);
const diff = now.getTime() - lastActive.getTime();
return msToDays(diff) > input.inactiveDays;
}).sort((a, b) => (a.last_activity_at === null || a.last_activity_at === undefined || b.last_activity_at === null || b.last_activity_at === undefined ?
-1 : new Date(a.last_activity_at).getTime() - new Date(b.last_activity_at).getTime()));
core.setOutput('inactive-seats', JSON.stringify(inactiveSeats));
core.setOutput('inactive-seat-count', inactiveSeats.length.toString());
core.setOutput('seat-count', seats.length.toString());
if (input.removeInactive) {
const inactiveSeatsAssignedIndividually = inactiveSeats.filter(seat => !seat.assigning_team);
if (inactiveSeatsAssignedIndividually.length > 0) {
core.group('Removing inactive seats', () => __awaiter(void 0, void 0, void 0, function* () {
const response = yield octokit.request(`DELETE /orgs/{org}/copilot/billing/selected_users`, {
org: input.org,
selected_usernames: inactiveSeatsAssignedIndividually.map(seat => seat.assignee.login),
});
core.info(`Removed ${response.data.seats_cancelled} seats`);
core.setOutput('removed-seats', response.data.seats_cancelled);
if (input.removefromTeam) {
const inactiveSeatsAssignedByTeam = inactiveSeats.filter(seat => seat.assigning_team);
core.group('Removing inactive seats from team', () => __awaiter(void 0, void 0, void 0, function* () {
for (const seat of inactiveSeatsAssignedByTeam) {
if (!seat.assigning_team || typeof (seat.assignee.login) !== 'string')
continue;
yield octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', {
org: org,
team_slug: seat.assigning_team.slug,
username: seat.assignee.login
});
}
}));
}
}
if (input.removefromTeam) {
const inactiveSeatsAssignedByTeam = inactiveSeats.filter(seat => seat.assigning_team);
core.group('Removing inactive seats from team', () => __awaiter(void 0, void 0, void 0, function* () {
for (const seat of inactiveSeatsAssignedByTeam) {
if (!seat.assigning_team || typeof (seat.assignee.login) !== 'string')
continue;
yield octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', {
org: input.org,
team_slug: seat.assigning_team.slug,
username: seat.assignee.login
});
if (input.jobSummary) {
yield core.summary
.addHeading(`${org} - Inactive Seats: ${inactiveSeats.length.toString()} / ${seats.length.toString()}`);
if (seats.length > 0) {
core.summary.addTable([
[
{ data: 'Avatar', header: true },
{ data: 'Login', header: true },
{ data: 'Last Activity', header: true },
{ data: 'Last Editor Used', header: true }
],
...inactiveSeats.sort((a, b) => {
const loginA = (a.assignee.login || 'Unknown');
const loginB = (b.assignee.login || 'Unknown');
return loginA.localeCompare(loginB);
}).map(seat => [
`<img src="${seat.assignee.avatar_url}" width="33" />`,
seat.assignee.login || 'Unknown',
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
seat.last_activity_editor || 'Unknown'
])
]);
}
}));
}
if (input.jobSummary) {
yield core.summary
.addHeading(`Inactive Seats: ${inactiveSeats.length.toString()} / ${seats.length.toString()}`)
.addTable([
[
{ data: 'Avatar', header: true },
{ data: 'Login', header: true },
{ data: 'Last Activity', header: true },
{ data: 'Last Editor Used', header: true }
],
...inactiveSeats.map(seat => [
`<img src="${seat.assignee.avatar_url}" width="33" />`,
seat.assignee.login || 'Unknown',
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
seat.last_activity_editor || 'Unknown'
])
])
.addLink('Manage GitHub Copilot seats', `https://github.com/organizations/${input.org}/settings/copilot/seat_management`)
.write();
core.summary.addLink('Manage GitHub Copilot seats', `https://github.com/organizations/${org}/settings/copilot/seat_management`)
.write();
}
}
if (input.csv) {
core.group('Writing CSV', () => __awaiter(void 0, void 0, void 0, function* () {
const sortedSeats = allInactiveSeats.sort((a, b) => {
if (a.organization < b.organization)
return -1;
if (a.organization > b.organization)
return 1;
if (a.assignee.login < b.assignee.login)
return -1;
if (a.assignee.login > b.assignee.login)
return 1;
return 0;
});
const csv = [
['Login', 'Last Activity', 'Last Editor Used'],
...inactiveSeats.map(seat => [
['Organization', 'Login', 'Last Activity', 'Last Editor Used'],
...sortedSeats.map(seat => [
seat.organization,
seat.assignee.login,
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
seat.last_activity_editor || '-'
Expand All @@ -25344,6 +25417,10 @@ const run = () => __awaiter(void 0, void 0, void 0, function* () {
yield artifactClient.uploadArtifact('inactive-seats', ['inactive-seats.csv'], '.');
}));
}
core.setOutput('inactive-seats', JSON.stringify(allInactiveSeats));
core.setOutput('inactive-seat-count', allInactiveSeats.length.toString());
core.setOutput('seat-count', allSeatsCount.toString());
core.setOutput('removed-seats', allRemovedSeatsCount.toString());
});
run();

Expand Down
Loading

0 comments on commit d42f960

Please sign in to comment.