Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/experience check #17

2 changes: 1 addition & 1 deletion .github/workflows/worker-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

- name: Enable corepack
run: corepack enable

- uses: actions/checkout@v4
- uses: cloudflare/wrangler-action@v3
with:
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "Start | Stop",
"description": "Assign or un-assign yourself from an issue.",
"ubiquity:listeners": [ "issue_comment.created" ],
"ubiquity:listeners": ["issue_comment.created"],
"commands": {
"start": {
"ubiquity:example": "/start",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@sinclair/typebox": "^0.32.5",
"@supabase/supabase-js": "2.42.0",
"@ubiquity-dao/ubiquibot-logger": "^1.3.0",
"cheerio": "^1.0.0-rc.12",
"dotenv": "^16.4.4",
"ms": "^2.1.3",
"typebox-validators": "^0.3.5"
Expand All @@ -46,7 +47,7 @@
"@cspell/dict-typescript": "^3.1.2",
"@jest/globals": "29.7.0",
"@mswjs/data": "0.16.1",
"@types/jest": "29.5.12",
"@types/jest": "^29.5.12",
"@types/ms": "^0.7.34",
"@types/node": "^20.11.19",
"@typescript-eslint/eslint-plugin": "^7.0.1",
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/supabase/helpers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class User extends Super {
}

async getWalletByUserId(userId: number, issueNumber: number) {
const { data, error } = await this.supabase.from("users").select("wallets(*)").eq("id", userId).single() as { data: { wallets: Wallet }, error: unknown };
const { data, error } = (await this.supabase.from("users").select("wallets(*)").eq("id", userId).single()) as { data: { wallets: Wallet }; error: unknown };
if ((error && !data) || !data.wallets?.address) {
this.context.logger.error("No wallet address found", { userId, issueNumber });
if (this.context.config.miscellaneous.startRequiresWallet) {
Expand Down
27 changes: 27 additions & 0 deletions src/handlers/experience-gate/account-age-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Context } from "../../types";

export async function accountAgeHandler(context: Context) {
const { octokit, payload, logger, config } = context;
const { sender } = payload;

if (!config.experience) {
return;
}

const user = await octokit.users.getByUsername({
username: sender.login,
});

const created = new Date(user.data.created_at);
const age = Math.round((Date.now() - created.getTime()) / (1000 * 60 * 60 * 24));
const {
experience: { minAccountAgeInDays },
} = config;

if (age < minAccountAgeInDays || isNaN(age)) {
logger.error(`${sender.login} has not met the minimum account age requirement of ${minAccountAgeInDays} days`);
return;
}

return true;
}
122 changes: 122 additions & 0 deletions src/handlers/experience-gate/account-code-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Context, LangData, Language, Thresholds } from "../../types";
import { statsParser, topLangsParser } from "./parsers";

export async function accountCodeStats(context: Context) {
const { payload, logger, config } = context;
const { sender } = payload;

if (!config.experience) {
return;
}

const {
experience: { languages, mostImportantLanguage, statThresholds },
} = config;

const { langs, stats } = await getAccountStats(sender.login);

if (!handleLanguageChecks(langs, mostImportantLanguage, languages, logger, sender)) {
return;
}

if (!handleStatChecks(stats, statThresholds, logger, sender)) {
return;
}

return true;
}

async function getAccountStats(username: string) {
const statsUrl = `https://github-readme-stats.vercel.app/api?username=${username}`;
const topLangsUrl = `https://github-readme-stats.vercel.app/api/top-langs/?username=${username}`;

const statsRes = await fetch(statsUrl);
const topLangsRes = await fetch(topLangsUrl);

if (!statsRes.ok || !topLangsRes.ok) {
throw new Error("Failed to fetch account stats");
}

const statsDoc = await statsRes.text();
const topLangsDoc = await topLangsRes.text();

return {
stats: statsParser(statsDoc),
langs: topLangsParser(topLangsDoc),
};
}

function handleLanguageChecks(
langs: LangData[],
mostImportantLanguage: Language,
languages: Language,
logger: Context["logger"],
sender: Context["payload"]["sender"]
) {
const mostImportantLang = Object.keys(mostImportantLanguage)[0].toLowerCase();
const requiredMilThreshold = Object.values(mostImportantLanguage)[0];
const mostImportantLangData = langs.find((lang) => lang.lang.toLowerCase() === mostImportantLang);

if (!mostImportantLangData) {
logger.error(`${sender.login} does not any recorded experience with ${mostImportantLang}`);
return;
}

if (mostImportantLangData.percentage < requiredMilThreshold) {
logger.error(`${sender.login} has less than required ${requiredMilThreshold}% experience with ${mostImportantLang}`);
return;
}

const langsToCheck = Object.keys(languages).map((lang) => lang.toLowerCase());
const detectedLangs = [];
for (const lang of langsToCheck) {
const langData = langs.find((l) => l.lang.toLowerCase() === lang);
if (langData) {
detectedLangs.push(langData);
}
}

for (const lang of detectedLangs) {
const threshold = languages[lang.lang.toLowerCase()];
const percentage = lang.percentage;

if (threshold > percentage) {
logger.error(`${sender.login}: ${percentage}% of ${lang.lang} is less than required ${threshold}%`);
return;
}
}

logger.info(`${sender.login} has passed all language checks`);

return true;
}

function handleStatChecks(stats: ReturnType<typeof statsParser>, thresholds: Thresholds, logger: Context["logger"], sender: Context["payload"]["sender"]) {
const { totalPRs, totalStars, totalIssues, totalCommitsThisYear } = stats;

logger.info(`Checking ${sender.login} stats`, { stats, thresholds });

if (totalPRs < thresholds.prs) {
logger.error(`${sender.login} has less than required ${thresholds.prs} PRs`);
return;
}

if (totalStars < thresholds.stars) {
logger.error(`${sender.login} has less than required ${thresholds.stars} stars`);
return;
}

if (totalIssues < thresholds.issues) {
logger.error(`${sender.login} has less than required ${thresholds.issues} issues`);
return;
}

if (totalCommitsThisYear < thresholds.minCommitsThisYear) {
logger.error(`${sender.login} has less than required ${thresholds.minCommitsThisYear} commits`);
return;
}

logger.info(`${sender.login} has passed all stat checks`);

return true;
}
53 changes: 53 additions & 0 deletions src/handlers/experience-gate/parsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { load } from "cheerio";

export function topLangsParser(html: string) {
const $ = load(html);
// cspell: disable
const langs = $('text[data-testid="lang-name"]');
// cspell: enable
const percentages = $("text.lang-name").filter((a, element) => {
a;
return /\d+(\.\d+)?%/.test($(element).text());
});

return langs
.map((i, lang) => {
return {
lang: $(lang).text().toLowerCase(),
percentage: parseFloat($(percentages[i]).text()),
};
})
.get();
}

export function statsParser(text2: string) {
const $ = load(text2);
const desc = $("desc").text();
const year = new Date().getFullYear();

const steps = {
totalStars: 0,
totalCommitsThisYear: 0,
totalPRs: 0,
totalIssues: 0,
totalContributionsLastYear: 0,
};

const regexes = [
/Total Stars Earned: (\d+)/,
new RegExp(`Total Commits in ${year} : (\\d+)`), // the space before ":" is intentional
/Total PRs: (\d+)/,
/Total Issues: (\d+)/,
/Contributed to \(last year\): (\d+)/,
];

return regexes.reduce((acc, regex, i) => {
const match = desc.match(regex);
if (match) {
const key = Object.keys(steps)[i] as keyof typeof steps;
const value = match[1];
steps[key] = parseInt(value);
}
return acc;
}, steps);
}
27 changes: 27 additions & 0 deletions src/handlers/experience-gate/xp-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { accountCodeStats } from "./account-code-stats";
import { accountAgeHandler } from "./account-age-handler";
import { Context } from "../../types";

export async function handleExperienceChecks(context: Context) {
const {
logger,
config: { experience },
} = context;

if (!experience) {
logger.info("Experience checks are disabled");
return true;
}

if (!(await accountAgeHandler(context))) {
return;
}

if (!(await accountCodeStats(context))) {
return;
}

logger.info("User meets all requirements");

return true;
}
2 changes: 1 addition & 1 deletion src/handlers/shared/check-task-stale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ export function checkTaskStale(staleTask: number, createdAt: string) {
}

return false;
}
}
2 changes: 1 addition & 1 deletion src/handlers/shared/structured-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { LogReturn } from "@ubiquity-dao/ubiquibot-logger";

function createStructuredMetadata(className: string, logReturn: LogReturn | null) {
let logMessage, metadata
let logMessage, metadata;
if (logReturn) {
logMessage = logReturn.logMessage;
metadata = logReturn.metadata;
Expand Down
12 changes: 9 additions & 3 deletions src/handlers/user-start-stop.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Context } from "../types";
import { addCommentToIssue } from "../utils/issue";
import { handleExperienceChecks } from "./experience-gate/xp-gate";
import { start } from "./shared/start";
import { stop } from "./shared/stop";

Expand All @@ -10,15 +11,20 @@ export async function userStartStop(context: Context): Promise<{ output: string
const { isEnabled } = config;

if (!isEnabled) {
const backTicks = "```";
await addCommentToIssue(context, `${backTicks}diff\n! The /${slashCommand} command is disabled for this repository.\n${backTicks}`);
const log = context.logger.error(`The '/${slashCommand}' command is disabled for this repository`);
await addCommentToIssue(context, log?.logMessage.diff as string);
throw new Error(`The '/${slashCommand}' command is disabled for this repository.`);
}

if (slashCommand === "stop") {
return await stop(context, issue, sender, repository);
} else if (slashCommand === "start") {
return await start(context, issue, sender);
if (await handleExperienceChecks(context)) {
return await start(context, issue, sender);
} else {
const log = context.logger.error(`You do not meet the requirements to start this task.`);
await addCommentToIssue(context, log?.logMessage.diff as string);
}
}

return { output: null };
Expand Down
2 changes: 1 addition & 1 deletion src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ export interface Context<T extends SupportedEventsU = SupportedEventsU, TU exten
adapters: ReturnType<typeof createAdapters>;
config: StartStopSettings;
env: Env;
logger: Logs
logger: Logs;
}
42 changes: 42 additions & 0 deletions src/types/plugin-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import { SupportedEvents, SupportedEventsU } from "./context";
import { StaticDecode, Type as T } from "@sinclair/typebox";
import { StandardValidator } from "typebox-validators";

export type Thresholds = {
prs: number;
stars: number;
issues: number;
minCommitsThisYear: number;
};

export type LangData = {
lang: string;
percentage: number;
};

export type Language = Record<string, number>;

export interface PluginInputs<T extends SupportedEventsU = SupportedEventsU, TU extends SupportedEvents[T] = SupportedEvents[T]> {
stateId: string;
eventName: T;
Expand Down Expand Up @@ -29,6 +43,34 @@ export const startStopSchema = T.Object({
},
{ default: { maxConcurrentTasks: 3, startRequiresWallet: true } }
),
experience: T.Optional(
T.Object(
{
minAccountAgeInDays: T.Number(), // Minimum account age in days,
mostImportantLanguage: T.Record(T.String(), T.Number()), // Most important language to detect
languages: T.Record(T.String(), T.Number()), // Languages to detect
statThresholds: T.Object({
stars: T.Number(), // Minimum number of stars
minCommitsThisYear: T.Number(), // Minimum number of commits
prs: T.Number(), // Minimum number of PRs
issues: T.Number(), // Minimum number of issues
}),
},
{
default: {
minAccountAgeInDays: 365,
mostImportantLanguage: { Typescript: 10 },
languages: { Solidity: 10, JavaScript: 10 },
statThresholds: {
stars: 1,
minCommitsThisYear: 1,
prs: 1,
issues: 1,
},
},
}
)
),
});

export type StartStopSettings = StaticDecode<typeof startStopSchema>;
Expand Down
Loading