From f1c8eda2342982aad9691b008a3dfcfde662cd63 Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 5 Nov 2026 13:30:22 +0000 Subject: [PATCH 1/2] DOC-7268 Add a read-only Repo Memory query action A local composite action, .github/actions/repo-memory-query/, plus a workflow_dispatch workflow, that searches the Repo Memory store from a description of planned work before any PR exists. It returns related PRs, commits and issues with the full squash or commit message behind each. Why a separate action: redis-learn/redis-repo-memory is on the GitHub Marketplace and the query feature is experimental. The team asked to keep the public action unchanged until the feature proves useful, so the query logic (first proposed as redis-learn/redis-repo-memory PR #4) lives here instead. Design: - Read-only. It never creates the index, writes a key, or posts to a PR or commit. It uses the same MEMORY_REDIS_URL and OPENAI_API_KEY secrets. - Compatible with what the memory action writes: repo_memory_idx, memory: hashes, a cosine embedding from text-embedding-3-small. The embedded text has the same shape (title, body, file list, cut to 4000 characters). - Results are limited to the repository the workflow runs in. - Full text is read at the source because stored summaries stop at 500 characters: the squash commit for a merged PR, the commit message for a push, the body otherwise. It falls back silently to the stored summary. - The result is one REPO_MEMORY_QUERY_RESULT_JSON log line, so build/repo-memory-eval/recall/recall.py can parse it. Tested offline with test.js against Redis 8.10 and a mock embedding. The first version of the test let two mutants survive (reversed sort, a stray write on the missing-index path), so it now asserts nearest-first order and an empty keyspace after the missing-index query. Five mutants are caught. Not verified: a real Actions run (github.token default, sparse checkout, input wiring), real OpenAI embeddings, and retrieval quality from a plan. Workflow logs of this public repo are public; the README says so. Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/repo-memory-query/README.md | 62 ++ .github/actions/repo-memory-query/action.yml | 60 ++ .../repo-memory-query/package-lock.json | 554 ++++++++++++++++++ .../actions/repo-memory-query/package.json | 13 + .github/actions/repo-memory-query/query.js | 177 ++++++ .github/actions/repo-memory-query/test.js | 131 +++++ .github/workflows/repo-memory-query.yml | 41 ++ 7 files changed, 1038 insertions(+) create mode 100644 .github/actions/repo-memory-query/README.md create mode 100644 .github/actions/repo-memory-query/action.yml create mode 100644 .github/actions/repo-memory-query/package-lock.json create mode 100644 .github/actions/repo-memory-query/package.json create mode 100644 .github/actions/repo-memory-query/query.js create mode 100644 .github/actions/repo-memory-query/test.js create mode 100644 .github/workflows/repo-memory-query.yml diff --git a/.github/actions/repo-memory-query/README.md b/.github/actions/repo-memory-query/README.md new file mode 100644 index 0000000000..564f1d6c96 --- /dev/null +++ b/.github/actions/repo-memory-query/README.md @@ -0,0 +1,62 @@ +# Repo Memory Query + +A read-only search of the [Repo Memory](https://github.com/redis-learn/redis-repo-memory) store from a +description of work you are about to do. Use it to find related prior PRs, commits and issues, with +their full squash or commit messages, before any PR exists. + +It is separate from the `redis-learn/redis-repo-memory` action on purpose. That action runs on every +push and PR and writes to the store. This one only reads, runs only when someone starts it, and can +change or go away without affecting the memory comments. + +It never creates the index, never writes a key, and never posts to a PR or commit. It uses the same +`MEMORY_REDIS_URL` and `OPENAI_API_KEY` secrets as `repo-memory.yml`. + +## Run it + +```sh +gh workflow run repo-memory-query.yml \ + -f query="Move the job-queue use case from JedisPool to RedisClient" \ + -f files="content/develop/use-cases/job-queue/java-jedis/RedisJobQueue.java" +``` + +The workflow file must be on the default branch for `gh workflow run` to find it. Results appear in +the run's step summary as a list. The full text of each result is on one log line that starts with +`REPO_MEMORY_QUERY_RESULT_JSON`, followed by JSON, so a script can read it from +`gh run view --log`. + +## Inputs + +| Input | Default | Meaning | +| --- | --- | --- | +| `redis-url` | required | Redis URL of the memory store | +| `openai-api-key` | required | Embeds the query with `text-embedding-3-small` | +| `query` | required | The planned work | +| `query-files` | none | Files you plan to change | +| `query-limit` | `5` | Maximum results | +| `query-max-distance` | `0.60` | Cosine distance cutoff, as in the memory action | +| `github-token` | `github.token` | Reads the full message behind each result | + +Only items from the repository the workflow runs in are returned. + +## Visibility + +Workflow logs of a public repository are public. The query text, and the results, are printed in the +log, so don't put anything confidential in a query. The results are titles and messages of this +repository's own public PRs and commits. + +## Compatibility with the memory action + +`query.js` reads what `redis-learn/redis-repo-memory` writes: the `repo_memory_idx` index, `memory:` +hashes, and a cosine `embedding` field from `text-embedding-3-small`. If the memory action changes +that layout, update `query.js` and the seed in `test.js` to match. + +## Test + +The test needs an empty Redis 8 (Search is built in) and uses a mock embedding, so it needs no +OpenAI key or network access. It refuses to run against a database that has any keys. + +```sh +cd .github/actions/repo-memory-query +npm ci +REPO_MEMORY_TEST_REDIS_URL=redis://localhost:6379 node test.js +``` diff --git a/.github/actions/repo-memory-query/action.yml b/.github/actions/repo-memory-query/action.yml new file mode 100644 index 0000000000..3a0507d74b --- /dev/null +++ b/.github/actions/repo-memory-query/action.yml @@ -0,0 +1,60 @@ +name: 'Redis Repo Memory Query' +description: > + Read-only search of an existing Redis Repo Memory store from a description of planned work. + Never writes to Redis and never posts to a pull request or commit. + +inputs: + redis-url: + description: > + Redis connection URL of the Repo Memory store (the same value the + redis-learn/redis-repo-memory action uses), e.g. redis://default:@: + required: true + openai-api-key: + description: OpenAI API key used to embed the query (text-embedding-3-small). + required: true + query: + description: Description of the work you are about to do. + required: true + query-files: + description: Files you plan to change, separated by spaces, commas or newlines. + required: false + default: '' + query-limit: + description: Maximum number of results. + required: false + default: '5' + query-max-distance: + description: Maximum cosine distance (0-2, lower is more similar) for a result. + required: false + default: '0.60' + github-token: + description: > + Token used to read the full squash or commit message behind each result. + The default github.token is enough for a public repository. + required: false + default: ${{ github.token }} + +runs: + using: 'composite' + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install dependencies + shell: bash + working-directory: ${{ github.action_path }} + run: npm ci --omit=dev + + - name: Query memory + shell: bash + env: + REDIS_URL: ${{ inputs.redis-url }} + OPENAI_API_KEY: ${{ inputs.openai-api-key }} + GITHUB_TOKEN: ${{ inputs.github-token }} + QUERY_TEXT: ${{ inputs.query }} + QUERY_FILES: ${{ inputs.query-files }} + QUERY_LIMIT: ${{ inputs.query-limit }} + QUERY_MAX_DISTANCE: ${{ inputs.query-max-distance }} + run: node "${{ github.action_path }}/query.js" diff --git a/.github/actions/repo-memory-query/package-lock.json b/.github/actions/repo-memory-query/package-lock.json new file mode 100644 index 0000000000..01dd082b44 --- /dev/null +++ b/.github/actions/repo-memory-query/package-lock.json @@ -0,0 +1,554 @@ +{ + "name": "repo-memory-query", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "repo-memory-query", + "version": "1.0.0", + "dependencies": { + "openai": "^4.77.0", + "redis": "^4.7.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", + "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "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/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/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "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/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/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/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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "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/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "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/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.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "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/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/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/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/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": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/redis": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", + "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.1", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/.github/actions/repo-memory-query/package.json b/.github/actions/repo-memory-query/package.json new file mode 100644 index 0000000000..615fbac6e6 --- /dev/null +++ b/.github/actions/repo-memory-query/package.json @@ -0,0 +1,13 @@ +{ + "name": "repo-memory-query", + "version": "1.0.0", + "private": true, + "description": "Read-only query of a Redis Repo Memory store", + "engines": { + "node": ">=18" + }, + "dependencies": { + "openai": "^4.77.0", + "redis": "^4.7.0" + } +} diff --git a/.github/actions/repo-memory-query/query.js b/.github/actions/repo-memory-query/query.js new file mode 100644 index 0000000000..91d34cc1c1 --- /dev/null +++ b/.github/actions/repo-memory-query/query.js @@ -0,0 +1,177 @@ +/** + * query.js + * + * Searches a Repo Memory store (written by redis-learn/redis-repo-memory) for items related to a + * description of planned work, and prints the matches. Read-only: it never creates the index, + * never writes a key, and never posts to GitHub. + * + * It must stay compatible with how that action stores items: index `repo_memory_idx`, hashes + * under `memory:`, a COSINE `embedding` field from text-embedding-3-small. + * + * Environment: + * REDIS_URL, OPENAI_API_KEY Same values the memory action uses. + * QUERY_TEXT Required. The planned work. + * QUERY_FILES Optional. Files to be changed. + * QUERY_LIMIT, QUERY_MAX_DISTANCE + * GITHUB_TOKEN Optional. Without it results carry no full text. + * GITHUB_REPOSITORY Only items from this repository are returned. + * REPO_MEMORY_EMBED_PROVIDER=mock Tests only: deterministic local embedding, no API call. + */ +'use strict'; + +const fs = require('fs'); +const { createClient } = require('redis'); + +const INDEX_NAME = 'repo_memory_idx'; +const MODEL = 'text-embedding-3-small'; +const DIM = 1536; +const DEFAULT_LIMIT = 5; +const DEFAULT_MAX_DISTANCE = 0.60; +const FULL_TEXT_MAX = 8000; + +const isMock = () => process.env.REPO_MEMORY_EMBED_PROVIDER === 'mock'; + +function mockEmbedding(text) { + const v = new Float32Array(DIM); + for (const word of text.toLowerCase().match(/[a-z0-9_.]+/g) || []) { + let h = 2166136261; + for (let i = 0; i < word.length; i++) h = Math.imul(h ^ word.charCodeAt(i), 16777619) >>> 0; + v[h % DIM] += 1; + } + const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0)) || 1; + return Array.from(v, x => x / norm); +} + +async function embed(text, apiKey) { + if (isMock()) return mockEmbedding(text); + const OpenAI = require('openai'); + const res = await new OpenAI({ apiKey }).embeddings.create({ model: MODEL, input: text }); + return res.data[0].embedding; +} + +async function githubGet(path, token) { + const res = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + return res.ok ? res.json() : null; +} + +// Stored summaries are cut to 500 characters, so read the full text at the source: the squash +// commit message for a merged PR, the commit message for a push, the body otherwise. Returns {} +// when the lookup fails; the stored summary is still reported. +async function fullTextFor(item, token) { + const m = (item.sourceUrl || '').match(/github\.com\/([^/]+\/[^/]+)\/(pull|commit|issues)\/([^/?#]+)/); + if (!m || !token) return {}; + const [, repo, kind, id] = m; + try { + if (kind === 'commit') { + const c = await githubGet(`/repos/${repo}/commits/${id}`, token); + return c ? { fullText: c.commit.message.slice(0, FULL_TEXT_MAX), state: 'commit' } : {}; + } + if (kind === 'pull') { + const p = await githubGet(`/repos/${repo}/pulls/${id}`, token); + if (!p) return {}; + if (p.merged && p.merge_commit_sha) { + const c = await githubGet(`/repos/${repo}/commits/${p.merge_commit_sha}`, token); + const msg = c && !/^Merge pull request/.test(c.commit.message) ? c.commit.message : (p.body || ''); + return { fullText: msg.slice(0, FULL_TEXT_MAX), state: 'merged', mergedAt: p.merged_at }; + } + return { fullText: (p.body || '').slice(0, FULL_TEXT_MAX), state: p.state, mergedAt: null }; + } + const i = await githubGet(`/repos/${repo}/issues/${id}`, token); + return i ? { fullText: (i.body || '').slice(0, FULL_TEXT_MAX), state: i.state } : {}; + } catch { + return {}; + } +} + +function report(query, files, results) { + // One line that `gh run view --log` output can be grepped for and parsed. + console.log('REPO_MEMORY_QUERY_RESULT_JSON ' + JSON.stringify({ query, files, results })); + const lines = ['## 🧠 Redis Memory query', '']; + if (results.length === 0) { + lines.push('No related prior context found.'); + } else { + for (const r of results) { + const link = r.sourceUrl ? `[${r.title}](${r.sourceUrl})` : r.title; + lines.push(`- ${link} (distance ${r.score.toFixed(2)}${r.state ? `, ${r.state}` : ''})`); + } + } + const out = lines.join('\n') + '\n'; + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, out); + else process.stdout.write(out); +} + +async function main() { + const { REDIS_URL, OPENAI_API_KEY, GITHUB_TOKEN, GITHUB_REPOSITORY } = process.env; + const query = (process.env.QUERY_TEXT || '').trim(); + if (!REDIS_URL) throw new Error('REDIS_URL is not set'); + if (!OPENAI_API_KEY && !isMock()) throw new Error('OPENAI_API_KEY is not set'); + if (!query) throw new Error('QUERY_TEXT is empty'); + if (!GITHUB_REPOSITORY) throw new Error('GITHUB_REPOSITORY is not set'); + + const files = (process.env.QUERY_FILES || '').split(/[\s,]+/).filter(Boolean); + const limit = Math.max(1, parseInt(process.env.QUERY_LIMIT || '', 10) || DEFAULT_LIMIT); + const maxDistance = parseFloat(process.env.QUERY_MAX_DISTANCE || '') || DEFAULT_MAX_DISTANCE; + + // Same text shape the memory action embeds for a push: title, body, then the file list. + const title = query.split('\n')[0].slice(0, 200); + const searchText = [title, query, files.join(' ')].join('\n').slice(0, 4000); + + const client = createClient({ url: REDIS_URL }); + client.on('error', err => console.error('Redis error:', err.message)); + await client.connect(); + try { + const exists = await client.ft.info(INDEX_NAME).then(() => true).catch(() => false); + if (!exists) { + console.log(`index ${INDEX_NAME} not found; nothing to search`); + report(query, files, []); + return; + } + + const blob = Buffer.from(new Float32Array(await embed(searchText, OPENAI_API_KEY)).buffer); + const found = await client.ft.search( + INDEX_NAME, + `*=>[KNN ${Math.max(10, limit * 2)} @embedding $BLOB AS score]`, + { + PARAMS: { BLOB: blob }, + RETURN: ['type', 'title', 'body_summary', 'source_url', 'created_at', 'score', 'repo'], + SORTBY: { BY: 'score', DIRECTION: 'ASC' }, + DIALECT: 2, + } + ); + + const related = found.documents + .filter(d => parseFloat(d.value.score ?? '2') < maxDistance && d.value.repo === GITHUB_REPOSITORY) + .slice(0, limit); + + const results = []; + for (const d of related) { + const item = { + type: d.value.type, + title: d.value.title, + bodySummary: d.value.body_summary, + sourceUrl: d.value.source_url, + createdAt: d.value.created_at, + score: parseFloat(d.value.score ?? '2'), + }; + results.push({ ...item, ...(await fullTextFor(item, GITHUB_TOKEN)) }); + } + report(query, files, results); + } finally { + await client.quit(); + } +} + +if (require.main === module) { + main().catch(err => { + console.error('repo-memory-query failed:', err.message); + process.exit(1); + }); +} + +module.exports = { mockEmbedding }; diff --git a/.github/actions/repo-memory-query/test.js b/.github/actions/repo-memory-query/test.js new file mode 100644 index 0000000000..ca77cdb114 --- /dev/null +++ b/.github/actions/repo-memory-query/test.js @@ -0,0 +1,131 @@ +/** + * test.js + * + * Offline test of query.js with the mock embedding provider (no OpenAI key, no GitHub calls). + * + * REPO_MEMORY_TEST_REDIS_URL=redis://localhost:6395 node test.js + * + * Needs a Redis with the Search module (Redis 8+ has it built in). It refuses to run unless the + * database is EMPTY and deletes everything it creates, so it cannot touch real memory data. + * + * The seeded index and hashes copy the layout written by redis-learn/redis-repo-memory + * (scripts/retrieve_memory.js). If that layout changes, update the seed here and in query.js. + */ +'use strict'; + +const assert = require('assert'); +const { execFileSync } = require('child_process'); +const path = require('path'); +const { createClient, SchemaFieldTypes, VectorAlgorithms } = require('redis'); + +const url = process.env.REPO_MEMORY_TEST_REDIS_URL; +if (!url) { + console.error('Set REPO_MEMORY_TEST_REDIS_URL to an EMPTY Redis (with Search) to run this test.'); + process.exit(2); +} + +const REPO = 'test/repo'; +const ITEMS = [ + ['1', 'Move the job-queue Jedis use case from JedisPool to RedisClient and close every pipeline'], + ['2', 'Bump Lettuce pins in the harness poms to 7.9.0 and update install snippets'], + ['3', 'Raise the redis-rs response timeout for blocking commands in the Rust demos'], + ['4', 'Rewrite the sorted set tutorial to use ZRANGE with BYSCORE and REV'], +]; + +function query(env) { + const out = execFileSync('node', [path.join(__dirname, 'query.js')], { + env: { + PATH: process.env.PATH, + REDIS_URL: url, + REPO_MEMORY_EMBED_PROVIDER: 'mock', + GITHUB_REPOSITORY: REPO, + ...env, + }, + encoding: 'utf8', + }); + const line = out.split('\n').find(l => l.startsWith('REPO_MEMORY_QUERY_RESULT_JSON ')); + assert.ok(line, `result line printed; output was: ${out}`); + return JSON.parse(line.slice('REPO_MEMORY_QUERY_RESULT_JSON '.length)); +} + +async function main() { + const { mockEmbedding } = require('./query.js'); + const client = createClient({ url }); + await client.connect(); + try { + assert.strictEqual(await client.dbSize(), 0, 'refusing to run: the test Redis is not empty'); + + // No index yet: nothing to find, and the index is not created. + assert.deepStrictEqual(query({ QUERY_TEXT: 'anything' }).results, [], 'missing index returns no results'); + assert.ok(!(await client.sendCommand(['FT._LIST'])).includes('repo_memory_idx'), 'must not create the index'); + assert.strictEqual(await client.dbSize(), 0, 'missing-index query wrote nothing'); + + await client.ft.create('repo_memory_idx', { + type: { type: SchemaFieldTypes.TAG }, + repo: { type: SchemaFieldTypes.TAG }, + title: { type: SchemaFieldTypes.TEXT, WEIGHT: 2 }, + body_summary: { type: SchemaFieldTypes.TEXT }, + source_url: { type: SchemaFieldTypes.TEXT, NOSTEM: true }, + created_at: { type: SchemaFieldTypes.TEXT }, + tags: { type: SchemaFieldTypes.TAG, SEPARATOR: ',' }, + embedding: { + type: SchemaFieldTypes.VECTOR, + ALGORITHM: VectorAlgorithms.HNSW, + TYPE: 'FLOAT32', + DIM: 1536, + DISTANCE_METRIC: 'COSINE', + }, + }, { ON: 'HASH', PREFIX: 'memory:' }); + for (const [n, title] of ITEMS) { + await client.hSet(`memory:test_repo_pr_${n}`, { + id: `test_repo_pr_${n}`, type: 'pr_summary', repo: REPO, title, + body_summary: title, source_url: `https://github.com/${REPO}/pull/${n}`, + created_at: '2026-01-01T00:00:00Z', tags: '', + embedding: Buffer.from(new Float32Array(mockEmbedding(`${title}\n${title}\n`)).buffer), + }); + } + // An item from another repository must never be returned. + await client.hSet('memory:other_repo_pr_9', { + id: 'other_repo_pr_9', type: 'pr_summary', repo: 'other/repo', title: ITEMS[0][1], + body_summary: ITEMS[0][1], source_url: 'https://github.com/other/repo/pull/9', + created_at: '2026-01-01T00:00:00Z', tags: '', + embedding: Buffer.from(new Float32Array(mockEmbedding(ITEMS[0][1])).buffer), + }); + + const before = await client.dbSize(); + const q = 'Move the job-queue Jedis use case to RedisClient and close pipelines'; + + // Closest item first, other repositories excluded, full text absent without a token. + const r = query({ QUERY_TEXT: q, QUERY_FILES: 'content/develop/use-cases/job-queue/RedisJobQueue.java' }); + assert.ok(r.results.length >= 1, 'returns at least one result'); + assert.ok(/JedisPool to RedisClient/.test(r.results[0].title), `closest first, got: ${r.results[0].title}`); + assert.ok(r.results.every(x => x.sourceUrl.includes(`/${REPO}/`)), 'only items from this repository'); + assert.strictEqual(r.results[0].fullText, undefined, 'no fullText without a token'); + assert.deepStrictEqual(r.files, ['content/develop/use-cases/job-queue/RedisJobQueue.java']); + + // Limit and distance are honored. + assert.strictEqual(query({ QUERY_TEXT: q, QUERY_MAX_DISTANCE: '0.0001' }).results.length, 0, 'tight distance'); + const wide = query({ QUERY_TEXT: q, QUERY_MAX_DISTANCE: '1.5' }).results; + assert.ok(wide.length >= 3, 'wide distance returns several results'); + assert.deepStrictEqual(wide.map(x => x.score), wide.map(x => x.score).sort((x, y) => x - y), 'nearest first'); + assert.strictEqual(query({ QUERY_TEXT: q, QUERY_MAX_DISTANCE: '1.5', QUERY_LIMIT: '2' }).results.length, 2, 'limit'); + + // Read-only: nothing added, changed or indexed. + assert.strictEqual(await client.dbSize(), before, 'query wrote nothing'); + + // Missing inputs fail loudly. + assert.throws(() => query({ QUERY_TEXT: ' ' }), /QUERY_TEXT is empty/, 'empty query is an error'); + assert.throws(() => query({ QUERY_TEXT: q, REDIS_URL: '' }), /REDIS_URL is not set/, 'missing url is an error'); + + console.log('all repo-memory-query tests passed'); + } finally { + for (const k of await client.keys('memory:*')) await client.del(k); + await client.ft.dropIndex('repo_memory_idx').catch(() => {}); + await client.quit(); + } +} + +main().catch(err => { + console.error('FAILED:', err.message); + process.exit(1); +}); diff --git a/.github/workflows/repo-memory-query.yml b/.github/workflows/repo-memory-query.yml new file mode 100644 index 0000000000..e80f08a3c6 --- /dev/null +++ b/.github/workflows/repo-memory-query.yml @@ -0,0 +1,41 @@ +# Manually triggered, read-only search of the Repo Memory store from a description of planned +# work, before any PR exists. Run it with `gh workflow run repo-memory-query.yml -f query=...`. +# See .github/actions/repo-memory-query/README.md. Unlike repo-memory.yml it never writes to +# Redis and never comments on a PR. +name: Repo Memory Query + +on: + workflow_dispatch: + inputs: + query: + description: Describe the work you are about to do + required: true + type: string + files: + description: Files you plan to change (space-separated, optional) + required: false + type: string + limit: + description: Maximum results + required: false + default: '5' + type: string + +permissions: + contents: read + +jobs: + query: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: .github/actions/repo-memory-query + + - uses: ./.github/actions/repo-memory-query + with: + redis-url: ${{ secrets.MEMORY_REDIS_URL }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + query: ${{ inputs.query }} + query-files: ${{ inputs.files }} + query-limit: ${{ inputs.limit }} From 2809ac3fca691c330b4f1953ddda9657fdc4b8eb Mon Sep 17 00:00:00 2001 From: Andy Stark Date: Thu, 5 Nov 2026 13:59:33 +0000 Subject: [PATCH 2/2] DOC-7268 Scope the memory query to this repo and fix the token scopes Bugbot found two real problems on #4479. The KNN search ran over the whole index and filtered by repository in JS, so other repositories' items could use up the neighbors, and FT.SEARCH returns only 10 rows by default, which silently capped any query-limit above 5. The search now filters on the repo TAG first (escaping the "/" and "-" in a repo name) and sets LIMIT to the neighbor count. The workflow granted only contents: read, so the token could not be relied on to read PR and issue text. My first draft had pull-requests: read and I dropped it when rewriting the workflow. It now grants pull-requests: read and issues: read. Whether GitHub serves public PRs to a token without that scope is unverified; the grant removes the question. A failed lookup now logs its HTTP status instead of silently dropping the full text. Tests: added a case with 12 other-repo items that match the query better than the in-repo item, and a query-limit of 13. My first crowding test was wrong (the in-repo item was the exact match, so nothing was crowded out); it now fails when the repo filter, LIMIT, or tag escaping is removed. Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/repo-memory-query/query.js | 10 +++++++- .github/actions/repo-memory-query/test.js | 28 ++++++++++++++++++++++ .github/workflows/repo-memory-query.yml | 2 ++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/actions/repo-memory-query/query.js b/.github/actions/repo-memory-query/query.js index 91d34cc1c1..9bbb3d45f4 100644 --- a/.github/actions/repo-memory-query/query.js +++ b/.github/actions/repo-memory-query/query.js @@ -57,6 +57,7 @@ async function githubGet(path, token) { 'X-GitHub-Api-Version': '2022-11-28', }, }); + if (!res.ok) console.error(`GitHub API ${res.status} for ${path}`); return res.ok ? res.json() : null; } @@ -89,6 +90,9 @@ async function fullTextFor(item, token) { } } +// Escape every character that is special in a TAG filter, such as the "/" and "-" in a repo name. +const escapeTag = s => s.replace(/[^A-Za-z0-9_]/g, c => '\\' + c); + function report(query, files, results) { // One line that `gh run view --log` output can be grepped for and parsed. console.log('REPO_MEMORY_QUERY_RESULT_JSON ' + JSON.stringify({ query, files, results })); @@ -134,10 +138,14 @@ async function main() { } const blob = Buffer.from(new Float32Array(await embed(searchText, OPENAI_API_KEY)).buffer); + // Filter to this repository inside the search, so other repositories' items cannot use up the + // neighbors, and set LIMIT because FT.SEARCH returns only 10 rows by default. + const k = Math.max(10, limit * 2); const found = await client.ft.search( INDEX_NAME, - `*=>[KNN ${Math.max(10, limit * 2)} @embedding $BLOB AS score]`, + `@repo:{${escapeTag(GITHUB_REPOSITORY)}}=>[KNN ${k} @embedding $BLOB AS score]`, { + LIMIT: { from: 0, size: k }, PARAMS: { BLOB: blob }, RETURN: ['type', 'title', 'body_summary', 'source_url', 'created_at', 'score', 'repo'], SORTBY: { BY: 'score', DIRECTION: 'ASC' }, diff --git a/.github/actions/repo-memory-query/test.js b/.github/actions/repo-memory-query/test.js index ca77cdb114..1d1c732253 100644 --- a/.github/actions/repo-memory-query/test.js +++ b/.github/actions/repo-memory-query/test.js @@ -92,6 +92,34 @@ async function main() { embedding: Buffer.from(new Float32Array(mockEmbedding(ITEMS[0][1])).buffer), }); + // Twelve other-repository items closer to the query than any in-repo item must not crowd the + // in-repo matches out of the neighbors. + // The query adds words the in-repo item lacks, so the other-repository items match it better. + const nearQuery = 'Rewrite the sorted set tutorial to use ZRANGE with BYSCORE and REV for leaderboard scores and ranks'; + for (let i = 0; i < 12; i++) { + await client.hSet(`memory:other_repo_pr_${100 + i}`, { + id: `other_repo_pr_${100 + i}`, type: 'pr_summary', repo: 'other/repo', title: `${nearQuery} ${i}`, + body_summary: nearQuery, source_url: `https://github.com/other/repo/pull/${100 + i}`, + created_at: '2026-01-01T00:00:00Z', tags: '', + embedding: Buffer.from(new Float32Array(mockEmbedding(`${nearQuery} ${i}\n${nearQuery}\n`)).buffer), + }); + } + const crowded = query({ QUERY_TEXT: nearQuery }).results; + assert.ok(crowded.length >= 1 && /sorted set tutorial/.test(crowded[0].title), 'other repos do not crowd out in-repo matches'); + assert.ok(crowded.every(x => x.sourceUrl.includes(`/${REPO}/`)), 'only items from this repository'); + + // A limit above FT.SEARCH's default of 10 rows is honored. + for (let i = 0; i < 14; i++) { + const title = `Update client example number ${i} for the shared tutorial`; + await client.hSet(`memory:test_repo_pr_${200 + i}`, { + id: `test_repo_pr_${200 + i}`, type: 'pr_summary', repo: REPO, title, + body_summary: title, source_url: `https://github.com/${REPO}/pull/${200 + i}`, + created_at: '2026-01-01T00:00:00Z', tags: '', + embedding: Buffer.from(new Float32Array(mockEmbedding(`${title}\n${title}\n`)).buffer), + }); + } + assert.strictEqual(query({ QUERY_TEXT: 'Update client example for the shared tutorial', QUERY_MAX_DISTANCE: '1.9', QUERY_LIMIT: '13' }).results.length, 13, 'limit above 10'); + const before = await client.dbSize(); const q = 'Move the job-queue Jedis use case to RedisClient and close pipelines'; diff --git a/.github/workflows/repo-memory-query.yml b/.github/workflows/repo-memory-query.yml index e80f08a3c6..e2d07c681b 100644 --- a/.github/workflows/repo-memory-query.yml +++ b/.github/workflows/repo-memory-query.yml @@ -23,6 +23,8 @@ on: permissions: contents: read + pull-requests: read + issues: read jobs: query: