diff --git a/README.md b/README.md index f617c99..65ab484 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/GodsBoy/repokeeper/pulls) ![Maintained by RepoKeeper](https://img.shields.io/badge/maintained%20by-RepoKeeper-blue) -[Getting Started](#quick-start) · [Features](#what-repokeeper-does) · [Deploy to VPS](#production-deployment) · [Multi-Repo](#multi-repo-configuration) · [Contributing](#contributing) +[Getting Started](#quick-start) · [Features](#what-repokeeper-does) · [Try it live](#interactive-playground) · [Deploy to VPS](#production-deployment) · [Multi-Repo](#multi-repo-configuration) · [Contributing](#contributing) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy) @@ -27,7 +27,7 @@ --- -![RepoKeeper Hero](docs/images/repokeeper-hero.png) +![RepoKeeper Demo](docs/images/repokeeper-demo.gif) ## Demo @@ -35,6 +35,14 @@ See [demo/demo.cast](demo/demo.cast) — play with: `asciinema play demo/demo.ca > **Live demo:** This repository is maintained by RepoKeeper itself — every issue, PR, and review goes through the same pipeline you can deploy in minutes. +## Interactive Playground + +**Try it live** — paste a GitHub issue or PR diff and see RepoKeeper's AI in action, no deployment required. + +Once running, visit `http://your-server:3001/playground` to try issue triage and PR summarisation instantly. + +Set `attribution.playgroundUrl` in your config to link the playground from every AI-generated comment. + ## The Problem Maintainers are drowning. AI-generated pull requests, duplicate issues, low-effort bug reports, and community questions pile up faster than any human can triage them. In 2026, the average popular open source repo receives more noise than signal — and maintainer burnout is at an all-time high. @@ -191,6 +199,8 @@ All configuration lives in `repokeeper.config.ts`: | `codeReview.focus` | `string[]` | `["security", "performance", "test-coverage", "breaking-changes"]` | Review focus areas | | `codeReview.maxContextFiles` | `number` | `5` | Max dependency files to include per changed file | | `codeReview.minDiffLines` | `number` | `10` | Minimum added lines to trigger review | +| `attribution.enabled` | `boolean` | `true` | Add "Powered by RepoKeeper" footer to AI comments | +| `attribution.playgroundUrl` | `string` | — | Absolute URL to your playground (adds "Try it live" link to footer) | | `port` | `number` | `3001` | Port for the webhook server | ## Per-Repository YAML Config @@ -269,6 +279,7 @@ The code review feature works with all three AI providers. Use Ollama for comple ┌──────────▼──────────┐ │ Express Server │ │ /webhook │ + │ /playground │ │ /health │ │ /metrics │ └──────────┬──────────┘ diff --git a/docs/images/repokeeper-demo.gif b/docs/images/repokeeper-demo.gif new file mode 100644 index 0000000..68f0f88 Binary files /dev/null and b/docs/images/repokeeper-demo.gif differ diff --git a/package.json b/package.json index 0b8e0fb..6e4335a 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,10 @@ "@types/express": "^5.0.0", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", + "@types/supertest": "^7.2.0", "eslint": "^9.0.0", "prettier": "^3.4.0", + "supertest": "^7.2.2", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3851613..1d3ff37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,12 +36,18 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.15 + '@types/supertest': + specifier: ^7.2.0 + version: 7.2.0 eslint: specifier: ^9.0.0 version: 9.39.4 prettier: specifier: ^3.4.0 version: 3.8.1 + supertest: + specifier: ^7.2.2 + version: 7.2.2 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -270,6 +276,10 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@octokit/auth-token@5.1.2': resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} engines: {node: '>= 18'} @@ -328,6 +338,9 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -475,6 +488,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -496,6 +512,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/node-fetch@2.6.13': resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} @@ -517,6 +536,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@7.2.0': + resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -578,6 +603,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -641,6 +669,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -660,6 +691,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -688,6 +722,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -809,6 +846,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -848,6 +888,10 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -999,6 +1043,10 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1015,6 +1063,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -1226,6 +1279,14 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1552,6 +1613,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@noble/hashes@1.8.0': {} + '@octokit/auth-token@5.1.2': {} '@octokit/core@6.1.6': @@ -1620,6 +1683,10 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -1709,6 +1776,8 @@ snapshots: dependencies: '@types/node': 22.19.15 + '@types/cookiejar@2.1.5': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -1732,6 +1801,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/methods@1.1.4': {} + '@types/node-fetch@2.6.13': dependencies: '@types/node': 22.19.15 @@ -1758,6 +1829,18 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.19.15 + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 22.19.15 + form-data: 4.0.5 + + '@types/supertest@7.2.0': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1832,6 +1915,8 @@ snapshots: argparse@2.0.1: {} + asap@2.0.6: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} @@ -1900,6 +1985,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + component-emitter@1.3.1: {} + concat-map@0.0.1: {} content-disposition@1.0.1: {} @@ -1910,6 +1997,8 @@ snapshots: cookie@0.7.2: {} + cookiejar@2.1.4: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1928,6 +2017,11 @@ snapshots: depd@2.0.0: {} + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2110,6 +2204,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2156,6 +2252,12 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -2287,6 +2389,8 @@ snapshots: merge-descriptors@2.0.0: {} + methods@1.1.2: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -2299,6 +2403,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@2.6.0: {} + minimatch@3.1.5: dependencies: brace-expansion: 1.1.12 @@ -2530,6 +2636,28 @@ snapshots: dependencies: js-tokens: 9.0.1 + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3 + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.15.0 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + supports-color@7.2.0: dependencies: has-flag: 4.0.0 diff --git a/repokeeper.config.example.ts b/repokeeper.config.example.ts index 159d99d..dafb698 100644 --- a/repokeeper.config.example.ts +++ b/repokeeper.config.example.ts @@ -19,5 +19,11 @@ export default { minDiffLines: 50, generateReleaseNotes: true, }, + // Attribution footer on AI-generated comments (default: enabled) + attribution: { + enabled: true, + // Set to your deployment URL to add a "Try it live" link in the footer + // playgroundUrl: 'https://your-server.example.com/playground', + }, port: 3001, }; diff --git a/src/config.ts b/src/config.ts index 747ea1e..5ce6019 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,6 +44,10 @@ export interface RepoKeeperConfig { ignore?: string[]; commitStatus?: boolean; }; + attribution: { + enabled: boolean; + playgroundUrl?: string; + }; port: number; } @@ -76,6 +80,10 @@ const defaults: RepoKeeperConfig = { ignore: [], commitStatus: false, }, + attribution: { + enabled: true, + playgroundUrl: undefined, + }, port: 3001, }; diff --git a/src/index.ts b/src/index.ts index 854820a..14386b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { createAIProvider } from './ai/index.js'; import { GitHubClient } from './github/client.js'; import { createWebhookHandler } from './webhook/handler.js'; import { getMetrics } from './metrics.js'; +import { registerPlayground } from './playground.js'; import { log } from './logger.js'; async function main(): Promise { @@ -60,6 +61,8 @@ async function main(): Promise { res.json(getMetrics()); }); + registerPlayground(app, ai, config); + const server = app.listen(config.port, () => { log('info', `RepoKeeper listening on port ${config.port}`); log('info', `AI provider: ${config.ai.provider} (${config.ai.model})`); diff --git a/src/playground.ts b/src/playground.ts new file mode 100644 index 0000000..ed1bbd8 --- /dev/null +++ b/src/playground.ts @@ -0,0 +1,164 @@ +import type { Express, Request, Response } from 'express'; +import rateLimit from 'express-rate-limit'; +import type { AIProvider } from './ai/provider.js'; +import type { RepoKeeperConfig } from './config.js'; +import { log } from './logger.js'; + +const MAX_INPUT_LENGTH = 10_000; + +const ISSUE_PROMPT = `You are a GitHub issue triage assistant. Given the following issue, classify it, suggest labels, and write a helpful response. Issue:\n\n`; + +const PR_PROMPT = `You are a code review assistant. Given the following PR diff, write a concise plain-English summary of what changed and why it matters.\n\nDiff:\n\n`; + +const PLAYGROUND_HTML = ` + + + + +RepoKeeper Playground + + + +
+

RepoKeeper Playground

+

Try AI-powered repo maintenance instantly — View on GitHub

+ + +
+ + +
+ +
+
+ + +`; + +interface PreviewRequest { + type: string; + input: string; +} + +export function registerPlayground(app: Express, ai: AIProvider, config: RepoKeeperConfig): void { + const playgroundLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Rate limit exceeded. Try again in a few minutes.' }, + }); + + app.get('/playground', (_req: Request, res: Response) => { + res.type('html').send(PLAYGROUND_HTML); + }); + + app.post('/playground/preview', playgroundLimiter, async (req: Request, res: Response) => { + const { type, input } = req.body as PreviewRequest; + + if (!type || !['issue', 'pr-summary'].includes(type)) { + res.status(400).json({ error: 'Invalid type. Must be "issue" or "pr-summary".' }); + return; + } + + if (!input || typeof input !== 'string' || input.trim().length === 0) { + res.status(400).json({ error: 'Please paste some content to preview.' }); + return; + } + + if (input.length > MAX_INPUT_LENGTH) { + res.status(400).json({ error: `Input too long. Maximum ${MAX_INPUT_LENGTH} characters.` }); + return; + } + + const prompt = type === 'issue' + ? ISSUE_PROMPT + input + : PR_PROMPT + input; + + try { + const { text } = await ai.complete(prompt); + res.json({ result: text.trim() }); + } catch (err) { + log('error', 'Playground AI error', { + error: err instanceof Error ? err.message : String(err), + }); + res.status(503).json({ error: 'AI provider unavailable. Check server configuration.' }); + } + }); + + log('info', 'Playground registered at /playground'); +} diff --git a/src/pr/summariser.ts b/src/pr/summariser.ts index 6394783..981356a 100644 --- a/src/pr/summariser.ts +++ b/src/pr/summariser.ts @@ -2,6 +2,7 @@ import type { AIProvider } from '../ai/provider.js'; import type { GitHubClient } from '../github/client.js'; import type { RepoKeeperConfig } from '../config.js'; import { getPRSizeLabel } from './labeler.js'; +import { withAttribution } from '../utils/attribution.js'; import { log } from '../logger.js'; interface PRPayload { @@ -87,11 +88,11 @@ export async function handlePullRequest( .map((f) => `- \`${f.filename}\` (+${f.additions}/-${f.deletions}) [${f.status}]`) .join('\n'); - const comment = + const commentBody = `## PR Summary\n\n${summary}\n\n` + `### Files Changed (${files.length})\n${fileList}\n\n` + - `---\n*Size: **${sizeLabel.replace('size/', '')}** (${totalLines} lines changed)*\n` + - `*Generated by [RepoKeeper](https://github.com/GodsBoy/repokeeper)*`; + `---\n*Size: **${sizeLabel.replace('size/', '')}** (${totalLines} lines changed)*`; + const comment = withAttribution(commentBody, config.attribution); await github.addComment(number, comment); @@ -124,9 +125,8 @@ export async function handlePullRequestMerged( const { text: releaseNotes } = await ai.complete(prompt); - const comment = - `## Release Notes\n\n${releaseNotes}\n\n` + - `---\n*Generated by [RepoKeeper](https://github.com/GodsBoy/repokeeper)*`; + const commentBody = `## Release Notes\n\n${releaseNotes}`; + const comment = withAttribution(commentBody, config.attribution); await github.addComment(number, comment); diff --git a/src/review/comment-poster.ts b/src/review/comment-poster.ts index a1c3182..f1445c2 100644 --- a/src/review/comment-poster.ts +++ b/src/review/comment-poster.ts @@ -1,6 +1,8 @@ import { Octokit } from '@octokit/rest'; import { log } from '../logger.js'; import type { ReviewFinding, ReviewResult } from './types.js'; +import type { AttributionConfig } from '../utils/attribution.js'; +import { withAttribution } from '../utils/attribution.js'; type ReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'; @@ -25,9 +27,10 @@ function determineEvent(findings: ReviewFinding[]): ReviewEvent { return 'COMMENT'; } -function buildReviewBody(result: ReviewResult): string { +function buildReviewBody(result: ReviewResult, attribution: AttributionConfig): string { if (result.findings.length === 0) { - return `## RepoKeeper Code Review\n\n${result.summary}\n\nNo issues found. Looks good! ✅`; + const body = `## RepoKeeper Code Review\n\n${result.summary}\n\nNo issues found. Looks good! ✅`; + return withAttribution(body, attribution); } const blocking = result.findings.filter((f) => f.severity === 'BLOCKING'); @@ -36,10 +39,9 @@ function buildReviewBody(result: ReviewResult): string { let body = `## RepoKeeper Code Review\n\n${result.summary}\n\n`; body += `**Found ${result.findings.length} issue(s):** `; - body += `${blocking.length} blocking, ${warnings.length} warning(s), ${suggestions.length} suggestion(s)\n\n`; - body += `---\n*Review by [RepoKeeper](https://github.com/GodsBoy/repokeeper)*`; + body += `${blocking.length} blocking, ${warnings.length} warning(s), ${suggestions.length} suggestion(s)`; - return body; + return withAttribution(body, attribution); } function buildComments(findings: ReviewFinding[]): ReviewComment[] { @@ -57,9 +59,10 @@ export async function postReview( pullNumber: number, commitSha: string, result: ReviewResult, + attribution: AttributionConfig = { enabled: true }, ): Promise { const event = determineEvent(result.findings); - const body = buildReviewBody(result); + const body = buildReviewBody(result, attribution); const comments = buildComments(result.findings); try { diff --git a/src/review/reviewer.ts b/src/review/reviewer.ts index 8dc1b6c..90a74de 100644 --- a/src/review/reviewer.ts +++ b/src/review/reviewer.ts @@ -237,7 +237,7 @@ export async function handleCodeReview( // Post review via GitHub API const octokit = new Octokit({ auth: config.github.token }); - await postReview(octokit, owner, repo, prNumber, headSha, result); + await postReview(octokit, owner, repo, prNumber, headSha, result, config.attribution); // Post commit status if enabled if (reviewConfig.commitStatus) { diff --git a/src/triage/responder.ts b/src/triage/responder.ts index ce13664..e398f02 100644 --- a/src/triage/responder.ts +++ b/src/triage/responder.ts @@ -3,6 +3,7 @@ import type { GitHubClient } from '../github/client.js'; import type { RepoKeeperConfig } from '../config.js'; import { classifyIssue, categoryToLabel } from './classifier.js'; import { findDuplicates } from './duplicate.js'; +import { withAttribution } from '../utils/attribution.js'; import { log } from '../logger.js'; interface IssuePayload { @@ -91,7 +92,7 @@ export async function handleIssueOpened( // Generate contextual response using AI const comment = await generateComment(title, bodyText, category, ai); - await github.addComment(number, comment); + await github.addComment(number, withAttribution(comment, config.attribution)); log('info', `Issue #${number} classified as "${category}", labelled [${labels.join(', ')}]`); } diff --git a/src/utils/attribution.ts b/src/utils/attribution.ts new file mode 100644 index 0000000..7cab02d --- /dev/null +++ b/src/utils/attribution.ts @@ -0,0 +1,12 @@ +export interface AttributionConfig { + enabled: boolean; + playgroundUrl?: string; +} + +export function withAttribution(body: string, config: AttributionConfig): string { + if (!config.enabled) return body; + const playgroundLink = config.playgroundUrl + ? ` · [Try it live](${config.playgroundUrl})` + : ''; + return `${body}\n\n---\n*Powered by [RepoKeeper](https://github.com/GodsBoy/repokeeper) — AI-powered repo maintenance${playgroundLink}*`; +} diff --git a/tests/attribution.test.ts b/tests/attribution.test.ts new file mode 100644 index 0000000..53e8ee4 --- /dev/null +++ b/tests/attribution.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { withAttribution } from '../src/utils/attribution.js'; + +describe('withAttribution', () => { + it('appends attribution footer when enabled', () => { + const result = withAttribution('Hello world', { enabled: true }); + expect(result).toContain('Hello world'); + expect(result).toContain('Powered by [RepoKeeper]'); + expect(result).toContain('AI-powered repo maintenance'); + }); + + it('returns body unchanged when disabled', () => { + const result = withAttribution('Hello world', { enabled: false }); + expect(result).toBe('Hello world'); + }); + + it('includes playground link when playgroundUrl is set', () => { + const result = withAttribution('Body', { + enabled: true, + playgroundUrl: 'https://example.com/playground', + }); + expect(result).toContain('[Try it live](https://example.com/playground)'); + }); + + it('omits playground link when playgroundUrl is undefined', () => { + const result = withAttribution('Body', { enabled: true }); + expect(result).not.toContain('Try it live'); + }); + + it('separates footer with horizontal rule', () => { + const result = withAttribution('Body', { enabled: true }); + expect(result).toContain('\n\n---\n'); + }); +}); diff --git a/tests/playground.test.ts b/tests/playground.test.ts new file mode 100644 index 0000000..ad67b4e --- /dev/null +++ b/tests/playground.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { registerPlayground } from '../src/playground.js'; +import type { AIProvider } from '../src/ai/provider.js'; +import type { RepoKeeperConfig } from '../src/config.js'; + +function createApp(ai: AIProvider) { + const app = express(); + app.use(express.json()); + + const config: RepoKeeperConfig = { + github: { token: 'test', webhookSecret: 'test', owner: 'test', repo: 'test' }, + ai: { provider: 'claude', model: 'test' }, + triage: { enabled: true, duplicateThreshold: 0.7, minimumBodyLength: 100 }, + prSummariser: { enabled: true, minDiffLines: 50, generateReleaseNotes: false }, + codeReview: { enabled: true, focus: [], maxContextFiles: 5, minDiffLines: 10 }, + attribution: { enabled: true }, + port: 3001, + }; + + registerPlayground(app, ai, config); + return app; +} + +function mockAI(response: string): AIProvider { + return { + complete: vi.fn().mockResolvedValue({ text: response, usage: { inputTokens: 10, outputTokens: 20 } }), + }; +} + +function failingAI(): AIProvider { + return { + complete: vi.fn().mockRejectedValue(new Error('API key invalid')), + }; +} + +describe('GET /playground', () => { + it('returns HTML page', async () => { + const app = createApp(mockAI('response')); + const res = await request(app).get('/playground'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('html'); + expect(res.text).toContain('RepoKeeper Playground'); + expect(res.text).toContain('textarea'); + }); +}); + +describe('POST /playground/preview', () => { + it('returns AI result for issue type', async () => { + const ai = mockAI('This is a bug report about login failures.'); + const app = createApp(ai); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'issue', input: 'Login is broken' }); + expect(res.status).toBe(200); + expect(res.body.result).toBe('This is a bug report about login failures.'); + expect(ai.complete).toHaveBeenCalledWith(expect.stringContaining('triage assistant')); + }); + + it('returns AI result for pr-summary type', async () => { + const ai = mockAI('Added a new feature to handle auth.'); + const app = createApp(ai); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'pr-summary', input: 'diff --git a/auth.ts' }); + expect(res.status).toBe(200); + expect(res.body.result).toBe('Added a new feature to handle auth.'); + expect(ai.complete).toHaveBeenCalledWith(expect.stringContaining('code review assistant')); + }); + + it('returns 400 for empty input', async () => { + const app = createApp(mockAI('x')); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'issue', input: '' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('paste some content'); + }); + + it('returns 400 for whitespace-only input', async () => { + const app = createApp(mockAI('x')); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'issue', input: ' \n ' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('paste some content'); + }); + + it('returns 400 for input exceeding 10000 characters', async () => { + const app = createApp(mockAI('x')); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'issue', input: 'a'.repeat(10_001) }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('too long'); + }); + + it('returns 400 for invalid type', async () => { + const app = createApp(mockAI('x')); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'unknown', input: 'some content' }); + expect(res.status).toBe(400); + expect(res.body.error).toContain('Invalid type'); + }); + + it('returns 503 when AI provider fails', async () => { + const app = createApp(failingAI()); + const res = await request(app) + .post('/playground/preview') + .send({ type: 'issue', input: 'some content' }); + expect(res.status).toBe(503); + expect(res.body.error).toContain('AI provider unavailable'); + expect(res.body.error).not.toContain('API key'); + }); +}); diff --git a/tests/responder.test.ts b/tests/responder.test.ts index 29045ed..cbe4170 100644 --- a/tests/responder.test.ts +++ b/tests/responder.test.ts @@ -41,6 +41,7 @@ function createConfig(overrides?: Partial): RepoKeep }, prSummariser: { enabled: false, minDiffLines: 50, generateReleaseNotes: false }, codeReview: { enabled: false, focus: [], maxContextFiles: 5, minDiffLines: 10 }, + attribution: { enabled: true }, port: 3001, }; } @@ -115,6 +116,70 @@ describe('handleIssueOpened', () => { expect(commentCall[1]).not.toContain('Thanks for the detailed bug report'); }); + it('includes attribution footer on AI-generated comments', async () => { + const ai = createMockAI('bug', 'We are looking into this crash.'); + const github = createMockGithub(); + const config = createConfig(); + + const detailedBody = 'The application crashes when I try to login. I am using Chrome on Windows 10. ' + + 'The console shows a TypeError: Cannot read property of undefined. Stack trace is below.'; + + await handleIssueOpened( + { issue: { number: 10, title: 'Login crash', body: detailedBody } }, + ai, + github, + config, + ); + + const commentCall = (github.addComment as ReturnType).mock.calls[0]; + expect(commentCall[1]).toContain('Powered by [RepoKeeper]'); + }); + + it('omits attribution footer when attribution is disabled', async () => { + const ai = createMockAI('bug', 'We are looking into this crash.'); + const github = createMockGithub(); + const config = createConfig(); + config.attribution = { enabled: false }; + + const detailedBody = 'The application crashes when I try to login. I am using Chrome on Windows 10. ' + + 'The console shows a TypeError: Cannot read property of undefined. Stack trace is below.'; + + await handleIssueOpened( + { issue: { number: 11, title: 'Login crash', body: detailedBody } }, + ai, + github, + config, + ); + + const commentCall = (github.addComment as ReturnType).mock.calls[0]; + expect(commentCall[1]).not.toContain('Powered by [RepoKeeper]'); + }); + + it('does NOT include attribution footer on duplicate detection comment', async () => { + const ai: AIProvider = { + complete: async (prompt: string) => { + if (prompt.includes('duplicate issue detector')) return { text: '0.85', usage }; + return { text: 'comment', usage }; + }, + }; + const github = createMockGithub(); + (github.listOpenIssues as ReturnType).mockResolvedValue([ + { number: 1, title: 'app crashes on start', body: 'the app crashes when I start it' }, + { number: 20, title: 'app wont start up', body: 'tried installing but the app refuses to start' }, + ]); + const config = createConfig(); + + await handleIssueOpened( + { issue: { number: 20, title: 'app wont start up', body: 'tried installing but the app refuses to start' } }, + ai, + github, + config, + ); + + const commentCall = (github.addComment as ReturnType).mock.calls[0]; + expect(commentCall[1]).not.toContain('Powered by [RepoKeeper]'); + }); + it('flags duplicates with possible-duplicate label instead of closing', async () => { const ai: AIProvider = { complete: async (prompt: string) => { diff --git a/tests/summariser.test.ts b/tests/summariser.test.ts index 7a7a832..734ad8f 100644 --- a/tests/summariser.test.ts +++ b/tests/summariser.test.ts @@ -26,6 +26,7 @@ const baseConfig: RepoKeeperConfig = { triage: { enabled: true, duplicateThreshold: 0.85, minimumBodyLength: 100 }, prSummariser: { enabled: true, minDiffLines: 5, generateReleaseNotes: true }, codeReview: { enabled: true, focus: ['security', 'performance', 'test-coverage', 'breaking-changes'], maxContextFiles: 5, minDiffLines: 10 }, + attribution: { enabled: true }, port: 3001, }; @@ -42,6 +43,31 @@ describe('handlePullRequest', () => { expect(ai.complete).toHaveBeenCalled(); }); + it('includes attribution footer on summary comment', async () => { + const ai = mockAI('This PR adds a new feature.'); + const github = mockGitHub(); + const payload = { pull_request: { number: 10, title: 'Add feature', body: 'Description' } }; + + await handlePullRequest(payload, ai, github as never, baseConfig); + + const commentCall = github.addComment.mock.calls[0]; + expect(commentCall[1]).toContain('Powered by [RepoKeeper]'); + // Must NOT have the old hardcoded footer + expect(commentCall[1]).not.toContain('Generated by [RepoKeeper]'); + }); + + it('omits attribution footer when disabled', async () => { + const ai = mockAI('This PR adds a new feature.'); + const github = mockGitHub(); + const config = { ...baseConfig, attribution: { enabled: false } }; + const payload = { pull_request: { number: 11, title: 'Add feature', body: 'Description' } }; + + await handlePullRequest(payload, ai, github as never, config); + + const commentCall = github.addComment.mock.calls[0]; + expect(commentCall[1]).not.toContain('Powered by [RepoKeeper]'); + }); + it('skips summary when below minDiffLines', async () => { const ai = mockAI('Summary'); const github = mockGitHub(); @@ -72,6 +98,18 @@ describe('handlePullRequestMerged', () => { expect(github.getPRFiles).toHaveBeenCalledWith(3); }); + it('includes attribution footer on release notes', async () => { + const ai = mockAI('## v1.0\n- Added new feature'); + const github = mockGitHub(); + const payload = { pull_request: { number: 30, title: 'Add feature', body: 'New feature', merged: true } }; + + await handlePullRequestMerged(payload, ai, github as never, baseConfig); + + const commentCall = github.addComment.mock.calls[0]; + expect(commentCall[1]).toContain('Powered by [RepoKeeper]'); + expect(commentCall[1]).not.toContain('Generated by [RepoKeeper]'); + }); + it('skips release notes when disabled', async () => { const ai = mockAI('Notes'); const github = mockGitHub();