OmniNode-ai

integration-gate

Cross-repo integration gate — scans repos for merge-ready PRs, classifies into lanes (fast/standard/high_risk), detects cross-repo dependencies, applies topological ordering, and enqueues into GitHub Merge Queue with Slack gate approval

OmniNode-ai 2 3 Updated 3mo ago

Resources

1
GitHub

Install

npx skillscat add omninode-ai/omniclaude/integration-gate

Install via the SkillsCat registry.

SKILL.md

Integration Gate

Dispatch Requirement

When invoked, dispatch to a polymorphic-agent:

Agent(
  subagent_type="onex:polymorphic-agent",
  description="Integration gate scan",
  prompt="Run the integration-gate skill. <full context>"
)

CRITICAL: subagent_type MUST be "onex:polymorphic-agent" (with the onex: prefix).

Overview

Composable skill that adds cross-repo ordering, lane policy, and observability on top of
GitHub Merge Queue. Where merge-sweep handles merge-ready PRs via gh pr merge --auto,
integration-gate orchestrates PRs through the merge queue with:

  • Lane classification: PRs are classified as fast, standard, or high_risk based on
    file paths, labels, and change volume
  • Cross-repo dependency detection: Dependency links in PR bodies (Depends on OmniNode-ai/repo#N)
    are extracted and used for topological ordering
  • Topological sort with cycle detection: Fail-fast before any enqueue if circular
    dependencies exist
  • Slack gate: HIGH_RISK gate with plan table, lane breakdown, and plan_hash
  • GitHub Merge Queue enqueue: PRs added via node_git_effect.pr_merge(use_merge_queue=True)
  • Queue monitoring: Poll merge state, handle ejections, respect lane-specific retry policies

Architecture:

/integration-gate                          -- policy + cross-repo ordering
  |
  +-> node_git_effect                      -- typed git operations
  |    +-> HandlerGitSubprocess            -- concrete subprocess backend
  |
  +-> GitHub Merge Queue (per-repo)        -- composition testing
  |
  +-> /slack-gate (inline)                 -- gate posting/polling
  |
  +-> Kafka events (node contract)         -- observability

Announce at start: "I'm using the integration-gate skill."

Quick Start

/integration-gate                                          # Scan all repos, full pipeline
/integration-gate --dry-run                                # Plan only, no mutations
/integration-gate --repos omniclaude,omnibase_core         # Limit to specific repos
/integration-gate --lane fast                              # Only fast-lane PRs
/integration-gate --dry-run --lane high_risk               # Preview high-risk PRs
/integration-gate --authors jonahgabriel --since 2026-02-01
/integration-gate --gate-attestation=<run_id>:<plan_hash>:<slack_ts>

Lane Classification

PRs are deterministically classified into lanes. First match wins.

import re
from pathlib import Path

HIGH_RISK_LABELS = {"high-risk", "migration", "breaking"}
HIGH_RISK_PATH_PATTERNS = [
    r"^\.github/workflows/",     # CI changes
    r"/migrations/",             # DB migrations
    r"contract\.yaml$",          # ONEX contracts
    r"pyproject\.toml$",         # Dependency/version changes
]

FAST_LABELS = {"docs-only", "chore"}
FAST_ONLY_EXTENSIONS = {".md", ".txt", ".yml", ".yaml"}
WORKFLOW_PATH = re.compile(r"^\.github/workflows/")


def classify_lane(pr: dict) -> tuple[str, str]:
    """Classify a PR into a lane. Returns (lane, reason).

    Lane precedence: high_risk > fast > standard (default).
    Reason is included in the plan table for explainability.
    """
    files = pr.get("files", [])
    labels = {label["name"] for label in pr.get("labels", [])}

    # High-risk checks (first match wins)
    if labels & HIGH_RISK_LABELS:
        return ("high_risk", f"label: {sorted(labels & HIGH_RISK_LABELS)}")
    if len(files) > 20:
        return ("high_risk", f"{len(files)} files changed")
    for f in files:
        path = f.get("path", "") if isinstance(f, dict) else str(f)
        for pattern in HIGH_RISK_PATH_PATTERNS:
            if re.search(pattern, path):
                return ("high_risk", f"touches {path}")

    # Fast checks
    if labels & FAST_LABELS:
        all_safe = all(
            Path(f.get("path", "") if isinstance(f, dict) else str(f)).suffix
            in FAST_ONLY_EXTENSIONS
            and not WORKFLOW_PATH.match(
                f.get("path", "") if isinstance(f, dict) else str(f)
            )
            for f in files
        )
        if all_safe and len(files) <= 3:
            return (
                "fast",
                f"label: {sorted(labels & FAST_LABELS)}, {len(files)} safe files",
            )

    return ("standard", "default")

Plan table includes lane_reason column for explainability.

Cross-Repo Dependency Detection

Dependencies are extracted from PR body text using regex patterns:

DEP_PATTERNS = [
    re.compile(r"Depends on (?:https://github\.com/)?OmniNode-ai/(\w+)#(\d+)", re.I),
    re.compile(r"After (?:https://github\.com/)?OmniNode-ai/(\w+)#(\d+)", re.I),
]


def extract_cross_repo_deps(pr_body: str) -> list[tuple[str, int]]:
    """Extract cross-repo dependencies from PR body.

    Returns list of (repo_name, pr_number) tuples.
    """
    deps = []
    for pattern in DEP_PATTERNS:
        for match in pattern.finditer(pr_body or ""):
            deps.append((match.group(1), int(match.group(2))))
    return deps

Cycle Policy

Deterministic, fail-fast: On cycle detection, the entire run fails before any enqueue.

  • Emit cycle edges in ModelSkillResult output
  • Do NOT enqueue non-cycle PRs (partial enqueue creates new conflicts)
  • Status: error with error type CROSS_REPO_CYCLE

Topological Sort

Tie-break rule for stable ordering: when dependencies do not constrain order, sort by
(repo_name, pr_number) ascending. This guarantees identical ordering across repeated
runs with the same input.

Gate Attestation

Format: <run_id>:<plan_hash>:<slack_ts>

  • plan_hash = SHA-256 of normalized plan table (repo, pr_number, lane, depends_on -- sorted)
  • Validation: recompute plan_hash from current state, compare to attestation
  • Reject on mismatch (plan changed since approval) with GATE_PLAN_DRIFT

PR Merge Readiness Predicate

Reuses the merge-sweep predicate:

def is_merge_ready(pr: dict, require_approval: bool = True) -> bool:
    """PR is safe to enqueue into merge queue."""
    if pr.get("isDraft"):
        return False
    if pr.get("mergeable") != "MERGEABLE":
        return False
    required_checks = [
        c for c in pr.get("statusCheckRollup", []) if c.get("isRequired")
    ]
    if required_checks and not all(
        c.get("conclusion") == "SUCCESS" for c in required_checks
    ):
        return False
    if require_approval:
        return pr.get("reviewDecision") in ("APPROVED", None)
    return True

Arguments

Argument Default Description
--repos all Comma-separated repo names
--lane all fast | standard | high_risk | all
--dry-run false Plan only, no mutations
--run-id generated Run identifier
--gate-attestation none Bypass Slack gate (bound to plan_hash)
--max-queue-size 50 Max PRs per run
--require-approval true Require GH APPROVED review
--authors all Filter by GitHub usernames
--since none Filter by ISO 8601 date
--label all Filter by GitHub labels
--monitor-timeout-minutes 90 Queue watch timeout

Execution Algorithm

1. VALIDATE: parse arguments, validate --since date, validate --gate-attestation format

2. SCAN (parallel, per repo):
   node_git_effect.pr_list() with json_fields:
     number, title, mergeable, statusCheckRollup, reviewDecision,
     headRefName, baseRefName, baseRepository, headRepository,
     headRefOid, author, labels, updatedAt, isDraft, body, files

3. CLASSIFY:
   - Apply filters (--authors, --since, --label)
   - Apply is_merge_ready() predicate
   - Classify into lanes via classify_lane()
   - Extract cross-repo deps from PR body
   - Apply --lane filter
   - Apply --max-queue-size cap

4. DEPENDENCY ANALYSIS:
   - Build dependency graph from extracted deps
   - Run topological sort with (repo_name, pr_number) tie-break
   - On cycle: FAIL with CROSS_REPO_CYCLE (emit cycle edges), exit

5. PLAN DISPLAY:
   - Table: repo | PR | lane | lane_reason | depends_on | action
   - Compute plan_hash (SHA-256 of normalized table)
   - --dry-run exits here with nothing_to_queue status

6. SLACK GATE:
   - Post HIGH_RISK gate with plan table + lane breakdown + plan_hash
   - High-risk PRs individually called out with file paths
   - --gate-attestation bypasses (validated: run_id match, plan_hash match)
   - Rejection -> gate_rejected status, zero mutations

7. ENQUEUE (by lane):
   - Fast lane first: node_git_effect.pr_merge(use_merge_queue=True) per PR
   - Standard lane: topological order, respect cross-repo deps
     - Blocker merges first -> wait -> then blocked PR enters queue
     - Independent PRs added simultaneously
   - High-risk last: one at a time, monitor result before next

8. MONITOR:
   - Poll node_git_effect.pr_view() for merge state
   - On ejection: fetch failing checks, post to Slack thread
   - Lane-specific retry:
     - fast: continue with unrelated repos
     - standard: stop chain when blocker fails
     - high_risk: stop entirely
   - On merge: unblock dependent PRs, update state
   - Timeout: report partial results

9. SUMMARY:
   - Slack LOW_RISK summary (merged/ejected/blocked per lane)
   - Emit ModelSkillResult
   - Emit Kafka event via node contract

ModelSkillResult

Written to ~/.claude/skill-results/<run_id>/integration-gate.json:

{
  "skill": "integration-gate",
  "status": "queued | merged | partial | nothing_to_queue | gate_rejected | error",
  "run_id": "<run_id>",
  "plan_hash": "<sha256>",
  "filters": {
    "since": "<date or null>",
    "labels": [],
    "authors": [],
    "repos": [],
    "lane": "all"
  },
  "candidates_found": 5,
  "enqueued": 4,
  "merged": 3,
  "ejected": 1,
  "skipped": 0,
  "by_lane": {
    "fast": {"enqueued": 2, "merged": 2, "ejected": 0},
    "standard": {"enqueued": 1, "merged": 1, "ejected": 0},
    "high_risk": {"enqueued": 1, "merged": 0, "ejected": 1}
  },
  "cycle_edges": [],
  "details": [
    {
      "repo": "OmniNode-ai/omniclaude",
      "pr": 247,
      "head_sha": "cbca770e",
      "lane": "fast",
      "lane_reason": "label: ['chore'], 1 safe files",
      "depends_on": [],
      "result": "merged",
      "ejection_reason": null
    }
  ]
}

Error Types

Error Description Behavior
QUEUE_NOT_ENABLED Merge queue not enabled on repo Skip repo, warn
CROSS_REPO_CYCLE Circular dependency detected Fail entire run before enqueue
QUEUE_EJECTION PR ejected from merge queue Lane-specific retry policy
GATE_REJECTED Slack gate denied Zero mutations
GATE_TIMEOUT Slack gate timed out Zero mutations
GATE_PLAN_DRIFT Gate attestation plan_hash mismatch Reject attestation
MONITOR_TIMEOUT Queue monitoring timed out Report partial results

Failure Handling

Error Behavior
node_git_effect.pr_list fails for a repo Log warning, skip that repo, continue others
node_git_effect.pr_merge fails for a PR Record result: failed; lane-specific retry policy
Cycle detected Fail entire run before any enqueue
Gate rejected Zero mutations; gate_rejected status
Gate attestation invalid Fail with GATE_PLAN_DRIFT
Queue ejection (fast lane) Continue with unrelated repos
Queue ejection (standard lane) Stop the dependency chain
Queue ejection (high_risk lane) Stop entirely
Monitor timeout Report partial results
Slack notification fails Log warning; do NOT fail skill result

Sub-skills Used

  • slack-gate -- gate posting and polling (inline, via @_lib/slack-gate/helpers.md)
  • node_git_effect -- all git/gh operations (typed, structured)

Dependencies

  • node_git_effect with HandlerGitSubprocess (OMN-2817)
  • GitHub Merge Queue enabled on target repos (OMN-2818)
  • @_lib/dependency-tiers/helpers.md -- tier graph helpers
  • @_lib/run-state/helpers.md -- state persistence helpers
  • @_lib/slack-gate/helpers.md -- gate posting/polling helpers

Changelog

  • v1.1.0: Increase default --max-queue-size from 10 to 50. The previous default was
    too conservative for repos with many merge-ready PRs, requiring multiple runs to drain
    the queue.
  • v1.0.0 (OMN-2819): Initial implementation.