feat(dev-infra): create caretaker handoff assistant to update github team membership (#42859)

Create a handoff assistant, currently only assists in updating github team membership
for caretaking team.

PR Close #42859
This commit is contained in:
Joey Perrott 2021-07-14 12:43:28 -07:00 committed by Alex Rickabaugh
parent 74228215a1
commit bc1da1fe14
8 changed files with 376 additions and 52 deletions

View File

@ -65,6 +65,7 @@ var GithubClient = /** @class */ (function () {
this.issues = this._octokit.issues;
this.git = this._octokit.git;
this.rateLimit = this._octokit.rateLimit;
this.teams = this._octokit.teams;
// Note: These are properties from `Octokit` that are brought in by optional plugins.
// TypeScript requires us to provide an explicit type for these.
this.rest = this._octokit.rest;

View File

@ -10,9 +10,11 @@ ts_library(
deps = [
"//dev-infra/release/versioning",
"//dev-infra/utils",
"@npm//@types/inquirer",
"@npm//@types/node",
"@npm//@types/node-fetch",
"@npm//@types/yargs",
"@npm//inquirer",
"@npm//multimatch",
"@npm//node-fetch",
"@npm//typed-graphqlify",

View File

@ -8,9 +8,10 @@
import {Argv} from 'yargs';
import {CheckModule} from './check/cli';
import {HandoffModule} from './handoff/cli';
/** Build the parser for the caretaker commands. */
export function buildCaretakerParser(yargs: Argv) {
return yargs.command(CheckModule);
return yargs.command(CheckModule).command(HandoffModule);
}

View File

@ -9,7 +9,13 @@
import {assertNoErrors, getConfig, NgDevConfig} from '../utils/config';
export interface CaretakerConfig {
/** Github queries showing a snapshot of pulls/issues caretakers need to monitor. */
githubQueries?: {name: string; query: string;}[];
/**
* The Github group used to track current caretakers. A second group is assumed to exist with the
* name "<group-name>-roster" containing a list of all users eligible for the caretaker group.
* */
caretakerGroup?: string;
}
/** Retrieve and validate the config as `CaretakerConfig`. */

View File

@ -0,0 +1,36 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Argv, CommandModule} from 'yargs';
import {addGithubTokenOption} from '../../utils/git/github-yargs';
import {updateCaretakerTeamViaPrompt} from './update-github-team';
export interface CaretakerHandoffOptions {
githubToken: string;
}
/** Builds the command. */
function builder(yargs: Argv) {
return addGithubTokenOption(yargs);
}
/** Handles the command. */
async function handler() {
await updateCaretakerTeamViaPrompt();
}
/** yargs command module for assisting in handing off caretaker. */
export const HandoffModule: CommandModule<{}, CaretakerHandoffOptions> = {
handler,
builder,
command: 'handoff',
describe: 'Run a handoff assistant to aide in moving to the next caretaker',
};

View File

@ -0,0 +1,132 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {prompt} from 'inquirer';
import {debug, green, info, red, yellow} from '../../utils/console';
import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
import {getCaretakerConfig} from '../config';
/** Update the Github caretaker group, using a prompt to obtain the new caretaker group members. */
export async function updateCaretakerTeamViaPrompt() {
/** Caretaker specific configuration. */
const caretakerConfig = getCaretakerConfig().caretaker;
if (caretakerConfig.caretakerGroup === undefined) {
throw Error('`caretakerGroup` is not defined in the `caretaker` config');
}
/** The list of current members in the group. */
const current = await getGroupMembers(caretakerConfig.caretakerGroup);
/** The list of members able to be added to the group as defined by a separate roster group. */
const roster = await getGroupMembers(`${caretakerConfig.caretakerGroup}-roster`);
const {
/** The list of users selected to be members of the caretaker group. */
selected,
/** Whether the user positively confirmed the selected made. */
confirm
} =
await prompt([
{
type: 'checkbox',
choices: roster,
message: 'Select 2 caretakers for the upcoming rotation:',
default: current,
name: 'selected',
prefix: '',
validate: (selected: string[]) => {
if (selected.length !== 2) {
return 'Please select exactly 2 caretakers for the upcoming rotation.';
}
return true;
},
},
{
type: 'confirm',
default: true,
prefix: '',
message: 'Are you sure?',
name: 'confirm',
}
]);
if (confirm === false) {
info(yellow(' ⚠ Skipping caretaker group update.'));
return;
}
if (JSON.stringify(selected) === JSON.stringify(current)) {
info(green(' √ Caretaker group already up to date.'));
return;
}
try {
await setCaretakerGroup(caretakerConfig.caretakerGroup, selected);
} catch {
info(red(' ✘ Failed to update caretaker group.'));
return;
}
info(green(' √ Successfully updated caretaker group'));
}
/** Retrieve the current list of members for the provided group. */
async function getGroupMembers(group: string) {
/** The authenticated GitClient instance. */
const git = AuthenticatedGitClient.get();
return (await git.github.teams.listMembersInOrg({
org: git.remoteConfig.owner,
team_slug: group,
}))
.data.filter(_ => !!_)
.map(member => member!.login);
}
async function setCaretakerGroup(group: string, members: string[]) {
/** The authenticated GitClient instance. */
const git = AuthenticatedGitClient.get();
/** The full name of the group <org>/<group name>. */
const fullSlug = `${git.remoteConfig.owner}/${group}`;
/** The list of current members of the group. */
const current = await getGroupMembers(group);
/** The list of users to be removed from the group. */
const removed = current.filter(login => !members.includes(login));
/** Add a user to the group. */
const add = async (username: string) => {
debug(`Adding ${username} to ${fullSlug}.`);
await git.github.teams.addOrUpdateMembershipForUserInOrg({
org: git.remoteConfig.owner,
team_slug: group,
username,
role: 'maintainer',
});
};
/** Remove a user from the group. */
const remove = async (username: string) => {
debug(`Removing ${username} from ${fullSlug}.`);
await git.github.teams.removeMembershipForUserInOrg({
org: git.remoteConfig.owner,
team_slug: group,
username,
});
};
debug.group(`Caretaker Group: ${fullSlug}`);
debug(`Current Membership: ${current.join(', ')}`);
debug(`New Membership: ${members.join(', ')}`);
debug(`Removed: ${removed.join(', ')}`);
debug.groupEnd();
// Add members before removing to prevent the account performing the action from removing their
// permissions to change the group membership early.
await Promise.all(members.map(add));
await Promise.all(removed.map(remove));
debug(`Successfuly updated ${fullSlug}`);
}

View File

@ -228,6 +228,7 @@ var GithubClient = /** @class */ (function () {
this.issues = this._octokit.issues;
this.git = this._octokit.git;
this.rateLimit = this._octokit.rateLimit;
this.teams = this._octokit.teams;
// Note: These are properties from `Octokit` that are brought in by optional plugins.
// TypeScript requires us to provide an explicit type for these.
this.rest = this._octokit.rest;
@ -1578,6 +1579,150 @@ const CheckModule = {
describe: 'Check the status of information the caretaker manages for the repository',
};
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/** Update the Github caretaker group, using a prompt to obtain the new caretaker group members. */
function updateCaretakerTeamViaPrompt() {
return tslib.__awaiter(this, void 0, void 0, function* () {
/** Caretaker specific configuration. */
const caretakerConfig = getCaretakerConfig().caretaker;
if (caretakerConfig.caretakerGroup === undefined) {
throw Error('`caretakerGroup` is not defined in the `caretaker` config');
}
/** The list of current members in the group. */
const current = yield getGroupMembers(caretakerConfig.caretakerGroup);
/** The list of members able to be added to the group as defined by a separate roster group. */
const roster = yield getGroupMembers(`${caretakerConfig.caretakerGroup}-roster`);
const {
/** The list of users selected to be members of the caretaker group. */
selected,
/** Whether the user positively confirmed the selected made. */
confirm } = yield inquirer.prompt([
{
type: 'checkbox',
choices: roster,
message: 'Select 2 caretakers for the upcoming rotation:',
default: current,
name: 'selected',
prefix: '',
validate: (selected) => {
if (selected.length !== 2) {
return 'Please select exactly 2 caretakers for the upcoming rotation.';
}
return true;
},
},
{
type: 'confirm',
default: true,
prefix: '',
message: 'Are you sure?',
name: 'confirm',
}
]);
if (confirm === false) {
info(yellow(' ⚠ Skipping caretaker group update.'));
return;
}
if (JSON.stringify(selected) === JSON.stringify(current)) {
info(green(' √ Caretaker group already up to date.'));
return;
}
try {
yield setCaretakerGroup(caretakerConfig.caretakerGroup, selected);
}
catch (_a) {
info(red(' ✘ Failed to update caretaker group.'));
return;
}
info(green(' √ Successfully updated caretaker group'));
});
}
/** Retrieve the current list of members for the provided group. */
function getGroupMembers(group) {
return tslib.__awaiter(this, void 0, void 0, function* () {
/** The authenticated GitClient instance. */
const git = AuthenticatedGitClient.get();
return (yield git.github.teams.listMembersInOrg({
org: git.remoteConfig.owner,
team_slug: group,
}))
.data.filter(_ => !!_)
.map(member => member.login);
});
}
function setCaretakerGroup(group, members) {
return tslib.__awaiter(this, void 0, void 0, function* () {
/** The authenticated GitClient instance. */
const git = AuthenticatedGitClient.get();
/** The full name of the group <org>/<group name>. */
const fullSlug = `${git.remoteConfig.owner}/${group}`;
/** The list of current members of the group. */
const current = yield getGroupMembers(group);
/** The list of users to be removed from the group. */
const removed = current.filter(login => !members.includes(login));
/** Add a user to the group. */
const add = (username) => tslib.__awaiter(this, void 0, void 0, function* () {
debug(`Adding ${username} to ${fullSlug}.`);
yield git.github.teams.addOrUpdateMembershipForUserInOrg({
org: git.remoteConfig.owner,
team_slug: group,
username,
role: 'maintainer',
});
});
/** Remove a user from the group. */
const remove = (username) => tslib.__awaiter(this, void 0, void 0, function* () {
debug(`Removing ${username} from ${fullSlug}.`);
yield git.github.teams.removeMembershipForUserInOrg({
org: git.remoteConfig.owner,
team_slug: group,
username,
});
});
debug.group(`Caretaker Group: ${fullSlug}`);
debug(`Current Membership: ${current.join(', ')}`);
debug(`New Membership: ${members.join(', ')}`);
debug(`Removed: ${removed.join(', ')}`);
debug.groupEnd();
// Add members before removing to prevent the account performing the action from removing their
// permissions to change the group membership early.
yield Promise.all(members.map(add));
yield Promise.all(removed.map(remove));
debug(`Successfuly updated ${fullSlug}`);
});
}
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$1(yargs) {
return addGithubTokenOption(yargs);
}
/** Handles the command. */
function handler$1() {
return tslib.__awaiter(this, void 0, void 0, function* () {
yield updateCaretakerTeamViaPrompt();
});
}
/** yargs command module for assisting in handing off caretaker. */
const HandoffModule = {
handler: handler$1,
builder: builder$1,
command: 'handoff',
describe: 'Run a handoff assistant to aide in moving to the next caretaker',
};
/**
* @license
* Copyright Google LLC All Rights Reserved.
@ -1587,7 +1732,7 @@ const CheckModule = {
*/
/** Build the parser for the caretaker commands. */
function buildCaretakerParser(yargs) {
return yargs.command(CheckModule);
return yargs.command(CheckModule).command(HandoffModule);
}
/**
@ -1666,7 +1811,7 @@ function restoreCommitMessage(filePath, source) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$1(yargs) {
function builder$2(yargs) {
return yargs
.option('file-env-variable', {
type: 'string',
@ -1678,7 +1823,7 @@ function builder$1(yargs) {
.positional('source', { type: 'string' });
}
/** Handles the command. */
function handler$1({ fileEnvVariable, file, source }) {
function handler$2({ fileEnvVariable, file, source }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
// File and source are provided as command line parameters
if (file !== undefined) {
@ -1700,8 +1845,8 @@ function handler$1({ fileEnvVariable, file, source }) {
}
/** yargs command module describing the command. */
const RestoreCommitMessageModule = {
handler: handler$1,
builder: builder$1,
handler: handler$2,
builder: builder$2,
command: 'restore-commit-message-draft [file] [source]',
// Description: Restore a commit message draft if one has been saved from a failed commit attempt.
// No describe is defiend to hide the command from the --help.
@ -2116,7 +2261,7 @@ function validateFile(filePath, isErrorMode) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$2(yargs) {
function builder$3(yargs) {
var _a;
return yargs
.option('file', {
@ -2146,7 +2291,7 @@ function builder$2(yargs) {
});
}
/** Handles the command. */
function handler$2({ error, file, fileEnvVariable }) {
function handler$3({ error, file, fileEnvVariable }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const filePath = file || fileEnvVariable || '.git/COMMIT_EDITMSG';
validateFile(filePath, error);
@ -2154,8 +2299,8 @@ function handler$2({ error, file, fileEnvVariable }) {
}
/** yargs command module describing the command. */
const ValidateFileModule = {
handler: handler$2,
builder: builder$2,
handler: handler$3,
builder: builder$3,
command: 'pre-commit-validate',
describe: 'Validate the most recent commit message',
};
@ -2240,7 +2385,7 @@ function validateCommitRange(from, to) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$3(yargs) {
function builder$4(yargs) {
return yargs
.positional('startingRef', {
description: 'The first ref in the range to select',
@ -2254,7 +2399,7 @@ function builder$3(yargs) {
});
}
/** Handles the command. */
function handler$3({ startingRef, endingRef }) {
function handler$4({ startingRef, endingRef }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
// If on CI, and no pull request number is provided, assume the branch
// being run on is an upstream branch.
@ -2270,8 +2415,8 @@ function handler$3({ startingRef, endingRef }) {
}
/** yargs command module describing the command. */
const ValidateRangeModule = {
handler: handler$3,
builder: builder$3,
handler: handler$4,
builder: builder$4,
command: 'validate-range <starting-ref> [ending-ref]',
describe: 'Validate a range of commit messages',
};
@ -2982,7 +3127,7 @@ function printTargetBranchesForPr(prNumber) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$4(yargs) {
function builder$5(yargs) {
return yargs.positional('pr', {
description: 'The pull request number',
type: 'number',
@ -2990,15 +3135,15 @@ function builder$4(yargs) {
});
}
/** Handles the command. */
function handler$4({ pr }) {
function handler$5({ pr }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
yield printTargetBranchesForPr(pr);
});
}
/** yargs command module describing the command. */
const CheckTargetBranchesModule = {
handler: handler$4,
builder: builder$4,
handler: handler$5,
builder: builder$5,
command: 'check-target-branches <pr>',
describe: 'Check a PR to determine what branches it is currently targeting',
};
@ -3203,11 +3348,11 @@ function checkOutPullRequestLocally(prNumber, githubToken, opts = {}) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the checkout pull request command. */
function builder$5(yargs) {
function builder$6(yargs) {
return addGithubTokenOption(yargs).positional('prNumber', { type: 'number', demandOption: true });
}
/** Handles the checkout pull request command. */
function handler$5({ prNumber, githubToken }) {
function handler$6({ prNumber, githubToken }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const prCheckoutOptions = { allowIfMaintainerCannotModify: true, branchName: `pr-${prNumber}` };
yield checkOutPullRequestLocally(prNumber, githubToken, prCheckoutOptions);
@ -3215,8 +3360,8 @@ function handler$5({ prNumber, githubToken }) {
}
/** yargs command module for checking out a PR */
const CheckoutCommandModule = {
handler: handler$5,
builder: builder$5,
handler: handler$6,
builder: builder$6,
command: 'checkout <pr-number>',
describe: 'Checkout a PR from the upstream repo',
};
@ -4534,7 +4679,7 @@ function createPullRequestMergeTask(flags) {
* found in the LICENSE file at https://angular.io/license
*/
/** Builds the command. */
function builder$6(yargs) {
function builder$7(yargs) {
return addGithubTokenOption(yargs)
.help()
.strict()
@ -4550,7 +4695,7 @@ function builder$6(yargs) {
});
}
/** Handles the command. */
function handler$6(_a) {
function handler$7(_a) {
var pr = _a.pr, branchPrompt = _a.branchPrompt;
return tslib.__awaiter(this, void 0, void 0, function () {
return tslib.__generator(this, function (_b) {
@ -4565,8 +4710,8 @@ function handler$6(_a) {
}
/** yargs command module describing the command. */
var MergeCommandModule = {
handler: handler$6,
builder: builder$6,
handler: handler$7,
builder: builder$7,
command: 'merge <pr>',
describe: 'Merge a PR into its targeted branches.',
};
@ -5217,7 +5362,7 @@ function buildReleaseOutput(stampForRelease = false) {
* found in the LICENSE file at https://angular.io/license
*/
/** Yargs command builder for configuring the `ng-dev release build` command. */
function builder$7(argv) {
function builder$8(argv) {
return argv.option('json', {
type: 'boolean',
description: 'Whether the built packages should be printed to stdout as JSON.',
@ -5225,7 +5370,7 @@ function builder$7(argv) {
});
}
/** Yargs command handler for building a release. */
function handler$7(args) {
function handler$8(args) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const { npmPackages } = getReleaseConfig();
let builtPackages = yield buildReleaseOutput(true);
@ -5260,8 +5405,8 @@ function handler$7(args) {
}
/** CLI command module for building release output. */
const ReleaseBuildCommandModule = {
builder: builder$7,
handler: handler$7,
builder: builder$8,
handler: handler$8,
command: 'build',
describe: 'Builds the release output for the current branch.',
};
@ -5337,7 +5482,7 @@ function printActiveReleaseTrains(active, config) {
* found in the LICENSE file at https://angular.io/license
*/
/** Yargs command handler for printing release information. */
function handler$8() {
function handler$9() {
return tslib.__awaiter(this, void 0, void 0, function* () {
const git = GitClient.get();
const gitRepoWithApi = Object.assign({ api: git.github }, git.remoteConfig);
@ -5348,7 +5493,7 @@ function handler$8() {
}
/** CLI command module for retrieving release information. */
const ReleaseInfoCommandModule = {
handler: handler$8,
handler: handler$9,
command: 'info',
describe: 'Prints active release trains to the console.',
};
@ -5758,7 +5903,7 @@ class ReleaseNotes {
* found in the LICENSE file at https://angular.io/license
*/
/** Yargs command builder for configuring the `ng-dev release build` command. */
function builder$8(argv) {
function builder$9(argv) {
return argv
.option('releaseVersion', { type: 'string', default: '0.0.0', coerce: (version) => new semver.SemVer(version) })
.option('from', {
@ -5784,7 +5929,7 @@ function builder$8(argv) {
});
}
/** Yargs command handler for generating release notes. */
function handler$9({ releaseVersion, from, to, outFile, type }) {
function handler$a({ releaseVersion, from, to, outFile, type }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
// Since `yargs` evaluates defaults even if a value as been provided, if no value is provided to
// the handler, the latest semver tag on the branch is used.
@ -5805,8 +5950,8 @@ function handler$9({ releaseVersion, from, to, outFile, type }) {
}
/** CLI command module for generating release notes. */
const ReleaseNotesCommandModule = {
builder: builder$8,
handler: handler$9,
builder: builder$9,
handler: handler$a,
command: 'notes',
describe: 'Generate release notes',
};
@ -7367,11 +7512,11 @@ class ReleaseTool {
* found in the LICENSE file at https://angular.io/license
*/
/** Yargs command builder for configuring the `ng-dev release publish` command. */
function builder$9(argv) {
function builder$a(argv) {
return addGithubTokenOption(argv);
}
/** Yargs command handler for staging a release. */
function handler$a() {
function handler$b() {
return tslib.__awaiter(this, void 0, void 0, function* () {
const git = GitClient.get();
const config = getConfig();
@ -7396,8 +7541,8 @@ function handler$a() {
}
/** CLI command module for publishing a release. */
const ReleasePublishCommandModule = {
builder: builder$9,
handler: handler$a,
builder: builder$a,
handler: handler$b,
command: 'publish',
describe: 'Publish new releases and configure version branches.',
};
@ -7409,7 +7554,7 @@ const ReleasePublishCommandModule = {
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
function builder$a(args) {
function builder$b(args) {
return args
.positional('tagName', {
type: 'string',
@ -7423,7 +7568,7 @@ function builder$a(args) {
});
}
/** Yargs command handler for building a release. */
function handler$b(args) {
function handler$c(args) {
return tslib.__awaiter(this, void 0, void 0, function* () {
const { targetVersion: rawVersion, tagName } = args;
const { npmPackages, publishRegistry } = getReleaseConfig();
@ -7455,8 +7600,8 @@ function handler$b(args) {
}
/** CLI command module for setting an NPM dist tag. */
const ReleaseSetDistTagCommand = {
builder: builder$a,
handler: handler$b,
builder: builder$b,
handler: handler$c,
command: 'set-dist-tag <tag-name> <target-version>',
describe: 'Sets a given NPM dist tag for all release packages.',
};
@ -7536,22 +7681,22 @@ function getCurrentGitUser() {
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
function builder$b(args) {
function builder$c(args) {
return args.option('mode', {
demandOption: true,
description: 'Whether the env-stamp should be built for a snapshot or release',
choices: ['snapshot', 'release']
});
}
function handler$c({ mode }) {
function handler$d({ mode }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
buildEnvStamp(mode);
});
}
/** CLI command module for building the environment stamp. */
const BuildEnvStampCommand = {
builder: builder$b,
handler: handler$c,
builder: builder$c,
handler: handler$d,
command: 'build-env-stamp',
describe: 'Build the environment stamping information',
};
@ -7984,7 +8129,7 @@ function convertReferenceChainToString(chain) {
* found in the LICENSE file at https://angular.io/license
*/
/** Yargs command builder for the command. */
function builder$c(argv) {
function builder$d(argv) {
return argv.positional('projectRoot', {
type: 'string',
normalize: true,
@ -7993,7 +8138,7 @@ function builder$c(argv) {
});
}
/** Yargs command handler for the command. */
function handler$d({ projectRoot }) {
function handler$e({ projectRoot }) {
return tslib.__awaiter(this, void 0, void 0, function* () {
try {
if (!fs.lstatSync(projectRoot).isDirectory()) {
@ -8020,8 +8165,8 @@ function handler$d({ projectRoot }) {
}
/** CLI command module. */
const BuildAndLinkCommandModule = {
builder: builder$c,
handler: handler$d,
builder: builder$d,
handler: handler$e,
command: 'build-and-link <projectRoot>',
describe: 'Builds the release output, registers the outputs as linked, and links via yarn to the provided project',
};

View File

@ -45,6 +45,7 @@ export class GithubClient {
readonly issues = this._octokit.issues;
readonly git = this._octokit.git;
readonly rateLimit = this._octokit.rateLimit;
readonly teams = this._octokit.teams;
// Note: These are properties from `Octokit` that are brought in by optional plugins.
// TypeScript requires us to provide an explicit type for these.