From 6a129434c04cca0352072678e1862377a65f703a Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:35:20 +0300 Subject: [PATCH 1/3] refactor(config): move non-secret settings from secrets to action inputs DOCS_SUBFOLDER, DOCS_BASE_BRANCH, and MAX_CONTEXT_CHARS were documented as repository secrets despite not being secret. GitHub masks secret values in logs, so a misconfigured subfolder prints as *** which actively obstructs debugging. These were already first-class action.yml inputs; the fix is in the README: move them from the secrets table to the inputs table, update the workflow example to use literal values, and add a migration note. --- README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 60a7e46..220924f 100644 --- a/README.md +++ b/README.md @@ -160,14 +160,14 @@ jobs: pr-number: ${{ github.event.issue.number }} pr-base: origin/${{ steps.pr_info.outputs.base_ref || 'main' }} pr-head-sha: ${{ steps.pr_info.outputs.head_ref }} - docs-subfolder: ${{ secrets.DOCS_SUBFOLDER }} + docs-subfolder: 'docs' # Optional: path to docs within the same repo comment-body: ${{ github.event.comment.body }} - docs-base-branch: ${{ secrets.DOCS_BASE_BRANCH || 'main' }} + docs-base-branch: 'main' # Optional: base branch for docs PRs jira-url: ${{ secrets.JIRA_URL }} jira-username: ${{ secrets.JIRA_USERNAME }} jira-api-token: ${{ secrets.JIRA_API_TOKEN }} google-sa-key: ${{ secrets.GOOGLE_SA_KEY }} - max-context-chars: ${{ secrets.MAX_CONTEXT_CHARS }} + max-context-chars: '400000' # Optional: decrease for small-context models style-config-path: '.code-to-docs/style.md' ``` @@ -181,22 +181,24 @@ Add these in **Settings → Secrets → Actions**: | `MODEL_API_KEY` | API key for the model endpoint (leave empty if not required) | | `MODEL_NAME` | Model name to use (e.g., `meta-llama/Llama-3.1-8B-Instruct`, `gemini-2.0-flash`) | | `DOCS_REPO_URL` | Docs repository URL (e.g., `https://github.com/org/docs`) | -| `GH_PAT` | _(Optional)_ GitHub PAT with `repo` scope. Only needed for **separate docs repos** (`docs-repo-url` pointing to a different repo). For same-repo setups, the built-in `GITHUB_TOKEN` works — no PAT required. | -| `DOCS_SUBFOLDER` | _(Optional)_ Docs subfolder path (e.g., `docs`) | -| `DOCS_BASE_BRANCH` | _(Optional)_ Base branch for docs PRs (default: `main`) | +| `GH_PAT` | _(Optional)_ GitHub PAT with `repo` scope. Only needed for **separate docs repos** (`docs-repo-url` pointing to a different repo). For same-repo setups, the built-in `GITHUB_TOKEN` works. | | `JIRA_URL` | _(Optional, for `[review-feature]`)_ Jira instance URL (e.g., `https://your-company.atlassian.net`) | | `JIRA_USERNAME` | _(Optional, for `[review-feature]`)_ Jira username/email | | `JIRA_API_TOKEN` | _(Optional, for `[review-feature]`)_ Jira API token ([create here](https://id.atlassian.com/manage-profile/security/api-tokens)) | | `GOOGLE_SA_KEY` | _(Optional, for `[review-feature]`)_ Google service account JSON key for fetching Google Docs. Docs must be shared with the service account email. | -| `MAX_CONTEXT_CHARS` | _(Optional)_ Maximum characters for LLM prompt content (default: `400000`, ~100K tokens). Decrease for models with smaller context windows (e.g., `32000` for an 8K-token model). | ### 3. Optional Action Inputs These are set as `with:` parameters in the workflow step (not as secrets): -| Input | Description | -|-------|-------------| -| `style-config-path` | _(Optional)_ Path to a Markdown style configuration file (`.md`) containing documentation style guidelines. If not set, auto-detects `.code-to-docs/style.md`. | +| Input | Default | Description | +|-------|---------|-------------| +| `docs-subfolder` | _(empty)_ | Relative path to docs subfolder within the same repo (e.g., `docs`) | +| `docs-base-branch` | `main` | Base branch for docs repository PRs | +| `max-context-chars` | `400000` | Maximum characters for LLM prompt content (~100K tokens). Decrease for models with smaller context windows. | +| `style-config-path` | _(auto-detect)_ | Path to a Markdown style configuration file. If not set, auto-detects `.code-to-docs/style.md`. | + +> **Migration note:** `DOCS_SUBFOLDER`, `DOCS_BASE_BRANCH`, and `MAX_CONTEXT_CHARS` were previously documented as repository secrets. They are not secret values and should be set as action inputs instead. GitHub masks secret values in logs, which obstructs debugging when these are misconfigured. The action still reads from environment variables as a fallback. ### Supported Model Backends From 39bd5900da29987d5eddcbdb373402bbeea1b415 Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:37:31 +0300 Subject: [PATCH 2/3] feat(config): add .code-to-docs/ignore exclusion list There was no way to keep the tool away from hand-crafted landing pages, generated API reference, or vendored docs. Users need this before pointing the tool at a real docs tree. Add .code-to-docs/ignore with gitignore-style glob syntax, loaded from the base branch. Filter at file-selection time in both full-scan and index-based discovery paths, before doc content is read. Excluded files never reach an LLM prompt. --- README.md | 17 ++++++++++++++ src/config.py | 45 ++++++++++++++++++++++++++++++++++++ src/discovery.py | 19 +++++++++++++++ tests/test_ignore.py | 55 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 tests/test_ignore.py diff --git a/README.md b/README.md index 220924f..ede82ea 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,23 @@ With this config, generated PRs will be titled `:book: docs: update documentatio This file is optional — if missing, the tool uses default titles with no prefix. +## Excluding Files + +You can exclude files from AI analysis by creating `.code-to-docs/ignore` in your repository root. This file uses gitignore-style glob patterns, one per line: + +``` +# Generated API reference (managed by a separate tool) +generated/* + +# Landing page (hand-crafted, should not be auto-updated) +docs/index.md + +# Vendored docs +vendor/**/*.md +``` + +Excluded files are never selected, never read into an LLM prompt, and are omitted from index-based selection results. The file is loaded from the base branch, consistent with other `.code-to-docs/` configuration. + ## How It Works 1. **Triggered by PR Comments** - When someone comments `[review-docs]`, `[update-docs]`, or `[review-feature]` on a Pull Request diff --git a/src/config.py b/src/config.py index 423166d..6464fd3 100644 --- a/src/config.py +++ b/src/config.py @@ -259,6 +259,51 @@ def get_pr_title_prefix(): return f"{prefix} " if prefix else "" +# ============================================================================= +# IGNORE LIST +# ============================================================================= + +_IGNORE_FILE = ".code-to-docs/ignore" + + +def load_ignore_patterns(): + """Load gitignore-style exclusion patterns from the base branch. + + Returns a list of pattern strings. Empty list if the file is absent. + """ + base_branch = os.environ.get("DOCS_BASE_BRANCH") or "main" + try: + result = run_command_safe( + ["git", "show", f"origin/{base_branch}:{_IGNORE_FILE}"], + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return [] + lines = result.stdout.strip().splitlines() + patterns = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")] + if patterns: + print(f"Loaded {len(patterns)} ignore pattern(s) from {base_branch}:{_IGNORE_FILE}") + return patterns + except Exception as e: + print(f"Warning: Could not load ignore patterns: {sanitize_output(str(e))}") + return [] + + +def is_path_ignored(path, patterns): + """Check whether a file path matches any gitignore-style pattern.""" + if not patterns: + return False + from fnmatch import fnmatch + + path_str = str(path) + for pattern in patterns: + if fnmatch(path_str, pattern) or fnmatch(path_str, f"**/{pattern}"): + return True + if "/" in pattern and fnmatch(path_str, pattern): + return True + return False + + def check_context_error(e): """ If e is a context-window error, print actionable guidance. diff --git a/src/discovery.py b/src/discovery.py index 0fc3cba..aab56d0 100644 --- a/src/discovery.py +++ b/src/discovery.py @@ -18,6 +18,8 @@ get_client, get_max_context_chars, get_model_name, + is_path_ignored, + load_ignore_patterns, truncate_content, ) @@ -101,6 +103,15 @@ def get_file_content_or_summaries(line_threshold=300): # Filter out internal index files (.doc-index/) - these are for internal use only doc_files = [f for f in doc_files if ".doc-index" not in str(f)] + # Filter out files matching .code-to-docs/ignore patterns + ignore_patterns = load_ignore_patterns() + if ignore_patterns: + before = len(doc_files) + doc_files = [f for f in doc_files if not is_path_ignored(f, ignore_patterns)] + ignored = before - len(doc_files) + if ignored: + print(f"Excluded {ignored} file(s) via .code-to-docs/ignore") + # Deduplicate file paths BEFORE processing to avoid duplicate work seen_paths = set() unique_doc_files = [] @@ -366,4 +377,12 @@ def find_relevant_files_optimized(diff): print("Falling back to full scan...") return None + ignore_patterns = load_ignore_patterns() + if ignore_patterns and relevant_files: + before = len(relevant_files) + relevant_files = [f for f in relevant_files if not is_path_ignored(f, ignore_patterns)] + ignored = before - len(relevant_files) + if ignored: + print(f"Excluded {ignored} file(s) via .code-to-docs/ignore") + return relevant_files diff --git a/tests/test_ignore.py b/tests/test_ignore.py new file mode 100644 index 0000000..2db46e2 --- /dev/null +++ b/tests/test_ignore.py @@ -0,0 +1,55 @@ +"""Tests for .code-to-docs/ignore exclusion list.""" + +from unittest.mock import MagicMock, patch + +from config import is_path_ignored, load_ignore_patterns + + +class TestIsPathIgnored: + def test_no_patterns_returns_false(self): + assert is_path_ignored("docs/guide.md", []) is False + + def test_exact_match(self): + assert is_path_ignored("README.md", ["README.md"]) is True + + def test_glob_match(self): + assert is_path_ignored("docs/api-ref.md", ["docs/api-*.md"]) is True + + def test_no_match(self): + assert is_path_ignored("docs/guide.md", ["docs/api-*.md"]) is False + + def test_directory_glob(self): + assert is_path_ignored("generated/openapi/ref.md", ["generated/*"]) is True + + def test_bare_filename_matches_anywhere(self): + assert is_path_ignored("deep/nested/CHANGELOG.md", ["CHANGELOG.md"]) is True + + def test_multiple_patterns(self): + patterns = ["CHANGELOG.md", "generated/*", "*.bak"] + assert is_path_ignored("docs/old.bak", patterns) is True + assert is_path_ignored("docs/guide.md", patterns) is False + + +class TestLoadIgnorePatterns: + def test_loads_patterns_from_branch(self): + result = MagicMock(returncode=0, stdout="# comment\ngenerated/*\nREADME.md\n\n") + with patch("config.run_command_safe", return_value=result): + patterns = load_ignore_patterns() + assert patterns == ["generated/*", "README.md"] + + def test_returns_empty_when_file_missing(self): + result = MagicMock(returncode=1, stdout="") + with patch("config.run_command_safe", return_value=result): + patterns = load_ignore_patterns() + assert patterns == [] + + def test_returns_empty_on_error(self): + with patch("config.run_command_safe", side_effect=RuntimeError("git failed")): + patterns = load_ignore_patterns() + assert patterns == [] + + def test_skips_comments_and_blanks(self): + result = MagicMock(returncode=0, stdout="# skip this\n\n \nkeep-this.md\n") + with patch("config.run_command_safe", return_value=result): + patterns = load_ignore_patterns() + assert patterns == ["keep-this.md"] From 11a78a533fbe051ed95ad8b7d73b10aa9d946d9a Mon Sep 17 00:00:00 2001 From: Benjamin Kapner Date: Mon, 17 Aug 2026 08:40:09 +0300 Subject: [PATCH 3/3] feat(config): make post-generation validation thresholds configurable _REMOVAL_THRESHOLD is a module constant governing a heuristic whose correct value varies by repo and doc style. Add validation.removal-threshold, validation.min-lines, and validation.llm-verification to .code-to-docs/config.json. Thread as parameters into the generation functions rather than reading config inside them, keeping them testable in isolation. Invalid values fall back to defaults with a warning. The validation logic itself is in PR #53; this commit adds the config plumbing. --- README.md | 14 +++++++---- src/config.py | 49 +++++++++++++++++++++++++++++++++++++++ src/generation.py | 2 ++ src/suggest_docs.py | 5 ++++ tests/test_repo_config.py | 32 +++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ede82ea..1e63853 100644 --- a/README.md +++ b/README.md @@ -63,17 +63,23 @@ You can configure code-to-docs behavior with a JSON config file in your reposito | Key | Description | Example | |-----|-------------|---------| | `pr-title-prefix` | Prefix prepended to all PR titles and commit messages created by the tool | `":book:"` | +| `validation.removal-threshold` | Fraction of original lines that can be removed before flagging (0.0 to 1.0) | `0.30` | +| `validation.min-lines` | Files shorter than this skip the ratio-based preservation check | `20` | +| `validation.llm-verification` | Set to `false` to skip the independent LLM verification call | `false` | **Example `.code-to-docs/config.json`:** ```json { - "pr-title-prefix": ":book:" + "pr-title-prefix": ":book:", + "validation": { + "removal-threshold": 0.30, + "min-lines": 20, + "llm-verification": true + } } ``` -With this config, generated PRs will be titled `:book: docs: update documentation from PR #123` instead of `docs: update documentation from PR #123`. - -This file is optional — if missing, the tool uses default titles with no prefix. +This file is optional. Missing or invalid values fall back to defaults with a warning. ## Excluding Files diff --git a/src/config.py b/src/config.py index 6464fd3..3499030 100644 --- a/src/config.py +++ b/src/config.py @@ -259,6 +259,55 @@ def get_pr_title_prefix(): return f"{prefix} " if prefix else "" +_VALIDATION_DEFAULTS = { + "removal-threshold": 0.20, + "min-lines": 30, + "llm-verification": True, +} + + +def get_validation_config(repo_config=None): + """Extract and validate the validation settings from repo config. + + Returns a dict with keys: removal_threshold, min_lines, llm_verification. + """ + defaults = _VALIDATION_DEFAULTS + if repo_config is None: + repo_config = load_repo_config() + + v = repo_config.get("validation", {}) + if not isinstance(v, dict): + print("Warning: validation config is not an object, using defaults") + v = {} + + threshold = v.get("removal-threshold", defaults["removal-threshold"]) + if not isinstance(threshold, (int, float)) or not (0.0 <= threshold <= 1.0): + print( + f"Warning: invalid removal-threshold {threshold!r}, " + f"using default {defaults['removal-threshold']}" + ) + threshold = defaults["removal-threshold"] + + min_lines = v.get("min-lines", defaults["min-lines"]) + if not isinstance(min_lines, int) or min_lines < 0: + print(f"Warning: invalid min-lines {min_lines!r}, using default {defaults['min-lines']}") + min_lines = defaults["min-lines"] + + llm_verify = v.get("llm-verification", defaults["llm-verification"]) + if not isinstance(llm_verify, bool): + print( + f"Warning: invalid llm-verification {llm_verify!r}, " + f"using default {defaults['llm-verification']}" + ) + llm_verify = defaults["llm-verification"] + + return { + "removal_threshold": float(threshold), + "min_lines": int(min_lines), + "llm_verification": llm_verify, + } + + # ============================================================================= # IGNORE LIST # ============================================================================= diff --git a/src/generation.py b/src/generation.py index 26f8ba4..8af721b 100644 --- a/src/generation.py +++ b/src/generation.py @@ -157,6 +157,7 @@ def generate_updates_parallel( file_instructions=None, style_guidelines="", pr_description="", + validation_config=None, ): """ Generate documentation updates in parallel. @@ -238,6 +239,7 @@ def ask_ai_for_updated_content( current_content, user_instructions="", file_instructions=None, + validation_config=None, style_guidelines="", pr_description="", ): diff --git a/src/suggest_docs.py b/src/suggest_docs.py index 805f09d..faaf286 100644 --- a/src/suggest_docs.py +++ b/src/suggest_docs.py @@ -33,6 +33,7 @@ get_max_context_chars, get_model_name, get_pr_title_prefix, + get_validation_config, load_style_config_from_branch, ) from discovery import ( @@ -248,6 +249,8 @@ def main(): # uses the repo's current style config, even if the PR branch predates it. style_guidelines = load_style_config_from_branch() + validation_config = get_validation_config() + # Handle --build-index mode if args.build_index: print("Building documentation indexes...") @@ -542,6 +545,7 @@ def main(): file_instructions=file_instructions, style_guidelines=style_guidelines, pr_description=pr_description, + validation_config=validation_config, ) for file_path, _current, updated in files_with_content: @@ -566,6 +570,7 @@ def main(): file_instructions=file_instructions, style_guidelines=style_guidelines, pr_description=pr_description, + validation_config=validation_config, ) if updated.strip() == "NO_UPDATE_NEEDED": diff --git a/tests/test_repo_config.py b/tests/test_repo_config.py index a9734e2..604d1fb 100644 --- a/tests/test_repo_config.py +++ b/tests/test_repo_config.py @@ -96,3 +96,35 @@ def test_non_string_prefix_returns_empty(self): def test_list_prefix_returns_empty(self): config._repo_config_cache = {"pr-title-prefix": [":book:"]} assert config.get_pr_title_prefix() == "" + + +class TestGetValidationConfig: + def test_defaults_when_no_config(self): + vc = config.get_validation_config({}) + assert vc["removal_threshold"] == 0.20 + assert vc["min_lines"] == 30 + assert vc["llm_verification"] is True + + def test_overrides_threshold(self): + vc = config.get_validation_config({"validation": {"removal-threshold": 0.50}}) + assert vc["removal_threshold"] == 0.50 + + def test_overrides_min_lines(self): + vc = config.get_validation_config({"validation": {"min-lines": 10}}) + assert vc["min_lines"] == 10 + + def test_disables_llm_verification(self): + vc = config.get_validation_config({"validation": {"llm-verification": False}}) + assert vc["llm_verification"] is False + + def test_invalid_threshold_falls_back(self): + vc = config.get_validation_config({"validation": {"removal-threshold": "bad"}}) + assert vc["removal_threshold"] == 0.20 + + def test_threshold_out_of_range_falls_back(self): + vc = config.get_validation_config({"validation": {"removal-threshold": 1.5}}) + assert vc["removal_threshold"] == 0.20 + + def test_non_dict_validation_falls_back(self): + vc = config.get_validation_config({"validation": "not a dict"}) + assert vc["llm_verification"] is True