pokemon-showdown/server/chat-plugins/github.ts
Guangcong Luo 78439b4a02
Update to ESLint 9 (#10926)
ESLint has a whole new config format, so I figure it's a good time to
make the config system saner.

- First, we no longer have separate eslint-no-types configs. Lint
  performance shouldn't be enough of a problem to justify the
  relevant maintenance complexity.

- Second, our base config should work out-of-the-box now. `npx eslint`
  will work as expected, without any CLI flags. You should still use
  `npm run lint` which adds the `--cached` flag for performance.

- Third, whatever updates I did fixed style linting, which apparently
  has been bugged for quite some time, considering all the obvious
  mixed-tabs-and-spaces issues I found in the upgrade.

Also here are some changes to our style rules. In particular:

- Curly brackets (for objects etc) now have spaces inside them. Sorry
  for the huge change. ESLint doesn't support our old style, and most
  projects use Prettier style, so we might as well match them in this way.
  See https://github.com/eslint-stylistic/eslint-stylistic/issues/415

- String + number concatenation is no longer allowed. We now
  consistently use template strings for this.
2025-02-25 20:03:46 -08:00

279 lines
10 KiB
TypeScript

/**
* Chat plugin to view GitHub events in a chatroom.
* By Mia, with design / html from xfix's original bot, https://github.com/xfix/GitHub-Bot-Legacy/
* @author mia-pi-git
*/
import { FS, Utils } from '../../lib';
const STAFF_REPOS = Config.staffrepos || [
'pokemon-showdown', 'pokemon-showdown-client', 'Pokemon-Showdown-Dex', 'pokemon-showdown-loginserver',
];
const COOLDOWN = 10 * 60 * 1000;
export const gitData: GitData = JSON.parse(FS("config/chat-plugins/github.json").readIfExistsSync() || "{}");
interface GitHookHandler {
on(event: 'pull_request', callback: (repo: string, ref: string | undefined, result: PullRequest) => void): void;
on(event: 'push', callback: (repo: string, ref: string, result: Push) => void): void;
on(event: string, callback: (repo: string, ref: string | undefined, result: any) => void): void;
listen(): void;
server: import('http').Server;
}
interface Push {
commits: Commit[];
sender: { login: string };
compare: string;
}
interface PullRequest {
action: string;
number: number;
pull_request: {
url: string,
html_url: string,
title: string,
user: {
login: string,
html_url: string,
},
merge_commit_sha: string,
};
sender: { login: string };
}
interface Commit {
id: string;
message: string;
author: { name: string, avatar_url: string };
url: string;
}
interface GitData {
usernames?: { [username: string]: string };
bans?: { [username: string]: string };
}
export const GitHub = new class {
readonly hook: GitHookHandler | null = null;
updates: { [k: string]: number } = Object.create(null);
constructor() {
// config.github: https://github.com/nlf/node-github-hook#readme
if (!Config.github) return;
try {
this.hook = require('githubhook')({
logger: {
log: (line: string) => Monitor.debug(line),
error: (line: string) => Monitor.notice(line),
},
...Config.github,
});
} catch (err) {
Monitor.crashlog(err, "GitHub hook");
}
this.listen();
}
listen() {
if (!this.hook) return;
this.hook.listen();
this.hook.on('push', (repo, ref, result) => this.handlePush(repo, ref, result));
this.hook.on('pull_request', (repo, ref, result) => this.handlePull(repo, ref, result));
}
private getRepoName(repo: string) {
switch (repo) {
case 'pokemon-showdown':
return 'server';
case 'pokemon-showdown-client':
return 'client';
case 'Pokemon-Showdown-Dex':
return 'dex';
default:
return repo.toLowerCase();
}
}
handlePush(repo: string, ref: string, result: Push) {
const branch = /[^/]+$/.exec(ref)?.[0] || "";
if (branch !== 'master') return;
const messages: { [k: string]: string[] } = {
staff: [],
development: [],
};
for (const commit of result.commits) {
const { message, url } = commit;
const [shortMessage] = message.split('\n\n');
const username = this.getUsername(commit.author.name);
const repoName = this.getRepoName(repo);
const id = commit.id.substring(0, 6);
messages.development.push(
Utils.html`[<span style="color:#FF00FF">${repoName}</span>] <a href="${url}" style="color:#606060">${id}</a> ${shortMessage} <span style="color:#909090">(${username})</span>`
);
messages.staff.push(Utils.html`[<span style="color:#FF00FF">${repoName}</span>] <a href="${url}">${shortMessage}</a> <span style="color:#909090">(${username})</span>`);
}
for (const k in messages) {
this.report(k as RoomID, repo, messages[k as RoomID]);
}
}
handlePull(repo: string, ref: string | undefined, result: PullRequest) {
if (this.isRateLimited(result.number)) return;
if (this.isGitbanned(result)) return;
const url = result.pull_request.html_url;
const action = this.isValidAction(result.action);
if (!action) return;
const repoName = this.getRepoName(repo);
const userName = this.getUsername(result.sender.login);
const title = result.pull_request.title;
let buf = Utils.html`[<span style="color:#FF00FF">${repoName}</span>] <span style="color:#909090">${userName}</span> `;
buf += Utils.html`${action} <a href="${url}">PR#${result.number}</a>: ${title}`;
this.report('development', repo, buf);
}
report(roomid: RoomID, repo: string, messages: string[] | string) {
if (!STAFF_REPOS.includes(repo) && roomid === 'staff') return;
if (Array.isArray(messages)) messages = messages.join('<br />');
Rooms.get(roomid)?.add(`|html|<div class="infobox">${messages}</div>`).update();
}
isGitbanned(result: PullRequest) {
if (!gitData.bans) return false;
return gitData.bans[result.sender.login] || gitData.bans[result.pull_request.user.login];
}
isRateLimited(prNumber: number) {
if (this.updates[prNumber]) {
if (this.updates[prNumber] + COOLDOWN > Date.now()) return true;
this.updates[prNumber] = Date.now();
return false;
}
this.updates[prNumber] = Date.now();
return false;
}
isValidAction(action: string) {
if (action === 'synchronize') return 'updated';
if (action === 'review_requested') {
return 'requested a review for';
} else if (action === 'review_request_removed') {
return 'removed a review request for';
}
if (['ready_for_review', 'labeled', 'unlabeled', 'converted_to_draft'].includes(action)) {
return null;
}
return action;
}
getUsername(name: string) {
return gitData.usernames?.[toID(name)] || name;
}
save() {
FS("config/chat-plugins/github.json").writeUpdate(() => JSON.stringify(gitData));
}
};
export const commands: Chat.ChatCommands = {
gh: 'github',
github: {
''() {
return this.parse('/help github');
},
ban(target, room, user) {
room = this.requireRoom('development');
this.checkCan('mute', null, room);
const [username, reason] = Utils.splitFirst(target, ',').map(u => u.trim());
if (!toID(target)) return this.parse(`/help github`);
if (!toID(username)) return this.errorReply("Provide a username.");
if (room.auth.has(toID(GitHub.getUsername(username)))) {
return this.errorReply("That user is Dev roomauth. If you need to do this, demote them and try again.");
}
if (!gitData.bans) gitData.bans = {};
if (gitData.bans[toID(username)]) {
return this.errorReply(`${username} is already gitbanned.`);
}
gitData.bans[toID(username)] = reason || " "; // to ensure it's truthy
GitHub.save();
this.privateModAction(`${user.name} banned the GitHub user ${username} from having their GitHub actions reported to this server.`);
this.modlog('GITHUB BAN', username, reason);
},
unban(target, room, user) {
room = this.requireRoom('development');
this.checkCan('mute', null, room);
target = toID(target);
if (!target) return this.parse('/help github');
if (!gitData.bans?.[target]) return this.errorReply("That user is not gitbanned.");
delete gitData.bans[target];
if (!Object.keys(gitData.bans).length) delete gitData.bans;
GitHub.save();
this.privateModAction(`${user.name} allowed the GitHub user ${target} to have their GitHub actions reported to this server.`);
this.modlog('GITHUB UNBAN', target);
},
bans() {
const room = this.requireRoom('development');
this.checkCan('mute', null, room);
return this.parse('/j view-github-bans');
},
setname: 'addusername',
addusername(target, room, user) {
room = this.requireRoom('development');
this.checkCan('mute', null, room);
const [gitName, username] = Utils.splitFirst(target, ',').map(u => u.trim());
if (!toID(gitName) || !toID(username)) return this.parse(`/help github`);
if (!gitData.usernames) gitData.usernames = {};
gitData.usernames[toID(gitName)] = username;
GitHub.save();
this.privateModAction(`${user.name} set ${gitName}'s name on reported GitHub actions to be ${username}.`);
this.modlog('GITHUB SETNAME', null, `'${gitName}' to '${username}'`);
},
clearname: 'removeusername',
removeusername(target, room, user) {
room = this.requireRoom('development');
this.checkCan('mute', null, room);
target = toID(target);
if (!target) return this.parse(`/help github`);
const name = gitData.usernames?.[target];
if (!name) return this.errorReply(`${target} is not a GitHub username on our list.`);
delete gitData.usernames?.[target];
if (!Object.keys(gitData.usernames || {}).length) delete gitData.usernames;
GitHub.save();
this.privateModAction(`${user.name} removed ${target}'s name from the GitHub username list.`);
this.modlog('GITHUB CLEARNAME', target, `from the name ${name}`);
},
names() {
return this.parse('/j view-github-names');
},
},
githubhelp: [
`/github ban [username], [reason] - Bans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # ~`,
`/github unban [username] - Unbans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # ~`,
`/github bans - Lists all GitHub users that are currently gitbanned. Requires: % @ # ~`,
`/github setname [username], [name] - Sets a GitHub user's name on reported GitHub actions to be [name]. Requires: % @ # ~`,
`/github clearname [username] - Removes a GitHub user's name from the GitHub username list. Requires: % @ # ~`,
`/github names - Lists all GitHub usernames that are currently on our list.`,
],
};
export const pages: Chat.PageTable = {
github: {
bans(query, user) {
const room = Rooms.get('development');
if (!room) return this.errorReply("No Development room found.");
this.checkCan('mute', null, room);
if (!gitData.bans) return this.errorReply("There are no gitbans at this time.");
let buf = `<div class="pad"><h2>Current Gitbans:</h2><hr /><ol>`;
for (const [username, reason] of Object.entries(gitData.bans)) {
buf += `<li><strong>${username}</strong> - ${reason.trim() || '(No reason found)'}</li>`;
}
buf += `</ol>`;
return buf;
},
names() {
if (!gitData.usernames) return this.errorReply("There are no GitHub usernames in the list.");
let buf = `<div class="pad"><h2>Current GitHub username mappings:</h2><hr /><ol>`;
for (const [username, name] of Object.entries(gitData.usernames)) {
buf += `<li><strong>${username}</strong> - ${name}</li>`;
}
buf += `</ol>`;
return buf;
},
},
};
export function destroy() {
GitHub.hook?.server.close();
}