Skip to content

Commit d42f960

Browse files
committed
Update action.yml and package.json, and improve README.md
1 parent 3c23592 commit d42f960

File tree

6 files changed

+878
-448
lines changed

6 files changed

+878
-448
lines changed

README.md

+35-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Create a workflow (eg: `.github/workflows/copilot-license-cleanup.yml`). See [Cr
88

99
### PAT(Personal Access Token)
1010

11-
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.
11+
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.
1212

1313
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).
1414
### Organizations
@@ -53,6 +53,38 @@ jobs:
5353
inactive-days: 10
5454
```
5555
56+
#### Example Specifying multiple organizations:
57+
```yml
58+
- uses: austenstone/[email protected]
59+
with:
60+
github-token: ${{ secrets.TOKEN }}
61+
organization: exampleorg1, demoorg2, myorg3
62+
```
63+
64+
#### Example specifying a GitHub Enterprise (to run on all organizations in the enterprise):
65+
```yml
66+
- uses: austenstone/[email protected]
67+
with:
68+
github-token: ${{ secrets.TOKEN }}
69+
enterprise: octodemo
70+
```
71+
72+
#### Example uploading inactive users JSON artifact
73+
```yml
74+
- uses: austenstone/[email protected]
75+
id: copilot
76+
with:
77+
github-token: ${{ secrets.TOKEN }}
78+
- name: Save inactive seats JSON to a file
79+
run: |
80+
echo '${{ steps.copilot.outputs.inactive-seats }}' | jq . > inactive-seats.json
81+
- name: Upload inactive seats JSON as artifact
82+
uses: actions/upload-artifact@v4
83+
with:
84+
name: inactive-seats-json
85+
path: inactive-seats.json
86+
```
87+
5688
<details>
5789
<summary>Job summary example</summary>
5890
@@ -67,7 +99,8 @@ Various inputs are defined in [`action.yml`](action.yml):
6799
| Name | Description | Default |
68100
| --- | - | - |
69101
| **github&#x2011;token** | Token to use to authorize. | ${{&nbsp;github.token&nbsp;}} |
70-
| organization | The organization to use for the action | ${{&nbsp;github.repository_owner&nbsp;}} |
102+
| organization | The organization(s) to use for the action (comma separated)| ${{&nbsp;github.repository_owner&nbsp;}} |
103+
| enterprise | (optional) All organizations in this enterprise (overrides organization) | null |
71104
| remove | Whether to remove inactive users | false |
72105
| remove-from-team | Whether to remove inactive users from their assigning team | false |
73106
| inactive&#x2011;days | The number of days to consider a user inactive | 90 |

action.yml

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ inputs:
1010
description: The organization to use for the action
1111
default: ${{ github.repository_owner }}
1212
required: true
13+
enterprise:
14+
description: Search for all organizations in the enterprise (overrides organization)
15+
default: null
16+
required: false
1317
github-token:
1418
description: The GitHub token used to create an authenticated client
1519
default: ${{ github.token }}

dist/index.js

+149-72
Original file line numberDiff line numberDiff line change
@@ -25237,10 +25237,12 @@ const github = __importStar(__nccwpck_require__(5438));
2523725237
const moment_1 = __importDefault(__nccwpck_require__(9623));
2523825238
const fs_1 = __nccwpck_require__(7147);
2523925239
const artifact = __importStar(__nccwpck_require__(2605));
25240+
const request_error_1 = __nccwpck_require__(537);
2524025241
function getInputs() {
2524125242
const result = {};
2524225243
result.token = core.getInput('github-token');
2524325244
result.org = core.getInput('organization');
25245+
result.enterprise = core.getInput('enterprise');
2524425246
result.removeInactive = core.getBooleanInput('remove');
2524525247
result.removefromTeam = core.getBooleanInput('remove-from-team');
2524625248
result.inactiveDays = parseInt(core.getInput('inactive-days'));
@@ -25251,89 +25253,160 @@ function getInputs() {
2525125253
exports.getInputs = getInputs;
2525225254
const run = () => __awaiter(void 0, void 0, void 0, function* () {
2525325255
const input = getInputs();
25256+
let organizations = [];
25257+
let hasNextPage = false;
25258+
let afterCursor = undefined;
25259+
let allInactiveSeats = [];
25260+
let allRemovedSeatsCount = 0;
25261+
let allSeatsCount = 0;
2525425262
const octokit = github.getOctokit(input.token);
25255-
const seats = yield core.group('Fetching GitHub Copilot seats', () => __awaiter(void 0, void 0, void 0, function* () {
25256-
let _seats = [], totalSeats = 0, page = 1;
25263+
if (input.enterprise && input.enterprise !== null) {
25264+
core.info(`Fetching all organizations for ${input.enterprise}...`);
2525725265
do {
25258-
const response = yield octokit.request(`GET /orgs/{org}/copilot/billing/seats?per_page=100&page=${page}`, {
25259-
org: input.org
25260-
});
25261-
totalSeats = response.data.total_seats;
25262-
_seats = _seats.concat(response.data.seats);
25263-
page++;
25264-
} while (_seats.length < totalSeats);
25265-
core.info(`Found ${_seats.length} seats`);
25266-
core.info(JSON.stringify(_seats, null, 2));
25267-
return _seats;
25268-
}));
25269-
const msToDays = (d) => Math.ceil(d / (1000 * 3600 * 24));
25270-
const now = new Date();
25271-
const inactiveSeats = seats.filter(seat => {
25272-
if (seat.last_activity_at === null || seat.last_activity_at === undefined) {
25273-
const created = new Date(seat.created_at);
25274-
const diff = now.getTime() - created.getTime();
25266+
const query = `
25267+
query ($enterprise: String!, $after: String) {
25268+
enterprise(slug: $enterprise) {
25269+
organizations(first: 100, after: $after) {
25270+
pageInfo {
25271+
endCursor
25272+
hasNextPage
25273+
}
25274+
nodes {
25275+
login
25276+
}
25277+
}
25278+
}
25279+
}
25280+
`;
25281+
const variables = { "enterprise": input.enterprise, "after": afterCursor };
25282+
const response = yield octokit.graphql(query, variables);
25283+
organizations = organizations.concat(response.enterprise.organizations.nodes.map(org => org.login));
25284+
hasNextPage = response.enterprise.organizations.pageInfo.hasNextPage;
25285+
afterCursor = response.enterprise.organizations.pageInfo.endCursor;
25286+
} while (hasNextPage);
25287+
core.info(`Found ${organizations.length} organizations: ${organizations.join(', ')}`);
25288+
}
25289+
else {
25290+
organizations = input.org.split(',').map(org => org.trim());
25291+
}
25292+
for (const org of organizations) {
25293+
const seats = yield core.group('Fetching GitHub Copilot seats for ' + org, () => __awaiter(void 0, void 0, void 0, function* () {
25294+
let _seats = [], totalSeats = 0, page = 1;
25295+
do {
25296+
try {
25297+
const response = yield octokit.request(`GET /orgs/{org}/copilot/billing/seats?per_page=100&page=${page}`, {
25298+
org: org
25299+
});
25300+
totalSeats = response.data.total_seats;
25301+
_seats = _seats.concat(response.data.seats);
25302+
page++;
25303+
}
25304+
catch (error) {
25305+
if (error instanceof request_error_1.RequestError && error.message === "Copilot Business is not enabled for this organization.") {
25306+
core.error(error.message + ` (${org})`);
25307+
break;
25308+
}
25309+
else if (error instanceof request_error_1.RequestError && error.status === 404) {
25310+
core.error(error.message + ` (${org}). Please ensure that the organization has GitHub Copilot enabled and you are an org owner.`);
25311+
break;
25312+
}
25313+
else {
25314+
throw error;
25315+
}
25316+
}
25317+
} while (_seats.length < totalSeats);
25318+
core.info(`Found ${_seats.length} seats`);
25319+
core.info(JSON.stringify(_seats, null, 2));
25320+
return _seats;
25321+
}));
25322+
const msToDays = (d) => Math.ceil(d / (1000 * 3600 * 24));
25323+
const now = new Date();
25324+
const inactiveSeats = seats.filter(seat => {
25325+
if (seat.last_activity_at === null || seat.last_activity_at === undefined) {
25326+
const created = new Date(seat.created_at);
25327+
const diff = now.getTime() - created.getTime();
25328+
return msToDays(diff) > input.inactiveDays;
25329+
}
25330+
const lastActive = new Date(seat.last_activity_at);
25331+
const diff = now.getTime() - lastActive.getTime();
2527525332
return msToDays(diff) > input.inactiveDays;
25333+
}).sort((a, b) => (a.last_activity_at === null || a.last_activity_at === undefined || b.last_activity_at === null || b.last_activity_at === undefined ?
25334+
-1 : new Date(a.last_activity_at).getTime() - new Date(b.last_activity_at).getTime()));
25335+
const inactiveSeatsWithOrg = inactiveSeats.map(seat => (Object.assign(Object.assign({}, seat), { organization: org })));
25336+
allInactiveSeats = [...allInactiveSeats, ...inactiveSeatsWithOrg];
25337+
allSeatsCount += seats.length;
25338+
if (input.removeInactive) {
25339+
const inactiveSeatsAssignedIndividually = inactiveSeats.filter(seat => !seat.assigning_team);
25340+
if (inactiveSeatsAssignedIndividually.length > 0) {
25341+
core.group('Removing inactive seats', () => __awaiter(void 0, void 0, void 0, function* () {
25342+
const response = yield octokit.request(`DELETE /orgs/{org}/copilot/billing/selected_users`, {
25343+
org: org,
25344+
selected_usernames: inactiveSeatsAssignedIndividually.map(seat => seat.assignee.login),
25345+
});
25346+
core.info(`Removed ${response.data.seats_cancelled} seats`);
25347+
console.log(typeof response.data.seats_cancelled);
25348+
allRemovedSeatsCount += response.data.seats_cancelled;
25349+
}));
25350+
}
2527625351
}
25277-
const lastActive = new Date(seat.last_activity_at);
25278-
const diff = now.getTime() - lastActive.getTime();
25279-
return msToDays(diff) > input.inactiveDays;
25280-
}).sort((a, b) => (a.last_activity_at === null || a.last_activity_at === undefined || b.last_activity_at === null || b.last_activity_at === undefined ?
25281-
-1 : new Date(a.last_activity_at).getTime() - new Date(b.last_activity_at).getTime()));
25282-
core.setOutput('inactive-seats', JSON.stringify(inactiveSeats));
25283-
core.setOutput('inactive-seat-count', inactiveSeats.length.toString());
25284-
core.setOutput('seat-count', seats.length.toString());
25285-
if (input.removeInactive) {
25286-
const inactiveSeatsAssignedIndividually = inactiveSeats.filter(seat => !seat.assigning_team);
25287-
if (inactiveSeatsAssignedIndividually.length > 0) {
25288-
core.group('Removing inactive seats', () => __awaiter(void 0, void 0, void 0, function* () {
25289-
const response = yield octokit.request(`DELETE /orgs/{org}/copilot/billing/selected_users`, {
25290-
org: input.org,
25291-
selected_usernames: inactiveSeatsAssignedIndividually.map(seat => seat.assignee.login),
25292-
});
25293-
core.info(`Removed ${response.data.seats_cancelled} seats`);
25294-
core.setOutput('removed-seats', response.data.seats_cancelled);
25352+
if (input.removefromTeam) {
25353+
const inactiveSeatsAssignedByTeam = inactiveSeats.filter(seat => seat.assigning_team);
25354+
core.group('Removing inactive seats from team', () => __awaiter(void 0, void 0, void 0, function* () {
25355+
for (const seat of inactiveSeatsAssignedByTeam) {
25356+
if (!seat.assigning_team || typeof (seat.assignee.login) !== 'string')
25357+
continue;
25358+
yield octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', {
25359+
org: org,
25360+
team_slug: seat.assigning_team.slug,
25361+
username: seat.assignee.login
25362+
});
25363+
}
2529525364
}));
2529625365
}
25297-
}
25298-
if (input.removefromTeam) {
25299-
const inactiveSeatsAssignedByTeam = inactiveSeats.filter(seat => seat.assigning_team);
25300-
core.group('Removing inactive seats from team', () => __awaiter(void 0, void 0, void 0, function* () {
25301-
for (const seat of inactiveSeatsAssignedByTeam) {
25302-
if (!seat.assigning_team || typeof (seat.assignee.login) !== 'string')
25303-
continue;
25304-
yield octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', {
25305-
org: input.org,
25306-
team_slug: seat.assigning_team.slug,
25307-
username: seat.assignee.login
25308-
});
25366+
if (input.jobSummary) {
25367+
yield core.summary
25368+
.addHeading(`${org} - Inactive Seats: ${inactiveSeats.length.toString()} / ${seats.length.toString()}`);
25369+
if (seats.length > 0) {
25370+
core.summary.addTable([
25371+
[
25372+
{ data: 'Avatar', header: true },
25373+
{ data: 'Login', header: true },
25374+
{ data: 'Last Activity', header: true },
25375+
{ data: 'Last Editor Used', header: true }
25376+
],
25377+
...inactiveSeats.sort((a, b) => {
25378+
const loginA = (a.assignee.login || 'Unknown');
25379+
const loginB = (b.assignee.login || 'Unknown');
25380+
return loginA.localeCompare(loginB);
25381+
}).map(seat => [
25382+
`<img src="${seat.assignee.avatar_url}" width="33" />`,
25383+
seat.assignee.login || 'Unknown',
25384+
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
25385+
seat.last_activity_editor || 'Unknown'
25386+
])
25387+
]);
2530925388
}
25310-
}));
25311-
}
25312-
if (input.jobSummary) {
25313-
yield core.summary
25314-
.addHeading(`Inactive Seats: ${inactiveSeats.length.toString()} / ${seats.length.toString()}`)
25315-
.addTable([
25316-
[
25317-
{ data: 'Avatar', header: true },
25318-
{ data: 'Login', header: true },
25319-
{ data: 'Last Activity', header: true },
25320-
{ data: 'Last Editor Used', header: true }
25321-
],
25322-
...inactiveSeats.map(seat => [
25323-
`<img src="${seat.assignee.avatar_url}" width="33" />`,
25324-
seat.assignee.login || 'Unknown',
25325-
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
25326-
seat.last_activity_editor || 'Unknown'
25327-
])
25328-
])
25329-
.addLink('Manage GitHub Copilot seats', `https://github.com/organizations/${input.org}/settings/copilot/seat_management`)
25330-
.write();
25389+
core.summary.addLink('Manage GitHub Copilot seats', `https://github.com/organizations/${org}/settings/copilot/seat_management`)
25390+
.write();
25391+
}
2533125392
}
2533225393
if (input.csv) {
2533325394
core.group('Writing CSV', () => __awaiter(void 0, void 0, void 0, function* () {
25395+
const sortedSeats = allInactiveSeats.sort((a, b) => {
25396+
if (a.organization < b.organization)
25397+
return -1;
25398+
if (a.organization > b.organization)
25399+
return 1;
25400+
if (a.assignee.login < b.assignee.login)
25401+
return -1;
25402+
if (a.assignee.login > b.assignee.login)
25403+
return 1;
25404+
return 0;
25405+
});
2533425406
const csv = [
25335-
['Login', 'Last Activity', 'Last Editor Used'],
25336-
...inactiveSeats.map(seat => [
25407+
['Organization', 'Login', 'Last Activity', 'Last Editor Used'],
25408+
...sortedSeats.map(seat => [
25409+
seat.organization,
2533725410
seat.assignee.login,
2533825411
seat.last_activity_at === null ? 'No activity' : (0, moment_1.default)(seat.last_activity_at).fromNow(),
2533925412
seat.last_activity_editor || '-'
@@ -25344,6 +25417,10 @@ const run = () => __awaiter(void 0, void 0, void 0, function* () {
2534425417
yield artifactClient.uploadArtifact('inactive-seats', ['inactive-seats.csv'], '.');
2534525418
}));
2534625419
}
25420+
core.setOutput('inactive-seats', JSON.stringify(allInactiveSeats));
25421+
core.setOutput('inactive-seat-count', allInactiveSeats.length.toString());
25422+
core.setOutput('seat-count', allSeatsCount.toString());
25423+
core.setOutput('removed-seats', allRemovedSeatsCount.toString());
2534725424
});
2534825425
run();
2534925426

0 commit comments

Comments
 (0)