diff --git a/appguardrail_core/__init__.py b/appguardrail_core/__init__.py index 9e036e4..8583ab5 100644 --- a/appguardrail_core/__init__.py +++ b/appguardrail_core/__init__.py @@ -27,6 +27,14 @@ SaleReadinessScore, score_sale_readiness, ) +from appguardrail_core.org_intelligence import ( + OrgInventory, + PullRequestGateSummary, + build_org_inventory, + classify_pr_gate, + render_org_readiness_report, + summarize_pr_gates, +) from appguardrail_core.rules import ( RuleMetadata, build_rule_metadata, @@ -43,12 +51,16 @@ "RuleMetadata", "MetricResult", "NON_BLOCKING_CONTEXTS", + "OrgInventory", + "PullRequestGateSummary", "SEVERITIES", "SaleReadinessInputs", "SaleReadinessScore", "StackProfile", "build_external_scan_plan", + "build_org_inventory", "build_rule_metadata", + "classify_pr_gate", "detect_language_axes", "detect_stack_profile", "extract_public_references", @@ -56,9 +68,11 @@ "is_deploy_blocking", "normalize_finding", "normalize_findings", + "render_org_readiness_report", "render_buyer_diligence_report", "safe_report_snippet", "score_sale_readiness", "severity_counts", + "summarize_pr_gates", "validate_rule_metadata", ] diff --git a/appguardrail_core/org_intelligence.py b/appguardrail_core/org_intelligence.py new file mode 100644 index 0000000..8aa35df --- /dev/null +++ b/appguardrail_core/org_intelligence.py @@ -0,0 +1,268 @@ +"""Organization-level repository and PR readiness summaries.""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, Iterable, Mapping + +SUPPORTED_PRIMARY_LANGUAGES = { + "HTML", + "Java", + "JavaScript", + "Python", + "TypeScript", +} + +EXTERNAL_FIRST_LANGUAGES = { + "C++", + "Kotlin", + "R", + "Rust", + "Shell", +} + +TECHNICAL_CHECK_FAILURES = { + "action_required", + "cancelled", + "error", + "failure", + "failed", + "timed_out", +} + +WAITING_CHECK_STATES = { + "", + "expected", + "in_progress", + "pending", + "queued", + "requested", + "waiting", +} + + +@dataclass(frozen=True) +class OrgInventory: + """Repository inventory facts used by product and diligence reports.""" + + total_repositories: int + nonfork_repositories: int + fork_repositories: int + private_repositories: int + supported_nonfork_repositories: int + unsupported_nonfork_languages: tuple[str, ...] + primary_language_counts: tuple[tuple[str, int], ...] + default_branch_counts: tuple[tuple[str, int], ...] + active_repository_target: int + active_repository_target_met: bool + + +@dataclass(frozen=True) +class PullRequestGateSummary: + """PR gate summary that separates source work from external waiting.""" + + total_pull_requests: int + gate_counts: tuple[tuple[str, int], ...] + repository_counts: tuple[tuple[str, int], ...] + + +def build_org_inventory( + repos: Iterable[Mapping[str, Any]], + *, + active_repository_target: int = 20, +) -> OrgInventory: + """Build a stable organization inventory from GitHub repo JSON.""" + repo_list = list(repos) + nonforks = [repo for repo in repo_list if not _truthy(repo.get("isFork"))] + forks = [repo for repo in repo_list if _truthy(repo.get("isFork"))] + primary_languages = Counter(_primary_language(repo) for repo in repo_list) + default_branches = Counter(_default_branch(repo) for repo in repo_list) + unsupported = sorted( + { + _primary_language(repo) + for repo in nonforks + if _primary_language(repo) not in SUPPORTED_PRIMARY_LANGUAGES + and _primary_language(repo) != "Unknown" + } + ) + supported_nonforks = sum( + 1 + for repo in nonforks + if _primary_language(repo) in SUPPORTED_PRIMARY_LANGUAGES + ) + return OrgInventory( + total_repositories=len(repo_list), + nonfork_repositories=len(nonforks), + fork_repositories=len(forks), + private_repositories=sum(1 for repo in repo_list if _truthy(repo.get("isPrivate"))), + supported_nonfork_repositories=supported_nonforks, + unsupported_nonfork_languages=tuple(unsupported), + primary_language_counts=_sorted_counts(primary_languages), + default_branch_counts=_sorted_counts(default_branches), + active_repository_target=active_repository_target, + active_repository_target_met=len(nonforks) >= active_repository_target, + ) + + +def summarize_pr_gates(prs: Iterable[Mapping[str, Any]]) -> PullRequestGateSummary: + """Summarize open PR gates by actionable source work versus external wait.""" + pr_list = list(prs) + gate_counts = Counter(classify_pr_gate(pr) for pr in pr_list) + repository_counts = Counter(_pr_repository(pr) for pr in pr_list) + return PullRequestGateSummary( + total_pull_requests=len(pr_list), + gate_counts=_sorted_counts(gate_counts), + repository_counts=_sorted_counts(repository_counts), + ) + + +def classify_pr_gate(pr: Mapping[str, Any]) -> str: + """Classify a PR into the gate that should drive the next action.""" + if _truthy(pr.get("isDraft")): + return "draft" + review_decision = str(pr.get("reviewDecision") or "").lower() + mergeable = str(pr.get("mergeable") or "").lower() + merge_state = str(pr.get("mergeStateStatus") or "").lower() + check_states = _check_states(pr) + if mergeable == "conflicting" or merge_state == "dirty": + return "source-conflict" + if review_decision == "changes_requested": + return "source-review" + if check_states & TECHNICAL_CHECK_FAILURES: + return "ci-failure" + if check_states and check_states <= WAITING_CHECK_STATES: + return "external-queued" + if review_decision == "review_required": + return "review-required" + if mergeable == "mergeable" and merge_state in {"clean", "has_hooks", "unstable"}: + return "merge-ready" + return "needs-triage" + + +def render_org_readiness_report( + inventory: OrgInventory, + pr_summary: PullRequestGateSummary, + *, + generated_at: str | None = None, +) -> str: + """Render a buyer-readable organization readiness report.""" + generated = generated_at or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + lines = [ + "# AppGuardrail Organization Readiness Report", + "", + f"Generated: {generated}", + "", + "## Repository Inventory", + "", + f"- Total repositories: {inventory.total_repositories}", + f"- Non-fork repositories: {inventory.nonfork_repositories}", + f"- Fork repositories: {inventory.fork_repositories}", + f"- Private repositories: {inventory.private_repositories}", + f"- Supported non-fork primary languages: {inventory.supported_nonfork_repositories}", + f"- Active repository target: {inventory.active_repository_target}", + f"- Active repository target met: {_yes_no(inventory.active_repository_target_met)}", + "", + "### Primary Languages", + "", + *_table("Language", inventory.primary_language_counts), + "", + "### Default Branches", + "", + *_table("Branch", inventory.default_branch_counts), + "", + "## Open PR Gate Summary", + "", + f"- Open PRs analyzed: {pr_summary.total_pull_requests}", + "", + *_table("Gate", pr_summary.gate_counts), + "", + "## Recommendations", + "", + *_recommendations(inventory, pr_summary), + "", + ] + return "\n".join(lines).rstrip() + "\n" + + +def _recommendations( + inventory: OrgInventory, pr_summary: PullRequestGateSummary +) -> list[str]: + recommendations: list[str] = [] + if inventory.unsupported_nonfork_languages: + languages = ", ".join(inventory.unsupported_nonfork_languages) + recommendations.append( + f"- Unsupported non-fork primary languages: {languages}. Use external engines first and promote only repeated, precise patterns into built-in rules." + ) + if inventory.active_repository_target_met: + recommendations.append( + "- The organization already meets the active repository count used by the sale-readiness KPI model." + ) + else: + recommendations.append( + "- Active repository coverage is still below the sale-readiness KPI target." + ) + gate_counts = dict(pr_summary.gate_counts) + if gate_counts.get("external-queued") or gate_counts.get("review-required"): + recommendations.append( + "- Queued checks or review waiting are tracked as external gates, not source defects." + ) + if gate_counts.get("source-conflict") or gate_counts.get("source-review"): + recommendations.append( + "- Source conflicts and change-requested PRs need separate product work before merge." + ) + if gate_counts.get("ci-failure"): + recommendations.append( + "- CI failures should be routed through AppGuardrail IssueOps with redacted logs and deduplicated issue comments." + ) + return recommendations or ["- No immediate org readiness recommendations."] + + +def _primary_language(repo: Mapping[str, Any]) -> str: + language = repo.get("primaryLanguage") + if isinstance(language, Mapping): + return str(language.get("name") or "Unknown") + return str(language or "Unknown") + + +def _default_branch(repo: Mapping[str, Any]) -> str: + branch = repo.get("defaultBranchRef") + if isinstance(branch, Mapping): + return str(branch.get("name") or "Unknown") + return str(branch or "Unknown") + + +def _pr_repository(pr: Mapping[str, Any]) -> str: + repo = pr.get("repository") + if isinstance(repo, Mapping): + return str(repo.get("nameWithOwner") or repo.get("name") or "unknown") + return str(repo or "unknown") + + +def _check_states(pr: Mapping[str, Any]) -> set[str]: + states: set[str] = set() + for check in pr.get("statusCheckRollup") or (): + if not isinstance(check, Mapping): + continue + value = check.get("conclusion") or check.get("status") or check.get("state") + states.add(str(value or "").lower()) + return states + + +def _truthy(value: Any) -> bool: + return value is True or str(value).lower() in {"1", "true", "yes"} + + +def _sorted_counts(counter: Counter[str]) -> tuple[tuple[str, int], ...]: + return tuple(sorted(counter.items(), key=lambda item: (-item[1], item[0]))) + + +def _table(label: str, rows: tuple[tuple[str, int], ...]) -> list[str]: + if not rows: + return [f"| {label} | Count |", "|---|---:|", "| n/a | 0 |"] + return [f"| {label} | Count |", "|---|---:|", *[f"| {key} | {count} |" for key, count in rows]] + + +def _yes_no(value: bool) -> str: + return "yes" if value else "no" diff --git a/docs/product/2026-07-03-phase3-org-readiness-plan.md b/docs/product/2026-07-03-phase3-org-readiness-plan.md new file mode 100644 index 0000000..66c3dce --- /dev/null +++ b/docs/product/2026-07-03-phase3-org-readiness-plan.md @@ -0,0 +1,97 @@ +# AppGuardrail Phase 3 Org Readiness Plan + +Date: 2026-07-03 +Status: Active execution plan +Goal: make AppGuardrail credible as a 2B KRW sale-readiness product by turning +ContextualWisdomLab organization evidence into repeatable product inputs. + +## Live Evidence Reviewed + +- `ContextualWisdomLab/appguardrail` default branch is `develop`. +- `ContextualWisdomLab` had 26 non-archived repositories at the review point: + 20 non-forks and 6 forks. +- Primary-language distribution at the review point: Python 11, TypeScript 4, + JavaScript 3, Shell 2, R 2, Rust 1, Java 1, C++ 1, Kotlin 1. +- GitHub search returned 200 open PRs before exhausting the requested page, + which means the org has at least 200 open PRs needing classification. +- A later repo-by-repo detailed PR pass with a 30 PR per repository cap + classified 309 open PRs: 109 source conflicts, 61 source review items, + 54 needs-triage items, 34 CI failures, 24 review-required items, + 21 external-queued gates, and 6 merge-ready PRs. +- `appguardrail` itself had 6 open PRs. PR #157 was conflicting and had + current unresolved product-compatibility review comments, so it was not a + review-process-only blocker. +- GitHub Actions required checks for recent `develop` and PR runs were queued, + which is operational evidence but not a source-code blocker under the current + execution policy. +- Product Design saved context was not configured, so current repo files, + GitHub state, and generated Figma artifacts are the source of truth. +- Ponytail debt scan returned no `ponytail:` markers. + +## Plugin Perspectives Applied + +### Superpowers + +Use a small branch per sale-readiness increment. Keep each increment backed by +a plan, tests, verification, PR, and explicit blocker classification. + +### Product Design + +The beginner-facing product cannot require a user to choose language profiles, +scanner engines, or workflow categories before first value. The useful first +screen is an operational posture surface: repo coverage, queued gates, source +work, CI failures, and report exports. + +### Figma + +Figma Code Connect remains out of scope. Use FigJam/Figma artifacts only to +explain the product loop and buyer demo flow: repos and PRs become normalized +org intelligence, org intelligence drives IssueOps and reports, and reports +support founder and buyer diligence conversations. + +### Data Analytics + +The KPI model should consume aggregate organization facts, not raw customer +code or full logs. The first measurable org facts are active repository count, +supported language coverage, PR gate split, CI failure routing, report exports, +and duplicate suppression. + +### Ponytail + +No local `ponytail:` debt markers were found in this worktree. Future shortcuts +must name a ceiling and upgrade trigger before merge. + +## Library Split Decision + +Do not introduce a submodule in Phase 3. + +The repo already has `appguardrail_core`, and the next useful boundary is an +in-repo `org_intelligence` module. It can later become a separately versioned +package only after it has at least three stable consumers, such as CLI report +generation, scheduled GitHub workflow, hosted dashboard, and buyer diligence +export. + +## Immediate Implementation Slice + +1. Add `appguardrail_core.org_intelligence`. +2. Normalize GitHub repo JSON into an organization inventory. +3. Normalize open PR JSON into a gate summary that separates source conflicts, + source review work, CI failures, queued checks, and review waiting. +4. Render a markdown org readiness report from the normalized model. +5. Add `scripts/ci/render_org_readiness_report.py` so live GitHub repository + and PR state can be converted into a report artifact without sending raw + code or logs outside GitHub. +6. Keep checks queued and review-process waiting as external gates, while + keeping conflicts and actual change-requested PRs as product work. + +## Acceptance Criteria + +- Unit tests cover repository counting, language coverage, PR gate + classification, and report recommendations. +- The report identifies unsupported language families that should start with + external engines before built-in regex promotion. +- The implementation keeps `appguardrail_core` importable with no dependencies. +- `python3 -m py_compile`, focused pytest, full pytest, and `appguardrail scan .` + pass on the branch. +- The Figma board is updated with the Phase 3 org intelligence flow and does + not use Code Connect. diff --git a/scripts/ci/render_org_readiness_report.py b/scripts/ci/render_org_readiness_report.py new file mode 100644 index 0000000..b1d2dc5 --- /dev/null +++ b/scripts/ci/render_org_readiness_report.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Render an AppGuardrail organization readiness report from GitHub JSON.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from appguardrail_core.org_intelligence import ( + build_org_inventory, + render_org_readiness_report, + summarize_pr_gates, +) + +REPO_FIELDS = "name,isFork,isPrivate,defaultBranchRef,url,description,visibility,primaryLanguage,pushedAt" +PR_DETAIL_FIELDS = "number,title,updatedAt,isDraft,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,headRefName,baseRefName" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--owner", default="ContextualWisdomLab") + parser.add_argument("--repos-json", help="Path to gh repo list JSON output.") + parser.add_argument("--prs-json", help="Path to gh search prs JSON output.") + parser.add_argument("--out", help="Write markdown report to this path.") + parser.add_argument("--per-repo-pr-limit", type=int, default=100) + parser.add_argument("--active-repository-target", type=int, default=20) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + repos = _load_json(args.repos_json) if args.repos_json else _gh_repo_list(args.owner) + prs = ( + _load_json(args.prs_json) + if args.prs_json + else _gh_pr_list(args.owner, repos, args.per_repo_pr_limit) + ) + inventory = build_org_inventory( + repos, + active_repository_target=args.active_repository_target, + ) + pr_summary = summarize_pr_gates(prs) + report = render_org_readiness_report(inventory, pr_summary) + if args.out: + Path(args.out).write_text(report) + else: + print(report, end="") + return 0 + + +def _load_json(path: str) -> list[dict[str, Any]]: + payload = json.loads(Path(path).read_text()) + if not isinstance(payload, list): + raise SystemExit(f"{path} must contain a JSON array") + return payload + + +def _gh_repo_list(owner: str) -> list[dict[str, Any]]: + return _gh_json( + [ + "repo", + "list", + owner, + "--no-archived", + "--limit", + "200", + "--json", + REPO_FIELDS, + ] + ) + + +def _gh_pr_list( + owner: str, + repos: list[dict[str, Any]], + per_repo_limit: int, +) -> list[dict[str, Any]]: + pulls: list[dict[str, Any]] = [] + for repo in repos: + if repo.get("isFork"): + continue + repo_name = repo.get("name") + if not repo_name: + continue + full_name = f"{owner}/{repo_name}" + for pull in _gh_json( + [ + "pr", + "list", + "--repo", + full_name, + "--state", + "open", + "--limit", + str(per_repo_limit), + "--json", + PR_DETAIL_FIELDS, + ] + ): + pull["repository"] = {"nameWithOwner": full_name} + pulls.append(pull) + return pulls + + +def _gh_json(args: list[str]) -> list[dict[str, Any]]: + gh = shutil.which("gh") + if not gh: + raise SystemExit("gh CLI is required when --repos-json or --prs-json is not provided") + result = subprocess.run( # noqa: S603 - fixed gh command with explicit argv. + [gh, *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=120, + ) + payload = json.loads(result.stdout or "[]") + if not isinstance(payload, list): + raise SystemExit("gh command returned non-array JSON") + return payload + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_org_intelligence_core.py b/tests/test_org_intelligence_core.py new file mode 100644 index 0000000..f6c3646 --- /dev/null +++ b/tests/test_org_intelligence_core.py @@ -0,0 +1,107 @@ +from appguardrail_core.org_intelligence import ( + build_org_inventory, + classify_pr_gate, + render_org_readiness_report, + summarize_pr_gates, +) + + +def test_build_org_inventory_counts_repositories_languages_and_targets(): + repos = [ + {"name": "appguardrail", "isFork": False, "isPrivate": False, "primaryLanguage": {"name": "Python"}, "defaultBranchRef": {"name": "develop"}}, + {"name": "clearfolio", "isFork": False, "isPrivate": False, "primaryLanguage": {"name": "Java"}, "defaultBranchRef": {"name": "main"}}, + {"name": "scopeweave", "isFork": False, "isPrivate": True, "primaryLanguage": {"name": "JavaScript"}, "defaultBranchRef": {"name": "develop"}}, + {"name": "waf-ids-ai-soc", "isFork": False, "isPrivate": False, "primaryLanguage": {"name": "Rust"}, "defaultBranchRef": {"name": "main"}}, + {"name": "html4tree", "isFork": True, "isPrivate": False, "primaryLanguage": {"name": "Kotlin"}, "defaultBranchRef": {"name": "master"}}, + ] + + inventory = build_org_inventory(repos, active_repository_target=4) + + assert inventory.total_repositories == 5 + assert inventory.nonfork_repositories == 4 + assert inventory.fork_repositories == 1 + assert inventory.private_repositories == 1 + assert inventory.supported_nonfork_repositories == 3 + assert inventory.unsupported_nonfork_languages == ("Rust",) + assert inventory.active_repository_target_met is True + assert dict(inventory.primary_language_counts)["Python"] == 1 + assert dict(inventory.default_branch_counts)["develop"] == 2 + + +def test_classify_pr_gate_separates_source_work_from_external_waiting(): + queued = { + "isDraft": False, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "statusCheckRollup": [{"status": "QUEUED"}], + } + conflict = { + "isDraft": False, + "mergeable": "CONFLICTING", + "mergeStateStatus": "DIRTY", + "reviewDecision": "", + "statusCheckRollup": [{"status": "QUEUED"}], + } + changes = { + "isDraft": False, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "CHANGES_REQUESTED", + "statusCheckRollup": [{"conclusion": "SUCCESS"}], + } + failed = { + "isDraft": False, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "", + "statusCheckRollup": [{"conclusion": "FAILURE"}], + } + + assert classify_pr_gate(queued) == "external-queued" + assert classify_pr_gate(conflict) == "source-conflict" + assert classify_pr_gate(changes) == "source-review" + assert classify_pr_gate(failed) == "ci-failure" + + +def test_summarize_pr_gates_and_render_report_include_recommendations(): + repos = [ + {"name": "appguardrail", "isFork": False, "primaryLanguage": {"name": "Python"}, "defaultBranchRef": {"name": "develop"}}, + {"name": "aFIPC", "isFork": False, "primaryLanguage": {"name": "C++"}, "defaultBranchRef": {"name": "master"}}, + ] + prs = [ + { + "repository": {"nameWithOwner": "ContextualWisdomLab/appguardrail"}, + "number": 157, + "title": "CLI emoji UX improvement", + "isDraft": False, + "mergeable": "CONFLICTING", + "mergeStateStatus": "DIRTY", + "reviewDecision": "", + "statusCheckRollup": [{"status": "QUEUED"}], + }, + { + "repository": {"nameWithOwner": "ContextualWisdomLab/appguardrail"}, + "number": 160, + "title": "Queued required workflows", + "isDraft": False, + "mergeable": "MERGEABLE", + "mergeStateStatus": "BLOCKED", + "reviewDecision": "REVIEW_REQUIRED", + "statusCheckRollup": [{"status": "QUEUED"}], + }, + ] + + inventory = build_org_inventory(repos, active_repository_target=2) + summary = summarize_pr_gates(prs) + report = render_org_readiness_report( + inventory, + summary, + generated_at="2026-07-03T00:00:00Z", + ) + + assert summary.total_pull_requests == 2 + assert dict(summary.gate_counts) == {"external-queued": 1, "source-conflict": 1} + assert "Unsupported non-fork primary languages: C++." in report + assert "Queued checks or review waiting are tracked as external gates" in report + assert "Source conflicts and change-requested PRs need separate product work" in report