diff --git a/.zai-scheduled.yml.template b/.zai-scheduled.yml.template index b18036f..bef7e6d 100644 --- a/.zai-scheduled.yml.template +++ b/.zai-scheduled.yml.template @@ -43,6 +43,37 @@ tasks: # URL to fetch content from gist_url: https://gist.githubusercontent.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14/raw + # --- AGENTS.md upgrade scoping (all optional) --- + # + # The bot always collects REAL repository context (file tree, existing + # AGENTS.md files, and key file contents) and feeds it to the model so it + # never hallucinates a project from the repo name alone. + # + # Limit which parts of the repo are analyzed for context (default: whole repo): + # context_paths: + # - "src" + # - "tests" + # + # Restrict where AGENTS.md may be written. Root AGENTS.md is ALWAYS allowed. + # When omitted, the model may update/create AGENTS.md anywhere justified. + # target_paths: + # - "src/lib" + # - "tests" + # + # Globs to ignore when collecting context (merged with built-in defaults + # like node_modules, dist, build, coverage, *.lock): + # exclude_paths: + # - "docs/generated/**" + # + # Prompt/context budgets: + # max_context_chars: 120000 # total chars of file contents sent to the model + # max_file_chars: 12000 # per-file content cap (large files head-truncated) + # max_files_to_fetch: 40 # hard cap on content fetches (rate-limit guard) + # + # Generation policy: + # allow_create_new: true # may propose new child AGENTS.md files (default true) + # update_existing_only: false # if true, only existing AGENTS.md files may change + # PR configuration pr_title: "chore: update AGENTS.md files" pr_body: | @@ -114,6 +145,20 @@ tasks: # Required config: branch (optional, defaults to main) # Optional config: gist_url, pr_title, pr_body, commit_message # +# AGENTS.md upgrade scoping (all optional): +# context_paths - limit which repo paths are analyzed for context +# target_paths - restrict where AGENTS.md files may be written +# exclude_paths - globs ignored when collecting context +# max_context_chars - total context char budget (default 120000) +# max_file_chars - per-file content cap (default 12000) +# max_files_to_fetch - hard cap on content fetches (default 40) +# allow_create_new - may propose new child AGENTS.md (default true) +# update_existing_only - only existing AGENTS.md files may change (default false) +# +# The bot ALWAYS collects real repository context before generating, and +# VALIDATES output against it: it rejects files that reference paths that do +# not exist in the repo, preventing hallucinated content from being committed. +# # Example command for auto-discovery mode (should be in Gist): # /init-agentsmd # This command must return JSON like: diff --git a/AGENTS.md b/AGENTS.md index e8f714a..06ac3fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,55 @@ # PROJECT KNOWLEDGE BASE -**Generated:** 2026-06-27T00:00:00Z +**Generated:** 2026-07-01T00:00:00Z **Branch:** main -**Refresh:** reconciled line counts, added `scheduled` handler + `config/` + `code-scope.js` +**Refresh:** reconciled against `plans/*` (scheduled-tasks pipeline); verified scheduled event routing, config loader, handler symbols, manual `/zai update-agents` command, action.yml inputs, and line counts. Gaps flagged: no scheduled test coverage, no `docs/`, README not updated. ## OVERVIEW -JavaScript GitHub Action that performs PR auto-review and collaborator-gated `/zai` PR comment commands. Runtime executes bundled `dist/index.js`; maintained logic lives in `src/index.js` plus modular services in `src/lib/*`. +JavaScript GitHub Action with three event flows: (1) PR auto-review, (2) collaborator-gated `/zai` PR comment commands, and (3) cron-triggered scheduled tasks (`.zai-scheduled.yml`) that regenerate AGENTS.md files and open PRs. Runtime executes bundled `dist/index.js`; maintained logic lives in `src/index.js` plus modular services in `src/lib/*`. ## STRUCTURE ```text zai-code-bot/ ├── src/index.js # Runtime orchestration and event dispatch (~1095 lines) ├── src/lib/ # Commands/auth/context/comments/api/services +├── src/lib/events.js # Event-type detection incl. `schedule` (cron) routing +├── src/lib/commands.js # `/zai` parser + allowlist (incl. `update-agents`) ├── src/lib/auto-review.js # Large PR batching and synthesis ├── src/lib/changed-files.js # Paginated changed-files fetch (3000 file limit) ├── src/lib/pr-context.js # Shared PR context fetch (files, content at ref, refs) ├── src/lib/code-scope.js # Token-budget calculation for prompt sizing ├── src/lib/config/scheduled-config.js # Scheduled-task config loader (.zai-scheduled.yml) -├── src/lib/handlers/ # Command handlers (ask/review/explain/describe/impact/help/scheduled) +├── src/lib/repository-context.js # Real repo context collection (tree + AGENTS.md discovery + key files) +├── src/lib/agents-validation.js # Hallucination guard: validates generated AGENTS.md vs real repo +├── src/lib/handlers/ # Command + scheduled handlers (ask/review/explain/describe/impact/help/scheduled) ├── tests/ # Unit and integration coverage ├── dist/index.js # Generated ncc bundle executed by GitHub ├── dist/licenses.txt # Generated third-party licenses -├── action.yml # Action inputs and runtime entry +├── action.yml # Action inputs (incl. ZAI_SCHEDULED_*, ZAI_AGENTS_GIST_URL) +├── .zai-scheduled.yml # Scheduled-task config for THIS repo (AGENTS.md upkeep) +├── .zai-scheduled.yml.template # Consumer template for scheduled tasks ├── .github/workflows/ci.yml # Test/build/dist-drift/audit gates +├── .github/workflows/zai-agents-update.yml # Self-hosted scheduled AGENTS.md upkeep workflow └── .github/workflows/code-review.yml # Consumer usage example ``` ## WHERE TO LOOK | Task | Location | Notes | |------|----------|-------| -| Route events and command execution | `src/index.js` | `run()`, pull_request path, issue_comment command path | -| Parse commands and enforce allowlist | `src/lib/commands.js` | `/zai` parser, command normalization, help fallback | +| Route events and command execution | `src/index.js` | `run()`, pull_request path, issue_comment command path, schedule path | +| Schedule event detection | `src/lib/events.js` | `getEventType` returns `schedule`; `shouldProcessEvent` always processes cron events | +| Parse commands and enforce allowlist | `src/lib/commands.js` | `/zai` parser, command normalization, help fallback; `update-agents` in allowlist | | Authorization and fork policy | `src/lib/auth.js` | Collaborator checks and fork-safe behavior | | Comment/reaction behavior | `src/lib/comments.js` | Marker-based upsert, threaded reply (`replyToId`), reactions | | API retry/error handling | `src/lib/api.js`, `src/lib/logging.js` | Retry policy, categorized safe errors | | Large PR batching and synthesis | `src/lib/auto-review.js` | Batch creation, context limit handling, synthesis prompt | | Paginated changed-files fetch | `src/lib/changed-files.js` | Handles GitHub's 3000 file API limit | | Shared PR context fetch | `src/lib/pr-context.js` | `fetchPrFiles`, `fetchFileAtRef`, `resolvePrRefs`; user-safe fallbacks, size limits | +| Scheduled-task config loading | `src/lib/config/scheduled-config.js` | `loadScheduledConfig`, `getTasksToRun`, `validateAndNormalizeConfig`, `validateAgentsConfig`, `getGistUrl`; scoping fields (`context_paths`/`target_paths`/`exclude_paths`/budgets) | +| Scheduled-task execution | `src/lib/handlers/scheduled.js` | `handleScheduledEvent`, `handleUpdateAgentsTask` (grounded flow: context→prompt→validate→PR), `SCHEDULED_HANDLERS`; see child `src/lib/handlers/AGENTS.md` | +| Repository context for AGENTS.md gen | `src/lib/repository-context.js` | `collectRepositoryContext` (git tree + existing AGENTS.md discovery + key files, budgeted), `renderRepositoryContext` | +| AGENTS.md output validation | `src/lib/agents-validation.js` | `validateGeneratedAgentFiles` — rejects non-AGENTS paths, out-of-scope writes, and hallucinated content referencing non-existent files | +| Manual `/zai update-agents` | `src/index.js` (`dispatchCommand`) | Reuses `handleUpdateAgentsTask` for ad-hoc AGENTS.md updates | | Command-specific behavior | `src/lib/handlers/AGENTS.md` | Local guide for each handler module | | Test strategy and fixtures | `tests/AGENTS.md` | Test map and suite conventions | | Action runtime contract | `action.yml` | Node runtime + dist entrypoint | @@ -64,9 +77,25 @@ zai-code-bot/ | `resolvePrRefs` | function | `src/lib/pr-context.js` | low | Resolves base/head refs for diff context | | `MAX_PR_FILES_API_LIMIT` | constant | `src/lib/changed-files.js` | low | GitHub API ceiling (3000) | | `calculateTokenBudget` | function | `src/lib/code-scope.js` | medium | Token/char budget sizing for prompts | -| `detectEventType` | function | `src/lib/events.js` | low | Event-type detection for routing | +| `getEventType` | function | `src/lib/events.js` | low | Event-type detection for routing (incl. `schedule`) | +| `shouldProcessEvent` | function | `src/lib/events.js` | low | Event filter; always-process gate for cron events | | `loadScheduledConfig` | function | `src/lib/config/scheduled-config.js` | low | Parses `.zai-scheduled.yml` task config | -| `handleScheduledCommand` | function | `src/lib/handlers/scheduled.js` | medium | Scheduled-task execution (largest handler) | +| `validateAndNormalizeConfig` | function | `src/lib/config/scheduled-config.js` | low | Schema validation + default-merging for tasks | +| `getTasksToRun` | function | `src/lib/config/scheduled-config.js` | low | Filters tasks whose schedule matches the event | +| `getGistUrl` | function | `src/lib/config/scheduled-config.js` | low | Resolves gist URL priority: task > defaults > env | +| `handleScheduledEvent` | function | `src/lib/handlers/scheduled.js` | medium | Scheduled pipeline entry: load config, run matching tasks | +| `executeScheduledTask` | function | `src/lib/handlers/scheduled.js` | medium | Per-task executor; builds context, dispatches via registry | +| `handleUpdateAgentsTask` | function | `src/lib/handlers/scheduled.js` | medium | AGENTS.md regeneration: gist → collect repo context → grounded Z.ai prompt → validate → JSON diff → PR | +| `SCHEDULED_HANDLERS` | constant | `src/lib/handlers/scheduled.js` | low | Command→handler registry; `getScheduledHandler`/`registerScheduledHandler` extend it | +| `buildAgentsUpgradePrompt` | function | `src/lib/handlers/scheduled.js` | low | Grounded prompt: embeds real tree + existing AGENTS.md + key files; tells model it has NO live repo access | +| `parseFileUpdatesFromResponse` | function | `src/lib/handlers/scheduled.js` | low | Multi-format JSON extraction from Z.ai output | +| `callZaiApiWithRetry` | function | `src/lib/handlers/scheduled.js` | low | Z.ai HTTP client (native https) with retry for scheduled tasks | +| `fetchFromUrl` | function | `src/lib/handlers/scheduled.js` | low | HTTP GET for gist command text (30s timeout) | +| `createPR` | function | `src/lib/handlers/scheduled.js` | low | Branch + multi-file commit + PR open for scheduled changes | +| `collectRepositoryContext` | function | `src/lib/repository-context.js` | medium | git.getTree → existing AGENTS.md discovery + key-file contents, budgeted + glob-excluded | +| `renderRepositoryContext` | function | `src/lib/repository-context.js` | low | Renders collected context into a compact prompt block | +| `validateGeneratedAgentFiles` | function | `src/lib/agents-validation.js` | medium | Pre-PR guard: rejects non-AGENTS paths, out-of-scope/target writes, and hallucinated content | +| `validateAgentsConfig` | function | `src/lib/config/scheduled-config.js` | low | Validates scoping/budget config fields (`context_paths`, `target_paths`, etc.) | ## CONVENTIONS - Edit maintained code in `src/`; do not hand-edit generated `dist/index.js`. @@ -79,6 +108,8 @@ zai-code-bot/ - Bypassing authorization/fork checks for command handlers. - Executing command logic for non-PR issue comments. - Allowing unbounded context payloads into prompts. +- Asking an LLM to "scan the repo" without providing real repo context (causes hallucinated AGENTS.md — PR #15 bug). +- Committing AGENTS.md output that references files/languages not present in the repo. - Editing `dist/` manually or shipping source changes without rebuilt artifacts. - Treating `.github/workflows/code-review.yml` example as runtime logic. @@ -99,6 +130,8 @@ After source changes: run `npm run build` and commit `dist/index.js` + `dist/lic ## NOTES - CI (`.github/workflows/ci.yml`) enforces tests, build, dist drift, and security audit across Node 20 + 22. - No linting/formatting configs (ESLint, Prettier) — rely on code review and CI gates. -- 7 command handlers: ask (521), review (218), explain (355), describe (129), impact (336), help (95), scheduled (1075 — largest handler, drives scheduled tasks via `.zai-scheduled.yml`). +- 7 command handlers + scheduled pipeline: ask (521), review (218), explain (355), describe (129), impact (336), help (95), scheduled (~1180 — largest handler, drives scheduled tasks via `.zai-scheduled.yml`). `update-agents` (manual `/zai` command) reuses `handleUpdateAgentsTask` from the scheduled module. +- AGENTS.md upgrade flow (the `update-agents` task) is grounded + validated: `handleUpdateAgentsTask` calls `collectRepositoryContext` (`src/lib/repository-context.js`) to gather the real file tree, auto-discover existing AGENTS.md files, and fetch key file contents; `buildAgentsUpgradePrompt` embeds that context and tells the model it has NO live repo access; `validateGeneratedAgentFiles` (`src/lib/agents-validation.js`) rejects hallucinated/non-AGENTS/out-of-scope output before any PR. Scoping knobs live in `.zai-scheduled.yml` task config: `context_paths`, `target_paths`, `exclude_paths`, `max_context_chars`, `max_file_chars`, `max_files_to_fetch`, `allow_create_new`, `update_existing_only`. +- Scheduled pipeline gaps (per `plans/*`): `docs/scheduled-tasks.md` not created; README has no scheduled section. (Scheduled unit/integration tests now exist: `tests/handlers/scheduled.test.js`, `tests/scheduled-config.test.js`, `tests/repository-context.test.js`, `tests/agents-validation.test.js`.) - Test framework: Vitest v3 (not Jest). Command: `npm test` → `vitest run --coverage`. -- Large files: src/lib/handlers/scheduled.js (1075 lines), src/index.js (1095 lines), src/lib/handlers/ask.js (521 lines), src/lib/pr-context.js (433 lines). +- Large files: src/lib/handlers/scheduled.js (1081 lines), src/index.js (1095 lines), src/lib/handlers/ask.js (521 lines), src/lib/pr-context.js (433 lines). diff --git a/README.md b/README.md index 7bcb90a..669451b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ GitHub Action for automatic PR reviews and context-rich `/zai` commands powered ## Features - Automatic pull request review on `opened` and `synchronize` -- Interactive PR commands: `/zai ask`, `/zai review`, `/zai explain`, `/zai describe`, `/zai impact`, `/zai help` +- Interactive PR commands: `/zai ask`, `/zai review`, `/zai explain`, `/zai describe`, `/zai impact`, `/zai update-agents`, `/zai help` +- Scheduled tasks (cron): periodically regenerate `AGENTS.md` files and open PRs (see [Scheduled Tasks](docs/scheduled-tasks.md)) - Context-aware command prompts with full-file, diff, and thread context - Inline review-comment support (`pull_request_review_comment`) with file/line anchors - `/zai explain` auto-detects selected line range from review comments @@ -66,6 +67,9 @@ jobs: | `ZAI_AUTO_REVIEW_MAX_BATCH_CHARS` | No | `120000` | Approximate character budget per batched PR auto-review request | | `ZAI_AUTO_REVIEW_MAX_FILES_PER_BATCH` | No | `40` | Maximum distinct files included in each batched PR auto-review request | | `ZAI_AUTO_REVIEW_MAX_PATCH_CHARS` | No | `18000` | Maximum diff characters per file chunk before a large patch is split across review parts | +| `ZAI_SCHEDULED_ENABLED` | No | `true` | Master switch for the scheduled-tasks pipeline | +| `ZAI_SCHEDULED_CONFIG_PATH` | No | `.zai-scheduled.yml` | Path to the scheduled-tasks config file | +| `ZAI_AGENTS_GIST_URL` | No | - | Fallback Gist URL for the `update-agents` task (lowest priority) | ## Commands @@ -78,6 +82,7 @@ Commands are processed from PR issue comments and PR review comments. Supported | `/zai explain` | `/zai explain ` | Explain selected lines (e.g., `/zai explain 10-25`) | | `/zai describe` | `/zai describe` | Generate a PR description from commit messages | | `/zai impact` | `/zai impact` | Analyze potential impact of changes | +| `/zai update-agents` | `/zai update-agents` | Regenerate `AGENTS.md` files on demand (same as the scheduled task) | | `/zai help` | `/zai help` | Show command help | **Note:** Only collaborators (and PR authors on their own fork PRs) can use these commands. @@ -93,6 +98,37 @@ Commands are processed from PR issue comments and PR review comments. Supported - Command execution is authorization-gated; fork PR authors can run commands on their own PR - If GitHub's changed-files API limit is reached, the final review notes that coverage is incomplete beyond the platform ceiling +## Scheduled Tasks + +In addition to PR review and `/zai` commands, Zai Code Bot can run tasks on a +schedule. The built-in `update-agents` task periodically regenerates your +`AGENTS.md` knowledge files and opens a pull request with the changes — a PR is +created only when at least one file actually changed. + +### Minimal setup + +1. Add a `.zai-scheduled.yml` to your repo root (copy `.zai-scheduled.yml.template`). +2. Put the generation command in a public Gist and expose its raw URL via the + `ZAI_AGENTS_GIST_URL` action input (or `gist_url` in the config). +3. Add a `schedule` (cron) workflow that runs the action. A ready-to-use + workflow snippet is included in `.zai-scheduled.yml.template`. + +### `gist_url` priority + +The Gist URL is resolved first-non-empty-wins: +`task.config.gist_url` > `defaults.gist_url` > `ZAI_AGENTS_GIST_URL`. + +### Manual run + +Trigger an AGENTS.md regeneration on any PR with: + +``` +/zai update-agents +``` + +For the full configuration reference, cron syntax, schedule-matching behavior, +troubleshooting, and examples, see **[docs/scheduled-tasks.md](docs/scheduled-tasks.md)**. + ## Setup 1. Generate a Z.ai API key from your Z.ai account. diff --git a/dist/index.js b/dist/index.js index 5344806..1c3a214 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32345,6 +32345,200 @@ run().catch(error => { }); +/***/ }), + +/***/ 3731: +/***/ ((module) => { + +/** + * AGENTS.md Output Validation + * + * Validates the model-generated AGENTS.md file updates against the collected + * repository context BEFORE any PR is created. This is the guardrail that + * prevents hallucinated output (e.g. a fictional Python project for a JS repo) + * from being committed — the exact failure seen in PR #15. + * + * Validation is conservative-by-default and never throws; it returns a + * structured result separating accepted files from rejected ones, with reasons. + */ + +// Regex for path-like tokens. We scan raw content (backticks act as natural +// delimiters since they are outside the character class), so this captures both +// `backtick/path.js` tokens and bare tree-listing entries like '├── main.py'. +const TOKEN_RE = /[A-Za-z0-9._/@-]{1,200}/g; + +// Suffix test: looks like a real file extension (letters/digits, length 1-5). +const EXT_RE = /\.[a-z0-9]{1,5}$/i; + +// Files referenced in content that we never treat as hallucination evidence: +// very common generic terms that look like paths but usually are not real files. +const GENERIC_TERMS = new Set([ + 'AGENTS.md', 'README.md', 'LICENSE', 'CONTRIBUTING.md', 'CHANGELOG.md', + '.env', '.gitignore', +]); + +/** + * Is this a valid AGENTS.md path? Must be exactly "AGENTS.md" or end with "/AGENTS.md". + * @param {string} path + * @returns {boolean} + */ +function isAgentsPath(path) { + if (typeof path !== 'string') return false; + return path === 'AGENTS.md' || path.endsWith('/AGENTS.md'); +} + +/** + * Extract backtick-quoted path-like tokens from markdown content. + * @param {string} content + * @returns {string[]} unique tokens + */ +function extractReferencedPaths(content) { + if (!content || typeof content !== 'string') return []; + const found = new Set(); + let match; + TOKEN_RE.lastIndex = 0; + while ((match = TOKEN_RE.exec(content)) !== null) { + const token = match[0]; + if (GENERIC_TERMS.has(token)) continue; + // Looks path-ish: contains a slash OR has a file-like extension. + if (token.includes('/') || EXT_RE.test(token)) { + found.add(token); + } + } + return [...found]; +} + +/** + * Check whether a referenced token corresponds to (or is a prefix of) a real path + * in the collected tree. Tolerates leading "./" and trailing slashes. + * @param {string} token + * @param {string[]} tree - repo-relative file paths + * @returns {boolean} + */ +function referenceExistsInTree(token, tree) { + if (!tree || tree.length === 0) return true; // cannot disprove -> don't flag + const norm = token.replace(/^\.\//, '').replace(/\/+$/, ''); + if (!norm) return true; + // Exact file match, or the token is a directory prefix of a real path. + if (tree.includes(norm)) return true; + return tree.some(p => p.startsWith(norm + '/') || p === norm); +} + +/** + * Validate a single generated file entry against context and policy. + * @param {Object} fileUpdate - { file, newContent, isNew } + * @param {Object} ctx - collected repository context + * @param {Object} policy - { targetPaths, allowCreateNew, updateExistingOnly } + * @returns {{ valid: boolean, reasons: string[] }} + */ +function validateFileEntry(fileUpdate, ctx, policy) { + const reasons = []; + const path = fileUpdate?.file; + + // Rule 1: must be an AGENTS.md path. + if (!isAgentsPath(path)) { + reasons.push(`path "${path}" is not an AGENTS.md file`); + return { valid: false, reasons }; + } + + // Rule 2: target-path scoping. Root AGENTS.md is always allowed. + const isRoot = path === 'AGENTS.md'; + if (!isRoot && policy.targetPaths && policy.targetPaths.length) { + const allowed = policy.targetPaths.some(prefix => { + if (!prefix) return true; + return path.startsWith(prefix + '/'); + }); + if (!allowed) { + reasons.push(`path "${path}" is outside configured target_paths (${policy.targetPaths.join(', ')})`); + } + } + + // Rule 3: update-existing-only mode rejects brand-new child files. + const existedBefore = ctx?.existingAgentsFiles?.includes(path) ?? false; + if (policy.updateExistingOnly === true && !isRoot && !existedBefore) { + reasons.push(`new file "${path}" rejected because update_existing_only is true`); + } + + // Rule 4: allow_create_new gate. + if (policy.allowCreateNew === false && !existedBefore && !isRoot) { + reasons.push(`new file "${path}" rejected because allow_create_new is false`); + } + + // Rule 5: hallucination check. If content references many file paths that do + // not exist in the tree, that is strong evidence of fabrication. + const referenced = extractReferencedPaths(fileUpdate?.newContent || ''); + if (referenced.length) { + const unknown = referenced.filter(t => !referenceExistsInTree(t, ctx?.tree)); + // Flag when MORE THAN HALF of referenced concrete paths are unknown, OR when + // several distinct unknown files appear (catches wholesale fabricated trees). + const threshold = Math.max(3, Math.ceil(referenced.length / 2)); + if (unknown.length >= threshold) { + reasons.push( + `content references ${unknown.length} file path(s) not present in the repository ` + + `(e.g. ${unknown.slice(0, 5).map(t => `\`${t}\``).join(', ')}); likely hallucination` + ); + } + } + + return { valid: reasons.length === 0, reasons }; +} + +/** + * Validate a full batch of generated file updates. + * + * @param {Object} params + * @param {Array} params.fileUpdates - [{ file, newContent, changed, isNew }] + * @param {Object} params.repositoryContext - collected context (see repository-context.js) + * @param {string[]} [params.targetPaths] - write scope restriction + * @param {boolean} [params.allowCreateNew=true] - may new child AGENTS.md be proposed + * @param {boolean} [params.updateExistingOnly=false] - reject new files entirely + * @param {Object} [params.logger] + * @returns {{ accepted: Array, rejected: Array<{file, reasons: string[]}>, allRejected: boolean }} + */ +function validateGeneratedAgentFiles(params) { + const fileUpdates = params.fileUpdates || []; + const repositoryContext = params.repositoryContext || {}; + const logger = params.logger || console; + const policy = { + targetPaths: params.targetPaths || repositoryContext.targetPaths || [], + allowCreateNew: params.allowCreateNew !== false, + updateExistingOnly: params.updateExistingOnly === true, + }; + + const accepted = []; + const rejected = []; + + for (const entry of fileUpdates) { + // Only validate entries that would actually change something. + if (entry && entry.changed === false) { + accepted.push(entry); + continue; + } + const { valid, reasons } = validateFileEntry(entry, repositoryContext, policy); + if (valid) { + accepted.push(entry); + } else { + rejected.push({ file: entry?.file, reasons }); + logger.warn?.({ file: entry?.file, reasons }, 'Rejected generated AGENTS.md file'); + } + } + + return { + accepted, + rejected, + allRejected: fileUpdates.length > 0 && accepted.length === 0, + }; +} + +module.exports = { + validateGeneratedAgentFiles, + validateFileEntry, + isAgentsPath, + extractReferencedPaths, + referenceExistsInTree, +}; + + /***/ }), /***/ 9729: @@ -34224,9 +34418,53 @@ function validateAndNormalizeTask(task, index, defaults) { throw new Error(`Task ${task.id} has invalid files value (must be array)`); } + // Validate AGENTS.md upgrade scoping fields (all optional). + validateAgentsConfig(task.id, normalized.config); + return normalized; } +/** + * Validate optional AGENTS.md upgrade scoping/budget fields on a task config. + * Unknown-but-typed-wrong values throw; missing values are left unset so + * callers apply their own defaults at runtime. + * @param {string} taskId + * @param {Object} cfg - task config (mutated to normalize array fields) + * @throws {Error} on type mismatches + */ +function validateAgentsConfig(taskId, cfg) { + const label = `Task ${taskId}`; + + const pathArrays = ['context_paths', 'target_paths', 'exclude_paths']; + for (const key of pathArrays) { + if (cfg[key] !== undefined && !Array.isArray(cfg[key])) { + throw new Error(`${label} has invalid ${key} value (must be array of path/glob strings)`); + } + if (Array.isArray(cfg[key])) { + const bad = cfg[key].find(v => typeof v !== 'string'); + if (bad !== undefined) { + throw new Error(`${label} has non-string entry in ${key}`); + } + } + } + + const positiveInts = ['max_context_chars', 'max_file_chars', 'max_files_to_fetch']; + for (const key of positiveInts) { + if (cfg[key] !== undefined) { + if (typeof cfg[key] !== 'number' || !Number.isFinite(cfg[key]) || cfg[key] <= 0) { + throw new Error(`${label} has invalid ${key} value (must be a positive number)`); + } + } + } + + const booleans = ['allow_create_new', 'update_existing_only']; + for (const key of booleans) { + if (cfg[key] !== undefined && typeof cfg[key] !== 'boolean') { + throw new Error(`${label} has invalid ${key} value (must be boolean)`); + } + } +} + /** * Get tasks that should run for this schedule event * @param {Object} config - Validated configuration @@ -34287,6 +34525,7 @@ module.exports = { // Export for testing validateDefaults, validateAndNormalizeTask, + validateAgentsConfig, }; @@ -36643,6 +36882,11 @@ const https = __nccwpck_require__(4708); const { parseCommand, isValid } = __nccwpck_require__(5055); const { loadScheduledConfig, getTasksToRun, getGistUrl } = __nccwpck_require__(4658); const { createLogger, generateCorrelationId } = __nccwpck_require__(2120); +const { + collectRepositoryContext, + renderRepositoryContext, +} = __nccwpck_require__(787); +const { validateGeneratedAgentFiles } = __nccwpck_require__(3731); const core = __nccwpck_require__(7484); // Module-level fallback logger so module-scoped helpers (e.g. fetchFromUrl) @@ -36959,9 +37203,36 @@ async function handleUpdateAgentsTask(context) { logger.info(`Fetched ${gistContent.length} characters from gist`); - // Step 2: Execute the command from Gist - // The command MUST return structured JSON with file paths and contents - logger.info('Executing command in auto-discovery mode - expecting structured file map'); + // Step 2: Collect REAL repository context (file tree, existing AGENTS.md + // files, key file contents). Without this the model has nothing to ground + // its output on and hallucinates a project from the repo name (PR #15 bug). + logger.info('Collecting repository context for grounded generation'); + const repositoryContext = await collectRepositoryContext({ + octokit, + owner, + repo, + branch: targetBranch, + contextPaths: task.config?.context_paths, + targetPaths: task.config?.target_paths, + excludePaths: task.config?.exclude_paths, + maxContextChars: task.config?.max_context_chars, + maxFileChars: task.config?.max_file_chars, + maxFilesToFetch: task.config?.max_files_to_fetch, + logger, + }); + + if (repositoryContext.totalFiles === 0 && !repositoryContext.truncated) { + logger.error('Repository context collection returned no files; cannot safely generate AGENTS.md'); + return { + success: false, + error: 'Empty repository context', + message: 'Could not read repository file tree. Aborting to avoid hallucinated output.', + }; + } + + // Step 3: Execute the command from Gist WITH the collected context attached. + // The command MUST return structured JSON with file paths and contents. + logger.info('Executing command in auto-discovery mode with repository context - expecting structured file map'); let fileUpdates = await executeCommandAndGetFileUpdates({ commandText: gistContent, octokit, @@ -36970,6 +37241,7 @@ async function handleUpdateAgentsTask(context) { owner, repo, targetBranch, + repositoryContext, logger, }); @@ -36982,9 +37254,48 @@ async function handleUpdateAgentsTask(context) { }; } - logger.info(`Auto-discovery found ${fileUpdates.length} file(s) to update`); + // Step 4: VALIDATE generated output against real repository context BEFORE + // creating the PR. Rejects non-AGENTS.md paths, out-of-scope writes, and + // content that references files that do not exist (hallucination guard). + const validation = validateGeneratedAgentFiles({ + fileUpdates, + repositoryContext, + targetPaths: repositoryContext.targetPaths, + allowCreateNew: task.config?.allow_create_new !== false, + updateExistingOnly: task.config?.update_existing_only === true, + logger, + }); + + logger.info( + { accepted: validation.accepted.length, rejected: validation.rejected.length }, + 'Validation complete' + ); + + if (validation.rejected.length) { + for (const r of validation.rejected) { + logger.warn({ file: r.file, reasons: r.reasons }, 'Rejected generated AGENTS.md file'); + } + } + + if (validation.allRejected) { + const sample = validation.rejected[0]; + return { + success: false, + error: 'All generated files failed validation', + message: 'Generated output was rejected by validation guards' + + (sample ? ` (e.g. ${sample.file}: ${sample.reasons[0]})` : '') + + '. No PR created to avoid committing hallucinated content.', + changes: fileUpdates, + rejected: validation.rejected, + }; + } + + // Use only accepted entries going forward. + fileUpdates = validation.accepted; + + logger.info(`Auto-discovery produced ${fileUpdates.length} file(s) to consider`); - // Step 3: Check if there are any actual changes + // Step 5: Check if there are any actual changes const updatedFiles = fileUpdates.filter(f => f.changed).map(f => f.file); if (updatedFiles.length === 0) { @@ -37009,7 +37320,7 @@ async function handleUpdateAgentsTask(context) { const prResult = await createPullRequest({ title: prTitle, - body: buildPrBody(prBody, updatedFiles, task.id), + body: buildPrBody(prBody, updatedFiles, task.id, repositoryContext), base: targetBranch, files: filesToUpdate, commitMessage, @@ -37049,7 +37360,7 @@ async function handleUpdateAgentsTask(context) { * @returns {Promise>} - Array of {file, oldContent, newContent, changed, isNew} objects */ async function executeCommandAndGetFileUpdates(params) { - const { commandText, octokit, apiKey, model, owner, repo, targetBranch, logger } = params; + const { commandText, octokit, apiKey, model, owner, repo, targetBranch, repositoryContext, logger } = params; if (!commandText || commandText.trim() === '') { logger.warn('Command text is empty, cannot execute'); @@ -37060,12 +37371,19 @@ async function executeCommandAndGetFileUpdates(params) { // Check if it's a valid /zai command const parseResult = parseCommand(commandText); - // Build prompt for auto-discovery mode - // The command should scan the repo and return a JSON structure with all AGENTS.md files - const prompt = buildAutoDiscoveryPrompt(commandText, owner, repo, targetBranch); + // Build a GROUNDED prompt using the collected repository context. + // Without real context the model hallucinates a project from the repo name. + const prompt = buildAgentsUpgradePrompt({ + commandText, + owner, + repo, + branch: targetBranch, + repositoryContext, + }); - // Call Z.ai API to execute the command - const response = await callZaiApiWithRetry(apiKey, model, prompt, logger); + // Call Z.ai API to execute the command (test-seam: __callZaiForTest hook). + const zaiCaller = module.exports.__callZaiForTest || callZaiApiWithRetry; + const response = await zaiCaller(apiKey, model, prompt, logger); if (!response || !response.content) { logger.warn('Z.ai API returned empty content'); @@ -37086,26 +37404,51 @@ async function executeCommandAndGetFileUpdates(params) { } /** - * Build prompt for auto-discovery mode - * @param {string} commandText - The command text - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} branch - Target branch - * @returns {string} - Formatted prompt - */ -function buildAutoDiscoveryPrompt(commandText, owner, repo, branch) { - return `Repository: ${owner}/${repo} + * Build a GROUNDED prompt for AGENTS.md generation. + * + * The model is given the REAL repository context (file tree, existing AGENTS.md + * files, and key file contents) and is explicitly told it has NO live repo + * access and must not invent files/languages/frameworks. This is the fix for + * the PR #15 hallucination bug where the model fabricated a Python Telegram + * bot for a JavaScript GitHub Action. + * + * @param {Object} params + * @param {string} params.commandText - The gist command text + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.branch - Target branch + * @param {Object} params.repositoryContext - Collected context (see repository-context.js) + * @returns {string} Formatted prompt + */ +function buildAgentsUpgradePrompt({ commandText, owner, repo, branch, repositoryContext }) { + const contextBlock = renderRepositoryContext(repositoryContext); + const existingCount = repositoryContext?.existingAgentsFiles?.length || 0; + const scopedNote = repositoryContext?.targetPaths?.length + ? `\nYou may ONLY write AGENTS.md at the repository root and under these target paths: ${repositoryContext.targetPaths.join(', ')}. Do not propose AGENTS.md anywhere else.` + : ''; + + return `You are a Staff-level software engineer generating and updating AGENTS.md files. + +Repository: ${owner}/${repo} Branch: ${branch} -You are an AI assistant tasked with generating and updating AGENTS.md files. +CRITICAL CONSTRAINTS: +- You do NOT have live repository access. Use ONLY the repository context below. +- Do NOT invent files, directories, languages, frameworks, or services that are not present in the provided context. +- Every fact in an AGENTS.md file must be grounded in the observable repository evidence below. +- If the context is insufficient to describe a file accurately, return an empty files array instead of guessing. -Execute the following command to scan the repository and generate/update AGENTS.md files: +The repository context below contains: the real file tree, the list of EXISTING AGENTS.md files (auto-discovered), and the contents of key files. Ground all output in these facts. -${commandText} +${contextBlock} -IMPORTANT: You must return the results in a specific JSON format so the bot can process your changes. +You must update the ${existingCount} existing AGENTS.md file(s) listed above.${scopedNote} -Return a JSON object with the following structure: +Execute the following command against the repository context above: + +${commandText} + +RETURN FORMAT (strict JSON only, no markdown fences, no prose): { "summary": "Brief description of changes", "files": [ @@ -37113,28 +37456,28 @@ Return a JSON object with the following structure: "path": "AGENTS.md", "content": "... full file content ...", "action": "created|updated|unchanged" - }, - { - "path": "src/lib/AGENTS.md", - "content": "... full file content ...", - "action": "created|updated|unchanged" } ] } Rules: -1. Scan the ENTIRE repository structure -2. Identify ALL existing AGENTS.md files -3. Determine if each needs to be updated -4. Create new AGENTS.md files where needed (root and important subdirectories) -5. For each file that needs changes, include the full new content -6. Only include files that have actual changes (action: "created" or "updated") -7. Return ONLY valid JSON, no other text, explanations, or markdown -8. The content must be the exact text that should be written to each file +1. Only emit paths that are "AGENTS.md" or end with "/AGENTS.md". +2. Preserve and reconcile EXISTING AGENTS.md content; do not discard repository-specific knowledge. +3. Only include files with actual changes (action "created" or "updated"). +4. The content must be the exact text written to the file. +5. If you cannot determine real facts, return {"summary": "insufficient context", "files": []}. Begin your response with the JSON object immediately, no preamble.`; } +/** + * @deprecated Kept as a thin alias for backward compatibility. + * Prefer buildAgentsUpgradePrompt(), which grounds output in real repo context. + */ +function buildAutoDiscoveryPrompt(commandText, owner, repo, branch) { + return buildAgentsUpgradePrompt({ commandText, owner, repo, branch, repositoryContext: null }); +} + /** * Parse file updates from Z.ai API response * @param {string} responseContent - Response content from Z.ai @@ -37273,18 +37616,42 @@ async function extractFilesFromText(text, octokit, owner, repo, targetBranch, fe * @param {string} baseBody - Base PR body * @param {Array} updatedFiles - List of updated files * @param {string} taskId - Task ID + * @param {Object} [repositoryContext] - Collected repo context for transparency * @returns {string} - Full PR body */ -function buildPrBody(baseBody, updatedFiles, taskId) { +function buildPrBody(baseBody, updatedFiles, taskId, repositoryContext) { const timestamp = new Date().toISOString(); const filesList = updatedFiles.map(f => `- ${f}`).join('\n'); - return `${baseBody}\n\n` + - `**Task:** ${taskId}\n` + - `**Timestamp:** ${timestamp}\n` + - `**Files Updated:**\n${filesList}\n\n` + - `---\n` + - `*Generated automatically by zai-code-bot scheduled task*`; + const parts = [baseBody || '', '']; + + if (repositoryContext) { + parts.push(`**Context mode:** ${repositoryContext.targetPaths?.length ? 'scoped' : 'full repository'}`); + if (repositoryContext.existingAgentsFiles?.length) { + parts.push('**Existing AGENTS.md files found:**'); + for (const f of repositoryContext.existingAgentsFiles) parts.push(`- ${f}`); + } + if (repositoryContext.targetPaths?.length) { + parts.push('**Target paths:**'); + for (const t of repositoryContext.targetPaths) parts.push(`- ${t}`); + } + if (repositoryContext.truncated) { + parts.push('**Note:** repository file tree was truncated; context may be incomplete.'); + } + parts.push(''); + } + + parts.push( + `**Task:** ${taskId}`, + `**Timestamp:** ${timestamp}`, + `**Files Updated:**`, + filesList, + '', + '---', + '*Generated automatically by zai-code-bot scheduled task*', + ); + + return parts.join('\n'); } // ============================================================================ @@ -37712,6 +38079,9 @@ module.exports = { updateFileInRepo, createPR, buildExecutionContext, + buildAgentsUpgradePrompt, + buildAutoDiscoveryPrompt, + parseFileUpdatesFromResponse, }; @@ -38473,6 +38843,416 @@ module.exports = { }; +/***/ }), + +/***/ 787: +/***/ ((module) => { + +/** + * Repository Context Collection + * + * Gathers real, observable repository facts (file tree, existing AGENTS.md files, + * and key file contents) so the AGENTS.md upgrade prompt is grounded in evidence + * instead of letting the model hallucinate a project from the repo name alone. + * + * This module is deliberately self-contained: it talks to Octokit directly and + * never depends on the scheduled handler, avoiding circular imports. + */ + +// Files that are always pulled into context when present (repo-defining metadata). +// These ground the model in the real language/framework/entry points. +const KEY_FILES = [ + 'README.md', + 'package.json', + 'package-lock.json', + 'tsconfig.json', + 'pyproject.toml', + 'requirements.txt', + 'Cargo.toml', + 'go.mod', + 'pom.xml', + 'build.gradle', + 'action.yml', + 'action.yaml', + '.zai-scheduled.yml', + 'Dockerfile', + 'CONTRIBUTING.md', + 'ARCHITECTURE.md', + '.github/copilot-instructions.md', +]; + +// Glob directory patterns excluded from context by default (vendored/generated noise). +const DEFAULT_EXCLUDE_PATHS = [ + 'node_modules/**', + '.git/**', + 'dist/**', + 'build/**', + 'coverage/**', + '.next/**', + '.nuxt/**', + '.cache/**', + '.turbo/**', + '__pycache__/**', + '.venv/**', + 'vendor/**', + '*.lock', + '*.min.js', + '*.min.css', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', +]; + +// Default context budget (characters). Generous enough for mid-size repos while +// staying well under typical model context windows after the gist prompt is added. +const DEFAULT_MAX_CONTEXT_CHARS = 120000; + +// Per-file content cap. Large files are head-truncated to this many characters. +const DEFAULT_MAX_FILE_CHARS = 12000; + +// Maximum number of content fetches we will issue (protects the API rate limit). +const DEFAULT_MAX_FILES_TO_FETCH = 40; + +/** + * Convert a glob pattern (supporting ** and *) into a RegExp. + * @param {string} pattern - Glob pattern + * @returns {RegExp} Matching regular expression + */ +function globToRegExp(pattern) { + // Anchor and escape, then translate glob tokens. + let re = '^'; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // '**' matches across directory separators. + // If it follows a literal '/', make that '/' optional so that + // 'node_modules/**' matches both 'node_modules' and 'node_modules/x'. + if (re.endsWith('/')) { + re = re.slice(0, -1) + '(?:/.*)?'; + } else { + re += '.*'; + } + i++; // consume second '*' + // swallow an optional trailing slash so 'a/**/' also works + if (pattern[i + 1] === '/') i++; + } else { + // '*' matches within a path segment + re += '[^/]*'; + } + } else if ('.+?^${}()|[]\\'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + re += '$'; + return new RegExp(re); +} + +/** + * Test whether a repo-relative path is excluded by any exclude glob. + * @param {string} path - Repo-relative path + * @param {RegExp[]} excludeRegexes - Compiled exclude patterns + * @returns {boolean} + */ +function isExcluded(path, excludeRegexes) { + return excludeRegexes.some(re => re.test(path)); +} + +/** + * Normalize a user-supplied path into a directory prefix form. + * '.' and '' -> '' (repo root). Trailing slash stripped. + * @param {string} p + * @returns {string} + */ +function normalizePathPrefix(p) { + if (!p) return ''; + let s = String(p).trim().replace(/\\/g, '/').replace(/\/+$/, ''); + if (s === '.' ) return ''; + return s; +} + +/** + * Returns true if `path` lives under one of the given context/target prefixes. + * Empty prefix list means "everywhere" (no restriction). + * @param {string} path + * @param {string[]} prefixes - normalized prefixes + * @returns {boolean} + */ +function isUnderPrefix(path, prefixes) { + if (!prefixes || prefixes.length === 0) return true; + const norm = path.replace(/\\/g, '/'); + return prefixes.some(prefix => { + if (!prefix) return true; + return norm === prefix || norm.startsWith(prefix + '/'); + }); +} + +/** + * Fetch a single file's text content from the repository. + * Returns null for missing files (404) instead of throwing. + * @param {Object} octokit + * @param {string} owner + * @param {string} repo + * @param {string} path + * @param {string} ref + * @returns {Promise} + */ +async function fetchFile(octokit, owner, repo, path, ref) { + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path, ref }); + // Directory entries (array) or non-text entries have no content. + if (!data || data.type !== 'file' || typeof data.content !== 'string') { + return null; + } + return Buffer.from(data.content, 'base64').toString('utf8'); + } catch (error) { + if (error.status === 404) return null; + throw error; + } +} + +/** + * Collect grounded repository context for AGENTS.md generation. + * + * @param {Object} params + * @param {Object} params.octokit - GitHub Octokit instance + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.branch - Branch/ref to read from + * @param {string[]} [params.contextPaths] - Limit analysis (file tree + contents) to these paths. Default: whole repo. + * @param {string[]} [params.targetPaths] - Limit where AGENTS.md may be written. Default: anywhere. (Root AGENTS.md always allowed.) + * @param {string[]} [params.excludePaths] - Globs to ignore. Merged with sensible defaults. + * @param {number} [params.maxContextChars] - Total char budget for file contents. + * @param {number} [params.maxFileChars] - Per-file content cap. + * @param {number} [params.maxFilesToFetch] - Hard cap on content fetches. + * @param {Object} [params.logger] + * @returns {Promise} Collected context object + */ +async function collectRepositoryContext(params) { + const { + octokit, + owner, + repo, + branch, + } = params; + + const logger = params.logger || console; + const contextPaths = (params.contextPaths || []).map(normalizePathPrefix).filter(Boolean); + const targetPaths = (params.targetPaths || []).map(normalizePathPrefix).filter(Boolean); + const excludePaths = [ + ...DEFAULT_EXCLUDE_PATHS, + ...(params.excludePaths || []), + ]; + const maxContextChars = params.maxContextChars || DEFAULT_MAX_CONTEXT_CHARS; + const maxFileChars = params.maxFileChars || DEFAULT_MAX_FILE_CHARS; + const maxFilesToFetch = params.maxFilesToFetch || DEFAULT_MAX_FILES_TO_FETCH; + + const excludeRegexes = excludePaths.map(globToRegExp); + + // ---- Step 1: full recursive file tree ---- + let tree = []; + let truncated = false; + try { + const { data } = await octokit.rest.git.getTree({ + owner, + repo, + tree_sha: branch, + recursive: 'true', + }); + tree = Array.isArray(data.tree) ? data.tree : []; + truncated = data.truncated === true; + } catch (error) { + logger.warn?.({ error: error.message }, 'Failed to fetch repository tree'); + // Treat as empty tree rather than crashing; existing-files detection still + // works via the targeted key-file fetches below. + tree = []; + } + + if (truncated) { + logger.warn?.('Repository tree is truncated; context may be incomplete'); + } + + // Build a flat list of repo-relative file paths (blobs only), respecting excludes. + const allFiles = tree + .filter(entry => entry.type === 'blob' && entry.path) + .map(entry => entry.path) + .filter(p => !isExcluded(p, excludeRegexes)); + + // Detect existing AGENTS.md files (auto-discovery). Root + any nested. + const existingAgentsFiles = allFiles.filter( + p => p === 'AGENTS.md' || p.endsWith('/AGENTS.md') + ); + + // ---- Step 2: select files whose CONTENT to fetch ---- + const contextScopedFiles = contextPaths.length + ? allFiles.filter(p => isUnderPrefix(p, contextPaths)) + : allFiles; + + // Priority order for content fetching: existing AGENTS.md > key files > dir hints. + const wantedSet = new Set(); + for (const p of existingAgentsFiles) wantedSet.add(p); + for (const kf of KEY_FILES) { + if (contextScopedFiles.includes(kf) || allFiles.includes(kf)) wantedSet.add(kf); + } + // Pull a few representative source files per context path so the model sees real code. + // Limit to avoid rate-limit pressure; prefer small/typical entry files. + const codeLike = contextScopedFiles.filter(p => + /\.(js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|rb|php|cs|sh)$/i.test(p) + ); + for (const p of codeLike) { + wantedSet.add(p); + if (wantedSet.size >= maxFilesToFetch) break; + } + + // Convert to ordered array, then enforce the fetch cap. + let filesToFetch = [...wantedSet]; + if (filesToFetch.length > maxFilesToFetch) { + filesToFetch = filesToFetch.slice(0, maxFilesToFetch); + } + + // ---- Step 3: fetch contents within the char budget ---- + const fileContents = {}; + let usedChars = 0; + + for (const path of filesToFetch) { + if (usedChars >= maxContextChars) { + logger.info?.({ path }, 'Context char budget reached, skipping remaining file fetches'); + break; + } + let content; + try { + content = await fetchFile(octokit, owner, repo, path, branch); + } catch (error) { + logger.warn?.({ error: error.message, path }, 'Failed to fetch file content for context'); + continue; + } + if (content === null || content === undefined) continue; + + let capped = content; + if (capped.length > maxFileChars) { + capped = capped.slice(0, maxFileChars) + '\n... [truncated]'; + } + if (usedChars + capped.length > maxContextChars) { + // Fill whatever budget remains for this file, then stop. + const remaining = maxContextChars - usedChars; + if (remaining <= 0) break; + capped = capped.slice(0, remaining) + '\n... [budget limit]'; + } + fileContents[path] = capped; + usedChars += capped.length; + } + + const collected = { + owner, + repo, + branch, + truncated, + totalFiles: allFiles.length, + tree: allFiles, // flat blob path list (post-exclude) + existingAgentsFiles, // auto-discovered + fileContents, // path -> text (budgeted) + contextPaths, // normalized + targetPaths, // normalized + excludePaths, // merged + normalized (raw globs) + contentCharCount: usedChars, + filesFetched: Object.keys(fileContents).length, + }; + + logger.info?.( + { + totalFiles: collected.totalFiles, + existingAgents: collected.existingAgentsFiles.length, + filesFetched: collected.filesFetched, + chars: usedChars, + truncated, + }, + 'Repository context collected' + ); + + return collected; +} + +/** + * Render collected context into a compact, deterministic text block for the prompt. + * @param {Object} ctx - Result of collectRepositoryContext + * @returns {string} + */ +function renderRepositoryContext(ctx) { + if (!ctx) return '(no repository context available)'; + + const lines = []; + + lines.push(`Repository: ${ctx.owner}/${ctx.repo}`); + lines.push(`Branch: ${ctx.branch}`); + if (ctx.truncated) { + lines.push('NOTE: The repository file tree was truncated by the GitHub API. The listing below may be incomplete.'); + } + + lines.push(''); + lines.push('# Existing AGENTS.md files (auto-discovered, do not omit any):'); + if (ctx.existingAgentsFiles.length) { + for (const f of ctx.existingAgentsFiles) lines.push(`- ${f}`); + } else { + lines.push('- (none found)'); + } + + lines.push(''); + lines.push('# Repository file tree (paths only):'); + const treePreview = ctx.tree && ctx.tree.length + ? ctx.tree + : []; + if (treePreview.length) { + // Cap the tree listing so it cannot blow the budget on its own. + const cap = 400; + for (const p of treePreview.slice(0, cap)) lines.push(p); + if (treePreview.length > cap) { + lines.push(`... [${treePreview.length - cap} more paths omitted]`); + } + } else { + lines.push('(file tree unavailable)'); + } + + lines.push(''); + lines.push('# Key file contents:'); + const entries = Object.entries(ctx.fileContents || {}); + if (entries.length) { + for (const [path, content] of entries) { + lines.push(''); + lines.push(`## ===== ${path} =====`); + lines.push(content); + lines.push(`## ===== end ${path} =====`); + } + } else { + lines.push('(no file contents available)'); + } + + if (ctx.targetPaths && ctx.targetPaths.length) { + lines.push(''); + lines.push('# Write targets are restricted to:'); + lines.push('- AGENTS.md (root, always allowed)'); + for (const t of ctx.targetPaths) lines.push(`- ${t}/AGENTS.md`); + } + + return lines.join('\n'); +} + +module.exports = { + collectRepositoryContext, + renderRepositoryContext, + fetchFile, + globToRegExp, + isExcluded, + isUnderPrefix, + normalizePathPrefix, + KEY_FILES, + DEFAULT_EXCLUDE_PATHS, + DEFAULT_MAX_CONTEXT_CHARS, + DEFAULT_MAX_FILE_CHARS, +}; + + /***/ }), /***/ 2613: diff --git a/docs/scheduled-tasks.md b/docs/scheduled-tasks.md new file mode 100644 index 0000000..b462682 --- /dev/null +++ b/docs/scheduled-tasks.md @@ -0,0 +1,319 @@ +# Scheduled Tasks + +Zai Code Bot can run tasks on a schedule (cron) in addition to its PR-review and +`/zai` comment-command flows. The built-in `update-agents` task periodically +regenerates your `AGENTS.md` knowledge files and opens a pull request with the +changes. + +This is how this very repository keeps its own `AGENTS.md` files fresh. + +--- + +## How it works + +1. A GitHub Actions `schedule` (cron) event triggers the workflow. +2. The bot loads `.zai-scheduled.yml` from your default branch. +3. Tasks whose schedule matches the event are selected (all enabled tasks run + when triggered manually, or when no specific schedule is matched). +4. For each task, the bot executes the configured command: + - `update-agents` fetches a command from a Gist URL, runs it against the Z.ai + model to auto-discover and regenerate `AGENTS.md` files, then opens a PR. +5. A pull request is opened **only if at least one file changed**. If everything + is already up to date, no PR is created. + +--- + +## Quickstart + +### 1. Add a Gist with the generation command + +Create a [GitHub Gist](https://gist.github.com/) containing the command the bot +should run. For `update-agents`, the Gist content is a prompt-command such as: + +```text +/init-agentsmd +This command must autonomously scan the repo and return JSON with the AGENTS.md tree. +``` + +Copy the **raw** URL of the Gist (`https://gist.githubusercontent.com///raw`). + +### 2. Add the config file + +Copy the template into your repository root: + +```bash +cp .zai-scheduled.yml.template .zai-scheduled.yml +``` + +Then edit it (see [Configuration reference](#configuration-reference)). + +### 3. Set the Gist URL + +Add the raw Gist URL as a repository variable (or secret): + +- **Settings -> Secrets and variables -> Actions -> Variables** +- Name: `ZAI_AGENTS_GIST_URL`, Value: your raw Gist URL + +You can also set it directly in the config file (see +[Priority order](#gist_url-priority-order)). + +### 4. Add a scheduled workflow + +Create `.github/workflows/zai-scheduled.yml`: + +```yaml +name: Zai Scheduled Tasks + +on: + schedule: + - cron: "0 0 * * 0" # every Sunday at 00:00 UTC + workflow_dispatch: # allow manual runs + +permissions: + contents: write # required to create branches + commits + pull-requests: write # required to open PRs + +jobs: + zai-scheduled: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run scheduled tasks + uses: AndreiDrang/zai-code-bot@main + with: + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + ZAI_MODEL: ${{ vars.ZAI_MODEL }} + ZAI_SCHEDULED_ENABLED: "true" + ZAI_AGENTS_GIST_URL: ${{ vars.ZAI_AGENTS_GIST_URL }} +``` + +> Note: GitHub Actions `schedule` events can be delayed or skipped during periods +> of high load. Use `workflow_dispatch` to trigger a run manually for testing. + +--- + +## Configuration reference + +All configuration lives in `.zai-scheduled.yml` at the root of your repository. + +### Top-level fields + +| Field | Required | Description | +|-------|----------|-------------| +| `version` | Yes | Config schema version. Must be `1`. | +| `defaults` | No | Default settings applied to every task (see below). | +| `tasks` | Yes | Array of task definitions (see below). | + +### `defaults` + +| Field | Default | Description | +|-------|---------|-------------| +| `branch` | `main` | Default target branch for PRs created by tasks. | +| `schedule` | `0 0 * * 0` (Sunday 00:00 UTC) | Default cron expression applied to tasks without one. | +| `gist_url` | _(empty)_ | Default Gist URL for commands. See [Priority order](#gist_url-priority-order). | +| `enabled` | `true` | Whether tasks are enabled by default. | + +### Task fields + +Each entry in the `tasks` array: + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `id` | Yes | - | Unique task identifier. | +| `command` | Yes | - | Command to run. Currently `update-agents`. | +| `name` | No | `id` | Human-readable task name. | +| `enabled` | No | `defaults.enabled` | Whether this task runs. | +| `schedule` | No | `defaults.schedule` | Cron expression for this task. | +| `config` | No | merged with `defaults` | Per-task config (see below). | + +### Task `config` (for `update-agents`) + +| Field | Default | Description | +|-------|---------|-------------| +| `branch` | `defaults.branch` | Target branch for the PR. | +| `gist_url` | `defaults.gist_url` | Gist URL to fetch the command from. | +| `pr_title` | _(bot default)_ | Title of the created PR. | +| `pr_body` | _(bot default)_ | Body of the created PR. | +| `commit_message` | _(bot default)_ | Commit message for the changes. | + +### Minimal example + +```yaml +version: 1 + +defaults: + branch: main + schedule: "0 0 * * 0" # weekly Sunday 00:00 UTC + +tasks: + - id: weekly-agents-update + name: "Weekly AGENTS.md Update" + enabled: true + command: update-agents + config: + gist_url: https://gist.githubusercontent.com///raw + pr_title: "chore: update AGENTS.md files" + commit_message: "docs: update AGENTS.md from scheduled task" +``` + +### Full example + +See `.zai-scheduled.yml.template` for a fully commented configuration including +a disabled secondary task and a ready-to-use workflow snippet. + +--- + +## `gist_url` priority order + +The Gist URL is resolved in this order (first non-empty wins): + +1. `task.config.gist_url` +2. `defaults.gist_url` +3. `ZAI_AGENTS_GIST_URL` action input / environment variable + +If none are set, the task fails with `Missing gist_url configuration`. + +--- + +## Cron schedule reference + +GitHub Actions uses 5-field cron in **UTC**: + +```text +┌───────────── minute (0 - 59) +│ ┌───────────── hour (0 - 23) +│ │ ┌───────────── day of month (1 - 31) +│ │ │ ┌───────────── month (1 - 12 or JAN-DEC) +│ │ │ │ ┌───────────── day of week (0 - 6 or SUN-SAT) +│ │ │ │ │ +* * * * * +``` + +| Expression | Meaning | +|------------|---------| +| `0 0 * * 0` | Every Sunday at 00:00 UTC | +| `0 0 * * 1` | Every Monday at 00:00 UTC | +| `0 0 * * *` | Every day at 00:00 UTC | +| `0 9 * * 1-5` | Weekdays at 09:00 UTC | +| `0 */6 * * *` | Every 6 hours | + +> Tip: use [crontab.guru](https://crontab.guru/) to build and verify expressions. + +### How schedule matching works + +- When a `schedule` event fires, GitHub delivers the cron expression that + triggered it as part of the event payload. +- A task runs if **either** is true: + - the task's `schedule` equals the event's cron expression, **or** + - the task's `schedule` equals `defaults.schedule`. +- In other words, tasks on the default schedule always run, and any task whose + schedule directly matches the fired cron also runs. +- Manually-triggered runs (`workflow_dispatch`) run all enabled tasks regardless + of their individual schedules. + +--- + +## Action inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `ZAI_API_KEY` | _(required)_ | Z.ai API key. | +| `ZAI_MODEL` | `glm-5.2` | Z.ai model to use. | +| `ZAI_SCHEDULED_ENABLED` | `true` | Master switch for scheduled tasks. | +| `ZAI_SCHEDULED_CONFIG_PATH` | `.zai-scheduled.yml` | Path to the config file. | +| `ZAI_AGENTS_GIST_URL` | _(empty)_ | Fallback Gist URL (lowest priority). | + +Set `ZAI_SCHEDULED_ENABLED: "false"` to disable the scheduled pipeline entirely +without removing the config file. + +--- + +## Manual run: `/zai update-agents` + +You can trigger an AGENTS.md regeneration on demand from any PR by leaving the +comment: + +```text +/zai update-agents +``` + +This runs the same `update-agents` task ad-hoc and posts the result in-thread. +It is subject to the same collaborator authorization as other `/zai` commands. + +--- + +## What `update-agents` does + +1. Resolves the Gist URL (see [Priority order](#gist_url-priority-order)). +2. Fetches the command text from the Gist. +3. Builds an auto-discovery prompt and sends it to the Z.ai model. +4. The model returns JSON describing which `AGENTS.md` files to create or update: + ```json + { + "summary": "Updated 3 AGENTS.md files", + "files": [ + {"path": "AGENTS.md", "content": "...", "action": "updated"}, + {"path": "src/lib/AGENTS.md", "content": "...", "action": "created"} + ] + } + ``` +5. Each returned file is compared against its current content on the target + branch. Only files whose content actually differs are committed. +6. If one or more files changed, a PR is opened on a branch named + `zai-scheduled/YYYY.MM.DD_HH.MM`. If nothing changed, no PR is created. + +The response parser accepts a few shapes: the `path`/`content` fields above, +plus `file` as an alias for `path`, `body` as an alias for `content`, and an +`action` of `updated`/`created` (a file with no current content is treated as +new). Non-JSON responses fall back to a single `AGENTS.md` entry containing the +raw text. + +--- + +## Troubleshooting + +**No PR is created.** +Either no files changed (everything was already up to date), or no tasks were +selected to run. Check the action logs for `skipped: no tasks` or +`prCreated: false`. + +**`Missing gist_url configuration`.** +No Gist URL was resolved. Set `ZAI_AGENTS_GIST_URL`, or `gist_url` in +`defaults` or the task's `config`. + +**`Failed to fetch from gist`.** +The Gist URL is invalid, private, or unreachable. Ensure you are using the +**raw** URL and that the Gist is public (or accessible to the workflow token). + +**`Empty response from gist URL`.** +The Gist exists but its content is blank. Add the command text to the Gist. + +**`Unsupported config version`.** +Your `.zai-scheduled.yml` has a `version` other than `1`. Regenerate it from the +latest `.zai-scheduled.yml.template`. + +**`Configuration must contain a "tasks" array`.** +The `tasks` key is missing or not an array. Add at least one task entry. + +**`Unknown scheduled command: `.** +The `command` field references a command the bot does not know. Currently only +`update-agents` is supported. + +**Scheduled runs are delayed or skipped.** +GitHub Actions does not guarantee `schedule` events run exactly on time or at +all during high load. For reliable runs, trigger manually via +`workflow_dispatch` or `/zai update-agents`. + +--- + +## Extending: custom scheduled commands + +The scheduled pipeline is extensible. Handlers are registered in a registry +(`SCHEDULED_HANDLERS` in `src/lib/handlers/scheduled.js`) and can be added +without changing the core dispatch logic. See the +[handlers guide](../src/lib/handlers/AGENTS.md) for the internal contract if you +are contributing a new task type. diff --git a/plans/SCHEDULED_TASKS_INTEGRATION_PLAN.md b/plans/SCHEDULED_TASKS_INTEGRATION_PLAN.md new file mode 100644 index 0000000..493a33f --- /dev/null +++ b/plans/SCHEDULED_TASKS_INTEGRATION_PLAN.md @@ -0,0 +1,698 @@ +# Zai Code Bot - Scheduled Tasks Integration Plan +## Детальный План Интеграции Периодического Обновления AGENTS.md + +**Версия:** 1.0 +**Дата:** 2025-01-15 +**Статус:** Ready for Implementation +**Автор:** AI Assistant (для AndreiDrang) + +--- + +## 🎯 Цели и Требования + +### Основные требования (от пользователя): + +1. ✅ **Интеграция как отдельный хендлер** - Добавить функционал в существующий бот +2. ✅ **Гибкая настройка расписания** - Через YAML файл в проекте +3. ✅ **Автоматическое выполнение** - Бот запускается по расписанию, выполняет команду без вопросов +4. ✅ **Создание PR при изменениях** - Если есть правки в файлах, делает PR в заданную ветку +5. ✅ **Гибкость для будущих команд** - Архитектура должна позволять добавлять другие виды команд + +### Технические требования: + +- **Источник команд:** Gist URL (https://gist.github.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14) +- **Частота:** Раз в неделю (конфигурируемо) +- **Целевые файлы:** AGENTS.md файлы в репозитории +- **Формат команды:** Команда из Gist должна возвращать структурированные данные (JSON) с информацией о файлах +- **Ветка PR:** Конфигурируемая через YAML + +--- + +## 📊 Текущее Состояние Репозитория + +### Уже реализовано: + +1. **Базовая архитектура scheduled tasks** (`src/lib/handlers/scheduled.js`) + - `handleScheduledEvent()` - основной обработчик + - `executeScheduledTask()` - выполнение отдельной задачи + - `handleUpdateAgentsTask()` - обработчик для обновления AGENTS.md + - Реестр обработчиков `SCHEDULED_HANDLERS` + +2. **Конфигурация** (`src/lib/config/scheduled-config.js`) + - Загрузка и валидация `.zai-scheduled.yml` + - Поддержка версионирования конфигурации + - Фильтрация задач по расписанию + +3. **Утилиты** + - `fetchFromUrl()` - загрузка содержимого по URL + - `fetchFileContent()` - получение файла из репозитория + - `createPR()` - создание Pull Request + - `updateFileInRepo()` - обновление файла + +4. **Интеграция в основной поток** (`src/index.js`) + - Маршрутизация schedule событий + - Поддержка в `action.yml` + +5. **Конфигурационные файлы** + - `.zai-scheduled.yml` - конфигурация для этого репозитория + - `.zai-scheduled.yml.template` - шаблон для пользователей + - `.github/workflows/zai-agents-update.yml` - пример workflow + +6. **Документация** + - `plans/scheduled-agents-update.md` - начальный план + +--- + +## 🏗️ Архитектура Решения + +### Общая Схема + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ GitHub Actions Event │ +│ (schedule: "0 0 * * 1") │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ zai-code-bot Action │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ src/index.js (run()) │ │ +│ │ ┌─────────────────────────────────────────────────────────┐ │ │ +│ │ │ Event Router (getEventType) │ │ │ +│ │ │ ↓ │ │ │ +│ │ │ ┌─────────────────────┐ ┌─────────────────────────────┐ │ │ │ +│ │ │ │ handlePullRequest │ │ handleScheduledEvent() │ │ │ │ +│ │ │ └─────────────────────┘ └─────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ src/lib/handlers/scheduled.js │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 1. Load Config (.zai-scheduled.yml) │ │ +│ │ 2. Filter Tasks by Schedule │ │ +│ │ 3. For each task: │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ executeScheduledTask() │ │ │ +│ │ │ ┌─────────────────────────────────────────────┐ │ │ │ +│ │ │ │ buildExecutionContext() │ │ │ │ +│ │ │ │ - octokit, apiKey, model, owner, repo │ │ │ │ +│ │ │ │ - targetBranch │ │ │ │ +│ │ │ │ - Utility functions (fetchFromUrl, etc.) │ │ │ │ +│ │ │ └─────────────────────────────────────────────┘ │ │ │ +│ │ │ ┌─────────────────────────────────────────────┐ │ │ │ +│ │ │ │ getScheduledHandler(command) │ │ │ │ +│ │ │ │ ↓ │ │ │ │ +│ │ │ │ SCHEDULED_HANDLERS[command]() │ │ │ │ +│ │ │ │ ↓ │ │ │ │ +│ │ │ │ handleUpdateAgentsTask() │ │ │ │ +│ │ │ └─────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ handleUpdateAgentsTask() │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ 1. Get Gist URL (priority: task > defaults > env var) │ │ +│ │ 2. Fetch content from Gist │ │ +│ │ 3. Execute command (via Z.ai API) │ │ +│ │ 4. Parse response for file updates │ │ +│ │ 5. Compare with existing files │ │ +│ │ 6. If changes: create PR │ │ +│ │ 7. Return result │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Ключевые Компоненты + +#### 1. Конфигурация (`.zai-scheduled.yml`) + +```yaml +version: 1 + +defaults: + branch: main + schedule: "0 0 * * 1" # Каждый понедельник в 00:00 UTC + gist_url: https://gist.githubusercontent.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14/raw + +tasks: + - id: weekly-agents-update + name: "Weekly AGENTS.md Update" + enabled: true + schedule: "0 0 * * 1" + command: update-agents + config: + branch: main + pr_title: "chore: update AGENTS.md files" + pr_body: "Automated weekly update..." + commit_message: "docs: update AGENTS.md from scheduled task" +``` + +#### 2. Workflow (`.github/workflows/zai-scheduled.yml`) + +```yaml +name: Zai Scheduled Tasks + +on: + schedule: + - cron: "0 0 * * 1" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + zai-scheduled: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: AndreiDrang/zai-code-bot@main + with: + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + ZAI_MODEL: ${{ vars.ZAI_MODEL }} + ZAI_SCHEDULED_ENABLED: "true" + ZAI_AGENTS_GIST_URL: ${{ vars.ZAI_AGENTS_GIST_URL }} +``` + +#### 3. Обработчик AGENTS.md (`handleUpdateAgentsTask`) + +**Алгоритм:** +1. Получаем Gist URL из конфигурации +2. Загружаем содержимое Gist +3. Исполняем команду через Z.ai API +4. Парсим ответ (ожидаем JSON с файлами) +5. Сравниваем с текущими файлами +6. Создаём PR если есть изменения + +--- + +## 📋 Детальный План Интеграции + +### Фаза 1: Подготовка и Анализ ✅ (УЖЕ ВЫПОЛНЕНО) + +- [x] Проанализировать текущий репозиторий +- [x] Выявить существующую функциональность +- [x] Определить пробелы и возможности для улучшения +- [x] Создать этот детальный план + +**Результат:** Этот документ + +--- + +### Фаза 2: Улучшение Существующей Реализации (Опционально) + +Хотя функционал уже работает, можно сделать его более гибким и надёжным: + +#### 2.1. Улучшение конфигурации + +**Файл:** `src/lib/config/scheduled-config.js` + +**Изменения:** +- [ ] Добавить валидацию cron-выражений +- [ ] Добавить поддержку переменных окружения в конфигурации (например, `${{ env.MY_VAR }}`) +- [ ] Улучшить ошибки валидации + +**Пример улучшенной валидации:** +```javascript +function validateCronExpression(cron) { + const cronRegex = /^(\*|[0-9]|[0-5][0-9]) (\*|[0-9]|1[0-9]|2[0-3]) (\*|[0-9]|[12][0-9]|3[01]) (\*|[0-9]|1[0-2]) (\*|[0-6])$/; + if (!cronRegex.test(cron)) { + throw new Error(`Invalid cron expression: ${cron}`); + } +} +``` + +#### 2.2. Улучшение обработчика scheduled.js + +**Файл:** `src/lib/handlers/scheduled.js` + +**Изменения:** +- [ ] Добавить более детальное логирование +- [ ] Улучшить обработку ошибок +- [ ] Добавить метрики выполнения +- [ ] Добавить поддержку кэширования (чтобы не выполнять одну и ту же задачу несколько раз) + +**Пример улучшенного логирования:** +```javascript +logger.info(`[Task ${task.id}] Starting execution`, { + taskId: task.id, + command: task.command, + timestamp: new Date().toISOString(), +}); +``` + +#### 2.3. Улучшение парсинга ответа от Z.ai + +**Файл:** `src/lib/handlers/scheduled.js` + +**Изменения:** +- [ ] Улучшить парсинг JSON из ответа Z.ai +- [ ] Добавить валидацию структуры ответа +- [ ] Добавить fallback-механизмы + +**Пример улучшенного парсинга:** +```javascript +function parseFileUpdatesFromResponse(responseContent, logger) { + // Пробуем разные форматы ответа + const formats = [ + // Формат 1: Прямой JSON + (text) => { + try { return JSON.parse(text); } catch { return null; } + }, + // Формат 2: JSON в markdown код-блоке + (text) => { + const match = text.match(/```(?:json)?\n([\s\S]*?)\n```/); + if (match) { + try { return JSON.parse(match[1]); } catch { return null; } + } + return null; + }, + // Формат 3: JSON в ответе после текста + (text) => { + const match = text.match(/\{[\s\S]*\}/); + if (match) { + try { return JSON.parse(match[0]); } catch { return null; } + } + return null; + } + ]; + + for (const parser of formats) { + const result = parser(responseContent); + if (result) { + logger.info('Successfully parsed response as JSON'); + return result; + } + } + + logger.warn('Could not parse response as JSON, trying fallback'); + // Fallback логика... +} +``` + +--- + +### Фаза 3: Документация и Примеры + +#### 3.1. Обновить README.md + +**Файл:** `README.md` + +**Добавления:** +- [ ] Раздел "Scheduled Tasks" +- [ ] Описание конфигурации +- [ ] Примеры использования +- [ ] Ссылки на документацию + +**Пример добавления:** +```markdown +## Scheduled Tasks + +Zai Code Bot supports automated tasks that run on a schedule, such as: + +- **AGENTS.md Updates**: Automatically update AGENTS.md files from a Gist URL +- **Custom Commands**: Execute any command on a schedule + +### Configuration + +Create a `.zai-scheduled.yml` file in your repository: + +```yaml +version: 1 + +defaults: + branch: main + schedule: "0 0 * * 0" + gist_url: https://gist.githubusercontent.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14/raw + +tasks: + - id: weekly-agents-update + command: update-agents + config: + pr_title: "chore: update AGENTS.md files" +``` + +See [Scheduled Tasks Documentation](docs/scheduled-tasks.md) for details. +``` + +#### 3.2. Создать документацию для пользователей + +**Новый файл:** `docs/scheduled-tasks.md` + +**Содержание:** +- Введение +- Установка и настройка +- Конфигурация задач +- Доступные команды +- Примеры +- Устранение неполадок +- Часто задаваемые вопросы + +#### 3.3. Обновить шаблон конфигурации + +**Файл:** `.zai-scheduled.yml.template` + +**Изменения:** +- [ ] Добавить больше примеров +- [ ] Улучшить комментарии +- [ ] Добавить секцию с часто используемыми cron-выражениями + +--- + +### Фаза 4: Тестирование + +#### 4.1. Unit-тесты + +**Файлы:** `tests/unit/scheduled-config.test.js`, `tests/unit/handlers/scheduled.test.js` + +**Покрытие:** +- [ ] Загрузка и валидация конфигурации +- [ ] Фильтрация задач по расписанию +- [ ] Парсинг ответа от Z.ai +- [ ] Создание PR +- [ ] Обработка ошибок + +**Пример теста:** +```javascript +const { validateAndNormalizeConfig } = require('../../src/lib/config/scheduled-config'); + +describe('Scheduled Config Validation', () => { + test('should validate basic config', () => { + const config = { + version: 1, + defaults: { branch: 'main' }, + tasks: [{ id: 'test', command: 'update-agents' }] + }; + + const result = validateAndNormalizeConfig(config); + expect(result).toBeDefined(); + expect(result.tasks[0].enabled).toBe(true); + }); + + test('should throw on invalid version', () => { + const config = { version: 2, tasks: [] }; + expect(() => validateAndNormalizeConfig(config)).toThrow(); + }); +}); +``` + +#### 4.2. Интеграционные тесты + +**Файл:** `tests/integration/scheduled.test.js` + +**Покрытие:** +- [ ] Полный цикл выполнения задачи +- [ ] Взаимодействие с GitHub API +- [ ] Взаимодействие с Z.ai API + +#### 4.3. Manual Testing + +**Сценарии:** +- [ ] Запуск задачи по расписанию +- [ ] Запуск задачи вручную (workflow_dispatch) +- [ ] Задача без изменений (не создаёт PR) +- [ ] Задача с изменениями (создаёт PR) +- [ ] Ошибка в конфигурации +- [ ] Ошибка в выполнении команды + +--- + +### Фаза 5: Доработка и Оптимизация + +#### 5.1. Оптимизация производительности + +- [ ] Кэширование конфигурации +- [ ] Параллельное выполнение задач (с ограничением) +- [ ] Оптимизация запросов к GitHub API + +#### 5.2. Улучшение UX + +- [ ] Логи в GitHub Actions с эмодзи и цветами +- [ ] Прогресс-бары для долгих операций +- [ ] Сводка выполнения в конце + +#### 5.3. Расширяемость + +- [ ] Документация по добавлению новых команд +- [ ] Примеры кастомных обработчиков +- [ ] Шаблоны для новых типов задач + +--- + +## 🔧 Технические Детали Реализации + +### Алгоритм handleUpdateAgentsTask + +``` +1. ПОЛУЧЕНИЕ KONФИГУРАЦИИ + ├─ Gist URL из: task.config.gist_url → config.defaults.gist_url → env.ZAI_AGENTS_GIST_URL + ├─ Target branch из: task.config.branch → config.defaults.branch → 'main' + └─ PR параметры из: task.config (pr_title, pr_body, commit_message) + +2. ЗАГРУЗКА КОМАНДЫ ИЗ GIST + ├─ Проверка URL + ├─ HTTP GET запрос + └─ Обработка ошибок (timeout, 404, invalid content) + +3. ИСПОЛНЕНИЕ КОМАНДЫ + ├─ Проверка, что команда не пустая + ├─ Формирование prompt для Z.ai API + │ └─ Включает: repository info, branch, command text + ├─ Вызов Z.ai API (с retry логикой) + └─ Парсинг ответа + +4. ПАРСИНГ ОТВЕТА + ├─ Попытка 1: Прямой JSON + ├─ Попытка 2: JSON в markdown код-блоке + ├─ Попытка 3: JSON в тексте ответа + └─ Fallback: Использовать весь ответ как содержимое AGENTS.md + +5. ОПРЕДЕЛЕНИЕ ИЗМЕНЕНИЙ + ├─ Для каждого файла из ответа: + │ ├─ Получение текущего содержимого из репозитория + │ ├─ Сравнение с новым содержимым + │ └─ Маркировка как changed/unchanged + └─ Фильтрация только изменённых файлов + +6. СОЗДАНИЕ PR (если есть изменения) + ├─ Создание ветки (zai-scheduled/yyyy.mm.dd_hh.mm) + ├─ Применение всех изменений файлов + ├─ Создание коммита + └─ Создание Pull Request + +7. ВОЗВРАТ РЕЗУЛЬТАТА + └─ { success, changes, prCreated, prNumber, prUrl, message } +``` + +### Формат ответа от Z.ai + +Ожидаемый формат (JSON): + +```json +{ + "summary": "Brief description of changes", + "files": [ + { + "path": "AGENTS.md", + "content": "... full file content ...", + "action": "created|updated|unchanged" + }, + { + "path": "src/lib/AGENTS.md", + "content": "... full file content ...", + "action": "updated" + } + ] +} +``` + +### Пример команды в Gist + +Команда в Gist должна быть в формате, который Z.ai поймёт. Например: + +``` +/init-agentsmd + +You are an AI assistant. Scan this repository and generate comprehensive AGENTS.md files. + +Requirements: +1. Find all AGENTS.md files in the repository +2. For each file, analyze the directory context +3. Generate appropriate AGENTS.md content based on the code in that directory +4. Return JSON with all files that need to be created or updated + +Return ONLY valid JSON, no other text. +``` + +--- + +## 📁 Структура Файлов Проекта + +``` +zai-code-bot/ +├── src/ +│ ├── index.js # Главный файл, маршрутизация событий +│ ├── lib/ +│ │ ├── events.js # Определение типов событий +│ │ ├── commands.js # Парсинг команд +│ │ ├── config/ +│ │ │ └── scheduled-config.js # Загрузка и валидация конфигурации +│ │ └── handlers/ +│ │ ├── index.js # Экспорт всех обработчиков +│ │ ├── scheduled.js # Обработчик scheduled событий +│ │ ├── ask.js # Обработчик /zai ask +│ │ ├── review.js # Обработчик /zai review +│ │ ├── explain.js # Обработчик /zai explain +│ │ ├── describe.js # Обработчик /zai describe +│ │ ├── impact.js # Обработчик /zai impact +│ │ └── help.js # Обработчик /zai help +│ └── ... +├── action.yml # Конфигурация GitHub Action +├── .zai-scheduled.yml # Конфигурация scheduled tasks (для этого репо) +├── .zai-scheduled.yml.template # Шаблон конфигурации +├── .github/ +│ └── workflows/ +│ ├── zai-code-bot.yml # Основной workflow +│ └── zai-agents-update.yml # Workflow для обновления AGENTS.md +├── plans/ +│ ├── scheduled-agents-update.md # Начальный план +│ └── SCHEDULED_TASKS_INTEGRATION_PLAN.md # Этот документ +├── docs/ +│ └── scheduled-tasks.md # Документация (планируется) +└── tests/ + ├── unit/ + │ └── scheduled.test.js # Unit-тесты + └── integration/ + └── scheduled.test.js # Интеграционные тесты +``` + +--- + +## 🎯 Критерии Успеха + +### MVP (Minimum Viable Product) + +- [x] Обнаружение и маршрутизация schedule событий +- [x] Загрузка и валидация конфигурации +- [x] Исполнение задачи update-agents +- [x] Создание PR при изменениях +- [x] Нет PR при отсутствии изменений +- [x] Обработка ошибок +- [ ] Все существующие тесты проходят +- [ ] Новые тесты покрывают 80%+ нового кода + +### Quality Gates + +- [ ] Нет breaking changes для существующего функционала +- [ ] Проверка безопасности +- [ ] Приемлемая производительность +- [ ] Полная документация + +--- + +## 🚀 Следующие Шаги + +### Непосредственные действия: + +1. **Обсудить этот план** с командой +2. **Принять решение** по приоритетам (что реализовывать в первую очередь) +3. **Распределить задачи** между участниками +4. **Начать реализацию** с Фазы 2 (улучшение существующего) + +### Долгосрочные планы: + +1. Добавить поддержку других типов задач (например, sync-docs, cleanup, etc.) +2. Реализовать UI для управления задачами через GitHub Issues +3. Добавить мониторинг и алерты для scheduled tasks +4. Реализовать dashboard для просмотра статуса задач + +--- + +## 📚 Полезные Ресурсы + +### GitHub Actions +- [Schedule Events](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) +- [Workflow Syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions) +- [Cron Syntax](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) + +### GitHub API +- [Octokit Documentation](https://github.com/octokit/octokit.js) +- [Repos API](https://docs.github.com/en/rest/repos/repos) +- [Pull Requests API](https://docs.github.com/en/rest/pulls/pulls) + +### YAML +- [YAML Specification](https://yaml.org/spec/1.2/spec.html) +- [js-yaml Documentation](https://github.com/nodeca/js-yaml) + +### Z.ai API +- [Z.ai API Documentation](https://api.z.ai/) + +--- + +## 🔍 Вопросы и Ответы + +### Q: Можно ли запускать задачи вручную? +**A:** Да, используйте `workflow_dispatch` в workflow файле: +```yaml +on: + schedule: + - cron: "0 0 * * 1" + workflow_dispatch: +``` + +### Q: Как отладить задачу? +**A:** +1. Запустите workflow вручную через GitHub UI +2. Посмотрите логи в Actions +3. Используйте `ACT=debug` для детального логирования + +### Q: Что делать, если команда в Gist возвращает невалидный JSON? +**A:** Бот попробует несколько форматов парсинга. Если всё равно не получается, он использует весь ответ как содержимое AGENTS.md файла. + +### Q: Можно ли использовать разные Gist URL для разных задач? +**A:** Да, вы можете указать `gist_url` на уровне задачи: +```yaml +tasks: + - id: task1 + command: update-agents + config: + gist_url: https://gist.github.com/user1/gist1 + - id: task2 + command: update-agents + config: + gist_url: https://gist.github.com/user2/gist2 +``` + +### Q: Как добавить свою кастомную команду? +**A:** +1. Создайте обработчик в `src/lib/handlers/scheduled.js` +2. Зарегистрируйте его в `SCHEDULED_HANDLERS` +3. Используйте её в конфигурации: +```yaml +tasks: + - id: my-task + command: my-custom-command +``` + +--- + +## 📝 История Изменений + +| Дата | Версия | Описание | Автор | +|------|--------|----------|-------| +| 2025-01-15 | 1.0 | Initial integration plan | AI Assistant | + +--- + +**Статус:** ✅ Ready for Review +**Следующий шаг:** Создать PR с этим планом в main ветку diff --git a/plans/SCHEDULED_TASKS_QUICK_START.md b/plans/SCHEDULED_TASKS_QUICK_START.md new file mode 100644 index 0000000..344969b --- /dev/null +++ b/plans/SCHEDULED_TASKS_QUICK_START.md @@ -0,0 +1,388 @@ +# Scheduled Tasks - Quick Start Guide + +## 🚀 Быстрый старт + +Этот гайд поможет быстро настроить периодическое обновление AGENTS.md файлов в вашем репозитории. + +--- + +## ⚡ Быстрая настройка (3 шага) + +### Шаг 1: Создайте конфигурационный файл + +Создайте файл `.zai-scheduled.yml` в корне вашего репозитория: + +```yaml +version: 1 + +defaults: + branch: main + schedule: "0 0 * * 1" # Каждый понедельник в 00:00 UTC + gist_url: https://gist.githubusercontent.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14/raw + +tasks: + - id: weekly-agents-update + name: "Weekly AGENTS.md Update" + enabled: true + schedule: "0 0 * * 1" + command: update-agents + config: + branch: main + pr_title: "chore: update AGENTS.md files" + pr_body: "Automated weekly update of AGENTS.md files" + commit_message: "docs: update AGENTS.md from scheduled task" +``` + +### Шаг 2: Создайте workflow файл + +Создайте файл `.github/workflows/zai-scheduled.yml`: + +```yaml +name: Zai Scheduled Tasks + +on: + schedule: + - cron: "0 0 * * 1" + workflow_dispatch: # Для ручного запуска + +permissions: + contents: write + pull-requests: write + +jobs: + zai-scheduled: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: AndreiDrang/zai-code-bot@main + with: + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + ZAI_MODEL: ${{ vars.ZAI_MODEL || 'glm-5.2' }} + ZAI_SCHEDULED_ENABLED: "true" + ZAI_AGENTS_GIST_URL: ${{ vars.ZAI_AGENTS_GIST_URL }} + env: + GITHUB_TOKEN: ${{ github.token }} +``` + +### Шаг 3: Настройте secrets и variables + +В настройках вашего репозитория (Settings > Secrets and variables > Actions): + +1. **Secrets:** + - `ZAI_API_KEY` - ваш API ключ от Z.ai + +2. **Variables (опционально):** + - `ZAI_MODEL` - модель Z.ai (по умолчанию: glm-5.2) + - `ZAI_AGENTS_GIST_URL` - URL вашего Gist файла (если не указан в .zai-scheduled.yml) + +--- + +## 📋 Конфигурация + +### Структура .zai-scheduled.yml + +```yaml +version: 1 # Обязательно + +defaults: # Значения по умолчанию для всех задач + branch: main # Целевая ветка для PR + schedule: "0 0 * * 0" # Расписание по умолчанию + gist_url: URL # URL Gist файла по умолчанию + +tasks: # Список задач + - id: unique-id # Уникальный идентификатор + name: "Task Name" # Имя задачи (для логов) + enabled: true # Включена ли задача + schedule: "0 0 * * 1" # Расписание (переопределяет defaults) + command: update-agents # Команда для выполнения + config: # Конфигурация задачи + branch: main # Целевая ветка (переопределяет defaults) + gist_url: URL # URL Gist файла (переопределяет defaults) + pr_title: "..." # Заголовок PR + pr_body: "..." # Описание PR + commit_message: "..." # Сообщение коммита +``` + +### Приоритет конфигурации + +1. **task.config.*** (на уровне задачи) +2. **defaults.*** (на уровне дефолтов) +3. **Переменные окружения** (ZAI_AGENTS_GIST_URL, etc.) + +--- + +## 🎯 Доступные команды + +### `update-agents` + +**Описание:** Обновляет AGENTS.md файлы из Gist URL + +**Конфигурация:** +```yaml +config: + branch: main # Целевая ветка + gist_url: URL # URL Gist файла с командой + pr_title: "..." # Заголовок PR + pr_body: "..." # Описание PR + commit_message: "..." # Сообщение коммита +``` + +**Как работает:** +1. Загружает команду из Gist URL +2. Исполняет команду через Z.ai API +3. Парсит ответ (ожидает JSON с файлами) +4. Сравнивает с текущими файлами +5. Создаёт PR если есть изменения + +**Формат Gist:** +Команда в Gist должна возвращать JSON в формате: + +```json +{ + "summary": "Описание изменений", + "files": [ + { + "path": "AGENTS.md", + "content": "... содержимое файла ...", + "action": "created|updated|unchanged" + } + ] +} +``` + +--- + +## 📅 Cron Syntax + +Формат: `minute hour day-of-month month day-of-week` + +| Поле | Значения | Пример | +|------|----------|--------| +| Minute | 0-59 | `0` | +| Hour | 0-23 | `12` | +| Day of month | 1-31 | `15` | +| Month | 1-12 | `6` | +| Day of week | 0-6 (0=Sun) | `1` (Понедельник) | + +### Примеры: + +```yaml +# Каждый день в полночь +schedule: "0 0 * * *" + +# Каждый понедельник в полночь +schedule: "0 0 * * 1" + +# Каждый день в 9:00 +schedule: "0 9 * * *" + +# Каждый час +schedule: "0 * * * *" + +# Каждые 6 часов +schedule: "0 */6 * * *" + +# Каждый будний день в 9:00 +schedule: "0 9 * * 1-5" +``` + +**⚠️ Важно:** GitHub Actions использует **UTC**! + +--- + +## 🔍 Устранение неполадок + +### Проблема: Задача не запускается + +**Проверьте:** +- [ ] Workflow файл в `.github/workflows/` +- [ ] Event `schedule` или `workflow_dispatch` в `on:` +- [ ] Permission `contents: write` и `pull-requests: write` +- [ ] Secrets `ZAI_API_KEY` настроен + +### Проблема: Нет PR при изменениях + +**Проверьте:** +- [ ] Gist URL доступен и возвращает валидный контент +- [ ] Команда в Gist возвращает валидный JSON +- [ ] Файлы действительно изменились +- [ ] Ветка для PR существует + +### Проблема: Ошибка парсинга JSON + +**Решения:** +1. Проверьте формат ответа от Z.ai +2. Убедитесь, что ответ содержит валидный JSON +3. Используйте [JSON Validator](https://jsonlint.com/) для проверки + +--- + +## 📊 Логирование и мониторинг + +### Просмотр логов + +1. Перейдите в **Actions** таб вашего репозитория +2. Выберите workflow **Zai Scheduled Tasks** +3. Посмотрите логи последнего запуска + +### Уровни логирования + +- `INFO` - Основные события (по умолчанию) +- `DEBUG` - Детальная информация (установите `ACT=debug`) +- `WARN` - Предупреждения +- `ERROR` - Ошибки + +--- + +## 🔧 Расширенная настройка + +### Несколько задач + +```yaml +tasks: + - id: weekly-agents-update + command: update-agents + schedule: "0 0 * * 1" + config: + gist_url: https://gist.github.com/user/gist1 + pr_title: "Update main AGENTS.md" + + - id: daily-docs-sync + command: update-agents + schedule: "0 12 * * *" + config: + gist_url: https://gist.github.com/user/gist2 + pr_title: "Daily docs sync" +``` + +### Кастомные обработчики + +Чтобы добавить свою команду: + +1. Добавьте обработчик в `src/lib/handlers/scheduled.js`: + +```javascript +async function handleMyCustomTask(context) { + const { octokit, owner, repo, logger } = context; + + // Ваша логика здесь + logger.info('Executing custom task'); + + return { + success: true, + changes: [], + message: 'Custom task completed' + }; +} +``` + +2. Зарегистрируйте обработчик: + +```javascript +const SCHEDULED_HANDLERS = { + 'update-agents': handleUpdateAgentsTask, + 'my-custom': handleMyCustomTask, // Новый обработчик +}; +``` + +3. Используйте в конфигурации: + +```yaml +tasks: + - id: my-task + command: my-custom + config: + # Ваши параметры +``` + +--- + +## 🎓 Примеры + +### Пример 1: Еженедельное обновление AGENTS.md + +**.zai-scheduled.yml:** +```yaml +version: 1 + +defaults: + branch: main + gist_url: https://gist.githubusercontent.com/AndreiDrang/1580ae796fe56074b600cee6352a5f14/raw + +tasks: + - id: weekly-update + command: update-agents + schedule: "0 0 * * 1" + config: + pr_title: "chore: weekly AGENTS.md update" +``` + +### Пример 2: Ежедневная синхронизация в 9:00 + +**.zai-scheduled.yml:** +```yaml +version: 1 + +defaults: + branch: develop + schedule: "0 9 * * *" + +tasks: + - id: daily-sync + command: update-agents + config: + gist_url: https://gist.github.com/myuser/my-gist/raw + pr_title: "docs: daily sync" + commit_message: "docs: sync from gist" +``` + +### Пример 3: Несколько задач с разными расписаниями + +**.zai-scheduled.yml:** +```yaml +version: 1 + +defaults: + branch: main + +tasks: + - id: weekly-agents + command: update-agents + schedule: "0 0 * * 1" + config: + gist_url: https://gist.github.com/user/gist1 + pr_title: "Weekly AGENTS.md update" + + - id: monthly-cleanup + command: cleanup + schedule: "0 0 1 * *" + config: + pr_title: "Monthly cleanup" +``` + +--- + +## 📚 Полезные ссылки + +- [GitHub Actions Docs](https://docs.github.com/en/actions) +- [Cron Syntax Checker](https://crontab.guru/) +- [JSON Validator](https://jsonlint.com/) +- [Z.ai Documentation](https://api.z.ai/) +- [Octokit Documentation](https://github.com/octokit/octokit.js) + +--- + +## 💡 Советы + +1. **Тестируйте вручную:** Используйте `workflow_dispatch` для тестирования перед настройкой расписания +2. **Начинайте с консервативного расписания:** Например, раз в неделю, затем увеличивайте частоту +3. **Мониторьте первые запуски:** Проверяйте логи и PR после первых запусков +4. **Используйте детальные сообщения коммитов:** Это поможет понять, что изменилось +5. **Документируйте свои задачи:** Добавьте комментарии в конфигурационный файл + +--- + +**Готово!** 🎉 Ваша задача по обновлению AGENTS.md файлов настроена и готова к работе. diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md index 0c3b608..a65b6da 100644 --- a/src/lib/AGENTS.md +++ b/src/lib/AGENTS.md @@ -25,7 +25,8 @@ src/lib/ ## WHERE TO LOOK | Task | File | Notes | |------|------|-------| -| Add or change command grammar | `src/lib/commands.js` | Keep command allowlist strict | +| Add or change command grammar | `src/lib/commands.js` | Keep command allowlist strict; `update-agents` is allowed | +| Event-type detection / routing | `src/lib/events.js` | `getEventType` + `shouldProcessEvent`; includes `schedule` (cron) always-process gate | | Adjust collaborator/fork policy | `src/lib/auth.js` | Respect silent fork-block behavior | | Tune context budget/range rules | `src/lib/context.js` | Keep truncation deterministic | | Change comment lifecycle | `src/lib/comments.js` | Preserve marker idempotency | diff --git a/src/lib/agents-validation.js b/src/lib/agents-validation.js new file mode 100644 index 0000000..4a4e93d --- /dev/null +++ b/src/lib/agents-validation.js @@ -0,0 +1,187 @@ +/** + * AGENTS.md Output Validation + * + * Validates the model-generated AGENTS.md file updates against the collected + * repository context BEFORE any PR is created. This is the guardrail that + * prevents hallucinated output (e.g. a fictional Python project for a JS repo) + * from being committed — the exact failure seen in PR #15. + * + * Validation is conservative-by-default and never throws; it returns a + * structured result separating accepted files from rejected ones, with reasons. + */ + +// Regex for path-like tokens. We scan raw content (backticks act as natural +// delimiters since they are outside the character class), so this captures both +// `backtick/path.js` tokens and bare tree-listing entries like '├── main.py'. +const TOKEN_RE = /[A-Za-z0-9._/@-]{1,200}/g; + +// Suffix test: looks like a real file extension (letters/digits, length 1-5). +const EXT_RE = /\.[a-z0-9]{1,5}$/i; + +// Files referenced in content that we never treat as hallucination evidence: +// very common generic terms that look like paths but usually are not real files. +const GENERIC_TERMS = new Set([ + 'AGENTS.md', 'README.md', 'LICENSE', 'CONTRIBUTING.md', 'CHANGELOG.md', + '.env', '.gitignore', +]); + +/** + * Is this a valid AGENTS.md path? Must be exactly "AGENTS.md" or end with "/AGENTS.md". + * @param {string} path + * @returns {boolean} + */ +function isAgentsPath(path) { + if (typeof path !== 'string') return false; + return path === 'AGENTS.md' || path.endsWith('/AGENTS.md'); +} + +/** + * Extract backtick-quoted path-like tokens from markdown content. + * @param {string} content + * @returns {string[]} unique tokens + */ +function extractReferencedPaths(content) { + if (!content || typeof content !== 'string') return []; + const found = new Set(); + let match; + TOKEN_RE.lastIndex = 0; + while ((match = TOKEN_RE.exec(content)) !== null) { + const token = match[0]; + if (GENERIC_TERMS.has(token)) continue; + // Looks path-ish: contains a slash OR has a file-like extension. + if (token.includes('/') || EXT_RE.test(token)) { + found.add(token); + } + } + return [...found]; +} + +/** + * Check whether a referenced token corresponds to (or is a prefix of) a real path + * in the collected tree. Tolerates leading "./" and trailing slashes. + * @param {string} token + * @param {string[]} tree - repo-relative file paths + * @returns {boolean} + */ +function referenceExistsInTree(token, tree) { + if (!tree || tree.length === 0) return true; // cannot disprove -> don't flag + const norm = token.replace(/^\.\//, '').replace(/\/+$/, ''); + if (!norm) return true; + // Exact file match, or the token is a directory prefix of a real path. + if (tree.includes(norm)) return true; + return tree.some(p => p.startsWith(norm + '/') || p === norm); +} + +/** + * Validate a single generated file entry against context and policy. + * @param {Object} fileUpdate - { file, newContent, isNew } + * @param {Object} ctx - collected repository context + * @param {Object} policy - { targetPaths, allowCreateNew, updateExistingOnly } + * @returns {{ valid: boolean, reasons: string[] }} + */ +function validateFileEntry(fileUpdate, ctx, policy) { + const reasons = []; + const path = fileUpdate?.file; + + // Rule 1: must be an AGENTS.md path. + if (!isAgentsPath(path)) { + reasons.push(`path "${path}" is not an AGENTS.md file`); + return { valid: false, reasons }; + } + + // Rule 2: target-path scoping. Root AGENTS.md is always allowed. + const isRoot = path === 'AGENTS.md'; + if (!isRoot && policy.targetPaths && policy.targetPaths.length) { + const allowed = policy.targetPaths.some(prefix => { + if (!prefix) return true; + return path.startsWith(prefix + '/'); + }); + if (!allowed) { + reasons.push(`path "${path}" is outside configured target_paths (${policy.targetPaths.join(', ')})`); + } + } + + // Rule 3: update-existing-only mode rejects brand-new child files. + const existedBefore = ctx?.existingAgentsFiles?.includes(path) ?? false; + if (policy.updateExistingOnly === true && !isRoot && !existedBefore) { + reasons.push(`new file "${path}" rejected because update_existing_only is true`); + } + + // Rule 4: allow_create_new gate. + if (policy.allowCreateNew === false && !existedBefore && !isRoot) { + reasons.push(`new file "${path}" rejected because allow_create_new is false`); + } + + // Rule 5: hallucination check. If content references many file paths that do + // not exist in the tree, that is strong evidence of fabrication. + const referenced = extractReferencedPaths(fileUpdate?.newContent || ''); + if (referenced.length) { + const unknown = referenced.filter(t => !referenceExistsInTree(t, ctx?.tree)); + // Flag when MORE THAN HALF of referenced concrete paths are unknown, OR when + // several distinct unknown files appear (catches wholesale fabricated trees). + const threshold = Math.max(3, Math.ceil(referenced.length / 2)); + if (unknown.length >= threshold) { + reasons.push( + `content references ${unknown.length} file path(s) not present in the repository ` + + `(e.g. ${unknown.slice(0, 5).map(t => `\`${t}\``).join(', ')}); likely hallucination` + ); + } + } + + return { valid: reasons.length === 0, reasons }; +} + +/** + * Validate a full batch of generated file updates. + * + * @param {Object} params + * @param {Array} params.fileUpdates - [{ file, newContent, changed, isNew }] + * @param {Object} params.repositoryContext - collected context (see repository-context.js) + * @param {string[]} [params.targetPaths] - write scope restriction + * @param {boolean} [params.allowCreateNew=true] - may new child AGENTS.md be proposed + * @param {boolean} [params.updateExistingOnly=false] - reject new files entirely + * @param {Object} [params.logger] + * @returns {{ accepted: Array, rejected: Array<{file, reasons: string[]}>, allRejected: boolean }} + */ +function validateGeneratedAgentFiles(params) { + const fileUpdates = params.fileUpdates || []; + const repositoryContext = params.repositoryContext || {}; + const logger = params.logger || console; + const policy = { + targetPaths: params.targetPaths || repositoryContext.targetPaths || [], + allowCreateNew: params.allowCreateNew !== false, + updateExistingOnly: params.updateExistingOnly === true, + }; + + const accepted = []; + const rejected = []; + + for (const entry of fileUpdates) { + // Only validate entries that would actually change something. + if (entry && entry.changed === false) { + accepted.push(entry); + continue; + } + const { valid, reasons } = validateFileEntry(entry, repositoryContext, policy); + if (valid) { + accepted.push(entry); + } else { + rejected.push({ file: entry?.file, reasons }); + logger.warn?.({ file: entry?.file, reasons }, 'Rejected generated AGENTS.md file'); + } + } + + return { + accepted, + rejected, + allRejected: fileUpdates.length > 0 && accepted.length === 0, + }; +} + +module.exports = { + validateGeneratedAgentFiles, + validateFileEntry, + isAgentsPath, + extractReferencedPaths, + referenceExistsInTree, +}; diff --git a/src/lib/config/scheduled-config.js b/src/lib/config/scheduled-config.js index 76ae708..5fc9865 100644 --- a/src/lib/config/scheduled-config.js +++ b/src/lib/config/scheduled-config.js @@ -174,9 +174,53 @@ function validateAndNormalizeTask(task, index, defaults) { throw new Error(`Task ${task.id} has invalid files value (must be array)`); } + // Validate AGENTS.md upgrade scoping fields (all optional). + validateAgentsConfig(task.id, normalized.config); + return normalized; } +/** + * Validate optional AGENTS.md upgrade scoping/budget fields on a task config. + * Unknown-but-typed-wrong values throw; missing values are left unset so + * callers apply their own defaults at runtime. + * @param {string} taskId + * @param {Object} cfg - task config (mutated to normalize array fields) + * @throws {Error} on type mismatches + */ +function validateAgentsConfig(taskId, cfg) { + const label = `Task ${taskId}`; + + const pathArrays = ['context_paths', 'target_paths', 'exclude_paths']; + for (const key of pathArrays) { + if (cfg[key] !== undefined && !Array.isArray(cfg[key])) { + throw new Error(`${label} has invalid ${key} value (must be array of path/glob strings)`); + } + if (Array.isArray(cfg[key])) { + const bad = cfg[key].find(v => typeof v !== 'string'); + if (bad !== undefined) { + throw new Error(`${label} has non-string entry in ${key}`); + } + } + } + + const positiveInts = ['max_context_chars', 'max_file_chars', 'max_files_to_fetch']; + for (const key of positiveInts) { + if (cfg[key] !== undefined) { + if (typeof cfg[key] !== 'number' || !Number.isFinite(cfg[key]) || cfg[key] <= 0) { + throw new Error(`${label} has invalid ${key} value (must be a positive number)`); + } + } + } + + const booleans = ['allow_create_new', 'update_existing_only']; + for (const key of booleans) { + if (cfg[key] !== undefined && typeof cfg[key] !== 'boolean') { + throw new Error(`${label} has invalid ${key} value (must be boolean)`); + } + } +} + /** * Get tasks that should run for this schedule event * @param {Object} config - Validated configuration @@ -237,4 +281,5 @@ module.exports = { // Export for testing validateDefaults, validateAndNormalizeTask, + validateAgentsConfig, }; diff --git a/src/lib/handlers/AGENTS.md b/src/lib/handlers/AGENTS.md index 89e5411..1b0464a 100644 --- a/src/lib/handlers/AGENTS.md +++ b/src/lib/handlers/AGENTS.md @@ -1,7 +1,7 @@ # HANDLER MODULE GUIDE ## OVERVIEW -Command handlers implement `/zai` behavior only after parsing + authorization; each module owns prompt construction, API call wiring, and response formatting. The `scheduled` handler is distinct: it executes scheduled tasks defined in `.zai-scheduled.yml` rather than responding to an inline `/zai` command. +Command handlers implement `/zai` behavior only after parsing + authorization; each module owns prompt construction, API call wiring, and response formatting. The `scheduled` handler is distinct: it executes scheduled tasks defined in `.zai-scheduled.yml` (and the manual `/zai update-agents` command) rather than responding to a standard review command. ## WHERE TO LOOK | Command | File | Lines | Notes | @@ -12,9 +12,18 @@ Command handlers implement `/zai` behavior only after parsing + authorization; e | `/zai describe` | `src/lib/handlers/describe.js` | 129 | File/directory description | | `/zai impact` | `src/lib/handlers/impact.js` | 336 | Change impact analysis | | `/zai help` | `src/lib/handlers/help.js` | 95 | Static help output with auth gate | -| scheduled tasks | `src/lib/handlers/scheduled.js` | 1075 | Largest module; runs `.zai-scheduled.yml` tasks, not a `/zai` command | +| `/zai update-agents` | `src/index.js` (`dispatchCommand`) | — | Manual AGENTS.md regen; reuses `handleUpdateAgentsTask` | +| scheduled tasks | `src/lib/handlers/scheduled.js` | ~1180 | Largest module; cron-driven `.zai-scheduled.yml` tasks; grounded + validated AGENTS.md upgrades | | Handler registry | `src/lib/handlers/index.js` | 42 | Dispatcher map consumed by runtime (note: `scheduled` is exported but not in the `/zai` HANDLERS map) | +## SCHEDULED MODULE (`scheduled.js`) KEY SYMBOLS +- `handleScheduledEvent` (entry) → `executeScheduledTask` (per-task) → `buildExecutionContext` → `getScheduledHandler` (registry lookup). +- `handleUpdateAgentsTask` (grounded flow): gist command → `collectRepositoryContext` (`../repository-context.js`: real tree + existing AGENTS.md discovery + key files) → `buildAgentsUpgradePrompt` (embeds context, tells model it has NO live repo access) → `callZaiApiWithRetry` → `parseFileUpdatesFromResponse` (multi-format JSON) → `validateGeneratedAgentFiles` (`../agents-validation.js`: rejects non-AGENTS paths, out-of-scope writes, hallucinated content referencing non-existent files) → diff vs repo files → `createPR`. +- Registry: `SCHEDULED_HANDLERS` (const) + `registerScheduledHandler`/`getAllScheduledHandlers` for extension. +- GitHub helpers: `fetchFileContent`, `getFileSha`, `updateFileInRepo`; HTTP: `fetchFromUrl` (gist, 30s timeout). +- Config consumed from `src/lib/config/scheduled-config.js` (`loadScheduledConfig`, `getTasksToRun`, `getGistUrl`, `validateAgentsConfig`). +- Scoping config (all optional, per-task in `.zai-scheduled.yml`): `context_paths`, `target_paths`, `exclude_paths`, `max_context_chars`, `max_file_chars`, `max_files_to_fetch`, `allow_create_new`, `update_existing_only`. + ## CONVENTIONS - Keep command argument parsing explicit and reject invalid formats early. - Always use threaded replies (`replyToId`) for command results. @@ -23,7 +32,8 @@ Command handlers implement `/zai` behavior only after parsing + authorization; e - Return user-safe failures; log internal details through shared logging helpers. ## TESTING -- Local handler unit coverage exists in this folder (`review.test.js`, `explain.test.js`). +- Local handler unit coverage exists in this folder (`review.test.js`, `explain.test.js`, `scheduled.test.js`). +- Scheduled pipeline coverage: `tests/handlers/scheduled.test.js` (registry, PR creation, parse, grounded `handleUpdateAgentsTask` flow incl. hallucination rejection), `tests/scheduled-config.test.js` (config + `validateAgentsConfig` scoping fields), `tests/repository-context.test.js` (tree/AGENTS.md discovery/budgets/globs), `tests/agents-validation.test.js` (path/hallucination/target-path guards incl. PR #15 regression). - End-to-end command pipeline behavior is validated in `tests/integration/command-pipeline.test.js`. - When changing parsing or output contracts, update both unit and integration assertions. diff --git a/src/lib/handlers/scheduled.js b/src/lib/handlers/scheduled.js index 7df3c9b..0c262af 100644 --- a/src/lib/handlers/scheduled.js +++ b/src/lib/handlers/scheduled.js @@ -9,6 +9,11 @@ const https = require('node:https'); const { parseCommand, isValid } = require('../commands'); const { loadScheduledConfig, getTasksToRun, getGistUrl } = require('../config/scheduled-config'); const { createLogger, generateCorrelationId } = require('../logging'); +const { + collectRepositoryContext, + renderRepositoryContext, +} = require('../repository-context'); +const { validateGeneratedAgentFiles } = require('../agents-validation'); const core = require('@actions/core'); // Module-level fallback logger so module-scoped helpers (e.g. fetchFromUrl) @@ -325,9 +330,36 @@ async function handleUpdateAgentsTask(context) { logger.info(`Fetched ${gistContent.length} characters from gist`); - // Step 2: Execute the command from Gist - // The command MUST return structured JSON with file paths and contents - logger.info('Executing command in auto-discovery mode - expecting structured file map'); + // Step 2: Collect REAL repository context (file tree, existing AGENTS.md + // files, key file contents). Without this the model has nothing to ground + // its output on and hallucinates a project from the repo name (PR #15 bug). + logger.info('Collecting repository context for grounded generation'); + const repositoryContext = await collectRepositoryContext({ + octokit, + owner, + repo, + branch: targetBranch, + contextPaths: task.config?.context_paths, + targetPaths: task.config?.target_paths, + excludePaths: task.config?.exclude_paths, + maxContextChars: task.config?.max_context_chars, + maxFileChars: task.config?.max_file_chars, + maxFilesToFetch: task.config?.max_files_to_fetch, + logger, + }); + + if (repositoryContext.totalFiles === 0 && !repositoryContext.truncated) { + logger.error('Repository context collection returned no files; cannot safely generate AGENTS.md'); + return { + success: false, + error: 'Empty repository context', + message: 'Could not read repository file tree. Aborting to avoid hallucinated output.', + }; + } + + // Step 3: Execute the command from Gist WITH the collected context attached. + // The command MUST return structured JSON with file paths and contents. + logger.info('Executing command in auto-discovery mode with repository context - expecting structured file map'); let fileUpdates = await executeCommandAndGetFileUpdates({ commandText: gistContent, octokit, @@ -336,6 +368,7 @@ async function handleUpdateAgentsTask(context) { owner, repo, targetBranch, + repositoryContext, logger, }); @@ -348,9 +381,48 @@ async function handleUpdateAgentsTask(context) { }; } - logger.info(`Auto-discovery found ${fileUpdates.length} file(s) to update`); + // Step 4: VALIDATE generated output against real repository context BEFORE + // creating the PR. Rejects non-AGENTS.md paths, out-of-scope writes, and + // content that references files that do not exist (hallucination guard). + const validation = validateGeneratedAgentFiles({ + fileUpdates, + repositoryContext, + targetPaths: repositoryContext.targetPaths, + allowCreateNew: task.config?.allow_create_new !== false, + updateExistingOnly: task.config?.update_existing_only === true, + logger, + }); + + logger.info( + { accepted: validation.accepted.length, rejected: validation.rejected.length }, + 'Validation complete' + ); + + if (validation.rejected.length) { + for (const r of validation.rejected) { + logger.warn({ file: r.file, reasons: r.reasons }, 'Rejected generated AGENTS.md file'); + } + } + + if (validation.allRejected) { + const sample = validation.rejected[0]; + return { + success: false, + error: 'All generated files failed validation', + message: 'Generated output was rejected by validation guards' + + (sample ? ` (e.g. ${sample.file}: ${sample.reasons[0]})` : '') + + '. No PR created to avoid committing hallucinated content.', + changes: fileUpdates, + rejected: validation.rejected, + }; + } + + // Use only accepted entries going forward. + fileUpdates = validation.accepted; + + logger.info(`Auto-discovery produced ${fileUpdates.length} file(s) to consider`); - // Step 3: Check if there are any actual changes + // Step 5: Check if there are any actual changes const updatedFiles = fileUpdates.filter(f => f.changed).map(f => f.file); if (updatedFiles.length === 0) { @@ -375,7 +447,7 @@ async function handleUpdateAgentsTask(context) { const prResult = await createPullRequest({ title: prTitle, - body: buildPrBody(prBody, updatedFiles, task.id), + body: buildPrBody(prBody, updatedFiles, task.id, repositoryContext), base: targetBranch, files: filesToUpdate, commitMessage, @@ -415,7 +487,7 @@ async function handleUpdateAgentsTask(context) { * @returns {Promise>} - Array of {file, oldContent, newContent, changed, isNew} objects */ async function executeCommandAndGetFileUpdates(params) { - const { commandText, octokit, apiKey, model, owner, repo, targetBranch, logger } = params; + const { commandText, octokit, apiKey, model, owner, repo, targetBranch, repositoryContext, logger } = params; if (!commandText || commandText.trim() === '') { logger.warn('Command text is empty, cannot execute'); @@ -426,12 +498,19 @@ async function executeCommandAndGetFileUpdates(params) { // Check if it's a valid /zai command const parseResult = parseCommand(commandText); - // Build prompt for auto-discovery mode - // The command should scan the repo and return a JSON structure with all AGENTS.md files - const prompt = buildAutoDiscoveryPrompt(commandText, owner, repo, targetBranch); + // Build a GROUNDED prompt using the collected repository context. + // Without real context the model hallucinates a project from the repo name. + const prompt = buildAgentsUpgradePrompt({ + commandText, + owner, + repo, + branch: targetBranch, + repositoryContext, + }); - // Call Z.ai API to execute the command - const response = await callZaiApiWithRetry(apiKey, model, prompt, logger); + // Call Z.ai API to execute the command (test-seam: __callZaiForTest hook). + const zaiCaller = module.exports.__callZaiForTest || callZaiApiWithRetry; + const response = await zaiCaller(apiKey, model, prompt, logger); if (!response || !response.content) { logger.warn('Z.ai API returned empty content'); @@ -452,26 +531,51 @@ async function executeCommandAndGetFileUpdates(params) { } /** - * Build prompt for auto-discovery mode - * @param {string} commandText - The command text - * @param {string} owner - Repository owner - * @param {string} repo - Repository name - * @param {string} branch - Target branch - * @returns {string} - Formatted prompt + * Build a GROUNDED prompt for AGENTS.md generation. + * + * The model is given the REAL repository context (file tree, existing AGENTS.md + * files, and key file contents) and is explicitly told it has NO live repo + * access and must not invent files/languages/frameworks. This is the fix for + * the PR #15 hallucination bug where the model fabricated a Python Telegram + * bot for a JavaScript GitHub Action. + * + * @param {Object} params + * @param {string} params.commandText - The gist command text + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.branch - Target branch + * @param {Object} params.repositoryContext - Collected context (see repository-context.js) + * @returns {string} Formatted prompt */ -function buildAutoDiscoveryPrompt(commandText, owner, repo, branch) { - return `Repository: ${owner}/${repo} +function buildAgentsUpgradePrompt({ commandText, owner, repo, branch, repositoryContext }) { + const contextBlock = renderRepositoryContext(repositoryContext); + const existingCount = repositoryContext?.existingAgentsFiles?.length || 0; + const scopedNote = repositoryContext?.targetPaths?.length + ? `\nYou may ONLY write AGENTS.md at the repository root and under these target paths: ${repositoryContext.targetPaths.join(', ')}. Do not propose AGENTS.md anywhere else.` + : ''; + + return `You are a Staff-level software engineer generating and updating AGENTS.md files. + +Repository: ${owner}/${repo} Branch: ${branch} -You are an AI assistant tasked with generating and updating AGENTS.md files. +CRITICAL CONSTRAINTS: +- You do NOT have live repository access. Use ONLY the repository context below. +- Do NOT invent files, directories, languages, frameworks, or services that are not present in the provided context. +- Every fact in an AGENTS.md file must be grounded in the observable repository evidence below. +- If the context is insufficient to describe a file accurately, return an empty files array instead of guessing. -Execute the following command to scan the repository and generate/update AGENTS.md files: +The repository context below contains: the real file tree, the list of EXISTING AGENTS.md files (auto-discovered), and the contents of key files. Ground all output in these facts. -${commandText} +${contextBlock} + +You must update the ${existingCount} existing AGENTS.md file(s) listed above.${scopedNote} -IMPORTANT: You must return the results in a specific JSON format so the bot can process your changes. +Execute the following command against the repository context above: -Return a JSON object with the following structure: +${commandText} + +RETURN FORMAT (strict JSON only, no markdown fences, no prose): { "summary": "Brief description of changes", "files": [ @@ -479,28 +583,28 @@ Return a JSON object with the following structure: "path": "AGENTS.md", "content": "... full file content ...", "action": "created|updated|unchanged" - }, - { - "path": "src/lib/AGENTS.md", - "content": "... full file content ...", - "action": "created|updated|unchanged" } ] } Rules: -1. Scan the ENTIRE repository structure -2. Identify ALL existing AGENTS.md files -3. Determine if each needs to be updated -4. Create new AGENTS.md files where needed (root and important subdirectories) -5. For each file that needs changes, include the full new content -6. Only include files that have actual changes (action: "created" or "updated") -7. Return ONLY valid JSON, no other text, explanations, or markdown -8. The content must be the exact text that should be written to each file +1. Only emit paths that are "AGENTS.md" or end with "/AGENTS.md". +2. Preserve and reconcile EXISTING AGENTS.md content; do not discard repository-specific knowledge. +3. Only include files with actual changes (action "created" or "updated"). +4. The content must be the exact text written to the file. +5. If you cannot determine real facts, return {"summary": "insufficient context", "files": []}. Begin your response with the JSON object immediately, no preamble.`; } +/** + * @deprecated Kept as a thin alias for backward compatibility. + * Prefer buildAgentsUpgradePrompt(), which grounds output in real repo context. + */ +function buildAutoDiscoveryPrompt(commandText, owner, repo, branch) { + return buildAgentsUpgradePrompt({ commandText, owner, repo, branch, repositoryContext: null }); +} + /** * Parse file updates from Z.ai API response * @param {string} responseContent - Response content from Z.ai @@ -639,18 +743,42 @@ async function extractFilesFromText(text, octokit, owner, repo, targetBranch, fe * @param {string} baseBody - Base PR body * @param {Array} updatedFiles - List of updated files * @param {string} taskId - Task ID + * @param {Object} [repositoryContext] - Collected repo context for transparency * @returns {string} - Full PR body */ -function buildPrBody(baseBody, updatedFiles, taskId) { +function buildPrBody(baseBody, updatedFiles, taskId, repositoryContext) { const timestamp = new Date().toISOString(); const filesList = updatedFiles.map(f => `- ${f}`).join('\n'); - return `${baseBody}\n\n` + - `**Task:** ${taskId}\n` + - `**Timestamp:** ${timestamp}\n` + - `**Files Updated:**\n${filesList}\n\n` + - `---\n` + - `*Generated automatically by zai-code-bot scheduled task*`; + const parts = [baseBody || '', '']; + + if (repositoryContext) { + parts.push(`**Context mode:** ${repositoryContext.targetPaths?.length ? 'scoped' : 'full repository'}`); + if (repositoryContext.existingAgentsFiles?.length) { + parts.push('**Existing AGENTS.md files found:**'); + for (const f of repositoryContext.existingAgentsFiles) parts.push(`- ${f}`); + } + if (repositoryContext.targetPaths?.length) { + parts.push('**Target paths:**'); + for (const t of repositoryContext.targetPaths) parts.push(`- ${t}`); + } + if (repositoryContext.truncated) { + parts.push('**Note:** repository file tree was truncated; context may be incomplete.'); + } + parts.push(''); + } + + parts.push( + `**Task:** ${taskId}`, + `**Timestamp:** ${timestamp}`, + `**Files Updated:**`, + filesList, + '', + '---', + '*Generated automatically by zai-code-bot scheduled task*', + ); + + return parts.join('\n'); } // ============================================================================ @@ -1078,4 +1206,7 @@ module.exports = { updateFileInRepo, createPR, buildExecutionContext, + buildAgentsUpgradePrompt, + buildAutoDiscoveryPrompt, + parseFileUpdatesFromResponse, }; diff --git a/src/lib/repository-context.js b/src/lib/repository-context.js new file mode 100644 index 0000000..eeb80e3 --- /dev/null +++ b/src/lib/repository-context.js @@ -0,0 +1,403 @@ +/** + * Repository Context Collection + * + * Gathers real, observable repository facts (file tree, existing AGENTS.md files, + * and key file contents) so the AGENTS.md upgrade prompt is grounded in evidence + * instead of letting the model hallucinate a project from the repo name alone. + * + * This module is deliberately self-contained: it talks to Octokit directly and + * never depends on the scheduled handler, avoiding circular imports. + */ + +// Files that are always pulled into context when present (repo-defining metadata). +// These ground the model in the real language/framework/entry points. +const KEY_FILES = [ + 'README.md', + 'package.json', + 'package-lock.json', + 'tsconfig.json', + 'pyproject.toml', + 'requirements.txt', + 'Cargo.toml', + 'go.mod', + 'pom.xml', + 'build.gradle', + 'action.yml', + 'action.yaml', + '.zai-scheduled.yml', + 'Dockerfile', + 'CONTRIBUTING.md', + 'ARCHITECTURE.md', + '.github/copilot-instructions.md', +]; + +// Glob directory patterns excluded from context by default (vendored/generated noise). +const DEFAULT_EXCLUDE_PATHS = [ + 'node_modules/**', + '.git/**', + 'dist/**', + 'build/**', + 'coverage/**', + '.next/**', + '.nuxt/**', + '.cache/**', + '.turbo/**', + '__pycache__/**', + '.venv/**', + 'vendor/**', + '*.lock', + '*.min.js', + '*.min.css', + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', +]; + +// Default context budget (characters). Generous enough for mid-size repos while +// staying well under typical model context windows after the gist prompt is added. +const DEFAULT_MAX_CONTEXT_CHARS = 120000; + +// Per-file content cap. Large files are head-truncated to this many characters. +const DEFAULT_MAX_FILE_CHARS = 12000; + +// Maximum number of content fetches we will issue (protects the API rate limit). +const DEFAULT_MAX_FILES_TO_FETCH = 40; + +/** + * Convert a glob pattern (supporting ** and *) into a RegExp. + * @param {string} pattern - Glob pattern + * @returns {RegExp} Matching regular expression + */ +function globToRegExp(pattern) { + // Anchor and escape, then translate glob tokens. + let re = '^'; + for (let i = 0; i < pattern.length; i++) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // '**' matches across directory separators. + // If it follows a literal '/', make that '/' optional so that + // 'node_modules/**' matches both 'node_modules' and 'node_modules/x'. + if (re.endsWith('/')) { + re = re.slice(0, -1) + '(?:/.*)?'; + } else { + re += '.*'; + } + i++; // consume second '*' + // swallow an optional trailing slash so 'a/**/' also works + if (pattern[i + 1] === '/') i++; + } else { + // '*' matches within a path segment + re += '[^/]*'; + } + } else if ('.+?^${}()|[]\\'.includes(c)) { + re += '\\' + c; + } else { + re += c; + } + } + re += '$'; + return new RegExp(re); +} + +/** + * Test whether a repo-relative path is excluded by any exclude glob. + * @param {string} path - Repo-relative path + * @param {RegExp[]} excludeRegexes - Compiled exclude patterns + * @returns {boolean} + */ +function isExcluded(path, excludeRegexes) { + return excludeRegexes.some(re => re.test(path)); +} + +/** + * Normalize a user-supplied path into a directory prefix form. + * '.' and '' -> '' (repo root). Trailing slash stripped. + * @param {string} p + * @returns {string} + */ +function normalizePathPrefix(p) { + if (!p) return ''; + let s = String(p).trim().replace(/\\/g, '/').replace(/\/+$/, ''); + if (s === '.' ) return ''; + return s; +} + +/** + * Returns true if `path` lives under one of the given context/target prefixes. + * Empty prefix list means "everywhere" (no restriction). + * @param {string} path + * @param {string[]} prefixes - normalized prefixes + * @returns {boolean} + */ +function isUnderPrefix(path, prefixes) { + if (!prefixes || prefixes.length === 0) return true; + const norm = path.replace(/\\/g, '/'); + return prefixes.some(prefix => { + if (!prefix) return true; + return norm === prefix || norm.startsWith(prefix + '/'); + }); +} + +/** + * Fetch a single file's text content from the repository. + * Returns null for missing files (404) instead of throwing. + * @param {Object} octokit + * @param {string} owner + * @param {string} repo + * @param {string} path + * @param {string} ref + * @returns {Promise} + */ +async function fetchFile(octokit, owner, repo, path, ref) { + try { + const { data } = await octokit.rest.repos.getContent({ owner, repo, path, ref }); + // Directory entries (array) or non-text entries have no content. + if (!data || data.type !== 'file' || typeof data.content !== 'string') { + return null; + } + return Buffer.from(data.content, 'base64').toString('utf8'); + } catch (error) { + if (error.status === 404) return null; + throw error; + } +} + +/** + * Collect grounded repository context for AGENTS.md generation. + * + * @param {Object} params + * @param {Object} params.octokit - GitHub Octokit instance + * @param {string} params.owner - Repository owner + * @param {string} params.repo - Repository name + * @param {string} params.branch - Branch/ref to read from + * @param {string[]} [params.contextPaths] - Limit analysis (file tree + contents) to these paths. Default: whole repo. + * @param {string[]} [params.targetPaths] - Limit where AGENTS.md may be written. Default: anywhere. (Root AGENTS.md always allowed.) + * @param {string[]} [params.excludePaths] - Globs to ignore. Merged with sensible defaults. + * @param {number} [params.maxContextChars] - Total char budget for file contents. + * @param {number} [params.maxFileChars] - Per-file content cap. + * @param {number} [params.maxFilesToFetch] - Hard cap on content fetches. + * @param {Object} [params.logger] + * @returns {Promise} Collected context object + */ +async function collectRepositoryContext(params) { + const { + octokit, + owner, + repo, + branch, + } = params; + + const logger = params.logger || console; + const contextPaths = (params.contextPaths || []).map(normalizePathPrefix).filter(Boolean); + const targetPaths = (params.targetPaths || []).map(normalizePathPrefix).filter(Boolean); + const excludePaths = [ + ...DEFAULT_EXCLUDE_PATHS, + ...(params.excludePaths || []), + ]; + const maxContextChars = params.maxContextChars || DEFAULT_MAX_CONTEXT_CHARS; + const maxFileChars = params.maxFileChars || DEFAULT_MAX_FILE_CHARS; + const maxFilesToFetch = params.maxFilesToFetch || DEFAULT_MAX_FILES_TO_FETCH; + + const excludeRegexes = excludePaths.map(globToRegExp); + + // ---- Step 1: full recursive file tree ---- + let tree = []; + let truncated = false; + try { + const { data } = await octokit.rest.git.getTree({ + owner, + repo, + tree_sha: branch, + recursive: 'true', + }); + tree = Array.isArray(data.tree) ? data.tree : []; + truncated = data.truncated === true; + } catch (error) { + logger.warn?.({ error: error.message }, 'Failed to fetch repository tree'); + // Treat as empty tree rather than crashing; existing-files detection still + // works via the targeted key-file fetches below. + tree = []; + } + + if (truncated) { + logger.warn?.('Repository tree is truncated; context may be incomplete'); + } + + // Build a flat list of repo-relative file paths (blobs only), respecting excludes. + const allFiles = tree + .filter(entry => entry.type === 'blob' && entry.path) + .map(entry => entry.path) + .filter(p => !isExcluded(p, excludeRegexes)); + + // Detect existing AGENTS.md files (auto-discovery). Root + any nested. + const existingAgentsFiles = allFiles.filter( + p => p === 'AGENTS.md' || p.endsWith('/AGENTS.md') + ); + + // ---- Step 2: select files whose CONTENT to fetch ---- + const contextScopedFiles = contextPaths.length + ? allFiles.filter(p => isUnderPrefix(p, contextPaths)) + : allFiles; + + // Priority order for content fetching: existing AGENTS.md > key files > dir hints. + const wantedSet = new Set(); + for (const p of existingAgentsFiles) wantedSet.add(p); + for (const kf of KEY_FILES) { + if (contextScopedFiles.includes(kf) || allFiles.includes(kf)) wantedSet.add(kf); + } + // Pull a few representative source files per context path so the model sees real code. + // Limit to avoid rate-limit pressure; prefer small/typical entry files. + const codeLike = contextScopedFiles.filter(p => + /\.(js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|rb|php|cs|sh)$/i.test(p) + ); + for (const p of codeLike) { + wantedSet.add(p); + if (wantedSet.size >= maxFilesToFetch) break; + } + + // Convert to ordered array, then enforce the fetch cap. + let filesToFetch = [...wantedSet]; + if (filesToFetch.length > maxFilesToFetch) { + filesToFetch = filesToFetch.slice(0, maxFilesToFetch); + } + + // ---- Step 3: fetch contents within the char budget ---- + const fileContents = {}; + let usedChars = 0; + + for (const path of filesToFetch) { + if (usedChars >= maxContextChars) { + logger.info?.({ path }, 'Context char budget reached, skipping remaining file fetches'); + break; + } + let content; + try { + content = await fetchFile(octokit, owner, repo, path, branch); + } catch (error) { + logger.warn?.({ error: error.message, path }, 'Failed to fetch file content for context'); + continue; + } + if (content === null || content === undefined) continue; + + let capped = content; + if (capped.length > maxFileChars) { + capped = capped.slice(0, maxFileChars) + '\n... [truncated]'; + } + if (usedChars + capped.length > maxContextChars) { + // Fill whatever budget remains for this file, then stop. + const remaining = maxContextChars - usedChars; + if (remaining <= 0) break; + capped = capped.slice(0, remaining) + '\n... [budget limit]'; + } + fileContents[path] = capped; + usedChars += capped.length; + } + + const collected = { + owner, + repo, + branch, + truncated, + totalFiles: allFiles.length, + tree: allFiles, // flat blob path list (post-exclude) + existingAgentsFiles, // auto-discovered + fileContents, // path -> text (budgeted) + contextPaths, // normalized + targetPaths, // normalized + excludePaths, // merged + normalized (raw globs) + contentCharCount: usedChars, + filesFetched: Object.keys(fileContents).length, + }; + + logger.info?.( + { + totalFiles: collected.totalFiles, + existingAgents: collected.existingAgentsFiles.length, + filesFetched: collected.filesFetched, + chars: usedChars, + truncated, + }, + 'Repository context collected' + ); + + return collected; +} + +/** + * Render collected context into a compact, deterministic text block for the prompt. + * @param {Object} ctx - Result of collectRepositoryContext + * @returns {string} + */ +function renderRepositoryContext(ctx) { + if (!ctx) return '(no repository context available)'; + + const lines = []; + + lines.push(`Repository: ${ctx.owner}/${ctx.repo}`); + lines.push(`Branch: ${ctx.branch}`); + if (ctx.truncated) { + lines.push('NOTE: The repository file tree was truncated by the GitHub API. The listing below may be incomplete.'); + } + + lines.push(''); + lines.push('# Existing AGENTS.md files (auto-discovered, do not omit any):'); + if (ctx.existingAgentsFiles.length) { + for (const f of ctx.existingAgentsFiles) lines.push(`- ${f}`); + } else { + lines.push('- (none found)'); + } + + lines.push(''); + lines.push('# Repository file tree (paths only):'); + const treePreview = ctx.tree && ctx.tree.length + ? ctx.tree + : []; + if (treePreview.length) { + // Cap the tree listing so it cannot blow the budget on its own. + const cap = 400; + for (const p of treePreview.slice(0, cap)) lines.push(p); + if (treePreview.length > cap) { + lines.push(`... [${treePreview.length - cap} more paths omitted]`); + } + } else { + lines.push('(file tree unavailable)'); + } + + lines.push(''); + lines.push('# Key file contents:'); + const entries = Object.entries(ctx.fileContents || {}); + if (entries.length) { + for (const [path, content] of entries) { + lines.push(''); + lines.push(`## ===== ${path} =====`); + lines.push(content); + lines.push(`## ===== end ${path} =====`); + } + } else { + lines.push('(no file contents available)'); + } + + if (ctx.targetPaths && ctx.targetPaths.length) { + lines.push(''); + lines.push('# Write targets are restricted to:'); + lines.push('- AGENTS.md (root, always allowed)'); + for (const t of ctx.targetPaths) lines.push(`- ${t}/AGENTS.md`); + } + + return lines.join('\n'); +} + +module.exports = { + collectRepositoryContext, + renderRepositoryContext, + fetchFile, + globToRegExp, + isExcluded, + isUnderPrefix, + normalizePathPrefix, + KEY_FILES, + DEFAULT_EXCLUDE_PATHS, + DEFAULT_MAX_CONTEXT_CHARS, + DEFAULT_MAX_FILE_CHARS, +}; diff --git a/tests/AGENTS.md b/tests/AGENTS.md index e406a77..5762080 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -22,6 +22,8 @@ tests/ | API and logging resilience | `tests/api.test.js`, `tests/logging.test.js` | Retry/error categorization | | Context, PR fetch, and batching | `tests/context.test.js`, `tests/pr-context.test.js`, `tests/changed-files.test.js`, `tests/auto-review.test.js` | Diff scoping, file-at-ref fetch, paginated/batched review | | Continuity and events | `tests/continuity.test.js`, `tests/events.test.js` | Hidden-marker state, event-type detection | +| Scheduled pipeline | `tests/handlers/scheduled.test.js`, `tests/scheduled-config.test.js`, `tests/repository-context.test.js`, `tests/agents-validation.test.js` | Config load + `validateAgentsConfig` scoping; `parseFileUpdatesFromResponse`; grounded `handleUpdateAgentsTask` flow incl. hallucination rejection; repo-context collection (tree/budgets/globs); validation guards incl. PR #15 regression | +| Scheduled pipeline (integration) | (pending) | End-to-end schedule event → context → Z.ai mock → validated PR is still a gap worth adding; unit coverage of the grounded flow exists via the `handleUpdateAgentsTask` seam (`__callZaiForTest`). | | Full command pipeline | `tests/integration/command-pipeline.test.js` | Parse -> auth -> handler -> output contract | | PR auto-review behavior | `tests/integration/pr-auto-review.test.js` | Marker upsert and PR event lifecycle | diff --git a/tests/agents-validation.test.js b/tests/agents-validation.test.js new file mode 100644 index 0000000..79123a2 --- /dev/null +++ b/tests/agents-validation.test.js @@ -0,0 +1,216 @@ +import { test, describe, expect } from 'vitest'; +const { + validateGeneratedAgentFiles, + validateFileEntry, + isAgentsPath, + extractReferencedPaths, + referenceExistsInTree, +} = require('../src/lib/agents-validation.js'); + +describe('isAgentsPath', () => { + test('accepts root and nested AGENTS.md', () => { + expect(isAgentsPath('AGENTS.md')).toBe(true); + expect(isAgentsPath('src/lib/AGENTS.md')).toBe(true); + }); + test('rejects non-AGENTS paths', () => { + expect(isAgentsPath('README.md')).toBe(false); + expect(isAgentsPath('src/AGENTS.md.txt')).toBe(false); + expect(isAgentsPath('agents.md')).toBe(false); // case-sensitive + expect(isAgentsPath(null)).toBe(false); + }); +}); + +describe('extractReferencedPaths', () => { + test('pulls path-like backtick tokens, skips generics', () => { + const content = 'Edit `src/index.js` and `main.py`. See `README.md` and `.env`.'; + const refs = extractReferencedPaths(content); + expect(refs).toContain('src/index.js'); + expect(refs).toContain('main.py'); + expect(refs).not.toContain('README.md'); + expect(refs).not.toContain('.env'); + }); + test('ignores non-path tokens', () => { + expect(extractReferencedPaths('use `npm` to `run` things')).toEqual([]); + }); +}); + +describe('referenceExistsInTree', () => { + const tree = ['src/index.js', 'src/lib/handlers/scheduled.js', 'package.json']; + test('exact file match', () => { + expect(referenceExistsInTree('src/index.js', tree)).toBe(true); + }); + test('directory prefix match', () => { + expect(referenceExistsInTree('src/lib', tree)).toBe(true); + }); + test('unknown token fails', () => { + expect(referenceExistsInTree('main.py', tree)).toBe(false); + }); + test('empty tree never flags (cannot disprove)', () => { + expect(referenceExistsInTree('main.py', [])).toBe(true); + }); +}); + +describe('validateFileEntry', () => { + const ctx = { + existingAgentsFiles: ['AGENTS.md', 'src/lib/AGENTS.md'], + tree: ['AGENTS.md', 'src/lib/AGENTS.md', 'src/index.js', 'package.json', 'action.yml'], + }; + + test('rejects non-AGENTS path', () => { + const r = validateFileEntry({ file: 'README.md', newContent: 'x' }, ctx, {}); + expect(r.valid).toBe(false); + expect(r.reasons.join(' ')).toContain('not an AGENTS.md file'); + }); + + test('accepts a clean root AGENTS.md update', () => { + const r = validateFileEntry({ + file: 'AGENTS.md', + newContent: '# Project\n\nUses `src/index.js` and `package.json`.', + }, ctx, {}); + expect(r.valid).toBe(true); + }); + + test('flags hallucination when many referenced files do not exist', () => { + const content = [ + '# AGENTS.md', + 'Entry: `main.py`', + 'Config: `config.py`', + 'Deps: `requirements.txt`', + 'Handlers: `handlers/__init__.py`', + 'Services: `services/client.py`', + 'Utils: `utils/logger.py`', + ].join('\n'); + const r = validateFileEntry({ file: 'AGENTS.md', newContent: content }, ctx, {}); + expect(r.valid).toBe(false); + expect(r.reasons.join(' ')).toContain('hallucination'); + }); + + test('does not flag when references are real', () => { + const content = '# AGENTS.md\nUses `src/index.js`, `package.json`, `action.yml`.'; + const r = validateFileEntry({ file: 'AGENTS.md', newContent: content }, ctx, {}); + expect(r.valid).toBe(true); + }); + + test('rejects out-of-scope target_path write', () => { + const r = validateFileEntry( + { file: 'tests/AGENTS.md', newContent: 'ok' }, + { ...ctx, tree: [...ctx.tree, 'tests/AGENTS.md'] }, + { targetPaths: ['src/lib'] } + ); + expect(r.valid).toBe(false); + expect(r.reasons.join(' ')).toContain('outside configured target_paths'); + }); + + test('allows in-scope target_path write', () => { + const r = validateFileEntry( + { file: 'src/lib/AGENTS.md', newContent: 'ok' }, + ctx, + { targetPaths: ['src/lib'] } + ); + expect(r.valid).toBe(true); + }); + + test('update_existing_only rejects new child file', () => { + const r = validateFileEntry( + { file: 'docs/AGENTS.md', newContent: 'new', isNew: true }, + { ...ctx, tree: [...ctx.tree, 'docs/AGENTS.md'] }, + { updateExistingOnly: true } + ); + expect(r.valid).toBe(false); + expect(r.reasons.join(' ')).toContain('update_existing_only'); + }); + + test('update_existing_only allows existing child file', () => { + const r = validateFileEntry( + { file: 'src/lib/AGENTS.md', newContent: 'updated' }, + ctx, + { updateExistingOnly: true } + ); + expect(r.valid).toBe(true); + }); + + test('allow_create_new=false rejects new child but root is fine', () => { + expect(validateFileEntry( + { file: 'newdir/AGENTS.md', newContent: 'x', isNew: true }, + { ...ctx, tree: [...ctx.tree, 'newdir/AGENTS.md'] }, + { allowCreateNew: false } + ).valid).toBe(false); + expect(validateFileEntry( + { file: 'AGENTS.md', newContent: 'x' }, + ctx, + { allowCreateNew: false } + ).valid).toBe(true); + }); +}); + +describe('validateGeneratedAgentFiles (batch)', () => { + const ctx = { + existingAgentsFiles: ['AGENTS.md'], + tree: ['AGENTS.md', 'src/index.js', 'package.json', 'action.yml'], + }; + + test('splits accepted vs rejected and flags allRejected', () => { + const fileUpdates = [ + { file: 'AGENTS.md', newContent: '# uses `src/index.js`', changed: true }, + { file: 'main.py', newContent: 'x', changed: true }, // not an AGENTS path + ]; + const res = validateGeneratedAgentFiles({ fileUpdates, repositoryContext: ctx }); + expect(res.accepted).toHaveLength(1); + expect(res.rejected).toHaveLength(1); + expect(res.allRejected).toBe(false); + }); + + test('allRejected true when every entry fails', () => { + const fileUpdates = [ + { file: 'main.py', newContent: 'x', changed: true }, + { file: 'config.py', newContent: 'y', changed: true }, + ]; + const res = validateGeneratedAgentFiles({ fileUpdates, repositoryContext: ctx }); + expect(res.accepted).toHaveLength(0); + expect(res.allRejected).toBe(true); + }); + + test('unchanged entries pass through unchanged', () => { + const fileUpdates = [{ file: 'AGENTS.md', changed: false }]; + const res = validateGeneratedAgentFiles({ fileUpdates, repositoryContext: ctx }); + expect(res.accepted).toHaveLength(1); + expect(res.allRejected).toBe(false); + }); + + test('PR #15 regression: fabricated Python Telegram bot is rejected', () => { + // This is exactly the hallucinated content the bot generated for a JS Action. + const hallucinated = [ + '# AGENTS.md', + '', + '`zai-code-bot` is a Telegram bot that proxies to Zalo AI (ZAI).', + '', + '```text', + '.', + '├── main.py # Bot entrypoint', + '├── config.py # Reads env vars', + '├── requirements.txt # Python dependencies', + '├── handlers/ # Telegram message handlers', + '├── services/ # ZAI client wrappers', + '└── keyboards/ # Inline keyboards', + '```', + ].join('\n'); + const res = validateGeneratedAgentFiles({ + fileUpdates: [{ file: 'AGENTS.md', newContent: hallucinated, changed: true }], + repositoryContext: ctx, + }); + expect(res.rejected).toHaveLength(1); + expect(res.allRejected).toBe(true); + expect(res.rejected[0].reasons.join(' ')).toContain('hallucination'); + }); + + test('informs allRejected via logger when provided', () => { + const warns = []; + const logger = { warn: (msg) => warns.push(msg) }; + validateGeneratedAgentFiles({ + fileUpdates: [{ file: 'main.py', newContent: 'x', changed: true }], + repositoryContext: ctx, + logger, + }); + expect(warns.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/handlers/scheduled.test.js b/tests/handlers/scheduled.test.js new file mode 100644 index 0000000..e4cc4e6 --- /dev/null +++ b/tests/handlers/scheduled.test.js @@ -0,0 +1,480 @@ +import { test, describe, expect, afterEach, vi } from 'vitest'; +const { + getScheduledHandler, + registerScheduledHandler, + getAllScheduledHandlers, + buildExecutionContext, + buildAgentsUpgradePrompt, + parseFileUpdatesFromResponse, + createPR, + handleUpdateAgentsTask, + executeScheduledTask, +} = require('../../src/lib/handlers/scheduled.js'); + +// Minimal no-op logger recording nothing; scheduled handlers only call info/warn/error. +function fakeLogger() { + const calls = { info: [], warn: [], error: [] }; + return { + info: (...a) => calls.info.push(a), + warn: (...a) => calls.warn.push(a), + error: (...a) => calls.error.push(a), + debug: () => {}, + child: () => fakeLogger(), + calls, + }; +} + +// Build an octokit mock with a call log so we can assert invocation order/args. +function recordingOctokit(handlers = {}) { + const calls = []; + const getContent = handlers.getContent || (async (args) => ({ data: { sha: 'sha-' + args.path } })); + const rest = { + repos: { + getContent: async (args) => { calls.push({ name: 'getContent', args }); return getContent(args); }, + createOrUpdateFileContents: async (args) => { + calls.push({ name: 'createOrUpdateFileContents', args }); + return { data: { commit: { sha: 'commit-' + args.path } } }; + }, + }, + git: { + getRef: async (args) => { calls.push({ name: 'getRef', args }); return { data: { object: { sha: 'basesha' } } }; }, + createRef: async (args) => { calls.push({ name: 'createRef', args }); return { data: {} }; }, + }, + pulls: { + create: async (args) => { + calls.push({ name: 'pulls.create', args }); + return { data: { number: 42, html_url: 'https://github.com/o/r/pull/42' } }; + }, + }, + }; + return { rest, calls }; +} + +describe('handler registry', () => { + test('getScheduledHandler returns the update-agents handler', () => { + expect(typeof getScheduledHandler('update-agents')).toBe('function'); + }); + + test('getScheduledHandler returns null for unknown commands', () => { + expect(getScheduledHandler('does-not-exist')).toBe(null); + }); + + test('registerScheduledHandler adds a handler that getScheduledHandler resolves', () => { + const fake = async () => ({ success: true }); + registerScheduledHandler('test-command', fake); + expect(getScheduledHandler('test-command')).toBe(fake); + }); + + test('getAllScheduledHandlers returns a copy that is safe to mutate', () => { + const before = Object.keys(getAllScheduledHandlers()); + const snapshot = getAllScheduledHandlers(); + snapshot['injected'] = () => {}; + // mutating the returned copy must not affect the live registry + expect(getAllScheduledHandlers()['injected']).toBeUndefined(); + expect(Object.keys(getAllScheduledHandlers()).sort()).toEqual(before.sort()); + }); + + afterEach(() => { + // restore registry to baseline (remove any test-only handlers) + if (getScheduledHandler('test-command')) { + registerScheduledHandler('test-command', null); + } + }); +}); + +describe('buildExecutionContext', () => { + test('resolves targetBranch from task.config.branch first', () => { + const ctx = buildExecutionContext({ + octokit: {}, apiKey: 'k', model: 'm', owner: 'o', repo: 'r', + task: { config: { branch: 'feature' } }, + config: { defaults: { branch: 'develop' } }, + logger: fakeLogger(), context: {}, + }); + expect(ctx.targetBranch).toBe('feature'); + }); + + test('falls back to config.defaults.branch', () => { + const ctx = buildExecutionContext({ + octokit: {}, task: { config: {} }, + config: { defaults: { branch: 'develop' } }, + logger: fakeLogger(), context: {}, + }); + expect(ctx.targetBranch).toBe('develop'); + }); + + test('falls back to main when no branch is configured', () => { + const ctx = buildExecutionContext({ + octokit: {}, task: { config: {} }, + config: { defaults: {} }, + logger: fakeLogger(), context: {}, + }); + expect(ctx.targetBranch).toBe('main'); + }); + + test('injects all expected utility helpers', () => { + const ctx = buildExecutionContext({ + octokit: {}, task: { config: {} }, config: { defaults: {} }, + logger: fakeLogger(), context: {}, + }); + for (const fn of ['fetchFromUrl', 'fetchFile', 'updateFile', 'createPullRequest', 'getFileSha']) { + expect(typeof ctx[fn]).toBe('function'); + } + expect(ctx.targetBranch).toBe('main'); + }); +}); + +describe('parseFileUpdatesFromResponse', () => { + // fetchFileContent(octokit, owner, repo, path, ref) -> string|null + function makeFetcher(existing = {}) { + return async (_octokit, _owner, _repo, path) => existing[path] ?? null; + } + + test('parses structured JSON with changed + unchanged files', async () => { + const response = JSON.stringify({ + files: [ + { path: 'AGENTS.md', content: 'new content' }, // changed (was null -> isNew) + { path: 'src/AGENTS.md', content: 'same', action: 'unchanged-action' }, + ], + }); + const fetcher = makeFetcher({ 'src/AGENTS.md': 'same' }); + const updates = await parseFileUpdatesFromResponse(response, {}, 'o', 'r', 'main', fetcher, fakeLogger()); + + expect(updates).toHaveLength(2); + expect(updates[0]).toMatchObject({ file: 'AGENTS.md', newContent: 'new content', changed: true, isNew: true }); + expect(updates[1]).toMatchObject({ file: 'src/AGENTS.md', changed: false }); + }); + + test('treats action "updated" as changed even when content matches', async () => { + const response = JSON.stringify({ + files: [{ path: 'AGENTS.md', content: 'same', action: 'updated' }], + }); + const fetcher = makeFetcher({ 'AGENTS.md': 'same' }); + const updates = await parseFileUpdatesFromResponse(response, {}, 'o', 'r', 'main', fetcher, fakeLogger()); + expect(updates[0].changed).toBe(true); + }); + + test('accepts "file" alias for path and "body" alias for content', async () => { + const response = JSON.stringify({ + files: [{ file: 'docs/AGENTS.md', body: 'B' }], + }); + const updates = await parseFileUpdatesFromResponse(response, {}, 'o', 'r', 'main', makeFetcher(), fakeLogger()); + expect(updates[0].file).toBe('docs/AGENTS.md'); + expect(updates[0].newContent).toBe('B'); + }); + + test('skips entries missing path or content', async () => { + const response = JSON.stringify({ + files: [ + { content: 'no path' }, + { path: 'no content' }, + { path: 'good.md', content: 'ok' }, + ], + }); + const updates = await parseFileUpdatesFromResponse(response, {}, 'o', 'r', 'main', makeFetcher(), fakeLogger()); + expect(updates.map(u => u.file)).toEqual(['good.md']); + }); + + test('falls back to text extraction (AGENTS.md) when response is not JSON', async () => { + const text = 'This is plain markdown, not JSON at all.'; + const updates = await parseFileUpdatesFromResponse(text, {}, 'o', 'r', 'main', makeFetcher(), fakeLogger()); + expect(updates).toHaveLength(1); + expect(updates[0].file).toBe('AGENTS.md'); + expect(updates[0].newContent).toBe(text); + expect(updates[0].changed).toBe(true); + expect(updates[0].isNew).toBe(true); + }); + + test('extracts JSON embedded inside a markdown fenced block', async () => { + const response = 'Here is the plan:\n```json\n{"files":[{"path":"AGENTS.md","content":"x"}]}\n```\nDone.'; + const updates = await parseFileUpdatesFromResponse(response, {}, 'o', 'r', 'main', makeFetcher(), fakeLogger()); + expect(updates).toHaveLength(1); + expect(updates[0].file).toBe('AGENTS.md'); + expect(updates[0].newContent).toBe('x'); + }); +}); + +describe('createPR', () => { + test('creates branch, commits files, opens PR, returns PR data', async () => { + const octokit = recordingOctokit({}); + + const result = await createPR(octokit, 'o', 'r', { + title: 'chore: update AGENTS.md', + body: 'body text', + base: 'main', + files: [{ path: 'AGENTS.md', content: 'new' }, { path: 'src/AGENTS.md', content: 'new2' }], + commitMessage: 'docs: update', + }, fakeLogger()); + + const sequence = octokit.calls.map(c => c.name); + + // getRef (base) then createRef (branch) once each + expect(sequence.filter(n => n === 'getRef')).toHaveLength(1); + expect(sequence.filter(n => n === 'createRef')).toHaveLength(1); + // getFileSha + commit per file -> 2 createOrUpdateFileContents calls + expect(sequence.filter(n => n === 'createOrUpdateFileContents')).toHaveLength(2); + // finally opens the PR + expect(sequence.filter(n => n === 'pulls.create')).toHaveLength(1); + expect(sequence[sequence.length - 1]).toBe('pulls.create'); + + // branch name pattern zai-scheduled/YYYY.MM.DD_HH.MM, head = that branch + const prArgs = octokit.calls.find(c => c.name === 'pulls.create').args; + expect(prArgs.head).toMatch(/^zai-scheduled\/\d{4}\.\d{2}\.\d{2}_\d{2}\.\d{2}$/); + expect(prArgs.base).toBe('main'); + expect(prArgs.title).toBe('chore: update AGENTS.md'); + + expect(result.number).toBe(42); + expect(result.html_url).toBe('https://github.com/o/r/pull/42'); + expect(result.commitSha).toBe('commit-src/AGENTS.md'); + }); + + test('tolerates an already-existing branch (422)', async () => { + const err = Object.assign(new Error('exists'), { status: 422 }); + const octokit = recordingOctokit({}); + octokit.rest.git.createRef = async () => { throw err; }; + + const result = await createPR(octokit, 'o', 'r', { + title: 't', body: 'b', base: 'main', + files: [{ path: 'AGENTS.md', content: 'x' }], + commitMessage: 'm', + }, fakeLogger()); + expect(result.number).toBe(42); + }); +}); + +describe('handleUpdateAgentsTask (early returns)', () => { + function baseContext(overrides = {}) { + return { + octokit: {}, apiKey: 'k', model: 'm', owner: 'o', repo: 'r', + task: { id: 'weekly', command: 'update-agents', config: { gist_url: 'https://gist.example' } }, + config: { defaults: { branch: 'main' } }, + logger: fakeLogger(), targetBranch: 'main', + fetchFromUrl: async () => 'gist content', + fetchFile: async () => null, + createPullRequest: async (p) => ({ number: 7, html_url: 'u' }), + ...overrides, + }; + } + + test('fails when no gist_url is configured anywhere', async () => { + const result = await handleUpdateAgentsTask(baseContext({ + task: { id: 'weekly', command: 'update-agents', config: {} }, + config: { defaults: {} }, + })); + expect(result.success).toBe(false); + expect(result.error).toBe('Missing gist_url configuration'); + }); + + test('fails when gist fetch throws', async () => { + const result = await handleUpdateAgentsTask(baseContext({ + fetchFromUrl: async () => { throw new Error('network down'); }, + })); + expect(result.success).toBe(false); + expect(result.error.startsWith('Failed to fetch from gist')).toBe(true); + }); + + test('fails when gist returns empty content', async () => { + const result = await handleUpdateAgentsTask(baseContext({ + fetchFromUrl: async () => ' ', + })); + expect(result.success).toBe(false); + expect(result.error).toBe('Empty response from gist URL'); + }); +}); + +describe('executeScheduledTask', () => { + test('returns an error result for an unknown command', async () => { + const result = await executeScheduledTask({ + octokit: {}, apiKey: 'k', model: 'm', owner: 'o', repo: 'r', + task: { id: 't1', command: 'totally-unknown' }, + config: { defaults: {} }, + logger: fakeLogger(), context: {}, + }); + expect(result.success).toBe(false); + expect(result.taskId).toBe('t1'); + expect(result.error.startsWith('Unknown scheduled command')).toBe(true); + }); + + test('runs a registered handler and maps its result', async () => { + const fakeHandler = async () => ({ + success: true, + changes: [{ file: 'AGENTS.md', changed: true }], + prCreated: true, + prNumber: 9, + prUrl: 'https://github.com/o/r/pull/9', + }); + registerScheduledHandler('test-runner', fakeHandler); + try { + const result = await executeScheduledTask({ + octokit: {}, apiKey: 'k', model: 'm', owner: 'o', repo: 'r', + task: { id: 't1', command: 'test-runner' }, + config: { defaults: {} }, + logger: fakeLogger(), context: {}, + }); + expect(result.success).toBe(true); + expect(result.prCreated).toBe(true); + expect(result.prNumber).toBe(9); + expect(result.changes).toHaveLength(1); + } finally { + registerScheduledHandler('test-runner', null); + } + }); +}); + +describe('buildAgentsUpgradePrompt', () => { + test('embeds real repo context and anti-hallucination constraints', () => { + const ctx = { + owner: 'o', repo: 'r', branch: 'main', truncated: false, + tree: ['AGENTS.md', 'src/index.js', 'package.json', 'action.yml'], + existingAgentsFiles: ['AGENTS.md'], + fileContents: { 'package.json': '{"name":"zai"}' }, + targetPaths: [], + }; + const prompt = buildAgentsUpgradePrompt({ + commandText: '/init-agentsmd', + owner: 'o', repo: 'r', branch: 'main', repositoryContext: ctx, + }); + + // Anti-hallucination: model told it has no live repo access. + expect(prompt).toContain('do NOT have live repository access'); + // Real tree is embedded. + expect(prompt).toContain('src/index.js'); + expect(prompt).toContain('action.yml'); + // Existing AGENTS.md listed. + expect(prompt).toContain('- AGENTS.md'); + // Real file content embedded. + expect(prompt).toContain('package.json'); + expect(prompt).toContain('"name":"zai"'); + }); + + test('includes scoped write restriction when targetPaths set', () => { + const ctx = { + owner: 'o', repo: 'r', branch: 'main', truncated: false, + tree: [], existingAgentsFiles: [], fileContents: {}, targetPaths: ['src/lib'], + }; + const prompt = buildAgentsUpgradePrompt({ + commandText: 'cmd', owner: 'o', repo: 'r', branch: 'main', repositoryContext: ctx, + }); + expect(prompt).toContain('ONLY write AGENTS.md'); + expect(prompt).toContain('src/lib'); + }); +}); + +describe('handleUpdateAgentsTask (grounded flow)', () => { + // Mocks the tree + content + gist + Z.ai layers to exercise the new pipeline + // end-to-end: gist fetch -> context collection -> Z.ai -> parse -> validate -> PR. + function buildCtx({ zaiResponse, existingAgentsFiles = ['AGENTS.md'] }) { + const tree = [ + { type: 'blob', path: 'AGENTS.md' }, + { type: 'blob', path: 'src/index.js' }, + { type: 'blob', path: 'package.json' }, + { type: 'blob', path: 'action.yml' }, + ...existingAgentsFiles.filter(f => f !== 'AGENTS.md').map(p => ({ type: 'blob', path: p })), + ]; + const fileContents = { + 'AGENTS.md': '# existing agents uses src/index.js', + 'package.json': '{"name":"zai-code-bot"}', + 'action.yml': 'runs: using: node', + 'src/index.js': 'module.exports = {};', + }; + const octokit = { + rest: { + git: { getTree: vi.fn(async () => ({ data: { tree, truncated: false } })) }, + repos: { + getContent: vi.fn(async ({ path }) => { + if (fileContents[path] === undefined) { + const e = new Error('nf'); e.status = 404; throw e; + } + return { data: { type: 'file', content: Buffer.from(fileContents[path], 'utf8').toString('base64') } }; + }), + }, + }, + }; + + let capturedPrompt; + const promptSpy = vi.fn(async (_apiKey, _model, prompt) => { + capturedPrompt = prompt; + return { content: typeof zaiResponse === 'function' ? zaiResponse() : zaiResponse }; + }); + + // Stub the module-internal Z.ai client with a capture hook. + const scheduled = require('../../src/lib/handlers/scheduled.js'); + const origCall = scheduled.__callZaiForTest; + scheduled.__callZaiForTest = promptSpy; + + const createPullRequest = vi.fn(async () => ({ number: 77, html_url: 'https://pr/77' })); + + return { + context: { + octokit, apiKey: 'k', model: 'm', owner: 'o', repo: 'r', + task: { id: 'weekly', command: 'update-agents', config: { gist_url: 'https://gist.example' } }, + config: { defaults: { branch: 'main' } }, + logger: fakeLogger(), targetBranch: 'main', + fetchFromUrl: async () => 'cmd from gist', + fetchFile: async () => null, + createPullRequest, + }, + createPullRequest, + restore: () => { scheduled.__callZaiForTest = origCall; }, + getPrompt: () => capturedPrompt, + }; + } + + test('creates a PR for a valid, grounded update', async () => { + const validResponse = JSON.stringify({ + summary: 'refreshed root', + files: [ + { path: 'AGENTS.md', content: '# Project\n\nUses `src/index.js`, `package.json`, `action.yml`.', action: 'updated' }, + ], + }); + const env = buildCtx({ zaiResponse: validResponse }); + try { + const result = await handleUpdateAgentsTask(env.context); + expect(result.success).toBe(true); + expect(result.prCreated).toBe(true); + expect(env.createPullRequest).toHaveBeenCalledTimes(1); + // PR body must include context metadata. + const prArgs = env.createPullRequest.mock.calls[0][0]; + expect(prArgs.body).toContain('Context mode'); + expect(prArgs.body).toContain('Existing AGENTS.md files found'); + } finally { + env.restore(); + } + }); + + test('rejects hallucinated output and creates NO PR', async () => { + const hallucinated = JSON.stringify({ + summary: 'rewrote', + files: [ + { path: 'AGENTS.md', content: [ + '`zai-code-bot` is a Telegram bot.', + 'Entry: `main.py`, config: `config.py`, deps: `requirements.txt`.', + 'Handlers: `handlers/__init__.py`, services: `services/client.py`.', + ].join('\n'), action: 'updated' }, + ], + }); + const env = buildCtx({ zaiResponse: hallucinated }); + try { + const result = await handleUpdateAgentsTask(env.context); + expect(result.success).toBe(false); + expect(result.prCreated).toBeUndefined(); + expect(result.error).toContain('validation'); + expect(env.createPullRequest).not.toHaveBeenCalled(); + } finally { + env.restore(); + } + }); + + test('aborts safely when repository context is empty', async () => { + const env = buildCtx({ zaiResponse: JSON.stringify({ files: [] }) }); + // Override tree to be empty + not truncated. + env.context.octokit.rest.git.getTree = vi.fn(async () => ({ data: { tree: [], truncated: false } })); + try { + const result = await handleUpdateAgentsTask(env.context); + expect(result.success).toBe(false); + expect(result.error).toBe('Empty repository context'); + expect(env.createPullRequest).not.toHaveBeenCalled(); + } finally { + env.restore(); + } + }); +}); diff --git a/tests/repository-context.test.js b/tests/repository-context.test.js new file mode 100644 index 0000000..a2359c0 --- /dev/null +++ b/tests/repository-context.test.js @@ -0,0 +1,257 @@ +import { test, describe, expect, vi } from 'vitest'; +const { + collectRepositoryContext, + renderRepositoryContext, + fetchFile, + globToRegExp, + isExcluded, + isUnderPrefix, + normalizePathPrefix, + KEY_FILES, +} = require('../src/lib/repository-context.js'); + +function fakeLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +// Build an octokit mock with a git tree + per-file content responses. +function treeOctokit({ tree = [], files = {}, truncated = false, failTree = false } = {}) { + return { + rest: { + git: { + getTree: failTree + ? vi.fn(async () => { throw Object.assign(new Error('boom'), { status: 500 }); }) + : vi.fn(async () => ({ data: { tree, truncated } })), + }, + repos: { + getContent: vi.fn(async ({ path }) => { + if (files[path] === undefined) { + const e = new Error('nf'); e.status = 404; throw e; + } + return { data: { type: 'file', content: Buffer.from(files[path], 'utf8').toString('base64') } }; + }), + }, + }, + }; +} + +function blob(path) { return { type: 'blob', path }; } + +describe('globToRegExp', () => { + test('matches ** across directory separators', () => { + const re = globToRegExp('node_modules/**'); + expect(re.test('node_modules/foo/bar.js')).toBe(true); + expect(re.test('node_modules')).toBe(true); + expect(re.test('src/index.js')).toBe(false); + }); + + test('matches single * within a segment', () => { + const re = globToRegExp('*.min.js'); + expect(re.test('app.min.js')).toBe(true); + expect(re.test('dir/app.min.js')).toBe(false); // * does not cross / + }); + + test('escapes regex metacharacters', () => { + const re = globToRegExp('dist/bundle.js'); + expect(re.test('dist/bundle.js')).toBe(true); + expect(re.test('distXbundle.js')).toBe(false); + }); +}); + +describe('isExcluded / isUnderPrefix / normalizePathPrefix', () => { + test('isExcluded matches any of the compiled patterns', () => { + const res = ['dist/**', '*.lock'].map(globToRegExp); + expect(isExcluded('dist/x.js', res)).toBe(true); + expect(isExcluded('yarn.lock', res)).toBe(true); + expect(isExcluded('src/index.js', res)).toBe(false); + }); + + test('isUnderPrefix empty list means everywhere', () => { + expect(isUnderPrefix('a/b.js', [])).toBe(true); + }); + + test('isUnderPrefix matches prefix boundary exactly', () => { + const p = [normalizePathPrefix('src/lib')]; + expect(isUnderPrefix('src/lib/x.js', p)).toBe(true); + expect(isUnderPrefix('src/libraries/x.js', p)).toBe(false); + expect(isUnderPrefix('src/lib', p)).toBe(true); + }); + + test('normalizePathPrefix canonicalizes', () => { + expect(normalizePathPrefix('.')).toBe(''); + expect(normalizePathPrefix('')).toBe(''); + expect(normalizePathPrefix('src/')).toBe('src'); + expect(normalizePathPrefix('src\\lib')).toBe('src/lib'); + }); +}); + +describe('collectRepositoryContext', () => { + test('discovers existing AGENTS.md files and includes them in contents', async () => { + const tree = [ + blob('AGENTS.md'), blob('package.json'), blob('src/index.js'), + blob('src/lib/AGENTS.md'), blob('README.md'), blob('node_modules/x.js'), + ]; + const octokit = treeOctokit({ + tree, + files: { + 'AGENTS.md': '# root agents', + 'package.json': '{"name":"zai"}', + 'README.md': '# readme', + 'src/lib/AGENTS.md': '# lib agents', + }, + }); + + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', logger: fakeLogger(), + }); + + expect(ctx.existingAgentsFiles.sort()).toEqual(['AGENTS.md', 'src/lib/AGENTS.md']); + expect(ctx.fileContents['AGENTS.md']).toBe('# root agents'); + expect(ctx.fileContents['package.json']).toBe('{"name":"zai"}'); + // node_modules excluded from tree and never fetched + expect(ctx.tree).not.toContain('node_modules/x.js'); + expect(ctx.fileContents['node_modules/x.js']).toBeUndefined(); + expect(ctx.truncated).toBe(false); + }); + + test('respects context_paths to limit code files', async () => { + const tree = [ + blob('src/index.js'), blob('tests/foo.test.js'), blob('README.md'), + ]; + const octokit = treeOctokit({ tree, files: { 'README.md': 'r' } }); + + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', + contextPaths: ['src'], logger: fakeLogger(), + }); + + expect(ctx.tree).toContain('tests/foo.test.js'); // tree is whole repo + expect(ctx.fileContents['tests/foo.test.js']).toBeUndefined(); // not fetched (out of context scope) + }); + + test('respects exclude_paths (merged with defaults)', async () => { + const tree = [blob('vendor/a.go'), blob('src/index.js'), blob('generated.gen.js')]; + const octokit = treeOctokit({ tree, files: {} }); + + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', + excludePaths: ['generated/**', '*.gen.js'], logger: fakeLogger(), + }); + + expect(ctx.tree).not.toContain('vendor/a.go'); // default excludes vendor + expect(ctx.tree).not.toContain('generated.gen.js'); // custom exclude + expect(ctx.tree).toContain('src/index.js'); + }); + + test('flags truncated tree from GitHub API', async () => { + const octokit = treeOctokit({ tree: [blob('a.js')], truncated: true, files: {} }); + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', logger: fakeLogger(), + }); + expect(ctx.truncated).toBe(true); + }); + + test('survives tree fetch failure (empty tree, not crash)', async () => { + const octokit = treeOctokit({ failTree: true, files: { 'package.json': '{}' } }); + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', logger: fakeLogger(), + }); + expect(ctx.totalFiles).toBe(0); + expect(ctx.existingAgentsFiles).toEqual([]); + }); + + test('enforces max_context_chars budget', async () => { + const big = 'x'.repeat(5000); + const tree = [blob('a.js'), blob('b.js'), blob('c.js')]; + const octokit = treeOctokit({ tree, files: { 'a.js': big, 'b.js': big, 'c.js': big } }); + + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', + maxContextChars: 6000, maxFileChars: 5000, logger: fakeLogger(), + }); + + const fetched = Object.keys(ctx.fileContents); + // First file fits fully, second gets budget-limited; third never fetched. + expect(fetched.length).toBeLessThanOrEqual(2); + expect(ctx.contentCharCount).toBeLessThanOrEqual(6000 + 20); // +budget-limit note slack + }); + + test('enforces max_files_to_fetch cap', async () => { + const tree = [blob('a.js'), blob('b.js'), blob('c.js'), blob('d.js')]; + const octokit = treeOctokit({ tree, files: {} }); + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', + maxFilesToFetch: 2, logger: fakeLogger(), + }); + expect(ctx.filesFetched).toBeLessThanOrEqual(2); + }); + + test('normalizes and surfaces targetPaths on the context object', async () => { + const octokit = treeOctokit({ tree: [blob('AGENTS.md')], files: { 'AGENTS.md': 'x' } }); + const ctx = await collectRepositoryContext({ + octokit, owner: 'o', repo: 'r', branch: 'main', + targetPaths: ['src/lib/', 'tests'], logger: fakeLogger(), + }); + expect(ctx.targetPaths.sort()).toEqual(['src/lib', 'tests']); + }); +}); + +describe('renderRepositoryContext', () => { + test('includes tree, existing agents files, and file contents sections', () => { + const ctx = { + owner: 'o', repo: 'r', branch: 'main', truncated: false, + tree: ['AGENTS.md', 'src/index.js'], + existingAgentsFiles: ['AGENTS.md'], + fileContents: { 'AGENTS.md': '# hi' }, + targetPaths: [], + }; + const out = renderRepositoryContext(ctx); + expect(out).toContain('Repository: o/r'); + expect(out).toContain('# Existing AGENTS.md files'); + expect(out).toContain('- AGENTS.md'); + expect(out).toContain('# Repository file tree (paths only):'); + expect(out).toContain('src/index.js'); + expect(out).toContain('## ===== AGENTS.md ====='); + expect(out).toContain('# hi'); + }); + + test('notes truncation', () => { + const out = renderRepositoryContext({ owner: 'o', repo: 'r', branch: 'main', truncated: true, tree: [], existingAgentsFiles: [], fileContents: {} }); + expect(out).toContain('truncated'); + }); + + test('renders write-target restriction when targetPaths set', () => { + const out = renderRepositoryContext({ owner: 'o', repo: 'r', branch: 'main', truncated: false, tree: [], existingAgentsFiles: [], fileContents: {}, targetPaths: ['src/lib'] }); + expect(out).toContain('Write targets are restricted'); + expect(out).toContain('src/lib'); + }); + + test('handles null gracefully', () => { + expect(renderRepositoryContext(null)).toContain('no repository context'); + }); +}); + +describe('fetchFile', () => { + test('returns null on 404', async () => { + const octokit = treeOctokit({ files: {} }); + expect(await fetchFile(octokit, 'o', 'r', 'missing', 'main')).toBe(null); + }); + + test('returns decoded content for a file', async () => { + const octokit = treeOctokit({ files: { 'a.txt': 'hello' } }); + expect(await fetchFile(octokit, 'o', 'r', 'a.txt', 'main')).toBe('hello'); + }); + + test('returns null for non-file entries', async () => { + const octokit = { rest: { repos: { getContent: vi.fn(async () => ({ data: { type: 'dir' } })) } } }; + expect(await fetchFile(octokit, 'o', 'r', 'somedir', 'main')).toBe(null); + }); +}); + +describe('KEY_FILES', () => { + test('includes the expected repo-defining files', () => { + expect(KEY_FILES).toContain('package.json'); + expect(KEY_FILES).toContain('action.yml'); + expect(KEY_FILES).toContain('README.md'); + }); +}); diff --git a/tests/scheduled-config.test.js b/tests/scheduled-config.test.js new file mode 100644 index 0000000..33a3749 --- /dev/null +++ b/tests/scheduled-config.test.js @@ -0,0 +1,420 @@ +import { test, describe, expect, beforeEach, afterEach } from 'vitest'; +const { + CONFIG_VERSION, + loadScheduledConfig, + validateAndNormalizeConfig, + validateDefaults, + validateAndNormalizeTask, + validateAgentsConfig, + getTasksToRun, + getTaskById, + areScheduledTasksEnabled, + getGistUrl, +} = require('../src/lib/config/scheduled-config.js'); + +const yaml = require('yaml'); + +// Build a base64-encoded getContent() response from a raw config string. +function contentResponse(rawConfig) { + return { + data: { + content: Buffer.from(rawConfig, 'utf8').toString('base64'), + }, + }; +} + +function buildOctokit({ getContent }) { + return { + rest: { + repos: { + getContent, + }, + }, + }; +} + +describe('CONFIG_VERSION', () => { + test('equals 1', () => { + expect(CONFIG_VERSION).toBe(1); + }); +}); + +describe('getGistUrl', () => { + const prevEnv = process.env.ZAI_AGENTS_GIST_URL; + + afterEach(() => { + if (prevEnv === undefined) delete process.env.ZAI_AGENTS_GIST_URL; + else process.env.ZAI_AGENTS_GIST_URL = prevEnv; + }); + + test('prefers task config gist_url', () => { + process.env.ZAI_AGENTS_GIST_URL = 'https://env.example'; + expect(getGistUrl({ gist_url: 'https://task.example' }, { gist_url: 'https://defaults.example' })) + .toBe('https://task.example'); + }); + + test('falls back to defaults gist_url when task has none', () => { + expect(getGistUrl({}, { gist_url: 'https://defaults.example' })).toBe('https://defaults.example'); + }); + + test('falls back to env var when no task or defaults value', () => { + process.env.ZAI_AGENTS_GIST_URL = 'https://env.example'; + expect(getGistUrl({}, {})).toBe('https://env.example'); + }); + + test('returns null when nothing is configured', () => { + delete process.env.ZAI_AGENTS_GIST_URL; + expect(getGistUrl({}, {})).toBe(null); + }); + + test('returns null for undefined inputs', () => { + delete process.env.ZAI_AGENTS_GIST_URL; + expect(getGistUrl(undefined, undefined)).toBe(null); + }); +}); + +describe('validateDefaults', () => { + test('merges provided values over built-in DEFAULTS', () => { + const result = validateDefaults({ branch: 'develop', gist_url: 'https://g.example' }); + expect(result.branch).toBe('develop'); + expect(result.gist_url).toBe('https://g.example'); + expect(result.schedule).toBe('0 0 * * 0'); // default retained + expect(result.enabled).toBe(true); // default retained + }); + + test('returns built-in DEFAULTS for empty input', () => { + const result = validateDefaults({}); + expect(result).toEqual({ branch: 'main', schedule: '0 0 * * 0', enabled: true }); + }); + + test('throws when branch is not a string', () => { + expect(() => validateDefaults({ branch: 123 })).toThrow('defaults.branch must be a string'); + }); + + test('throws when schedule is not a string', () => { + expect(() => validateDefaults({ schedule: 42 })).toThrow('defaults.schedule must be a string'); + }); + + test('throws when gist_url is not a string', () => { + expect(() => validateDefaults({ gist_url: [] })).toThrow('defaults.gist_url must be a string'); + }); +}); + +describe('validateAndNormalizeTask', () => { + const defaults = { branch: 'main', schedule: '0 0 * * 0', enabled: true }; + + test('throws when task id is missing', () => { + expect(() => validateAndNormalizeTask({ command: 'update-agents' }, 0, defaults)) + .toThrow('Task 0 missing required field: id'); + }); + + test('throws when task id is not a string', () => { + expect(() => validateAndNormalizeTask({ id: 5, command: 'update-agents' }, 0, defaults)) + .toThrow("Task 0 field 'id' must be a string"); + }); + + test('throws when command is missing', () => { + expect(() => validateAndNormalizeTask({ id: 't1' }, 0, defaults)) + .toThrow('Task t1 missing required field: command'); + }); + + test('throws when command is not a string', () => { + expect(() => validateAndNormalizeTask({ id: 't1', command: 9 }, 0, defaults)) + .toThrow("Task t1 field 'command' must be a string"); + }); + + test('throws when enabled is not a boolean', () => { + expect(() => validateAndNormalizeTask({ id: 't1', command: 'update-agents', enabled: 'yes' }, 0, defaults)) + .toThrow('Task t1 has invalid enabled value (must be boolean)'); + }); + + test('applies defaults for enabled and schedule when omitted', () => { + const result = validateAndNormalizeTask({ id: 't1', command: 'update-agents' }, 0, defaults); + expect(result.id).toBe('t1'); + expect(result.command).toBe('update-agents'); + expect(result.enabled).toBe(true); + expect(result.schedule).toBe('0 0 * * 0'); + }); + + test('uses task name when provided, otherwise falls back to id', () => { + expect(validateAndNormalizeTask({ id: 't1', name: 'Custom', command: 'x' }, 0, defaults).name).toBe('Custom'); + expect(validateAndNormalizeTask({ id: 't1', command: 'x' }, 0, defaults).name).toBe('t1'); + }); + + test('respects task-level enabled and schedule overrides', () => { + const result = validateAndNormalizeTask( + { id: 't1', command: 'update-agents', enabled: false, schedule: '0 0 1 * *' }, + 0, + defaults, + ); + expect(result.enabled).toBe(false); + expect(result.schedule).toBe('0 0 1 * *'); + }); + + test('merges task config over defaults', () => { + const result = validateAndNormalizeTask( + { id: 't1', command: 'update-agents', config: { branch: 'develop', files: ['AGENTS.md'] } }, + 0, + defaults, + ); + expect(result.config.branch).toBe('develop'); + expect(result.config.files).toEqual(['AGENTS.md']); + expect(result.config.schedule).toBe('0 0 * * 0'); // inherited default + }); + + test('throws for non-string config branch', () => { + expect(() => validateAndNormalizeTask({ id: 't1', command: 'x', config: { branch: 7 } }, 0, defaults)) + .toThrow('Task t1 has invalid branch value'); + }); + + test('throws for non-string config gist_url', () => { + expect(() => validateAndNormalizeTask({ id: 't1', command: 'x', config: { gist_url: 1 } }, 0, defaults)) + .toThrow('Task t1 has invalid gist_url value'); + }); + + test('throws for non-array config files', () => { + expect(() => validateAndNormalizeTask({ id: 't1', command: 'x', config: { files: 'nope' } }, 0, defaults)) + .toThrow('Task t1 has invalid files value (must be array)'); + }); +}); + +describe('validateAgentsConfig', () => { + test('accepts valid scoping/budget fields', () => { + const cfg = { + context_paths: ['src'], + target_paths: ['tests'], + exclude_paths: ['dist/**'], + max_context_chars: 100000, + max_file_chars: 8000, + max_files_to_fetch: 20, + allow_create_new: true, + update_existing_only: false, + }; + expect(() => validateAgentsConfig('t1', cfg)).not.toThrow(); + }); + + test('accepts empty/undefined config (all fields optional)', () => { + expect(() => validateAgentsConfig('t1', {})).not.toThrow(); + }); + + test('throws for non-array path fields', () => { + expect(() => validateAgentsConfig('t1', { context_paths: 'src' })).toThrow('context_paths'); + expect(() => validateAgentsConfig('t1', { target_paths: 1 })).toThrow('target_paths'); + expect(() => validateAgentsConfig('t1', { exclude_paths: {} })).toThrow('exclude_paths'); + }); + + test('throws for non-string entries in path arrays', () => { + expect(() => validateAgentsConfig('t1', { context_paths: ['ok', 5] })).toThrow('non-string'); + }); + + test('throws for non-positive budget numbers', () => { + expect(() => validateAgentsConfig('t1', { max_context_chars: 0 })).toThrow('max_context_chars'); + expect(() => validateAgentsConfig('t1', { max_file_chars: -1 })).toThrow('max_file_chars'); + expect(() => validateAgentsConfig('t1', { max_files_to_fetch: 'x' })).toThrow('max_files_to_fetch'); + }); + + test('throws for non-boolean flags', () => { + expect(() => validateAgentsConfig('t1', { allow_create_new: 1 })).toThrow('allow_create_new'); + expect(() => validateAgentsConfig('t1', { update_existing_only: 'yes' })).toThrow('update_existing_only'); + }); + + test('is wired into validateAndNormalizeTask end-to-end', () => { + expect(() => validateAndNormalizeTask( + { id: 't1', command: 'update-agents', config: { target_paths: 'bad' } }, + 0, + { branch: 'main', schedule: '0 0 * * 0', enabled: true }, + )).toThrow('target_paths'); + }); +}); + +describe('validateAndNormalizeConfig', () => { + function baseConfig(overrides = {}) { + return { + version: 1, + defaults: { branch: 'main' }, + tasks: [{ id: 't1', command: 'update-agents' }], + ...overrides, + }; + } + + test('throws when version is missing', () => { + expect(() => validateAndNormalizeConfig({ tasks: [] })).toThrow('Configuration missing required field: version'); + }); + + test('throws when version is unsupported', () => { + expect(() => validateAndNormalizeConfig({ version: 2, tasks: [] })).toThrow('Unsupported config version: 2. Expected: 1'); + }); + + test('throws when tasks is not an array', () => { + expect(() => validateAndNormalizeConfig({ version: 1 })).toThrow('Configuration must contain a "tasks" array'); + expect(() => validateAndNormalizeConfig({ version: 1, tasks: {} })).toThrow('Configuration must contain a "tasks" array'); + }); + + test('normalizes a valid config and applies defaults', () => { + const config = validateAndNormalizeConfig(baseConfig()); + expect(config.defaults.branch).toBe('main'); + expect(config.defaults.enabled).toBe(true); // injected default + expect(config.tasks[0].id).toBe('t1'); + expect(config.tasks[0].enabled).toBe(true); + }); + + test('returns the same config object reference', () => { + const input = baseConfig(); + expect(validateAndNormalizeConfig(input)).toBe(input); + }); +}); + +describe('getTasksToRun', () => { + function config(tasks, defaultsSchedule = '0 0 * * 0') { + return { defaults: { schedule: defaultsSchedule }, tasks }; + } + + test('returns only enabled tasks', () => { + const cfg = config([ + { id: 'a', command: 'x', enabled: true }, + { id: 'b', command: 'x', enabled: false }, + ]); + expect(getTasksToRun(cfg, null).map(t => t.id)).toEqual(['a']); + }); + + test('returns empty array when no tasks are enabled', () => { + const cfg = config([{ id: 'a', command: 'x', enabled: false }]); + expect(getTasksToRun(cfg, null)).toEqual([]); + }); + + test('runs all enabled tasks when no event schedule is given', () => { + const cfg = config([ + { id: 'a', command: 'x', enabled: true, schedule: '0 0 * * 0' }, + { id: 'b', command: 'x', enabled: true, schedule: '0 0 1 * *' }, + ]); + expect(getTasksToRun(cfg, null).map(t => t.id)).toEqual(['a', 'b']); + }); + + test('runs tasks matching the event schedule OR the defaults schedule', () => { + // defaults.schedule = '0 0 * * 0' + const cfg = config([ + { id: 'a', command: 'x', enabled: true, schedule: '0 0 1 * *' }, // matches eventSchedule + { id: 'b', command: 'x', enabled: true, schedule: '0 0 * * 0' }, // matches defaults.schedule + { id: 'c', command: 'x', enabled: true, schedule: '30 5 * * *' }, // matches neither + ]); + // eventSchedule = '0 0 1 * *' -> runs a (event match) + b (defaults match), excludes c + expect(getTasksToRun(cfg, '0 0 1 * *').map(t => t.id)).toEqual(['a', 'b']); + }); + + test('excludes tasks whose schedule matches neither event nor defaults', () => { + const cfg = config([ + { id: 'c', command: 'x', enabled: true, schedule: '30 5 * * *' }, + ]); + expect(getTasksToRun(cfg, '0 0 1 * *')).toEqual([]); + }); + + test('matches tasks whose schedule equals the defaults schedule', () => { + const cfg = config([ + { id: 'a', command: 'x', enabled: true, schedule: '0 0 * * 0' }, + { id: 'b', command: 'x', enabled: true, schedule: '0 0 1 * *' }, + ]); + expect(getTasksToRun(cfg, '0 0 * * 0').map(t => t.id)).toEqual(['a']); + }); +}); + +describe('getTaskById', () => { + const cfg = { tasks: [{ id: 'a' }, { id: 'b' }] }; + + test('returns the matching task', () => { + expect(getTaskById(cfg, 'b').id).toBe('b'); + }); + + test('returns null when not found', () => { + expect(getTaskById(cfg, 'missing')).toBe(null); + }); +}); + +describe('loadScheduledConfig', () => { + const prevPath = process.env.ZAI_SCHEDULED_CONFIG_PATH; + + afterEach(() => { + if (prevPath === undefined) delete process.env.ZAI_SCHEDULED_CONFIG_PATH; + else process.env.ZAI_SCHEDULED_CONFIG_PATH = prevPath; + }); + + test('fetches, parses, and validates config via octokit', async () => { + const raw = yaml.stringify({ + version: 1, + defaults: { branch: 'main' }, + tasks: [{ id: 'weekly', command: 'update-agents' }], + }); + let calledPath; + let calledRef; + const octokit = buildOctokit({ + getContent: async ({ path, ref }) => { + calledPath = path; + calledRef = ref; + return contentResponse(raw); + }, + }); + + const config = await loadScheduledConfig(octokit, 'owner', 'repo', 'develop'); + + expect(calledPath).toBe('.zai-scheduled.yml'); + expect(calledRef).toBe('develop'); + expect(config.version).toBe(1); + expect(config.tasks[0].id).toBe('weekly'); + }); + + test('uses ZAI_SCHEDULED_CONFIG_PATH when set', async () => { + process.env.ZAI_SCHEDULED_CONFIG_PATH = '.custom.yml'; + const octokit = buildOctokit({ + getContent: async ({ path }) => { + expect(path).toBe('.custom.yml'); + return contentResponse(yaml.stringify({ version: 1, tasks: [] })); + }, + }); + await loadScheduledConfig(octokit, 'owner', 'repo'); + }); + + test('defaults ref to main', async () => { + let calledRef; + const octokit = buildOctokit({ + getContent: async ({ ref }) => { + calledRef = ref; + return contentResponse(yaml.stringify({ version: 1, tasks: [] })); + }, + }); + await loadScheduledConfig(octokit, 'owner', 'repo'); + expect(calledRef).toBe('main'); + }); + + test('returns null on 404 (config not found)', async () => { + const err = Object.assign(new Error('Not Found'), { status: 404 }); + const octokit = buildOctokit({ getContent: async () => { throw err; } }); + expect(await loadScheduledConfig(octokit, 'owner', 'repo')).toBe(null); + }); + + test('rethrows non-404 errors', async () => { + const err = Object.assign(new Error('Server error'), { status: 500 }); + const octokit = buildOctokit({ getContent: async () => { throw err; } }); + await expect(loadScheduledConfig(octokit, 'owner', 'repo')).rejects.toThrow('Server error'); + }); + + test('rethrows validation errors from upstream', async () => { + const octokit = buildOctokit({ + getContent: async () => contentResponse(yaml.stringify({ tasks: [] })), // missing version + }); + await expect(loadScheduledConfig(octokit, 'owner', 'repo')).rejects.toThrow('Configuration missing required field: version'); + }); +}); + +describe('areScheduledTasksEnabled', () => { + test('returns true when config is found', async () => { + const octokit = buildOctokit({ + getContent: async () => contentResponse(yaml.stringify({ version: 1, tasks: [] })), + }); + expect(await areScheduledTasksEnabled(octokit, 'owner', 'repo')).toBe(true); + }); + + test('returns false when config is not found (404)', async () => { + const err = Object.assign(new Error('Not Found'), { status: 404 }); + const octokit = buildOctokit({ getContent: async () => { throw err; } }); + expect(await areScheduledTasksEnabled(octokit, 'owner', 'repo')).toBe(false); + }); +});