Skip to content

feat(#160): implement the Source Control Manager - #166

Merged
lukaskellerstein merged 5 commits into
redhat-et:mainfrom
lukaskellerstein:lukas/160-implement-scm
Sep 23, 2026
Merged

lukaskellerstein merged 5 commits into
redhat-et:mainfrom
lukaskellerstein:lukas/160-implement-scm

Conversation

@lukaskellerstein

@lukaskellerstein lukaskellerstein commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

What changed

A new Go module, source-control-manager/, implements the Source Control
Manager that docs/architecture/source-control-manager.md defines.

  • One executable, source-control-manager, with the SCM core as a
    library inside it. It is a static Go binary, like ears-manager, so it
    adds no runtime to contributor machines (Environmental Constraints). The
    MCP SDK is the official Go SDK, v1.8.0.
  • The Drafting Table face as a dual-era MCP server over stdio
    (serve --face drafting-table): repo_state, branch_init,
    branch_resume, commit, publish, and refresh, in that fixed order.
    A modern client gets revision 2026-07-28, and a client that opens with
    initialize gets 2025-11-25, with byte-identical results.
  • The approved-state read face as the CLI subcommand approved-merge.
  • The same operations as CLI subcommands, with the same checks.
  • Git through argument lists only. Every command runs with an empty
    hooks directory of the SCM's own, core.fsmonitor off, literal
    pathspecs, and no submodule recursion. commit stages into a private
    index, compares every staged blob and mode with the content that check
    saw, writes the commit with git commit-tree, and moves the branch with
    a compare-and-swap git update-ref. Pushes run without force and without
    tags. The remote and push-URL checks refuse userinfo and redirected
    pushes.
  • The GitHub host adapter through gh, with --repo on every call and
    GH_REPO and GH_HOST cleared.
  • Rendering by code: the commit subject and Change-Set: trailer, the
    refresh merge message, and the pull-request title and body. Record values
    sit in code spans or fenced blocks. Closing keywords, mentions, and
    CI-skip tokens are refused in every line the SCM writes outside code.
  • Every code of the failure table, and the audit fields in each result.
  • CI: a new job, "Source Control Manager checks", runs format,
    golangci-lint, vet, the tests, and the static build.
  • Docs: the root README lists the new module and links the design.
    docs/architecture.md and the SCM design record the choice of Go and
    the MCP Go SDK, and the design names every text that UNSAFE_TEXT
    refuses: closing keywords, mentions, CI-skip tokens, and a
    pull-request title or body that GitHub would not take.

ears-manager is not implemented yet

The SCM reads the specification only through the ears-manager CLI of #30.
The ears-manager binary in this repository is the storage foundation of
#115; its command set does not exist yet. The SCM needs:

Command Used by Issue
change-set show (with --at) every operation but branch_init #112
change-set compare, impact publish (the pull-request body) #112
check with the artifact.digest_mismatch code commit #110
project init, projection.yaml entries the Drafting Table flow before the first commit #113

Without them, every operation that reads ears-manager fails closed with
SPEC_TOOL_FAILED, and nothing changes.

The tests use a stub that speaks #30's envelopes, as the design allows
("ears-manager, or its recording stub with #30's golden envelopes"). Where
#30 leaves a field name open, the SCM names what it reads, in
internal/ears/ears.go:

  • change-set show data: change_set (the manifest), status
    (proposed or approved), manifest_path, and paths, the file set with
    the manifest included.
  • check: status 4 with artifact.digest_mismatch diagnostics that carry
    path; status 5 for an incomplete or stale impact assessment.
  • change-set compare: before and after values for a revised artifact,
    so a content-only change updates the pull-request body (fixture row
    scm-7).
  • .protobot/projection.yaml: a paths list of path and class entries.

Once the command set exists, the same replay runs against the real binary:

(cd ears-manager && go build -o /tmp/ears-manager ./cmd/ears-manager)
cd source-control-manager
SCM_FIXTURE_EARS_MANAGER=/tmp/ears-manager go test ./internal/golden/

Today that run stops at step 1, because project init answers nothing.
The run against the real binary is tracked in #167, blocked by #110,
#112, and #113.

Out of scope

The hosted face behind the Gate, as #160 states.
serve --transport streamable-http exits non-zero and never listens. The 12
fixture rows of the hosted face are skipped.

How it was tested

  • internal/golden replays
    docs/architecture/fixtures/source-control-manager-golden.jsonl against
    the built binary: it starts serve, calls the tools as an MCP client with
    no harness and no model, and compares every result, with <sha:NAME>
    bound on first sight. It covers Define the Single-Player Git Integration #34's eight steps, Define the Single-Player Git Integration #34's eight negative
    checks, and every SCM check except the hosted rows.
  • Every failed call is checked to leave local branches, the index, the
    working tree, the remote, and the host unchanged.
  • Extra checks: a legacy 2025-11-25 client gets the same bytes as a modern
    one; repo_state is byte-stable; the trace context is copied; the CLI and
    MCP results are equal; planted hooks and fsmonitor do not run, but do
    run for an ordinary git commit; a tag named origin/main cannot steer
    refresh or the fast-forward; protected names in another letter case are
    refused; no result holds the remote URL or the planted token.
  • Unit tests for the renderer, the URL rules, branch names, request
    validation, and the host classes.
  • go test ./... passes on Go 1.25.12 and 1.26.5. golangci-lint v2.13.2
    finds 0 issues. gofmt and pre-commit are clean.
  • Two independent reviews, one for correctness against the design and one
    for security, found 17 distinct problems; 15 are fixed, and the 2 left are
    listed below.

Known limits

  • approved_merge reads at most the 1,000 newest merged pull requests.
  • A writer that changes a file and changes it back while check runs can
    get content that check never saw into the commit. The design accepts
    this under "one session per checkout"; the full fix is
    check --at <new commit> before the ref moves.
  • Outside a Git working tree, repo_state returns PROJECT_NOT_FOUND.
  • The materializer and reconciler roles of approved-merge do the same
    read and are not configurable.

Protected path

.github/workflows/ci-workflow.yaml gains the SCM's CI job. #160 authorizes
it: its acceptance criteria require the fixture to replay green, and CI is
where it replays. A maintainer must approve this change.

Closes #160

@lukaskellerstein
lukaskellerstein requested a review from a team September 22, 2026 10:36
@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Repository: redhat-et/ProtoBot/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 682d3698-d2f8-4f03-b0d2-df5c4819208e


Comment @coderabbitai help to get the list of available commands.

@fullsend-ai-review

Copy link
Copy Markdown

Review

Findings

High

  • [injection-pattern] source-control-manager/internal/render/render.go:73 — ActsOnGitHub's closing-keyword and CI-skip-token regexes match ASCII whitespace only, but OneLine() (used to render the commit subject and PR title) normalizes Unicode whitespace (e.g. NBSP) to ASCII spaces. An intent like "Fixes\u00A0#12" or "[skip\u00A0ci]" bypasses the check on the raw intent and then becomes literal "Fixes #12" / "[skip ci]" in the rendered subject/title, which GitHub's own Unicode-aware matcher acts on. This defeats the design's stated invariant that SCM-authored commits/PRs never close issues or skip CI. Only ASCII-whitespace-separated tokens are actually protected today.
    Remediation: Run ActsOnGitHub on the final rendered subject/title (post-OneLine), and/or make closingKeyword/skipCI Unicode-whitespace-aware before matching. Add regression tests using NBSP and other unicode.IsSpace separators.

Medium

  • [logic-error] source-control-manager/internal/scm/op_branch.go:145 — branch_init's "does the init branch exist on a remote" check only fetches the default branch's upstream remote (no --prune), then checks remote-tracking refs for every configured remote. A stale tracking ref (branch deleted on the host, or belonging to a remote never fetched this call) can produce a false BRANCH_EXISTS, or a live branch on a non-upstream remote can be missed, contrary to the design's "locally or on any remote" requirement.
    Remediation: After fetching, verify existence per remote with ls-remote --heads <remote> <branch> (or fetch --prune each remote actually checked) before reporting where=remote.

  • [stale-doc] README.md:61 — the "Repository Layout" section still says "The current Go component is organized as:" (singular) and documents only ears-manager/; this PR adds source-control-manager/ as a second Go module in go.work with no corresponding README update, and the Documentation section has no link to docs/architecture/source-control-manager.md.
    Remediation: Update the Repository Layout section to list both Go components with their layout/install targets, and link the SCM design doc under Documentation.

  • [stale-doc] docs/architecture.md:912 — the Environmental Constraints table still says "The language is chosen in Implement the Source Control Manager #160 (Packaging)" for the Source Control Manager row. This PR resolves that open decision (Go, github.com/modelcontextprotocol/go-sdk v1.8.0, single static executable) but the governed architecture document is not updated to record it.
    Remediation: Update the Source Control Manager row to state the language decision (Go, static executable, no added runtime).

  • [protected-path] .github/workflows/ci-workflow.yaml — this PR modifies a protected path (adds a "Source Control Manager checks" CI job). The PR links issue Implement the Source Control Manager #160 and includes an explicit "Protected path" section justifying the change, which is sufficient context, but a human maintainer with authority over .github/ must still explicitly approve this change; it cannot be approved automatically regardless of the rest of the diff's review outcome.

Low


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

Comment thread source-control-manager/internal/render/render.go
Comment thread source-control-manager/internal/scm/op_branch.go Outdated
Comment thread source-control-manager/internal/scm/op_publish.go Outdated
@fullsend-ai-review

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 10:37 AM UTC · Completed 11:05 AM UTC

Commit: 9fdb322 · View workflow run →

Runtime: pi · Model: sonnet → claude-sonnet-5 · Effort: high · Cost: $10.92

@lukaskellerstein

Copy link
Copy Markdown
Contributor Author

Round 1, the findings without a thread:

Also in 875297a, from a local review: the design names CI-skip tokens and the refresh merge message among the text that UNSAFE_TEXT refuses, a missing remote HEAD names git remote set-head <remote> --auto, and the fixture tables list the new checks.

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Ended 11:54 AM UTC

Commit: 875297a · View workflow run →

@lukaskellerstein lukaskellerstein added the please-review Ready for maintainers to review this PR label Sep 22, 2026

@JohnStrunk JohnStrunk left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two inline blocking correctness issues must be resolved before merge.

ETA: Luna was a bit blunt with that phrasing. 😬

Comment thread source-control-manager/internal/scm/op_publish.go
Comment thread source-control-manager/internal/scm/op_publish.go
Comment thread source-control-manager/internal/project/project.go
Comment thread source-control-manager/internal/scm/op_publish.go
Comment thread source-control-manager/internal/scm/op_publish.go
Comment thread source-control-manager/internal/scm/remote.go Outdated
@lukaskellerstein

Copy link
Copy Markdown
Contributor Author

Round 2. The branch is rebased onto main with #141 and #168.

From my own check of the design against #34, ADR-0002, and #30, also fixed in cc8d61f:

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Ended 5:55 PM UTC

Commit: cc8d61f · View workflow run →

Add the source-control-manager Go module: the SCM core, the Drafting Table face as a dual-era MCP server over stdio, the approved-state read face as the approved-merge CLI subcommand, the same operations as CLI subcommands, the gh host adapter, and the renderer for commit messages and pull-request bodies. The golden fixture of the design replays against the built binary over MCP, with stubs for gh and ears-manager; the hosted face is out of scope. A CI job runs format, lint, vet, the tests, and the static build.
T2: the root README lists both Go components and the SCM install target, and links the SCM design.
T3: architecture.md and the SCM design record the choice of Go and the MCP Go SDK.
T4: the check for text that GitHub acts on folds Unicode spaces and drops format characters, and commit and publish also check the rendered subject, message, and title.
T5: branch_init asks the upstream remote with git ls-remote for the initialization branch; redhat-et#34's Fetch row names that read.
T6: publish refuses an empty title, a title over 256 characters, and a body over 65,536 characters before the push.
Also from a local review: the design names CI-skip tokens and the refresh merge message among the refused text, a missing remote HEAD names git remote set-head, and the fixture tables list the new checks.
T1: commit maps artifact.digest_mismatch by record_id and project.store_digest_mismatch by its store_digests field to the artifact or store path, so the discard route never names project.yaml.
T2: publish's list mode names every change-set path it cannot compare, a directory included, as an uncommitted change.
T3: a create or update that fails after the push with an open host outcome reports mutation unknown and retry reconcile, and the details name the branch and the pull request.
T4: the project walk refuses a .protobot that is a symbolic link or not a directory with PROJECT_UNREADABLE.
T5: a push that fails in transport reports mutation unknown and retry reconcile.
T6: a failed read after the refresh merge reports mutation unknown.
T7: every fetch runs with an explicit refspec and an empty --refmap, so no configured refspec moves a local branch or a tag.
T8: a digest mismatch in any structured store refuses the commit; only a registered artifact outside the file set stays a warning.
T9: the fixture holds redhat-et#34's ninth negative check, a structured record changed outside ears-manager, and the ears-manager stub keeps store_digests.
T10: redhat-et#30's change-set show row lists the records the change set touches, not directory registry entries.
T12: the design says which store entries the store digest covers.
T13: redhat-et#34's Stage row lists the structured records, and its Fetch row names remote-tracking refs.
T14: the hosted rule covers the configured stores and the store digests.
T15: the transcript's 4-check step carries the redhat-et#108 diagnostic shape, and the driver compares it.
T16: the checks that only the Go driver asserts are in their own table.
T17: the design orders publish's host-failure outcomes by what the host did.
T18: a store mismatch names the untracked entries that the discard leaves.
Also from the spec-doc check: refresh's failure after its merge commit, redhat-et#34's discard route and project-root exception, the reasons a push is rejected, and the redhat-et#108 diagnostic shape in redhat-et#33's fixture.
@lukaskellerstein

Copy link
Copy Markdown
Contributor Author

Correction to the replies above: after they were posted, main took #162, which rewrites the same git-integration.md rows. The branch is rebased onto that main, so the round-2 commit is 6a00754, not cc8d61f. Nothing else changed: the Stage row of #34 now stands as #162 wrote it, which covers the record list my commit added there.

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Ended 6:18 PM UTC

Commit: 6a00754 · View workflow run →

@JohnStrunk JohnStrunk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1 is blocking: the SCM trusts remote-tracking refs that can remain stale after remote branch deletion, including the default branch used for publish, refresh, approval, and initialization.

Comment thread source-control-manager/internal/scm/remote.go Outdated
Comment thread source-control-manager/internal/project/project.go
T1: every fetch runs with --prune, so a branch the remote deleted leaves no remote-tracking ref behind, and repo_state, publish, refresh, approved_merge and branch_init read refs that the same call refreshed. Two fixture checks cover a deleted change-set branch and a deleted default branch.
Also from the spec-doc check: publish's step 5 and the BASE_NOT_ON_DEFAULT row name a remote with no default branch, repo_state's default_branch field can be null, refresh and approved_merge say what they do in that state, redhat-et#34 says that a prune of remote-tracking refs is no write to a branch, and the stale-tracking-ref fixture row says why it now passes.
@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Ended 10:03 PM UTC

Commit: 3b9181a · View workflow run →

T1: loading project.yaml applies the branch-namespace rules of the branch_init request, so a configured default_branch inside branch_prefix, or either field in the reserved wi/ namespace, is PROJECT_UNREADABLE. Without it, such a default branch reads as a change-set branch and the ref policy permits commit and publish to write it. Unit tests cover each refusal, and a fixture check rewrites project.yaml and calls commit and publish.
Also from the spec-doc check: the request now refuses the bare branch wi and a prefix below wi/, as the loader does, so both paths hold the same rule; redhat-et#34's default_branch and branch_prefix rows state the namespace rules and name their enforcer; and the fixture check asserts the local and remote refs it claims.
@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Ended 10:30 PM UTC

Commit: 0d7f94d · View workflow run →

@lukaskellerstein
lukaskellerstein added this pull request to the merge queue Sep 23, 2026
Merged via the queue into redhat-et:main with commit 926abc4 Sep 23, 2026
34 of 35 checks passed
@lukaskellerstein
lukaskellerstein deleted the lukas/160-implement-scm branch September 23, 2026 14:38
@fullsend-ai-retro

Copy link
Copy Markdown

PR #166 (redhat-et/ProtoBot) implemented the Source Control Manager, closing issue #160, and merged successfully. The issue had been correctly triaged as blocked; once its blockers cleared, a human (lukaskellerstein) implemented it directly on a non-agent branch rather than the code agent being dispatched — no evidence this was a defect, just a human taking the work.

The main finding: of ~6 review-dispatch events triggered by the PR's push history, only the very first automated review run completed (round 1, high-quality: it caught a genuine High-severity Unicode-whitespace bypass of the closing-keyword guard, plus valid stale-doc and stale-ref findings, and one Low finding the author reasonably overrode with a tracked follow-up in #167). Every subsequent review run was auto-cancelled (5 runs) or skipped (1 run) because the author pushed fix commits faster than the review agent could finish, and the dispatch/Review concurrency group is keyed only on repo+PR number (not commit SHA), so each new push cancels the in-flight review regardless of progress. As a direct consequence, two blocking, security-relevant bugs found in rounds 3-4 — stale git remote-tracking refs trusted after branch deletion, and a project.yaml branch-namespace collision allowing a ref-boundary bypass — were caught only by human reviewers (JohnStrunk, jerry-ng2); the automated reviewer never got a chance to see that code.

This is a well-documented, actively open problem upstream, not a new gap: fullsend-ai/fullsend#1014 ("Debounce review dispatch on rapid synchronize events") is the canonical issue, with #4960, #7314, and #7107 as near-duplicates, and #7521 pinpointing the exact mechanical defect confirmed here — the concurrency group isn't keyed on commit SHA. Several prior "Evidence for #1014" issues already document the same pattern on other PRs (e.g. #3694, #5210, #4693, #3752, #4414, #4635, #5106). Given this density of existing coverage, I'm not filing a new proposal; this comment serves as fresh evidence for #1014: on PR #166, the missing debounce caused 5 review-run cancellations plus 1 skip across ~11 hours of fix cycles, and specifically let 2 confirmed security-relevant bugs go unreviewed by automation until human reviewers caught them. No other systemic issues were identified in this workflow.

@fullsend-ai-retro

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 2:40 PM UTC · Completed 2:49 PM UTC

Commit: 0d7f94d · View workflow run →

Runtime: claude · Model: sonnet → claude-sonnet-5 · Effort: high · Cost: $1.19

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

please-review Ready for maintainers to review this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement the Source Control Manager

3 participants