Testland
Browse all skills & agents

lcov-analysis

Parses both mainstream coverage interchange formats: LCOV `.info` text files (produced by gcov, llvm-cov, Coverage.py via `py2lcov`, JaCoCo via `xml2lcov`, Devel::Cover, Jest via `lcov` reporter, NYC, and most others) and Cobertura XML (coverage-04.dtd - emitted by JaCoCo, coverage.py `--xml`, Jest's `cobertura` reporter, coverlet, gocover-cobertura; full parser in references/cobertura.md). Extracts per-file line / function / branch metrics from the canonical record keywords (TN/SF/FN/FNDA/FNF/FNH/BRDA/BRF/BRH/DA/LH/LF), computes the diff vs a baseline, and emits per-file gating verdicts. Use for PR coverage gates that don't depend on a specific language runtime, whichever of the two formats the CI emits.

Install with skills.sh (any agent)

npx skills add testland/qa --skill lcov-analysis
View source

lcov-analysis

Overview

LCOV is "a tool suite for manipulating and displaying code coverage information" with three command-line utilities: geninfo (creates LCOV data files from raw coverage data), lcov (captures, filters, manipulates, processes in parallel), and genhtml (HTML report generation) (lcov-readme (opens in new window)).

The toolchain is "language-agnostic (via converter scripts: llvm2lcov, py2lcov, perl2lcov, xml2lcov)" (lcov-readme (opens in new window)) - LCOV .info is the lingua franca every coverage UI (Coveralls, Codecov, Codacy, SonarQube, in-house dashboards) ingests.

This skill covers parsing the .info text format directly so the team can gate PRs without running the full HTML generation step. The sibling Cobertura XML format (same PR-gating shape, different parser) is covered in references/cobertura.md - pick whichever format the existing reporter produces.

When to use

  • The CI already emits LCOV or Cobertura XML (or can via a converter) and the team wants PR-time per-file coverage gating.
  • A coverage SaaS isn't an option (compliance, cost, air-gapped CI).
  • A multi-language project needs one analyzer (Python via py2lcov, Java via xml2lcov, Node via nyc/jest's lcov reporter, C++ via gcovlcov).
  • A custom coverage UI / Slack bot needs structured input.

How to use

  1. Confirm CI emits LCOV .info (natively or via a converter: py2lcov, xml2lcov, nyc / Jest's lcov reporter, gcovlcov).
  2. Parse the .info into per-file line / function / branch metrics (Parse below); recompute totals from the per-line records rather than trusting the summary fields.
  3. Cache main's .info as the baseline; diff each PR's coverage against it.
  4. Gate per file on the three rules below (whole-repo drop, per-file drop, new-file minimum) - the diff + gate reference implementation, the full record-format table, and the CI wiring are in references/format-diff-and-ci.md.

Worked example

A single source file's .info record, and how to read it:

TN:
SF:src/checkout/cart.ts
FN:10,addItem
FN:32,removeItem
FNDA:42,addItem
FNDA:0,removeItem
FNF:2
FNH:1
DA:11,42
DA:12,42
DA:13,0
DA:33,0
DA:34,0
LF:5
LH:2
BRDA:13,0,0,42
BRDA:13,0,1,0
BRF:2
BRH:1
end_of_record

Reading: cart.ts has 2 functions, 1 hit (50% function coverage); 5 lines, 2 hit (40% line); 2 branches, 1 hit (50% branch). removeItem was never called (FNDA:0). SF opens the record, DA:<line>,<count> gives per-line hits, LH/LF and BRH/BRF are the roll-ups, and end_of_record closes the file. The full record-keyword table is in references/format-diff-and-ci.md.

Parse

# scripts/parse_lcov.py
from collections import defaultdict

def parse_lcov(path):
    files = []
    cur = None
    with open(path) as f:
        for line in f:
            line = line.strip()
            if line.startswith('SF:'):
                cur = {
                    'path': line[3:],
                    'functions': [],
                    'lines': {},
                    'branches': defaultdict(list),
                    'fnf': 0, 'fnh': 0,
                    'lf': 0, 'lh': 0,
                    'brf': 0, 'brh': 0,
                }
            elif line.startswith('FN:'):
                lineno, name = line[3:].split(',', 1)
                cur['functions'].append({'line': int(lineno), 'name': name, 'hits': 0})
            elif line.startswith('FNDA:'):
                hits, name = line[5:].split(',', 1)
                for fn in cur['functions']:
                    if fn['name'] == name:
                        fn['hits'] = int(hits)
                        break
            elif line.startswith('DA:'):
                lineno, hits = line[3:].split(',', 1)
                cur['lines'][int(lineno)] = int(hits.split(',')[0])  # checksum optional
            elif line.startswith('BRDA:'):
                lineno, block, branch, taken = line[5:].split(',', 3)
                cur['branches'][int(lineno)].append({
                    'block': int(block),
                    'branch': int(branch),
                    'taken': 0 if taken == '-' else int(taken),
                })
            elif line.startswith(('FNF:', 'FNH:', 'LF:', 'LH:', 'BRF:', 'BRH:')):
                key, val = line.split(':', 1)
                cur[key.lower()] = int(val)
            elif line == 'end_of_record':
                files.append(cur)
                cur = None
    return files

Don't trust the FNF/FNH/LF/LH/BRF/BRH summary fields blindly - some buggy emitters produce summaries that don't match the per-line data. For correctness, recompute from lines, functions, branches.

Gate rules

A defensible per-file gate has three rules:

  1. Whole-repo line coverage MAY drop by at most N pp (typically N = 0.5 - guards against runaway erosion without blocking refactors).
  2. No file MAY drop more than M pp (typically M = 5 - calls out the specific file that lost coverage).
  3. New files MUST hit threshold (typically 80% line, 70% branch).

Per-file gates beat whole-repo gates: an aggregate drop hides which file caused it; per-file output gives the reviewer a direct target. The coverage_diff + gate reference implementation and the PR coverage-gate CI job are in references/format-diff-and-ci.md.

Anti-patterns

Anti-patternWhy it failsFix
Whole-repo gate onlyAggregate drops hide which file caused them; review focus is unclear.Per-file gates; per-file PR comments.
BRH < BRF ignoredA single uncovered branch on a critical path silently slips through.Track branch% separately from line%; gate threshold differs.
Trusting FNF/FNH/LF/LH summary fields without recomputingSome emitters produce wrong summaries; gate verdict drifts from the data.Recompute from per-line records (see the Parse note).
Gate against the PR's own merge base (running coverage twice)Slow; flaky if coverage itself is non-deterministic.Cache main's LCOV as an artifact; PRs diff against it (see the CI wiring in references).
Treating new test files as "new code, gate at 80%"The new test file is the test, not the SUT.Filter the file list to source paths only (e.g. src/**, not tests/**).
Strict mode that fails on any dropRefactors that legitimately remove dead code drop coverage; team disables gate.Allow whole-repo drop ≤0.5pp; allow per-file drop ≤5pp; only new files have a hard min.
One unified threshold for line + branchBranch coverage is harder; identical thresholds always fail one or the other.Separate thresholds (e.g. line 80, branch 70).

Limitations

  • .info is text, not standardized via a formal spec. The authoritative source is the lcov-readme (opens in new window) + the codebase itself; format extensions vary by emitter. Tolerant parsing is required.
  • No file-level "this is a test file" marker. Filter by path convention.
  • No PR-context awareness. The format doesn't know about git diff - pair with git diff --name-only to scope coverage changes to PR-touched files when needed.
  • Branch coverage shape varies. Some emitters report per-condition (multi-arm BRDA); some report per-decision (single arm). Normalize before cross-tool comparisons.

References

  • lcov-readme (opens in new window) - LCOV toolchain (geninfo / lcov / genhtml), .info format keywords, language-agnostic converters.
  • references/format-diff-and-ci.md - full .info record-keyword table, the baseline-diff + gate reference implementation, and the PR coverage-gate CI job.
  • references/cobertura.md - the sibling Cobertura XML format (same PR-gating shape, different parser).
  • coverage-diff-reporter - build-an-X workflow that consumes parsed coverage and emits a PR comment with file-level deltas.
  • test-coverage-targeter - picks which uncovered branches to target first using the parsed output.

Cobertura XML - parsing, gating, and cross-tool normalization

View source (opens in new window)

Cobertura XML - parsing, gating, and cross-tool normalization

Reference detail for lcov-analysis (opens in new window): the sibling Cobertura XML format, with the same PR-gating shape as LCOV but a different parser.

Cobertura is "a free Java tool that calculates the percentage of code accessed by tests" (cobertura-home (opens in new window)). Its XML report format - sometimes called coverage-04.dtd after its DTD - became the de-facto JVM coverage interchange and is now emitted by JaCoCo, coverage.py (--xml), Jest's cobertura reporter, Istanbul, gocover-cobertura, .NET's coverlet, and many CI plugins.

Schema (coverage-04.dtd)

Per cobertura-dtd (opens in new window), the DTD declares this hierarchy:

coverage
├── sources*           (paths the report is rooted at)
└── packages
    └── package*       (one per package; in non-Java languages, often per-directory)
        └── classes
            └── class*      (one per file, despite the name)
                ├── methods
                │   └── method*
                │       └── lines/line*
                └── lines
                    └── line*  (line | condition)

Required attributes per cobertura-dtd (opens in new window):

ElementRequired attributes
coverageline-rate, branch-rate, lines-covered, lines-valid, branches-covered, branches-valid, complexity, version, timestamp
packagename, line-rate, branch-rate, complexity
classname, filename, line-rate, branch-rate, complexity
methodname, signature, line-rate, branch-rate, complexity
linenumber, hits, plus branch="false" (default) and condition-coverage="100%" (default)

Two important nuances:

  • line-rate and branch-rate are decimals 0 - 1, not percentages. 0.85 = 85%.
  • class is a misnomer - it usually maps to one source file. Non-Java emitters set name = filename for clarity.

Sample document

<?xml version="1.0" ?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<coverage line-rate="0.78" branch-rate="0.62" lines-covered="156" lines-valid="200"
          branches-covered="31" branches-valid="50" complexity="0" version="2.1.1" timestamp="1715000000">
  <sources>
    <source>src</source>
  </sources>
  <packages>
    <package name="checkout" line-rate="0.92" branch-rate="0.83" complexity="0">
      <classes>
        <class name="cart.ts" filename="checkout/cart.ts" line-rate="0.88" branch-rate="0.75" complexity="0">
          <methods>
            <method name="addItem" signature="(Item)V" line-rate="1.0" branch-rate="1.0" complexity="0">
              <lines><line number="11" hits="42"/></lines>
            </method>
          </methods>
          <lines>
            <line number="11" hits="42"/>
            <line number="12" hits="42"/>
            <line number="13" hits="42" branch="true" condition-coverage="50% (1/2)"/>
            <line number="33" hits="0"/>
          </lines>
        </class>
      </classes>
    </package>
  </packages>
</coverage>

The SYSTEM DOCTYPE URL above no longer resolves, but emitters still write that exact literal - see the hardcoded string in istanbul-reports' cobertura reporter (opens in new window) - so keep it in fixtures and disable external-entity resolution when parsing. A live copy of the DTD text is at raw.githubusercontent.com/cobertura/web (opens in new window).

The condition-coverage attribute on a branch line ("50% (1/2)") means one of two branch arms was hit. Parse it as /(\d+(?:\.\d+)?)% \((\d+)\/(\d+)\)/ to extract (pct, hit, total).

Parse

# scripts/parse_cobertura.py
import re
import xml.etree.ElementTree as ET

CC_RE = re.compile(r'(\d+(?:\.\d+)?)% \((\d+)/(\d+)\)')

def parse_cobertura(path):
    root = ET.parse(path).getroot()
    files = []
    for pkg in root.findall('packages/package'):
        for cls in pkg.findall('classes/class'):
            lines = []
            for ln in cls.findall('lines/line'):
                hit = int(ln.get('hits', '0'))
                line_data = {'number': int(ln.get('number')), 'hits': hit}
                if ln.get('branch') == 'true':
                    cc = CC_RE.match(ln.get('condition-coverage', '0% (0/0)'))
                    if cc:
                        pct, br_hit, br_total = cc.groups()
                        line_data['branch'] = {
                            'pct': float(pct), 'hit': int(br_hit), 'total': int(br_total),
                        }
                lines.append(line_data)
            files.append({
                'package': pkg.get('name'),
                'name': cls.get('name'),
                'filename': cls.get('filename'),
                'line_rate': float(cls.get('line-rate')),
                'branch_rate': float(cls.get('branch-rate')),
                'lines': lines,
            })
    return {
        'overall': {
            'line_rate': float(root.get('line-rate')),
            'branch_rate': float(root.get('branch-rate')),
            'lines_covered': int(root.get('lines-covered')),
            'lines_valid': int(root.get('lines-valid')),
            'branches_covered': int(root.get('branches-covered')),
            'branches_valid': int(root.get('branches-valid')),
        },
        'files': files,
    }

Diff vs baseline + gate

The same shape as the LCOV baseline-diff in the parent SKILL - pivot on filename, compute deltas, apply the per-file + whole-repo gate rules:

def diff(current, baseline):
    base = {f['filename']: f for f in baseline['files']}
    out = []
    for f in current['files']:
        b = base.get(f['filename'])
        out.append({
            'filename': f['filename'],
            'line_now':  f['line_rate']   * 100,
            'line_then': b['line_rate']   * 100 if b else None,
            'branch_now':  f['branch_rate']  * 100,
            'branch_then': b['branch_rate']  * 100 if b else None,
            'is_new': b is None,
        })
    return out

Cross-tool normalization

When the team has Cobertura from one language and LCOV from another, emit a normalized intermediate (file → line% → branch% → uncovered line list) that both parsers feed:

def normalize_cobertura(parsed):
    return [
        {
            'path': f['filename'],
            'line_pct': f['line_rate'] * 100,
            'branch_pct': f['branch_rate'] * 100,
            'uncovered_lines': [ln['number'] for ln in f['lines'] if ln['hits'] == 0],
        }
        for f in parsed['files']
    ]

The downstream gate / reporter consumes the normalized shape, language-agnostic.

CI shape

# Java with JaCoCo emitting Cobertura
- run: ./mvnw -B verify
- run: |
    # JaCoCo's Cobertura output (via maven-jacoco-plugin's report goal):
    cat target/site/jacoco/cobertura.xml > coverage.xml

# Python with coverage.py
- run: |
    coverage run -m pytest
    coverage xml -o coverage.xml

# JavaScript with Jest
- run: npx jest --coverage --coverageReporters=cobertura

# Then parse + gate (same shape regardless of upstream)
- run: python scripts/parse_cobertura.py coverage.xml > current.json
- run: python scripts/coverage_gate.py current.json baseline.json

Anti-patterns

Anti-patternWhy it failsFix
Treating line-rate as a percentageThe DTD specifies decimal 0 - 1 (cobertura-dtd (opens in new window)); code mistakes 0.85 for 85 / 100 mid-pipeline.Multiply by 100 only in display layer; preserve decimal in storage.
Pivoting on class@name instead of class@filename"name" can be a Java FQCN that overlaps two physical files (inner classes).Pivot on filename.
Ignoring condition-coverageBranch coverage drops invisible; line% looks fine while branch% degrades.Parse the pct (hit/total) form; gate branch% separately.
Mixing Cobertura + LCOV without normalizationBranch coverage definitions differ; cross-tool sums lie.Normalize first (above).
Using coverage root's summary blindlySome emitters miscompute the summary on multi-package merges.Recompute by summing lines-covered / lines-valid from all class records.
Loading multi-100MB XML with ET.parseWhole-tree-in-memory; OOM on large reports.ET.iterparse for streaming + element clearing.
Assuming package@name == JVM packageNon-Java emitters use it for directory paths or arbitrary labels.Treat package@name as a label only; group by filename.

Limitations

  • DTD is permissive. Some emitters omit the <methods> block; some omit <sources>; some emit complexity="0" regardless. Tolerant parsing is required.
  • Per-condition vs per-decision branch reporting varies. JaCoCo reports per-condition; coverage.py reports per-decision. Don't compare branch% across emitters without flagging the difference.
  • No native PR / commit / VCS metadata. The format is a snapshot of a single run. Pair with git context for diff-aware gating.
  • hits is a count, not a unique-test count. hits=0 ≠ "no test exists" - a test may exercise the line via a path the instrumentation didn't observe.

References

  • cobertura-home (opens in new window) - Cobertura overview and tool positioning ("free Java tool that calculates the percentage of code accessed by tests").
  • cobertura-dtd (opens in new window) - coverage-04.dtd element / attribute declarations: coverage, sources, packages, package, classes, class, methods, method, lines, line with required attributes.
  • jacoco-analysis - JVM-specific JaCoCo native XML (when Cobertura conversion isn't desired).

LCOV record format, baseline diff, and CI wiring

View source (opens in new window)

LCOV record format, baseline diff, and CI wiring

Deep reference for the lcov-analysis SKILL.md. Consult for the full .info record-keyword table, the baseline-diff and gate reference implementation, and the PR coverage-gate CI job.

.info record-keyword reference

Per lcov-readme (opens in new window), the LCOV coverage data format uses these record types:

KeywordMeaning
TN:<test>Test name (often empty for whole-suite captures).
SF:<path>Source file path (one record set per source file).
FN:<line>,<name>Function declared at <line> named <name>.
FNDA:<count>,<name>Function <name> was called <count> times.
FNF:<n>Functions found in this file.
FNH:<n>Functions hit at least once.
BRDA:<line>,<block>,<branch>,<taken>Branch coverage data.
BRF:<n>Branches found.
BRH:<n>Branches hit.
DA:<line>,<count>Line <line> was executed <count> times.
LH:<n>Lines hit.
LF:<n>Lines found.
end_of_recordMarks completion of the current source file's data.

Per record, BRDA's fourth value <taken> is the hit count for that branch arm or - if the branch was never reached (the preceding line wasn't executed).

Diff vs baseline

def coverage_diff(current, baseline):
    """For each file, compute (line%_now - line%_then), (branch%_now - branch%_then)."""
    base_by_path = {f['path']: f for f in baseline}
    out = []
    for f in current:
        b = base_by_path.get(f['path'])
        line_now = pct(f['lh'], f['lf'])
        line_then = pct(b['lh'], b['lf']) if b else None
        branch_now = pct(f['brh'], f['brf'])
        branch_then = pct(b['brh'], b['brf']) if b else None
        out.append({
            'path': f['path'],
            'line_now': line_now, 'line_then': line_then,
            'branch_now': branch_now, 'branch_then': branch_then,
            'is_new': b is None,
        })
    return out

def pct(num, denom):
    return None if denom == 0 else round(100 * num / denom, 1)

The interesting outputs are drops (line_now < line_then) and new files with sub-threshold coverage (is_new and line_now < gate).

Gate

def gate(diff, whole_drop_max=0.5, file_drop_max=5.0, new_file_min=80.0):
    failures = []
    for f in diff:
        if f['is_new'] and f['line_now'] is not None and f['line_now'] < new_file_min:
            failures.append((f['path'], 'new file below threshold', f['line_now']))
        elif f['line_then'] is not None and f['line_now'] is not None:
            drop = f['line_then'] - f['line_now']
            if drop > file_drop_max:
                failures.append((f['path'], f'line% dropped {drop:.1f}pp', drop))
    # Whole-repo drop:
    sum_then_lh = sum(f['line_then'] for f in diff if f['line_then'] is not None)
    sum_now_lh  = sum(f['line_now']  for f in diff if f['line_now']  is not None)
    return failures

CI shape

- name: Run tests with LCOV reporter
  run: npm test -- --coverage --coverageReporters=lcov

- name: Download baseline
  uses: actions/download-artifact@v4
  with:
    name: lcov-main
    path: baseline/

- name: Parse + diff + gate
  run: |
    python scripts/parse_lcov.py coverage/lcov.info > current.json
    python scripts/parse_lcov.py baseline/lcov.info > baseline.json
    python scripts/coverage_gate.py current.json baseline.json

- name: Upload current LCOV (becomes next PR's baseline when on main)
  if: github.ref == 'refs/heads/main'
  uses: actions/upload-artifact@v4
  with:
    name: lcov-main
    path: coverage/lcov.info
    retention-days: 90

Cache main's LCOV as an artifact so PRs diff against it rather than recomputing the merge base's coverage twice.

Related skills

allure-reports

Configures Allure Report (test-runner adapter install, `allure-results` directory wiring, `categories.json` for failure classification, `history-trend.json` retention via the copy-history-between-runs pattern), runs the Allure CLI to convert `allure-results` to a static HTML site, and uploads the report as a CI artifact. Use when the team needs richer test reporting than JUnit XML - step-level attachments, per-test history, retry tracking, and severity / epic / feature labeling across framework-agnostic adapters (pytest, Jest, JUnit, TestNG, NUnit, Mocha). As a rich static HTML report generator, it is the open-source alternative to the sunset ExtentReports (JVM/.NET per-test HTML narrative); for hosted cross-run flakiness analytics rather than a static per-run report use currents-integration.

coverage-diff-reporter

Builds a per-PR coverage delta report from any pair of LCOV / Cobertura / JSON coverage outputs (current run + baseline from the merge target) - emits a per-file table with line% / branch% deltas, called-out new files, hidden drops (overall +0.1pp but one file -8pp), and a single-line PR-comment summary. Use when the team has coverage in CI but needs human-readable PR feedback that points at the specific file the reviewer should focus on, not just an aggregate number.

coverage-py-analysis

Configures coverage.py for Python projects - wires `coverage run` (replacing `python` for instrumentation), enables branch coverage via the `--branch` flag or `branch = True` config, manages the `.coverage` data file (single-process and `combine` for parallel pytest-xdist runs), authors `.coveragerc` with `source` / `omit` / `fail_under`, and emits the format the downstream tool needs (`coverage report` for terminal, `coverage xml` for Cobertura, `coverage html` for human review, `coverage lcov` for SaaS, `coverage json` for programmatic post-processing). Use for any Python test stack (pytest, unittest, nose) that needs PR-time coverage signal.

currents-integration

Wires Currents.dev cross-run test analytics into a Playwright suite: installs `@currents/playwright`, authors `currents.config.ts` (env-sourced `recordKey` + `projectId`), registers `currentsReporter()`, enables trace/video/screenshot artifacts, and runs via `npx pwc` so per-test traces stream to the Currents dashboard with over-time flakiness, slowest-test, and pass-rate trends. Use when a Playwright suite needs hosted cross-run suite-health analytics; for a static per-run report use extentreports or allure-reports, and to sync results into TestRail / Xray / Zephyr test management use test-management-sync.

jacoco-analysis

Configures JaCoCo for JVM projects (Java / Kotlin / Scala / Groovy) - wires the runtime agent via `jacoco-maven-plugin` `prepare-agent`, generates per-build reports (HTML / XML / CSV) via the `report` goal, gates the build via the `check` goal with element / limit / minimum rules, parses the six native counters (instructions, branches, lines, methods, classes, cyclomatic complexity), and converts JaCoCo XML to LCOV / Cobertura when downstream tools need a different format. Use when the JVM build is Maven / Gradle and the team wants the canonical JVM coverage tool - or to convert JaCoCo output for cross-language coverage aggregation.

junit-xml-analysis

Explains CI test numbers that disagree with what the suite actually did - a 'slowest tests' list dominated by the wrong suite, a release gate or dashboard reading only the summary attributes on the suite element and never the cases below them, or a pass rate that quietly counts skipped tests as passes. Parses JUnit-format XML (the interchange format Jenkins, GitHub Actions, GitLab, Buildkite, and CircleCI all ingest) into per-suite and per-case metrics tables - passed / failed / errored / skipped, time, classname, message, stack - groups failures by classname for trend analysis, and separates new failures from flakes by cross-referencing the `flakyFailure` and rerun elements. Use when a report, gate, or metric derived from test results cannot be trusted.

test-coverage-targeter

Builds a "what to test next" recommendation by combining a coverage report (LCOV / Cobertura / coverage.py JSON / Jest JSON / JaCoCo XML) with the PR's `git diff`, ranking uncovered branches by risk × cost - risk weighted by McCabe cyclomatic complexity and code-churn frequency, cost weighted by the unit-test pyramid layer (unit tests cheaper than integration than E2E). Also carries the coverage debt ledger: a weekly per-file drift report over N historical main runs flagging `falling` (line% slid >M pp from peak), `stale` (flat coverage + high churn), and `orphan` (lost last covering test) files, whose rows feed the same targeting. Emits a prioritized list with concrete file:line targets and the test layer recommended for each. Use when a team has the budget to write 5 - 10 new tests and needs help picking which uncovered code to target first instead of blindly chasing 100% coverage, or when specific modules are eroding silently while whole-repo coverage looks fine.

test-management-sync

Syncs automated test results into test management tools - TestRail (standalone, `add_run` + batched `add_results_for_cases`), Xray for Jira (JWT auth + `/api/v2/import/execution/*`), and Zephyr Scale (Bearer token + `/testexecutions`) - from CI. The body carries the vendor-independent push-results workflow (map tests to case IDs, open a run / execution / cycle per build, batch results back, close on main only, run as an `if: always()` step) with TestRail as the worked example; full vendor specifics live in references/ (testrail.md, xray.md, zephyr.md). Use when automated suites must keep the team's test management view in sync without a human copy-paste step; for hosted cross-run flakiness analytics rather than TCM sync use currents-integration, and for authoring / migrating test CASES rather than pushing results see qa-test-management's tcm-case-management.

test-run-summary-author

Build-an-X workflow that turns a structured test-run artifact (JUnit XML, Allure JSON, TestRail / Xray / Zephyr export) plus optional release context (version, build URL, deploy target) into a narrative markdown summary for release notes, an exec status update, or a stand-up Slack post. Distinct from the per-framework parsers junit-xml-analysis / allure-reports / coverage-diff-reporter, which emit structured tabular reports: this skill takes the same data and writes the human-readable narrative. Use when a manager needs a draft release note or stand-up summary from a single run; for cross-run trend analytics use currents-integration.