Skip to content

feat(ci): add evidence-gated issue-draft composer; add ADR-0022 - #1685

Open
seonghobae wants to merge 5 commits into
mainfrom
design/noema-agent-autofix-review-findings-scope
Open

feat(ci): add evidence-gated issue-draft composer; add ADR-0022#1685
seonghobae wants to merge 5 commits into
mainfrom
design/noema-agent-autofix-review-findings-scope

Conversation

@seonghobae

@seonghobae seonghobae commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

The owner asked that Noema and/or the OpenCode Agent also handle PR follow-up work more broadly,
process review feedback, search the web, find papers, handle issues, and author issues on the
owner's behalf, to the same evidence-based, ADR-traceable standard docs/product-goal-directive.md
already holds PR work to. The hard constraint given alongside the ask — the required,
pull_request_target-triggered review gates (opencode-review.yml, noema-review.yml) must keep
"edit": "deny" — was independently re-verified true against a fresh clone before any design work
started, and nothing in this PR touches that file or that trigger.

  • Architecture decision + trust-boundary reasoning: ADR-0022.
    The required gate (judges first-look, unauthenticated-author content) and the existing
    pr-review-autofix.yml worker (acts only on already-reviewed content, repository_dispatch-only,
    scoped edit allowlist, mandatory fresh re-review of its output) sit on opposite sides of the same
    trust boundary and stay there. noema-core is not chosen as a host for anything in this pass — it
    has no tool/edit/sandbox machinery yet (PR feat(reviewer): extract shared noema-core package (ADR-0014) noema#536, hours old) — but the
    shipped increment is deliberately pure/side-effect-free so a future move there (once ADR-0012
    grows tool machinery) is a lift, not a rewrite. Connects to backlog item 5 (Noema as a reusable DDD
    agent for naruon) and noema's own ADR-0012.
  • Full design + deferred roadmap: the 2026-09-02 dated entry in docs/product-technical-gap-baseline.md
    (web search wiring once contextual-orchestrator#1009 merges, an academic paper-search client,
    wiring --create into a trusted trigger once the directive names issue-creation authorization,
    and eventual noema-core adoption).
  • First increment shipped for real: scripts/ci/issue_draft_composer.py — a pure,
    evidence-gated GitHub issue-draft composer. Rejects a draft with no findings, no citations, or no
    traceable source. Renders the draft to stdout by default; only calls
    gh api -X POST repos/{repo}/issues when invoked with --create explicitly. No workflow in
    this PR wires --create into any scheduled or dispatched trigger
    — a fresh re-read of
    docs/product-goal-directive.md found it authorizes autonomous PR work but never mentions issue
    creation (zero occurrences of "이슈"/"issue" in its 96 lines), a genuine authorization gap flagged
    explicitly rather than assumed away in either direction.

Why this is safe

  • The required review gate's opencode.jsonc is unmodified: edit: deny stays on every agent, and
    this PR adds no MCP/webfetch/websearch surface anywhere.
  • The new module has zero automated trigger surface: nothing in .github/workflows/ calls it. It is
    a CLI a human (or an interactively-working agent) can invoke; unattended use is explicitly
    deferred per ADR-0022 until the standing directive names issue-creation authorization.
  • create_issue's only side effect is a single, explicit gh api POST repos/{repo}/issues call,
    gated behind --create, using the same run()/argv-list (no shell) pattern already used
    throughout scripts/ci.

Developer experience

scripts/ci/issue_draft_composer.py + tests/test_issue_draft_composer.py (28 tests): evidence-gate
rejection for every required field (missing/blank repo, title, summary, source; malformed repo
slug; oversized title; non-list/empty findings; findings missing description or citation;
non-list/malformed labels), Markdown rendering content (summary/evidence/citations/source/
attribution footer), the --create-gated gh api argv (including repeated labels[] fields) via a
monkeypatched run() — the same seam pr_review_fix_scheduler.py's own tests use — the CLI's
draft-only default (asserts run() is never called), error paths (bad JSON, missing file, failed
evidence gate all exit 1 with a clear stderr message), and the __main__ guard via runpy.

$ .venv/bin/python -m coverage run -m pytest tests/test_issue_draft_composer.py -q
28 passed

$ .venv/bin/python -m coverage report --show-missing --include="scripts/ci/issue_draft_composer.py"
Name                                 Stmts   Miss Branch BrPart  Cover   Missing
--------------------------------------------------------------------------------
scripts/ci/issue_draft_composer.py     100      0     30      0   100%

$ .venv/bin/python -m interrogate scripts/ci/issue_draft_composer.py
RESULT: PASSED (minimum: 100.0%, actual: 100.0%)

Full-repository coverage run -m pytest tests was also started locally to confirm nothing else
regressed; this machine had several other concurrent, unrelated agent sessions consuming CPU at the
same time, so it had not finished by the time this PR was opened. No existing file was modified
except two docs (CHANGELOG.md, docs/product-technical-gap-baseline.md) and no existing script
changed, so the blast radius against the existing suite is structurally limited to test collection
succeeding — which the standalone run already confirms for the new file. CI will run the
authoritative hash-pinned full suite on this PR.

User experience

No change for any PR author or reviewer: no new workflow trigger exists, so no issue can appear on
any repository as a side effect of this PR.

Deferred (full detail in ADR-0022 and the gap-baseline entry)

  1. Web search: merge contextual-orchestrator#1009 on its own review, then wire it into
    pr-review-autofix.yml's generated config with a new outbound-scope check (owner:
    contextual-orchestrator, then .github).
  2. Academic paper search: build a minimal ZDR-evaluated client (owner: contextual-orchestrator or
    noema-core once tool-capable, then .github).
  3. Wire --create into a trusted, scoped trigger once docs/product-goal-directive.md explicitly
    authorizes unattended issue creation (owner: .github, blocked on a directive-text decision).
  4. Migrate the composer's pure functions into noema-core as an @agent.tool once it grows
    tool/edit/sandbox machinery (owner: noema).
  5. Re-adopt ADR-0022's reasoning once .github#1682 merges its "immature core" directive text.

