buddy-bot
Version:
The Stacks CLI.
1,239 lines (1,212 loc) • 6.51 MB
JavaScript
// @bun
var __create = Object.create;
var __getProtoOf = Object.getPrototypeOf;
var __defProp = Object.defineProperty;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __toESM = (mod, isNodeMode, target) => {
target = mod != null ? __create(__getProtoOf(mod)) : {};
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
for (let key of __getOwnPropNames(mod))
if (!__hasOwnProp.call(to, key))
__defProp(to, key, {
get: () => mod[key],
enumerable: true
});
return to;
};
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, {
get: all[name],
enumerable: true,
configurable: true,
set: (newValue) => all[name] = () => newValue
});
};
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
var __require = import.meta.require;
// src/dashboard/dashboard-generator.ts
class DashboardGenerator {
generateDashboard(data, options2 = {}) {
const {
showOpenPRs = true,
showDetectedDependencies = true,
showDeprecatedDependencies = true,
bodyTemplate
} = options2;
const title = "Dependency Dashboard";
if (bodyTemplate) {
return {
title,
body: this.applyTemplate(bodyTemplate, data)
};
}
let body = this.generateDefaultHeader(data);
if (showDeprecatedDependencies && data.deprecatedDependencies && data.deprecatedDependencies.length > 0) {
body += this.generateDeprecatedDependenciesSection(data.deprecatedDependencies);
}
if (showOpenPRs && data.openPRs.length > 0) {
body += this.generateOpenPRsSection(data.openPRs);
}
if (showDetectedDependencies) {
body += this.generateDetectedDependenciesSection(data.detectedDependencies);
}
body += this.generateFooter();
return { title, body };
}
generateDefaultHeader(_data) {
return `This issue lists Buddy Bot updates and detected dependencies. Read the [Dependency Dashboard](https://buddy-bot.sh/features/dependency-dashboard) docs to learn more.
`;
}
generateOpenPRsSection(openPRs) {
let section = `## Open
The following updates have all been created. To force a retry/rebase of any, click on a checkbox below.
`;
for (const pr of openPRs) {
const packageInfo = this.extractPackageInfo(pr);
const rebaseBranch = pr.head;
const relativeUrl = pr.url.includes("/pull/") && pr.url.includes("github.com") ? `../pull/${pr.number}` : pr.url;
section += ` - [ ] <!-- rebase-branch=${rebaseBranch} -->[${pr.title}](${relativeUrl})`;
if (packageInfo.length > 0) {
section += ` (\`${packageInfo.join("`, `")}\`)`;
}
section += `
`;
}
section += ` - [ ] <!-- rebase-all-open-prs -->**Click on this checkbox to rebase all open PRs at once**
`;
section += `
`;
return section;
}
generateDetectedDependenciesSection(dependencies) {
let section = `## Detected dependencies
`;
if (dependencies.packageJson.length > 0) {
section += this.generatePackageJsonSection(dependencies.packageJson);
}
const composerFiles = dependencies.dependencyFiles.filter((file) => file.path === "composer.json" || file.path.endsWith("/composer.json"));
const rootComposerFiles = composerFiles.filter((file) => file.path === "composer.json");
const vendorComposerFiles = composerFiles.filter((file) => file.path !== "composer.json");
if (rootComposerFiles.length > 0) {
section += this.generateComposerSection(rootComposerFiles);
}
if (dependencies.githubActions.length > 0) {
section += this.generateGitHubActionsSection(dependencies.githubActions);
}
const otherDependencyFiles = dependencies.dependencyFiles.filter((file) => !file.path.endsWith("/composer.json") && file.path !== "composer.json");
if (otherDependencyFiles.length > 0 || vendorComposerFiles.length > 0) {
section += this.generateDependencyFilesSection([...otherDependencyFiles, ...vendorComposerFiles]);
}
return section;
}
generatePackageJsonSection(packageFiles) {
let section = `<details><summary>npm</summary>
<blockquote>
`;
for (const file of packageFiles) {
const fileName = file.path.split("/").pop() || file.path;
section += `<details><summary>${fileName}</summary>
`;
const depsByType = {
dependencies: file.dependencies.filter((d) => d.type === "dependencies"),
devDependencies: file.dependencies.filter((d) => d.type === "devDependencies"),
peerDependencies: file.dependencies.filter((d) => d.type === "peerDependencies"),
optionalDependencies: file.dependencies.filter((d) => d.type === "optionalDependencies")
};
for (const [_type, deps] of Object.entries(depsByType)) {
if (deps.length > 0) {
for (const dep of deps) {
section += ` - \`${dep.name} ${dep.currentVersion}\`
`;
}
}
}
section += `
</details>
`;
}
section += `</blockquote>
</details>
`;
return section;
}
generateGitHubActionsSection(actionFiles) {
let section = `<details><summary>github-actions</summary>
<blockquote>
`;
for (const file of actionFiles) {
section += `<details><summary>${file.path}</summary>
`;
const uniqueActions = new Map;
for (const action of file.dependencies) {
const key = `${action.name}@${action.currentVersion}`;
if (!uniqueActions.has(key)) {
uniqueActions.set(key, {
name: action.name,
currentVersion: action.currentVersion
});
}
}
for (const action of uniqueActions.values()) {
section += ` - \`${action.name} ${action.currentVersion}\`
`;
}
section += `
</details>
`;
}
section += `</blockquote>
</details>
`;
return section;
}
generateDependencyFilesSection(dependencyFiles) {
let section = `<details><summary>dependency-files</summary>
<blockquote>
`;
for (const file of dependencyFiles) {
section += `<details><summary>${file.path}</summary>
`;
for (const dep of file.dependencies) {
section += ` - \`${dep.name} ${dep.currentVersion}\`
`;
}
section += `
</details>
`;
}
section += `</blockquote>
</details>
`;
return section;
}
generateComposerSection(composerFiles) {
let section = `<details><summary>composer</summary>
<blockquote>
`;
for (const file of composerFiles) {
const fileName = file.path.split("/").pop() || file.path;
section += `<details><summary>${fileName}</summary>
`;
const depsByType = {
require: file.dependencies.filter((d) => d.type === "require"),
"require-dev": file.dependencies.filter((d) => d.type === "require-dev")
};
for (const [_type, deps] of Object.entries(depsByType)) {
if (deps.length > 0) {
for (const dep of deps) {
section += ` - \`${dep.name} ${dep.currentVersion}\`
`;
}
}
}
section += `
</details>
`;
}
section += `</blockquote>
</details>
`;
return section;
}
generateDeprecatedDependenciesSection(deprecatedDependencies) {
let section = `> [!WARNING]
> These dependencies are deprecated and should be updated to avoid potential security risks and compatibility issues.
| Datasource | Name | Replacement PR? |
|------------|------|-----------------|
`;
for (const dep of deprecatedDependencies) {
const nameDisplay = `\`${dep.name}\``;
const replacementStatus = dep.replacementAvailable ? "available" : "unavailable";
const replacementBadge = replacementStatus === "available" ? "" : "";
section += `| ${dep.datasource} | ${nameDisplay} | ${replacementBadge} |
`;
}
section += `
`;
return section;
}
generateFooter() {
return `---
- [ ] <!-- manual job -->Check this box to trigger a request for Buddy Bot to run again on this repository
`;
}
extractPackageInfo(pr) {
const packages = [];
const titlePatterns = [
/update.*?dependency\s+(\S+)/i,
/update\s+require(?:-dev)?\s+(\S+)\s+to\s+v?\d+/i,
/update\s+(\S+)\s+to\s+v?\d+/i,
/bump\s+(\S+)\s+from/i,
/chore\(deps\):\s*update\s+dependency\s+(\S+)/i
];
for (const pattern of titlePatterns) {
const match = pr.title.match(pattern);
if (match && match[1] && !packages.includes(match[1])) {
packages.push(match[1]);
}
}
const tableSections = [
{ name: "npm", pattern: /### npm Dependencies[\s\S]*?(?=###|\n\n---|z)/i },
{ name: "pkgx", pattern: /### Launchpad\/pkgx Dependencies[\s\S]*?(?=###|\n\n---|z)/i },
{ name: "actions", pattern: /### GitHub Actions[\s\S]*?(?=###|\n\n---|z)/i }
];
for (const section of tableSections) {
const sectionMatch = pr.body.match(section.pattern);
if (sectionMatch) {
const sectionContent = sectionMatch[0];
const tableRowMatches = sectionContent.match(/\|\s*\[([^\]]+)\]\([^)]+\)\s*\|/g);
if (tableRowMatches) {
for (const match of tableRowMatches) {
const packageMatch = match.match(/\|\s*\[([^\]]+)\]/);
if (packageMatch && packageMatch[1]) {
const packageName = packageMatch[1].trim();
if (packageName.includes("`") && packageName.includes("->")) {
const urlMatch = match.match(/\]\(([^)]+)\)/);
if (urlMatch && urlMatch[1]) {
const url = urlMatch[1];
const diffUrlMatch = url.match(/\/diffs\/npm\/([^/]+)\//);
if (diffUrlMatch && diffUrlMatch[1]) {
const extractedPackage = decodeURIComponent(diffUrlMatch[1]);
if (extractedPackage && extractedPackage.length > 1 && !packages.includes(extractedPackage)) {
packages.push(extractedPackage);
}
}
}
continue;
}
if (!packageName.includes("://") && !packageName.includes("Compare Source") && !packageName.includes("badge") && !packageName.includes("!") && !packageName.startsWith("[![") && !packageName.includes("`") && !packageName.includes("->") && !packageName.includes(" -> ") && !packageName.match(/^\d+\.\d+/) && !packageName.includes(" ") && packageName.length > 0 && !packages.includes(packageName)) {
packages.push(packageName);
}
}
}
}
}
}
if (packages.length < 3) {
const bodyMatches = pr.body.match(/`([^`]+)`/g);
if (bodyMatches) {
for (const match of bodyMatches) {
let packageName = match.replace(/`/g, "").trim();
if (packageName.includes("->") || packageName.includes(" -> ") || packageName.includes("` -> `") || packageName.match(/^\d+\.\d+/) || packageName.match(/^v\d+/) || packageName.match(/[\d.]+\s*->\s*[\d.]+/) || packageName.match(/^[\d.]+$/) || packageName.match(/^\d+\.\d+\.\d+/) || packageName.match(/^\d+\.\d+\.\d+\./) || packageName.match(/^\^?\d+\.\d+/) || packageName.match(/^~\d+\.\d+/) || packageName.includes("://") || packageName.includes("Compare Source") || packageName.includes("badge") || packageName.includes(" ")) {
continue;
}
packageName = packageName.split(",")[0].trim();
if (packageName && packageName.length > 1 && !packages.includes(packageName) && (packageName.startsWith("@") || packageName.includes("/") || packageName.match(/^[a-z][a-z0-9.-]*$/i))) {
packages.push(packageName);
}
}
}
}
return packages;
}
applyTemplate(template, data) {
return template.replace(/\{\{repository\.owner\}\}/g, data.repository.owner).replace(/\{\{repository\.name\}\}/g, data.repository.name).replace(/\{\{openPRs\.count\}\}/g, data.openPRs.length.toString()).replace(/\{\{lastUpdated\}\}/g, data.lastUpdated.toISOString()).replace(/\{\{detectedDependencies\.packageJson\.count\}\}/g, data.detectedDependencies.packageJson.length.toString()).replace(/\{\{detectedDependencies\.githubActions\.count\}\}/g, data.detectedDependencies.githubActions.length.toString()).replace(/\{\{detectedDependencies\.dependencyFiles\.count\}\}/g, data.detectedDependencies.dependencyFiles.length.toString());
}
}
// src/git/github-provider.ts
var exports_github_provider = {};
__export(exports_github_provider, {
GitHubProvider: () => GitHubProvider
});
import { Buffer as Buffer2 } from "buffer";
import { spawn } from "child_process";
import process2 from "process";
class GitHubProvider {
token;
owner;
repo;
hasWorkflowPermissions;
apiUrl = "https://api.github.com";
constructor(token, owner, repo, hasWorkflowPermissions = false) {
this.token = token;
this.owner = owner;
this.repo = repo;
this.hasWorkflowPermissions = hasWorkflowPermissions;
}
async createBranch(branchName, baseBranch) {
try {
const baseRef = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/git/ref/heads/${baseBranch}`);
const baseSha = baseRef.object.sha;
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/git/refs`, {
ref: `refs/heads/${branchName}`,
sha: baseSha
});
console.log(`\u2705 Created branch ${branchName} from ${baseBranch}`);
} catch (error) {
console.error(`\u274C Failed to create branch ${branchName}:`, error);
throw error;
}
}
async commitChanges(branchName, message, files) {
try {
await this.commitChangesWithGit(branchName, message, files);
} catch (gitError) {
console.warn(`\u26A0\uFE0F Git CLI commit failed, falling back to GitHub API: ${gitError}`);
await this.commitChangesWithAPI(branchName, message, files);
}
}
async commitChangesWithGit(branchName, message, files) {
try {
const workflowFiles = files.filter((f) => f.path.includes(".github/workflows/"));
const nonWorkflowFiles = files.filter((f) => !f.path.includes(".github/workflows/"));
if (workflowFiles.length > 0 && !this.hasWorkflowPermissions) {
console.warn(`\u26A0\uFE0F Detected ${workflowFiles.length} workflow file(s). These require elevated permissions.`);
console.warn(`\u26A0\uFE0F Workflow files: ${workflowFiles.map((f) => f.path).join(", ")}`);
console.warn(`\u2139\uFE0F Workflow files will be skipped in this commit. BUDDY_BOT_TOKEN not detected or lacks workflow permissions.`);
if (nonWorkflowFiles.length > 0) {
console.log(`\uD83D\uDCDD Committing ${nonWorkflowFiles.length} non-workflow files...`);
files = nonWorkflowFiles;
} else {
console.warn(`\u26A0\uFE0F All files are workflow files. No files will be committed in this PR.`);
console.warn(`\uD83D\uDCA1 To update workflow files, ensure BUDDY_BOT_TOKEN is set with workflow:write permissions.`);
console.log(`\uD83D\uDCDD Creating empty commit to avoid "No commits between branches" error...`);
try {
await this.runCommand("git", ["commit", "--allow-empty", "-m", "Workflow files require elevated permissions - no changes committed"]);
console.log(`\u2705 Created empty commit for workflow-only PR`);
} catch (error) {
console.warn(`\u26A0\uFE0F Failed to create empty commit: ${error}`);
try {
const readmePath = "README.md";
const fs = await import("fs");
if (fs.existsSync(readmePath)) {
const content = fs.readFileSync(readmePath, "utf-8");
const updatedContent = `${content}
<!-- Updated by Buddy Bot -->
`;
fs.writeFileSync(readmePath, updatedContent);
await this.runCommand("git", ["add", readmePath]);
await this.runCommand("git", ["commit", "-m", "Update README for workflow-only PR"]);
console.log(`\u2705 Created README update for workflow-only PR`);
}
} catch (readmeError) {
console.error(`\u274C Failed to create any commit: ${readmeError}`);
}
}
return;
}
} else if (workflowFiles.length > 0) {
console.log(`\u2705 Including ${workflowFiles.length} workflow file(s) with elevated permissions`);
}
try {
await this.runCommand("git", ["config", "user.name", "github-actions[bot]"]);
await this.runCommand("git", ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]);
console.log("\u2705 Git identity configured for github-actions[bot]");
} catch (error) {
console.warn("\u26A0\uFE0F Failed to configure Git identity:", error);
}
await this.runCommand("git", ["fetch", "origin"]);
console.log(`\uD83D\uDD04 Resetting ${branchName} to main for clean rebase...`);
await this.runCommand("git", ["checkout", "main"]);
await this.runCommand("git", ["reset", "--hard", "HEAD"]);
await this.runCommand("git", ["clean", "-fd"]);
try {
await this.runCommand("git", ["branch", "-D", branchName]);
} catch {}
await this.runCommand("git", ["checkout", "-b", branchName]);
for (const file of files) {
const cleanPath = file.path.replace(/^\.\//, "").replace(/^\/+/, "");
if (file.type === "delete") {
try {
await this.runCommand("git", ["rm", cleanPath]);
} catch {}
} else {
const fs = await import("fs");
const path = await import("path");
const dir = path.dirname(cleanPath);
if (dir !== ".") {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(cleanPath, file.content, "utf8");
await this.runCommand("git", ["add", cleanPath]);
}
}
const status = await this.runCommand("git", ["status", "--porcelain"]);
if (status.trim()) {
await this.runCommand("git", ["commit", "-m", message]);
await this.runCommand("git", ["push", "origin", branchName, "--force"]);
console.log(`\u2705 Successfully rebased ${branchName} with fresh changes: ${message}`);
} else {
console.log(`\u2139\uFE0F No changes to commit for ${branchName}`);
}
} catch (error) {
console.error(`\u274C Failed to commit changes to ${branchName} with Git CLI:`, error);
throw error;
}
}
async commitChangesWithAPI(branchName, message, files) {
try {
const workflowFiles = files.filter((f) => f.path.includes(".github/workflows/"));
const nonWorkflowFiles = files.filter((f) => !f.path.includes(".github/workflows/"));
if (workflowFiles.length > 0 && !this.hasWorkflowPermissions) {
console.warn(`\u26A0\uFE0F Detected ${workflowFiles.length} workflow file(s). These require elevated permissions.`);
console.warn(`\u26A0\uFE0F Workflow files: ${workflowFiles.map((f) => f.path).join(", ")}`);
console.warn(`\u2139\uFE0F Workflow files will be skipped in this commit. Consider using a GitHub App with workflow permissions for workflow updates.`);
if (nonWorkflowFiles.length > 0) {
console.log(`\uD83D\uDCDD Committing ${nonWorkflowFiles.length} non-workflow files...`);
files = nonWorkflowFiles;
} else {
console.warn(`\u26A0\uFE0F All files are workflow files. No files will be committed in this PR.`);
console.warn(`\uD83D\uDCA1 To update workflow files, consider using a GitHub App with appropriate permissions.`);
return;
}
} else if (workflowFiles.length > 0) {
console.log(`\u2705 Including ${workflowFiles.length} workflow file(s) with elevated permissions`);
}
const branchRef = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/git/ref/heads/${branchName}`);
const currentSha = branchRef.object.sha;
const currentCommit = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/git/commits/${currentSha}`);
const currentTreeSha = currentCommit.tree.sha;
const tree = [];
for (const file of files) {
const cleanPath = file.path.replace(/^\.\//, "").replace(/^\/+/, "");
if (file.type === "delete") {
tree.push({
path: cleanPath,
mode: "100644",
type: "blob",
sha: null
});
} else {
const blob = await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/git/blobs`, {
content: Buffer2.from(file.content).toString("base64"),
encoding: "base64"
});
tree.push({
path: cleanPath,
mode: "100644",
type: "blob",
sha: blob.sha
});
}
}
const newTree = await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/git/trees`, {
base_tree: currentTreeSha,
tree
});
const newCommit = await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/git/commits`, {
message,
tree: newTree.sha,
parents: [currentSha],
author: {
name: "github-actions[bot]",
email: "41898282+github-actions[bot]@users.noreply.github.com"
},
committer: {
name: "github-actions[bot]",
email: "41898282+github-actions[bot]@users.noreply.github.com"
}
});
await this.apiRequest(`PATCH /repos/${this.owner}/${this.repo}/git/refs/heads/${branchName}`, {
sha: newCommit.sha
});
console.log(`\u2705 Committed changes to ${branchName}: ${message}`);
} catch (error) {
console.error(`\u274C Failed to commit changes to ${branchName}:`, error);
throw error;
}
}
async createPullRequest(options2) {
try {
return await this.createPullRequestWithCLI(options2);
} catch (cliError) {
console.warn(`\u26A0\uFE0F GitHub CLI failed, falling back to API: ${cliError}`);
return await this.createPullRequestWithAPI(options2);
}
}
async createPullRequestWithCLI(options2) {
try {
const args = [
"pr",
"create",
"--title",
options2.title,
"--body",
options2.body,
"--head",
options2.head,
"--base",
options2.base
];
if (options2.draft) {
args.push("--draft");
}
if (options2.reviewers && options2.reviewers.length > 0) {
console.log(`\uD83D\uDD0D Adding reviewers via CLI: ${options2.reviewers.join(", ")}`);
args.push("--reviewer", options2.reviewers.join(","));
}
if (options2.assignees && options2.assignees.length > 0) {
args.push("--assignee", options2.assignees.join(","));
}
const result = await this.runCommand("gh", args);
const prUrlMatch = result.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
if (!prUrlMatch) {
throw new Error("Failed to parse PR number from GitHub CLI output");
}
const prNumber = Number.parseInt(prUrlMatch[1]);
const prUrl = prUrlMatch[0];
console.log(`\u2705 Created PR #${prNumber}: ${options2.title}`);
if (options2.labels && options2.labels.length > 0) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${prNumber}/labels`, {
labels: options2.labels
});
console.log(`\u2705 Added labels to PR #${prNumber}: ${options2.labels.join(", ")}`);
} catch (labelError) {
console.warn(`\u26A0\uFE0F Failed to add labels: ${labelError}`);
for (const label of options2.labels) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${prNumber}/labels`, {
labels: [label]
});
} catch (singleLabelError) {
console.warn(`\u26A0\uFE0F Failed to add label '${label}': ${singleLabelError}`);
}
}
}
}
return {
number: prNumber,
title: options2.title,
body: options2.body,
head: options2.head,
base: options2.base,
state: "open",
url: prUrl,
createdAt: new Date,
updatedAt: new Date,
author: "github-actions[bot]",
reviewers: options2.reviewers || [],
assignees: options2.assignees || [],
labels: options2.labels || [],
draft: options2.draft || false
};
} catch (error) {
console.error(`\u274C Failed to create PR with GitHub CLI: ${options2.title}`, error);
throw error;
}
}
async createPullRequestWithAPI(options2) {
try {
const response = await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/pulls`, {
title: options2.title,
body: options2.body,
head: options2.head,
base: options2.base,
draft: options2.draft || false
});
if (options2.reviewers && options2.reviewers.length > 0) {
try {
console.log(`\uD83D\uDD0D Adding reviewers to PR #${response.number}: ${options2.reviewers.join(", ")}`);
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/pulls/${response.number}/requested_reviewers`, {
reviewers: options2.reviewers,
team_reviewers: options2.teamReviewers || []
});
console.log(`\u2705 Successfully added reviewers: ${options2.reviewers.join(", ")}`);
} catch (reviewerError) {
console.error(`\u274C Failed to add reviewers: ${reviewerError}`);
console.error(` Reviewers: ${options2.reviewers.join(", ")}`);
console.error(` Repository: ${this.owner}/${this.repo}`);
console.error(` PR: #${response.number}`);
}
}
if (options2.assignees && options2.assignees.length > 0) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${response.number}/assignees`, {
assignees: options2.assignees
});
} catch (assigneeError) {
console.warn(`\u26A0\uFE0F Failed to add assignees: ${assigneeError}`);
}
}
if (options2.labels && options2.labels.length > 0) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${response.number}/labels`, {
labels: options2.labels
});
} catch (labelError) {
console.warn(`\u26A0\uFE0F Failed to add labels: ${labelError}`);
for (const label of options2.labels) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${response.number}/labels`, {
labels: [label]
});
} catch (singleLabelError) {
console.warn(`\u26A0\uFE0F Failed to add label '${label}': ${singleLabelError}`);
}
}
}
}
console.log(`\u2705 Created PR #${response.number}: ${options2.title}`);
return {
number: response.number,
title: response.title,
body: response.body || "",
head: response.head.ref,
base: response.base.ref,
state: response.state,
url: response.html_url,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
author: response.user.login,
reviewers: options2.reviewers || [],
assignees: options2.assignees || [],
labels: options2.labels || [],
draft: response.draft
};
} catch (error) {
console.error(`\u274C Failed to create PR with API: ${options2.title}`, error);
throw error;
}
}
async runCommand(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: "pipe",
env: {
...process2.env,
GITHUB_TOKEN: this.token,
GH_TOKEN: this.token
}
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`Command failed with code ${code}: ${stderr}`));
}
});
child.on("error", (error) => {
reject(error);
});
});
}
async getPullRequests(state = "open") {
try {
const response = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/pulls?state=${state}`);
return response.map((pr) => ({
number: pr.number,
title: pr.title,
body: pr.body || "",
head: pr.head.ref,
base: pr.base.ref,
state: pr.state,
url: pr.html_url,
createdAt: new Date(pr.created_at),
updatedAt: new Date(pr.updated_at),
mergedAt: pr.merged_at ? new Date(pr.merged_at) : undefined,
author: pr.user.login,
reviewers: pr.requested_reviewers?.map((r) => r.login) || [],
assignees: pr.assignees?.map((a) => a.login) || [],
labels: pr.labels?.map((l) => l.name) || [],
draft: pr.draft
}));
} catch (error) {
console.error(`\u274C Failed to get PRs:`, error);
throw error;
}
}
async updatePullRequest(prNumber, options2) {
try {
const updateData = {};
if (options2.title)
updateData.title = options2.title;
if (options2.body)
updateData.body = options2.body;
if (options2.base)
updateData.base = options2.base;
if (options2.draft !== undefined)
updateData.draft = options2.draft;
const response = await this.apiRequest(`PATCH /repos/${this.owner}/${this.repo}/pulls/${prNumber}`, updateData);
if (options2.labels && options2.labels.length > 0) {
try {
await this.apiRequest(`PUT /repos/${this.owner}/${this.repo}/issues/${prNumber}/labels`, {
labels: options2.labels
});
console.log(`\u2705 Updated labels for PR #${prNumber}: ${options2.labels.join(", ")}`);
} catch (labelError) {
console.warn(`\u26A0\uFE0F Failed to update labels for PR #${prNumber}: ${labelError}`);
for (const label of options2.labels) {
try {
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues/${prNumber}/labels`, {
labels: [label]
});
} catch (singleLabelError) {
console.warn(`\u26A0\uFE0F Failed to add label '${label}' to PR #${prNumber}: ${singleLabelError}`);
}
}
}
}
if (options2.reviewers && options2.reviewers.length > 0) {
try {
console.log(`\uD83D\uDD0D Adding reviewers to existing PR #${prNumber}: ${options2.reviewers.join(", ")}`);
await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/pulls/${prNumber}/requested_reviewers`, {
reviewers: options2.reviewers,
team_reviewers: options2.teamReviewers || []
});
console.log(`\u2705 Updated reviewers for PR #${prNumber}: ${options2.reviewers.join(", ")}`);
} catch (reviewerError) {
console.error(`\u274C Failed to update reviewers for PR #${prNumber}: ${reviewerError}`);
console.error(` Reviewers: ${options2.reviewers.join(", ")}`);
console.error(` Repository: ${this.owner}/${this.repo}`);
}
}
if (options2.assignees && options2.assignees.length > 0) {
try {
await this.runCommand("gh", ["issue", "edit", prNumber.toString(), "--add-assignee", options2.assignees.join(",")]);
console.log(`\u2705 Updated assignees for PR #${prNumber}: ${options2.assignees.join(", ")}`);
} catch (assigneeError) {
console.warn(`\u26A0\uFE0F Failed to update assignees for PR #${prNumber}: ${assigneeError}`);
}
}
console.log(`\u2705 Updated PR #${prNumber}`);
return {
number: response.number,
title: response.title,
body: response.body || "",
head: response.head.ref,
base: response.base.ref,
state: response.state,
url: response.html_url,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
author: response.user.login,
reviewers: [],
assignees: [],
labels: options2.labels || [],
draft: response.draft
};
} catch (error) {
console.error(`\u274C Failed to update PR #${prNumber}:`, error);
throw error;
}
}
async closePullRequest(prNumber) {
try {
const prDetails = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/pulls/${prNumber}`);
const branchName = prDetails.head.ref;
await this.apiRequest(`PATCH /repos/${this.owner}/${this.repo}/pulls/${prNumber}`, {
state: "closed"
});
console.log(`\u2705 Closed PR #${prNumber}`);
try {
await this.deleteBranch(branchName);
console.log(`\uD83E\uDDF9 Cleaned up branch ${branchName} after close`);
} catch (cleanupError) {
console.warn(`\u26A0\uFE0F Failed to clean up branch ${branchName}:`, cleanupError);
}
} catch (error) {
console.error(`\u274C Failed to close PR #${prNumber}:`, error);
throw error;
}
}
async mergePullRequest(prNumber, strategy = "merge") {
try {
const mergeMethod = strategy === "rebase" ? "rebase" : strategy === "squash" ? "squash" : "merge";
const prDetails = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/pulls/${prNumber}`);
const branchName = prDetails.head.ref;
await this.apiRequest(`PUT /repos/${this.owner}/${this.repo}/pulls/${prNumber}/merge`, {
merge_method: mergeMethod
});
console.log(`\u2705 Merged PR #${prNumber} using ${strategy}`);
try {
await this.deleteBranch(branchName);
console.log(`\uD83E\uDDF9 Cleaned up branch ${branchName} after merge`);
} catch (cleanupError) {
console.warn(`\u26A0\uFE0F Failed to clean up branch ${branchName}:`, cleanupError);
}
} catch (error) {
console.error(`\u274C Failed to merge PR #${prNumber}:`, error);
throw error;
}
}
async deleteBranch(branchName) {
try {
try {
await this.runCommand("gh", ["api", "repos", this.owner, this.repo, "git/refs/heads", branchName, "-X", "DELETE"]);
console.log(`\u2705 Deleted branch ${branchName} via CLI`);
return;
} catch (cliError) {
console.log(`\u26A0\uFE0F CLI branch deletion failed, trying API: ${cliError}`);
}
await this.apiRequest(`DELETE /repos/${this.owner}/${this.repo}/git/refs/heads/${branchName}`);
console.log(`\u2705 Deleted branch ${branchName} via API`);
} catch (error) {
console.warn(`\u26A0\uFE0F Failed to delete branch ${branchName}:`, error);
}
}
async apiRequest(endpoint, data) {
const [method, path] = endpoint.split(" ");
const url = `${this.apiUrl}${path}`;
const options2 = {
method,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
"User-Agent": "buddy-bot"
}
};
if (data && (method === "POST" || method === "PATCH" || method === "PUT")) {
options2.body = JSON.stringify(data);
}
const response = await fetch(url, options2);
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`GitHub API error: ${response.status} ${response.statusText}
${errorBody}`);
}
if (response.headers.get("content-type")?.includes("application/json")) {
return response.json();
}
return response.text();
}
async createIssue(options2) {
try {
const response = await this.apiRequest(`POST /repos/${this.owner}/${this.repo}/issues`, {
title: options2.title,
body: options2.body,
assignees: options2.assignees || [],
labels: options2.labels || [],
milestone: options2.milestone
});
console.log(`\u2705 Created issue #${response.number}: ${options2.title}`);
return {
number: response.number,
title: response.title,
body: response.body,
state: response.state,
url: response.html_url,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
closedAt: response.closed_at ? new Date(response.closed_at) : undefined,
author: response.user.login,
assignees: response.assignees?.map((a) => a.login) || [],
labels: response.labels?.map((l) => typeof l === "string" ? l : l.name) || [],
pinned: false
};
} catch (error) {
console.error(`\u274C Failed to create issue: ${options2.title}`, error);
throw error;
}
}
async getIssues(state = "open") {
try {
const response = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/issues?state=${state}&sort=updated&direction=desc`);
return response.filter((issue) => !issue.pull_request).map((issue) => ({
number: issue.number,
title: issue.title,
body: issue.body || "",
state: issue.state,
url: issue.html_url,
createdAt: new Date(issue.created_at),
updatedAt: new Date(issue.updated_at),
closedAt: issue.closed_at ? new Date(issue.closed_at) : undefined,
author: issue.user.login,
assignees: issue.assignees?.map((a) => a.login) || [],
labels: issue.labels?.map((l) => typeof l === "string" ? l : l.name) || [],
pinned: false
}));
} catch (error) {
console.error("\u274C Failed to get issues:", error);
throw error;
}
}
async updateIssue(issueNumber, options2) {
try {
const updateData = {};
if (options2.title !== undefined)
updateData.title = options2.title;
if (options2.body !== undefined)
updateData.body = options2.body;
if (options2.assignees !== undefined)
updateData.assignees = options2.assignees;
if (options2.labels !== undefined)
updateData.labels = options2.labels;
if (options2.milestone !== undefined)
updateData.milestone = options2.milestone;
const response = await this.apiRequest(`PATCH /repos/${this.owner}/${this.repo}/issues/${issueNumber}`, updateData);
console.log(`\u2705 Updated issue #${issueNumber}: ${response.title}`);
return {
number: response.number,
title: response.title,
body: response.body,
state: response.state,
url: response.html_url,
createdAt: new Date(response.created_at),
updatedAt: new Date(response.updated_at),
closedAt: response.closed_at ? new Date(response.closed_at) : undefined,
author: response.user.login,
assignees: response.assignees?.map((a) => a.login) || [],
labels: response.labels?.map((l) => typeof l === "string" ? l : l.name) || [],
pinned: false
};
} catch (error) {
console.error(`\u274C Failed to update issue #${issueNumber}:`, error);
throw error;
}
}
async closeIssue(issueNumber) {
try {
await this.apiRequest(`PATCH /repos/${this.owner}/${this.repo}/issues/${issueNumber}`, {
state: "closed"
});
console.log(`\u2705 Closed issue #${issueNumber}`);
} catch (error) {
console.error(`\u274C Failed to close issue #${issueNumber}:`, error);
throw error;
}
}
async unpinIssue(issueNumber) {
try {
await this.apiRequest(`DELETE /repos/${this.owner}/${this.repo}/issues/${issueNumber}/pin`, undefined);
} catch (error) {
console.log(`\u26A0\uFE0F Failed to unpin issue #${issueNumber}:`, error);
}
}
}
var init_github_provider = () => {};
// src/services/release-notes-fetcher.ts
class ReleaseNotesFetcher {
userAgent = "Buddy-Bot/1.0.0 (https://github.com/stacksjs/buddy)";
async fetchPackageInfo(packageName, currentVersion, newVersion) {
try {
const packageInfo = await this.fetchNpmPackageInfo(packageName);
let releaseNotes = [];
let changelog = [];
let compareUrl;
if (packageInfo.repository?.url) {
const githubInfo = this.parseGitHubUrl(packageInfo.repository.url);
if (githubInfo) {
releaseNotes = await this.fetchGitHubReleases(githubInfo.owner, githubInfo.repo, currentVersion, newVersion);
compareUrl = this.generateCompareUrl(githubInfo.owner, githubInfo.repo, currentVersion, newVersion);
changelog = await this.fetchChangelog(githubInfo.owner, githubInfo.repo);
}
}
return {
packageInfo,
releaseNotes,
changelog,
compareUrl
};
} catch (error) {
console.error(`\u274C Failed to fetch package info for ${packageName}:`, error);
return {
packageInfo: {
name: packageName,
description: `Package ${packageName} - see npm for details`,
repository: { type: "git", url: `https://github.com/search?q=${encodeURIComponent(packageName)}&type=repositories` }
},
releaseNotes: [],
changelog: []
};
}
}
async fetchNpmPackageInfo(packageName) {
try {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`, {
headers: { "User-Agent": this.userAgent }
});
if (!response.ok) {
throw new Error(`NPM registry responded with ${response.status}`);
}
const data = await response.json();
const latest = data["dist-tags"]?.latest;
let weeklyDownloads;
try {
const downloadsResponse = await fetch(`https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(packageName)}`, { headers: { "User-Agent": this.userAgent } });
if (downloadsResponse.ok) {
const downloadsData = await downloadsResponse.json();
weeklyDownloads = downloadsData.downloads;
}
} catch {}
return {
name: packageName,
description: data.description,
homepage: data.homepage,
repository: data.repository,
license: data.license,
author: data.author,
keywords: data.keywords,
weeklyDownloads,
lastPublish: data.time?.[latest],
maintainers: data.maintainers
};
} catch (error) {
console.warn(`\u26A0\uFE0F Failed to fetch npm info for ${packageName}:`, error);
return {
name: packageName,
description: `NPM package ${packageName}`,
homepage: `https://www.npmjs.com/package/${encodeURIComponent(packageName)}`
};
}
}
parseGitHubUrl(repositoryUrl) {
try {
const cleanUrl = repositoryUrl.replace(/^git\+/, "").replace(/\.git$/, "").replace(/^git:\/\//, "https://").replace(/^ssh:\/\/git@/, "https://").replace(/^git@github\.com:/, "https://github.com/");
const url = new URL(cleanUrl);
if (url.hostname !== "github.com") {
return null;
}
const pathParts = url.pathname.split("/").filter(Boolean);
if (pathParts.length >= 2) {
return {
owner: pathParts[0],
repo: pathParts[1]
};
}
return null;
} catch {
return null;
}
}
async fetchGitHubReleases(owner, repo, currentVersion, newVersion) {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases?per_page=50`, { headers: { "User-Agent": this.userAgent } });
if (!response.ok) {
return [];
}
const releases = await response.json();
return releases.filter((release) => {
const releaseVersion = release.tag_name.replace(/^v/, "");
return this.isVersionBetween(releaseVersion, currentVersion, newVersion);
}).map((release) => ({
version: release.tag_name,
date: release.published_at,
title: release.name || release.tag_name,
body: release.body || "",
htmlUrl: release.html_url,
compareUrl: this.generateCompareUrl(owner, repo, currentVersion, release.tag_name),
author: release.author?.login,
isPrerelease: release.prerelease,
assets: release.assets?.map((asset) => ({
name: asset.name,
downloadUrl: asset.browser_download_url,
size: asset.size,
contentType: asset.content_type
})) || []
}));
} catch (error) {
console.warn(`Failed to fetch GitHub releases for ${owner}/${repo}:`, error);
return [];
}
}
async fetchChangelog(owner, repo) {
const changelogFiles = ["CHANGELOG.md", "CHANGELOG.rst", "HISTORY.md", "RELEASES.md"];
for (const filename of changelogFiles) {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${filename}`, { headers: { "User-Agent": this.userAgent } });
if (response.ok) {
const data = await response.json();
if (data.content) {
const content = atob(data.content);
return this.parseChangelog(content);
}
}
} catch {}
}
return [];
}
parseChangelog(content) {
const entries = [];
const lines = content.split(`
`);
let currentEntry = null;
let currentSection = null;
for (const line of lines) {
const versionMatch = line.match(/^##\s*\[?([^\]]+)\]?\s*-?\s*(.*)/);
if (versionMatch) {
if (currentEntry) {
entries.push(currentEntry);
}
currentEntry = {
version: versionMatch[1],
date: versionMatch[2].trim(),
changes: {}
};
currentSection = null;
continue;
}
const sectionMatch = line.match(/^###\s*(.+)/);
if (sectionMatch && currentEntry) {
currentSection = sectionMatch[1].toLowerCase();
continue;
}
const itemMatch = line.match(/^[-*]\s*(.+)/);
if (itemMatch && currentEntry && currentSection) {
if (!currentEntry.changes)
currentEntry.changes = {};
const sectionKey = currentSection;
if (!currentEntry.changes[sectionKey]) {
currentEntry.changes[sectionKey] = [];
}
currentEntry.changes[sectionKey].push(itemMatch[1]);
}
}
if (currentEntry) {
entries.push(currentEntry);
}
return entries.slice(0, 10);
}
generateCompareUrl(owner, repo, fromVersion, toVersion) {
const cleanFrom = fromVersion.startsWith("v") ? fromVersion : `v${fromVersion}`;
const cleanTo = toVersion.startsWith("v") ? toVersion : `v${toVersion}`;
return `https://github.com/${owner}/${repo}/compare/${cleanFrom}...${cleanTo}`;
}
isVersionBetween(version, current, target) {
const cleanVersion = version.replace(/^v/, "");
const cleanCurrent = current.replace(/^v/, "");
const cleanTarget = target.replace(/^v/, "");
return cleanVersion === cleanTarget || cleanVersion > cleanCurrent;
}
generatePackageBadges(packageInfo, currentVersion, newVersion) {
const packageName = encodeURIComponent(packageInfo.name);
const normalizedCurrent = this.normalizeVersionForBadges(currentVersion);
const normalizedNew = this.normalizeVersionForBadges(newVersion);
const encodedCurrent = encodeURIComponent(normalizedCurrent);
const encodedNew = encodeURIComponent(normalizedNew);
return {
age: `[](https://docs.renovatebot.com/merge-confidence/)`,
adoption: `[](https://docs.renovatebot.com/merge-confidence/)`,
passing: `[](https://docs.renovatebot.com/merge-confidence/)`,
confidence: `[](https://docs.renovatebot.com/merge-confidence/)`
};
}
async fetchComposerPackageInfo(packageName) {
try {
const response = await fetch(`https://packagist.org/packages/${encodeURIComponent(packageName)}.json`, {
headers: { "User-Agent": this.userAgent }
});
if (!response.ok) {
throw new Error(`Packagist responded with ${response.status}`);
}
const data = await response.json();
const packageData = data.package;
if (!packageData) {
return { name: packageName };
}
const versions = Object.keys(packageData.versions || {});
const latestVersion = versions.find((v) => !v.includes("dev") && !v.includes("alpha") && !v.includes("beta")) || versions[0];
const versionData = packageData.versions[latestVersion] || {};
return {
name: packageData.name,
description: versionData.description,
homepage: versionData.homepage,
repository: versionData.source ? { type: "git", url: versionData.source.url } : undefined,
license: versionData.license?.[0] || versionData.license,
author: versionData.authors?.[0],
keywords: versionData.keywords,
lastPublish: versionData.time
};
} catch (erro