import { App } from "octokit"; import { AssertionFailureInfo, StackTraceInfo } from "./logParse"; import { levenshtein } from "./levenshtein"; type FaultPanic = { type: "panic" seed: string command: string stackTrace: StackTraceInfo } type FaultAssertion = { type: "assertion" seed: string command: string failureInfo: AssertionFailureInfo } type FaultTimeout = { type: "timeout" seed: string command: string output: string } type Fault = FaultPanic | FaultTimeout | FaultAssertion; const MAX_OPEN_SIMULATOR_ISSUES = parseInt(process.env.MAX_OPEN_SIMULATOR_ISSUES || "10", 10); const GITHUB_ISSUE_TITLE_MAX_LENGTH = 256; const GITHUB_ISSUE_BODY_MAX_LENGTH = 65536; export class GithubClient { /* This is the git hash of the commit that the simulator was built from. */ GIT_HASH: string; /* Github app ID. */ GITHUB_APP_ID: string; /* This is the private key of the "Turso Github Handyman" Github App. It's stored in AWS secrets manager and will be injected into the container at runtime. */ GITHUB_APP_PRIVATE_KEY: string; /* This is the unique installation id of the above app into the tursodatabase organization. */ GITHUB_APP_INSTALLATION_ID: number; GITHUB_REPO: string; GITHUB_ORG: string = "tursodatabase"; GITHUB_REPO_NAME: string = "limbo"; mode: 'real' | 'dry-run'; app: App | null; initialized: boolean = false; openIssueTitles: string[] = []; constructor() { this.GIT_HASH = process.env.GIT_HASH || "unknown"; this.GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY || ""; this.GITHUB_APP_ID = process.env.GITHUB_APP_ID || ""; this.GITHUB_APP_INSTALLATION_ID = parseInt(process.env.GITHUB_APP_INSTALLATION_ID || "0", 10); this.mode = this.GITHUB_APP_PRIVATE_KEY ? 'real' : 'dry-run'; this.GITHUB_REPO = `${this.GITHUB_ORG}/${this.GITHUB_REPO_NAME}`; // Initialize GitHub OAuth App this.app = this.mode === 'real' ? new App({ appId: this.GITHUB_APP_ID, privateKey: this.GITHUB_APP_PRIVATE_KEY, }) : null; } private async getOpenIssues(): Promise { const octokit = await this.app!.getInstallationOctokit(this.GITHUB_APP_INSTALLATION_ID); const issues = await octokit.request('GET /repos/{owner}/{repo}/issues', { owner: this.GITHUB_ORG, repo: this.GITHUB_REPO_NAME, state: 'open', creator: 'app/turso-github-handyman', }); return issues.data.map((issue) => issue.title); } async initialize(): Promise { if (this.mode === 'dry-run') { console.log("Dry-run mode: Skipping initialization"); this.initialized = true; return; } this.openIssueTitles = await this.getOpenIssues(); this.initialized = true; } async postGitHubIssue(fault: Fault): Promise { if (!this.initialized) { await this.initialize(); } let title = ((f: Fault) => { if (f.type === "panic") { return `Simulator panic: "${f.stackTrace.mainError}"`; } else if (f.type === "assertion") { return `Simulator assertion failure: "${f.failureInfo.mainError}"`; } return `Simulator timeout using git hash ${this.GIT_HASH}`; })(fault); title = title.slice(0, GITHUB_ISSUE_TITLE_MAX_LENGTH); for (const existingIssueTitle of this.openIssueTitles) { const MAGIC_NUMBER = 6; if (levenshtein(existingIssueTitle, title) < MAGIC_NUMBER) { console.log(`Not creating issue ${title} because it is too similar to ${existingIssueTitle}`); return; } } let body = this.createIssueBody(fault); body = body.slice(0, GITHUB_ISSUE_BODY_MAX_LENGTH); if (this.mode === 'dry-run') { console.log(`Dry-run mode: Would create issue in ${this.GITHUB_REPO} with title: ${title} and body: ${body}`); return; } if (this.openIssueTitles.length >= MAX_OPEN_SIMULATOR_ISSUES) { console.log(`Max open simulator issues reached: ${MAX_OPEN_SIMULATOR_ISSUES}`); console.log(`Would create issue in ${this.GITHUB_REPO} with title: ${title} and body: ${body}`); return; } const [owner, repo] = this.GITHUB_REPO.split('/'); const octokit = await this.app!.getInstallationOctokit(this.GITHUB_APP_INSTALLATION_ID); const response = await octokit.request('POST /repos/{owner}/{repo}/issues', { owner, repo, title, body, labels: ['bug', 'simulator', 'automated'] }); console.log(`Successfully created GitHub issue: ${response.data.html_url}`); this.openIssueTitles.push(title); } private createIssueBody(fault: Fault): string { const gitShortHash = this.GIT_HASH.substring(0, 7); return ` ## Simulator failure type:${fault.type} - **Seed**: ${fault.seed} - **Git Hash**: ${this.GIT_HASH} - **Command**: \`limbo-sim ${fault.command}\` - **Timestamp**: ${new Date().toISOString()} ### Run locally with Docker \`\`\` git checkout ${this.GIT_HASH} docker buildx build -t limbo-sim:${gitShortHash} -f simulator-docker-runner/Dockerfile.simulator . --build-arg GIT_HASH=$(git rev-parse HEAD) docker run --network host limbo-sim:${gitShortHash} ${fault.command} \`\`\` ### ${fault.type === "panic" ? "Stack Trace" : "Output"} \`\`\` ${fault.type === "panic" ? fault.stackTrace.trace : fault.type === "assertion" ? fault.failureInfo.output : fault.output} \`\`\` `; } }