Skip to content
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ the individual command README for details.
- [`garden github-branch`](src/github/branch#readme)
- [`garden github-commit`](src/github/commit#readme)
- [`garden github-deploy`](src/github/deploy#readme)
- [`garden github-membership`](src/github/membership#readme)
- [`garden github-pages`](src/github/pages#readme)
- [`garden github-release`](src/github/release#readme)
- [`garden github-repository`](src/github/repository#readme)
Expand Down Expand Up @@ -68,6 +69,6 @@ conduct](.github/CODE_OF_CONDUCT.md). Please participate accordingly.

## License

Copyright 2021 Zendesk
Copyright 2025 Zendesk

Licensed under the [Apache License, Version 2.0](LICENSE.md)
1 change: 1 addition & 0 deletions bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ program
.addCommand(github.branchCommand(spinner))
.addCommand(github.commitCommand(spinner))
.addCommand(github.deployCommand(spinner))
.addCommand(github.membershipCommand(spinner))
.addCommand(github.pagesCommand(spinner))
.addCommand(github.releaseCommand(spinner))
.addCommand(github.repositoryCommand(spinner))
Expand Down
4 changes: 4 additions & 0 deletions src/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { execute as branch, default as branchCommand } from './branch/index.js';
import { execute as commit, default as commitCommand } from './commit/index.js';
import { execute as deploy, default as deployCommand } from './deploy/index.js';
import { execute as membership, default as membershipCommand } from './membership/index.js';
import { execute as pages, default as pagesCommand } from './pages/index.js';
import { execute as release, default as releaseCommand } from './release/index.js';
import { execute as repository, default as repositoryCommand } from './repository/index.js';
Expand All @@ -18,6 +19,7 @@ const commands = {
branchCommand,
commitCommand,
deployCommand,
membershipCommand,
pagesCommand,
releaseCommand,
repositoryCommand,
Expand All @@ -33,6 +35,8 @@ export {
commitCommand,
deploy,
deployCommand,
membership,
membershipCommand,
pages,
pagesCommand,
release,
Expand Down
43 changes: 43 additions & 0 deletions src/github/membership/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# `garden github-membership`

Manage GitHub organization [membership](https://docs.github.com/en/rest/orgs/members).

## Usage

```ts
import { githubMembership } from '@zendeskgarden/scripts';

const args: {
org?: string;
users?: string[];
path?: string;
token?: string;
} = {
/* optional overrides */
};

(async () => {
const { success, failure } = await githubMembership(args);

console.log(success, failure);
})();
```

### Arguments

- `org` optional GitHub organization name; defaults to repository owner.
- `delete` optional list of members to remove.
- `list` determine if output should be listed; defaults to a numeric summary.
- `path` optional path to a git directory; defaults to the current directory.
- `token` optional GitHub personal access token; defaults to the value
provided by [`githubToken`](../token#readme).

## Command

```sh
garden github-membership \
[--org [org]] \
[--delete <users...>] \
[--path <path>] \
[--token <token>]
```
150 changes: 150 additions & 0 deletions src/github/membership/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Copyright Zendesk, Inc.
*
* Use of this source code is governed under the Apache License, Version 2.0
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import { repository as getRepository, token as getToken } from '../index.js';
import { handleErrorMessage, handleSuccessMessage } from '../../utils/index.js';
import { Command } from 'commander';
import { Octokit } from '@octokit/rest';
import { Ora } from 'ora';

interface IGitHubMembershipArgs {
org?: string;
users?: string[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
users?: string[];
users?: string | string[];

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah. For TS API usage, I think we want to force the developer to give us a string[]

path?: string;
token?: string;
spinner?: Ora;
}

type RETVAL = {
success: string[];
failure: string[];
};

/**
* Execute the `github-membership` command.
*
* @param {string} [args.org] GitHub organization name.
* @param {string[]} [args.users] GitHub user names to remove from membership.
* @param {string} [args.path] Path to a git directory.
* @param {string} [args.token] GitHub personal access token.
* @param {string} [args.spinner] Terminal spinner.
*
* @returns {Promise<object>} The success and failure results of the membership command.
*/
export const execute = async (args: IGitHubMembershipArgs): Promise<RETVAL> => {
const retVal: RETVAL = { success: [], failure: [] };

try {
const auth = args.token || (await getToken(args.spinner));
const github = new Octokit({ auth });
const org = (args.org || (await getRepository(args.path, args.spinner))?.owner)!;

if (args.users) {
const requests = [];

for (const user of args.users) {
/* https://octokit.github.io/rest.js/v21/#orgs-remove-member */
const request = github.orgs.removeMember({ org, username: user });

requests.push(request);
}

await Promise.allSettled(requests).then(results => {
results.forEach(result => {
if (result.status === 'fulfilled') {
const user = result.value.url.split('/').pop();

retVal.success.push(user!);
} else {
const user = result.reason.response.url.split('/').pop();

if (typeof user === 'string') {
retVal.failure.push(user);
}

handleErrorMessage(result.reason, 'github-membership', args.spinner);
}
});
});
} else {
/* https://octokit.github.io/rest.js/v21/#orgs-list-members */
const members = await github.paginate(github.orgs.listMembers, { org, per_page: 100 });

retVal.success = members.map(member => member.login).sort();
}
} catch (error: unknown) {
handleErrorMessage(error, 'github-membership', args.spinner);

throw error;
}

return retVal;
};

export default (spinner: Ora): Command => {
const command = new Command('github-membership');

return command
.description('manage GitHub organization membership')
.option('-o, --org [org]', 'GitHub organization name; defaults to repository owner')
.option('-d --delete <users...>', 'remove members')
.option('-l --list', 'list members')
.option('-p, --path <path>', 'git directory')
.option('-t, --token <token>', 'access token')
.action(async () => {
try {
spinner.start();

const options = command.opts();
const results = await execute({
org: options.org,
users: options.delete,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About this comment.

I'm a little confused. If users only accepts string[] | undefined, TS would throw - unless options.delete is of type any. Is that the case here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All commander.js options are of type any. The code we're swirling around here is simply a belt+suspenders mechanism to ensure users is being treated as the expected array and not turned into [j, z, e, m, p, e, l] for the limited corner case where the script would be used to delete one user. The intended & expected usage is bulk deletion. To say it another way:

  • CLI could naturally be used to delete one or more users (and the script ensures type safety)
  • API woud naturally be used to delete users in bulk

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like commander.js always returns an array for variadic options. I’ll leave it up to you whether it’s worth removing the defensive line on L47.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, seems like this was fixed in tj/commander.js#2410. I'll remove the over-aggressive defense.

path: options.path,
token: options.token,
spinner
});
const toMessage = (members: string[]): string => {
let retVal: string;

if (options.list) {
retVal = members.join(', ');
} else {
const length = members.length;

retVal = `${length} ${length === 1 ? 'member' : 'members'}`;
}

return retVal;
};

if (results.success.length > 0) {
let message = toMessage(results.success);

if (options.delete) {
message = `Removed: ${message}`;
}

handleSuccessMessage(message, spinner);
}

if (results.failure.length > 0) {
let message = toMessage(results.failure);

if (options.delete) {
message = `Failed: ${message}`;
}

handleErrorMessage(message, 'github-membership', spinner);
}
} catch {
spinner.fail('GitHub membership not found');
process.exitCode = 1;
} finally {
spinner.stop();
}
});
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
branch as githubBranch,
commit as githubCommit,
deploy as githubDeploy,
membership as githubMembership,
pages as githubPages,
release as githubRelease,
repository as githubRepository,
Expand Down