🤖 Generated with Claude Code


Devin Review

The owner asked that Noema and/or the OpenCode Agent also handle PR
follow-up work, process review feedback, search the web, find papers,
handle issues, and author issues, to the same evidence-based,
ADR-traceable standard as docs/product-goal-directive.md. Re-verified
the given hard constraint (opencode.jsonc's edit:deny on the
pull_request_target-triggered required review gates) is still true
against a fresh clone before any design work.

Investigation (re-verified, not assumed from a prior read) found:
review/rca/conflict autofix already exists and needed no widening;
contextual-orchestrator's web_search.py is real but unmerged with zero
callers; academic paper search has no code anywhere; noema-core (PR
#536, hours old) has no tool/edit/sandbox machinery and is not a
viable host yet; and, critically, docs/product-goal-directive.md's
standing autonomous-loop authorization is written entirely in PR terms
and never mentions issue creation - a genuine authorization gap, not
an assumption in either direction.

ADR-0022 records the trust-boundary reasoning (the required gate and
the autofix worker sit on opposite sides of the same boundary and stay
there), the chosen architecture, and the full deferred roadmap (web
search wiring, paper search, unattended issue-creation triggering,
noema-core adoption), connecting to backlog item 5 and noema's
ADR-0012.

The one increment shipped for real: scripts/ci/issue_draft_composer.py,
a pure evidence-gated composer that rejects a draft with no findings,
citations, or traceable source, and only calls `gh api -X POST
repos/{repo}/issues` when invoked with --create explicitly. No
workflow wires --create into any trigger in this change, sidestepping
the authorization gap rather than assuming it away. 100%
coverage/docstrings on the new module, matching this repository's
existing scripts/ci gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 5 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: f7b56e9a-fc1d-4edd-a530-ad442c169eca

📥 Commits

Reviewing files that changed from the base of the PR and between f250638 and 300494a.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • docs/adr/0022-agent-pr-followup-search-and-issue-authoring-scope.md
  • docs/product-technical-gap-baseline.md
  • scripts/ci/issue_draft_composer.py
  • tests/test_issue_draft_composer.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 5 potential issues.

Devin Review

Comment on lines +109 to +110
description = _non_empty_str(raw.get("description"), field=f"findings[{index}].description")
citation = _non_empty_str(raw.get("citation"), field=f"findings[{index}].citation")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Placeholder evidence passes the gate

load_draft accepts any nonblank citation and source, including c and z. --create can therefore publish an issue with no traceable evidence.

Prompt for agents
Strengthen scripts/ci/issue_draft_composer.py's load_draft evidence validation so source and every finding citation have a defined, mechanically traceable format rather than merely being nonblank. Decide and document the accepted forms, such as repository path plus line/range, SHA-pinned repository URL, issue/PR URL, DOI, or another durable source identifier. Add rejection tests for arbitrary placeholders such as "c" and "z", while retaining tests for each supported citation form.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +134 to +137
lines = ["## Summary", "", draft.summary, "", "## Evidence", ""]
for finding in draft.findings:
lines.append(f"- {finding.description} ({finding.citation})")
lines.extend(["", "## Source", "", draft.source, "", ATTRIBUTION_FOOTER])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Oversized drafts fail at publication

render_markdown_body accepts unbounded evidence text. A body beyond GitHub's issue limit passes validation, then gh api rejects creation.

Prompt for agents
Add a GitHub issue-body size contract to scripts/ci/issue_draft_composer.py. Validate the final rendered body, including headings and the attribution footer, before invoking gh. Return an IssueDraftError with a clear limit message and add boundary tests for the largest accepted body and the first rejected size. Ensure main handles this validation in both draft and create modes.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +168 to +169
result = json.loads(run(args))
return str(result.get("html_url") or "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Creation failures remain visible

create_issue propagates command and response failures. The CLI exits nonzero rather than reporting a false success, and the shared runner scrubs diagnostics.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +200 to +204
if not args.create:
print(render_draft_text(draft))
return 0

print(create_issue(draft))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Dry-run boundary is effective

Draft mode returns before create_issue, and no workflow references this module. The PR therefore adds no unattended issue-creation path.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

from scripts.ci.pr_review_merge_scheduler import run


REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟨 Repository target permits traversal segments

REPOSITORY_RE accepts ../repo and owner/... Untrusted evidence can alter the REST endpoint instead of selecting one repository.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…independent

The module's try/except ModuleNotFoundError import (the pattern most
cross-referencing scripts/ci modules use to work both package-qualified
and as a bare sibling import) only had its except branch exercised by
accident of which test file happened to run first in the full suite
and mutate sys.path - a stale worktree read had masked this behind
a `# pragma: no cover` initially, and removing it exposed a genuine
98% gap (lines 34-35) when the full suite's actual collection order
was checked instead of assumed.

Added a dedicated test that clears the bare-name sys.modules cache
entry and strips scripts/ci from sys.path before re-executing the
module fresh under a private name, forcing the except branch
deterministically regardless of what else has run. Registers the
fresh module in sys.modules before exec so its dataclasses can resolve
their (`from __future__ import annotations`-deferred) field types via
sys.modules[cls.__module__], which importlib.util.module_from_spec
does not do automatically the way a normal import statement does.

Verified both orderings directly: standalone
(tests/test_issue_draft_composer.py alone) and with
tests/test_agent_mention_sweep.py collected first (the file whose
sys.path mutation had been masking the gap) - both report 100%
coverage on scripts/ci/issue_draft_composer.py now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +273 to +284
monkeypatch.delitem(sys.modules, "pr_review_merge_scheduler", raising=False)
monkeypatch.setattr(sys, "path", [p for p in sys.path if p != SCRIPTS_CI_DIR])

spec = importlib.util.spec_from_file_location(
"issue_draft_composer_fallback_import_check", MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
# Register before exec: the module's dataclasses resolve their string annotations (from
# __future__ import annotations) via sys.modules[cls.__module__], which only exists for a
# normal `import` statement by default.
monkeypatch.setitem(sys.modules, spec.name, module)
spec.loader.exec_module(module)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Fallback test isolates interpreter state

monkeypatch restores the temporary sys.path and sys.modules changes. Registering the fresh module before execution also preserves dataclass annotation resolution.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae seonghobae added area: ci-cd CI, GitHub Actions, checks, release, or supply chain enhancement New feature or request priority: medium Normal-priority or P2 work status: needs-review Open pull request requiring current-head review or checks type: feature New or expanded product capability labels Sep 2, 2026 — with ChatGPT Codex Connector

@cwl-noema-review cwl-noema-review Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Noema LLM review

The PR implements the evidence-gated issue-draft composer as specified in ADR-0022, but it fails to address critical security and correctness issues identified in prior review threads. Specifically, the repository regex allows path traversal segments, the evidence gate is too permissive (accepting arbitrary placeholders), and there is no validation for the final rendered body size, which will lead to API failures for oversized drafts.

Reviewed changed lines

  • scripts/ci/issue_draft_composer.py:38 (RIGHT): REPOSITORY_RE allows dots and dashes in both segments, which permits traversal segments like '..' that could manipulate the REST API endpoint.
  • scripts/ci/issue_draft_composer.py:110 (RIGHT): The evidence gate relies on _non_empty_str, which only checks for non-blank strings. This allows meaningless placeholders (e.g., 'z') to pass as valid citations/sources.
  • scripts/ci/issue_draft_composer.py:134 (RIGHT): render_markdown_body does not enforce a maximum length. GitHub's issue body limit is not checked, meaning the tool will pass local validation but fail during the 'gh api' call.

Adversarial validation

  • scripts/ci/issue_draft_composer.py:38 (RIGHT) confirmed: The repository regex allows traversal segments. — REPOSITORY_RE = re.compile(r'^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') allows dots in both segments.
  • scripts/ci/issue_draft_composer.py:110 (RIGHT) confirmed: Placeholder evidence passes the gate. — _non_empty_str only checks if the string is not blank.
  • Residual risk: High. The tool is vulnerable to API endpoint manipulation via the repo field, allows the creation of issues with non-traceable evidence, and lacks a size contract for the issue body.

Findings

  • [high] scripts/ci/issue_draft_composer.py:38 (RIGHT): Repository target permits traversal segments. REPOSITORY_RE should be tightened to prevent '..' or other segments that could alter the REST endpoint.
  • [medium] scripts/ci/issue_draft_composer.py:134 (RIGHT): Missing GitHub issue-body size contract. The rendered body should be validated against GitHub's size limits before attempting to call the API.
  • [medium] scripts/ci/issue_draft_composer.py:110 (RIGHT): Evidence validation is too weak. Citations and sources should follow a mechanically traceable format rather than just being non-blank.
  • Result: REQUEST_CHANGES
  • Head SHA: 2b06563238221a9cd4ecc776de5f0eff270bdc59
  • Reviewer credential: noema-review-github-app-refresh
  • Actor: cwl-noema-review[bot]

# Conflicts:
#	CHANGELOG.md
#	docs/product-technical-gap-baseline.md

Copy link
Copy Markdown
Contributor Author

Merge-conflict repair (main advanced since this branch's base)

mergeable_state was dirty. Repaired non-destructively in a scratch clone (git fetch origin main <branch> && git merge --no-ff origin/main), non-force push, same branch.

2 conflicting files, both ordinary append-only journal entries under the same dated-entry format — no semantic overlap, kept both sides:

  1. CHANGELOG.md — this branch's Unreleased/Added entry for the issue-draft composer vs. main's independent entries.
  2. docs/product-technical-gap-baseline.md — this branch's 2026-09-02 dated entry vs. main's own later dated entries.

No other file conflicted — this branch's actual payload (scripts/ci/issue_draft_composer.py, tests/test_issue_draft_composer.py, docs/adr/0022-*.md) is a pure new-file addition with no overlap against main's changes, consistent with the PR's own description.

Verification

  • Full suite: coverage run -m pytest tests -q (excluding the 2 pre-existing Python-3.11-vs-3.12+ sandbox-incompatible files) → 2790 passed, 1 skipped, 21 subtests passed
  • coverage report --show-missing --include="scripts/ci/*"100%
  • interrogate --fail-under=100 scripts/ci100%
  • git diff --check on touched files → clean

Pushed non-force to the same branch: 2b065632..e8b8f527 (merge commit e8b8f527, no other history rewritten). Required checks are currently sitting queued org-wide (severe capacity congestion observed this session) — not a defect in this push.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4


Generated by Claude Code

Both conflicts are purely additive and both sides are kept:

- CHANGELOG.md — this branch's entry above main's, newest-first convention.
- docs/product-technical-gap-baseline.md — both sides add a distinct top-level
  `## 2026-09-02 …` section at the same anchor. Unlike a sibling merge on this
  file, neither block continues the paragraph above it (both open with their
  own `##` heading), so concatenation does not orphan any prose.

Verified after resolution that the section sequence reads correctly
(2629 sidecar pool / 2652 this branch's agent-PR scope / 2779 main's
org-queue-sweep / 2818 stale cron) and that fenced code blocks stay balanced.

Verified after resolution: 2919 passed, 1 skipped, coverage 100%,
interrogate 100%; zero conflict markers; `ruff check --select F821` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ci-cd CI, GitHub Actions, checks, release, or supply chain enhancement New feature or request priority: medium Normal-priority or P2 work status: needs-review Open pull request requiring current-head review or checks type: feature New or expanded product capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants