commit 2cc258daba7751c09df691d6b90c3dbbce871118 Author: Othmane Ataallah <”othmane.ataallah.triztech@gmail.com”> Date: Sun May 3 14:28:21 2026 +0100 release(v1.0) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31df881 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Ignore local environment and service account credentials +.env +service-account.json + +# Node modules +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..15f30de --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# Gitea Sheets Sync + +Synchronizes Gitea issue events into a Google Sheets document with a production-ready worker, idempotency, and Google Sheets styling. + +This repository contains a small HTTP ingress for Gitea webhooks, an internal event bus, a background worker that processes issue events, and a Google Sheets adapter that persists canonical `Issues`, `Events`, `Comments`, `TimeEntries`, and `DeadLetters` sheets. + +Table of Contents +- Overview +- Architecture +- Production checklist +- Environment variables +- Google authentication +- Google Sheets schema +- Running locally +- Deploying to Render +- Operational details (health, readiness, logging) +- Security +- Troubleshooting +- Contributing + +Overview +-------- +This service listens for Gitea webhooks, verifies them using HMAC, extracts metadata, and records structured rows in a Google Sheets spreadsheet. It implements: + +- HMAC verification for webhook authenticity. +- Event idempotency via `eventHash` and updating existing `Events` rows (no duplicate audit rows). +- Per-issue queueing and retries with exponential backoff for Google Sheets API calls. +- Styling and sizing of created sheets (frozen header, formats, widths, colors). +- Time entry aggregation from Gitea's `/times` endpoint on issue close. + +Architecture +------------ + +- `src/server.js` — Express HTTP server exposing webhook endpoints and health/readiness endpoints. +- `src/routes/` — Route definitions (wire HTTP to controllers). +- `src/controllers/webhookController.js` — Verifies HMAC signatures and publishes normalized events to the internal bus. +- `src/lib/eventBus.js` — Lightweight in-process pub/sub used to decouple HTTP and background workers. +- `src/workers/issueProcessor.js` — Subscribes to events and orchestrates persistence and external calls. +- `src/adapters/sheetsAdapter.js` — Google Sheets adapter: ensures sheets exist, enforces headers, styles sheets, appends/updates rows, and provides idempotent operations. +- `src/lib/logger.js` — Structured logging helper (integrated with `morgan`). +- `src/config/index.js` — Centralized configuration loaded from environment variables. + +Production checklist +-------------------- + +Before deploying to production, ensure: + +- You have a Google service account with `Editor` access to the target spreadsheet (or at least `spreadsheets` write access). +- The spreadsheet id is configured via `GOOGLE_SHEET_ID`. +- `GITEA_WEBHOOK_SECRET` is set and configured in your Gitea webhook settings. +- For time aggregation, `GITEA_APPLICATION_TOKEN` (personal access token or application token) is set. +- All secrets are stored in your platform's secret store (do not commit `.env` or JSON keys). +- Health/readiness probes are configured in the host to use `/health` and `/ready`. + +Environment variables +--------------------- + +Required: +- `GOOGLE_SHEET_ID` — Spreadsheet ID where sheets will be created/managed. +- `GITEA_WEBHOOK_SECRET` — HMAC secret used to verify incoming webhooks. + +At least one of the Google auth options: +- `GOOGLE_SERVICE_ACCOUNT_JSON` — Full service account JSON string (recommended to store as a secret). OR +- `GOOGLE_APPLICATION_CREDENTIALS` — Path to a service account JSON file available to the process. OR +- Legacy compatibility: `GOOGLE_PRIVATE_KEY` and `GOOGLE_SERVICE_ACCOUNT_EMAIL`. + +Optional: +- `PORT` — Port to bind (default: `3000`). +- `GITEA_APPLICATION_TOKEN` — Token used to call Gitea `/times` API for time aggregation. +- `GITEA_API_BASE` — Base URL for your Gitea server (e.g., `https://gitea.example.com`). If not provided, the adapter attempts to derive it from the webhook payload. +- `GOOGLE_SHEET_DEFAULT_ROWS` — Default number of rows when creating a sheet (default `100`). +- `GOOGLE_EVENTS_SHEET`, `GOOGLE_ISSUES_SHEET`, `GOOGLE_COMMENTS_SHEET`, `GOOGLE_TIME_ENTRIES_SHEET`, `GOOGLE_DEADLETTERS_SHEET` — override sheet names. + +Google authentication +--------------------- + +The adapter supports three ways to authenticate with Google: + +1. `GOOGLE_SERVICE_ACCOUNT_JSON` — provide the full service account JSON as an environment variable (recommended to base64-encode in CI/CD and decode when injecting). +2. `GOOGLE_APPLICATION_CREDENTIALS` — file path to a service account JSON on disk. +3. Legacy: `GOOGLE_PRIVATE_KEY` and `GOOGLE_SERVICE_ACCOUNT_EMAIL`. + +Grant the service account Editor access to the target spreadsheet (Share the sheet to the `client_email` from the service account JSON). + +Google Sheets schema +-------------------- + +The adapter creates and manages the following sheets (tabs) with canonical headers and basic styling: + +- `Issues` — canonical issue rows. Header columns: + - `Number`, `Title`, `State`, `Labels`, `Assignees`, `CommentsCount`, `CreatedAt`, `CreatedBy`, `UpdatedAt`, `ClosedAt`, `ClosedBy`, `LastEvent`, `LastEventAt`, `LastEventHash`, `LastActor`, `IssueURL`, `EstimatedDays`, `Importance`, `TotalTimeMinutes`, `TimeSummary`, `_IssueKey` + +- `Events` — audit log of processing attempts. Header columns: + - `ProcessedAt`, `EventHash`, `EventType`, `IssueKey`, `Actor`, `Summary`, `Status` + - When `eventHash` matches an existing event, the adapter updates the `Status` and `ProcessedAt` in-place instead of appending duplicates. + +- `Comments` — issue comments. Header columns: + - `ProcessedAt`, `IssueNumber`, `Actor`, `CommentURL` + +- `TimeEntries` — aggregated time entries per issue, per user. Header columns: + - `ProcessedAt`, `IssueKey`, `IssueNumber`, `Assignee`, `UserID`, `TotalMinutes` + - The adapter converts the time values returned from Gitea (seconds) into minutes, rounded. It upserts rows by `IssueNumber`+`UserID` to avoid duplicates for the same issue/user. + +- `DeadLetters` — failed events for manual inspection. Header columns: + - `ProcessedAt`, `EventHash`, `IssueKey`, `Error`, `PayloadBase64`, `Notes` + +All sheets are styled when created or when headers change: frozen header row, bold gray header background, column widths, date formatting (`yyyy-MM-dd HH:mm:ss`) for date columns, number formats and text wrapping for long fields. + +Running locally +--------------- + +1. Install dependencies: + +```bash +npm install +``` + +2. Set environment variables locally (for development only — use `.env` or your shell): + +```bash +export GOOGLE_SHEET_ID=your_spreadsheet_id +export GOOGLE_SERVICE_ACCOUNT_JSON='{"type":...}' +export GITEA_WEBHOOK_SECRET=your_secret +export PORT=3000 +``` + +3. Start the service: + +```bash +npm start +# or: node src/server.js +``` + +4. Health endpoints: + +- `GET /health` — returns 200 when server is running. +- `GET /ready` — returns readiness status (checks Google Sheets auth + spreadsheet access). + +Testing webhooks locally +------------------------- + +To test webhooks, compute the HMAC sha256 signature using your `GITEA_WEBHOOK_SECRET` and send it in the `x-hub-signature-256` header in format `sha256=`. + +Example with Node (one-liner) to compute signature and send a sample payload: + +```bash +payload='{"action":"opened","issue":{"number":123}}' +sig=$(node -e "const crypto=require('crypto'); const s=crypto.createHmac('sha256', process.env.GITEA_WEBHOOK_SECRET).update(process.argv[1]).digest('hex'); console.log('sha256='+s)" "$payload") +curl -X POST http://localhost:3000/webhooks/gitea -H "content-type: application/json" -H "x-hub-signature-256: $sig" -d "$payload" +``` + +Deploying to Render +-------------------- + +This service runs on Node and is Render-friendly. Minimal steps: + +1. Create a new Web Service in Render and connect your Git repository. +2. Set the build command to: + +``` +npm ci +``` + +3. Set the start command to: + +``` +npm start +``` + +4. In Render's Dashboard, add the required environment variables (see Environment variables section). Provide `GOOGLE_SERVICE_ACCOUNT_JSON` or `GOOGLE_APPLICATION_CREDENTIALS` via Render's secret storage. Provide `GITEA_WEBHOOK_SECRET`, `GOOGLE_SHEET_ID`, and `GITEA_APPLICATION_TOKEN` (if you need time aggregation). + +5. Set health check path to `/health` and readiness check to `/ready`. + +6. (Optional) If you use `GOOGLE_APPLICATION_CREDENTIALS` and uploaded a secret file, ensure the file is available on disk to the process, or prefer `GOOGLE_SERVICE_ACCOUNT_JSON` as a Render secret. + +Notes for Render: +- Bind port via `PORT` environment variable (Render sets this for you). The service listens on `process.env.PORT`. +- Ensure the target spreadsheet is shared with the service account email. + +Operational details +------------------- + +- Logging: The service uses structured JSON-like logs via the internal logger and `morgan` for HTTP access logs. +- Retries: The `sheetsAdapter` retries transient Google API errors with exponential backoff and jitter. +- Idempotency: Events are identified by `eventHash`. The worker avoids reprocessing and the `Events` sheet is updated in-place for repeated processing attempts. +- Graceful shutdown: The server supports SIGINT/SIGTERM and drains adapter queues before exit. + +Security +-------- + +- Do not commit secrets or service account JSON files. Use the platform secrets store. +- Rotate service-account keys if you believe any secret was leaked. +- Limit the service account access to only the spreadsheet it needs to reduce blast radius. + +Troubleshooting +--------------- + +- Authorization errors with Google Sheets: ensure the service account has access to the spreadsheet and the JSON credentials are valid. +- `ready` endpoint returns false: check that `GOOGLE_SHEET_ID` is set and the service account has access. +- Time aggregation failures: verify `GITEA_APPLICATION_TOKEN` is set and the Gitea instance `GITEA_API_BASE` is reachable from the service. + +Contributing / Development notes +-------------------------------- + +- This codebase is organized into small, testable modules. To add business logic or change mapping rules, modify `src/adapters/sheetsAdapter.js` and `src/workers/issueProcessor.js`. +- Keep `ensureSheet()` idempotent — it is safe to run multiple times and only modifies headers/styles when needed. + +Files of interest +----------------- + +- `src/server.js` — server bootstrap and route mounting. +- `src/routes/giteaWebhook.js` — webhook route. +- `src/controllers/webhookController.js` — HMAC verification and event normalization. +- `src/workers/issueProcessor.js` — background processing and Gitea `/times` integration. +- `src/adapters/sheetsAdapter.js` — Google Sheets I/O, header enforcement, styling, and upsert helpers. + +Contact +------- +For questions about the code or deployment, open an issue in the repository. diff --git a/SECRETS.md b/SECRETS.md new file mode 100644 index 0000000..d53d706 --- /dev/null +++ b/SECRETS.md @@ -0,0 +1,32 @@ +Secrets & deployment + +This repository contained a committed Google service account and a local `.env` file with private keys. Those files were removed from the repository. + +How to provide credentials securely in your environment: + +Options (pick one): + +1) Set `GOOGLE_APPLICATION_CREDENTIALS` to the path of a service account JSON file on the host (do NOT commit the file): + + - Upload the JSON to a secure location on the host (e.g. `/etc/secrets/gitea-sync-sa.json`) and set: + + export GOOGLE_APPLICATION_CREDENTIALS=/etc/secrets/gitea-sync-sa.json + +2) Set `GOOGLE_SERVICE_ACCOUNT_JSON` to the full JSON string (base64 encoding recommended): + + - Encode the JSON and provide it as an env var in your process manager or CI/CD secret store: + + export GOOGLE_SERVICE_ACCOUNT_JSON='{"type":...}' + + - For CI/CD you can store it as a secret and map it into the runtime. + +3) Legacy: set `GOOGLE_PRIVATE_KEY` and `GOOGLE_SERVICE_ACCOUNT_EMAIL` separately as env vars. This is supported for backward compatibility but less convenient. + +Other notes: +- Do NOT commit `.env` or service account files. Add them to project `.gitignore` (done). +- Rotate service account keys immediately if these keys were used publicly. +- Use a secret manager (AWS Secrets Manager, GCP Secret Manager, Vault) in production. + +Webhook secret: +- Provide `GITEA_WEBHOOK_SECRET` as an env var in your runtime environment. +- Do NOT commit it to the repository. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e7b6ee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1349 @@ +{ + "name": "gitea-sheets-sync", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-sheets-sync", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.15.2", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "googleapis": "^171.4.0", + "morgan": "^1.10.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "171.4.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz", + "integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..514c6ca --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "gitea-sheets-sync", + "version": "1.0.0", + "description": "", + "license": "ISC", + "author": "", + "type": "module", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "axios": "^1.15.2", + "dotenv": "^17.4.2", + "express": "^5.2.1", + "googleapis": "^171.4.0", + "morgan": "^1.10.1" + } +} diff --git a/src/adapters/sheetsAdapter.js b/src/adapters/sheetsAdapter.js new file mode 100644 index 0000000..6bc0266 --- /dev/null +++ b/src/adapters/sheetsAdapter.js @@ -0,0 +1,683 @@ +import { google } from "googleapis"; +import axios from "axios"; +import fs from "fs"; +import path from "path"; +import logger from "../lib/logger.js"; +import config from "../config/index.js"; + +const ISSUES_SHEET = process.env.GOOGLE_ISSUES_SHEET || "Issues"; +const EVENTS_SHEET = process.env.GOOGLE_EVENTS_SHEET || "Events"; +const COMMENTS_SHEET = process.env.GOOGLE_COMMENTS_SHEET || "Comments"; + +// Issues: display-focused header (internal _IssueKey kept at end for lookups) +const ISSUES_HEADER = [ + "Number", + "Title", + "State", + "Labels", + "Assignees", + "CommentsCount", + "CreatedAt", + "CreatedBy", + "UpdatedAt", + "ClosedAt", + "ClosedBy", + "LastEvent", + "LastEventAt", + "LastEventHash", + "LastActor", + "IssueURL", + "EstimatedDays", + "Importance", + "TotalTimeMinutes", + "TimeSummary", + "_IssueKey", +]; + +const EVENTS_HEADER = ["ProcessedAt", "EventHash", "EventType", "IssueKey", "Actor", "Summary", "Status"]; +const DEADLETTERS_SHEET = process.env.GOOGLE_DEADLETTERS_SHEET || "DeadLetters"; +const DEADLETTERS_HEADER = ["ProcessedAt", "EventHash", "IssueKey", "Error", "PayloadBase64", "Notes"]; +const COMMENTS_HEADER = process.env.GOOGLE_COMMENTS_HEADER ? JSON.parse(process.env.GOOGLE_COMMENTS_HEADER) : ["ProcessedAt", "IssueNumber", "Actor", "CommentURL"]; +const TIME_ENTRIES_SHEET = process.env.GOOGLE_TIME_ENTRIES_SHEET || "TimeEntries"; +const TIME_ENTRIES_HEADER = ["ProcessedAt", "IssueKey", "IssueNumber", "Assignee", "UserID", "TotalMinutes"]; + +// Default number of rows to keep on a freshly-created sheet. Can be overridden with env var +const DEFAULT_SHEET_ROWS = Number(process.env.GOOGLE_SHEET_DEFAULT_ROWS || 100); + +const ensuredSheets = new Set(); + +function colNumberToLetter(n) { + let s = ""; + while (n > 0) { + const m = (n - 1) % 26; + s = String.fromCharCode(65 + m) + s; + n = Math.floor((n - m) / 26); + } + return s; +} + +async function ensureSheet(sheets, spreadsheetId, sheetName, header) { + const cacheKey = `${spreadsheetId}:${sheetName}`; + if (ensuredSheets.has(cacheKey)) return; + + // Ensure sheet exists + const meta = await requestWithRetry(() => sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties" })); + const sheetsList = (meta && meta.data && meta.data.sheets) || []; + const exists = sheetsList.some((s) => s.properties && s.properties.title === sheetName); + let sheetCreated = false; + if (!exists) { + await requestWithRetry(() => + sheets.spreadsheets.batchUpdate({ + spreadsheetId, + requestBody: { requests: [{ addSheet: { properties: { title: sheetName } } }] }, + }) + ); + logger.info("sheetsAdapter created sheet", { sheetName }); + sheetCreated = true; + } + + // Ensure header + const headerRange = `${sheetName}!A1:${colNumberToLetter(header.length)}1`; + const res = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range: headerRange })); + const values = (res && res.data && res.data.values) || []; + const firstRow = values[0] || []; + // If header cells are missing or different, overwrite + let needSet = false; + if (firstRow.length < header.length) needSet = true; + else { + for (let i = 0; i < header.length; i++) { + if (String(firstRow[i] || "").trim() !== header[i]) { + needSet = true; + break; + } + } + } + + if (needSet) { + await requestWithRetry(() => + sheets.spreadsheets.values.update({ + spreadsheetId, + range: headerRange, + valueInputOption: "RAW", + requestBody: { values: [header] }, + }) + ); + logger.info("sheetsAdapter set header for sheet", { sheetName }); + } + + // Apply styling only when the sheet was newly created or header was reset + if (needSet || sheetCreated) { + try { + // re-fetch to get the sheetId + const meta2 = await requestWithRetry(() => sheets.spreadsheets.get({ spreadsheetId, fields: "sheets.properties" })); + const sheetObj = (meta2 && meta2.data && meta2.data.sheets || []).find((s) => s.properties && s.properties.title === sheetName); + const sheetId = sheetObj && sheetObj.properties && sheetObj.properties.sheetId; + if (sheetId != null) { + const requests = []; + + // Freeze the header row, and set grid size (rows/columns) to remove unused space + requests.push({ + updateSheetProperties: { + properties: { sheetId, gridProperties: { frozenRowCount: 1, rowCount: DEFAULT_SHEET_ROWS, columnCount: header.length } }, + fields: "gridProperties.frozenRowCount,gridProperties.rowCount,gridProperties.columnCount", + }, + }); + + // Header styling: light gray background + bold + requests.push({ + repeatCell: { + range: { sheetId, startRowIndex: 0, endRowIndex: 1, startColumnIndex: 0, endColumnIndex: header.length }, + cell: { userEnteredFormat: { backgroundColor: { red: 0.95, green: 0.95, blue: 0.95 }, textFormat: { bold: true } } }, + fields: "userEnteredFormat(backgroundColor,textFormat)", + }, + }); + + // sensible default widths per column name + const defaultWidthsByHeader = { + Number: 80, + Title: 360, + State: 90, + Labels: 200, + Assignees: 160, + CommentsCount: 90, + CreatedAt: 170, + CreatedBy: 140, + UpdatedAt: 170, + ClosedAt: 170, + ClosedBy: 140, + LastEvent: 120, + LastEventAt: 170, + LastEventHash: 220, + LastActor: 140, + IssueURL: 260, + EstimatedDays: 90, + Importance: 90, + TotalTimeMinutes: 110, + TimeSummary: 260, + _IssueKey: 220, + ProcessedAt: 170, + EventHash: 220, + EventType: 140, + Actor: 140, + Summary: 260, + Status: 120, + IssueNumber: 90, + Assignee: 160, + UserID: 120, + TotalMinutes: 110, + }; + + const dateCols = new Set(["CreatedAt", "UpdatedAt", "ClosedAt", "LastEventAt", "ProcessedAt"]); + const numberCols = new Set(["CommentsCount", "Number", "TotalTimeMinutes", "EstimatedDays", "Importance", "IssueNumber", "TotalMinutes"]); + const wrapCols = new Set(["Title", "Labels", "TimeSummary", "Summary", "IssueURL"]); + + for (let i = 0; i < header.length; i++) { + const key = header[i]; + const width = defaultWidthsByHeader[key] || 150; + // set column width + requests.push({ + updateDimensionProperties: { + range: { sheetId, dimension: "COLUMNS", startIndex: i, endIndex: i + 1 }, + properties: { pixelSize: width }, + fields: "pixelSize", + }, + }); + + // apply formats for the column (rows 2..10000) + const colRange = { sheetId, startRowIndex: 1, endRowIndex: 10000, startColumnIndex: i, endColumnIndex: i + 1 }; + + if (dateCols.has(key)) { + requests.push({ + repeatCell: { + range: colRange, + cell: { userEnteredFormat: { numberFormat: { type: "DATE_TIME", pattern: "yyyy-MM-dd HH:mm:ss" }, horizontalAlignment: "CENTER" } }, + fields: "userEnteredFormat(numberFormat,horizontalAlignment)", + }, + }); + } else if (numberCols.has(key)) { + requests.push({ + repeatCell: { + range: colRange, + cell: { userEnteredFormat: { numberFormat: { type: "NUMBER", pattern: "#,##0" }, horizontalAlignment: "CENTER" } }, + fields: "userEnteredFormat(numberFormat,horizontalAlignment)", + }, + }); + } else if (wrapCols.has(key)) { + requests.push({ + repeatCell: { + range: colRange, + cell: { userEnteredFormat: { wrapStrategy: "WRAP", horizontalAlignment: "LEFT" } }, + fields: "userEnteredFormat(wrapStrategy,horizontalAlignment)", + }, + }); + } else { + // default alignment + requests.push({ + repeatCell: { + range: colRange, + cell: { userEnteredFormat: { horizontalAlignment: "CENTER" } }, + fields: "userEnteredFormat(horizontalAlignment)", + }, + }); + } + } + + if (requests.length > 0) { + await requestWithRetry(() => sheets.spreadsheets.batchUpdate({ spreadsheetId, requestBody: { requests } })); + logger.info("sheetsAdapter applied sheet styling", { sheetName }); + } + } + } catch (e) { + logger.warn("sheetsAdapter styling failed", { sheetName, err: e && e.message ? e.message : e }); + } + } + + ensuredSheets.add(cacheKey); +} + +// Simple per-key queue to serialize operations for the same issue number +const queues = new Map(); +function enqueue(key, work) { + const prev = queues.get(key) || Promise.resolve(); + const next = prev + .then(() => work()) + .catch((err) => { + logger.error("sheetsAdapter queue worker error", { key, err: err && err.message ? err.message : err }); + throw err; + }) + .then((res) => { + if (queues.get(key) === next) queues.delete(key); + return res; + }); + + queues.set(key, next); + return next; +} + +function parseRetryAfter(header) { + if (!header) return null; + const s = String(header).trim(); + // If numeric, seconds + if (/^\d+$/.test(s)) return Number(s) * 1000; + // try parse date + const ts = Date.parse(s); + if (!isNaN(ts)) return Math.max(ts - Date.now(), 0); + return null; +} + +function formatDate(iso) { + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const YYYY = d.getFullYear(); + const MM = String(d.getMonth() + 1).padStart(2, "0"); + const DD = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + const ss = String(d.getSeconds()).padStart(2, "0"); + return `${YYYY}-${MM}-${DD} ${hh}:${mm}:${ss}`; + } catch (e) { + return ""; + } +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function isTransientError(err) { + const status = err && err.response && err.response.status; + if (!status) return false; + return status === 429 || status >= 500; +} + +async function sheetsClient() { + // Prefer JSON creds via env + if (process.env.GOOGLE_SERVICE_ACCOUNT_JSON) { + let creds; + try { + creds = JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON); + } catch (e) { + throw new Error("Invalid GOOGLE_SERVICE_ACCOUNT_JSON"); + } + const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ["https://www.googleapis.com/auth/spreadsheets"] }); + const client = await auth.getClient(); + return google.sheets({ version: "v4", auth: client }); + } + + // Key file path + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { + const auth = new google.auth.GoogleAuth({ keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS, scopes: ["https://www.googleapis.com/auth/spreadsheets"] }); + const client = await auth.getClient(); + return google.sheets({ version: "v4", auth: client }); + } + + // local service-account.json fallback + const local = path.resolve(process.cwd(), "service-account.json"); + if (fs.existsSync(local)) { + const raw = fs.readFileSync(local, "utf8"); + const creds = JSON.parse(raw); + const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ["https://www.googleapis.com/auth/spreadsheets"] }); + const client = await auth.getClient(); + return google.sheets({ version: "v4", auth: client }); + } + + // legacy env vars + if (process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) { + const creds = { + client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL, + private_key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, "\n"), + }; + const auth = new google.auth.GoogleAuth({ credentials: creds, scopes: ["https://www.googleapis.com/auth/spreadsheets"] }); + const client = await auth.getClient(); + return google.sheets({ version: "v4", auth: client }); + } + + throw new Error("No Google service account credentials available"); +} + +function mapIssueToRow(issue, action, meta = {}) { + // Compute normalized labels and assignees + const labels = Array.isArray(issue.labels) ? issue.labels.map((l) => (typeof l === 'string' ? l : l.name)).filter(Boolean) : []; + const labelStr = labels.map((s) => String(s).trim()).sort().join(","); + const assignees = issue.assignees || (issue.assignee ? [issue.assignee] : []); + const assigneeStr = Array.isArray(assignees) ? assignees.map((a) => (a && a.login) || (typeof a === 'string' ? a : '')).filter(Boolean).join(",") : ""; + + const extra = {}; + if (issue.milestone) extra.milestone = issue.milestone; + + const createdAt = issue.created_at || issue.createdAt || new Date().toISOString(); + const updatedAt = issue.updated_at || issue.updatedAt || new Date().toISOString(); + const closedAt = issue.closed_at || issue.closedAt || ""; + + // TimeSummary will be a JSON string mapping user->minutes + const timeSummary = meta.timeSummary || ""; + + return [ + issue.number || "", + issue.title || "", + issue.state || "", + labelStr, + assigneeStr, + issue.comments || 0, + formatDate(createdAt), + (issue.user && issue.user.login) || meta.createdBy || "", + formatDate(updatedAt), + closedAt ? formatDate(closedAt) : "", + meta.closedBy || "", + action || "", + formatDate(new Date().toISOString()), + meta.eventHash || "", + meta.actor || (issue.user && issue.user.login) || "", + (issue.html_url || issue.url) || "", + meta.estimatedDays != null ? meta.estimatedDays : "", + meta.importance != null ? meta.importance : "", + meta.totalTimeMinutes != null ? meta.totalTimeMinutes : "", + timeSummary || "", + meta.issueKey || `${(issue.repository && issue.repository.full_name) || ''}#${issue.number}`, + ]; +} + +async function requestWithRetry(fn, opts = {}) { + const maxRetries = opts.maxRetries || 4; + const base = opts.baseDelay || 500; // ms + const maxDelay = opts.maxDelay || 30000; // ms + + let attempt = 0; + while (true) { + try { + return await fn(); + } catch (err) { + attempt += 1; + if (!isTransientError(err) || attempt > maxRetries) throw err; + + const ra = err.response && err.response.headers && err.response.headers["retry-after"]; + const retryAfter = parseRetryAfter(ra); + let delay = retryAfter != null ? retryAfter : Math.min(base * Math.pow(2, attempt - 1), maxDelay); + // add jitter + delay = Math.floor(delay + Math.random() * 300); + + logger.warn("sheetsAdapter transient error, retrying", { attempt, delay, err: err && err.message ? err.message : err }); + await sleep(delay); + continue; + } + } +} + +async function findRowIndexByIssueKey(sheets, spreadsheetId, issueKey) { + await ensureSheet(sheets, spreadsheetId, ISSUES_SHEET, ISSUES_HEADER); + // _IssueKey is last column in ISSUES_HEADER + const keyColIndex = ISSUES_HEADER.length; // 1-based + const keyColLetter = colNumberToLetter(keyColIndex); + const res = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range: `${ISSUES_SHEET}!${keyColLetter}:${keyColLetter}` })); + const values = (res && res.data && res.data.values) || []; + for (let i = 0; i < values.length; i++) { + if (String(values[i][0]) === String(issueKey)) return i + 1; // 1-based + } + return null; +} + +export async function appendIssue(issue, meta = {}) { + const issueKey = meta.issueKey || `${(issue.repository && issue.repository.full_name) || ''}#${issue.number}`; + return enqueue(String(issueKey), async () => { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, ISSUES_SHEET, ISSUES_HEADER); + const row = mapIssueToRow(issue, meta.action, meta); + const range = `${ISSUES_SHEET}!A:${colNumberToLetter(ISSUES_HEADER.length)}`; + + await requestWithRetry(() => + sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } }) + ); + + logger.info("sheetsAdapter appended issue", { issue: issueKey }); + }); +} + +export async function upsertIssue(issue, meta = {}) { + const issueKey = meta.issueKey || `${(issue.repository && issue.repository.full_name) || ''}#${issue.number}`; + return enqueue(String(issueKey), async () => { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, ISSUES_SHEET, ISSUES_HEADER); + const row = mapIssueToRow(issue, meta.action, meta); + + const rowIndex = await findRowIndexByIssueKey(sheets, spreadsheetId, issueKey); + if (rowIndex) { + const range = `${ISSUES_SHEET}!A${rowIndex}:${colNumberToLetter(ISSUES_HEADER.length)}${rowIndex}`; + await requestWithRetry(() => + sheets.spreadsheets.values.update({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } }) + ); + logger.info("sheetsAdapter updated issue", { issue: issueKey, row: rowIndex }); + } else { + const range = `${ISSUES_SHEET}!A:${colNumberToLetter(ISSUES_HEADER.length)}`; + await requestWithRetry(() => + sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } }) + ); + logger.info("sheetsAdapter appended issue", { issue: issueKey }); + } + }); +} + +export default { upsertIssue, appendIssue, appendEvent, appendComment, isEventKnown, getIssueByIssueKey, appendDeadLetter }; + +export async function appendEvent(evt = {}) { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, EVENTS_SHEET, EVENTS_HEADER); + + const processedAt = formatDate(new Date().toISOString()); + const eventHash = evt.eventHash || (evt.meta && evt.meta.eventHash) || ""; + const eventType = evt.type || (evt.event && evt.event.action) || ""; + const issueKey = evt.issueKey || (evt.meta && evt.meta.issueKey) || (evt.event && evt.event.repository && evt.event.issue ? `${evt.event.repository.full_name}#${evt.event.issue.number}` : ""); + const actor = (evt.actor || (evt.event && evt.event.sender && evt.event.sender.login) || (evt.meta && evt.meta.actor)) || ""; + const summary = evt.summary || (evt.event && evt.event.action) || ""; + const status = evt.status || "pending"; + + // If eventHash exists in Events, update only the Status (and ProcessedAt) + if (eventHash) { + try { + const res = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range: `${EVENTS_SHEET}!B:B` })); + const values = (res && res.data && res.data.values) || []; + for (let i = 0; i < values.length; i++) { + if (String(values[i][0]) === String(eventHash)) { + const rowIndex = i + 1; + const statusCol = EVENTS_HEADER.indexOf('Status') + 1; + const processedAtCol = 1; // ProcessedAt + const statusRange = `${EVENTS_SHEET}!${colNumberToLetter(statusCol)}${rowIndex}`; + const processedAtRange = `${EVENTS_SHEET}!A${rowIndex}`; + await requestWithRetry(() => sheets.spreadsheets.values.update({ spreadsheetId, range: statusRange, valueInputOption: 'RAW', requestBody: { values: [[status]] } })); + await requestWithRetry(() => sheets.spreadsheets.values.update({ spreadsheetId, range: processedAtRange, valueInputOption: 'RAW', requestBody: { values: [[processedAt]] } })); + logger.info('sheetsAdapter updated event status', { issueKey, eventHash, status }); + return; + } + } + } catch (e) { + logger.warn('Error checking for existing eventHash, will append', { err: e && e.message ? e.message : e }); + } + } + + const row = [processedAt, eventHash, eventType, issueKey, actor, summary, status]; + const range = `${EVENTS_SHEET}!A:${colNumberToLetter(EVENTS_HEADER.length)}`; + + await requestWithRetry(() => sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } })); + logger.info("sheetsAdapter appended event", { issueKey, eventHash }); +} + +export async function appendComment(comment = {}) { + try { + logger.info('appendComment called', { issueNumber: comment.issueNumber || (comment.comment && comment.comment.issue && comment.comment.issue.number), preview: (comment.body || (comment.comment && comment.comment.body) || '').slice(0, 120) }); + } catch (e) { + // ignore + } + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, COMMENTS_SHEET, COMMENTS_HEADER); + + const processedAt = formatDate(new Date().toISOString()); + // prefer provided issueNumber, else try from comment.issue.number or event.issue.number + const issueNumber = comment.issueNumber || (comment.issue && comment.issue.number) || (comment.comment && comment.comment.issue && comment.comment.issue.number) || (comment.comment && comment.issue && comment.issue.number) || ""; + const actor = (comment.actor || (comment.sender && comment.sender.login) || (comment.comment && ((comment.comment.user && comment.comment.user.login) || (comment.comment.sender && comment.comment.sender.login)))) || ""; + const url = comment.url || (comment.comment && (comment.comment.html_url || comment.comment.url)) || ""; + + const row = [processedAt, issueNumber, actor, url]; + const range = `${COMMENTS_SHEET}!A:${colNumberToLetter(COMMENTS_HEADER.length)}`; + await requestWithRetry(() => sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } })); + logger.info("sheetsAdapter appended comment", { issueNumber, actor }); +} + +export async function appendDeadLetter({ eventHash, issueKey, error, payload, notes } = {}) { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, DEADLETTERS_SHEET, DEADLETTERS_HEADER); + + const processedAt = formatDate(new Date().toISOString()); + const errStr = error && error.message ? error.message : String(error || ''); + const payloadB64 = payload ? Buffer.from(typeof payload === 'string' ? payload : JSON.stringify(payload)).toString('base64') : ''; + + const row = [processedAt, eventHash || '', issueKey || '', errStr, payloadB64, notes || '']; + const range = `${DEADLETTERS_SHEET}!A:${colNumberToLetter(DEADLETTERS_HEADER.length)}`; + await requestWithRetry(() => sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: [row] } })); + logger.info("sheetsAdapter appended dead letter", { issueKey, eventHash }); +} + +export async function isEventKnown(eventHash) { + if (!eventHash) return false; + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, EVENTS_SHEET, EVENTS_HEADER); + const res = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range: `${EVENTS_SHEET}!B:B` })); + const values = (res && res.data && res.data.values) || []; + for (const row of values) { + if (String(row[0]) === String(eventHash)) return true; + } + return false; +} + +export async function appendTimeEntries(issueKey, issueNumber, times = []) { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, TIME_ENTRIES_SHEET, TIME_ENTRIES_HEADER); + const processedAt = formatDate(new Date().toISOString()); + + // Aggregate seconds per userId (fallback to username if userId missing) + const perUserSeconds = {}; // key -> { userId, userName, seconds } + for (const t of times) { + const userId = (t.user_id || (t.user && (t.user.id || t.user.user_id)) || '') + ''; + const userName = t.user_name || (t.user && (t.user.name || t.user.login || t.user.username)) || ''; + const seconds = Number(t.time || 0); + const key = userId || userName || '__unknown__'; + if (!perUserSeconds[key]) perUserSeconds[key] = { userId: userId || '', userName: userName || '', seconds: 0 }; + perUserSeconds[key].seconds += seconds; + } + + // Read existing time entries (skip header) + const existingRes = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range: `${TIME_ENTRIES_SHEET}!A2:${colNumberToLetter(TIME_ENTRIES_HEADER.length)}` })).catch(() => ({ data: { values: [] } })); + const existing = (existingRes && existingRes.data && existingRes.data.values) || []; + + const toAppend = []; + const perUserMinutes = {}; + + for (const entryKey of Object.keys(perUserSeconds)) { + const { userId, userName, seconds } = perUserSeconds[entryKey]; + const minutes = Math.round(Number(seconds || 0) / 60); + perUserMinutes[userName || userId || entryKey] = minutes; + + // Find existing row for same IssueNumber and UserID + let found = false; + for (let i = 0; i < existing.length; i++) { + const row = existing[i] || []; + const rowIssueNumber = row[2] !== undefined ? String(row[2]) : ''; + const rowUserId = row[4] !== undefined ? String(row[4]) : ''; + if (String(rowIssueNumber) === String(issueNumber) && String(rowUserId) === String(userId)) { + // update TotalMinutes (column index) + const rowIndex = 2 + i; // because we started at A2 + const totalMinutesCol = TIME_ENTRIES_HEADER.indexOf('TotalMinutes') + 1; + const range = `${TIME_ENTRIES_SHEET}!${colNumberToLetter(totalMinutesCol)}${rowIndex}`; + await requestWithRetry(() => sheets.spreadsheets.values.update({ spreadsheetId, range, valueInputOption: 'RAW', requestBody: { values: [[minutes]] } })); + found = true; + break; + } + } + + if (!found) { + toAppend.push([processedAt, issueKey, issueNumber, userName, userId, minutes]); + } + } + + if (toAppend.length > 0) { + const range = `${TIME_ENTRIES_SHEET}!A:${colNumberToLetter(TIME_ENTRIES_HEADER.length)}`; + await requestWithRetry(() => sheets.spreadsheets.values.append({ spreadsheetId, range, valueInputOption: "RAW", requestBody: { values: toAppend } })); + } + + logger.info('sheetsAdapter appended/updated time entries', { issueKey, appended: toAppend.length, users: Object.keys(perUserMinutes).length }); + return perUserMinutes; +} + +export async function updateIssueFields(issueKey, updates = {}) { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, ISSUES_SHEET, ISSUES_HEADER); + const rowIndex = await findRowIndexByIssueKey(sheets, spreadsheetId, issueKey); + if (!rowIndex) return null; + const existing = await getIssueByIssueKey(issueKey); + const newRow = []; + for (let i = 0; i < ISSUES_HEADER.length; i++) { + const key = ISSUES_HEADER[i]; + if (updates[key] !== undefined) newRow[i] = updates[key]; + else newRow[i] = existing[key] !== undefined ? existing[key] : ""; + } + const range = `${ISSUES_SHEET}!A${rowIndex}:${colNumberToLetter(ISSUES_HEADER.length)}${rowIndex}`; + await requestWithRetry(() => sheets.spreadsheets.values.update({ spreadsheetId, range, valueInputOption: 'RAW', requestBody: { values: [newRow] } })); + logger.info('sheetsAdapter updated issue fields', { issueKey }); + return newRow; +} + +export async function getIssueByIssueKey(issueKey) { + if (!issueKey) return null; + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + await ensureSheet(sheets, spreadsheetId, ISSUES_SHEET, ISSUES_HEADER); + const rowIndex = await findRowIndexByIssueKey(sheets, spreadsheetId, issueKey); + if (!rowIndex) return null; + const range = `${ISSUES_SHEET}!A${rowIndex}:${colNumberToLetter(ISSUES_HEADER.length)}${rowIndex}`; + const res = await requestWithRetry(() => sheets.spreadsheets.values.get({ spreadsheetId, range })); + const row = (res && res.data && res.data.values && res.data.values[0]) || []; + const obj = {}; + for (let i = 0; i < ISSUES_HEADER.length; i++) { + obj[ISSUES_HEADER[i]] = row[i] !== undefined ? row[i] : ""; + } + return obj; +} + +export function pendingOperationsCount() { + return queues.size; +} + +export async function waitForIdle(timeoutMs = 10000) { + const start = Date.now(); + while (queues.size > 0) { + if (Date.now() - start > timeoutMs) { + throw new Error("timeout waiting for adapter idle"); + } + // small sleep + // re-use sleep function defined above + // eslint-disable-next-line no-await-in-loop + await sleep(200); + } +} + +export async function readinessProbe() { + try { + const sheets = await sheetsClient(); + const spreadsheetId = process.env.GOOGLE_SHEET_ID || config.googleSheetId; + if (!spreadsheetId) throw new Error("GOOGLE_SHEET_ID not configured"); + // do a lightweight get for spreadsheet metadata + await requestWithRetry(() => sheets.spreadsheets.get({ spreadsheetId, fields: "spreadsheetId" }), { maxRetries: 1 }); + return true; + } catch (err) { + logger.warn("sheetsAdapter readiness probe failed", { err: err && err.message ? err.message : err }); + return false; + } +} + diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 0000000..f3683a8 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,8 @@ +const config = { + port: process.env.PORT ? Number(process.env.PORT) : 3000, + env: process.env.NODE_ENV || "development", + giteaWebhookSecret: process.env.GITEA_WEBHOOK_SECRET || "", + googleSheetId: process.env.GOOGLE_SHEET_ID || "", +}; + +export default config; diff --git a/src/controllers/webhookController.js b/src/controllers/webhookController.js new file mode 100644 index 0000000..8fadcb5 --- /dev/null +++ b/src/controllers/webhookController.js @@ -0,0 +1,122 @@ +import crypto from "crypto"; +import { publish } from "../lib/eventBus.js"; +import config from "../config/index.js"; +import logger from "../lib/logger.js"; + +function verifyHmac(req) { + const signature = req.headers["x-hub-signature-256"] || req.headers["x-hub-signature"]; + const secret = config.giteaWebhookSecret; + if (!signature || !secret || !req.rawBody) return false; + + const hmac = crypto.createHmac("sha256", secret); + hmac.update(req.rawBody); + const digest = `sha256=${hmac.digest("hex")}`; + + try { + const sigBuf = Buffer.from(signature); + const digBuf = Buffer.from(digest); + if (sigBuf.length !== digBuf.length) return false; + return crypto.timingSafeEqual(sigBuf, digBuf); + } catch (e) { + return false; + } +} + +export async function handleWebhook(req, res, next) { + try { + if (!verifyHmac(req)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const event = req.body; + + // compute event hash for idempotency + const eventHash = crypto.createHash("sha256").update(req.rawBody || JSON.stringify(req.body)).digest("hex"); + + // extract repo full name + const repoFullName = (event.repository && (event.repository.full_name || (event.repository.owner && event.repository.owner.login && event.repository.name && `${event.repository.owner.login}/${event.repository.name}`))) || ""; + + // parse meta (estimated days d, importance i) from frontmatter, labels, or title + const meta = { estimatedDays: null, importance: null, estimateSource: null, estimateRaw: null, eventHash, actor: (event.sender && event.sender.login) || null }; + + const body = event.issue && event.issue.body ? event.issue.body : ""; + + function parseFrontmatter(text) { + if (!text) return null; + const m = text.match(/^---\s*\n([\s\S]*?)\n---/); + if (!m) return null; + const block = m[1]; + const lines = block.split(/\r?\n/); + const obj = {}; + for (const line of lines) { + const idx = line.indexOf(":"); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const val = line.slice(idx + 1).trim(); + obj[key] = val; + } + return obj; + } + + function parseFromLabels(labels) { + if (!Array.isArray(labels)) return null; + const out = {}; + for (const l of labels) { + const name = (typeof l === "string") ? l : (l.name || ""); + const md = name.match(/d\s*[:=]\s*([0-9]+(?:[.,][0-9]+)?)/i); + if (md) out.d = md[1]; + const mi = name.match(/i\s*[:=]\s*(\d+)/i); + if (mi) out.i = mi[1]; + } + return Object.keys(out).length ? out : null; + } + + function parseFromTitle(title) { + if (!title) return null; + const out = {}; + const md = title.match(/d\s*=\s*([0-9]+(?:[.,][0-9]+)?)/i); + if (md) out.d = md[1]; + const mi = title.match(/i\s*=\s*(\d+)/i); + if (mi) out.i = mi[1]; + return Object.keys(out).length ? out : null; + } + + // precedence: frontmatter > labels > title + const fm = parseFrontmatter(body); + if (fm && (fm.d || fm.i)) { + if (fm.d) { + const d = String(fm.d).replace(/,/g, '.'); + meta.estimatedDays = Number(d); + meta.estimateRaw = fm.d; + } + if (fm.i) meta.importance = parseInt(fm.i, 10); + meta.estimateSource = 'frontmatter'; + } else { + const lb = parseFromLabels(event.issue && event.issue.labels); + if (lb && (lb.d || lb.i)) { + if (lb.d) meta.estimatedDays = Number(String(lb.d).replace(/,/g, '.')); + if (lb.i) meta.importance = parseInt(lb.i, 10); + meta.estimateSource = 'label'; + meta.estimateRaw = lb.d || lb.i || null; + } else { + const tt = parseFromTitle(event.issue && event.issue.title); + if (tt && (tt.d || tt.i)) { + if (tt.d) meta.estimatedDays = Number(String(tt.d).replace(/,/g, '.')); + if (tt.i) meta.importance = parseInt(tt.i, 10); + meta.estimateSource = 'title'; + meta.estimateRaw = tt.d || tt.i || null; + } + } + } + + const issueKey = repoFullName && event.issue ? `${repoFullName}#${event.issue.number}` : null; + + // Publish event to internal bus for background processing. + publish("gitea.event", { event, headers: req.headers, meta: Object.assign(meta, { issueKey }) }); + logger.info("Webhook received and published to event bus", { issueKey, eventHash }); + + return res.status(202).json({ status: "accepted" }); + } catch (err) { + return next(err); + } +} diff --git a/src/lib/eventBus.js b/src/lib/eventBus.js new file mode 100644 index 0000000..e485c53 --- /dev/null +++ b/src/lib/eventBus.js @@ -0,0 +1,15 @@ +import EventEmitter from "events"; + +const bus = new EventEmitter(); + +function publish(topic, payload) { + bus.emit(topic, payload); +} + +function subscribe(topic, handler) { + bus.on(topic, handler); + return () => bus.off(topic, handler); +} + +export { publish, subscribe }; +export default bus; diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 0000000..4a0da7f --- /dev/null +++ b/src/lib/logger.js @@ -0,0 +1,33 @@ +const pretty = process.env.LOG_PRETTY === "true" || process.env.NODE_ENV === "development"; + +function serializeMessage(args) { + if (!args || args.length === 0) return ""; + if (args.length === 1) return args[0]; + return args; +} + +function base(level, args, meta) { + const ts = new Date().toISOString(); + const msg = serializeMessage(args); + const payload = Object.assign({ timestamp: ts, level, msg }, meta || {}); + if (pretty) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(payload, null, 2)); + } else { + // eslint-disable-next-line no-console + console.log(JSON.stringify(payload)); + } +} + +function createLogger(component) { + return { + info: (...args) => base("info", args, { component }), + warn: (...args) => base("warn", args, { component }), + error: (...args) => base("error", args, { component }), + debug: (...args) => base("debug", args, { component }), + child: (childName) => createLogger(`${component}:${childName}`) + }; +} + +const root = createLogger(undefined); +export default root; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js new file mode 100644 index 0000000..ecf9629 --- /dev/null +++ b/src/middleware/errorHandler.js @@ -0,0 +1,6 @@ +import logger from "../lib/logger.js"; + +export default function errorHandler(err, req, res, next) { + logger.error(err && err.stack ? err.stack : err); + res.status(500).json({ error: "internal_error", message: err.message || "Internal server error" }); +} diff --git a/src/routes/giteaWebhook.js b/src/routes/giteaWebhook.js new file mode 100644 index 0000000..37e5f30 --- /dev/null +++ b/src/routes/giteaWebhook.js @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { handleWebhook } from "../controllers/webhookController.js"; + +const router = Router(); + +// The controller handles verification and publishes the event to the internal event bus. +// Business logic (Google Sheets writes) is handled by worker adapters and is intentionally +// separated and not implemented here. +router.post("/", handleWebhook); + +export default router; \ No newline at end of file diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..e775a24 --- /dev/null +++ b/src/server.js @@ -0,0 +1,187 @@ +import dotenv from "dotenv"; +dotenv.config(); + +import express from "express"; +import morgan from "morgan"; +import fs from "fs"; +import path from "path"; + +import giteaWebhook from "./routes/giteaWebhook.js"; +import issueProcessor, { isRunning as workerIsRunning } from "./workers/issueProcessor.js"; +import errorHandler from "./middleware/errorHandler.js"; +import logger from "./lib/logger.js"; +import config from "./config/index.js"; +import sheetsAdapter from "./adapters/sheetsAdapter.js"; + +const app = express(); + +// use morgan but route logs through structured logger +app.use(morgan("combined", { stream: { write: (s) => logger.info(s.trim()) } })); + +// capture raw body for webhook signature verification on the gitea webhook route +app.use( + "/webhooks/gitea", + express.json({ verify: (req, res, buf) => (req.rawBody = buf) }) +); + +// regular JSON parser for other routes +app.use(express.json()); + +app.use("/webhooks/gitea", giteaWebhook); + +app.get("/health", (req, res) => { + res.json({ status: "ok", uptime: process.uptime(), pid: process.pid, timestamp: new Date().toISOString() }); +}); + +app.get("/ready", async (req, res) => { + const missing = []; + + // config checks + if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET"); + if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID"); + + const hasGoogleCreds = !!( + process.env.GOOGLE_SERVICE_ACCOUNT_JSON || + process.env.GOOGLE_APPLICATION_CREDENTIALS || + (process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) || + fs.existsSync(path.resolve(process.cwd(), "service-account.json")) + ); + + if (!hasGoogleCreds) missing.push("Google service account credentials"); + + if (missing.length) { + logger.warn("Readiness check failed (missing config)", { missing }); + return res.status(503).json({ ready: false, missing }); + } + + // active adapter readiness probe + try { + const ok = await sheetsAdapter.readinessProbe(); + if (!ok) { + logger.warn("Readiness probe: sheets adapter not ready"); + return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] }); + } + } catch (err) { + logger.warn("Readiness probe error", { err: err && err.message ? err.message : err }); + return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] }); + } + + // worker status + if (!workerIsRunning()) { + logger.warn("Readiness check failed: worker not running"); + return res.status(503).json({ ready: false, missing: ["worker_not_running"] }); + } + + return res.json({ ready: true, uptime: process.uptime(), timestamp: new Date().toISOString() }); +}); + +const PORT = config.port; +function validateConfig() { + const missing = []; + + if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET"); + if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID"); + + const hasGoogleCreds = !!( + process.env.GOOGLE_SERVICE_ACCOUNT_JSON || + process.env.GOOGLE_APPLICATION_CREDENTIALS || + (process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) || + fs.existsSync(path.resolve(process.cwd(), "service-account.json")) + ); + + if (!hasGoogleCreds) missing.push("Google service account credentials"); + + if (missing.length) { + logger.warn("Startup config warning: missing env vars:", { missing }); + logger.warn("See SECRETS.md for secure configuration instructions."); + } +} + +validateConfig(); + +// Start background workers (no business logic implemented yet) +issueProcessor.start(); + +// Central error handler +app.use(errorHandler); + +const server = app.listen(PORT, "0.0.0.0", () => { + logger.info("Server running on port", { port: PORT }); +}); + +// track active connections so we can force-close them on shutdown +const connections = new Set(); +server.on("connection", (socket) => { + connections.add(socket); + socket.on("close", () => connections.delete(socket)); +}); + +const SHUTDOWN_GRACE_MS = process.env.SHUTDOWN_GRACE_MS ? Number(process.env.SHUTDOWN_GRACE_MS) : 10000; + +let shuttingDown = false; +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + logger.info("Shutdown initiated", { signal }); + + // stop accepting new connections + const closeServer = new Promise((resolve) => { + try { + server.close((err) => { + if (err) logger.warn("Error closing server", { err: err && err.message ? err.message : err }); + else logger.info("HTTP server closed"); + resolve(); + }); + } catch (e) { + logger.warn("Error during server.close", { err: e && e.message ? e.message : e }); + resolve(); + } + }); + + // schedule forced socket destruction after grace period + const forceTimeout = setTimeout(() => { + logger.warn("Forcing destruction of active connections after grace period"); + for (const s of connections) { + try { + s.destroy(); + } catch (e) { + // ignore + } + } + }, SHUTDOWN_GRACE_MS); + + await closeServer; + + // stop workers and drain adapter queues + try { + await issueProcessor.stop(SHUTDOWN_GRACE_MS); + } catch (e) { + logger.warn("Error stopping workers", { err: e && e.message ? e.message : e }); + } + + // final attempt to wait for adapter + try { + await sheetsAdapter.waitForIdle(SHUTDOWN_GRACE_MS); + } catch (e) { + logger.warn("Timeout waiting for adapter to become idle", { err: e && e.message ? e.message : e }); + } + + clearTimeout(forceTimeout); + + logger.info("Shutdown complete, exiting"); + // give a moment for logs to flush + setTimeout(() => process.exit(0), 100); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +process.on("unhandledRejection", (reason) => { + logger.error("UnhandledRejection", { reason }); + shutdown("unhandledRejection"); +}); + +process.on("uncaughtException", (err) => { + logger.error("UncaughtException", { err: err && err.stack ? err.stack : err }); + shutdown("uncaughtException"); +}); \ No newline at end of file diff --git a/src/services/googleSheets.js b/src/services/googleSheets.js new file mode 100644 index 0000000..e1864dc --- /dev/null +++ b/src/services/googleSheets.js @@ -0,0 +1,10 @@ +// Deprecated placeholder for the removed Google Sheets business logic. +// The repository has been restructured: business logic should live in +// `src/adapters/*` and `src/workers/*`. This file intentionally does not +// perform any network calls. + +export async function handleGiteaEvent(event) { + throw new Error( + "handleGiteaEvent is removed: implement Google Sheets integration in src/adapters/sheetsAdapter.js" + ); +} \ No newline at end of file diff --git a/src/workers/issueProcessor.js b/src/workers/issueProcessor.js new file mode 100644 index 0000000..db44b98 --- /dev/null +++ b/src/workers/issueProcessor.js @@ -0,0 +1,211 @@ +import { subscribe } from "../lib/eventBus.js"; +import logger from "../lib/logger.js"; +import * as sheetsAdapter from "../adapters/sheetsAdapter.js"; +import axios from "axios"; + +let unsub = null; +let running = false; + +async function handler(payload) { + try { + logger.info("Worker received event"); + const event = payload && payload.event ? payload.event : payload; + const meta = payload.meta || {}; + const headers = payload.headers || {}; + + const issue = event && event.issue; + if (!issue) { + logger.info("Event has no issue field; skipping processing"); + return; + } + + const issueKey = meta.issueKey || (event.repository && event.repository.full_name ? `${event.repository.full_name}#${issue.number}` : null); + + // dedupe: if eventHash already exists in Events or matches issue.LastEventHash, skip + try { + const seen = await sheetsAdapter.isEventKnown(meta.eventHash); + if (seen) { + logger.info("Duplicate event detected in Events sheet; skipping", { issueKey, eventHash: meta.eventHash }); + try { + await sheetsAdapter.appendEvent({ event, eventHash: meta.eventHash, issueKey, actor: meta.actor, summary: event.action || "", status: "duplicate", meta }); + } catch (e) { + logger.warn("Failed to append duplicate event record", { err: e && e.message ? e.message : e }); + } + return; + } + + const existing = await sheetsAdapter.getIssueByIssueKey(issueKey); + if (existing && existing.LastEventHash && String(existing.LastEventHash) === String(meta.eventHash)) { + logger.info("Duplicate event detected by Issue.LastEventHash; skipping", { issueKey, eventHash: meta.eventHash }); + try { + await sheetsAdapter.appendEvent({ event, eventHash: meta.eventHash, issueKey, actor: meta.actor, summary: event.action || "", status: "duplicate", meta }); + } catch (e) { + logger.warn("Failed to append duplicate event record", { err: e && e.message ? e.message : e }); + } + return; + } + } catch (e) { + logger.warn("Error during duplicate check; continuing processing", { err: e && e.message ? e.message : e }); + } + + // Append an event audit row (processing) + try { + await sheetsAdapter.appendEvent({ event, eventHash: meta.eventHash, issueKey, actor: meta.actor, summary: event.action || "", status: "processing", meta }); + } catch (e) { + logger.warn("Failed to append processing event", { err: e && e.message ? e.message : e }); + } + + // If this is a comment event, append comment row and update issue + const giteaEvent = (headers && (headers['x-gitea-event'] || headers['x-gitea-event'.toLowerCase()])) || null; + const isComment = Boolean(event && event.comment) || giteaEvent === 'issue_comment'; + + try { + if (isComment) { + // Log comment detection for debugging + try { + logger.info('Detected comment event', { + issueKey, + commentId: event.comment && event.comment.id, + commentUser: event.comment && ((event.comment.user && event.comment.user.login) || (event.comment.sender && event.comment.sender.login)), + preview: event.comment && (event.comment.body ? event.comment.body.slice(0, 160) : ''), + }); + } catch (lg) { + // ignore logging errors + } + + try { + const issueNumber = (issue && issue.number) || (event && event.issue && event.issue.number) || null; + await sheetsAdapter.appendComment({ issueNumber, comment: event.comment, actor: (event.comment && ((event.comment.user && event.comment.user.login) || (event.comment.sender && event.comment.sender.login))) || meta.actor, body: (event.comment && event.comment.body) || '' }); + logger.info('appendComment invoked', { issueNumber, commentId: event.comment && event.comment.id }); + } catch (e) { + logger.warn("Failed to append comment", { err: e && e.message ? e.message : e }); + } + } + + await sheetsAdapter.upsertIssue(issue, Object.assign({}, meta, { action: event.action })); + logger.info("Worker processed issue", issue.number); + + // If this was a comment event, ensure CommentsCount is accurate in Issues sheet + try { + if (isComment) { + let newCount = null; + if (issue && typeof issue.comments !== 'undefined' && issue.comments !== null) newCount = issue.comments; + if (newCount !== null) { + await sheetsAdapter.updateIssueFields(issueKey, { CommentsCount: String(newCount) }); + } else { + const existing = await sheetsAdapter.getIssueByIssueKey(issueKey); + const existingCount = existing && existing.CommentsCount ? parseInt(existing.CommentsCount, 10) || 0 : 0; + await sheetsAdapter.updateIssueFields(issueKey, { CommentsCount: String(existingCount + 1) }); + } + } + } catch (e) { + logger.warn('Failed to update CommentsCount', { err: e && e.message ? e.message : e, issueKey }); + } + await sheetsAdapter.appendEvent({ event, eventHash: meta.eventHash, issueKey, actor: meta.actor, summary: event.action || "", status: "success", meta }); + + // If issue was closed, fetch time entries from Gitea and record per-assignee totals + try { + if (String(event.action).toLowerCase() === "closed") { + const token = process.env.GITEA_APPLICATION_TOKEN; + if (!token) { + logger.warn('GITEA_APPLICATION_TOKEN not configured; skipping time aggregation'); + } else { + // derive owner/repo + const fullName = (event.repository && (event.repository.full_name || (event.repository.owner && `${event.repository.owner.login}/${event.repository.name}`))) || null; + if (!fullName) { + logger.warn('Cannot determine repository full_name for time aggregation', { event }); + } else { + const parts = fullName.split('/'); + const owner = parts[0]; + const repo = parts.slice(1).join('/'); + // derive API base + let apiBase = process.env.GITEA_API_BASE; + if (!apiBase) { + try { + if (event.repository && event.repository.html_url) { + const u = new URL(event.repository.html_url); + apiBase = `${u.protocol}//${u.host}`; + } + } catch (e) { + // ignore + } + } + if (!apiBase) { + logger.warn('Cannot determine Gitea API base URL; set GITEA_API_BASE to override'); + } else { + const url = `${apiBase.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${encodeURIComponent(issue.number)}/times`; + try { + const resp = await axios.get(url, { headers: { Authorization: `Bearer ${token}` }, timeout: 10000 }); + const times = (resp && resp.data) || []; + const perUser = await sheetsAdapter.appendTimeEntries(issueKey, issue.number, times); + // compute total minutes and human summary + let total = 0; + let summary = ''; + if (perUser && Object.keys(perUser).length > 0) { + total = Object.values(perUser).reduce((s, v) => s + Number(v || 0), 0); + summary = Object.entries(perUser).map(([u, m]) => `${u}:${m}min`).join('; '); + } + // update Issues row with totals and who closed + const closedBy = meta.actor || (event && event.sender && event.sender.login) || ''; + await sheetsAdapter.updateIssueFields(issueKey, { TotalTimeMinutes: total, TimeSummary: summary, ClosedBy: closedBy }); + logger.info('Recorded time entries for closed issue', { issueKey, total, summary }); + } catch (e) { + logger.warn('Failed fetching time entries from Gitea', { err: e && e.message ? e.message : e, url }); + } + } + } + } + } + } catch (e) { + logger.warn('Time aggregation failed', { err: e && e.message ? e.message : e }); + } + } catch (err) { + logger.error("Worker error processing issue", { err: err && err.message ? err.message : err, issue: issueKey }); + try { + await sheetsAdapter.appendEvent({ event, eventHash: meta.eventHash, issueKey, actor: meta.actor, summary: event.action || "", status: "failed", meta }); + } catch (e2) { + logger.warn("Failed to append failure event", { err: e2 && e2.message ? e2.message : e2 }); + } + + // Also write to DeadLetters for manual inspection + try { + await sheetsAdapter.appendDeadLetter({ eventHash: meta.eventHash, issueKey, error: err, payload: event, notes: 'worker-processing-failed' }); + } catch (e3) { + logger.warn("Failed to append dead letter", { err: e3 && e3.message ? e3.message : e3 }); + } + } + } catch (err) { + logger.error("Worker error:", err && err.message ? err.message : err); + } +} + +function start() { + if (running) return; + unsub = subscribe("gitea.event", handler); + running = true; +} + +async function stop(timeoutMs = 10000) { + if (unsub) { + try { + unsub(); + } catch (e) { + logger.warn("Error unsubscribing worker", { err: e && e.message ? e.message : e }); + } + unsub = null; + } + running = false; + + try { + await sheetsAdapter.waitForIdle(timeoutMs); + logger.info("Worker: drained adapter queues"); + } catch (err) { + logger.warn("Worker: timeout waiting for adapter to drain", { err: err && err.message ? err.message : err }); + } +} + +export default { start, stop }; + +export function isRunning() { + return running; +}