Skip to content

Commit 941005a

Browse files
committed
feat: Implement CICDPipelineVisibilityAssessor (fixes #85)
1 parent 819d7b7 commit 941005a

20 files changed

+454
-86
lines changed

src/agentready/assessors/testing.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Testing assessors for test coverage, naming conventions, and pre-commit hooks."""
22

3+
import re
4+
from pathlib import Path
5+
36
from ..models.attribute import Attribute
47
from ..models.finding import Citation, Finding, Remediation
58
from ..models.repository import Repository
@@ -314,3 +317,328 @@ def _create_remediation(self) -> Remediation:
314317
)
315318
],
316319
)
320+
321+
322+
class CICDPipelineVisibilityAssessor(BaseAssessor):
323+
"""Assesses CI/CD pipeline configuration visibility and quality.
324+
325+
Tier 3 Important (1.5% weight) - Clear CI/CD configs enable AI to understand
326+
build/test/deploy processes and suggest improvements.
327+
"""
328+
329+
@property
330+
def attribute_id(self) -> str:
331+
return "cicd_pipeline_visibility"
332+
333+
@property
334+
def tier(self) -> int:
335+
return 3 # Important
336+
337+
@property
338+
def attribute(self) -> Attribute:
339+
return Attribute(
340+
id=self.attribute_id,
341+
name="CI/CD Pipeline Visibility",
342+
category="Testing & CI/CD",
343+
tier=self.tier,
344+
description="Clear, well-documented CI/CD configuration files",
345+
criteria="CI config with descriptive names, caching, parallelization",
346+
default_weight=0.015,
347+
)
348+
349+
def assess(self, repository: Repository) -> Finding:
350+
"""Check for CI/CD configuration and assess quality.
351+
352+
Scoring:
353+
- CI config exists (50%)
354+
- Config quality (30%): descriptive names, caching, parallelization
355+
- Best practices (20%): comments, artifacts
356+
"""
357+
# Check for CI config files
358+
ci_configs = self._detect_ci_configs(repository)
359+
360+
if not ci_configs:
361+
return Finding(
362+
attribute=self.attribute,
363+
status="fail",
364+
score=0.0,
365+
measured_value="no CI config",
366+
threshold="CI config present",
367+
evidence=[
368+
"No CI/CD configuration found",
369+
"Checked: GitHub Actions, GitLab CI, CircleCI, Travis CI",
370+
],
371+
remediation=self._create_remediation(),
372+
error_message=None,
373+
)
374+
375+
# Score: CI exists (50%)
376+
score = 50
377+
evidence = [
378+
f"CI config found: {', '.join(str(c.relative_to(repository.path)) for c in ci_configs)}"
379+
]
380+
381+
# Analyze first CI config for quality
382+
primary_config = ci_configs[0]
383+
quality_score, quality_evidence = self._assess_config_quality(primary_config)
384+
score += quality_score
385+
evidence.extend(quality_evidence)
386+
387+
status = "pass" if score >= 75 else "fail"
388+
389+
return Finding(
390+
attribute=self.attribute,
391+
status=status,
392+
score=score,
393+
measured_value=(
394+
"configured with best practices" if score >= 75 else "basic config"
395+
),
396+
threshold="CI with best practices",
397+
evidence=evidence,
398+
remediation=self._create_remediation() if status == "fail" else None,
399+
error_message=None,
400+
)
401+
402+
def _detect_ci_configs(self, repository: Repository) -> list:
403+
"""Detect CI/CD configuration files."""
404+
ci_config_checks = [
405+
repository.path / ".github" / "workflows", # GitHub Actions (directory)
406+
repository.path / ".gitlab-ci.yml", # GitLab CI
407+
repository.path / ".circleci" / "config.yml", # CircleCI
408+
repository.path / ".travis.yml", # Travis CI
409+
repository.path / "Jenkinsfile", # Jenkins
410+
]
411+
412+
configs = []
413+
for config_path in ci_config_checks:
414+
if config_path.exists():
415+
if config_path.is_dir():
416+
# GitHub Actions: check for workflow files
417+
workflow_files = list(config_path.glob("*.yml")) + list(
418+
config_path.glob("*.yaml")
419+
)
420+
if workflow_files:
421+
configs.extend(workflow_files)
422+
else:
423+
configs.append(config_path)
424+
425+
return configs
426+
427+
def _assess_config_quality(self, config_file: Path) -> tuple:
428+
"""Assess quality of CI config file.
429+
430+
Returns:
431+
Tuple of (quality_score, evidence_list)
432+
quality_score: 0-50 (30 for quality checks + 20 for best practices)
433+
"""
434+
try:
435+
content = config_file.read_text()
436+
except OSError:
437+
return (0, ["Could not read CI config file"])
438+
439+
quality_score = 0
440+
evidence = []
441+
442+
# Quality checks (30 points total)
443+
# Descriptive job/step names (10 points)
444+
if self._has_descriptive_names(content):
445+
quality_score += 10
446+
evidence.append("Descriptive job/step names found")
447+
else:
448+
evidence.append("Generic job names (consider more descriptive names)")
449+
450+
# Caching configured (10 points)
451+
if self._has_caching(content):
452+
quality_score += 10
453+
evidence.append("Caching configured")
454+
else:
455+
evidence.append("No caching detected")
456+
457+
# Parallelization (10 points)
458+
if self._has_parallelization(content):
459+
quality_score += 10
460+
evidence.append("Parallel job execution detected")
461+
else:
462+
evidence.append("No parallelization detected")
463+
464+
# Best practices (20 points total)
465+
# Comments in config (10 points)
466+
if self._has_comments(content):
467+
quality_score += 10
468+
evidence.append("Config includes comments")
469+
470+
# Artifact uploading (10 points)
471+
if self._has_artifacts(content):
472+
quality_score += 10
473+
evidence.append("Artifacts uploaded")
474+
475+
return (quality_score, evidence)
476+
477+
def _has_descriptive_names(self, content: str) -> bool:
478+
"""Check for descriptive job/step names (not just 'build', 'test')."""
479+
# Look for name fields with descriptive text (>2 words or specific actions)
480+
descriptive_patterns = [
481+
r'name:\s*["\']?[A-Z][^"\'\n]{20,}', # Long descriptive names
482+
r'name:\s*["\']?(?:Run|Build|Deploy|Install|Lint|Format|Check)\s+\w+', # Action + context
483+
]
484+
485+
return any(
486+
re.search(pattern, content, re.IGNORECASE)
487+
for pattern in descriptive_patterns
488+
)
489+
490+
def _has_caching(self, content: str) -> bool:
491+
"""Check for caching configuration."""
492+
cache_patterns = [
493+
r'cache:\s*["\']?(pip|npm|yarn|maven|gradle)', # GitLab/CircleCI style
494+
r"actions/cache@", # GitHub Actions cache action
495+
r"with:\s*\n\s*cache:", # GitHub Actions setup with cache
496+
]
497+
498+
return any(
499+
re.search(pattern, content, re.IGNORECASE) for pattern in cache_patterns
500+
)
501+
502+
def _has_parallelization(self, content: str) -> bool:
503+
"""Check for parallel job execution."""
504+
parallel_patterns = [
505+
r"jobs:\s*\n\s+\w+:\s*\n.*\n\s+\w+:", # Multiple jobs defined
506+
r"matrix:", # Matrix strategy
507+
r"parallel:\s*\d+", # Explicit parallelization
508+
]
509+
510+
return any(
511+
re.search(pattern, content, re.DOTALL) for pattern in parallel_patterns
512+
)
513+
514+
def _has_comments(self, content: str) -> bool:
515+
"""Check for explanatory comments in config."""
516+
# Look for YAML comments
517+
comment_lines = [
518+
line for line in content.split("\n") if line.strip().startswith("#")
519+
]
520+
# Filter out just shebang or empty comments
521+
meaningful_comments = [c for c in comment_lines if len(c.strip()) > 2]
522+
523+
return len(meaningful_comments) >= 3 # At least 3 meaningful comments
524+
525+
def _has_artifacts(self, content: str) -> bool:
526+
"""Check for artifact uploading."""
527+
artifact_patterns = [
528+
r"actions/upload-artifact@", # GitHub Actions
529+
r"artifacts:", # GitLab CI
530+
r"store_artifacts:", # CircleCI
531+
]
532+
533+
return any(re.search(pattern, content) for pattern in artifact_patterns)
534+
535+
def _create_remediation(self) -> Remediation:
536+
"""Create remediation guidance for CI/CD visibility."""
537+
return Remediation(
538+
summary="Add or improve CI/CD pipeline configuration",
539+
steps=[
540+
"Create CI config for your platform (GitHub Actions, GitLab CI, etc.)",
541+
"Define jobs: lint, test, build",
542+
"Use descriptive job and step names",
543+
"Configure dependency caching",
544+
"Enable parallel job execution",
545+
"Upload artifacts: test results, coverage reports",
546+
"Add status badge to README",
547+
],
548+
tools=["github-actions", "gitlab-ci", "circleci"],
549+
commands=[
550+
"# Create GitHub Actions workflow",
551+
"mkdir -p .github/workflows",
552+
"touch .github/workflows/ci.yml",
553+
"",
554+
"# Validate workflow",
555+
"gh workflow view ci.yml",
556+
],
557+
examples=[
558+
"""# .github/workflows/ci.yml - Good example
559+
560+
name: CI Pipeline
561+
562+
on:
563+
push:
564+
branches: [main]
565+
pull_request:
566+
branches: [main]
567+
568+
jobs:
569+
lint:
570+
name: Lint Code
571+
runs-on: ubuntu-latest
572+
steps:
573+
- uses: actions/checkout@v4
574+
575+
- name: Set up Python
576+
uses: actions/setup-python@v5
577+
with:
578+
python-version: '3.11'
579+
cache: 'pip' # Caching
580+
581+
- name: Install dependencies
582+
run: pip install -r requirements.txt
583+
584+
- name: Run linters
585+
run: |
586+
black --check .
587+
isort --check .
588+
ruff check .
589+
590+
test:
591+
name: Run Tests
592+
runs-on: ubuntu-latest
593+
steps:
594+
- uses: actions/checkout@v4
595+
596+
- name: Set up Python
597+
uses: actions/setup-python@v5
598+
with:
599+
python-version: '3.11'
600+
cache: 'pip'
601+
602+
- name: Install dependencies
603+
run: pip install -r requirements.txt
604+
605+
- name: Run tests with coverage
606+
run: pytest --cov --cov-report=xml
607+
608+
- name: Upload coverage reports
609+
uses: codecov/codecov-action@v3
610+
with:
611+
files: ./coverage.xml
612+
613+
build:
614+
name: Build Package
615+
runs-on: ubuntu-latest
616+
needs: [lint, test] # Runs after lint/test pass
617+
steps:
618+
- uses: actions/checkout@v4
619+
620+
- name: Build package
621+
run: python -m build
622+
623+
- name: Upload build artifacts
624+
uses: actions/upload-artifact@v3
625+
with:
626+
name: dist
627+
path: dist/
628+
""",
629+
],
630+
citations=[
631+
Citation(
632+
source="GitHub",
633+
title="GitHub Actions Documentation",
634+
url="https://docs.github.com/en/actions",
635+
relevance="Official GitHub Actions guide",
636+
),
637+
Citation(
638+
source="CircleCI",
639+
title="CI/CD Best Practices",
640+
url="https://circleci.com/blog/ci-cd-best-practices/",
641+
relevance="Industry best practices for CI/CD",
642+
),
643+
],
644+
)

src/agentready/cli/main.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@
3434
LockFilesAssessor,
3535
create_stub_assessors,
3636
)
37-
from ..assessors.testing import PreCommitHooksAssessor, TestCoverageAssessor
37+
from ..assessors.testing import (
38+
CICDPipelineVisibilityAssessor,
39+
PreCommitHooksAssessor,
40+
TestCoverageAssessor,
41+
)
3842
from ..models.config import Config
3943
from ..reporters.html import HTMLReporter
4044
from ..reporters.markdown import MarkdownReporter
@@ -78,9 +82,10 @@ def create_all_assessors():
7882
GitignoreAssessor(),
7983
OneCommandSetupAssessor(),
8084
CyclomaticComplexityAssessor(), # Actually Tier 3, but including here
81-
# Tier 3 Important (2 implemented)
85+
# Tier 3 Important (4 implemented)
8286
ArchitectureDecisionsAssessor(),
8387
IssuePRTemplatesAssessor(),
88+
CICDPipelineVisibilityAssessor(),
8489
]
8590

8691
# Add remaining stub assessors

src/agentready/cli/research.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,8 @@ def init(output, template):
154154
@click.argument("report_path", type=click.Path(exists=True))
155155
@click.option("--attribute-id", required=True, help="Attribute ID (e.g., '1.3')")
156156
@click.option("--name", required=True, help="Attribute name")
157-
@click.option(
158-
"--tier", type=int, required=True, help="Tier assignment (1-4)"
159-
)
160-
@click.option(
161-
"--category", default="Uncategorized", help="Category name"
162-
)
157+
@click.option("--tier", type=int, required=True, help="Tier assignment (1-4)")
158+
@click.option("--category", default="Uncategorized", help="Category name")
163159
def add_attribute(report_path, attribute_id, name, tier, category):
164160
"""Add new attribute to research report.
165161
@@ -251,9 +247,7 @@ def bump_version(report_path, bump_type, new_version):
251247

252248
@research.command()
253249
@click.argument("report_path", type=click.Path(exists=True))
254-
@click.option(
255-
"--check", is_flag=True, help="Check formatting without making changes"
256-
)
250+
@click.option("--check", is_flag=True, help="Check formatting without making changes")
257251
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
258252
def format(report_path, check, verbose):
259253
"""Lint and format research report.

0 commit comments

Comments
 (0)