GitNexus/CONTRIBUTING.md
Gergő Magyar 6906be3695
feat(autofix): replace inline reviewdog with /autofix ChatOps button (#1458)
* fix(autofix): verify reviewdog actually posted before claiming "click Apply"

The sticky summary comment was stating "Posted formatting suggestions
inline. Click Apply suggestion on each" even when reviewdog landed zero
inline review comments — typical case: the formatter touched lines
outside the PR's added range, so `-filter-mode=added` (correctly)
filtered everything out. The script unconditionally set `posted=true`
after running reviewdog regardless of whether any comments were
actually created, leaving the user staring at a sticky that promised
buttons that didn't exist.

The publish job now snapshots the count of `github-actions[bot]` review
comments before and after reviewdog. If the delta is zero, surface a
new `diff-no-overlap` UI state that tells the user plainly:

  "Formatter found fixable issues, but they're on lines outside this
  PR's added range — there's nothing to click here. Run locally:
  npm run lint:fix && npm run format."

Plus a matching `gitnexus/autofix` Check Run conclusion (still neutral,
distinct title) so agents reading `gh pr checks` see the same signal.

Three states are now machine-distinguishable in the sticky's
gitnexus-autofix JSON block: suggestions-posted (delta > 0),
diff-no-overlap (delta == 0), skipped-too-large (>3k lines).

* feat(autofix): replace inline reviewdog with /autofix ChatOps button

Pivot the PR autofix UX from per-line reviewdog suggestions to a single
slash-command button. Contributors comment `/autofix` on the PR; a new
trusted workflow downloads the existing autofix patch artifact, applies
it to the PR head, and pushes a commit back.

Why:
- 3K+ diffs hit GitHub's review-comment API 406 limit -> dead end.
- Diffs where the formatter touches lines outside the PR's added range
  ("no-overlap") get filtered by reviewdog's -filter-mode=added -> dead
  end (PR #1457 patched the lying sticky but the underlying UX gap
  remained).
- Per-line click-Apply-suggestion is high-friction for big diffs and
  easy to apply unevenly.
- A single `git apply` + push works at any size and lands fixes
  atomically.

Changes:
- pr-autofix-publish.yml: remove `Install reviewdog` and
  `Post inline suggestions` steps. Collapse three sticky states
  (suggestions-posted, diff-no-overlap, skipped-too-large) into one
  (fixes-available). Bump JSON schema v1 -> v2 with `apply_command`
  field; all v1 fields preserved.
- pr-autofix-apply.yml (new): triggers on issue_comment with body
  `/autofix`, validates body via strict regex, validates commenter
  has write/admin/maintain or is the PR author, locates latest
  successful pr-autofix run for PR head SHA, downloads artifact,
  applies patch, pushes commit. Reacts +1/-1/eyes on triggering
  comment per outcome. Idempotent (`git apply --check --reverse`
  detects already-applied state).
- CONTRIBUTING.md: document v2 schema and the /autofix flow,
  including the maintainer-edit requirement for fork PR pushes.

Trust posture: apply workflow runs from default-branch code only,
under issue_comment trigger. Comment body and author login flow
through env vars and pattern-matched, never interpolated into shell.
Permission gate (write/admin/maintain OR PR author) before any
artifact fetch. Fork PRs require "Allow edits by maintainers"
(GitHub-native; we don't bypass).

Net YAML: -139 lines in publish.yml, +260 in apply.yml. Removes
reviewdog binary pin and the entire review-comment API surface.

* fix(autofix): address Codex adversarial findings on PR #1458

Two findings from the Codex adversarial review of the autofix ChatOps
pivot. Both are localized YAML changes that close trust gaps the pivot
inherited from the original PR #1446 design.

U1 — Cross-verify metadata against workflow_run authority
(.github/workflows/pr-autofix-publish.yml):
  Previously the trusted publisher accepted pr_number, head_sha, and
  head_repo from metadata.json after only an allowlist regex. A
  fork-controlled `npm run lint:fix` could have written a syntactically
  valid metadata.json referencing another PR/SHA, redirecting the
  write-scoped sticky/check-run onto an attacker-chosen target.

  New `Verify metadata against workflow_run authority` step compares
  artifact-claimed identity against:
    - github.event.workflow_run.head_sha
    - github.event.workflow_run.head_repository.full_name
    - workflow_run.pull_requests[].number (within-repo PRs)
    - gh api commits/{sha}/pulls fallback (fork PRs, where
      pull_requests[] is empty)
  Fail closed on mismatch — no sticky, no check-run, no override.

U2 — Lease-protected push in apply workflow
(.github/workflows/pr-autofix-apply.yml):
  Previously the apply step pushed `HEAD:${HEAD_REF}` plain. A force-
  push between resolve (Step 5) and push (Step 9) would silently
  fast-forward an older commit graph over the contributor's newer
  state.

  Push now uses `--force-with-lease=refs/heads/${HEAD_REF}:${HEAD_SHA}`
  against the SHA resolved earlier. Distinct `lease-failed` result code
  + retry-message reply, separated from `push-failed` (fork without
  maintainer-edit) so contributors can diagnose the actual cause.

Plan: docs/plans/2026-05-09-005-fix-autofix-codex-adversarial-findings-plan.md
(local-only per repo convention).

Trust posture preserved: no new permissions, no new workflows, no
contract change. JSON v2 schema unchanged. CodeQL js/server-side-
request-forgery and template-injection posture unchanged — all new
inputs flow via env vars and pattern-matched.

* fix(autofix): close zizmor credential-persistence finding on apply checkout

actions/checkout's default behavior writes the GITHUB_TOKEN into
.git/config as an extraheader. The token then sits on disk in the
checkout directory — an actions/upload-artifact step on that
directory would leak it. We don't upload, but zizmor's
credential-persistence lint correctly flags the latent risk.

Set persist-credentials: false on the Checkout PR head step. Provide
push auth inline via `git -c http.extraheader="Authorization: Basic
<base64-of-x-access-token:TOKEN>"` so the credential never lands on
disk and never appears in process listings (the URL form
https://x-access-token:TOKEN@… is rejected here because it leaks via
ps and git remote -v).

Push lease semantics from U2 unchanged — same --force-with-lease
against the resolved HEAD_SHA, same lease-failed/push-failed/stale
result codes.

* fix(review): apply autofix feedback

ce-code-review surfaced 15 findings on PR #1458; this commit applies
the 7 with concrete fixes (#1, #2, #3, #4, #5, #9, #13). Five P2
findings (#6, #7, #8, #10, #12) are recorded as residual actionable
work for follow-up; two advisory items (#11, #14) skipped.

#1 — applied_run_id schema drift (CONTRIBUTING.md):
  v2 docs claimed `state: applied` enum value and an `applied_run_id`
  field that no code path emits. Trimmed docs to match what the
  workflow actually writes (state: fixes-available; v1 field set as
  superset). Implementing the apply-side sticky upsert that would
  populate `applied_run_id` is deferred — cleaner than carrying a
  contract claim with no code.

#2 — result= unset between idempotency probe and lease push
(pr-autofix-apply.yml):
  After `git apply --check` passed, an early non-zero exit from
  `git config` / `git apply` / `git add` / `git commit` left
  `result=` unset, sending the user to the `*` "unexpected state
  (`unknown`)" arm. Wrapped the apply/commit phase in a single
  if-test that sets `result=apply-failed` on any failure. New
  React-and-reply branch surfaces an actionable message.

#3 — permission lookup conflated transient API failures with denial
(pr-autofix-apply.yml):
  `gh api … 2>/dev/null || echo "none"` swallowed 5xx, 429 secondary
  rate-limit, and network failures, surfacing them as a public 👎
  refusal to legitimate maintainers. Now distinguishes 404
  (genuine non-collaborator) from other API failures via stderr
  match. New `allowed=api-failed` state triggers a 😕 reaction with
  a "transient API failure, retry" reply instead of a misleading
  refusal.

#4 — lease-failure grep missed git's "remote rejected" / branch-
deleted phrasings (pr-autofix-apply.yml):
  Real lease failures got classified as `push-failed` →
  user told to enable maintainer-edit, which won't help. Expanded
  regex to match `remote rejected` and `! [rejected]`.

#5 — broken bullet continuation in CONTRIBUTING.md release-candidate
section: rejoined the split bullet so it renders correctly.

#9 — base64 GITHUB_TOKEN bypassed GitHub's secret-masker
(pr-autofix-apply.yml):
  Added `::add-mask::${auth_header}` immediately after construction
  so any subsequent log line (set -x, GIT_TRACE) gets *** redacted.

#13 — misleading schema-bump comment in pr-autofix-publish.yml:
  Comment claimed all v1 fields preserved exactly, but the `state`
  enum was redefined v1→v2. Updated to make the migration path
  explicit (v1 readers see unfamiliar schema, fall back to prose).

Residual actionable work (deferred to follow-up):
  #6 locate step gh api retry; #7 artifact-expired graceful fallback;
  #8 re-entrancy comment-spam guard; #10 producer-still-running UX;
  #12 gh_retry wrapper for apply.yml.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.

* fix(autofix): apply remaining ce-code-review residual findings (#6, #7, #8, #10, #12)

Pulls the deferred items from the previous review pass into this PR so
the workflow ships with full reliability + UX coverage rather than
follow-up debt.

#6 + #12 — gh_retry wrapper on idempotent GETs in apply.yml:
  Permission lookup, PR metadata fetch, and workflow-run lookup are now
  wrapped in the same gh_retry helper publish.yml uses (3 attempts,
  linear backoff). Reaction/comment POSTs remain unwrapped (retrying
  POST would dupe the resource).

#10 — producer-still-running UX:
  The locate step now distinguishes three cases via `found_status`
  output: success (proceed), in-progress / queued / pending / waiting
  (reply  "wait for autofix run to finish"), not-found (reply 🤔
  "push a commit"), api-failed (reply ⚠️ "transient API failure"). The
  "no successful autofix run" message no longer fires immediately after
  a fresh push while the producer is still mid-run.

#7 — artifact-expired graceful fallback:
  actions/download-artifact gains `continue-on-error: true`. The apply
  step distinguishes patch-file-missing (artifact expired, 1-day
  retention elapsed) from patch-file-zero-bytes (formatter found
  nothing). New `result=artifact-expired` case +  "push a new commit
  to regenerate" reply.

#8 — re-entrancy loop guard:
  After checkout but before applying, check if HEAD itself is a
  github-actions[bot] `chore(autofix)` commit. If so, refuse to
  re-apply (`result=loop-prevented`) with a 🔁 reply telling the user
  to push a human-authored commit or revert before retrying. Prevents
  formatter-config-drift loops where an automated agent watching the
  sticky could pump arbitrary apply commits.

Net effect: every code path in apply.yml now sets a meaningful `result=`
that maps to a specific user-facing reaction + reply. The `*` "unexpected
state (unknown)" arm becomes truly unreachable in normal operation.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.

* fix(autofix): refresh stale reviewdog comments + reject patches touching .github/

Two follow-up findings on PR #1458:

#1 — Stale reviewdog references in workflow header comments:
  pr-autofix-publish.yml's header still described the removed inline-
  suggestion path ("posts inline review-comment suggestions to the PR
  using `reviewdog`", "Reviewdog reporter: github-pr-review reads
  $REVIEWDOG_GITHUB_API_TOKEN…"). The Check Run permissions comment
  enumerated the old outcomes (clean / suggestions-posted /
  skipped-too-large) instead of the current set (clean / fixes-
  available). pr-autofix.yml's header described the trusted job as
  posting "inline review-comment suggestions" and the changed_lines
  comment referenced the dead 3000-line cap. Refreshed all three to
  describe the actual sticky + Check Run + /autofix flow.

#2 — Reject patches touching .github/ (sensitive-paths guard):
  Theoretical supply-chain vector: a malicious PR could ship a custom
  prettier/ESLint config that reformats workflow YAML, dependabot.yml,
  or CODEOWNERS. The producer would capture those edits in
  autofix.patch; a maintainer running `/autofix` would push them under
  `contents: write` without human review. The default GITHUB_TOKEN
  lacks the `workflows` scope so workflow-file pushes would fail at
  the platform layer anyway, but as a generic `push-failed` (which
  misleads users into enabling maintainer-edit). Reject early with
  a specific reason.

  Match runs against the patch with grep on `^(diff --git|---|+++)
  [ab]?/?\.github/`. New `result=sensitive-paths` case + 🛑 reply
  telling the user to apply .github/ formatter changes manually.

  Documented the constraint in CONTRIBUTING.md under the /autofix
  section so contributors aren't surprised when the workflow refuses
  a patch that includes formatter changes to workflow files.

Validations: yaml.safe_load OK, check-workflow-concurrency.py OK.
2026-05-09 16:32:38 +01:00

15 KiB
Raw Permalink Blame History

Contributing to GitNexus

How to propose changes, run checks locally, and open pull requests.

License

This project uses the PolyForm Noncommercial License 1.0.0. By contributing, you agree your contributions are licensed under the same terms unless stated otherwise.

Where to discuss

  • Issues & feature ideas: use GitHub Issues for the upstream repo, or your forks tracker if you work from a fork.
  • Community: see the Discord link in the root README.md.

Development setup

  1. Clone the repository.
  2. CLI / MCP package: cd gitnexus && npm install && npm run build
  3. Web UI (if needed): cd gitnexus-web && npm install
  4. Run tests as described in TESTING.md.

Branch and pull requests

  • Use short-lived branches off the default branch of the repo you are targeting.
  • PR titles MUST follow the conventional-commit formatpr-labeler.yml enforces this on every PR and auto-applies the matching label so release notes group the change correctly.
  • PR description: what changed, why, how to verify (commands), and any risk or rollback notes.

Pull request titles

Format: <type>[(scope)][!]: <subject>

Allowed types and the release-notes section each one lands in (defined in .github/release.yml):

Type Label applied Release-notes section
feat enhancement 🚀 Features
fix bug 🐛 Bug Fixes
perf performance 🏎️ Performance
refactor refactor 🔄 Refactoring
test test 🧪 Tests
ci ci 👷 CI/CD
build / deps dependencies 📦 Dependencies
docs documentation (grouped under Other Changes unless a Docs section is added)
chore / revert chore (excluded from release notes)

Append ! to the type (e.g. feat(api)!: drop /v1 endpoint) or include BREAKING CHANGE: in the PR body to flag a breaking change — the labeler then adds the breaking label and the 💥 Breaking Changes section is rendered first.

Examples:

feat(web): add smart chat scroll
fix(extractors): resolve silent contract mis-resolution
perf: avoid O(n²) traversal in heritage walker
chore(deps): bump vitest to 3.0.0
ci: standardize workflow concurrency

Commits within a PR may use any style — only the merged PR title shows up in release notes, so that's the one the convention applies to.

Before you open a PR

  • Tests pass for the packages you touched (gitnexus and/or gitnexus-web).
  • Typecheck passes: npx tsc --noEmit in gitnexus/ and npx tsc -b --noEmit in gitnexus-web/.
  • No secrets, tokens, or machine-specific paths committed.
  • Documentation updated if behavior or public CLI/MCP contract changes.
  • Pre-commit hook runs clean (.husky/pre-commit — formatting via lint-staged + typecheck for staged packages; tests run in CI only).

Code review

Maintainers may request changes for correctness, tests, performance, or consistency with existing patterns. Keeping diffs focused makes review faster.

GitHub Actions — Concurrency Convention

Every workflow under .github/workflows/ MUST declare a top-level concurrency: block using this convention:

  • Group key starts with ${{ github.workflow }} so no two workflows can collide on the same group name. The discriminator that follows is chosen per event shape:

    • Branch/tag scope: ${{ github.workflow }}-${{ github.ref }}
    • Per-PR scope (for issue_comment, pull_request_review*, pull_request meta events): ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number }}
    • workflow_run scope (e.g. ci-report.yml): ${{ github.workflow }}-${{ github.event.workflow_run.pull_requests[0].number || format('{0}/{1}', github.event.workflow_run.head_repository.full_name, github.event.workflow_run.head_branch) }} — the fork fallback must be stable across reruns (never workflow_run.id, which is per-run-unique and defeats serialization).
    • Global single-slot (manual dispatch utilities): ${{ github.workflow }}
    • Reusable workflows invoked via workflow_call: do NOT use ${{ github.workflow }} in the group key — in called-workflow context its evaluation is ambiguous and can resolve to the caller's name, which would deadlock against the caller's own group. Use a hardcoded literal prefix and a github.event_name-aware expression that falls through to github.run_id for reusable invocations (see ci.yml for the canonical form). Approved literal prefixes: CI- (ci.yml) and docker-build-push- (docker.yml). The check-workflow-concurrency.py validation script must be updated whenever a new approved literal prefix is added.
    • Merge queue (merge_group): when this event is added, use ${{ github.workflow }}-${{ github.event.merge_group.head_ref }} with cancel-in-progress: false (every queue entry is a distinct ref; never cancel).
  • cancel-in-progress policy:

    Event cancel-in-progress Why
    pull_request CI run true New push supersedes old run
    push to main false Every main commit gets validated
    Tag push (v* publish) false Never cancel mid-publish
    push to main for release-candidate false Never cancel mid-RC publish
    workflow_dispatch (release/publish) false Manual runs are intentional
    workflow_run (sticky-comment reports) false Serialize, don't race
    Per-PR bot workflows (@claude, review) false Serialize comments per PR
    PR-meta re-checks (pr-description-check) true Cheap, latest wins
    Single-slot utilities (triage sweep) true Latest dispatch supersedes
  • For workflows that serve multiple events at once (e.g. ci.yml handles pull_request, push, and workflow_call), make cancel-in-progress event-aware:

    concurrency:
      group: ${{ github.workflow }}-${{ github.ref }}
      cancel-in-progress: ${{ github.event_name == 'pull_request' }}
    
  • When adding a new workflow, copy the concurrency block from an existing workflow of the same event shape.

CI automation contracts

Two workflows produce machine-readable signals on every PR. Coding agents and humans alike can rely on the names and shapes below — change them with intent.

gitnexus/autofix

pr-autofix.yml (untrusted) + pr-autofix-publish.yml (trusted) run prettier --write and eslint --fix against the PR head and surface a single ChatOps button on the PR. Three signals are emitted:

Surface Where Notes
Sticky PR comment Top-level comment with the HTML marker <!-- gitnexus:pr-autofix-summary --> and heading ## :sparkles: PR Autofix. Only posted when there is something to fix; clean PRs stay silent. Edit-in-place via marker; one comment per PR.
Fenced JSON block Inside the sticky, fenced as gitnexus-autofix. Schema gitnexus.pr-autofix/v2 with fields state (fixes-available), pr_number, head_sha, changed_lines, run_id, and apply_command (literal /autofix). Parseable signal — preferred over regexing prose. v1 fields preserved as a superset.
Check Run Stable name gitnexus/autofix on the PR head SHA. Conclusion: success (clean) or neutral (fixes-available). The neutral title is Autofix available — comment /autofix to apply. Surfaced under PR Checks; readable via gh pr checks <pr>.

To detect outcome from an agent: gh pr checks <pr> --json name,conclusion,output | jq '.[] | select(.name == "gitnexus/autofix")'.

Forks are supported. The untrusted half runs fork code with permissions: {} and ships the diff as an artifact; the trusted publish job consumes only the diff (data, not code) and posts the comment + check run.

Applying autofix

Comment /autofix on the PR (whole-line, no arguments). The pr-autofix-apply.yml workflow:

  1. Validates the comment body matches ^/autofix\s*$ exactly. Quoted or inline mentions are silently ignored.
  2. Validates the commenter has admin, write, or maintain permission on the repo, OR is the PR author. Other commenters get a 👎 reaction and a refusal reply.
  3. Locates the most recent successful pr-autofix.yml run for the PR's current head SHA, downloads its autofix artifact, applies the patch, and pushes a chore(autofix): ... commit back to the PR head branch.
  4. Reacts on success, 👎 on stale-patch / push-failure, and posts a short reply with the apply-run URL in either case.

The apply workflow runs from the default branch's copy of the file regardless of where the comment originates — that's the trust anchor. There is no diff-size cap (the apply workflow uses git apply + push, not the GitHub review-comment API).

For fork PRs, the push succeeds only when the contributor has Allow edits by maintainers enabled on the PR (the default). When they have disabled it, the workflow fails loud with a 👎 reaction and an explanation comment.

Re-invoking /autofix after a successful apply is a safe no-op — the workflow detects the already-applied state via git apply --check --reverse and reacts without pushing.

Sensitive paths. The apply workflow refuses any patch that touches .github/ (workflow files, CODEOWNERS, dependabot config). A malicious PR could ship a custom prettier or ESLint config that reformats workflow YAML; if accepted, those edits would be pushed under contents: write without human review. Apply formatter changes to files under .github/ manually in a normal commit so they get the same review every other workflow change gets.

AI-assisted contributions

If you use coding agents, follow project context files (e.g. AGENTS.md, CLAUDE.md) and avoid drive-by refactors unrelated to the issue. Prefer incremental, test-backed changes.

Releases

Two publish workflows ship gitnexus to npm:

  • Stable (.github/workflows/publish.yml) — triggered by pushing any v* tag. Publishes to the latest dist-tag with a changelog-backed GitHub release. Maintainers are expected to tag from main as a convention; the workflow itself does not enforce branch reachability.

  • Release Candidate (.github/workflows/release-candidate.yml) — runs on every push to main (typically a merged PR) plus manual dispatch. Docs-only changes are skipped via paths-ignore. Publishes to the rc dist-tag with version X.Y.Z-rc.N and a GitHub prerelease, where:

    • X.Y.Z is selected automatically. On push (and on dispatch with bump: auto, the default) the workflow continues the active rc cycle: if the registry already has X.Y.Z-rc.* versions with X.Y.Z > current latest, it reuses the highest such base; otherwise it patch-bumps from latest. Dispatching with bump: patch|minor|major resets the cycle from latest.
    • N is auto-incremented against existing X.Y.Z-rc.* entries on the registry. First rc for a given base is rc.1.
    • After the npm publish succeeds, the workflow calls docker.yml as a reusable workflow to build and push the corresponding RC Docker images (e.g. ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1, mirrored to docker.io/akonlabs/gitnexus:1.7.0-rc.1). The images are signed with Cosign; the OIDC identity is docker.yml@refs/heads/main (the caller's ref — see README.md § Docker for the verify command).

    Idempotency: the workflow pushes an rc/<HEAD_SHA> marker tag and a v<RC> release tag atomically, before calling npm publish. The guard refuses to re-run once the marker exists, so a post-publish failure will not mint a duplicate rc for the same commit. The v<RC> tag points at a detached release commit whose package.json matches the npm tarball exactly (traceable releases). Recovery after a partial failure:

    git push --delete origin rc/<HEAD_SHA> v<RC>
    # then redispatch the workflow with force: true
    

    Docker-only partial failure: if publish succeeds (npm tarball + tags are live) but the docker job subsequently fails (e.g. GHCR flakiness), the npm RC is already published and the rc/<HEAD_SHA> marker is in place. Re-running release-candidate.yml with force: true will abort at the "Version already exists on npm" guard. To recover without cutting a new RC:

    # 1. Manually trigger only the docker workflow, passing the existing RC tag:
    gh workflow run docker.yml --ref main -f tag=v<RC_VERSION>
    # (requires a workflow_dispatch trigger on docker.yml — see note below)
    

    Because docker.yml intentionally has no workflow_dispatch (images are tag-driven by design), the practical recovery options are:

    • Wait for the next commit on main, which will cut a new RC that includes the Docker build.
    • Manually run docker build + docker push locally and sign with Cosign against the same digest.
    • Delete rc/<HEAD_SHA> and v<RC> tags, then redispatch with force: true to re-run the full RC pipeline (cuts a new RC number).

The rc workflow never moves latest. To verify after a change, inspect dist-tags:

npm view gitnexus dist-tags