fleet/tools/fleet-slackbot/github-client.js
2026-03-27 12:07:27 -05:00

302 lines
8.2 KiB
JavaScript

const { Octokit } = require("@octokit/rest");
class GitHubClient {
constructor({ token, repo, baseBranch = "main", gitopsBasePath = "it-and-security" }) {
this.octokit = new Octokit({ auth: token });
const [owner, repoName] = repo.split("/");
this.owner = owner;
this.repo = repoName;
this.baseBranch = baseBranch;
this.gitopsBasePath = gitopsBasePath;
}
/**
* Fetch the content of a single file from the base branch.
* Returns the file content as a string, or null if the file doesn't exist.
*/
async getFileContent(filePath) {
try {
const { data } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path: filePath,
ref: this.baseBranch,
});
if (!data.content) return null;
return Buffer.from(data.content, "base64").toString("utf-8");
} catch (err) {
if (err.status === 404) return null;
throw new Error(`Failed to fetch ${filePath}: ${err.message}`);
}
}
/**
* Get all file paths under a prefix using the Git tree API.
* Returns an array of paths relative to the gitops base path.
*/
async getRepoTreePaths() {
const { data: refData } = await this.octokit.git.getRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${this.baseBranch}`,
});
// Fetch the root tree (non-recursive) to find the gitops subtree SHA
const { data: rootTree } = await this.octokit.git.getTree({
owner: this.owner,
repo: this.repo,
tree_sha: refData.object.sha,
});
const subtreeEntry = rootTree.tree.find(
(item) => item.type === "tree" && item.path === this.gitopsBasePath
);
if (!subtreeEntry) {
throw new Error(`GitOps directory "${this.gitopsBasePath}" not found in repo`);
}
// Fetch only the gitops subtree recursively
const { data: subtree } = await this.octokit.git.getTree({
owner: this.owner,
repo: this.repo,
tree_sha: subtreeEntry.sha,
recursive: "true",
});
if (subtree.truncated) {
throw new Error(`GitOps directory tree was truncated by GitHub API; too many files`);
}
return subtree.tree
.filter((item) => item.type === "blob")
.map((item) => item.path);
}
/**
* Create a new branch from the head of the base branch.
*/
async createBranch(branchName) {
const { data: refData } = await this.octokit.git.getRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${this.baseBranch}`,
});
await this.octokit.git.createRef({
owner: this.owner,
repo: this.repo,
ref: `refs/heads/${branchName}`,
sha: refData.object.sha,
});
}
/**
* Commit one or more file changes to a branch as a single commit.
* Uses the Git Data API for multi-file atomic commits.
*
* @param {string} branchName
* @param {Array<{path: string, content: string}>} changes
* @param {string} commitMessage
* @returns {string} The new commit SHA
*/
async commitChanges(branchName, changes, commitMessage) {
// Get the current commit on the branch
const { data: refData } = await this.octokit.git.getRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${branchName}`,
});
const baseCommitSha = refData.object.sha;
const { data: baseCommit } = await this.octokit.git.getCommit({
owner: this.owner,
repo: this.repo,
commit_sha: baseCommitSha,
});
// Create blobs for each changed file
const treeItems = [];
for (const change of changes) {
const { data: blob } = await this.octokit.git.createBlob({
owner: this.owner,
repo: this.repo,
content: Buffer.from(change.content).toString("base64"),
encoding: "base64",
});
treeItems.push({
path: change.path,
mode: "100644",
type: "blob",
sha: blob.sha,
});
}
// Create a new tree
const { data: newTree } = await this.octokit.git.createTree({
owner: this.owner,
repo: this.repo,
base_tree: baseCommit.tree.sha,
tree: treeItems,
});
// Create the commit
const { data: newCommit } = await this.octokit.git.createCommit({
owner: this.owner,
repo: this.repo,
message: commitMessage,
tree: newTree.sha,
parents: [baseCommitSha],
});
// Update the branch ref
await this.octokit.git.updateRef({
owner: this.owner,
repo: this.repo,
ref: `heads/${branchName}`,
sha: newCommit.sha,
});
return newCommit.sha;
}
async getFileContentFromRef(filePath, ref) {
try {
const { data } = await this.octokit.repos.getContent({
owner: this.owner,
repo: this.repo,
path: filePath,
ref,
});
if (!data.content) return null; // large files have no inline content
return Buffer.from(data.content, "base64").toString("utf-8");
} catch (err) {
if (err.status === 404) return null;
throw new Error(`Failed to fetch ${filePath} at ref ${ref}: ${err.message}`);
}
}
async getPullRequest(pullNumber) {
const { data } = await this.octokit.pulls.get({
owner: this.owner,
repo: this.repo,
pull_number: pullNumber,
});
return {
number: data.number,
title: data.title,
body: data.body,
headBranch: data.head.ref,
baseBranch: data.base.ref,
state: data.state,
url: data.html_url,
authorAssociation: data.author_association,
};
}
async getPullRequestFiles(pullNumber) {
const data = await this.octokit.paginate(this.octokit.pulls.listFiles, {
owner: this.owner,
repo: this.repo,
pull_number: pullNumber,
per_page: 100,
});
return data.map((f) => ({
filename: f.filename,
status: f.status,
}));
}
async addPullRequestComment(pullNumber, body) {
const { data } = await this.octokit.issues.createComment({
owner: this.owner,
repo: this.repo,
issue_number: pullNumber,
body,
});
return { id: data.id, url: data.html_url };
}
async addIssueCommentReaction(commentId, reaction) {
await this.octokit.reactions.createForIssueComment({
owner: this.owner,
repo: this.repo,
comment_id: commentId,
content: reaction,
});
}
async addReviewCommentReaction(commentId, reaction) {
await this.octokit.reactions.createForPullRequestReviewComment({
owner: this.owner,
repo: this.repo,
comment_id: commentId,
content: reaction,
});
}
async getCommit(sha) {
const { data } = await this.octokit.git.getCommit({
owner: this.owner,
repo: this.repo,
commit_sha: sha,
});
return {
sha: data.sha,
message: data.message,
authorName: data.author.name,
authorEmail: data.author.email,
parentSha: data.parents?.[0]?.sha || null,
};
}
async getFailedJobLogs(headSha, checkName) {
// Find workflow runs for this commit
const { data: runs } = await this.octokit.actions.listWorkflowRunsForRepo({
owner: this.owner,
repo: this.repo,
head_sha: headSha,
});
for (const run of runs.workflow_runs) {
// List jobs for this run
const { data: jobsData } = await this.octokit.actions.listJobsForWorkflowRun({
owner: this.owner,
repo: this.repo,
run_id: run.id,
});
const failedJob = jobsData.jobs.find(
(j) => j.name === checkName && j.conclusion === "failure"
);
if (!failedJob) continue;
// Fetch the logs
const { data: logs } = await this.octokit.actions.downloadJobLogsForWorkflowRun({
owner: this.owner,
repo: this.repo,
job_id: failedJob.id,
});
return logs;
}
return null;
}
/**
* Open a pull request from branchName into baseBranch.
* @returns {{ url: string, number: number }}
*/
async createPullRequest(branchName, title, body, { draft = false } = {}) {
const { data: pr } = await this.octokit.pulls.create({
owner: this.owner,
repo: this.repo,
title,
body,
head: branchName,
base: this.baseBranch,
draft,
});
return { url: pr.html_url, number: pr.number };
}
}
module.exports = GitHubClient;