Skip to content
8 changes: 8 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ LOGIN_USER=username
LOGIN_PASSWORD=strongpassword
DEFAULT_GITHUB_ORG=Git-Commit-Show
ONE_CLA_PER_ORG=true
DOCS_AGENT_API_URL=http://localhost:3001
DOCS_AGENT_API_KEY=random
DOCS_AGENT_API_REVIEW_URL=#full url to review endpoint of docs agent e.g. http://localhost:3001/review, overrides `DOCS_AGENT_API_URL` base url config
DOCS_AGENT_API_PRIORITIZE_URL=#full url
DOCS_AGENT_API_EDIT_URL=
DOCS_AGENT_API_LINK_URL=
DOCS_AGENT_API_AUDIT_URL=
DOCS_REPOS= #repos separated by comma
SLACK_DEFAULT_MESSAGE_CHANNEL_WEBHOOK_URL=https://hooks.slack.com/services/T05487DUMMY/B59DUMMY1U/htdsEdsdf7CNeDUMMY
GITHUB_BOT_USERS=dependabot[bot],devops-github-rudderstack
GITHUB_ORG_MEMBERS=
Expand Down
47 changes: 47 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isMessageAfterMergeRequired,
getWebsiteAddress,
} from "./src/helpers.js";
import DocsAgent from "./src/services/DocsAgent.js";

