From 96eac9bb554bba56d603324e7c6a7510e8a4b8a8 Mon Sep 17 00:00:00 2001 From: Nikil Date: Sun, 23 Feb 2025 22:22:34 +0530 Subject: [PATCH] Github actions snap-in --- Github Actions/.gitignore | 8 + Github Actions/README.md | 143 +++++++ Github Actions/code/.eslintrc.js | 35 ++ Github Actions/code/.gitignore | 39 ++ Github Actions/code/.npmrc | 1 + Github Actions/code/.prettierignore | 4 + Github Actions/code/.prettierrc | 15 + Github Actions/code/babel.config.js | 3 + Github Actions/code/jest.config.js | 12 + Github Actions/code/nodemon.json | 5 + Github Actions/code/package.json | 61 +++ .../src/fixtures/on_work_created_event.json | 79 ++++ Github Actions/code/src/function-factory.ts | 7 + .../code/src/functions/workflow/index.ts | 352 ++++++++++++++++++ Github Actions/code/src/index.ts | 1 + Github Actions/code/src/main.ts | 27 ++ Github Actions/code/src/operations/index.ts | 26 ++ .../code/src/test-runner/test-runner.ts | 27 ++ Github Actions/code/test/http_client.ts | 52 +++ Github Actions/code/test/main.ts | 29 ++ Github Actions/code/test/runner.ts | 294 +++++++++++++++ Github Actions/code/test/types.ts | 87 +++++ Github Actions/code/tsconfig.eslint.json | 4 + Github Actions/code/tsconfig.json | 24 ++ Github Actions/manifest.yaml | 44 +++ 25 files changed, 1379 insertions(+) create mode 100644 Github Actions/.gitignore create mode 100644 Github Actions/README.md create mode 100644 Github Actions/code/.eslintrc.js create mode 100644 Github Actions/code/.gitignore create mode 100644 Github Actions/code/.npmrc create mode 100644 Github Actions/code/.prettierignore create mode 100644 Github Actions/code/.prettierrc create mode 100644 Github Actions/code/babel.config.js create mode 100644 Github Actions/code/jest.config.js create mode 100644 Github Actions/code/nodemon.json create mode 100644 Github Actions/code/package.json create mode 100644 Github Actions/code/src/fixtures/on_work_created_event.json create mode 100644 Github Actions/code/src/function-factory.ts create mode 100644 Github Actions/code/src/functions/workflow/index.ts create mode 100644 Github Actions/code/src/index.ts create mode 100644 Github Actions/code/src/main.ts create mode 100644 Github Actions/code/src/operations/index.ts create mode 100644 Github Actions/code/src/test-runner/test-runner.ts create mode 100644 Github Actions/code/test/http_client.ts create mode 100644 Github Actions/code/test/main.ts create mode 100644 Github Actions/code/test/runner.ts create mode 100644 Github Actions/code/test/types.ts create mode 100644 Github Actions/code/tsconfig.eslint.json create mode 100644 Github Actions/code/tsconfig.json create mode 100644 Github Actions/manifest.yaml diff --git a/Github Actions/.gitignore b/Github Actions/.gitignore new file mode 100644 index 0000000..f635237 --- /dev/null +++ b/Github Actions/.gitignore @@ -0,0 +1,8 @@ +# Dependencies +/node_modules +package-lock.json + +# Environment files +.env +.env.* +!.env.example \ No newline at end of file diff --git a/Github Actions/README.md b/Github Actions/README.md new file mode 100644 index 0000000..275a089 --- /dev/null +++ b/Github Actions/README.md @@ -0,0 +1,143 @@ +# DevRev Snap-in Tutorial + +This tutorial guides you through installing the DevRev CLI and creating/deploying a snap-in. + +## Installing DevRev CLI + +### Debian/Linux +```bash +# For amd64 +sudo dpkg -i devrev_0.4.0-linux_amd64.deb + +# For arm64 +sudo dpkg -i devrev_0.4.0-linux_arm64.deb + +# Install completions +wget https://raw.githubusercontent.com/devrev/cli/main/install_completions.sh && sh install_completions.sh /usr/local/bin/devrev +``` + +## Prerequisites + +Before starting: +- Install DevRev CLI (steps above) +- Install `jq` for JSON processing +- Create a dev organization in DevRev + +## Step-by-Step Guide + +### 1. Authentication +```bash +devrev profiles authenticate -o -u +``` + +### 2. Initialize Snap-in Template +```bash +devrev snap_in_version init +``` + +This creates a new directory structure: +``` +devrev-snaps-typescript-template/ +├── code +│ ├── babel.config.js +│ ├── jest.config.js +│ ├── nodemon.json +│ ├── package.json +│ ├── src +│ │ ├── fixtures +│ │ ├── function-factory.ts +│ │ ├── functions +│ │ ├── index.ts +│ │ ├── main.ts +│ │ └── test-runner +│ ├── tsconfig.eslint.json +│ └── tsconfig.json +├── manifest.yaml +└── README.md +``` + +### 3. Create Snap-in Package +```bash +# Create package with unique slug +devrev snap_in_package create-one --slug my-first-snap-in | jq . +``` + +Note: If the slug is already taken, you'll need to choose a different one. + +### 4. Create Snap-in Version +```bash +devrev snap_in_version create-one --path ./devrev-snaps-typescript-template +``` + +Expected output: +```json +{ + "id": "don:integration:dvrv-us-1:devo/fOFb0IdZ:snap_in_package/...", + "state": "draft" +} +``` + +Important: A non-published package can only have one snap-in version. + +### 5. Manage Snap-in Versions + +List versions: +```bash +devrev snap_in_version list +``` + +Delete version: +```bash +devrev snap_in_version delete-one +``` + +### 6. Install Snap-in +```bash +devrev snap_in draft +``` + +### 7. Deploy Snap-in +```bash +# Update snap-in configuration (if needed) +devrev snap_in update + +# Activate the snap-in +devrev snap_in activate +``` + +## Common Operations + +### View Snap-in Versions +```bash +devrev snap_in_version list +``` + +### Upgrade Snap-in Version +```bash +devrev snap_in_version upgrade --manifest --testing-url +``` + +For non-patch compatible updates: +```bash +devrev snap_in_version upgrade --force --manifest --testing-url +``` + +### Uninstall DevRev CLI + +Debian/Linux: +```bash +sudo dpkg -r devrev +``` + +MacOS: +```bash +brew uninstall devrev/tools/devrev +``` + + + +## Additional Resources + +- [Snap-in Manifest Documentation](https://docs.devrev.ai/snap-ins/references/manifest) +- [DevRev CLI Reference](https://docs.devrev.ai/cli) +- [Snap-in Development Guide](https://docs.devrev.ai/snap-ins) diff --git a/Github Actions/code/.eslintrc.js b/Github Actions/code/.eslintrc.js new file mode 100644 index 0000000..ca876e7 --- /dev/null +++ b/Github Actions/code/.eslintrc.js @@ -0,0 +1,35 @@ +module.exports = { + extends: 'airbnb-typescript/base', + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', // Makes ESLint and Prettier play nicely together + 'plugin:import/recommended', + 'plugin:import/typescript', + ], + ignorePatterns: ['**/dist/*'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.eslint.json', + }, + plugins: ['prettier', 'unused-imports', 'import', 'simple-import-sort', 'sort-keys-fix'], + root: true, + rules: { + 'import/first': 'error', // Ensures all imports are at the top of the file + 'import/newline-after-import': 'error', // Ensures there’s a newline after the imports + 'import/no-duplicates': 'error', // Merges import statements from the same file + 'import/order': 'off', // Not compatible with simple-import-sort + 'no-unused-vars': 'off', // Handled by @typescript-eslint/no-unused-vars + 'simple-import-sort/exports': 'error', // Auto-formats exports + 'simple-import-sort/imports': 'error', // Auto-formats imports + 'sort-imports': 'off', // Not compatible with simple-import-sort + 'sort-keys-fix/sort-keys-fix': ['error', 'asc', { natural: true }], // Sorts long object key lists alphabetically + 'unused-imports/no-unused-imports': 'error', // Removes unused imports automatically, + '@typescript-eslint/no-explicit-any': 'warn', // Allows any type with a warning + }, + overrides:[{ + "files": ["**/*.test.ts"], + "rules": { + 'simple-import-sort/imports': 'off', // for test files we would want to load the mocked up modules later so on sorting the mocking mechanism will not work + } + }] +}; diff --git a/Github Actions/code/.gitignore b/Github Actions/code/.gitignore new file mode 100644 index 0000000..6db3937 --- /dev/null +++ b/Github Actions/code/.gitignore @@ -0,0 +1,39 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# Testing +coverage/ + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# System Files +.DS_Store +Thumbs.db + +deps.json +results.json +before-run-data.json + +# packaged app +*.tar.gz diff --git a/Github Actions/code/.npmrc b/Github Actions/code/.npmrc new file mode 100644 index 0000000..cffe8cd --- /dev/null +++ b/Github Actions/code/.npmrc @@ -0,0 +1 @@ +save-exact=true diff --git a/Github Actions/code/.prettierignore b/Github Actions/code/.prettierignore new file mode 100644 index 0000000..640c051 --- /dev/null +++ b/Github Actions/code/.prettierignore @@ -0,0 +1,4 @@ +# Add files here to ignore them from prettier formatting + +/dist + diff --git a/Github Actions/code/.prettierrc b/Github Actions/code/.prettierrc new file mode 100644 index 0000000..de83c71 --- /dev/null +++ b/Github Actions/code/.prettierrc @@ -0,0 +1,15 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "jsxSingleQuote": false, + "arrowParens": "always", + "proseWrap": "never", + "htmlWhitespaceSensitivity": "strict", + "endOfLine": "lf", + "organizeImportsSkipDestructiveCodeActions": true +} diff --git a/Github Actions/code/babel.config.js b/Github Actions/code/babel.config.js new file mode 100644 index 0000000..e6ffbd4 --- /dev/null +++ b/Github Actions/code/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'], +}; diff --git a/Github Actions/code/jest.config.js b/Github Actions/code/jest.config.js new file mode 100644 index 0000000..1bf82d0 --- /dev/null +++ b/Github Actions/code/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + collectCoverage: true, + coverageDirectory: 'coverage', + coverageThreshold: { + "**/*": { + branches: 60 + } + }, + coverageReporters: ['text'], + preset: 'ts-jest', + testEnvironment: 'node' +}; diff --git a/Github Actions/code/nodemon.json b/Github Actions/code/nodemon.json new file mode 100644 index 0000000..8e47e98 --- /dev/null +++ b/Github Actions/code/nodemon.json @@ -0,0 +1,5 @@ +{ + "execMap": { + "ts": "ts-node" + } +} diff --git a/Github Actions/code/package.json b/Github Actions/code/package.json new file mode 100644 index 0000000..0227fae --- /dev/null +++ b/Github Actions/code/package.json @@ -0,0 +1,61 @@ +{ + "name": "devrev-snaps-typescript-template", + "version": "1.0.0", + "description": "", + "main": "./dist/index.js", + "scripts": { + "lint": "eslint --ignore-path .gitignore .", + "lint:fix": "eslint --fix --ignore-path .gitignore .", + "build": "rimraf ./dist && tsc", + "build:watch": "tsc --watch", + "prepackage": "npm run build", + "package": "tar -cvzf build.tar.gz dist package.json package-lock.json .npmrc", + "start": "ts-node src/main.ts", + "start:watch": "nodemon src/main.ts", + "start:production": "node dist/main.js", + "test:server": "nodemon --watch src --watch test test/main.ts", + "test": "jest", + "test:watch": "jest --watch" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.20.12", + "@babel/preset-env": "^7.20.2", + "@babel/preset-typescript": "^7.18.6", + "@types/body-parser": "1.19.5", + "@types/express": "4.17.21", + "@types/jest": "^29.4.0", + "@types/node": "^18.13.0", + "@types/yargs": "^17.0.24", + "@typescript-eslint/eslint-plugin": "^5.51.0", + "babel-jest": "^29.4.2", + "body-parser": "1.20.3", + "dotenv": "^16.0.3", + "eslint": "^8.33.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", + "eslint-config-prettier": "8.5.0", + "eslint-plugin-import": "2.25.4", + "eslint-plugin-prettier": "4.0.0", + "eslint-plugin-simple-import-sort": "7.0.0", + "eslint-plugin-sort-keys-fix": "1.1.2", + "eslint-plugin-unused-imports": "2.0.0", + "express": "4.21.2", + "jest": "^29.4.2", + "nodemon": "3.1.9", + "prettier": "^2.8.3", + "prettier-plugin-organize-imports": "^3.2.2", + "rimraf": "^4.1.2", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "typescript": "^4.9.5", + "yargs": "^17.6.2" + }, + "dependencies": { + "@devrev/typescript-sdk": "1.1.27", + "axios": "1.7.8", + "protobufjs": "7.3.0" + } +} diff --git a/Github Actions/code/src/fixtures/on_work_created_event.json b/Github Actions/code/src/fixtures/on_work_created_event.json new file mode 100644 index 0000000..2c51959 --- /dev/null +++ b/Github Actions/code/src/fixtures/on_work_created_event.json @@ -0,0 +1,79 @@ +[ + { + "payload": { + "id": "don:integration:dvrv-us-1:devo/XXXXXX:webhook/mpLkC87l:webhook_event/A47zYgvZGDM", + "timestamp": "2023-04-03T14:15:08.101098Z", + "type": "work_created", + "unique_key": "ZG9uOmludGVncmF0aW9uOmR2cnYtdXMtMTpkZXZvLzFGR2lCenJhNjpldmVudF9zb3VyY2UvYzMzMDI2Y2UtOTM5Yy00ZWEzLThjYzctOTI4M2M0NWI3ZjRk", + "webhook_id": "don:integration:dvrv-us-1:devo/XXXXXX:webhook/mpLkC87l", + "work_created": { + "work": { + "applies_to_part": { + "display_id": "ENH-9", + "id": "don:core:dvrv-us-1:devo/XXXXXX:enhancement/9", + "id_v1": "don:DEV-XXXXXX:enhancement: 9", + "name": "test-enhancement", + "type": "enhancement" + }, + "created_by": { + "display_handle": "Sample Name", + "display_id": "DEVU-5", + "display_name": "Sample Name", + "email": "i-Demo1.Demo2@domain.com", + "full_name": "Sample Name", + "id": "don:identity:dvrv-us-1:devo/XXXXXX:devu/5", + "id_v1": "don:DEV-XXXXXX:dev_user:DEVU-5", + "state": "active", + "type": "dev_user" + }, + "created_date": "2023-04-03T14: 14: 30.336Z", + "custom_fields": null, + "display_id": "TKT-122", + "id": "don:core:dvrv-us-1:devo/XXXXXX:ticket/122", + "id_v1": "don:DEV-XXXXXX:ticket: 122", + "owned_by": [ + { + "display_handle": "Sample Name", + "display_id": "DEVU-5", + "display_name": "Sample Name", + "email": "i-Demo1.Demo2@domain.com", + "full_name": "Sample Name", + "id": "don:identity:dvrv-us-1:devo/XXXXXX:devu/5", + "id_v1": "don:DEV-XXXXXX:dev_user:DEVU-5", + "state": "active", + "type": "dev_user" + } + ], + "severity": "medium", + "stage": { + "name": "queued", + "ordinal": 700 + }, + "state": "open", + "stock_schema_fragment": "don:core:dvrv-us-1:stock_sf/297430", + "title": "test-ticket", + "type": "ticket" + } + } + }, + "context": { + "dev_oid": "don:identity:dvrv-us-1:devo/XXXXXX", + "automation_id": "don:integration:dvrv-us-1:devo/XXXXXX:automation/be9f0869-77a4-4210-9f84-47409e67c781", + "source_id": "don:integration:dvrv-us-1:devo/XXXXXX:automation/be9f0869-77a4-4210-9f84-47409e67c781", + "snap_in_id": "don:integration:dvrv-us-1:devo/XXXXXX:snap_in/641c009b-0476-40ef-b728-f3ce6fb77bb9", + "snap_in_version_id": "don:integration:dvrv-us-1:devo/XXXXXX:snap_in_package/fd20d23b-372c-48ad-ac24-d13c271c63d6:snap_in_version/d506f96a-131f-451c-87d6-e73605fac8df", + "secrets": { + "service_account_token": "TEST-TOKEN" + } + }, + "input_data": { + "global_values": { + "greeting": "Hello World!" + } + }, + "execution_metadata": { + "devrev_endpoint": "https://api.devrev.ai", + "function_name": "on_work_creation" + } + } + ] diff --git a/Github Actions/code/src/function-factory.ts b/Github Actions/code/src/function-factory.ts new file mode 100644 index 0000000..f518526 --- /dev/null +++ b/Github Actions/code/src/function-factory.ts @@ -0,0 +1,7 @@ +import workflow from './functions/workflow'; + +export const functionFactory = { + workflow, +} as const; + +export type FunctionFactoryType = keyof typeof functionFactory; diff --git a/Github Actions/code/src/functions/workflow/index.ts b/Github Actions/code/src/functions/workflow/index.ts new file mode 100644 index 0000000..081bdd4 --- /dev/null +++ b/Github Actions/code/src/functions/workflow/index.ts @@ -0,0 +1,352 @@ +import axios from "axios"; + +const GITHUB_API_BASE = "https://api.github.com"; +const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + +const createHeaders = (token: string) => ({ + Authorization: `token ${token}`, + Accept: "application/vnd.github.v3+json", +}); + +const createOpenAIHeaders = (token: string) => ({ + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", +}); + +const helpText = `\`\`\` +Usage: /workflow [additional parameters...] [--summary] + +Available Commands: + +WORKFLOWS: + list - List repository workflows + get - Get workflow details + disable - Disable a workflow + enable - Enable a workflow + dispatch - Trigger workflow dispatch event + usage - Get workflow usage + +WORKFLOW RUNS: + runs-list - List workflow runs for a repository + run-get - Get a workflow run + run-delete - Delete a workflow run + run-reviews - Get review history + run-approve - Approve a fork pull request + run-logs - Download run logs + run-cancel - Cancel a workflow run + run-force-cancel - Force cancel a workflow run + run-rerun - Re-run a workflow + run-rerun-failed - Re-run failed jobs + run-usage - Get run usage + run-deployments - List pending deployments + run-deployment-rules - Review custom deployment rules + run-review-deployments - Review pending deployments + +WORKFLOW JOBS: + job-get - Get a job for a workflow run + job-logs - Download job logs + jobs-list - List jobs for a workflow run + jobs-attempt - List jobs for a workflow run attempt + +Examples: + /workflow list react facebook + /workflow run-get react facebook 12345 + /workflow job-logs react facebook 12345 67890 + /workflow list react facebook --summary + +Use "/workflow help" to see this message +\`\`\``; + +interface BaseParams { + repo: string; + owner: string; +} + +interface WorkflowParams extends BaseParams { + workflow_id?: string; +} + +interface RunParams extends BaseParams { + run_id: string; +} + +interface JobParams extends RunParams { + job_id?: string; + attempt_number?: string; +} + +type CommandParams = WorkflowParams | RunParams | JobParams; + +async function postResponse(baseURL: string, workId: string, token: string, message: string): Promise { + await axios.post( + `${baseURL}/timeline-entries.create`, + { + type: "timeline_comment", + object: workId, + body: message, + visibility: "external" + }, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + } + } + ); +} + +async function handleWorkflowCommands(command: string, params: WorkflowParams, token: string): Promise { + const { repo, owner, workflow_id } = params; + const baseUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/actions/workflows`; + + switch (command) { + case 'list': + return axios.get(baseUrl, { headers: createHeaders(token) }); + case 'get': + return axios.get(`${baseUrl}/${workflow_id}`, { headers: createHeaders(token) }); + case 'disable': + return axios.put(`${baseUrl}/${workflow_id}/disable`, {}, { headers: createHeaders(token) }); + case 'enable': + return axios.put(`${baseUrl}/${workflow_id}/enable`, {}, { headers: createHeaders(token) }); + case 'dispatch': + return axios.post(`${baseUrl}/${workflow_id}/dispatches`, { ref: 'main' }, { headers: createHeaders(token) }); + case 'usage': + return axios.get(`${baseUrl}/${workflow_id}/timing`, { headers: createHeaders(token) }); + default: + throw new Error(`Unknown workflow command: ${command}`); + } +} + +async function handleRunCommands(command: string, params: RunParams, token: string): Promise { + const { repo, owner, run_id } = params; + const baseUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/actions/runs`; + + switch (command) { + case 'runs-list': + return axios.get(baseUrl, { headers: createHeaders(token) }); + case 'run-get': + return axios.get(`${baseUrl}/${run_id}`, { headers: createHeaders(token) }); + case 'run-delete': + return axios.delete(`${baseUrl}/${run_id}`, { headers: createHeaders(token) }); + case 'run-reviews': + return axios.get(`${baseUrl}/${run_id}/reviews`, { headers: createHeaders(token) }); + case 'run-approve': + return axios.post(`${baseUrl}/${run_id}/approve`, {}, { headers: createHeaders(token) }); + case 'run-logs': + return axios.get(`${baseUrl}/${run_id}/logs`, { headers: createHeaders(token) }); + case 'run-cancel': + return axios.post(`${baseUrl}/${run_id}/cancel`, {}, { headers: createHeaders(token) }); + case 'run-force-cancel': + return axios.post(`${baseUrl}/${run_id}/force-cancel`, {}, { headers: createHeaders(token) }); + case 'run-rerun': + return axios.post(`${baseUrl}/${run_id}/rerun`, {}, { headers: createHeaders(token) }); + case 'run-rerun-failed': + return axios.post(`${baseUrl}/${run_id}/rerun-failed-jobs`, {}, { headers: createHeaders(token) }); + case 'run-usage': + return axios.get(`${baseUrl}/${run_id}/timing`, { headers: createHeaders(token) }); + case 'run-deployments': + return axios.get(`${baseUrl}/${run_id}/pending_deployments`, { headers: createHeaders(token) }); + case 'run-deployment-rules': + return axios.get(`${baseUrl}/${run_id}/deployment_protection_rules`, { headers: createHeaders(token) }); + case 'run-review-deployments': + return axios.get(`${baseUrl}/${run_id}/pending_deployments`, { headers: createHeaders(token) }); + default: + throw new Error(`Unknown run command: ${command}`); + } +} + +async function handleJobCommands(command: string, params: JobParams, token: string): Promise { + const { repo, owner, run_id, job_id, attempt_number } = params; + const baseUrl = `${GITHUB_API_BASE}/repos/${owner}/${repo}/actions`; + + switch (command) { + case 'job-get': + return axios.get(`${baseUrl}/jobs/${job_id}`, { headers: createHeaders(token) }); + case 'job-logs': + return axios.get(`${baseUrl}/jobs/${job_id}/logs`, { headers: createHeaders(token) }); + case 'jobs-list': + return axios.get(`${baseUrl}/runs/${run_id}/jobs`, { headers: createHeaders(token) }); + case 'jobs-attempt': + return axios.get(`${baseUrl}/runs/${run_id}/attempts/${attempt_number}/jobs`, { headers: createHeaders(token) }); + default: + throw new Error(`Unknown job command: ${command}`); + } +} + +async function summarizeWithOpenAI(openaiToken: string, content: string): Promise { + const response = await axios.post( + OPENAI_API_URL, + { + model: "gpt-3.5-turbo", + messages: [ + { role: "system", content: "You are a helpful assistant that summarizes text." }, + { role: "user", content: `Please summarize the following content in 3 sentences:\n${content}` } + ], + max_tokens: 150 + }, + { headers: createOpenAIHeaders(openaiToken) } + ); + + return response.data.choices[0].message.content; +} + +function parseInput(input: string): { command: string; params: CommandParams; summaryFlag: boolean } | "help" | null { + const args = input.trim().split(/\s+/); + + if (args[0] === "help" || args.length === 0) { + return "help"; + } + + const command = args[0]; + const repo = args[1]; + const owner = args[2]; + const summaryFlag = args.includes('--summary'); + + if (!command || !repo || !owner) { + return null; + } + + const baseParams: BaseParams = { repo, owner }; + + switch (command) { + case 'job-get': + case 'job-logs': + if (args.length < 5) return null; + return { + command, + params: { + ...baseParams, + run_id: args[3], + job_id: args[4] + } as JobParams, + summaryFlag + }; + case 'jobs-list': + if (args.length < 4) return null; + return { + command, + params: { + ...baseParams, + run_id: args[3] + } as JobParams, + summaryFlag + }; + case 'jobs-attempt': + if (args.length < 5) return null; + return { + command, + params: { + ...baseParams, + run_id: args[3], + attempt_number: args[4] + } as JobParams, + summaryFlag + }; + case 'runs-list': + return { + command, + params: baseParams as RunParams, + summaryFlag + }; + case 'run-get': + case 'run-delete': + case 'run-reviews': + case 'run-approve': + case 'run-logs': + case 'run-cancel': + case 'run-force-cancel': + case 'run-rerun': + case 'run-rerun-failed': + case 'run-usage': + case 'run-deployments': + case 'run-deployment-rules': + case 'run-review-deployments': + if (args.length < 4) return null; + return { + command, + params: { + ...baseParams, + run_id: args[3] + } as RunParams, + summaryFlag + }; + case 'list': + return { + command, + params: baseParams as WorkflowParams, + summaryFlag + }; + case 'get': + case 'disable': + case 'enable': + case 'dispatch': + case 'usage': + if (args.length < 4) return null; + return { + command, + params: { + ...baseParams, + workflow_id: args[3] + } as WorkflowParams, + summaryFlag + }; + default: + return null; + } +} + +export default async function workflow(event: any[]): Promise { + const workId = event[0].payload.source_id; + const baseURL = event[0].execution_metadata.devrev_endpoint; + const { service_account_token } = event[0].context.secrets; + const githubToken = event[0].input_data.keyrings.github_api_key; + const openaiToken = event[0].input_data.keyrings.openai_api_key; + + try { + const userInput = event[0].payload.parameters || ""; + const parsed = parseInput(userInput); + + if (parsed === "help") { + await postResponse(baseURL, workId, service_account_token, helpText); + return; + } + + if (!parsed) { + await postResponse(baseURL, workId, service_account_token, + "Invalid input format. Use '/workflow help' to see usage instructions."); + return; + } + + let response; + try { + if (parsed.command.startsWith('job-') || parsed.command === 'jobs-attempt') { + response = await handleJobCommands(parsed.command, parsed.params as JobParams, githubToken); + } else if (parsed.command.startsWith('run-') || parsed.command === 'runs-list') { + response = await handleRunCommands(parsed.command, parsed.params as RunParams, githubToken); + } else { + response = await handleWorkflowCommands(parsed.command, parsed.params as WorkflowParams, githubToken); + } + } catch (error: any) { + const errorMessage = error.response?.status === 401 + ? "Error: Invalid or expired GitHub token" + : `Error executing command: ${error.message}`; + + await postResponse(baseURL, workId, service_account_token, errorMessage); + return; + } + + const content = JSON.stringify(response.data, null, 2); + const message = parsed.summaryFlag + ? await summarizeWithOpenAI(openaiToken, content) + : `Command Result:\n\`\`\`json\n${content}\n\`\`\``; + + await postResponse(baseURL, workId, service_account_token, message); + + } catch (error: any) { + const errorMessage = error.response?.status === 401 + ? "Error: Invalid or expired GitHub token" + : `Error executing command: ${error.message}`; + + await postResponse(baseURL, workId, service_account_token, errorMessage); + } +} diff --git a/Github Actions/code/src/index.ts b/Github Actions/code/src/index.ts new file mode 100644 index 0000000..5254e38 --- /dev/null +++ b/Github Actions/code/src/index.ts @@ -0,0 +1 @@ +export * from './function-factory'; diff --git a/Github Actions/code/src/main.ts b/Github Actions/code/src/main.ts new file mode 100644 index 0000000..2bb6a36 --- /dev/null +++ b/Github Actions/code/src/main.ts @@ -0,0 +1,27 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { FunctionFactoryType } from './function-factory'; +import { testRunner } from './test-runner/test-runner'; + +(async () => { + const argv = await yargs(hideBin(process.argv)).options({ + fixturePath: { + require: true, + type: 'string', + }, + functionName: { + require: true, + type: 'string', + }, + }).argv; + + if (!argv.fixturePath || !argv.functionName) { + console.error('Please make sure you have passed fixturePath & functionName'); + } + + await testRunner({ + fixturePath: argv.fixturePath, + functionName: argv.functionName as FunctionFactoryType, + }); +})(); diff --git a/Github Actions/code/src/operations/index.ts b/Github Actions/code/src/operations/index.ts new file mode 100644 index 0000000..3db0e21 --- /dev/null +++ b/Github Actions/code/src/operations/index.ts @@ -0,0 +1,26 @@ +import { OperationMap,OperationIfc, FunctionInput } from '@devrev/typescript-sdk/dist/snap-ins'; + +/** + * OperationFactory is a factory class that creates a map of operations with the slug mentioned + * in the manifest. + */ +export class OperationFactory { + operationMap: OperationMap; + + constructor(operationMap?: OperationMap) { + this.operationMap = operationMap || {}; + } + +/** + * @param slug The slug of the operation mentioned in the manifest + * @param event Event object that is passed to the snap-in + * @returns Operation + */ + public getOperation(slug: string, event: FunctionInput): OperationIfc { + if (!this.operationMap[slug]) { + throw new Error(`Operation with slug ${slug} not found`); + } + return new this.operationMap[slug](event); + } +} + diff --git a/Github Actions/code/src/test-runner/test-runner.ts b/Github Actions/code/src/test-runner/test-runner.ts new file mode 100644 index 0000000..9d7206d --- /dev/null +++ b/Github Actions/code/src/test-runner/test-runner.ts @@ -0,0 +1,27 @@ +import * as dotenv from 'dotenv'; + +import { functionFactory, FunctionFactoryType } from '../function-factory'; + +export interface TestRunnerProps { + functionName: FunctionFactoryType; + fixturePath: string; +} + +export const testRunner = async ({ functionName, fixturePath }: TestRunnerProps) => { + //Since we were not using the env anywhere its not require to load it + dotenv.config(); + + if (!functionFactory[functionName]) { + console.error(`${functionName} is not found in the functionFactory`); + console.error('Add your function to the function-factory.ts file'); + throw new Error('Function is not found in the functionFactory'); + } + + //Since the import is loaded dynamically, we need to use require + const run = functionFactory[functionName]; + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const eventFixture = require(`../fixtures/${fixturePath}`); + + await run(eventFixture); +}; diff --git a/Github Actions/code/test/http_client.ts b/Github Actions/code/test/http_client.ts new file mode 100644 index 0000000..0e222f8 --- /dev/null +++ b/Github Actions/code/test/http_client.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 DevRev Inc. All rights reserved. + +Disclaimer: +The code provided herein is intended solely for testing purposes. +Under no circumstances should it be utilized in a production environment. Use of +this code in live systems, production environments, or any situation where +reliability and stability are critical is strongly discouraged. The code is +provided as-is, without any warranties or guarantees of any kind, and the user +assumes all risks associated with its use. It is the responsibility of the user +to ensure that proper testing and validation procedures are carried out before +deploying any code into production environments. +*/ + +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; + +interface SetupOptions { + endpoint: string; + token?: string; +} + +export interface HttpRequest { + headers?: any; + path: string; + body: unknown; +} + +export class HTTPClient { + public instance: AxiosInstance; + + constructor({ endpoint, token }: SetupOptions) { + const axiosConfig: AxiosRequestConfig = { + baseURL: endpoint, + headers: { + Authorization: token, + }, + }; + + this.instance = axios.create({ + ...axiosConfig, + }); + } + + async post({ headers, path, body }: HttpRequest): Promise> { + return this.instance.request({ + method: 'POST', + headers: headers, + data: body, + url: path, + }); + } +} diff --git a/Github Actions/code/test/main.ts b/Github Actions/code/test/main.ts new file mode 100644 index 0000000..8ff9ef3 --- /dev/null +++ b/Github Actions/code/test/main.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 DevRev Inc. All rights reserved. + +Disclaimer: +The code provided herein is intended solely for testing purposes. +Under no circumstances should it be utilized in a production environment. Use of +this code in live systems, production environments, or any situation where +reliability and stability are critical is strongly discouraged. The code is +provided as-is, without any warranties or guarantees of any kind, and the user +assumes all risks associated with its use. It is the responsibility of the user +to ensure that proper testing and validation procedures are carried out before +deploying any code into production environments. +*/ + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { startServer } from './runner'; + +(async () => { + const argv = await yargs(hideBin(process.argv)).options({ + port: { + require: false, + type: 'number', + }, + }).argv; + + const port = argv.port || 8000; + startServer(port); +})(); diff --git a/Github Actions/code/test/runner.ts b/Github Actions/code/test/runner.ts new file mode 100644 index 0000000..c0fa89d --- /dev/null +++ b/Github Actions/code/test/runner.ts @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2024 DevRev Inc. All rights reserved. + +Disclaimer: +The code provided herein is intended solely for testing purposes. +Under no circumstances should it be utilized in a production environment. Use of +this code in live systems, production environments, or any situation where +reliability and stability are critical is strongly discouraged. The code is +provided as-is, without any warranties or guarantees of any kind, and the user +assumes all risks associated with its use. It is the responsibility of the user +to ensure that proper testing and validation procedures are carried out before +deploying any code into production environments. +*/ + +import bodyParser from 'body-parser'; +import express, { Express, Handler, Request, Response } from 'express'; + +import process from 'process'; +import { functionFactory, FunctionFactoryType } from '../src/function-factory'; +import { HTTPClient, HttpRequest } from './http_client'; +import { + ActivateHookResult, + DeactivateHookResult, + ExecutionResult, + FunctionError, + HandlerError, + RuntimeError, + RuntimeErrorType, + SnapInsSystemUpdateRequest, + SnapInsSystemUpdateRequestInactive, + SnapInsSystemUpdateRequestStatus, + SnapInsSystemUpdateResponse, +} from './types'; + + +import { + Context as SnapInContext, + ExecuteOperationResult, + ExecuteOperationResult_SerializationFormat, + ExecutionMetadata, + FunctionExecutionError, + FunctionInput, + OperationOutput, +} from '@devrev/typescript-sdk/dist/snap-ins'; + +const app: Express = express(); +app.use(bodyParser.json(), bodyParser.urlencoded({ extended: false })); + +export const startServer = (port: number) => { + app.listen(port, () => { + console.log(`[server]: Server is running at http://localhost:${port}`); + }); +}; + +// handle async requests +app.post('/handle/async', async (req: Request, resp: Response) => { + const events = req.body; + if (events === undefined) { + resp.status(400).send('Invalid request format: body is undefined'); + return; + } + + await handleEvent(events, true /* isAsync */, resp); +}); + +app.post('/handle/sync', async (req: Request, resp: Response) => { + if (req.body === undefined) { + resp.status(400).send('Invalid request format: body is undefined'); + return; + } + // for sync invokation, wrap in an array + const events: any[] = [req.body]; + await handleEvent(events, false /* isAsync */, resp); +}); + +async function run(f: any, event: any): Promise { + let result = await f(event); + return result; +} + +async function handleEvent(events: any[], isAsync: boolean, resp: Response) { + let error; + let results: ExecutionResult[] = []; + let receivedError = false; + + if (!Array.isArray(events)) { + let errMsg = 'Invalid request format: body is not an array'; + error = { + err_type: RuntimeErrorType.InvalidRequest, + err_msg: errMsg, + } as RuntimeError; + console.error(error.err_msg); + resp.status(400).send(errMsg); + return; + } + // if the request is synchronous, there should be a single event + if (!isAsync) { + if (events.length > 1) { + let errMsg = 'Invalid request format: multiple events provided for synchronous request'; + error = { + err_type: RuntimeErrorType.InvalidRequest, + err_msg: errMsg, + } as RuntimeError; + console.error(error.err_msg); + resp.status(400).send(errMsg); + return; + } + } else { + // return a success response back to the server + resp.status(200).send(); + } + + for (let event of events) { + let result; + const functionName: FunctionFactoryType = event.execution_metadata.function_name as FunctionFactoryType; + if (functionName === undefined) { + error = { + err_type: RuntimeErrorType.FunctionNameNotProvided, + err_msg: 'Function name not provided in event', + } as RuntimeError; + console.error(error.err_msg); + receivedError = true; + } else { + const f = functionFactory[functionName]; + try { + if (f == undefined) { + error = { + err_type: RuntimeErrorType.FunctionNotFound, + err_msg: `Function ${event.execution_metadata.function_name} not found in factory`, + } as RuntimeError; + console.error(error.err_msg); + receivedError = true; + } else { + result = await run(f, [event]); + } + } catch (e) { + error = { error: e } as FunctionError; + console.error(e); + } + + // Any common post processing goes here. The function returns + // only if the function execution was by an operation + } + const opResult = await postRun(event,error, result); + + // Return result. + let res: ExecutionResult = {}; + + if (opResult !== undefined) { + res.function_result = opResult; + } else if (result !== undefined) { + res.function_result = result; + } + + if (error !== undefined) { + res.error = error; + } + results.push(res); + } + + if (!isAsync) { + resp.status(200).send(results[0]); + } +} + +// post processing +async function postRun(event: any, handlerError: HandlerError, result: any){ + console.debug('Function execution complete'); + // Check if the function was invoked by an operation. + if (isInvokedFromOperation(event)) { + // handle operation specific logic + console.debug('Function was invoked by an operation'); + const data: Uint8Array = OperationOutput.encode(result).finish(); + + return { + serialization_format: ExecuteOperationResult_SerializationFormat.Proto, + data: Buffer.from(data).toString('base64'), + } as ExecuteOperationResult; + } + if (isActivateHook(event)) { + handleActivateHookResult(event,handlerError, result); + } else if (isDeactivateHook(event)) { + handleDeactivateHookResult(event,handlerError, result); + } + return undefined +} + +function isActivateHook(event: any): boolean { + return event.execution_metadata.event_type === 'hook:snap_in_activate'; +} + +function isDeactivateHook(event: any): boolean { + return event.execution_metadata.event_type === 'hook:snap_in_deactivate'; +} + +function isInvokedFromOperation(event: any): boolean { + return event.execution_metadata.operation_slug !== undefined; +} + +function handleActivateHookResult(event: any, handlerError: HandlerError, result: any) { + let update_req: SnapInsSystemUpdateRequest = { + id: event.context.snap_in_id, + status: SnapInsSystemUpdateRequestStatus.Active, + }; + let res = getActivateHookResult(result); + update_req.inputs_values = res.inputs_values; + + if (handlerError !== undefined || res?.status === 'error') { + console.debug('Setting snap-in status to error'); + update_req.status = SnapInsSystemUpdateRequestStatus.Error; + } + + return updateSnapInState(event, update_req); +} + +function handleDeactivateHookResult(event: any, handlerError: HandlerError, result: any) { + let update_req: SnapInsSystemUpdateRequest = { + id: event.context.snap_in_id, + status: SnapInsSystemUpdateRequestStatus.Inactive, + }; + let res = getDeactivateHookResult(result); + update_req.inputs_values = res.inputs_values; + if (event.payload.force_deactivate) { + console.debug('Snap-in is being force deactivated, errors ignored'); + } + if ((handlerError !== undefined || res?.status === 'error') && !event.payload.force_deactivate) { + console.debug('Setting snap-in status to error'); + update_req.status = SnapInsSystemUpdateRequestStatus.Error; + } else { + if (event.payload.is_deletion) { + console.debug('Marking snap-in to be deleted'); + (update_req as SnapInsSystemUpdateRequestInactive).is_deletion = true; + } else { + console.debug('Setting snap-in status to inactive'); + } + } + + return updateSnapInState(event, update_req); +} + +// Update the snap-in status based on hook result. +async function updateSnapInState(event: any, update_req: SnapInsSystemUpdateRequest) { + console.debug('Updating snap-in state after running async hook'); + const { secrets } = event.context; + const client = new HTTPClient({ + endpoint: event.execution_metadata.devrev_endpoint, + token: secrets?.service_account_token, + }); + + const request: HttpRequest = { + path: '/internal/snap-ins.system-update', + body: update_req, + }; + + try { + await client.post(request); + } catch (e) { + console.error(e); + } +} + +function getActivateHookResult(input: any): ActivateHookResult { + let res = {} as ActivateHookResult; + if (input instanceof Object) { + if (input.status === 'active' || input.status === 'error') { + res.status = input.status; + } else if (input.status !== undefined) { + console.error(`Invalid status field ${input.status}: status must be active or error`); + } + if (input.inputs_values instanceof Object) { + res.inputs_values = input.inputs_values; + } else if (input.inputs_values !== undefined) { + console.error(`Invalid inputs_values field ${input.inputs_values}: inputs_values is not an object`); + } + } + return res; +} + +function getDeactivateHookResult(input: any): DeactivateHookResult { + let res = {} as DeactivateHookResult; + if (input instanceof Object) { + if (input.status === 'inactive' || input.status === 'error') { + res.status = input.status; + } else if (input.status !== undefined) { + console.error(`Invalid status field ${input.status}: status must be inactive or error`); + } + if (input.inputs_values instanceof Object) { + res.inputs_values = input.inputs_values; + } else if (input.inputs_values !== undefined) { + console.error(`Invalid inputs_values field ${input.inputs_values}: inputs_values is not an object`); + } + } + return res; +} diff --git a/Github Actions/code/test/types.ts b/Github Actions/code/test/types.ts new file mode 100644 index 0000000..6199093 --- /dev/null +++ b/Github Actions/code/test/types.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2024 DevRev Inc. All rights reserved. + +Disclaimer: +The code provided herein is intended solely for testing purposes. +Under no circumstances should it be utilized in a production environment. Use of +this code in live systems, production environments, or any situation where +reliability and stability are critical is strongly discouraged. The code is +provided as-is, without any warranties or guarantees of any kind, and the user +assumes all risks associated with its use. It is the responsibility of the user +to ensure that proper testing and validation procedures are carried out before +deploying any code into production environments. +*/ + +/* + Error Types +*/ + +export enum RuntimeErrorType { + FunctionNotFound = 'FUNCTION_NOT_FOUND', + FunctionNameNotProvided = 'FUNCTION_NAME_NOT_PROVIDED', + InvalidRequest = 'INVALID_REQUEST', +} + +export type FunctionError = { + error: unknown; +}; + +export type RuntimeError = { + err_type: RuntimeErrorType; + err_msg: string; +}; + +/* + Snap-in types +*/ + +/** snap-ins-system-update-request */ +export type SnapInsSystemUpdateRequest = ( + | SnapInsSystemUpdateRequestActive + | SnapInsSystemUpdateRequestError + | SnapInsSystemUpdateRequestInactive +) & { + /** The ID of the snap-in to update. */ + id: string; + /** Values of the inputs. */ + inputs_values?: object; + status: SnapInsSystemUpdateRequestStatus; +}; + +/* snap-ins-system-update-request-active */ +export type SnapInsSystemUpdateRequestActive = object; + +/* snap-ins-system-update-request-error */ +export type SnapInsSystemUpdateRequestError = object; + +/* snap-ins-system-update-request-inactive */ +export interface SnapInsSystemUpdateRequestInactive { + /** Parameter to proceed with deletion of snap-in. */ + is_deletion?: boolean; +} + +export enum SnapInsSystemUpdateRequestStatus { + Active = 'active', + Error = 'error', + Inactive = 'inactive', +} + +/* snap-ins-system-update-response */ +export type SnapInsSystemUpdateResponse = object; + +export type HandlerError = FunctionError | RuntimeError | undefined; + +export type ExecutionResult = { + function_result?: any; + error?: HandlerError; +}; + +export type ActivateHookResult = { + status: 'active' | 'error'; + inputs_values?: Record; +}; + +export type DeactivateHookResult = { + status: 'inactive' | 'error'; + inputs_values?: Record; +}; diff --git a/Github Actions/code/tsconfig.eslint.json b/Github Actions/code/tsconfig.eslint.json new file mode 100644 index 0000000..c8722d7 --- /dev/null +++ b/Github Actions/code/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./**/*.ts", "./**/*.js", "./.*.js"] +} diff --git a/Github Actions/code/tsconfig.json b/Github Actions/code/tsconfig.json new file mode 100644 index 0000000..92f5866 --- /dev/null +++ b/Github Actions/code/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "baseUrl": "./", + "paths": { + "*": ["./src/*"] + }, + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "allowJs": true, + "allowSyntheticDefaultImports": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "test"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/Github Actions/manifest.yaml b/Github Actions/manifest.yaml new file mode 100644 index 0000000..9980c08 --- /dev/null +++ b/Github Actions/manifest.yaml @@ -0,0 +1,44 @@ +# For reference: https://docs.devrev.ai/snap-ins/references/manifest. +# Refactor the code based on your business logic. + +version: "2" +name: "Github Actions" +description: "Helps query Github Actions API using commands and natural language." + +# This is the name displayed in DevRev where the Snap-In takes actions using the token of this service account. +service_account: + display_name: Github Actions Bot + +# Add any external connection, reference: https://docs.devrev.ai/snap-ins/concepts#connection. + +# Add organization level inputs, reference: https://docs.devrev.ai/snap-ins/references/inputs. +keyrings: + organization: + - name: github_api_key + description: GitHub Personal Access Token for API access. Generate one at https://github.com/settings/tokens + types: + - snap_in_secret + display_name: GitHub API Key + + - name: openai_api_key + description: OpenAI API Key for summarizing the response. + types: + - snap_in_secret + display_name: OpenAI API Key + +# Functions reference: https://docs.devrev.ai/snap-ins/references/functions. +functions: + - name: workflow + description: Function containing logic to hit the Github Actions API for workflow. + +commands: + - name: workflow + namespace: GithubActions + description: List workflows for facebook/react repository + surfaces: + - surface: discussions + object_types: + - issue + function: workflow + +