diff --git a/.env.sample b/.env.sample index e1d19a1..fcd9939 100644 --- a/.env.sample +++ b/.env.sample @@ -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=docs_agent_api_base_url +DOCS_AGENT_API_KEY=docs_agent_api_key_here +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= diff --git a/README.md b/README.md index 95c6888..60f6db5 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,23 @@ A Node.js server for GitHub app to assist external contributors and save maintai - [x] When an external contributor (not the internal team) raises a PR, post a comment to sign CLA and label PR `Pending CLA` - [x] On signing CLA, remove `Pending CLA` label from all the PRs of that user. Never ask that user to sign the CLA on any of our repo in future - [x] On `rudder-transformer` PR merge, post a comment to raise PR in `integrations-config` -- [ ] On `integrations-config` PR merge, psot a comment to join Slack's product-releases channel to get notified when that integration goes live +- [ ] On `integrations-config` PR merge, post a comment to join Slack's product-releases channel to get notified when that integration goes live - [ ] On `integrations-config` PR merge, post a comment to raise PR in `rudder-docs` - [x] List of open PRs by external contributors +- [x] Notify on Slack when `product review` label is added to a PR +- [ ] Analyze merged PRs and suggest next actions +- [x] Analyze docs pages using AI on PR labelled with `docs review` + +## Features + +### Next Actions Feature + +The Next Actions feature automatically analyzes merged pull requests from external contributors and suggests next actions based on the code changes. Here's how it works: + +1. **Triggers**: Listens to `pull_request.closed` events and checks if the PR was merged +2. **Analysis**: Extracts production code changes (excludes test files) +3. **External API**: Sends changes to services such as DocsAgent +4. **Comments**: Posts the API response as a comment on the PR ## Requirements @@ -26,7 +40,7 @@ A Node.js server for GitHub app to assist external contributors and save maintai 2. Create a `.env` file similar to `.env.example` and set actual values. If you are using GitHub Enterprise Server, also include a `ENTERPRISE_HOSTNAME` variable and set the value to the name of your GitHub Enterprise Server instance. 3. Install dependencies with `npm install`. 4. Start the server with `npm run server`. -5. Ensure your server is reachable from the internet. +5. Ensure your server is reachable from the internet. This is necessary for GitHub to send webhook events to your local server. - If you're using `smee`, run `smee -u -t http://localhost:3000/api/webhook`. 6. Ensure your GitHub App includes at least one repository on its installations. @@ -35,15 +49,33 @@ A Node.js server for GitHub app to assist external contributors and save maintai ### Using `Docker` 1. [Register a GitHub app](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) for your GitHub organization. Make sure to activate the webhook with webhook url `https://YOUR_WEBSITE/api/webhook` in your app with a secret. Enable Permissions & Events as you may need, at minimum pull_request and issue related events should be enabled. -2. Install your GitHub app in all the repos where you need this app. +2. Install your GitHub app in all the repos where you need this app. 3. Clone this repo OR download the [`build/docker-compose.yml`](./build/docker-compose.yml) to install via dockerhub image -4. Update `docker-compose.yml` environment variables with the details received from the step 2 -> To convert GitHub App's private key to base64, use this command - `openssl base64 -in /path/to/original-private-key.pem -out ./base64EncodedKey.txt -A` -5. Run `docker-componse build` to build the service -6. Run `docker-compose up` to create and start the container -7. Test by visiting `http://localhost:3000` OR whatever `WEBSITE_ADDRESS` environment variable you've configured +4. Update the `docker-compose.yml` file with the environment variables obtained from step 2. Make sure to replace placeholder values with your actual GitHub App details. +5. To convert GitHub App's private key to base64, use this command: + ``` + openssl base64 -in /path/to/original-private-key.pem -out ./base64EncodedKey.txt -A + ``` +6. Run `docker-compose build` to build the service +7. Run `docker-compose up` to create and start the container +8. Test by visiting `http://localhost:3000` OR whatever `WEBSITE_ADDRESS` environment variable you've configured + +## Advanced Features Setup + +### Docs Agent Setup + +To set up the Docs Agent feature: + +1. Locate your Docs Agent API project. This is a separate service that analyzes documentation and provides suggestions. +2. In the Docs Agent API project's environment configuration, add the following URL to the `ALLOWED_WEBHOOK_URLS` variable: + ``` + https://your-github-app-host.com/api/comment + ``` + Replace `your-github-app-host.com` with the actual hostname where your GitHub App is deployed. + +This setup allows the Docs Agent to send webhook requests to your GitHub App. -## Usage +## How It Works With your server running, you can now create a pull request on any repository that your app can access. GitHub will emit a `pull_request.opened` event and will deliver @@ -67,4 +99,4 @@ etc. ## References - [Docs - octokit.rest.* methods](https://github.com/octokit/plugin-rest-endpoint-methods.js/tree/main/docs) -- [Docs - GitHub API](https://docs.github.com/en/rest) +- [Docs - GitHub API](https://docs.github.com/en/rest) \ No newline at end of file diff --git a/app.js b/app.js index bab4e74..1d6218c 100644 --- a/app.js +++ b/app.js @@ -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", { @@ -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";//TODO: add this url to `ALLOWED_WEBHOOK_URLS` env of docs-agent project + 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( @@ -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; diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..2a02324 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,308 @@ +# API Reference + +This document describes all publicly exposed API endpoints. + +## Base URL + +The API is served from the configured domain (set via `WEBSITE_ADDRESS` environment variable) or `http://localhost:3000` for local development. + +## Authentication + +Most endpoints do not require authentication. However, some endpoints require specific authentication: + +- **Download endpoints**: Require username/password authentication via request body (using `LOGIN_USER` and `LOGIN_PASSWORD` environment variables) +- **GitHub webhook endpoints**: Use GitHub webhook secret for verification +- **GitHub API interactions**: Use GitHub App authentication + +## Endpoints + +### Webhook Endpoints + +#### `POST /api/webhook` + +GitHub webhook endpoint for receiving GitHub events. + +**Description**: This endpoint receives webhook events from GitHub and processes them according to the configured event handlers. + +**Headers**: +- `Content-Type: application/json` +- `X-GitHub-Event`: GitHub event type +- `X-Hub-Signature-256`: GitHub webhook signature + +**Request Body**: GitHub webhook payload (varies by event type) + +**Response**: +- `200 OK`: Webhook processed successfully +- `400 Bad Request`: Invalid webhook signature or payload + +**Supported Events**: +- `pull_request.opened`: Adds "Pending CLA" label to PRs requiring CLA +- `pull_request.labeled`: Handles label-specific actions (CLA, product review, docs review) +- `pull_request.closed`: Sends post-merge messages +- `issues.opened`: Adds welcome comment to new issues +- `push`: Logs push events + +**Note**: For "docs review" label, the app integrates with DocsAgent service if configured. This is limited to repositories specified in the `DOCS_REPOS` environment variable. + +--- + +### CLA (Contributor License Agreement) Endpoints + +#### `GET /cla` + +Displays the CLA form page. + +**Description**: Serves the HTML page for contributors to sign the CLA. + +**Response**: +- `200 OK`: HTML page with CLA form +- `404 Not Found`: CLA form file not found + +#### `POST /cla` + +Submits a CLA form. + +**Description**: Processes CLA form submissions and updates PR status. + +**Request Body** (form-encoded): +- `terms` (string): Must be "on" to accept terms +- `legalName` (string): Contributor's legal name +- `username` (string): GitHub username +- `email` (string): Contributor's email address +- `referrer` (string, optional): URL that referred to this form + +**Response**: +- `302 Found`: Redirects to PR or success page +- `200 OK`: Success message with CLA details + +**Success Response**: HTML page confirming CLA submission with contributor details. + +**Example**: +```bash +curl -X POST http://localhost:3000/cla \ + -d "terms=on&legalName=John Doe&username=johndoe&email=john@example.com" +``` + +--- + +### Download Endpoints + +#### `GET /download` + +Displays the download center page. + +**Description**: Serves the HTML page for downloading contribution data. + +**Response**: +- `200 OK`: HTML page with download form +- `404 Not Found`: Download page file not found + +#### `POST /download` + +Downloads contribution data in various formats. + +**Description**: Authenticates user and provides CLA data in JSON or CSV format. + +**Request Body** (form-encoded): +- `username` (string): Authentication username (must match `LOGIN_USER` env variable) +- `password` (string): Authentication password (must match `LOGIN_PASSWORD` env variable) +- `format` (string, optional): "json" or "csv" (defaults to CSV) + +**Response**: +- `200 OK`: File download with appropriate headers +- `404 Not Found`: Authentication failed (Note: This endpoint returns `404 Not Found` for failed authentication attempts, which is unconventional but matches the implementation) + +**Response Headers**: +- `Content-Disposition: attachment; filename=data.json` (for JSON) +- `Content-Disposition: attachment; filename=data.csv` (for CSV) +- `Content-Type: application/json` (for JSON) +- `Content-Type: text/csv` (for CSV) + +**Note**: Authentication is rate-limited. After 3 failed attempts, the account is temporarily locked. + +**Example**: +```bash +curl -X POST http://localhost:3000/download \ + -d "username=admin&password=secret&format=json" +``` + +--- + +### Contribution Management Endpoints + +#### `GET /contributions/sync` + +Synchronizes pull request data. + +**Description**: Currently returns a 404 error (not fully implemented). + +**Response**: +- `404 Not Found`: "Not implemented yet" message + +#### `GET /contributions` + +Lists pull requests and contributions. + +**Description**: Retrieves and displays pull request data with filtering options. + +**Query Parameters**: +- `org` (string, required): GitHub organization name +- `repo` (string, optional): Specific repository name +- `page` (number, optional): Page number for pagination +- `status` (string, optional): PR status filter ("open", "closed") +- `after` (string, optional): Filter PRs created after this date (YYYY-MM-DD) +- `before` (string, optional): Filter PRs created before this date (YYYY-MM-DD) +- `merged` (boolean, optional): Filter by merge status ("true", "false") + +**Note**: `after` and `before` parameters cannot be used together. + +**Response**: +- `200 OK`: HTML page with contribution data or JSON data +- `400 Bad Request`: Missing required parameters or invalid parameter combination + +**Content-Type**: +- `text/html`: Default HTML response +- `application/json`: If `Accept: application/json` header is sent in the request + +**Example**: +```bash +curl -H "Accept: application/json" \ + "http://localhost:3000/contributions?org=myorg&status=open" +``` + +#### `GET /contributions/pr` + +Gets detailed information about a specific pull request. + +**Description**: Retrieves detailed information about a single pull request. + +**Query Parameters**: +- `org` (string, required): GitHub organization name +- `repo` (string, required): Repository name +- `number` (number, required): Pull request number + +**Response**: +- `200 OK`: HTML page with PR details or JSON data +- `400 Bad Request`: Missing required parameters + +**Content-Type**: +- `text/html`: Default HTML response +- `application/json`: If `Accept: application/json` header is sent in the request + +#### `GET /contributions/reset` + +Clears the contribution data cache. + +**Description**: Resets cached contribution data. + +**Response**: +- `200 OK`: "Cache cleared" message + +--- + +### Comment Management Endpoints + +#### `POST /api/comment` + +Adds a comment to a GitHub issue or pull request. + +**Description**: Adds a comment to a specified GitHub issue or PR (used by external services like docs agent). + +**Request Body** (JSON): +```json +{ + "owner": "string", + "repo": "string", + "issue_number": number, + "result": "string" +} +``` + +**Response**: +- `200 OK`: "Comment added to GitHub issue or PR" +- `400 Bad Request`: Missing required parameters +- `500 Internal Server Error`: Failed to add comment + +**Example**: +```bash +curl -X POST http://localhost:3000/api/comment \ + -H "Content-Type: application/json" \ + -d '{"owner":"myorg","repo":"myrepo","issue_number":123,"result":"Review completed"}' +``` + +--- + +### General Endpoints + +#### `GET /` + +Displays the home page. + +**Description**: Serves the main application homepage. + +**Response**: +- `200 OK`: HTML home page +- `404 Not Found`: Home page file not found + +#### `GET /*` (Default Route) + +Handles unmatched routes. + +**Description**: Returns 404 for any unmatched paths. + +**Response**: +- `404 Not Found`: "Path not found!" message + +--- + +## Error Responses + +All endpoints may return the following error responses: + +- `400 Bad Request`: Invalid request parameters or missing required fields +- `404 Not Found`: Resource not found or authentication failed +- `500 Internal Server Error`: Server-side error during processing + +## Rate Limiting + +The application respects GitHub API rate limits and implements caching to minimize API calls. For download endpoints, there's a limit of 3 failed login attempts before temporary account lockout. + +## Environment Variables + +The following environment variables affect API behavior: + +### Server Configuration +- `PORT`: Server port (default: 3000) +- `WEBSITE_ADDRESS`: Public URL for the application +- `NODE_ENV`: Node environment (e.g., "production", "development") + +### GitHub App Authentication +- `APP_ID`: GitHub App ID +- `PRIVATE_KEY_PATH`: Path to GitHub App private key file +- `GITHUB_APP_PRIVATE_KEY_BASE64`: Base64-encoded private key (alternative to file) +- `WEBHOOK_SECRET`: GitHub webhook secret for verification +- `ENTERPRISE_HOSTNAME`: GitHub Enterprise hostname (if applicable) + +### Download Endpoint Authentication +- `LOGIN_USER`: Username for download authentication +- `LOGIN_PASSWORD`: Password for download authentication + +### DocsAgent Integration +- `DOCS_AGENT_API_URL`: Base URL for DocsAgent API +- `DOCS_AGENT_API_KEY`: API key for DocsAgent +- `DOCS_AGENT_API_REVIEW_URL`: URL for docs review endpoint +- `DOCS_AGENT_API_AUDIT_URL`: URL for docs audit endpoint +- `DOCS_AGENT_API_PRIORITIZE_URL`: URL for docs prioritization endpoint +- `DOCS_AGENT_API_EDIT_URL`: URL for docs editing endpoint +- `DOCS_AGENT_API_LINK_URL`: URL for docs linking endpoint +- `DOCS_AGENT_API_TIMEOUT`: Timeout for DocsAgent API calls (default: 350000ms) +- `DOCS_REPOS`: Comma-separated list of repositories eligible for docs review + +### Development & Deployment +- `DEFAULT_GITHUB_ORG`: Default GitHub organization +- `GITHUB_BOT_USERS`: Comma-separated list of GitHub bot usernames to ignore +- `GITHUB_ORG_MEMBERS`: Comma-separated list of organization members to ignore +- `ONE_CLA_PER_ORG`: If "true", one CLA signature is valid for all repos in an org +- `CODESANDBOX_HOST`: CodeSandbox host (for staging environments) +- `HOSTNAME`: Hostname for the application +- `SMEE_URL`: Smee proxy URL for local development \ No newline at end of file diff --git a/src/routes.js b/src/routes.js index 07c9fc5..69d9956 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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 => `
  • ${pr?.user?.login} contributed a PR - ${pr?.title} [${pr?.labels?.map(label => label?.name).join('] [')}] updated ${timeAgo(pr?.updated_at)}
  • `).join('')} default(req, res) { res.writeHead(404); diff --git a/src/services/DocsAgent.js b/src/services/DocsAgent.js new file mode 100644 index 0000000..213184e --- /dev/null +++ b/src/services/DocsAgent.js @@ -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} - 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} - 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} - 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} - 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(); \ No newline at end of file diff --git a/src/services/GitHub.js b/src/services/GitHub.js index d6b4c0d..f595fe6 100644 --- a/src/services/GitHub.js +++ b/src/services/GitHub.js @@ -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; @@ -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(); \ No newline at end of file diff --git a/test/e2e/api.test.js b/test/e2e/api.test.js new file mode 100644 index 0000000..2c48068 --- /dev/null +++ b/test/e2e/api.test.js @@ -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'); + }); + }); + +}); diff --git a/test/integration/docs-agent.test.js b/test/integration/docs-agent.test.js new file mode 100644 index 0000000..d93f0ac --- /dev/null +++ b/test/integration/docs-agent.test.js @@ -0,0 +1,45 @@ +/** + * E2E tests for the docs agent related services. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { server } from '../../app.js'; +import sinon from 'sinon'; +import DocsAgent from '../../src/services/DocsAgent.js'; +import GitHub from '../../src/services/GitHub.js'; + +describe('Docs Agent Services', function () { + this.timeout(40000); + + after(function () { + server.close(); + }); + + describe('reviewDocs', function () { + it('should review the docs', async function () { + const review = await DocsAgent.reviewDocs('Hello, world!', 'https://raw.githubusercontent.com/gitcommitshow/rudder-github-app/e14433e76d74dc680b8cf9102d39f31970e8b794/.codesandbox/tasks.json'); + expect(review).to.not.throw; + expect(review).to.be.a('string'); + expect(review).to.not.be.empty; + }); + + it('should review the docs with webhookUrl and webhookMetadata', async function () { + const addCommentToIssueOrPRStub = sinon.stub(GitHub, 'addCommentToIssueOrPR').resolves(); + const review = await DocsAgent.reviewDocs('Hello, world!', 'https://raw.githubusercontent.com/gitcommitshow/rudder-github-app/e14433e76d74dc680b8cf9102d39f31970e8b794/.codesandbox/tasks.json', { + webhookUrl: 'http://localhost:3000/api/comment', + webhookMetadata: { + issue_number: 7, + repo: 'gcs-cli', + owner: 'Git-Commit-Show', + }, + }); + // Wait for the webhook to be processed + await new Promise(resolve => setTimeout(resolve, 10000)); + expect(review).to.not.throw; + expect(review).to.be.a('string'); + expect(review).to.not.be.empty; + expect(addCommentToIssueOrPRStub.called).to.be.true; + }); + }); +}); \ No newline at end of file