try {
const packageJson = await import("./package.json", {
Expand Down Expand Up @@ -112,6 +113,49 @@ GitHub.app.webhooks.on("pull_request.labeled", async ({ octokit, payload }) => {
const message = `:mag: <${pull_request.html_url}|#${pull_request.number}: ${pull_request.title}> by ${pull_request.user?.login}`;
await Slack.sendMessage(message);
}
if(label.name?.toLowerCase() === "docs review") {
console.log("Processing docs review for this PR");
try {
const DOCS_REPOS = process.env.DOCS_REPOS?.split(",")?.map((item) => item?.trim()) || [];
if(DOCS_REPOS?.length > 0 && !DOCS_REPOS.includes(repository.name)) {
throw new Error("Docs agent review is not available for this repository");
}
if(!DocsAgent.isConfigured()) {
throw new Error("Docs agent service is not configured");
}
console.log("Going to analyze the docs pages in this PR");
// Get PR changes
const prChanges = await GitHub.getPRChanges(
repository.owner.login,
repository.name,
pull_request.number
);
const docsFiles = prChanges.files.filter((file) => file.filename.endsWith(".md"));
console.log(`Found ${docsFiles.length} docs files being changed`);
if(docsFiles.length === 0) {
throw new Error("No docs files being changed in this PR");
}
for(const file of docsFiles) {
const content = file.content;
// Convert relative file path to full remote github file path using PR head commit SHA https://raw.githubusercontent.com/gitcommitshow/rudder-github-app/e14433e76d74dc680b8cf9102d39f31970e8b794/.codesandbox/tasks.json
const relativePath = file.filename;
const fullPath = `https://raw.githubusercontent.com/${repository.owner.login}/${repository.name}/${prChanges.headCommit}/${relativePath}`;
const webhookUrl = getWebsiteAddress() + "/api/comment";
DocsAgent.reviewDocs(content, fullPath, {
webhookUrl: webhookUrl,
webhookMetadata: {
issue_number: pull_request.number,
repo: repository.name,
owner: repository.owner.login,
},
});
console.log(`Successfully started docs review for ${fullPath}, results will be handled by webhook: ${webhookUrl}`);
}
console.log(`Successfully started all necessary docs reviews for PR ${repository.name} #${pull_request.number}`);
} catch (error) {
console.error(error);
}
}
} catch (error) {
if (error.response) {
console.error(
Expand Down Expand Up @@ -216,6 +260,9 @@ const server = http
case "POST /api/webhook":
githubWebhookRequestHandler(req, res);
break;
case "POST /api/comment":
routes.addCommentToGitHubIssueOrPR(req, res);
break;
case "GET /":
routes.home(req, res);
break;
Expand Down
34 changes: 34 additions & 0 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,40 @@ export const routes = {
res.writeHead(200, { "Content-Type": "text/html" });
res.write("Cache cleared");
},
async addCommentToGitHubIssueOrPR(req, res) {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString(); // convert Buffer to string
});

req.on("end", async () => {
let bodyJson = null;
try {
bodyJson = JSON.parse(body) || {};
} catch (err) {
res.writeHead(400);
return res.end("Please add owner, repo, issue_number and result parameters in the request body in order to add a comment to a GitHub issue or PR");
}
const owner = sanitizeInput(bodyJson.owner);
const repo = sanitizeInput(bodyJson.repo);
const issue_number = bodyJson.issue_number;
const result = bodyJson.result;
if (!owner || !repo || !issue_number || !result) {
res.writeHead(400);
return res.end("Please add owner, repo, issue_number and result parameters in the request body in order to add a comment to a GitHub issue or PR");
}
try {
console.log("Adding comment to ", owner, repo, "issue/PR #",issue_number);
await GitHub.addCommentToIssueOrPR(owner, repo, issue_number, result);
res.writeHead(200);
res.write("Comment added to GitHub issue or PR");
return res.end();
} catch (err) {
res.writeHead(500);
return res.end("Failed to add comment to GitHub issue or PR");
}
});
},
// ${!Array.isArray(prs) || prs?.length < 1 ? "No contributions found! (Might be an access issue)" : prs?.map(pr => `<li><a href="${pr?.user?.html_url}">${pr?.user?.login}</a> contributed a PR - <a href="${pr?.html_url}" target="_blank">${pr?.title}</a> [${pr?.labels?.map(label => label?.name).join('] [')}] <small>updated ${timeAgo(pr?.updated_at)}</small></li>`).join('')}
default(req, res) {
res.writeHead(404);
Expand Down
155 changes: 155 additions & 0 deletions src/services/DocsAgent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Service for interacting with external APIs to get next actions
*/
export class DocsAgent {
constructor() {
this.apiUrl = process.env.DOCS_AGENT_API_URL;
this.apiKey = process.env.DOCS_AGENT_API_KEY;
this.reviewDocsApiUrl = process.env.DOCS_AGENT_API_REVIEW_URL;
this.auditDocsApiUrl = process.env.DOCS_AGENT_API_AUDIT_URL;
this.prioritizeDocsApiUrl = process.env.DOCS_AGENT_API_PRIORITIZE_URL;
this.editDocsApiUrl = process.env.DOCS_AGENT_API_EDIT_URL;
this.linkDocsApiUrl = process.env.DOCS_AGENT_API_LINK_URL;
this.timeout = parseInt(process.env.DOCS_AGENT_API_TIMEOUT) || 350000; // 5+ minutes default
}

/**
* For comprehensiveness and standardization of the docs
* @param {*} content
* @param {*} filepath - complete remote file path
* @returns {Promise<string>} - Comment text for the PR
*/
async reviewDocs(content, filepath, webhookInfoForResults) {
return this.makeAPICall(this.reviewDocsApiUrl || "/review", {
content,
filepath,
webhookUrl: webhookInfoForResults?.webhookUrl,
webhookMetadata: webhookInfoForResults?.webhookMetadata,
});
}

/**
* For technical accuracy of the docs
* @param {*} content
* @param {*} filepath - complete remote file path
* @returns {Promise<string>} - Comment text for the PR
*/
async auditDocs(content, filepath, webhookInfoForResults) {
return this.makeAPICall(this.auditDocsApiUrl || "/audit", {
content,
filepath,
webhookUrl: webhookInfoForResults?.webhookUrl,
webhookMetadata: webhookInfoForResults?.webhookMetadata,
});
}

/**
* Get next actions from external API
* @param {Object} changes - Formatted PR changes
* @returns {Promise<string>} - Comment text for the PR
*/
async getAffectedDocsPages(changes, webhookInfoForResults) {
throw new Error("Not implemented");
if (!this.apiUrl || !this.apiKey) {
throw new Error("External API configuration missing. Please set EXTERNAL_API_URL and EXTERNAL_API_KEY environment variables.");
}

try {
const response = await this.makeAPICall("/getAffectedDocsPages", changes, webhookInfoForResults);
return this.validateResponse(response);
} catch (error) {
console.error("External API call failed:", error);
throw new Error(`Failed to get next actions: ${error.message}`);
}
}

/**
* Make the actual API call to the docs agent api
* @param {Object} requestBody - the request body as JSON
* @returns {Promise<Object>} - API response
*/
async makeAPICall(endpoint, requestBody = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);

try {
const apiUrl = (endpoint && endpoint.startsWith("/")) ? (this.apiUrl + endpoint) : endpoint;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'User-Agent': 'rudder-github-app/1.0',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new Error(`API request failed with status ${response.status}: ${response.statusText}`);
}

let data;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
data = await response.json();
} else {
// Try to parse as JSON, but fallback to text if not possible
const text = await response.text();
try {
data = JSON.parse(text);
} catch (e) {
data = text;
}
}
return data;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error('API request timed out');
}
throw error;
}
}

/**
* Validate and extract comment from API response
* @param {Object} response - API response
* @returns {string} - Comment text
*/
validateResponse(response = {}) {
if (!response) {
throw new Error("Empty response from external API");
}

// Expected response format: { comment: "..." }
if (response.comment && typeof response.comment === 'string') {
return response.comment;
}

// Fallback: if response is a string, use it directly
if (typeof response === 'string') {
return response;
}

// Fallback: if response has a message field
if (response.message && typeof response.message === 'string') {
return response.message;
}

throw new Error("Invalid response format from external API. Expected 'comment' field.");
}

/**
* Check if the service is properly configured
* @returns {boolean} - True if configured
*/
isConfigured() {
console.log("Checking if DocsAgent is configured", this.apiUrl, this.apiKey);
return !!(this.apiUrl && this.apiKey);
}
}

export default new DocsAgent();
25 changes: 23 additions & 2 deletions src/services/GitHub.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,15 @@ class GitHub {
* @returns
*/
async getOctokitForOrg(org) {
if(typeof org !== "string") {
throw new Error("Unexpected org type passed to getOctokitForOrg: " + typeof org);
}
if (!this.app) {
throw new Error("GitHub App is not iniitalized or authenticated");
}
// Find the installation for the organization
for await (const { installation } of this.app?.eachInstallation?.iterator()) {
if (installation.account.login.toLowerCase() === org.toLowerCase()) {
if (installation.account.login.toLowerCase() === org?.toLowerCase()) {
// Create an authenticated client for this installation
const octokit = await this.app.getInstallationOctokit(installation.id);
return octokit;
Expand Down Expand Up @@ -777,7 +780,25 @@ async isAllowedToWriteToTheRepo(octokit, username, owner, repo) {
}
}


/**
* Add a comment to a GitHub issue or PR
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {number} issue_number - Issue number
* @param {string} result - Comment body
*/
async addCommentToIssueOrPR(owner, repo, issue_number, result) {
if(!owner || !repo || !issue_number || !result) {
throw new Error("Please add owner, repo, issue_number and result parameters in order to add a comment to a GitHub issue or PR");
}
const octokit = await this.getOctokitForOrg(owner);
await octokit.rest.issues.createComment({
owner,
repo,
issue_number,
body: result,
});
}
}

export default new GitHub();
35 changes: 35 additions & 0 deletions test/e2e/api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* E2E tests for the cla related routes.
*/
import { expect, use } from 'chai';
import chaiHttp from 'chai-http';
import { describe, it } from 'mocha';

const chai = use(chaiHttp);
const SITE_URL = 'http://localhost:' + (process.env.PORT || 3000);

describe('CLA Routes', function () {
this.timeout(40000);
let agent;
before(function () {
agent = chai.request.agent(SITE_URL);
});

after(function () {
agent.close();
});

describe('POST /api/comment', function () {
it('should return the comment added to GitHub issue or PR', async function () {
const res = await agent.post('/api/comment').send({
owner: 'Git-Commit-Show',
repo: 'gcs-cli',
issue_number: 7,
result: 'Hello, world!',
});
expect(res).to.have.status(200);
expect(res.text).to.include('Comment added to GitHub issue or PR');
});
});

});
Loading