diff --git a/README.md b/README.md index 2cc0221..f087220 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,27 @@ Caching is best-effort: a cache miss, an unavailable cache service, or a GitHub Actions cache entries are scoped to the ref that wrote them, and GitHub gives comment-triggered runs a read-only cache token. So automatic `pull_request` runs reuse each other's analysis and build the chain, `sync` publishes the base entry everyone shares, and a `/codeboarding` command reads both but writes neither — it costs one base-seeded incremental, and `/codeboarding refresh` improves the comment it posts rather than what later runs start from. The action also accepts `pull_request_target`, which runs on the base branch ref and lets both share one chain; that trigger has its own trade-offs (a PR that adds this workflow will not run it until merged, and the fork gate becomes load-bearing), so `pull_request` remains the recommended default. +### Analyzing without commenting + +`post_comment: false` runs the analysis and uploads the artifact, but writes nothing to the pull request — no progress placeholder, no result, no failure notice. The webview still has everything it reads, so this is the setting for keeping a PR's analysis current without a bot comment on it. A `/codeboarding` command still gets its 👀 reaction, since with posting off that is the only sign it was picked up. + +The action's outputs are unaffected, so a workflow can decide for itself what to say and when: + +```yaml + - uses: CodeBoarding/CodeBoarding-action@v1 + id: codeboarding + with: + post_comment: false + - if: steps.codeboarding.outputs.n_changed != '0' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: codeboarding-review + number: ${{ github.event.pull_request.number }} + path: ${{ steps.codeboarding.outputs.diagram_md }} +``` + +That example only comments when the architecture actually changed, which suits per-push reviews: most pushes change no components, and the comment stays put instead of being rewritten with the same content. + ## Authentication and providers With no LLM inputs, the action uses CodeBoarding's hosted OpenRouter tier. It mints short-lived GitHub OIDC credentials per request, so the job needs `id-token: write` and no stored LLM secret. @@ -226,6 +247,7 @@ With the default `github.token`, the repository or organization must allow GitHu | `sync_strategy` | sync | `push` | `push` or `pull_request`. | | `target_branch` | sync | event branch | Branch receiving the baseline or rolling PR. | | `force_full` | sync | `false` | Ignore the committed baseline for this run. | +| `post_comment` | review | `true` | `false` analyzes and uploads the artifact without writing to the pull request. | The `/codeboarding` command, comment heading, Mermaid direction (`LR`), hosted webview URL, rolling sync branch, commit message, and CodeBoarding 0.13.8 version are intentionally fixed in v2 rather than exposed as configuration. diff --git a/action.yml b/action.yml index 2827c48..426b4c8 100644 --- a/action.yml +++ b/action.yml @@ -49,6 +49,10 @@ inputs: description: 'Run a full sync analysis instead of reusing the committed baseline.' required: false default: 'false' + post_comment: + description: 'Post the review to the pull request. false still analyzes and uploads the artifact.' + required: false + default: 'true' outputs: diagram_md: description: 'Path to the rendered Mermaid review diagram.' @@ -102,6 +106,7 @@ runs: HEAD_AUTHOR_EMAIL: ${{ github.event.head_commit.author.email }} TARGET_BRANCH_INPUT: ${{ inputs.target_branch }} SYNC_STRATEGY: ${{ inputs.sync_strategy }} + POST_COMMENT_INPUT: ${{ inputs.post_comment }} COMMENT_BODY: ${{ github.event.comment.body }} AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} ISSUE_PR_URL: ${{ github.event.issue.pull_request.url }} @@ -127,7 +132,7 @@ runs: run: GH_HOST="${GITHUB_SERVER_URL#*://}" gh api -X POST "repos/${REPOSITORY}/issues/comments/${COMMENT_ID}/reactions" -f content=eyes >/dev/null - name: Post review progress - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.post_comment != 'false' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: @@ -402,7 +407,7 @@ runs: - name: Post review comment id: review_comment - if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' + if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.post_comment != 'false' uses: marocchino/sticky-pull-request-comment@v2 with: header: ${{ steps.guard.outputs.comment_id }} @@ -411,7 +416,7 @@ runs: path: ${{ steps.review_body.outputs.path }} - name: Post review failure - if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.review_comment.outcome != 'success' + if: failure() && steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' && steps.guard.outputs.post_comment != 'false' && steps.review_comment.outcome != 'success' continue-on-error: true uses: marocchino/sticky-pull-request-comment@v2 with: diff --git a/scripts/action/guard.sh b/scripts/action/guard.sh index 5b48ddc..2b8ff2c 100755 --- a/scripts/action/guard.sh +++ b/scripts/action/guard.sh @@ -40,6 +40,16 @@ if [ "$MODE" = sync ]; then exit 0 fi +# Validated here rather than read straight from the input, so a typo fails the +# run with a reason instead of silently posting when the author asked for +# silence. The reaction on a /codeboarding comment is not a comment and stays: +# with posting off it is the only sign the command was picked up. +post_comment="${POST_COMMENT_INPUT:-true}" +case "$post_comment" in + true|false) ;; + *) fail "post_comment must be true or false." ;; +esac + seed_mode=chain case "$EVENT" in pull_request|pull_request_target) @@ -137,5 +147,6 @@ is_fork=false echo "checkout_ref=$head_sha" echo "comment_id=$comment_id" echo "seed_mode=$seed_mode" + echo "post_comment=$post_comment" echo "is_fork=$is_fork" } >> "$GITHUB_OUTPUT" diff --git a/tests/test_action_cache.py b/tests/test_action_cache.py index 5f7cecc..2f45f44 100644 --- a/tests/test_action_cache.py +++ b/tests/test_action_cache.py @@ -407,7 +407,7 @@ class CachePathParityTests(unittest.TestCase): """actions/cache derives its lookup version from the path strings, so a save under a different path than the restore can never be found again.""" - def _cache_steps(self) -> list[dict[str, str]]: + def _steps(self) -> list[dict[str, str]]: steps: list[dict[str, str]] = [] current: dict[str, str] | None = None for line in (ROOT / "action.yml").read_text(encoding="utf-8").splitlines(): @@ -419,7 +419,10 @@ def _cache_steps(self) -> list[dict[str, str]]: for field in ("uses", "path", "key", "restore-keys", "if"): if stripped.startswith(f"{field}:"): current[field] = stripped.split(":", 1)[1].strip() - return [step for step in steps if step.get("uses", "").startswith("actions/cache/")] + return steps + + def _cache_steps(self) -> list[dict[str, str]]: + return [step for step in self._steps() if step.get("uses", "").startswith("actions/cache/")] def test_the_chain_is_restored_by_prefix_so_a_refresh_survives(self) -> None: steps = {step["name"]: step for step in self._cache_steps()} @@ -449,6 +452,18 @@ def test_reviews_do_not_attempt_a_save_a_comment_run_cannot_make(self) -> None: f"{step['name']} would attempt a save that a comment-triggered run cannot make", ) + def test_every_comment_step_honours_post_comment(self) -> None: + # Analysis, artifact upload and outputs continue when posting is off; + # only the steps that write to the pull request are gated. + posting = [step for step in self._steps() if "sticky-pull-request-comment" in step.get("uses", "")] + self.assertEqual(len(posting), 3, "expected progress, result and failure comments") + for step in posting: + self.assertIn( + "steps.guard.outputs.post_comment != 'false'", + step.get("if", ""), + f"{step['name']} posts to the pull request regardless of post_comment", + ) + def test_every_saved_path_is_a_restored_path(self) -> None: steps = self._cache_steps() self.assertTrue(steps, "no cache steps found in action.yml") diff --git a/tests/test_action_sync.py b/tests/test_action_sync.py index b88e39f..ae66da8 100644 --- a/tests/test_action_sync.py +++ b/tests/test_action_sync.py @@ -147,6 +147,33 @@ def test_review_guard_accepts_trusted_fork_command(self) -> None: self.assertIn("checkout_repo=contributor/repo\n", values) self.assertIn("checkout_ref=head-sha\n", values) + def test_review_guard_rejects_an_unusable_post_comment_value(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "github-output" + result = subprocess.run( + [str(GUARD)], + env={ + "PATH": os.environ["PATH"], + "GITHUB_OUTPUT": str(output), + "MODE": "review", + "EVENT": "pull_request", + "POST_COMMENT_INPUT": "no", + "EVENT_PR_NUMBER": "42", + "PULL_BASE_SHA": "base-sha", + "PULL_HEAD_SHA": "head-sha", + "PULL_BASE_REPO": "owner/repo", + "PULL_HEAD_REPO": "owner/repo", + }, + capture_output=True, + text=True, + check=False, + ) + + # Silently posting when the author asked for silence is worse than + # failing, so an unusable value stops the run. + self.assertEqual(result.returncode, 1, result.stdout) + self.assertIn("post_comment must be true or false", result.stdout) + def test_installs_core_manifest_and_preserves_user_configuration(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp)