From 5158aeeeb81b32456cff943c2d68990325fb197e Mon Sep 17 00:00:00 2001 From: z2z23n0 Date: Mon, 27 Jul 2026 03:10:52 +0800 Subject: [PATCH] fix: support fork PR intent comments --- .github/workflows/mainline-pr-intent.yml | 146 +++++++++++- internal/cli/pr.go | 12 +- internal/cli/root_test.go | 12 + internal/engine/pr.go | 34 ++- internal/engine/pr_fork.go | 236 +++++++++++++++++++ internal/engine/pr_fork_test.go | 278 +++++++++++++++++++++++ 6 files changed, 705 insertions(+), 13 deletions(-) create mode 100644 internal/engine/pr_fork.go create mode 100644 internal/engine/pr_fork_test.go diff --git a/.github/workflows/mainline-pr-intent.yml b/.github/workflows/mainline-pr-intent.yml index 8c045d00..2cc5840d 100644 --- a/.github/workflows/mainline-pr-intent.yml +++ b/.github/workflows/mainline-pr-intent.yml @@ -1,7 +1,7 @@ name: Mainline PR Intent on: - pull_request: + pull_request_target: types: [opened, edited, synchronize, reopened] permissions: @@ -9,30 +9,145 @@ permissions: pull-requests: write issues: write +concurrency: + group: mainline-pr-intent-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: comment: - if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 5 steps: - - uses: actions/checkout@v4 + - name: Inspect Mainline PR comment state + id: comment_state + uses: actions/github-script@v7 + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const bodyMarker = ""; + const commentMarker = ""; + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const issue_number = pr.number; + const { data: livePR } = await github.rest.pulls.get({ + owner, + repo, + pull_number: issue_number, + }); + if (livePR.head.sha !== process.env.EXPECTED_HEAD_SHA) { + core.notice( + `Skipping stale PR event for ${process.env.EXPECTED_HEAD_SHA}; current head is ${livePR.head.sha}` + ); + core.setOutput("needs_comment", "false"); + return; + } + if (!(livePR.body || "").includes(bodyMarker)) { + core.setOutput("needs_comment", "true"); + return; + } + + const comments = await github.paginate( + github.rest.issues.listComments, + { owner, repo, issue_number, per_page: 100 } + ); + const existing = comments.find((comment) => + comment.user.type === "Bot" && + comment.body && + comment.body.includes(commentMarker) + ); + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + } + core.setOutput("needs_comment", "false"); + + - name: Check out PR head + if: > + steps.comment_state.outputs.needs_comment == 'true' && + github.event.pull_request.head.repo.full_name == github.repository + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 + - name: Check out trusted base for fork PR + if: > + steps.comment_state.outputs.needs_comment == 'true' && + github.event.pull_request.head.repo.full_name != github.repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@v5 + if: steps.comment_state.outputs.needs_comment == 'true' with: go-version-file: go.mod + - name: Retain fork PR head as Git data + if: > + steps.comment_state.outputs.needs_comment == 'true' && + github.event.pull_request.head.repo.full_name != github.repository + id: fork_head + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + data_ref="refs/mainline/imports/pr-heads/pr-${PR_NUMBER}" + git fetch --no-tags origin \ + "+refs/pull/${PR_NUMBER}/head:${data_ref}" + actual_head="$(git rev-parse "${data_ref}^{commit}")" + if [ "$actual_head" != "$HEAD_SHA" ]; then + echo "current=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping stale PR event for ${HEAD_SHA}; current head is ${actual_head}" + exit 0 + fi + echo "current=true" >> "$GITHUB_OUTPUT" + - name: Generate Mainline PR intent comment + if: > + steps.comment_state.outputs.needs_comment == 'true' && + (github.event.pull_request.head.repo.full_name == github.repository || + steps.fork_head.outputs.current == 'true') + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} + FORK_URL: ${{ github.event.pull_request.head.repo.clone_url }} + IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }} + COMMENT_PATH: ${{ runner.temp }}/mainline-pr-comment.md run: | - go run . pr-comment \ - --base "${{ github.event.pull_request.base.sha }}" \ - --head "${{ github.event.pull_request.head.sha }}" \ - --branch "${{ github.event.pull_request.head.ref }}" \ - > /tmp/mainline-pr-comment.md + args=( + pr-comment + --base "$BASE_SHA" + --head "$HEAD_SHA" + --branch "$HEAD_REF" + ) + if [ "$IS_FORK" = "true" ]; then + args=( + --no-sync + "${args[@]}" + --pr "$PR_NUMBER" + --fork-url "$FORK_URL" + ) + fi + go run . "${args[@]}" > "$COMMENT_PATH" - name: Upsert Mainline PR intent comment + if: > + steps.comment_state.outputs.needs_comment == 'true' && + (github.event.pull_request.head.repo.full_name == github.repository || + steps.fork_head.outputs.current == 'true') uses: actions/github-script@v7 + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + COMMENT_PATH: ${{ runner.temp }}/mainline-pr-comment.md with: script: | const fs = require("fs"); @@ -41,6 +156,17 @@ jobs: const pr = context.payload.pull_request; const { owner, repo } = context.repo; const issue_number = pr.number; + const { data: livePR } = await github.rest.pulls.get({ + owner, + repo, + pull_number: issue_number, + }); + if (livePR.head.sha !== process.env.EXPECTED_HEAD_SHA) { + core.notice( + `Skipping stale PR event for ${process.env.EXPECTED_HEAD_SHA}; current head is ${livePR.head.sha}` + ); + return; + } const comments = await github.paginate( github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 } @@ -51,7 +177,7 @@ jobs: comment.body.includes(commentMarker) ); - if ((pr.body || "").includes(bodyMarker)) { + if ((livePR.body || "").includes(bodyMarker)) { if (existing) { await github.rest.issues.deleteComment({ owner, @@ -62,7 +188,7 @@ jobs: return; } - const body = fs.readFileSync("/tmp/mainline-pr-comment.md", "utf8"); + const body = fs.readFileSync(process.env.COMMENT_PATH, "utf8"); if (existing) { await github.rest.issues.updateComment({ owner, diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 19a51fb2..ac7dce65 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -13,6 +13,8 @@ var prDescIntentID string var prCommentBase string var prCommentHead string var prCommentBranch string +var prCommentPRNumber int +var prCommentForkURL string var prImportPRNumber int var prImportForkURL string var prImportHeadRef string @@ -61,7 +63,13 @@ var prCommentCmd = &cobra.Command{ return } - comment, err := svc.PRComment(prCommentBase, prCommentHead, prCommentBranch) + comment, err := svc.PRCommentWithOptions(engine.PullRequestCommentOptions{ + Base: prCommentBase, + Head: prCommentHead, + Branch: prCommentBranch, + PRNumber: prCommentPRNumber, + ForkURL: prCommentForkURL, + }) if err != nil { outputError(err) return @@ -128,6 +136,8 @@ func init() { prCommentCmd.Flags().StringVar(&prCommentBase, "base", "", "base commit SHA for the PR range") prCommentCmd.Flags().StringVar(&prCommentHead, "head", "", "head commit SHA for the PR range") prCommentCmd.Flags().StringVar(&prCommentBranch, "branch", "", "PR head branch name fallback") + prCommentCmd.Flags().IntVar(&prCommentPRNumber, "pr", 0, "pull request number for temporary fork refs") + prCommentCmd.Flags().StringVar(&prCommentForkURL, "fork-url", "", "fork repository URL to read contributor intent metadata from") prImportCmd.Flags().IntVar(&prImportPRNumber, "pr", 0, "pull request number for diagnostics") prImportCmd.Flags().StringVar(&prImportForkURL, "fork-url", "", "fork repository URL to discover actor logs from") prImportCmd.Flags().StringVar(&prImportHeadRef, "head-ref", "", "pull request head branch name") diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 233aa0ef..d1e132a4 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -48,6 +48,18 @@ func TestPRImportCommandIsRegistered(t *testing.T) { } } +func TestPRCommentCommandSupportsForkMetadata(t *testing.T) { + cmd, _, err := rootCmd.Find([]string{"pr-comment"}) + if err != nil || cmd.Name() != "pr-comment" { + t.Fatalf("pr-comment command missing: cmd=%v err=%v", cmd, err) + } + for _, name := range []string{"base", "head", "branch", "pr", "fork-url"} { + if cmd.Flags().Lookup(name) == nil { + t.Fatalf("pr-comment missing --%s flag", name) + } + } +} + func TestPublishCommandHasForkRemoteFlag(t *testing.T) { cmd, _, err := rootCmd.Find([]string{"publish"}) if err != nil || cmd.Name() != "publish" { diff --git a/internal/engine/pr.go b/internal/engine/pr.go index 785bc852..55289aaa 100644 --- a/internal/engine/pr.go +++ b/internal/engine/pr.go @@ -15,6 +15,14 @@ const ( prCommentMarker = "" ) +type PullRequestCommentOptions struct { + Base string + Head string + Branch string + PRNumber int + ForkURL string +} + func (s *Service) PRDescription(intentID string) (string, error) { if err := s.requireInit(); err != nil { return "", err @@ -40,16 +48,35 @@ func (s *Service) PRDescription(intentID string) (string, error) { } func (s *Service) PRComment(base, head, branch string) (string, error) { + return s.PRCommentWithOptions(PullRequestCommentOptions{ + Base: base, + Head: head, + Branch: branch, + }) +} + +func (s *Service) PRCommentWithOptions(opts PullRequestCommentOptions) (string, error) { if err := s.requireInit(); err != nil { return "", err } view, _ := s.Store.ReadMainlineView() - if view == nil { + if view == nil && strings.TrimSpace(opts.ForkURL) == "" { return formatMissingPRComment("Mainline view is not available. Run `mainline sync` and retry."), nil } + if view == nil { + view = &domain.MainlineView{SchemaVersion: 1} + } + + if strings.TrimSpace(opts.ForkURL) != "" { + forkIntents, err := s.readForkPRCommentIntents(strings.TrimSpace(opts.ForkURL), opts.PRNumber) + if err != nil { + return "", err + } + view.Intents = mergePRCommentIntents(view.Intents, forkIntents) + } - matches := s.matchPRIntents(view, base, head, branch) + matches := s.matchPRIntents(view, opts.Base, opts.Head, opts.Branch) if len(matches) == 0 { return formatMissingPRComment("No sealed Mainline intent was found for this PR range."), nil } @@ -143,6 +170,9 @@ func formatPRIntent(iv domain.IntentView, level int) string { sb.WriteString(fmt.Sprintf("**Status:** `%s`\n", iv.Status)) } sb.WriteString(fmt.Sprintf("**Title:** %s\n\n", summary.Title)) + if iv.Provenance != nil && iv.Provenance.Kind == forkPRCommentProvenanceKind { + sb.WriteString("> Contributor-published intent; not yet accepted into the upstream Mainline log.\n\n") + } sb.WriteString(subheading + " What changed\n\n") sb.WriteString(summary.What + "\n\n") diff --git a/internal/engine/pr_fork.go b/internal/engine/pr_fork.go new file mode 100644 index 00000000..feaf6de4 --- /dev/null +++ b/internal/engine/pr_fork.go @@ -0,0 +1,236 @@ +package engine + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/mainline-org/mainline/internal/domain" +) + +const forkPRCommentProvenanceKind = "fork_actor_log_preview" + +func (s *Service) readForkPRCommentIntents(forkURL string, prNumber int) ([]domain.IntentView, error) { + cfg, err := s.getTeamConfig() + if err != nil { + return nil, err + } + + remoteRefs, err := s.discoverPullRequestActorRefs(forkURL, cfg.Mainline.ActorLogPrefix, "") + if err != nil { + return nil, err + } + sort.SliceStable(remoteRefs, func(i, j int) bool { + if remoteRefs[i].ActorID != remoteRefs[j].ActorID { + return remoteRefs[i].ActorID < remoteRefs[j].ActorID + } + return remoteRefs[i].SourceRef < remoteRefs[j].SourceRef + }) + + byIntent := make(map[string]domain.IntentView) + conflicted := make(map[string]bool) + for _, remoteRef := range remoteRefs { + intents, err := s.readForkActorPRCommentIntents(forkURL, remoteRef, prNumber) + if err != nil { + return nil, err + } + for _, intent := range intents { + if conflicted[intent.IntentID] { + continue + } + if existing, ok := byIntent[intent.IntentID]; ok && existing.ActorID != intent.ActorID { + delete(byIntent, intent.IntentID) + conflicted[intent.IntentID] = true + continue + } + byIntent[intent.IntentID] = intent + } + } + + out := make([]domain.IntentView, 0, len(byIntent)) + for _, intent := range byIntent { + out = append(out, intent) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].ActorID != out[j].ActorID { + return out[i].ActorID < out[j].ActorID + } + return out[i].IntentID < out[j].IntentID + }) + return out, nil +} + +func (s *Service) readForkActorPRCommentIntents( + forkURL string, + remoteRef pullRequestActorRef, + prNumber int, +) ([]domain.IntentView, error) { + importRef := forkPRCommentImportRef(remoteRef.ActorID, prNumber) + defer func() { + _, _ = s.Git.Run("update-ref", "-d", importRef) + }() + + refspec := "+" + remoteRef.SourceRef + ":" + importRef + if err := s.Git.Fetch(forkURL, refspec); err != nil { + return nil, domain.NewRecoverableError( + domain.ErrSyncFailed, + fmt.Sprintf("fetch actor log %s from %s failed: %v", remoteRef.SourceRef, forkURL, err), + "check the fork URL", + "ask the contributor to run mainline publish --remote ", + ) + } + if actual := s.Git.ReadRef(importRef); actual != remoteRef.SourceHead { + return nil, domain.NewRecoverableError( + domain.ErrSyncFailed, + fmt.Sprintf("fork actor log %s changed while preparing the PR comment", remoteRef.SourceRef), + "retry the PR comment workflow against the latest fork state", + ) + } + + rawEvents, err := s.Store.ReadActorLogEventsFromRef(importRef) + if err != nil { + return nil, domain.NewRecoverableError( + domain.ErrSyncFailed, + fmt.Sprintf("read actor log %s failed: %v", remoteRef.SourceRef, err), + "retry the PR comment workflow", + ) + } + + intents := make(map[string]domain.IntentView) + for index, raw := range rawEvents { + var base domain.BaseEvent + if err := json.Unmarshal(raw, &base); err != nil { + return nil, domain.NewError( + domain.ErrInvalidInput, + fmt.Sprintf("fork actor log event %d is not valid JSON: %v", index, err), + ) + } + if base.ActorID != remoteRef.ActorID { + continue + } + + switch base.EventType { + case domain.EventIntentSealed: + var event domain.IntentSealedEvent + if err := json.Unmarshal(raw, &event); err != nil { + return nil, domain.NewError( + domain.ErrInvalidInput, + fmt.Sprintf("fork sealed event %s is invalid: %v", base.EventID, err), + ) + } + if strings.TrimSpace(event.IntentID) == "" { + continue + } + intents[event.IntentID] = forkPRIntentView(event, forkURL, remoteRef) + case domain.EventIntentAbandoned: + var event domain.IntentAbandonedEvent + if err := json.Unmarshal(raw, &event); err != nil { + return nil, domain.NewError( + domain.ErrInvalidInput, + fmt.Sprintf("fork abandoned event %s is invalid: %v", base.EventID, err), + ) + } + if intent, ok := intents[event.IntentID]; ok { + intent.Status = domain.StatusAbandoned + intents[event.IntentID] = intent + } + case domain.EventIntentSuperseded: + var event domain.IntentSupersededEvent + if err := json.Unmarshal(raw, &event); err != nil { + return nil, domain.NewError( + domain.ErrInvalidInput, + fmt.Sprintf("fork superseded event %s is invalid: %v", base.EventID, err), + ) + } + if intent, ok := intents[event.IntentID]; ok { + intent.Status = domain.StatusSuperseded + intents[event.IntentID] = intent + } + } + } + + out := make([]domain.IntentView, 0, len(intents)) + for _, intent := range intents { + out = append(out, intent) + } + return out, nil +} + +func forkPRIntentView( + event domain.IntentSealedEvent, + forkURL string, + remoteRef pullRequestActorRef, +) domain.IntentView { + summary := event.Summary + summary.UserGoal = event.Goal + + worktreeStatus := event.WorktreeStatus + if worktreeStatus == "" { + worktreeStatus = "clean" + } + sealedAtBranch := event.SealedAtBranch + if sealedAtBranch == "" { + sealedAtBranch = event.GitBranch + } + evidenceComplete := event.EvidenceComplete + if event.WorktreeStatus == "" { + evidenceComplete = true + } + + return domain.IntentView{ + IntentID: event.IntentID, + SchemaVersion: 1, + Status: domain.StatusProposed, + Publication: "published", + ActorID: event.ActorID, + ActorName: event.ActorName, + Thread: event.Thread, + GitBranch: event.GitBranch, + Goal: event.Goal, + SealedAt: event.SealedAt, + BaseCommit: event.BaseCommit, + CodeCommit: event.CodeCommit, + CodeTree: event.CodeTree, + BackfillCommits: event.BackfillCommits, + Summary: &summary, + Fingerprint: &event.Fingerprint, + References: event.References, + StatusEvidence: domain.StatusEvidence{ + SealedEventID: event.EventID, + EvidenceComplete: evidenceComplete, + WorktreeStatus: worktreeStatus, + SealedAtBranch: sealedAtBranch, + }, + Provenance: &domain.IntentProvenance{ + Kind: forkPRCommentProvenanceKind, + SourceRemote: forkURL, + SourceRef: remoteRef.SourceRef, + SourceHead: remoteRef.SourceHead, + }, + } +} + +func forkPRCommentImportRef(actorID string, prNumber int) string { + if prNumber > 0 { + return fmt.Sprintf("refs/mainline/imports/pr-comments/pr-%d/%s/log", prNumber, actorID) + } + return "refs/mainline/imports/pr-comments/manual/" + actorID + "/log" +} + +func mergePRCommentIntents(upstream, fork []domain.IntentView) []domain.IntentView { + out := make([]domain.IntentView, 0, len(upstream)+len(fork)) + seen := make(map[string]bool, len(upstream)+len(fork)) + for _, intent := range upstream { + out = append(out, intent) + seen[intent.IntentID] = true + } + for _, intent := range fork { + if seen[intent.IntentID] { + continue + } + out = append(out, intent) + seen[intent.IntentID] = true + } + return out +} diff --git a/internal/engine/pr_fork_test.go b/internal/engine/pr_fork_test.go new file mode 100644 index 00000000..8ee4656f --- /dev/null +++ b/internal/engine/pr_fork_test.go @@ -0,0 +1,278 @@ +package engine + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/mainline-org/mainline/internal/domain" +) + +func TestPRCommentWithOptionsReadsForkIntentWithoutImporting(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("maintainer"); err != nil { + t.Fatalf("init: %v", err) + } + + forkDir := cloneForkForPRImport(t, dir) + branch := "feature/fork-pr-comment" + codeCommit, codeTree := seedForkPRBranch(t, forkDir, branch, "sources/fork_pr_comment.go") + actorID := "actor_fork_pr_comment" + intentID := "int_fork_pr_comment" + writeForkActorLog(t, svc, forkDir, actorID, intentID, branch, codeCommit, codeTree) + fetchForkPRHeadForComment(t, svc, forkDir, branch, 203) + + beforeView, _ := svc.Store.ReadMainlineView() + beforeViewJSON, _ := json.Marshal(beforeView) + beforeNotes := svc.Git.ReadRef("refs/notes/mainline/intents") + sourceRef := domain.ActorLogRef(actorID, domain.DefaultActorLogPrefix) + beforeSourceHead := strings.TrimSpace(mustGitRun(t, forkDir, "rev-parse", sourceRef)) + + comment, err := svc.PRCommentWithOptions(PullRequestCommentOptions{ + Base: svc.Git.ReadRef("refs/heads/main"), + Head: codeCommit, + Branch: branch, + PRNumber: 203, + ForkURL: forkDir, + }) + if err != nil { + t.Fatalf("fork PR comment: %v", err) + } + for _, want := range []string{ + prCommentMarker, + intentID, + "Fork PR import fixture", + "Contributor-published intent; not yet accepted into the upstream Mainline log.", + } { + if !strings.Contains(comment, want) { + t.Fatalf("fork comment missing %q:\n%s", want, comment) + } + } + + if got := svc.Git.ReadRef(sourceRef); got != "" { + t.Fatalf("PR comment must not accept the fork actor ref, got %s", got) + } + if got := svc.Git.ReadRef("refs/notes/mainline/intents"); got != beforeNotes { + t.Fatalf("PR comment changed Mainline notes: before=%s after=%s", beforeNotes, got) + } + afterView, _ := svc.Store.ReadMainlineView() + afterViewJSON, _ := json.Marshal(afterView) + if string(afterViewJSON) != string(beforeViewJSON) { + t.Fatalf("PR comment changed the upstream Mainline view") + } + if got := strings.TrimSpace(mustGitRun(t, forkDir, "rev-parse", sourceRef)); got != beforeSourceHead { + t.Fatalf("PR comment changed the fork actor ref: before=%s after=%s", beforeSourceHead, got) + } + assertNoForkPRCommentImportRefs(t, svc) +} + +func TestPRCommentWithOptionsRendersMultipleForkIntentsAndFiltersTerminalState(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("maintainer"); err != nil { + t.Fatalf("init: %v", err) + } + + forkDir := cloneForkForPRImport(t, dir) + branch := "feature/fork-pr-comment-multiple" + gitCmd(t, forkDir, "checkout", "-b", branch, "main") + writeFile(t, forkDir, "sources/fork_pr_comment_first.go", "package sources\n") + gitCmd(t, forkDir, "add", "sources/fork_pr_comment_first.go") + gitCmd(t, forkDir, "commit", "-m", "feat: add first fork PR comment fixture") + firstCommit := strings.TrimSpace(mustGitRun(t, forkDir, "rev-parse", "HEAD")) + writeFile(t, forkDir, "sources/fork_pr_comment_second.go", "package sources\n") + gitCmd(t, forkDir, "add", "sources/fork_pr_comment_second.go") + gitCmd(t, forkDir, "commit", "-m", "feat: add second fork PR comment fixture") + headCommit := strings.TrimSpace(mustGitRun(t, forkDir, "rev-parse", "HEAD")) + + writeForkPRCommentIntent(t, forkDir, "actor_fork_comment_first", "int_fork_comment_first", + branch, firstCommit, "2026-07-01T00:00:00Z", "First fork intent") + writeForkPRCommentIntent(t, forkDir, "actor_fork_comment_second", "int_fork_comment_second", + branch, headCommit, "2026-07-02T00:00:00Z", "Second fork intent") + writeForkPRCommentIntent(t, forkDir, "actor_fork_comment_terminal", "int_fork_comment_terminal", + branch, headCommit, "2026-07-03T00:00:00Z", "Abandoned fork intent") + appendForkActorEvent(t, forkDir, "actor_fork_comment_terminal", domain.IntentAbandonedEvent{ + BaseEvent: domain.BaseEvent{ + EventID: "evt_fork_comment_terminal_abandoned", + SchemaVersion: 1, + EventType: domain.EventIntentAbandoned, + ActorID: "actor_fork_comment_terminal", + ActorName: "fork contributor", + Timestamp: "2026-07-04T00:00:00Z", + }, + IntentID: "int_fork_comment_terminal", + Reason: "superseded by the submitted implementation", + }) + fetchForkPRHeadForComment(t, svc, forkDir, branch, 204) + + comment, err := svc.PRCommentWithOptions(PullRequestCommentOptions{ + Base: svc.Git.ReadRef("refs/heads/main"), + Head: headCommit, + Branch: branch, + PRNumber: 204, + ForkURL: forkDir, + }) + if err != nil { + t.Fatalf("fork PR comment: %v", err) + } + firstIndex := strings.Index(comment, "First fork intent") + secondIndex := strings.Index(comment, "Second fork intent") + if firstIndex < 0 || secondIndex < 0 || firstIndex >= secondIndex { + t.Fatalf("fork intents were not rendered in sealed order:\n%s", comment) + } + if strings.Contains(comment, "Abandoned fork intent") { + t.Fatalf("terminal fork intent must not be rendered:\n%s", comment) + } + if strings.Count(comment, "### Mainline Intent") != 2 { + t.Fatalf("expected two fork intents, got:\n%s", comment) + } + assertNoForkPRCommentImportRefs(t, svc) +} + +func TestPRCommentWithOptionsIgnoresMismatchedForkActorEvent(t *testing.T) { + dir, cleanup := testRepo(t) + defer cleanup() + + svc := NewServiceFromRoot(dir) + if _, err := svc.Init("maintainer"); err != nil { + t.Fatalf("init: %v", err) + } + + forkDir := cloneForkForPRImport(t, dir) + branch := "feature/fork-pr-comment-mismatch" + codeCommit, _ := seedForkPRBranch(t, forkDir, branch, "sources/fork_pr_comment_mismatch.go") + sourceActor := "actor_fork_comment_source" + event := forkPRCommentSealedEvent( + "actor_fork_comment_other", + "int_fork_comment_mismatch", + branch, + codeCommit, + "2026-07-01T00:00:00Z", + "Mismatched fork intent", + ) + sourceRef := domain.ActorLogRef(sourceActor, domain.DefaultActorLogPrefix) + forkSvc := NewServiceFromRoot(forkDir) + if err := forkSvc.Git.UpdateRef(sourceRef, writeActorEventCommit(t, forkSvc, event)); err != nil { + t.Fatalf("write mismatched fork actor ref: %v", err) + } + fetchForkPRHeadForComment(t, svc, forkDir, branch, 205) + + comment, err := svc.PRCommentWithOptions(PullRequestCommentOptions{ + Base: svc.Git.ReadRef("refs/heads/main"), + Head: codeCommit, + Branch: branch, + PRNumber: 205, + ForkURL: forkDir, + }) + if err != nil { + t.Fatalf("fork PR comment: %v", err) + } + if !strings.Contains(comment, "No sealed Mainline intent was found for this PR range.") { + t.Fatalf("mismatched actor event should produce the existing missing comment:\n%s", comment) + } + if strings.Contains(comment, "Mismatched fork intent") { + t.Fatalf("mismatched actor event must not be rendered:\n%s", comment) + } + assertNoForkPRCommentImportRefs(t, svc) +} + +func writeForkPRCommentIntent( + t *testing.T, + forkDir, actorID, intentID, branch, codeCommit, sealedAt, title string, +) { + t.Helper() + appendForkActorEvent(t, forkDir, actorID, + forkPRCommentSealedEvent(actorID, intentID, branch, codeCommit, sealedAt, title)) +} + +func forkPRCommentSealedEvent( + actorID, intentID, branch, codeCommit, sealedAt, title string, +) domain.IntentSealedEvent { + return domain.IntentSealedEvent{ + BaseEvent: domain.BaseEvent{ + EventID: "evt_" + intentID + "_sealed", + SchemaVersion: 1, + EventType: domain.EventIntentSealed, + ActorID: actorID, + ActorName: "fork contributor", + Timestamp: sealedAt, + }, + IntentID: intentID, + Thread: branch, + Goal: "exercise fork PR intent comments", + GitBranch: branch, + CodeCommit: codeCommit, + Summary: domain.IntentSummary{ + Title: title, + What: "Rendered contributor-published intent metadata without importing it.", + Why: "Fork PR reviewers need the same Mainline context as same-repository PR reviewers.", + UserGoal: "exercise fork PR intent comments", + }, + Fingerprint: domain.SemanticFingerprint{ + Subsystems: []string{"fork-pr-comment"}, + FilesTouched: []string{"sources"}, + }, + TurnCount: 1, + SealedAt: sealedAt, + } +} + +func appendForkActorEvent(t *testing.T, forkDir, actorID string, event any) { + t.Helper() + forkSvc := NewServiceFromRoot(forkDir) + data, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal fork actor event: %v", err) + } + blobHash, err := forkSvc.Git.HashObject(data) + if err != nil { + t.Fatalf("hash fork actor event: %v", err) + } + treeHash, err := forkSvc.Git.MakeTree("event.json", blobHash) + if err != nil { + t.Fatalf("make fork actor event tree: %v", err) + } + sourceRef := domain.ActorLogRef(actorID, domain.DefaultActorLogPrefix) + parent := forkSvc.Git.ReadRef(sourceRef) + commitHash, err := forkSvc.Git.CommitTree(treeHash, parent, "actor-log-event") + if err != nil { + t.Fatalf("commit fork actor event: %v", err) + } + if err := forkSvc.Git.UpdateRef(sourceRef, commitHash); err != nil { + t.Fatalf("update fork actor ref: %v", err) + } +} + +func fetchForkPRHeadForComment( + t *testing.T, + svc *Service, + forkDir, branch string, + prNumber int, +) { + t.Helper() + refspec := fmt.Sprintf( + "+refs/heads/%s:refs/mainline/imports/pr-heads/pr-%d", + branch, + prNumber, + ) + if err := svc.Git.Fetch(forkDir, refspec); err != nil { + t.Fatalf("fetch fork PR head: %v", err) + } +} + +func assertNoForkPRCommentImportRefs(t *testing.T, svc *Service) { + t.Helper() + refs, err := svc.Git.ListRefs("refs/mainline/imports/pr-comments") + if err != nil { + t.Fatalf("list fork PR comment refs: %v", err) + } + if len(refs) != 0 { + t.Fatalf("fork PR comment refs were not cleaned up: %v", refs) + } +}