diff --git a/.github/workflows/redline-action-test.yml b/.github/workflows/redline-action-test.yml new file mode 100644 index 0000000..0e41fe3 --- /dev/null +++ b/.github/workflows/redline-action-test.yml @@ -0,0 +1,92 @@ +name: Redline Action self-test + +# Exercises the composite action published from this repository's root +# (uses: JSv4/Python-Redlines@) in both of its modes. The action installs +# python-redlines from PyPI, so this validates the action plumbing rather than +# the working-tree Python packages (ci.yml covers those). + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + explicit-pair: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Redline the test fixtures + id: redline + uses: ./ + with: + original: tests/fixtures/original.docx + modified: tests/fixtures/modified.docx + author: Action Self-Test + # 'auto' exercises the graceful-skip path until a Docx2Html release + # with --track-changes support is on NuGet, then starts rendering. + html-preview: auto + artifact-name: docx-redlines-explicit + + - name: Assert outputs + env: + COUNT: ${{ steps.redline.outputs.count }} + ANY_CHANGES: ${{ steps.redline.outputs.any-changes }} + REDLINES: ${{ steps.redline.outputs.redlines }} + run: | + test "$COUNT" = "1" + test "$ANY_CHANGES" = "true" + echo "$REDLINES" | python3 -c ' + import json, sys + [record] = json.load(sys.stdin) + assert record["status"] == "explicit", record + assert record["revisions"] == 9, record + ' + test -s "redlines/tests/fixtures/modified.redline.docx" + + auto-detect: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create a synthetic .docx change in the local history + run: | + git config user.name "Action Self-Test" + git config user.email "self-test@example.com" + mkdir -p contracts + cp tests/fixtures/original.docx contracts/agreement.docx + git add contracts + git commit -m "add agreement" + cp tests/fixtures/modified.docx contracts/agreement.docx + git commit -am "revise agreement" + + - name: Redline the changed files + id: redline + uses: ./ + with: + base-ref: HEAD~1 + head-ref: HEAD + author: Action Self-Test + html-preview: 'false' + artifact-name: docx-redlines-auto + + - name: Assert outputs + env: + COUNT: ${{ steps.redline.outputs.count }} + REDLINES: ${{ steps.redline.outputs.redlines }} + run: | + test "$COUNT" = "1" + echo "$REDLINES" | python3 -c ' + import json, sys + [record] = json.load(sys.stdin) + assert record["path"] == "contracts/agreement.docx", record + assert record["status"] == "modified", record + assert record["revisions"] == 9, record + ' + test -s "redlines/contracts/agreement.redline.docx" diff --git a/CLAUDE.md b/CLAUDE.md index c5927b2..c0ce19a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,20 @@ prebuilt wheel, so end users never compile anything. The repo root is **not** an installable project — its `pyproject.toml` holds only shared pytest/coverage config. +## GitHub Action + +The repo root also publishes a composite GitHub Action (`uses: JSv4/Python-Redlines@`), +issue #12. `action.yml` wires inputs/artifact-upload; the logic lives in +`action/redline_changed.py` (stdlib-only driver): auto-detects `.docx` files changed +between two commits (PR base/head, push before/after, or explicit `base-ref`/`head-ref`) +or takes an explicit `original`/`modified` pair, runs an engine via pip-installed +python-redlines (PyPI wheels, not the working tree), and optionally renders HTML previews +by invoking the Docxodus `Docx2Html` dotnet tool with `--track-changes` (requires +Docxodus ≥ 7.1.0; `html-preview: auto` skips gracefully below that). Script unit + +integration tests: `tests/test_action_script.py`; action-level self-test: +`.github/workflows/redline-action-test.yml` (runs the action from the checkout in both +modes and asserts on outputs). + ## Commands ```bash diff --git a/README.md b/README.md index 1bfbde9..e0706bd 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,80 @@ not support. > table anchoring). Redline output on the default path can therefore differ from 0.2.1 independently > of this new flag. Diff a representative document if byte-level stability matters to you. +## GitHub Action + +This repository doubles as a GitHub Action, so a repo that versions `.docx` files can get a +redline of every Word document a pull request changes — as a reviewable workflow artifact — +without anyone opening Word ([#12](https://github.com/JSv4/Python-Redlines/issues/12)). + +```yaml +name: DOCX redlines +on: pull_request + +permissions: + contents: read + +jobs: + redline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # both sides of the comparison must be fetchable + - uses: JSv4/Python-Redlines@main +``` + +That's the whole workflow. For every `.docx` file the pull request modifies (or renames), the +action extracts the base and head versions from git, runs the redline engine, and: + +- writes `//.redline.docx` with native Word tracked changes, +- uploads the output directory as a workflow artifact, +- posts a job-summary table (file, revision count, output paths), +- when HTML previews are on, also writes a browser-viewable `.redline.html` per file, rendered + with the [Docxodus](https://github.com/JSv4/Docxodus) `Docx2Html` tool's `--track-changes` + mode so insertions/deletions/moves show as `ins`/`del` markup. + +You can also compare an explicit pair of files instead of auto-detecting: + +```yaml + - uses: JSv4/Python-Redlines@main + with: + original: docs/contract-v1.docx + modified: docs/contract-v2.docx + author: Legal Review +``` + +### Action inputs + +| Input | Default | Purpose | +|---|---|---| +| `original` / `modified` | — | Explicit-pair mode: compare these two files instead of auto-detecting. | +| `files` | `**/*.docx` | Newline-separated git glob pattern(s) selecting which changed files to redline. | +| `base-ref` / `head-ref` | event-derived | Commits to compare. Defaults: PR base (merge-base) → head on `pull_request`, `before` → `after` on `push`, else `HEAD~1` → `HEAD`. | +| `author` | `python-redlines` | Author recorded on the tracked changes. | +| `engine` | `docxodus` | `docxodus` or `xmlpowertools`. | +| `comparison` | engine default | `wmlcomparer` or `docxdiff` (docxodus engine only). | +| `detect-moves` | `false` | Move detection (docxodus engine only). | +| `output-dir` | `redlines` | Where outputs are written (mirrors the source tree). | +| `html-preview` | `auto` | `auto` (render when the Docx2Html tool supports `--track-changes`, else warn and skip), `true` (require), `false` (skip — no .NET needed). | +| `summary` | `true` | Write the job-summary table. | +| `upload-artifact` / `artifact-name` | `true` / `docx-redlines` | Artifact upload controls. | +| `package-version` | latest | pip pin for python-redlines, e.g. `==0.3.0`. | +| `docx2html-version` | latest | NuGet pin for the Docx2Html preview tool. | + +Outputs: `count` (redlines generated), `any-changes`, `redlines` (a JSON array of +`{path, previous_path, status, revisions, redline, html, error}` records), and `artifact-url`. + +Notes: + +- Added and deleted `.docx` files are listed in the summary but not redlined — a redline + needs both a base and a head version. Pure renames report zero revisions without + invoking the engine. +- HTML previews require a `Docx2Html` release with `--track-changes` support (Docxodus + ≥ 7.1.0); until that is on NuGet, the default `auto` mode skips previews with a warning. +- The action installs python-redlines from PyPI with prebuilt engine binaries — it does + not build anything from the repository, so runs are fast on `ubuntu-latest` runners. + ## Comparison Engines Python-Redlines gives you **three ways to compare**, across two engine classes. `DocxodusEngine` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..3a69de0 --- /dev/null +++ b/action.yml @@ -0,0 +1,197 @@ +name: 'DOCX Redlines' +description: >- + Generate Word tracked-changes (redline) documents for .docx files changed in a + pull request or push — or for an explicit pair of files — using python-redlines. + Optionally renders browser-viewable HTML previews of each redline via Docxodus. +author: 'JSv4' +branding: + icon: 'file-text' + color: 'red' + +inputs: + original: + description: >- + Path to the original .docx for explicit-pair mode. Must be set together with + 'modified'. Leave both empty to auto-detect the .docx files changed between + two commits instead. + required: false + default: '' + modified: + description: 'Path to the modified .docx for explicit-pair mode.' + required: false + default: '' + files: + description: >- + Newline-separated glob pattern(s) selecting which changed .docx files to + redline in auto-detect mode (git pathspec globs, case-insensitive). + required: false + default: '**/*.docx' + base-ref: + description: >- + Base commit/ref to compare from in auto-detect mode. Must be set together with + 'head-ref'. Defaults to the PR base (merge-base with the head when computable) + on pull_request events, the pre-push commit on push events, or HEAD~1. + required: false + default: '' + head-ref: + description: 'Head commit/ref to compare to in auto-detect mode.' + required: false + default: '' + author: + description: 'Author name recorded on the generated tracked changes.' + required: false + default: 'python-redlines' + engine: + description: "Redline engine: 'docxodus' or 'xmlpowertools'." + required: false + default: 'docxodus' + comparison: + description: >- + Comparison algorithm for the docxodus engine: 'wmlcomparer' (default) or + 'docxdiff'. + required: false + default: '' + detect-moves: + description: "Enable move detection (docxodus engine only): 'true' or 'false'." + required: false + default: 'false' + output-dir: + description: 'Directory (relative to the workspace) where redlines are written.' + required: false + default: 'redlines' + html-preview: + description: >- + Render an HTML preview of each redline with the Docx2Html dotnet tool: + 'auto' (render when the tool supports it, otherwise warn and skip), + 'true' (require it, fail otherwise), or 'false' (skip; no .NET needed). + required: false + default: 'auto' + summary: + description: "Write a job-summary table of the generated redlines: 'true' or 'false'." + required: false + default: 'true' + upload-artifact: + description: "Upload the output directory as a workflow artifact: 'true' or 'false'." + required: false + default: 'true' + artifact-name: + description: 'Name of the uploaded artifact.' + required: false + default: 'docx-redlines' + package-version: + description: >- + pip version specifier for python-redlines, e.g. '==0.3.0'. Empty installs the + latest release. + required: false + default: '' + docx2html-version: + description: 'NuGet version of the Docx2Html dotnet tool. Empty installs the latest.' + required: false + default: '' + +outputs: + count: + description: 'Number of redline documents generated.' + value: ${{ steps.redline.outputs.count }} + any-changes: + description: >- + 'true' when at least one .docx change matched (including added/deleted files + that cannot be redlined), otherwise 'false'. + value: ${{ steps.redline.outputs.any-changes }} + redlines: + description: >- + JSON array describing each detected change: + [{"path", "previous_path", "status", "revisions", "redline", "html"}, ...]. + value: ${{ steps.redline.outputs.redlines }} + artifact-url: + description: 'URL of the uploaded artifact (empty when nothing was uploaded).' + value: ${{ steps.upload.outputs.artifact-url }} + +runs: + using: 'composite' + steps: + - name: Set up Python + id: python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + update-environment: false + + - name: Install python-redlines + shell: bash + env: + INPUT_ENGINE: ${{ inputs.engine }} + INPUT_PACKAGE_VERSION: ${{ inputs.package-version }} + run: | + case "${INPUT_ENGINE}" in + docxodus) EXTRA="docxodus" ;; + xmlpowertools) EXTRA="ooxmlpowertools" ;; + *) echo "::error::Unknown engine '${INPUT_ENGINE}' (expected 'docxodus' or 'xmlpowertools')"; exit 1 ;; + esac + "${{ steps.python.outputs.python-path }}" -m pip install --quiet "python-redlines[${EXTRA}]${INPUT_PACKAGE_VERSION}" + + - name: Set up .NET for HTML previews + if: ${{ inputs.html-preview != 'false' }} + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Install the Docx2Html preview tool + if: ${{ inputs.html-preview != 'false' }} + shell: bash + env: + INPUT_HTML_PREVIEW: ${{ inputs.html-preview }} + INPUT_DOCX2HTML_VERSION: ${{ inputs.docx2html-version }} + run: | + echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + VERSION_ARGS=() + if [ -n "${INPUT_DOCX2HTML_VERSION}" ]; then + VERSION_ARGS=(--version "${INPUT_DOCX2HTML_VERSION}") + fi + if [ "${INPUT_HTML_PREVIEW}" = "true" ]; then + dotnet tool update --global Docx2Html "${VERSION_ARGS[@]}" + else + dotnet tool update --global Docx2Html "${VERSION_ARGS[@]}" \ + || echo "::warning::Could not install the Docx2Html dotnet tool; HTML previews will be skipped." + fi + + - name: Generate redlines + id: redline + shell: bash + env: + INPUT_ORIGINAL: ${{ inputs.original }} + INPUT_MODIFIED: ${{ inputs.modified }} + INPUT_FILES: ${{ inputs.files }} + INPUT_BASE_REF: ${{ inputs.base-ref }} + INPUT_HEAD_REF: ${{ inputs.head-ref }} + INPUT_AUTHOR: ${{ inputs.author }} + INPUT_ENGINE: ${{ inputs.engine }} + INPUT_COMPARISON: ${{ inputs.comparison }} + INPUT_DETECT_MOVES: ${{ inputs.detect-moves }} + INPUT_OUTPUT_DIR: ${{ inputs.output-dir }} + INPUT_HTML_PREVIEW: ${{ inputs.html-preview }} + INPUT_SUMMARY: ${{ inputs.summary }} + run: | + "${{ steps.python.outputs.python-path }}" "${GITHUB_ACTION_PATH}/action/redline_changed.py" + + - name: Upload redlines artifact + id: upload + if: ${{ inputs.upload-artifact == 'true' && steps.redline.outputs.count != '0' }} + uses: actions/upload-artifact@v7 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.output-dir }} + if-no-files-found: error + + - name: Link the artifact from the job summary + if: ${{ inputs.upload-artifact == 'true' && inputs.summary == 'true' && steps.redline.outputs.count != '0' }} + shell: bash + env: + ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }} + run: | + if [ -n "${ARTIFACT_URL}" ]; then + { + echo "" + echo "📦 [Download the redlines artifact](${ARTIFACT_URL})" + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/action/redline_changed.py b/action/redline_changed.py new file mode 100644 index 0000000..4c71b1a --- /dev/null +++ b/action/redline_changed.py @@ -0,0 +1,484 @@ +"""Driver script for the python-redlines GitHub Action. + +Generates Word tracked-changes ("redline") documents for .docx files, either +for an explicit original/modified pair or for every .docx changed between two +git commits (the PR base and head by default). Optionally converts each +redline to an HTML preview via the Docx2Html dotnet tool. + +The script is intentionally stdlib-only (plus python-redlines itself) and +runs from the repository root ($GITHUB_WORKSPACE). Inputs arrive as INPUT_* +environment variables mapped in action.yml; results are written to +$GITHUB_OUTPUT and $GITHUB_STEP_SUMMARY. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +ZERO_SHA = re.compile(r'^0+$') +REVISION_COUNT = re.compile(r'(?:Revisions found:|Redline complete:)\s*(\d+)') + +ENGINES = ('docxodus', 'xmlpowertools') +HTML_PREVIEW_MODES = ('auto', 'true', 'false') + +# kwargs only DocxodusEngine understands; XmlPowerToolsEngine would silently +# ignore them, so requesting one with engine=xmlpowertools is a config error. +DOCXODUS_ONLY_INPUTS = ('comparison', 'detect-moves') + + +class ConfigError(Exception): + """A bad input combination; reported as ::error:: and exit 1.""" + + +@dataclass +class Change: + """One changed .docx file between the two compared commits.""" + path: str # path in the head commit (or base for deletions) + status: str # 'modified' | 'renamed' | 'added' | 'deleted' | 'explicit' + previous_path: Optional[str] = None # old path for renames + revisions: Optional[int] = None + redline: Optional[str] = None + html: Optional[str] = None + error: Optional[str] = None + + def to_dict(self) -> Dict: + return { + 'path': self.path, + 'previous_path': self.previous_path, + 'status': self.status, + 'revisions': self.revisions, + 'redline': self.redline, + 'html': self.html, + 'error': self.error, + } + + +@dataclass +class Inputs: + original: str = '' + modified: str = '' + files: str = '**/*.docx' + base_ref: str = '' + head_ref: str = '' + author: str = 'python-redlines' + engine: str = 'docxodus' + comparison: str = '' + detect_moves: bool = False + output_dir: str = 'redlines' + html_preview: str = 'auto' + write_summary: bool = True + raw: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_env(cls, env: Dict[str, str]) -> 'Inputs': + def get(name: str, default: str = '') -> str: + return env.get('INPUT_' + name.upper().replace('-', '_'), default).strip() + + def get_bool(name: str, default: bool) -> bool: + value = get(name, 'true' if default else 'false').lower() + if value not in ('true', 'false'): + raise ConfigError(f"Input '{name}' must be 'true' or 'false', got '{value}'") + return value == 'true' + + inputs = cls( + original=get('original'), + modified=get('modified'), + files=get('files') or '**/*.docx', + base_ref=get('base-ref'), + head_ref=get('head-ref'), + author=get('author') or 'python-redlines', + engine=(get('engine') or 'docxodus').lower(), + comparison=get('comparison').lower(), + detect_moves=get_bool('detect-moves', False), + output_dir=get('output-dir') or 'redlines', + html_preview=(get('html-preview') or 'auto').lower(), + write_summary=get_bool('summary', True), + ) + inputs.raw = {k: v for k, v in env.items() if k.startswith('INPUT_')} + inputs.validate() + return inputs + + def validate(self) -> None: + if self.engine not in ENGINES: + raise ConfigError(f"Input 'engine' must be one of {ENGINES}, got '{self.engine}'") + if self.html_preview not in HTML_PREVIEW_MODES: + raise ConfigError( + f"Input 'html-preview' must be one of {HTML_PREVIEW_MODES}, got '{self.html_preview}'") + if bool(self.original) != bool(self.modified): + raise ConfigError( + "Inputs 'original' and 'modified' must be provided together " + "(explicit-pair mode) or both left empty (auto-detect mode).") + if self.engine != 'docxodus': + if self.comparison: + raise ConfigError( + "Input 'comparison' is only supported by the docxodus engine.") + if self.detect_moves: + raise ConfigError( + "Input 'detect-moves' is only supported by the docxodus engine.") + + def engine_kwargs(self) -> Dict: + kwargs: Dict = {} + if self.comparison: + kwargs['engine'] = self.comparison + if self.detect_moves: + kwargs['detect_moves'] = True + return kwargs + + +# -------------------------------------------------------------------------- +# git helpers +# -------------------------------------------------------------------------- + +def run_git(args: List[str], cwd: Optional[str] = None, binary: bool = False): + result = subprocess.run( + ['git'] + args, cwd=cwd, check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.stdout if binary else result.stdout.decode('utf-8', 'replace') + + +def try_git(args: List[str], cwd: Optional[str] = None) -> Optional[str]: + try: + return run_git(args, cwd=cwd).strip() + except subprocess.CalledProcessError: + return None + + +def resolve_commit(ref: str, cwd: Optional[str] = None) -> str: + """Resolve ref to a commit sha, fetching it from origin when absent locally.""" + sha = try_git(['rev-parse', '--verify', '--quiet', ref + '^{commit}'], cwd=cwd) + if sha: + return sha + # PR base/head shas are often absent from a shallow checkout; GitHub allows + # fetching them directly because they are reachable from branch refs. + try_git(['fetch', '--no-tags', '--depth=1', 'origin', ref], cwd=cwd) + sha = try_git(['rev-parse', '--verify', '--quiet', ref + '^{commit}'], cwd=cwd) + if sha: + return sha + raise ConfigError( + f"Could not resolve '{ref}' to a commit. Check out the repository with " + "'fetch-depth: 0' (actions/checkout) so both sides of the comparison are available.") + + +def resolve_refs(inputs: Inputs, env: Dict[str, str], event: Dict, + cwd: Optional[str] = None) -> Tuple[str, str]: + """Decide which two commits to compare. + + Priority: explicit base-ref/head-ref inputs, then the triggering event + (PR base/head, push before/after), then HEAD~1..HEAD. + For PR events the merge-base of base and head is used when computable, so + the redline matches the PR's "files changed" view even if the base branch + has moved on. + """ + event_name = env.get('GITHUB_EVENT_NAME', '') + + if inputs.base_ref or inputs.head_ref: + if not (inputs.base_ref and inputs.head_ref): + raise ConfigError("Inputs 'base-ref' and 'head-ref' must be provided together.") + return (resolve_commit(inputs.base_ref, cwd=cwd), + resolve_commit(inputs.head_ref, cwd=cwd)) + + if event_name in ('pull_request', 'pull_request_target') and 'pull_request' in event: + base = resolve_commit(event['pull_request']['base']['sha'], cwd=cwd) + head = resolve_commit(event['pull_request']['head']['sha'], cwd=cwd) + merge_base = try_git(['merge-base', base, head], cwd=cwd) + if merge_base: + base = merge_base + else: + print(f"::warning::Could not compute the merge-base of {base[:12]} and " + f"{head[:12]} (shallow clone?); comparing against the base branch tip " + "instead. Use 'fetch-depth: 0' on actions/checkout for exact PR semantics.") + return base, head + + if event_name == 'push': + after = event.get('after') or env.get('GITHUB_SHA', 'HEAD') + before = event.get('before', '') + if before and not ZERO_SHA.match(before): + return resolve_commit(before, cwd=cwd), resolve_commit(after, cwd=cwd) + head = resolve_commit(after, cwd=cwd) + parent = try_git(['rev-parse', '--verify', '--quiet', head + '~1'], cwd=cwd) + if parent: + return parent, head + raise ConfigError( + "This push has no previous commit to compare against " + "(new branch or initial commit). Set 'base-ref' explicitly.") + + head = resolve_commit(env.get('GITHUB_SHA') or 'HEAD', cwd=cwd) + parent = try_git(['rev-parse', '--verify', '--quiet', head + '~1'], cwd=cwd) + if not parent: + raise ConfigError( + "HEAD has no parent commit to compare against. Set 'base-ref' explicitly.") + return parent, head + + +def parse_name_status(raw: bytes) -> List[Change]: + """Parse `git diff --name-status -z` output into Change records.""" + fields = [f.decode('utf-8', 'replace') for f in raw.split(b'\0') if f] + changes: List[Change] = [] + i = 0 + while i < len(fields): + status = fields[i] + if status.startswith(('R', 'C')): # rename/copy: score, old path, new path + old, new = fields[i + 1], fields[i + 2] + changes.append(Change(path=new, previous_path=old, status='renamed')) + i += 3 + continue + path = fields[i + 1] + kind = {'M': 'modified', 'A': 'added', 'D': 'deleted', 'T': 'modified'}.get(status[0]) + if kind: + changes.append(Change(path=path, status=kind)) + i += 2 + return changes + + +def detect_changes(base: str, head: str, patterns: List[str], + cwd: Optional[str] = None) -> List[Change]: + pathspecs = [f':(glob,icase){p}' for p in patterns] + raw = run_git(['diff', '--name-status', '-z', '--find-renames', + base, head, '--'] + pathspecs, cwd=cwd, binary=True) + changes = parse_name_status(raw) + return [c for c in changes if c.path.lower().endswith('.docx')] + + +def read_blob(commit: str, path: str, cwd: Optional[str] = None) -> bytes: + return run_git(['cat-file', 'blob', f'{commit}:{path}'], cwd=cwd, binary=True) + + +# -------------------------------------------------------------------------- +# redline + preview generation +# -------------------------------------------------------------------------- + +def _as_text(value) -> str: + if isinstance(value, bytes): + return value.decode('utf-8', 'replace') + return value or '' + + +def make_engine(inputs: Inputs): + from python_redlines.engines import DocxodusEngine, XmlPowerToolsEngine + return DocxodusEngine() if inputs.engine == 'docxodus' else XmlPowerToolsEngine() + + +def revision_count_from_stdout(stdout: Optional[str]) -> Optional[int]: + if not stdout: + return None + match = REVISION_COUNT.search(stdout) + return int(match.group(1)) if match else None + + +def redline_output_paths(output_dir: str, source_path: str) -> Tuple[Path, Path]: + """Mirror the source's relative directory tree under output_dir.""" + rel = Path(source_path) + # An absolute path joined onto output_dir would replace it entirely, and a + # '..' component would escape it. Note is_absolute() alone is not enough on + # Windows, where a rooted-but-driveless path ('/x/y') still hijacks a join. + if rel.is_absolute() or rel.drive or rel.root or '..' in rel.parts: + try: + rel = rel.relative_to(Path.cwd()) + except ValueError: + rel = Path(rel.name) + stem = rel.name[:-len('.docx')] if rel.name.lower().endswith('.docx') else rel.name + base = Path(output_dir) / rel.parent / stem + return Path(str(base) + '.redline.docx'), Path(str(base) + '.redline.html') + + +def find_docx2html() -> Optional[str]: + exe = shutil.which('docx2html') + if not exe: + return None + try: + result = subprocess.run([exe, '--help'], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=120) + help_text = result.stdout.decode('utf-8', 'replace') + except (OSError, subprocess.TimeoutExpired): + return None + return exe if '--track-changes' in help_text else None + + +def resolve_previewer(inputs: Inputs) -> Optional[str]: + if inputs.html_preview == 'false': + return None + exe = find_docx2html() + if exe: + return exe + message = ( + "HTML previews need the Docx2Html dotnet tool with --track-changes support " + "(Docxodus >= 7.1.0) on PATH. Install it with " + "'dotnet tool install --global Docx2Html', or set html-preview: 'false'.") + if inputs.html_preview == 'true': + raise ConfigError(message) + print(f"::warning::Skipping HTML previews. {message}") + return None + + +def generate_preview(previewer: str, redline_path: Path, html_path: Path) -> bool: + result = subprocess.run( + [previewer, str(redline_path), str(html_path), '--track-changes'], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if result.returncode != 0: + output = result.stdout.decode('utf-8', 'replace').strip() + print(f"::warning::docx2html failed for {redline_path}: {output}") + return False + return True + + +def run_redline_pair(engine, inputs: Inputs, change: Change, + original: bytes, modified: bytes, + previewer: Optional[str]) -> None: + if original == modified: + # e.g. a pure rename — nothing to redline. + change.revisions = 0 + return + + redline_path, html_path = redline_output_paths(inputs.output_dir, change.path) + redline_path.parent.mkdir(parents=True, exist_ok=True) + + try: + redline_bytes, stdout, stderr = engine.run_redline( + inputs.author, original, modified, **inputs.engine_kwargs()) + except subprocess.CalledProcessError as exc: + detail = _as_text(exc.stderr) or _as_text(exc.stdout) + change.error = detail.strip() or f'engine exited with code {exc.returncode}' + print(f'::error::Redline generation failed for {change.path}: {change.error}') + return + + if stderr: + print(f"::warning::redline engine stderr for {change.path}: {stderr.strip()}") + + redline_path.write_bytes(redline_bytes) + change.redline = redline_path.as_posix() + change.revisions = revision_count_from_stdout(stdout) + + if previewer and generate_preview(previewer, redline_path, html_path): + change.html = html_path.as_posix() + + +# -------------------------------------------------------------------------- +# reporting +# -------------------------------------------------------------------------- + +STATUS_LABELS = { + 'modified': 'Modified', + 'renamed': 'Renamed', + 'added': 'Added (no base version to compare)', + 'deleted': 'Deleted (no head version to compare)', + 'explicit': 'Explicit pair', +} + + +def build_summary(changes: List[Change], base: Optional[str], head: Optional[str]) -> str: + lines = ['## 📕 DOCX redlines', ''] + if base and head: + lines.append(f'Compared `{base[:12]}` → `{head[:12]}`.') + lines.append('') + if not changes: + lines.append('No changed `.docx` files matched the configured patterns.') + return '\n'.join(lines) + '\n' + + lines.append('| File | Change | Revisions | Redline | HTML preview |') + lines.append('|---|---|---|---|---|') + for c in changes: + name = f'`{c.path}`' + if c.previous_path: + name = f'`{c.previous_path}` → `{c.path}`' + revisions = str(c.revisions) if c.revisions is not None else '—' + redline = f'`{c.redline}`' if c.redline else '—' + if c.error: + redline = '⚠️ failed' + html = f'`{c.html}`' if c.html else '—' + lines.append(f'| {name} | {STATUS_LABELS[c.status]} | {revisions} | {redline} | {html} |') + + if any(c.redline for c in changes): + lines.append('') + lines.append('Redline documents carry native Word tracked changes: open one in ' + 'Word/LibreOffice to review, accept, or reject each edit.') + return '\n'.join(lines) + '\n' + + +def append_to_file(env_var: str, content: str, env: Dict[str, str]) -> None: + path = env.get(env_var) + if not path: + return + with open(path, 'a', encoding='utf-8') as handle: + handle.write(content) + + +def write_outputs(changes: List[Change], env: Dict[str, str]) -> None: + generated = [c for c in changes if c.redline] + payload = json.dumps([c.to_dict() for c in changes], separators=(',', ':')) + content = ( + f'count={len(generated)}\n' + f'any-changes={"true" if changes else "false"}\n' + f'redlines={payload}\n' + ) + append_to_file('GITHUB_OUTPUT', content, env) + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- + +def load_event(env: Dict[str, str]) -> Dict: + path = env.get('GITHUB_EVENT_PATH') + if path and os.path.isfile(path): + with open(path, encoding='utf-8') as handle: + return json.load(handle) + return {} + + +def main(env: Dict[str, str]) -> int: + inputs = Inputs.from_env(env) + previewer = resolve_previewer(inputs) + base = head = None + + if inputs.original: # explicit-pair mode + for label, candidate in (('original', inputs.original), ('modified', inputs.modified)): + if not os.path.isfile(candidate): + raise ConfigError(f"Input '{label}' file not found: {candidate}") + changes = [Change(path=inputs.modified, previous_path=inputs.original, status='explicit')] + engine = make_engine(inputs) + run_redline_pair(engine, inputs, changes[0], + Path(inputs.original).read_bytes(), + Path(inputs.modified).read_bytes(), + previewer) + else: # auto-detect mode + event = load_event(env) + base, head = resolve_refs(inputs, env, event) + patterns = [line.strip() for line in inputs.files.splitlines() if line.strip()] + changes = detect_changes(base, head, patterns) + comparable = [c for c in changes if c.status in ('modified', 'renamed')] + print(f'Found {len(changes)} changed .docx file(s) between ' + f'{base[:12]} and {head[:12]}; {len(comparable)} comparable.') + if comparable: + engine = make_engine(inputs) + for change in comparable: + original = read_blob(base, change.previous_path or change.path) + modified = read_blob(head, change.path) + run_redline_pair(engine, inputs, change, original, modified, previewer) + print(f' {change.path}: {change.revisions} revision(s) ' + f'-> {change.redline}') + + write_outputs(changes, env) + if inputs.write_summary: + append_to_file('GITHUB_STEP_SUMMARY', build_summary(changes, base, head), env) + + failed = [c for c in changes if c.error] + if failed: + print(f'::error::Redline generation failed for {len(failed)} file(s).') + return 1 + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main(dict(os.environ))) + except ConfigError as error: + print(f'::error::{error}') + sys.exit(1) diff --git a/tests/test_action_script.py b/tests/test_action_script.py new file mode 100644 index 0000000..4ac2ad9 --- /dev/null +++ b/tests/test_action_script.py @@ -0,0 +1,304 @@ +"""Tests for the GitHub Action driver script (action/redline_changed.py). + +Most tests exercise the pure/git-only helpers and need no engine binaries; +the two integration tests at the bottom run the real DocxodusEngine against +the repo fixtures, matching the requirements of the other test modules. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / 'action')) + +import redline_changed as ra # noqa: E402 + +FIXTURES = REPO_ROOT / 'tests' / 'fixtures' + + +def git(repo, *args, check=True): + return subprocess.run(['git', '-C', str(repo)] + list(args), check=check, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip() + + +@pytest.fixture +def repo(tmp_path): + path = tmp_path / 'repo' + path.mkdir() + git(path, 'init', '-q') + git(path, 'config', 'user.email', 'test@example.com') + git(path, 'config', 'user.name', 'Test') + return path + + +def commit_file(repo, rel, data: bytes, message='commit'): + target = repo / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + git(repo, 'add', '-A') + git(repo, 'commit', '-q', '-m', message) + return git(repo, 'rev-parse', 'HEAD') + + +# --------------------------------------------------------------------------- +# input parsing / validation +# --------------------------------------------------------------------------- + +def test_inputs_defaults(): + inputs = ra.Inputs.from_env({}) + assert inputs.engine == 'docxodus' + assert inputs.files == '**/*.docx' + assert inputs.output_dir == 'redlines' + assert inputs.html_preview == 'auto' + assert inputs.write_summary is True + assert inputs.engine_kwargs() == {} + + +def test_inputs_engine_kwargs(): + inputs = ra.Inputs.from_env({ + 'INPUT_COMPARISON': 'docxdiff', + 'INPUT_DETECT_MOVES': 'true', + }) + assert inputs.engine_kwargs() == {'engine': 'docxdiff', 'detect_moves': True} + + +@pytest.mark.parametrize('env', [ + {'INPUT_ENGINE': 'wordperfect'}, + {'INPUT_HTML_PREVIEW': 'maybe'}, + {'INPUT_ORIGINAL': 'a.docx'}, # original without modified + {'INPUT_ENGINE': 'xmlpowertools', 'INPUT_COMPARISON': 'docxdiff'}, + {'INPUT_ENGINE': 'xmlpowertools', 'INPUT_DETECT_MOVES': 'true'}, + {'INPUT_DETECT_MOVES': 'yes'}, # not a bool +]) +def test_inputs_rejects_bad_combinations(env): + with pytest.raises(ra.ConfigError): + ra.Inputs.from_env(env) + + +# --------------------------------------------------------------------------- +# small pure helpers +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('stdout,expected', [ + ('Revisions found: 9', 9), + ('Redline complete: 11 revision(s) found', 11), + ('nothing to see here', None), + (None, None), +]) +def test_revision_count_from_stdout(stdout, expected): + assert ra.revision_count_from_stdout(stdout) == expected + + +def test_parse_name_status_handles_modifications_and_renames(): + raw = b'M\0docs/contract.docx\0R097\0old name.docx\0new name.docx\0A\0added.docx\0D\0gone.docx\0' + changes = ra.parse_name_status(raw) + assert [(c.status, c.path, c.previous_path) for c in changes] == [ + ('modified', 'docs/contract.docx', None), + ('renamed', 'new name.docx', 'old name.docx'), + ('added', 'added.docx', None), + ('deleted', 'gone.docx', None), + ] + + +def test_redline_output_paths_mirror_source_tree(): + docx, html = ra.redline_output_paths('redlines', 'docs/deals/Contract.DOCX') + assert docx.as_posix() == 'redlines/docs/deals/Contract.redline.docx' + assert html.as_posix() == 'redlines/docs/deals/Contract.redline.html' + + +def test_redline_output_paths_absolute_source_stays_under_output_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + inside = tmp_path / 'docs' / 'a.docx' + docx, _ = ra.redline_output_paths('out', str(inside)) + assert docx.as_posix() == 'out/docs/a.redline.docx' + outside = '/somewhere/else/entirely/b.docx' + docx, _ = ra.redline_output_paths('out', outside) + assert docx.as_posix() == 'out/b.redline.docx' + traversal = '../outside/c.docx' + docx, _ = ra.redline_output_paths('out', traversal) + assert docx.as_posix() == 'out/c.redline.docx' + + +def test_build_summary_lists_each_change(): + changes = [ + ra.Change(path='a.docx', status='modified', revisions=3, + redline='redlines/a.redline.docx'), + ra.Change(path='b.docx', status='added'), + ra.Change(path='c.docx', status='modified', error='boom'), + ] + summary = ra.build_summary(changes, 'a' * 40, 'b' * 40) + assert '| `a.docx` | Modified | 3 | `redlines/a.redline.docx` | — |' in summary + assert 'Added (no base version to compare)' in summary + assert '⚠️ failed' in summary + + +def test_build_summary_no_changes(): + summary = ra.build_summary([], None, None) + assert 'No changed `.docx` files' in summary + + +def test_write_outputs(tmp_path): + out = tmp_path / 'out.txt' + changes = [ra.Change(path='a.docx', status='modified', revisions=2, + redline='redlines/a.redline.docx')] + ra.write_outputs(changes, {'GITHUB_OUTPUT': str(out)}) + text = out.read_text() + assert 'count=1\n' in text + assert 'any-changes=true\n' in text + payload = [line for line in text.splitlines() if line.startswith('redlines=')][0] + parsed = json.loads(payload[len('redlines='):]) + assert parsed[0]['path'] == 'a.docx' + assert parsed[0]['revisions'] == 2 + + +# --------------------------------------------------------------------------- +# git plumbing against a real temporary repository +# --------------------------------------------------------------------------- + +def test_detect_changes_and_read_blob(repo): + base = commit_file(repo, 'docs/contract.docx', b'original bytes') + (repo / 'notes.txt').write_text('irrelevant') + head = commit_file(repo, 'docs/contract.docx', b'modified bytes') + + changes = ra.detect_changes(base, head, ['**/*.docx'], cwd=str(repo)) + assert [(c.status, c.path) for c in changes] == [('modified', 'docs/contract.docx')] + assert ra.read_blob(base, 'docs/contract.docx', cwd=str(repo)) == b'original bytes' + assert ra.read_blob(head, 'docs/contract.docx', cwd=str(repo)) == b'modified bytes' + + +def test_detect_changes_pure_rename(repo): + base = commit_file(repo, 'a.docx', b'same bytes either way') + git(repo, 'mv', 'a.docx', 'b.docx') + git(repo, 'commit', '-q', '-m', 'rename') + head = git(repo, 'rev-parse', 'HEAD') + + changes = ra.detect_changes(base, head, ['**/*.docx'], cwd=str(repo)) + assert [(c.status, c.path, c.previous_path) for c in changes] == [ + ('renamed', 'b.docx', 'a.docx')] + + +def test_detect_changes_respects_patterns_and_extension(repo): + base = commit_file(repo, 'docs/in-scope.docx', b'v1') + (repo / 'other.docx').write_bytes(b'v1') + (repo / 'docs' / 'readme.txt').write_text('v1') + git(repo, 'add', '-A') + git(repo, 'commit', '-q', '-m', 'more files') + (repo / 'docs' / 'in-scope.docx').write_bytes(b'v2') + (repo / 'other.docx').write_bytes(b'v2') + (repo / 'docs' / 'readme.txt').write_text('v2') + git(repo, 'add', '-A') + head = commit_file(repo, 'docs/in-scope.docx', b'v3') + + changes = ra.detect_changes(base, head, ['docs/*.docx'], cwd=str(repo)) + assert [c.path for c in changes] == ['docs/in-scope.docx'] + + +def test_resolve_refs_push_event(repo): + base = commit_file(repo, 'a.docx', b'v1') + head = commit_file(repo, 'a.docx', b'v2') + inputs = ra.Inputs.from_env({}) + resolved = ra.resolve_refs( + inputs, {'GITHUB_EVENT_NAME': 'push'}, + {'before': base, 'after': head}, cwd=str(repo)) + assert resolved == (base, head) + + +def test_resolve_refs_push_event_new_branch_uses_parent(repo): + base = commit_file(repo, 'a.docx', b'v1') + head = commit_file(repo, 'a.docx', b'v2') + inputs = ra.Inputs.from_env({}) + resolved = ra.resolve_refs( + inputs, {'GITHUB_EVENT_NAME': 'push'}, + {'before': '0' * 40, 'after': head}, cwd=str(repo)) + assert resolved == (base, head) + + +def test_resolve_refs_pull_request_uses_merge_base(repo): + fork_point = commit_file(repo, 'a.docx', b'v1') + default_branch = git(repo, 'rev-parse', '--abbrev-ref', 'HEAD') + git(repo, 'checkout', '-q', '-b', 'feature') + pr_head = commit_file(repo, 'a.docx', b'feature edit') + git(repo, 'checkout', '-q', default_branch) + base_tip = commit_file(repo, 'unrelated.txt', b'base moved on') + + inputs = ra.Inputs.from_env({}) + event = {'pull_request': {'base': {'sha': base_tip}, 'head': {'sha': pr_head}}} + resolved = ra.resolve_refs( + inputs, {'GITHUB_EVENT_NAME': 'pull_request'}, event, cwd=str(repo)) + assert resolved == (fork_point, pr_head) + + +def test_resolve_refs_explicit_inputs_win(repo): + base = commit_file(repo, 'a.docx', b'v1') + head = commit_file(repo, 'a.docx', b'v2') + inputs = ra.Inputs.from_env({'INPUT_BASE_REF': 'HEAD~1', 'INPUT_HEAD_REF': 'HEAD'}) + resolved = ra.resolve_refs(inputs, {'GITHUB_EVENT_NAME': 'push'}, {}, cwd=str(repo)) + assert resolved == (base, head) + + +def test_resolve_refs_unresolvable_ref_raises(repo): + commit_file(repo, 'a.docx', b'v1') + inputs = ra.Inputs.from_env({'INPUT_BASE_REF': 'no-such-ref', 'INPUT_HEAD_REF': 'HEAD'}) + with pytest.raises(ra.ConfigError): + ra.resolve_refs(inputs, {}, {}, cwd=str(repo)) + + +# --------------------------------------------------------------------------- +# integration: run main() with the real engine over the repo fixtures +# --------------------------------------------------------------------------- + +def test_main_explicit_pair(tmp_path, monkeypatch): + monkeypatch.chdir(REPO_ROOT) + out_file, summary_file = tmp_path / 'out.txt', tmp_path / 'summary.md' + env = { + 'INPUT_ORIGINAL': str(FIXTURES / 'original.docx'), + 'INPUT_MODIFIED': str(FIXTURES / 'modified.docx'), + 'INPUT_OUTPUT_DIR': str(tmp_path / 'redlines'), + 'INPUT_HTML_PREVIEW': 'false', + 'GITHUB_OUTPUT': str(out_file), + 'GITHUB_STEP_SUMMARY': str(summary_file), + } + assert ra.main(env) == 0 + + text = out_file.read_text() + assert 'count=1\n' in text + payload = [line for line in text.splitlines() if line.startswith('redlines=')][0] + record = json.loads(payload[len('redlines='):])[0] + assert record['revisions'] == 9 + redline = Path(record['redline']) + assert redline.is_file() and redline.stat().st_size > 0 + # absolute source paths must not escape the requested output directory + assert (tmp_path / 'redlines') in redline.resolve().parents + assert 'DOCX redlines' in summary_file.read_text() + + +def test_main_auto_detect_over_git_history(repo, tmp_path, monkeypatch): + base = commit_file(repo, 'contracts/agreement.docx', + (FIXTURES / 'original.docx').read_bytes()) + head = commit_file(repo, 'contracts/agreement.docx', + (FIXTURES / 'modified.docx').read_bytes()) + monkeypatch.chdir(repo) + + out_file, summary_file = tmp_path / 'out.txt', tmp_path / 'summary.md' + env = { + 'INPUT_BASE_REF': base, + 'INPUT_HEAD_REF': head, + 'INPUT_OUTPUT_DIR': str(tmp_path / 'redlines'), + 'INPUT_HTML_PREVIEW': 'false', + 'GITHUB_OUTPUT': str(out_file), + 'GITHUB_STEP_SUMMARY': str(summary_file), + } + assert ra.main(env) == 0 + + payload = [line for line in out_file.read_text().splitlines() + if line.startswith('redlines=')][0] + record = json.loads(payload[len('redlines='):])[0] + assert record['path'] == 'contracts/agreement.docx' + assert record['status'] == 'modified' + assert record['revisions'] == 9 + assert Path(record['redline']).is_file() + assert 'contracts/agreement.docx' in summary_file.read_text()