diff --git a/package.json b/package.json index 1e8e621a..5b69d4e8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hawk.workers", "private": true, - "version": "0.0.1", + "version": "0.1.0", "description": "Hawk workers", "repository": "git@github.com:codex-team/hawk.workers.git", "license": "BUSL-1.1", @@ -33,6 +33,7 @@ "test:paymaster": "jest workers/paymaster", "test:notifier": "jest workers/notifier", "test:js": "jest workers/javascript", + "test:task-manager": "jest workers/task-manager", "test:clear": "jest --clearCache", "run-default": "yarn worker hawk-worker-default", "run-sentry": "yarn worker hawk-worker-sentry", @@ -47,7 +48,8 @@ "run-release": "yarn worker hawk-worker-release", "run-email": "yarn worker hawk-worker-email", "run-telegram": "yarn worker hawk-worker-telegram", - "run-limiter": "yarn worker hawk-worker-limiter" + "run-limiter": "yarn worker hawk-worker-limiter", + "run-task-manager": "yarn worker hawk-worker-task-manager" }, "dependencies": { "@babel/parser": "^7.26.9", @@ -61,7 +63,7 @@ "amqplib": "^0.8.0", "codex-accounting-sdk": "codex-team/codex-accounting-sdk", "debug": "^4.1.1", - "dotenv": "^8.2.0", + "dotenv": "^17.2.3", "migrate-mongo": "^7.2.1", "mockdate": "^3.0.2", "mongodb": "^3.5.7", diff --git a/workers/grouper/src/index.ts b/workers/grouper/src/index.ts index 8d20daf5..73f16fc7 100644 --- a/workers/grouper/src/index.ts +++ b/workers/grouper/src/index.ts @@ -132,12 +132,10 @@ export default class GrouperWorker extends Worker { uniqueEventHash = similarEvent.groupHash; existedEvent = similarEvent; - } - - /** - * If we couldn't group by grouping pattern — try grouping bt hash (title) - */ - else { + } else { + /** + * If we couldn't group by grouping pattern — try grouping by hash (title) + */ /** * Find event by group hash. */ diff --git a/workers/task-manager/.env.example b/workers/task-manager/.env.example new file mode 100644 index 00000000..c62cf58c --- /dev/null +++ b/workers/task-manager/.env.example @@ -0,0 +1,15 @@ +# Maximum number of auto-created tasks per project per day +# Default: 10 +MAX_AUTO_TASKS_PER_DAY=10 + +# Number of tasks handling simultaneously +# Default: 1 +SIMULTANEOUS_TASKS=1 + +# GitHub App configuration +GITHUB_APP_ID=your_github_app_id +GITHUB_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +# Client ID of GitHub app +GITHUB_APP_CLIENT_ID=Iv23li65HEIkWZXsm6qO +# Generated in GitHub app settings +GITHUB_APP_CLIENT_SECRET=0663e20d484234e17b0871c1f070581739c14e04 diff --git a/workers/task-manager/README.md b/workers/task-manager/README.md new file mode 100644 index 00000000..236e6101 --- /dev/null +++ b/workers/task-manager/README.md @@ -0,0 +1,32 @@ +# Task Manager Worker + +Worker for automatically creating GitHub issues for errors that meet the threshold. + +## Description + +This worker processes tasks to automatically create GitHub issues for events that: +- Have `totalCount >= taskThresholdTotalCount` +- Don't have a `taskManagerItem` (not yet processed) +- Occurred after `taskManager.connectedAt` + +## Rate Limiting + +The worker implements daily rate limiting: +- Maximum `MAX_AUTO_TASKS_PER_DAY` (default: 10) tasks per project per day +- Uses atomic increment to prevent race conditions +- Resets daily budget at the start of each UTC day + +## Environment Variables + +- `MAX_AUTO_TASKS_PER_DAY` - Maximum auto tasks per day (default: 10) +- `GITHUB_APP_ID` - GitHub App ID +- `GITHUB_PRIVATE_KEY` - GitHub App private key (PEM format) + +## Usage + +The worker is triggered by cron tasks with routing key `cron-tasks/task-manager` and payload: +```json +{ + "type": "auto-task-creation" +} +``` diff --git a/workers/task-manager/package.json b/workers/task-manager/package.json new file mode 100644 index 00000000..e28d16a9 --- /dev/null +++ b/workers/task-manager/package.json @@ -0,0 +1,16 @@ +{ + "name": "hawk-worker-task-manager", + "version": "1.0.0", + "main": "src/index.ts", + "license": "MIT", + "workerType": "cron-tasks/task-manager", + "dependencies": { + "@octokit/oauth-methods": "^4.0.0", + "@octokit/rest": "^22.0.1", + "@octokit/types": "^16.0.0", + "jsonwebtoken": "^9.0.3" + }, + "devDependencies": { + "@types/jsonwebtoken": "^8.3.5" + } +} diff --git a/workers/task-manager/src/GithubService.ts b/workers/task-manager/src/GithubService.ts new file mode 100644 index 00000000..476a88c2 --- /dev/null +++ b/workers/task-manager/src/GithubService.ts @@ -0,0 +1,633 @@ +import jwt from 'jsonwebtoken'; +import { Octokit } from '@octokit/rest'; +import type { Endpoints } from '@octokit/types'; +import TimeMs from '../../../lib/utils/time'; +import { normalizeGitHubPrivateKey } from './utils/githubPrivateKey'; +import { refreshToken as refreshOAuthToken } from '@octokit/oauth-methods'; + +/** + * Type for GitHub Issue creation parameters + */ +export type IssueData = Pick< + Endpoints['POST /repos/{owner}/{repo}/issues']['parameters'], + 'title' | 'body' | 'labels' +>; + +/** + * Type for GitHub Issue response data + */ +export type GitHubIssue = Pick< + Endpoints['POST /repos/{owner}/{repo}/issues']['response']['data'], + 'number' | 'html_url' | 'title' | 'state' +>; + +/** + * Service for interacting with GitHub API from workers + * Simplified version of api/src/integrations/github/service.ts + * Only includes methods needed for creating issues + */ +export class GitHubService { + /** + * Default timeout for GitHub API requests (in milliseconds) + */ + private static readonly DEFAULT_TIMEOUT = 10000; + + /** + * Minutes in 10 minutes (JWT expiration time) + */ + private static readonly JWT_EXPIRATION_MINUTES = 10; + + /** + * Minutes for token refresh buffer + */ + private static readonly TOKEN_REFRESH_BUFFER_MINUTES = 5; + + /** + * Number of assignees to fetch in GraphQL query + */ + private static readonly ASSIGNEES_QUERY_LIMIT = 20; + + /** + * GitHub App ID from environment variables + */ + private readonly appId?: string; + + /** + * GitHub App Client ID from environment variables (optional, needed for token refresh) + */ + private readonly clientId?: string; + + /** + * GitHub App Client Secret from environment variables (optional, needed for token refresh) + */ + private readonly clientSecret?: string; + + /** + * Creates an instance of GitHubService + * Requires GitHub App authentication + */ + constructor() { + if (!process.env.GITHUB_APP_ID) { + throw new Error('GITHUB_APP_ID environment variable must be set'); + } + + this.appId = process.env.GITHUB_APP_ID; + + /** + * Client ID and Secret are optional but needed for token refresh + */ + if (process.env.GITHUB_APP_CLIENT_ID) { + this.clientId = process.env.GITHUB_APP_CLIENT_ID; + } + + if (process.env.GITHUB_APP_CLIENT_SECRET) { + this.clientSecret = process.env.GITHUB_APP_CLIENT_SECRET; + } + } + + /** + * Create a GitHub issue using GitHub App installation token + * + * @param {string} repoFullName - Repository full name (owner/repo) + * @param {string | null} installationId - GitHub App installation ID + * @param {IssueData} issueData - Issue data (title, body, labels) + * @returns {Promise} Created issue + * @throws {Error} If issue creation fails + */ + public async createIssue( + repoFullName: string, + installationId: string | null, + issueData: IssueData + ): Promise { + const [owner, repo] = repoFullName.split('/'); + + if (!owner || !repo) { + throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`); + } + + /** + * Get installation access token (GitHub App token) + */ + const accessToken = await this.getAuthToken(installationId); + + /** + * Create Octokit instance with installation token and configured timeout + */ + const octokit = this.createOctokit(accessToken); + + /** + * Create issue via REST API using installation token + */ + return this.createIssueViaRest(octokit, owner, repo, issueData); + } + + /** + * Assign Copilot agent to a GitHub issue using user-to-server OAuth token + * + * @param {string} repoFullName - Repository full name (owner/repo) + * @param {number} issueNumber - Issue number + * @param {string} delegatedUserToken - User-to-server OAuth token + * @returns {Promise} + * @throws {Error} If Copilot assignment fails + */ + public async assignCopilot( + repoFullName: string, + issueNumber: number, + delegatedUserToken: string + ): Promise { + const [owner, repo] = repoFullName.split('/'); + + if (!owner || !repo) { + throw new Error(`Invalid repository name format: ${repoFullName}. Expected format: owner/repo`); + } + + /** + * Create Octokit instance with user-to-server OAuth token + */ + const octokit = this.createOctokit(delegatedUserToken); + + try { + /** + * Step 1: Get repository ID and find Copilot bot ID + */ + const suggestedActorsLimit = GitHubService.ASSIGNEES_QUERY_LIMIT; + const repoInfoQuery = ` + query($owner: String!, $name: String!, $issueNumber: Int!) { + repository(owner: $owner, name: $name) { + id + issue(number: $issueNumber) { + id + } + suggestedActors(capabilities: [CAN_BE_ASSIGNED], first: ${suggestedActorsLimit}) { + nodes { + login + __typename + ... on Bot { + id + } + ... on User { + id + } + } + } + } + } + `; + + const repoInfo: any = await octokit.graphql(repoInfoQuery, { + owner, + name: repo, + issueNumber, + }); + + const JSON_INDENT_SPACES = 2; + + console.log('[GitHub API] Repository info query response:', JSON.stringify(repoInfo, null, JSON_INDENT_SPACES)); + + const repositoryId = repoInfo?.repository?.id; + const issueId = repoInfo?.repository?.issue?.id; + + if (!repositoryId) { + throw new Error(`Failed to get repository ID for ${repoFullName}`); + } + + if (!issueId) { + throw new Error(`Failed to get issue ID for issue #${issueNumber}`); + } + + /** + * Find Copilot bot in suggested actors + */ + let copilotBot = repoInfo.repository.suggestedActors.nodes.find( + (node: any) => node.login === 'copilot-swe-agent' + ); + + console.log('[GitHub API] Copilot bot found in suggestedActors:', copilotBot + ? { + login: copilotBot.login, + id: copilotBot.id, + } + : 'not found'); + + /** + * If not found in suggestedActors, try to get it directly by login + */ + if (!copilotBot || !copilotBot.id) { + console.log('[GitHub API] Trying to get Copilot bot directly by login...'); + + try { + const copilotBotQuery = ` + query($login: String!) { + user(login: $login) { + id + login + __typename + } + } + `; + + const copilotUserInfo: any = await octokit.graphql(copilotBotQuery, { + login: 'copilot-swe-agent', + }); + + console.log('[GitHub API] Direct Copilot bot query response:', JSON.stringify(copilotUserInfo, null, JSON_INDENT_SPACES)); + + if (copilotUserInfo?.user?.id) { + copilotBot = { + login: copilotUserInfo.user.login, + id: copilotUserInfo.user.id, + }; + } + } catch (directQueryError) { + console.log('[GitHub API] Failed to get Copilot bot directly:', directQueryError); + } + } + + if (!copilotBot || !copilotBot.id) { + throw new Error('Copilot coding agent (copilot-swe-agent) is not available for this repository'); + } + + console.log('[GitHub API] Using Copilot bot:', { + login: copilotBot.login, + id: copilotBot.id, + }); + + /** + * Step 2: Assign Copilot to issue via GraphQL + * Note: Assignable is a union type (Issue | PullRequest), so we need to use fragments + */ + const assignCopilotMutation = ` + mutation($issueId: ID!, $assigneeIds: [ID!]!) { + addAssigneesToAssignable(input: { + assignableId: $issueId + assigneeIds: $assigneeIds + }) { + assignable { + ... on Issue { + id + number + assignees(first: 10) { + nodes { + login + } + } + } + ... on PullRequest { + id + number + assignees(first: 10) { + nodes { + login + } + } + } + } + } + } + `; + + const response: any = await octokit.graphql(assignCopilotMutation, { + issueId, + assigneeIds: [ copilotBot.id ], + }); + + console.log('[GitHub API] Assign Copilot mutation response:', JSON.stringify(response, null, JSON_INDENT_SPACES)); + + const assignable = response?.addAssigneesToAssignable?.assignable; + + if (!assignable) { + throw new Error('Failed to assign Copilot to issue'); + } + + /** + * Assignable is a union type (Issue | PullRequest), so we need to check which type it is + * Both Issue and PullRequest have assignees field, so we can access it directly + * + * Note: The assignees list might not be immediately updated in the response, + * so we check if the mutation succeeded (assignable is not null) rather than + * verifying the assignees list directly + */ + const assignedLogins = assignable.assignees?.nodes?.map((n: any) => n.login) || []; + + /** + * Log assignees for debugging (but don't fail if Copilot is not in the list yet) + * GitHub API might not immediately reflect the assignment in the response + */ + console.log(`[GitHub API] Issue assignees after mutation:`, assignedLogins); + + /** + * Get issue number from assignable (works for both Issue and PullRequest) + */ + const assignedNumber = assignable.number; + + /** + * If Copilot is in the list, log success. Otherwise, just log a warning + * but don't throw an error, as the mutation might have succeeded even if + * the response doesn't show the assignee yet + */ + if (assignedLogins.includes('copilot-swe-agent')) { + console.log(`[GitHub API] Successfully assigned Copilot to issue #${assignedNumber}`); + } else { + /** + * Mutation succeeded (assignable is not null), but assignees list might not be updated yet + * This is a known behavior of GitHub API - the mutation succeeds but the response + * might not immediately reflect the new assignee + */ + console.log(`[GitHub API] Copilot assignment mutation completed for issue #${assignedNumber}, but assignees list not yet updated in response`); + } + } catch (error) { + throw new Error(`Failed to assign Copilot: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get valid access token with automatic refresh if needed + * Checks if token is expired or close to expiration and refreshes if necessary + * + * @param {Object} tokenInfo - Current token information + * @param {string} tokenInfo.accessToken - Current access token + * @param {string} tokenInfo.refreshToken - Refresh token + * @param {Date | null} tokenInfo.accessTokenExpiresAt - Access token expiration date + * @param {Date | null} tokenInfo.refreshTokenExpiresAt - Refresh token expiration date + * @param {Function} onRefresh - Callback to save refreshed tokens (called after successful refresh) + * @returns {Promise} Valid access token + * @throws {Error} If token refresh fails or refresh token is expired + */ + public async getValidAccessToken( + tokenInfo: { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date | null; + refreshTokenExpiresAt: Date | null; + }, + onRefresh?: (newTokens: { + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + refreshTokenExpiresAt: Date | null; + }) => Promise + ): Promise { + const now = new Date(); + const bufferTime = GitHubService.TOKEN_REFRESH_BUFFER_MINUTES * TimeMs.MINUTE; // 5 minutes buffer before expiration + + /** + * Check if access token is expired or close to expiration + */ + if (tokenInfo.accessTokenExpiresAt) { + const timeUntilExpiration = tokenInfo.accessTokenExpiresAt.getTime() - now.getTime(); + + if (timeUntilExpiration <= bufferTime) { + /** + * Token is expired or close to expiration, need to refresh + */ + if (!tokenInfo.refreshToken) { + throw new Error('Access token expired and no refresh token available'); + } + + /** + * Check if refresh token is expired + */ + if (tokenInfo.refreshTokenExpiresAt && tokenInfo.refreshTokenExpiresAt <= now) { + throw new Error('Refresh token is expired'); + } + + if (!this.clientId || !this.clientSecret) { + throw new Error('GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET are required for token refresh'); + } + + /** + * Refresh the token + */ + const newTokens = await this.refreshUserToken(tokenInfo.refreshToken); + + /** + * Save refreshed tokens if callback provided + */ + if (onRefresh) { + await onRefresh(newTokens); + } + + return newTokens.accessToken; + } + } + + /** + * Token is still valid, return it + */ + return tokenInfo.accessToken; + } + + /** + * Refresh user-to-server access token using refresh token + * Rotates refresh token if a new one is provided + * + * @param {string} refreshToken - OAuth refresh token + * @returns {Promise<{ accessToken: string; refreshToken: string; expiresAt: Date | null; refreshTokenExpiresAt: Date | null }>} New tokens + * @throws {Error} If token refresh fails + */ + public async refreshUserToken(refreshToken: string): Promise<{ + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + refreshTokenExpiresAt: Date | null; + }> { + if (!this.clientId || !this.clientSecret) { + throw new Error('GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET are required for token refresh'); + } + + try { + const { authentication } = await refreshOAuthToken({ + clientType: 'github-app', + clientId: this.clientId, + clientSecret: this.clientSecret, + refreshToken, + }); + + if (!authentication.token) { + throw new Error('No access token in refresh response'); + } + + /** + * refreshToken, expiresAt, and refreshTokenExpiresAt are only available in certain authentication types + * Use type guards to safely access these properties + */ + const newRefreshToken = 'refreshToken' in authentication && authentication.refreshToken + ? authentication.refreshToken + : refreshToken; // Use new refresh token if provided, otherwise keep old one + + return { + accessToken: authentication.token, + refreshToken: newRefreshToken, + expiresAt: 'expiresAt' in authentication && authentication.expiresAt + ? new Date(authentication.expiresAt) + : null, + refreshTokenExpiresAt: 'refreshTokenExpiresAt' in authentication && authentication.refreshTokenExpiresAt + ? new Date(authentication.refreshTokenExpiresAt) + : null, + }; + } catch (error) { + throw new Error(`Failed to refresh user token: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Create a GitHub issue via REST API (helper method) + * + * @param {Octokit} octokit - Octokit instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @param {IssueData} issueData - Issue data (title, body, labels) + * @returns {Promise} Created issue + * @throws {Error} If issue creation fails + */ + private async createIssueViaRest( + octokit: Octokit, + owner: string, + repo: string, + issueData: IssueData + ): Promise { + try { + const { data } = await octokit.rest.issues.create({ + owner, + repo, + title: issueData.title, + body: issueData.body, + labels: issueData.labels, + }); + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { html_url } = data; + + const JSON_INDENT_SPACES = 2; + + console.log('[GitHub API] Create issue response:', JSON.stringify({ + number: data.number, + // eslint-disable-next-line @typescript-eslint/naming-convention + html_url, + title: data.title, + state: data.state, + assignees: data.assignees?.map(a => a.login) || [], + }, null, JSON_INDENT_SPACES)); + + return { + number: data.number, + // eslint-disable-next-line @typescript-eslint/naming-convention + html_url, + title: data.title, + state: data.state, + }; + } catch (error) { + throw new Error(`Failed to create issue: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Create Octokit instance with configured timeout + * + * @param auth - Authentication token (JWT or installation access token) + * @returns Configured Octokit instance + */ + private createOctokit(auth: string): Octokit { + return new Octokit({ + auth, + request: { + timeout: GitHubService.DEFAULT_TIMEOUT, + headers: { + 'GraphQL-Features': 'issues_copilot_assignment_api_support', + }, + }, + }); + } + + /** + * Get private key from environment variables + * + * @returns {string} Private key in PEM format with real newlines + * @throws {Error} If GITHUB_PRIVATE_KEY is not set + */ + private getPrivateKey(): string { + if (process.env.GITHUB_PRIVATE_KEY) { + return normalizeGitHubPrivateKey(process.env.GITHUB_PRIVATE_KEY); + } + + throw new Error('GITHUB_PRIVATE_KEY must be set'); + } + + /** + * Create JWT token for GitHub App authentication + * + * @returns {string} JWT token + * @throws {Error} If GITHUB_APP_ID is not set + */ + private createJWT(): string { + if (!this.appId) { + throw new Error('GITHUB_APP_ID is required for GitHub App authentication'); + } + + const privateKey = this.getPrivateKey(); + const now = Math.floor(Date.now() / TimeMs.SECOND); + + /** + * JWT payload for GitHub App + * - iat: issued at time (current time) + * - exp: expiration time (10 minutes from now, GitHub allows up to 10 minutes) + * - iss: issuer (GitHub App ID) + */ + const secondsInMinute = TimeMs.MINUTE / TimeMs.SECOND; + const payload = { + iat: now - secondsInMinute, // Allow 1 minute clock skew + exp: now + (GitHubService.JWT_EXPIRATION_MINUTES * secondsInMinute), // 10 minutes expiration + iss: this.appId, + }; + + return jwt.sign(payload, privateKey, { algorithm: 'RS256' }); + } + + /** + * Get authentication token (installation access token) + * + * @param {string | null} installationId - GitHub App installation ID + * @returns {Promise} Authentication token + * @throws {Error} If token creation fails + */ + private async getAuthToken(installationId: string | null): Promise { + if (!installationId) { + throw new Error('installationId is required for GitHub App authentication'); + } + + console.log('[GitHub API] Using GitHub App authentication with installation ID:', installationId); + + return this.createInstallationToken(installationId); + } + + /** + * Get installation access token from GitHub API + * + * @param {string} installationId - GitHub App installation ID + * @returns {Promise} Installation access token (valid for 1 hour) + * @throws {Error} If token creation fails + */ + private async createInstallationToken(installationId: string): Promise { + const token = this.createJWT(); + + /** + * Create Octokit instance with JWT authentication and configured timeout + */ + const octokit = this.createOctokit(token); + + try { + /** + * Request installation access token + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + const { data } = await octokit.rest.apps.createInstallationAccessToken({ + // eslint-disable-next-line @typescript-eslint/naming-convention + installation_id: parseInt(installationId, 10), + }); + + return data.token; + } catch (error) { + throw new Error(`Failed to create installation token: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/workers/task-manager/src/env.ts b/workers/task-manager/src/env.ts new file mode 100644 index 00000000..ad36e93e --- /dev/null +++ b/workers/task-manager/src/env.ts @@ -0,0 +1,7 @@ +import * as path from 'path'; +import * as dotenv from 'dotenv'; + +/** + * Load environment variables from .env file + */ +dotenv.config({ path: path.resolve(__dirname, '../.env') }); diff --git a/workers/task-manager/src/index.ts b/workers/task-manager/src/index.ts new file mode 100644 index 00000000..d6dec032 --- /dev/null +++ b/workers/task-manager/src/index.ts @@ -0,0 +1,690 @@ +import './env'; +import { ObjectId } from 'mongodb'; +import { DatabaseController } from '../../../lib/db/controller'; +import { Worker } from '../../../lib/worker'; +import * as pkg from '../package.json'; +import type { TaskManagerWorkerTask } from '../types/task-manager-worker-task'; +import type { + ProjectDBScheme, + GroupedEventDBScheme, + ProjectTaskManagerConfig +} from '@hawk.so/types'; +import type { TaskManagerItem } from '@hawk.so/types/src/base/event/taskManagerItem'; +import HawkCatcher from '@hawk.so/nodejs'; +import { decodeUnsafeFields } from '../../../lib/utils/unsafeFields'; +import { GitHubService } from './GithubService'; +import { formatIssueFromEvent } from './utils/issue'; +import TimeMs from '../../../lib/utils/time'; + +/** + * Default maximum number of auto-created tasks per project per day + */ +const DEFAULT_MAX_AUTO_TASKS_PER_DAY = 10; + +/** + * Maximum number of auto-created tasks per project per day + */ +const MAX_AUTO_TASKS_PER_DAY = parseInt(process.env.MAX_AUTO_TASKS_PER_DAY, 10) || DEFAULT_MAX_AUTO_TASKS_PER_DAY; + +/** + * Worker for automatically creating GitHub issues for errors that meet the threshold + */ +export default class TaskManagerWorker extends Worker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Database Controller for accounts database + */ + private accountsDb: DatabaseController = new DatabaseController(process.env.MONGO_ACCOUNTS_DATABASE_URI); + + /** + * Database Controller for events database + */ + private eventsDb: DatabaseController = new DatabaseController(process.env.MONGO_EVENTS_DATABASE_URI); + + /** + * GitHub Service for creating issues + */ + private githubService: GitHubService = new GitHubService(); + + /** + * Start consuming messages + */ + public async start(): Promise { + await this.accountsDb.connect(); + await this.eventsDb.connect(); + + await super.start(); + this.handle({ type: 'auto-task-creation' }); + } + + /** + * Finish everything + */ + public async finish(): Promise { + await this.accountsDb.close(); + await this.eventsDb.close(); + await super.finish(); + } + + /** + * Task handling function + * + * @param task - task manager task to handle + */ + public async handle(task: TaskManagerWorkerTask): Promise { + try { + this.logger.info('Starting task manager worker', { taskType: task.type }); + + /** + * Get all projects with GitHub task manager enabled + */ + const projects = await this.getProjectsWithTaskManager(); + + this.logger.info(`Found ${projects.length} projects with task manager enabled`); + + /** + * Process each project + */ + for (const project of projects) { + await this.processProject(project); + } + + this.logger.info('Task manager worker completed'); + } catch (error) { + this.logger.error('Failed to handle task manager task:', error); + + HawkCatcher.send(error as Error, { + taskType: task.type, + }); + } + } + + /** + * Get all projects with task manager enabled + * + * @returns Promise with array of projects + */ + private async getProjectsWithTaskManager(): Promise { + const connection = await this.accountsDb.getConnection(); + const projectsCollection = connection.collection('projects'); + + const projects = await projectsCollection.find({ + 'taskManager.type': 'github', + 'taskManager.autoTaskEnabled': true, + 'taskManager.config.repoId': { + $exists: true, + $ne: null, + }, + 'taskManager.config.repoFullName': { + $exists: true, + $ne: null, + }, + }).toArray(); + + return projects; + } + + /** + * Process a single project + * + * @param project - project to process + */ + private async processProject(project: ProjectDBScheme): Promise { + const projectId = project._id.toString(); + const taskManager = project.taskManager as ProjectTaskManagerConfig; + + if (!taskManager) { + this.logger.warn(`Project ${projectId} has no task manager config`); + + return; + } + + this.logger.info(`Processing project ${projectId}`, { + repoFullName: taskManager.config.repoFullName, + threshold: taskManager.taskThresholdTotalCount, + }); + + /** + * Calculate day start UTC for today + */ + const now = new Date(); + const dayStartUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + + /** + * Check and reset usage if dayStartUtc differs + */ + const usage = taskManager.usage; + const shouldResetUsage = !usage || usage.dayStartUtc.getTime() !== dayStartUtc.getTime(); + + let currentUsage: { autoTasksCreated: number }; + + if (shouldResetUsage) { + this.logger.info(`Resetting usage for project ${projectId}`, { + oldDayStartUtc: usage?.dayStartUtc, + newDayStartUtc: dayStartUtc, + }); + + await this.resetUsage(projectId, dayStartUtc); + + /** + * After reset, usage is 0 + */ + currentUsage = { autoTasksCreated: 0 }; + } else { + /** + * Use usage from already loaded project + */ + currentUsage = { + autoTasksCreated: usage.autoTasksCreated || 0, + }; + } + + /** + * Check if budget is available + */ + if (currentUsage.autoTasksCreated >= MAX_AUTO_TASKS_PER_DAY) { + this.logger.info(`Project ${projectId} has reached daily budget limit`, { + autoTasksCreated: currentUsage.autoTasksCreated, + maxAutoTasksPerDay: MAX_AUTO_TASKS_PER_DAY, + }); + + return; + } + + /** + * Calculate remaining budget + */ + const remainingBudget = MAX_AUTO_TASKS_PER_DAY - currentUsage.autoTasksCreated; + + this.logger.info(`Project ${projectId} has remaining budget`, { + autoTasksCreated: currentUsage.autoTasksCreated, + remainingBudget, + }); + + /** + * Find events that need task creation + */ + const events = await this.findEventsForTaskCreation( + projectId, + taskManager.connectedAt, + taskManager.taskThresholdTotalCount + ); + + this.logger.info(`Found ${events.length} events for task creation in project ${projectId}`); + + /** + * Process events up to remaining budget + */ + const eventsToProcess = events.slice(0, remainingBudget); + + for (const event of eventsToProcess) { + await this.processEventForAutoTaskCreation({ + project, + projectId, + taskManager, + event, + dayStartUtc, + }); + } + } + + /** + * Process a single event for auto task creation + * + * @param params - method params + * @param params.project - project + * @param params.projectId - project id + * @param params.taskManager - task manager config + * @param params.event - grouped event + * @param params.dayStartUtc - day start UTC used for usage increment + */ + private async processEventForAutoTaskCreation(params: { + project: ProjectDBScheme; + projectId: string; + taskManager: ProjectTaskManagerConfig; + event: GroupedEventDBScheme; + dayStartUtc: Date; + }): Promise { + const { project, projectId, taskManager, event, dayStartUtc } = params; + + /** + * Format Issue data from event + */ + const issueData = formatIssueFromEvent(event, project); + + /** + * Step 1: Create GitHub Issue using installation token (GitHub App) + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + let githubIssue: { number: number; html_url: string }; + + try { + githubIssue = await this.githubService.createIssue( + taskManager.config.repoFullName, + taskManager.config.installationId, + issueData + ); + } catch (error) { + /** + * Log error message only, not the full error object to avoid logging tokens + */ + this.logger.error(`Failed to create GitHub issue for event ${event.groupHash} (project ${projectId}): ${error instanceof Error ? error.message : String(error)}`); + + /** + * Do not increment usage and do not save taskManagerItem if issue creation failed + */ + return; + } + + /** + * Atomically increment usage.autoTasksCreated (only after successful issue creation) + */ + const incrementSuccess = await this.incrementAutoTasksCreated(projectId, dayStartUtc); + + if (!incrementSuccess) { + this.logger.warn( + `Issue #${githubIssue.number} was created but usage increment failed for project ${projectId} (budget may be exhausted)` + ); + + /** + * We still link the created issue to the event to avoid duplicates. + */ + } + + /** + * Save taskManagerItem to event (independent of Copilot assignment) + * We determine assignee status based on whether assignAgent is enabled, + * not whether assignment actually succeeded + */ + let copilotAssigned = false; + + /** + * Step 2: Assign Copilot agent if enabled (using user-to-server OAuth token) + */ + if (taskManager.assignAgent && taskManager.config.delegatedUser?.accessToken) { + try { + await this.executeWithTokenRefresh({ + projectId, + taskManager, + operationName: 'assign Copilot', + operation: async (token) => { + await this.githubService.assignCopilot( + taskManager.config.repoFullName, + githubIssue.number, + token + ); + }, + }); + copilotAssigned = true; + } catch (error) { + /** + * Log error but don't fail the whole operation - issue was created successfully + */ + this.logger.warn(`Failed to assign Copilot to issue #${githubIssue.number}: ${error instanceof Error ? error.message : String(error)}`); + copilotAssigned = false; + } + } + + /** + * Save taskManagerItem to event + * Note: assignee is set based on whether assignment was attempted and succeeded, + * not just whether assignAgent is enabled + */ + await this.saveTaskManagerItem( + projectId, + event, + githubIssue.number, + taskManager, + githubIssue.html_url, + copilotAssigned + ); + + this.logger.info(`Created task for event ${event.groupHash} in project ${projectId}`, { + issueNumber: githubIssue.number, + issueUrl: githubIssue.html_url, + assignAgent: taskManager.assignAgent, + }); + } + + /** + * Update delegatedUser tokens in project + * + * @param {string} projectId - Project ID + * @param {Object} tokenData - New token data + * @returns {Promise} + */ + private async updateDelegatedUserTokens( + projectId: string, + tokenData: { + accessToken: string; + refreshToken: string; + expiresAt: Date | null; + refreshTokenExpiresAt: Date | null; + tokenLastValidatedAt: Date; + } + ): Promise { + const connection = await this.accountsDb.getConnection(); + const projectsCollection = connection.collection('projects'); + + await projectsCollection.updateOne( + { _id: new ObjectId(projectId) }, + { + $set: { + 'taskManager.config.delegatedUser.accessToken': tokenData.accessToken, + 'taskManager.config.delegatedUser.refreshToken': tokenData.refreshToken, + 'taskManager.config.delegatedUser.accessTokenExpiresAt': tokenData.expiresAt, + 'taskManager.config.delegatedUser.refreshTokenExpiresAt': tokenData.refreshTokenExpiresAt, + 'taskManager.config.delegatedUser.tokenLastValidatedAt': tokenData.tokenLastValidatedAt, + 'taskManager.updatedAt': new Date(), + }, + } + ); + } + + /** + * Execute a GitHub API operation with automatic token refresh on 401 errors + * This function handles token refresh and retry logic for operations that may fail with 401 + * + * @param {Object} params - Parameters for the operation + * @param {string} params.projectId - Project ID + * @param {ProjectTaskManagerConfig} params.taskManager - Task manager configuration + * @param {Function} params.operation - Async function that performs the GitHub API operation + * @param {string} params.operationName - Name of the operation for logging (e.g., "create issue") + * @returns {Promise} Result of the operation + * @throws {Error} If operation fails and token refresh doesn't help + */ + private async executeWithTokenRefresh(params: { + projectId: string; + taskManager: ProjectTaskManagerConfig; + operation: (token: string | null) => Promise; + operationName: string; + }): Promise { + const { projectId, taskManager, operation, operationName } = params; + + /** + * Get valid access token with automatic refresh if needed + */ + let delegatedUserToken: string | null = null; + + if (taskManager.config.delegatedUser?.status === 'active') { + const delegatedUser = taskManager.config.delegatedUser; + + try { + /** + * Get valid access token with automatic refresh if needed + */ + delegatedUserToken = await this.githubService.getValidAccessToken( + { + accessToken: delegatedUser.accessToken, + refreshToken: delegatedUser.refreshToken, + accessTokenExpiresAt: delegatedUser.accessTokenExpiresAt + ? new Date(delegatedUser.accessTokenExpiresAt) + : null, + refreshTokenExpiresAt: delegatedUser.refreshTokenExpiresAt + ? new Date(delegatedUser.refreshTokenExpiresAt) + : null, + }, + /** + * Callback to save refreshed tokens in database + * + * @param newTokens + */ + async (newTokens) => { + await this.updateDelegatedUserTokens(projectId, { + ...newTokens, + tokenLastValidatedAt: new Date(), + }); + + this.logger.info(`Refreshed and saved new tokens for project ${projectId}`); + } + ); + } catch (refreshError) { + /** + * Log error message only, not the full error object to avoid logging tokens + */ + this.logger.warn(`Failed to refresh token for project ${projectId}, falling back to installation token: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); + + /** + * If refresh fails, fall back to installation token + */ + delegatedUserToken = null; + } + } + + /** + * Try to execute the operation + */ + try { + return await operation(delegatedUserToken); + } catch (error) { + /** + * Check if error is 401 (unauthorized) - token might be revoked + * Try to refresh token and retry once + */ + const HTTP_UNAUTHORIZED = 401; + + if (error?.status === HTTP_UNAUTHORIZED && taskManager.config.delegatedUser?.status === 'active') { + const delegatedUser = taskManager.config.delegatedUser; + + this.logger.warn(`Received 401 error for project ${projectId} during ${operationName}, attempting token refresh...`); + + try { + /** + * Refresh token + */ + const newTokens = await this.githubService.refreshUserToken(delegatedUser.refreshToken); + + /** + * Save refreshed tokens + */ + await this.updateDelegatedUserTokens(projectId, { + ...newTokens, + tokenLastValidatedAt: new Date(), + }); + + /** + * Retry operation with new token + */ + const result = await operation(newTokens.accessToken); + + this.logger.info(`Successfully refreshed token and completed ${operationName} for project ${projectId}`); + + return result; + } catch (refreshError) { + /** + * Refresh failed, mark token as revoked + * Log error message only, not the full error object to avoid logging tokens + */ + this.logger.error(`Failed to refresh token for project ${projectId}: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`); + + await this.markDelegatedUserAsRevoked(projectId); + + /** + * Re-throw original error + */ + throw error; + } + } else { + /** + * Not a 401 error or no delegatedUser, re-throw original error + */ + throw error; + } + } + } + + /** + * Mark delegatedUser as revoked + * + * @param {string} projectId - Project ID + * @returns {Promise} + */ + private async markDelegatedUserAsRevoked(projectId: string): Promise { + const connection = await this.accountsDb.getConnection(); + const projectsCollection = connection.collection('projects'); + + await projectsCollection.updateOne( + { _id: new ObjectId(projectId) }, + { + $set: { + 'taskManager.config.delegatedUser.status': 'revoked', + 'taskManager.updatedAt': new Date(), + }, + } + ); + } + + /** + * Reset usage for a project + * + * @param projectId - project ID + * @param dayStartUtc - new day start UTC + */ + private async resetUsage(projectId: string, dayStartUtc: Date): Promise { + const connection = await this.accountsDb.getConnection(); + const projectsCollection = connection.collection('projects'); + + await projectsCollection.updateOne( + { _id: new ObjectId(projectId) }, + { + $set: { + 'taskManager.usage': { + dayStartUtc, + autoTasksCreated: 0, + }, + }, + } + ); + } + + /** + * Atomically increment autoTasksCreated + * + * @param projectId - project ID + * @param dayStartUtc - day start UTC + * @returns Promise with true if increment was successful, false if budget exhausted + */ + private async incrementAutoTasksCreated(projectId: string, dayStartUtc: Date): Promise { + const connection = await this.accountsDb.getConnection(); + const projectsCollection = connection.collection('projects'); + + /** + * Use findOneAndUpdate with condition to atomically increment + * Only increment if autoTasksCreated < MAX_AUTO_TASKS_PER_DAY + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (projectsCollection.findOneAndUpdate( + { + _id: new ObjectId(projectId), + 'taskManager.usage.dayStartUtc': dayStartUtc, + $or: [ + { 'taskManager.usage.autoTasksCreated': { $exists: false } }, + { 'taskManager.usage.autoTasksCreated': { $lt: MAX_AUTO_TASKS_PER_DAY } }, + ], + }, + { + $inc: { 'taskManager.usage.autoTasksCreated': 1 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + { + returnDocument: 'after', + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) as any); + + return result?.value !== null && result?.value !== undefined; + } + + /** + * Find events that need task creation + * + * @param projectId - project ID + * @param connectedAt - task manager connection date + * @param threshold - minimum totalCount threshold + * @returns Promise with array of events + */ + private async findEventsForTaskCreation( + projectId: string, + connectedAt: Date, + threshold: number + ): Promise { + const connection = await this.eventsDb.getConnection(); + const eventsCollection = connection.collection(`events:${projectId}`); + + /** + * Convert connectedAt to timestamp (seconds) + */ + const connectedAtTimestamp = Math.floor(connectedAt.getTime() / TimeMs.SECOND); + + const events = await eventsCollection + .find({ + taskManagerItem: { $exists: false }, + timestamp: { $gte: connectedAtTimestamp }, + totalCount: { $gte: threshold }, + }) + .sort({ + totalCount: -1, + timestamp: -1, + }) + .toArray(); + + return events; + } + + /** + * Save taskManagerItem to event + * + * @param projectId - project ID + * @param event - event to save taskManagerItem to + * @param issueNumber - GitHub issue number + * @param taskManager - task manager config + * @param issueUrl - GitHub issue URL + * @param copilotAssigned - whether Copilot was successfully assigned + */ + private async saveTaskManagerItem( + projectId: string, + event: GroupedEventDBScheme, + issueNumber: number, + taskManager: ProjectTaskManagerConfig, + issueUrl: string, + copilotAssigned = false + ): Promise { + const connection = await this.eventsDb.getConnection(); + const eventsCollection = connection.collection(`events:${projectId}`); + + /** + * Decode unsafe fields to get actual title + */ + const decodedEvent = { ...event }; + + decodeUnsafeFields(decodedEvent); + + const taskManagerItem: TaskManagerItem = { + type: 'github-issue', + number: issueNumber, + url: issueUrl, + title: decodedEvent.payload.title, + createdBy: 'auto', + createdAt: new Date(), + assignee: copilotAssigned ? 'copilot' : null, + }; + + await eventsCollection.updateOne( + { _id: event._id }, + { + $set: { + taskManagerItem, + }, + } + ); + + this.logger.info(`Saved taskManagerItem for event ${event.groupHash}`, { + issueNumber, + url: taskManagerItem.url, + }); + } +} diff --git a/workers/task-manager/src/utils/githubPrivateKey.ts b/workers/task-manager/src/utils/githubPrivateKey.ts new file mode 100644 index 00000000..7dfd6872 --- /dev/null +++ b/workers/task-manager/src/utils/githubPrivateKey.ts @@ -0,0 +1,58 @@ +/** + * Normalize and validate GitHub App private key. + * + * @param rawPrivateKey - raw value from env (GITHUB_PRIVATE_KEY) + * @returns PEM-encoded private key string + */ +export function normalizeGitHubPrivateKey(rawPrivateKey: string): string { + /** + * Trim and remove surrounding quotes (dotenv can keep them) + */ + let privateKey = rawPrivateKey.trim(); + + if ( + (privateKey.startsWith('"') && privateKey.endsWith('"')) || + (privateKey.startsWith('\'') && privateKey.endsWith('\'')) + ) { + privateKey = privateKey.slice(1, -1); + } + + /** + * Support passing base64-encoded private key (common in CI). + * If it doesn't look like a PEM block but looks like base64, decode it. + */ + const MIN_BASE64_KEY_LENGTH = 200; + + if (!privateKey.includes('BEGIN') && /^[A-Za-z0-9+/=\s]+$/.test(privateKey) && privateKey.length > MIN_BASE64_KEY_LENGTH) { + try { + privateKey = Buffer.from(privateKey, 'base64').toString('utf8'); + } catch { + /** + * Keep original value, we'll validate below. + */ + } + } + + /** + * Replace literal "\n" sequences with actual newlines. + */ + if (privateKey.includes('\\n') && !privateKey.includes('\n')) { + privateKey = privateKey.replace(/\\n/g, '\n'); + } + + /** + * Normalize Windows line endings if any. + */ + privateKey = privateKey.replace(/\r\n/g, '\n'); + + /** + * Basic validation: must be a PEM private key. + */ + if (!privateKey.includes('-----BEGIN') || !privateKey.includes('PRIVATE KEY-----')) { + throw new Error( + 'GITHUB_PRIVATE_KEY must be a valid PEM-encoded private key (-----BEGIN ... PRIVATE KEY----- ... -----END ... PRIVATE KEY-----)' + ); + } + + return privateKey; +} diff --git a/workers/task-manager/src/utils/issue.ts b/workers/task-manager/src/utils/issue.ts new file mode 100644 index 00000000..c6298edc --- /dev/null +++ b/workers/task-manager/src/utils/issue.ts @@ -0,0 +1,260 @@ +import type { GroupedEventDBScheme, ProjectDBScheme } from '@hawk.so/types'; +import { decodeUnsafeFields } from '../../../../lib/utils/unsafeFields'; +import TimeMs from '../../../../lib/utils/time'; +import type { IssueData } from '../GithubService'; + +/** + * Width used for padding date/time parts. + */ +const DATE_TIME_PART_WIDTH = 2; + +/** + * Number of spaces used for JSON pretty-printing. + */ +const JSON_INDENT_SPACES = 2; + +/** + * Format date for display in GitHub issue + * + * @param timestamp - Unix timestamp in seconds + * @returns {string} Formatted date string (e.g., "23 Feb 2025 14:40:21") + */ +function formatDate(timestamp: number): string { + const date = new Date(timestamp * TimeMs.SECOND); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const day = date.getUTCDate(); + const month = months[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const hours = date.getUTCHours().toString() + .padStart(DATE_TIME_PART_WIDTH, '0'); + const minutes = date.getUTCMinutes().toString() + .padStart(DATE_TIME_PART_WIDTH, '0'); + const seconds = date.getUTCSeconds().toString() + .padStart(DATE_TIME_PART_WIDTH, '0'); + + return `${day} ${month} ${year} ${hours}:${minutes}:${seconds}`; +} + +/** + * Calculate days repeating from timestamp + * + * @param timestamp - Unix timestamp in seconds + * @returns {number} Number of days since first occurrence + */ +function calculateDaysRepeating(timestamp: number): number { + const now = Date.now(); + const eventTimestamp = timestamp * TimeMs.SECOND; + const differenceInDays = (now - eventTimestamp) / TimeMs.DAY; + + return Math.round(differenceInDays); +} + +/** + * Format source code as diff with line numbers + * The error line is marked with minus sign, other lines with space + * + * @param sourceCode - Array of source code lines + * @param errorLine - Line number where error occurred + * @returns {string} Formatted diff string + */ +function formatSourceCodeAsDiff(sourceCode: Array<{ line: number; content: string }>, errorLine: number): string { + const lines: string[] = []; + + /** + * Use the widest line number among provided source lines and error line. + * This keeps alignment correct for both small and large line numbers. + */ + const maxLineNumber = sourceCode.reduce((maxLine, current) => { + if (current.line > maxLine) { + return current.line; + } + + return maxLine; + }, errorLine); + const lineNumberWidth = String(maxLineNumber).length; + + for (const sourceLine of sourceCode) { + const lineNumber = sourceLine.line.toString().padStart(lineNumberWidth, ' '); + const isErrorLine = sourceLine.line === errorLine; + const prefix = isErrorLine ? '-' : ' '; + + /** + * Do not escape HTML here because content is rendered inside Markdown code block. + */ + const content = sourceLine.content ?? ''; + + lines.push(`${prefix}${lineNumber}: ${content}`); + } + + return lines.join('\n'); +} + +/** + * Format GitHub Issue from event + * + * @param event - event to format issue for + * @param project - project + * @returns {IssueData} Issue data for GitHub API + */ +export function formatIssueFromEvent(event: GroupedEventDBScheme, project: ProjectDBScheme): IssueData { + /** + * Decode unsafe fields (context, addons) if they are strings + */ + const decodedEvent = { ...event }; + + decodeUnsafeFields(decodedEvent); + + const projectId = project._id.toString(); + const garageUrl = process.env.GARAGE_URL || 'https://garage.hawk.so'; + const eventUrl = `${garageUrl}/project/${projectId}/event/${event.groupHash}`; + + /** + * Format title: [Hawk] ${event.payload.title} + */ + const title = `[Hawk] ${decodedEvent.payload.title}`; + + /** + * Format body according to the template: + * - H2 header + * - Stacktrace with first frame expanded, others in details + * - Table with event data + * - Context and Addons as JSON + * - Link to event in Hawk + */ + const bodyParts: string[] = []; + + /** + * H2 header with title + */ + bodyParts.push(`## ${decodedEvent.payload.title}`); + + /** + * Stacktrace section + */ + if (decodedEvent.payload.backtrace && decodedEvent.payload.backtrace.length > 0) { + const firstFrame = decodedEvent.payload.backtrace[0]; + const file = firstFrame.file || ''; + const line = firstFrame.line || 0; + const column = firstFrame.column || 0; + const func = firstFrame.function || ''; + + /** + * First frame - always visible + */ + bodyParts.push(`\n- at ${func} (${file}:${line}:${column})`); + + /** + * Source code for first frame in diff format + */ + if (firstFrame.sourceCode && firstFrame.sourceCode.length > 0) { + bodyParts.push('\n```diff'); + bodyParts.push(formatSourceCodeAsDiff(firstFrame.sourceCode, line)); + bodyParts.push('```'); + } + + /** + * Additional frames in details section + */ + if (decodedEvent.payload.backtrace.length > 1) { + bodyParts.push('\n
'); + bodyParts.push(' View full stack trace'); + bodyParts.push(' \n'); + + for (let i = 1; i < decodedEvent.payload.backtrace.length; i++) { + const frame = decodedEvent.payload.backtrace[i]; + const frameFile = frame.file || ''; + const frameLine = frame.line || 0; + const frameColumn = frame.column || 0; + const frameFunc = frame.function || ''; + + bodyParts.push(`- at ${frameFunc} (${frameFile}:${frameLine}:${frameColumn})`); + + /** + * Source code for this frame in diff format + */ + if (frame.sourceCode && frame.sourceCode.length > 0) { + bodyParts.push('\n```diff'); + bodyParts.push(formatSourceCodeAsDiff(frame.sourceCode, frameLine)); + bodyParts.push('```'); + } + + /** + * Add newline between frames if not last + */ + if (i < decodedEvent.payload.backtrace.length - 1) { + bodyParts.push(''); + } + } + + bodyParts.push('\n
'); + } + } + + /** + * Table with event data + */ + const sinceDate = formatDate(decodedEvent.timestamp); + const daysRepeating = calculateDaysRepeating(decodedEvent.timestamp); + + bodyParts.push('\n| Param | Value |'); + bodyParts.push('| -- | :--: |'); + bodyParts.push(`| Since | ${sinceDate} |`); + bodyParts.push(`| Days Repeating | ${daysRepeating} |`); + bodyParts.push(`| Total Occurrences | ${decodedEvent.totalCount} |`); + bodyParts.push(`| Users Affected | ${decodedEvent.usersAffected || '-'} |`); + + /** + * Context and Addons sections in details + */ + if (decodedEvent.payload.context || decodedEvent.payload.addons) { + bodyParts.push('\n
'); + bodyParts.push(' View Context and Addons'); + bodyParts.push(' \n'); + + /** + * Context section + */ + if (decodedEvent.payload.context) { + bodyParts.push('### Context'); + bodyParts.push('\n```json'); + bodyParts.push(JSON.stringify(decodedEvent.payload.context, null, JSON_INDENT_SPACES)); + bodyParts.push('```'); + } + + /** + * Addons section + */ + if (decodedEvent.payload.addons) { + bodyParts.push('\n### Addons'); + bodyParts.push('\n```json'); + bodyParts.push(JSON.stringify(decodedEvent.payload.addons, null, JSON_INDENT_SPACES)); + bodyParts.push('```'); + } + + bodyParts.push('\n
'); + } + + /** + * Link to event in Hawk + */ + bodyParts.push('\n### Details'); + bodyParts.push(`\n[View in Hawk](${eventUrl})`); + + /** + * Technical marker for tracking + */ + bodyParts.push(`\n\n`); + + const body = bodyParts.join('\n'); + + /** + * Labels: hawk:error + */ + const labels = [ 'hawk:error' ]; + + return { + title, + body, + labels, + }; +} diff --git a/workers/task-manager/types/task-manager-worker-task.ts b/workers/task-manager/types/task-manager-worker-task.ts new file mode 100644 index 00000000..e2a81e53 --- /dev/null +++ b/workers/task-manager/types/task-manager-worker-task.ts @@ -0,0 +1,11 @@ +import { WorkerTask } from '../../../lib/types/worker-task'; + +/** + * TaskManagerWorker task description + */ +export interface TaskManagerWorkerTask extends WorkerTask { + /** + * Task type + */ + type: 'auto-task-creation'; +} diff --git a/yarn.lock b/yarn.lock index 3fccdbef..2232af0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -714,6 +714,155 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@octokit/auth-token@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-6.0.0.tgz#b02e9c08a2d8937df09a2a981f226ad219174c53" + integrity sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== + +"@octokit/core@^7.0.6": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.6.tgz#0d58704391c6b681dec1117240ea4d2a98ac3916" + integrity sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q== + dependencies: + "@octokit/auth-token" "^6.0.0" + "@octokit/graphql" "^9.0.3" + "@octokit/request" "^10.0.6" + "@octokit/request-error" "^7.0.2" + "@octokit/types" "^16.0.0" + before-after-hook "^4.0.0" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.2.tgz#a8d955e053a244938b81d86cd73efd2dcb5ef5af" + integrity sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ== + dependencies: + "@octokit/types" "^16.0.0" + universal-user-agent "^7.0.2" + +"@octokit/endpoint@^9.0.6": + version "9.0.6" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-9.0.6.tgz#114d912108fe692d8b139cfe7fc0846dfd11b6c0" + integrity sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw== + dependencies: + "@octokit/types" "^13.1.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^9.0.3": + version "9.0.3" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.3.tgz#5b8341c225909e924b466705c13477face869456" + integrity sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA== + dependencies: + "@octokit/request" "^10.0.6" + "@octokit/types" "^16.0.0" + universal-user-agent "^7.0.0" + +"@octokit/oauth-authorization-url@^6.0.2": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz#cc82ca29cc5e339c9921672f39f2b3f5c8eb6ef2" + integrity sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA== + +"@octokit/oauth-methods@^4.0.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@octokit/oauth-methods/-/oauth-methods-4.1.0.tgz#1403ac9c4d4e277922fddc4c89fa8a782f8f791b" + integrity sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw== + dependencies: + "@octokit/oauth-authorization-url" "^6.0.2" + "@octokit/request" "^8.3.1" + "@octokit/request-error" "^5.1.0" + "@octokit/types" "^13.0.0" + btoa-lite "^1.0.0" + +"@octokit/openapi-types@^24.2.0": + version "24.2.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-24.2.0.tgz#3d55c32eac0d38da1a7083a9c3b0cca77924f7d3" + integrity sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg== + +"@octokit/openapi-types@^27.0.0": + version "27.0.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-27.0.0.tgz#374ea53781965fd02a9d36cacb97e152cefff12d" + integrity sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA== + +"@octokit/plugin-paginate-rest@^14.0.0": + version "14.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz#44dc9fff2dacb148d4c5c788b573ddc044503026" + integrity sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw== + dependencies: + "@octokit/types" "^16.0.0" + +"@octokit/plugin-request-log@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz#de1c1e557df6c08adb631bf78264fa741e01b317" + integrity sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q== + +"@octokit/plugin-rest-endpoint-methods@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz#8c54397d3a4060356a1c8a974191ebf945924105" + integrity sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw== + dependencies: + "@octokit/types" "^16.0.0" + +"@octokit/request-error@^5.1.0", "@octokit/request-error@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-5.1.1.tgz#b9218f9c1166e68bb4d0c89b638edc62c9334805" + integrity sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g== + dependencies: + "@octokit/types" "^13.1.0" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request-error@^7.0.2": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.1.0.tgz#440fa3cae310466889778f5a222b47a580743638" + integrity sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw== + dependencies: + "@octokit/types" "^16.0.0" + +"@octokit/request@^10.0.6": + version "10.0.7" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.7.tgz#93f619914c523750a85e7888de983e1009eb03f6" + integrity sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA== + dependencies: + "@octokit/endpoint" "^11.0.2" + "@octokit/request-error" "^7.0.2" + "@octokit/types" "^16.0.0" + fast-content-type-parse "^3.0.0" + universal-user-agent "^7.0.2" + +"@octokit/request@^8.3.1": + version "8.4.1" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-8.4.1.tgz#715a015ccf993087977ea4365c44791fc4572486" + integrity sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw== + dependencies: + "@octokit/endpoint" "^9.0.6" + "@octokit/request-error" "^5.1.1" + "@octokit/types" "^13.1.0" + universal-user-agent "^6.0.0" + +"@octokit/rest@^22.0.1": + version "22.0.1" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-22.0.1.tgz#4d866c32b76b711d3f736f91992e2b534163b416" + integrity sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw== + dependencies: + "@octokit/core" "^7.0.6" + "@octokit/plugin-paginate-rest" "^14.0.0" + "@octokit/plugin-request-log" "^6.0.0" + "@octokit/plugin-rest-endpoint-methods" "^17.0.0" + +"@octokit/types@^13.0.0", "@octokit/types@^13.1.0": + version "13.10.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.10.0.tgz#3e7c6b19c0236c270656e4ea666148c2b51fd1a3" + integrity sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA== + dependencies: + "@octokit/openapi-types" "^24.2.0" + +"@octokit/types@^16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-16.0.0.tgz#fbd7fa590c2ef22af881b1d79758bfaa234dbb7c" + integrity sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg== + dependencies: + "@octokit/openapi-types" "^27.0.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -1012,6 +1161,13 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/jsonwebtoken@^8.3.5": + version "8.5.9" + resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz#2c064ecb0b3128d837d2764aa0b117b0ff6e4586" + integrity sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg== + dependencies: + "@types/node" "*" + "@types/mongodb@^3.5.15", "@types/mongodb@^3.5.34": version "3.6.20" resolved "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz" @@ -1739,6 +1895,11 @@ bcrypt-pbkdf@^1.0.2: dependencies: tweetnacl "^0.14.3" +before-after-hook@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-4.0.0.tgz#cf1447ab9160df6a40f3621da64d6ffc36050cb9" + integrity sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" @@ -1873,6 +2034,11 @@ bson@^7.0.0: resolved "https://registry.yarnpkg.com/bson/-/bson-7.1.1.tgz#19965d9138e1c4d88e4690414d91c84f217c84e8" integrity sha512-TtJgBB+QyOlWjrbM+8bRgH84VM/xrDjyBFgSgGrfZF4xvt6gbEDtcswm27Tn9F9TWsjQybxT8b8VpCP/oJK4Dw== +btoa-lite@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" + integrity sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA== + buffer-crc32@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405" @@ -1883,6 +2049,11 @@ buffer-crc32@~0.2.3: resolved "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= +buffer-equal-constant-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" @@ -2384,6 +2555,11 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== +deprecation@^2.0.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -2468,6 +2644,11 @@ dotenv@*: resolved "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz" integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q== +dotenv@^17.2.3: + version "17.2.3" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.2.3.tgz#ad995d6997f639b11065f419a22fabf567cdb9a2" + integrity sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w== + dotenv@^8.2.0: version "8.6.0" resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz" @@ -2483,6 +2664,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + electron-to-chromium@^1.5.263: version "1.5.279" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.279.tgz#67dfdeb22fd81412d0d18d1d9b2c749e9b8945cb" @@ -2883,6 +3071,11 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +fast-content-type-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" + integrity sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -4155,6 +4348,39 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz" @@ -4269,11 +4495,21 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== + lodash.isboolean@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz" integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY= +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== + lodash.isnumber@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz" @@ -4284,6 +4520,11 @@ lodash.isobject@^3.0.2: resolved "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz" integrity sha1-PI+41bW/S/kK4G4U8qUwpO2TXh0= +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== + lodash.isstring@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" @@ -4314,6 +4555,11 @@ lodash.omitby@^4.6.0: resolved "https://registry.npmjs.org/lodash.omitby/-/lodash.omitby-4.6.0.tgz" integrity sha1-XBX/R1StVVAWtTwEExHo8HkgR5E= +lodash.once@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" + integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz" @@ -5477,7 +5723,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0, safe-buffer@~5.2.1: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0, safe-buffer@~5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6282,6 +6528,16 @@ unique-string@^2.0.0: dependencies: crypto-random-string "^2.0.0" +universal-user-agent@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.1.tgz#15f20f55da3c930c57bddbf1734c6654d5fd35aa" + integrity sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ== + +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.3" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.3.tgz#c05870a58125a2dc00431f2df815a77fe69736be" + integrity sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A== + universalify@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz"