limbo/simulator-docker-runner/github.ts
Jussi Saurio d682f85584 sim/aws: comment on existing issues instead of skipping duplicates
Currently the simulator running on AWS will skip posting issues that
are too similar to existing ones, to avoid spamming the issue tracker.
However, this loses some information about how frequent a given failure
is, so a better solution is to comment on the existing issue whenever
a similar failure occurs.

Changes:

- Track issue numbers alongside titles in openIssues
- Add commentOnIssue() to post comments on duplicate issues
- Extract shared createFaultDetails() from issue/comment body creation

Whole PR written using Opus 4.5
2025-12-09 10:14:01 +02:00

195 lines
No EOL
6.3 KiB
TypeScript

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;
openIssues: { title: string; number: number }[] = [];
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<{ title: string; number: number }[]> {
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) => ({ title: issue.title, number: issue.number }));
}
async initialize(): Promise<void> {
if (this.mode === 'dry-run') {
console.log("Dry-run mode: Skipping initialization");
this.initialized = true;
return;
}
this.openIssues = await this.getOpenIssues();
this.initialized = true;
}
async postGitHubIssue(fault: Fault): Promise<void> {
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 existingIssue of this.openIssues) {
const MAGIC_NUMBER = 6;
if (levenshtein(existingIssue.title, title) < MAGIC_NUMBER) {
console.log(`Found similar issue #${existingIssue.number}: "${existingIssue.title}"`);
await this.commentOnIssue(existingIssue.number, fault);
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.openIssues.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.openIssues.push({ title, number: response.data.number });
}
private async commentOnIssue(issueNumber: number, fault: Fault): Promise<void> {
const comment = this.createCommentBody(fault);
if (this.mode === 'dry-run') {
console.log(`Dry-run mode: Would comment on issue #${issueNumber} with: ${comment}`);
return;
}
const octokit = await this.app!.getInstallationOctokit(this.GITHUB_APP_INSTALLATION_ID);
const response = await octokit.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner: this.GITHUB_ORG,
repo: this.GITHUB_REPO_NAME,
issue_number: issueNumber,
body: comment,
});
console.log(`Successfully commented on issue #${issueNumber}: ${response.data.html_url}`);
}
private createFaultDetails(fault: Fault): string {
const gitShortHash = this.GIT_HASH.substring(0, 7);
return `- **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}
\`\`\``;
}
private createCommentBody(fault: Fault): string {
return `### Duplicate occurrence detected
${this.createFaultDetails(fault)}
`;
}
private createIssueBody(fault: Fault): string {
const output = fault.type === "panic" ? fault.stackTrace.trace : fault.type === "assertion" ? fault.failureInfo.output : fault.output;
return `## Simulator failure type: ${fault.type}
${this.createFaultDetails(fault)}
### ${fault.type === "panic" ? "Stack Trace" : "Output"}
\`\`\`
${output}
\`\`\`
`;
}
}