Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better

[![Go Report Card](https://goreportcard.com/badge/github.com/multimediallc/codeowners-plus)](https://goreportcard.com/report/github.com/multimediallc/codeowners-plus?kill_cache=1)
[![Tests](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml/badge.svg)](https://github.com/multimediallc/codeowners-plus/actions/workflows/go.yml)
![Coverage](https://img.shields.io/badge/Coverage-83.6%25-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-83.9%25-brightgreen)
[![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)

Expand All @@ -25,6 +25,7 @@ Code Ownership & Review Assignment Tool - GitHub CODEOWNERS but better
- [Quiet Mode](#quiet-mode)
- [Hunk Filters](#hunk-filters)
- [CLI Tool](#cli-tool)
- [JUnit Owners Action](#junit-owners-action)
- [Contributing](#contributing)
- [Future Features](#future-features)

Expand Down Expand Up @@ -462,6 +463,92 @@ Available subcommands are:
* `unowned` to check for unowned files
* `owner` to check who owns a specific file or list of files
* `validate` to check for typos in a `.codeowners` file
* `map` to generate a JSON ownership map of the entire repository
* `junit` to annotate JUnit XML test reports with code owners

### Annotating test reports

`junit` writes the owners of each test's source file onto its `<testcase>` element:

```bash
codeowners-cli junit --in-place --type pytest junit.xml
```

```xml
<testcase classname="abuse.tests.test_abuse" name="test_user_updated_at" time="0.2"
codeowners="@your-org/backend-team,@your-org/security-team" codeownersCount="2"/>
```

This lets whatever consumes the report downstream — a test analytics service, a dashboard, a
flaky-test tracker — group results by the team that owns the test.

Each testcase is traced back to a file in two ways. When the framework records a `file`
attribute (jest-junit's [`addFileAttribute`](https://github.com/jest-community/jest-junit#configuration),
among others) that path is used. Otherwise `classname` is read as a dotted module path, with
trailing segments trimmed until a real file is found, which is what pytest emits for both
module-level tests (`abuse.tests.test_abuse`) and class-based ones
(`abuse.tests.test_abuse.TestAbuse`).

### Report types

`--type` names the framework that produced the report, which selects the right strategy and,
just as importantly, skips the wrong one:

`--type` is required, because there is no reliable way to tell the frameworks apart from the
report alone and guessing wrong misattributes tests.

| Type | `file` attribute | `classname` as a path | Extensions tried | Writes `file` |
|------|------------------|-----------------------|------------------|---------------|
| `pytest` | yes | yes | `.py` | **yes** |
| `jest` | yes | **no** | — | no |

`pytest` writes the resolved path back to `file` because pytest omits the attribute entirely
under its default `xunit2` family, so the write is purely additive. `jest` does not, because
overwriting a path the framework already set would change the meaning of a field its consumers
may rely on.

`jest` also refuses to read `classname` as a path, because jest puts the text of the describe
block there. A block named something like `chatconnection.reconnectlimiter` looks exactly like a
module path and could otherwise resolve to an unrelated file.

Reports that name files relative to a subdirectory rather than the repository root — as jest
does in a monorepo, where paths are relative to the package — need `--prefix`:

```bash
codeowners-cli junit --in-place --type jest --prefix frontend/react frontend/junit-react.xml
```

Useful options:

| Option | Purpose |
|--------|---------|
| `--in-place`, `-i` | Rewrite the report in place instead of writing to stdout |
| `--type`, `-t` | **Required.** Framework that produced the report: `pytest` or `jest` |
| `--prefix`, `-p` | Path prefix for reports that name files relative to a subdirectory |

Testcases that cannot be resolved, and files with no owner, are left untouched.

## JUnit Owners Action

The `junit` subcommand is also packaged as an action, so annotating a report in CI does not
require installing the CLI yourself. Add it between the step that runs your tests and the step
that uploads the report:

```yaml
- name: 'Annotate test results with code owners'
uses: multimediallc/codeowners-plus/actions/junit-owners@v1.11.0
with:
path: junit.xml
type: pytest
```

| Input | Default | Purpose |
|-------|---------|---------|
| `path` | *required* | Report(s) to annotate; separate several with whitespace or commas |
| `type` | *required* | Framework that produced the report: `pytest` or `jest` |
| `root` | `.` | Path to the Git repository the reports belong to |
| `prefix` | `''` | Path prefix for reports that name files relative to a subdirectory |


## Contributing

Expand Down
146 changes: 146 additions & 0 deletions actions/junit-owners/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
name: 'Codeowners Plus JUnit Owners'
description: 'Annotate JUnit XML test reports with the code owners of each test file'
inputs:
path:
description: 'JUnit XML report(s) to annotate. Separate multiple reports with whitespace or newlines.'
required: true
type:
description: 'Framework that produced the report: pytest or jest. Selects the resolution strategy, and determines whether the resolved path is written back to the `file` attribute.'
required: true
root:
description: 'Path to the Git repository the reports belong to'
required: false
default: '.'
prefix:
description: 'Path prefix to prepend to test file paths, for reports that name files relative to a subdirectory (for example a workspace root)'
required: false
default: ''

runs:
using: 'composite'
steps:
- name: 'Resolve codeowners-cli binary'
id: resolve
shell: bash
env:
# The release tag this commit belongs to. Non-empty only in release
# commits: set by scripts/prepare-release.sh and cleared by
# scripts/post-release.sh. When set, the action downloads that
# release's prebuilt binary; when empty (any non-release ref) it
# builds from the checked-out source.
RELEASE_VERSION: ''
run: |
set -euo pipefail
# GITHUB_ACTION_PATH is this action's directory; the module root, which
# holds go.mod and scripts/, is two levels up.
ACTION_PATH=$(cd "${GITHUB_ACTION_PATH}/../.." && pwd)

# The build cache has to be keyed on what is actually compiled. The ref
# alone is constant for a floating pin such as @main or @v1, so the
# cache would hit forever and upstream fixes would never be rebuilt.
# hashFiles() cannot do this: it only matches inside GITHUB_WORKSPACE,
# and a remote `uses:` puts the action under _actions/, outside it,
# where it returns an empty string and every revision shares one key.
# sha256sum is GNU coreutils (Linux); macOS runners only ship shasum.
if command -v sha256sum >/dev/null 2>&1; then
sum() { sha256sum "$@"; }
else
sum() { shasum -a 256 "$@"; }
fi
# Paths are relative to ACTION_PATH so the digest does not change with
# the checkout location, and go.mod/go.sum are included because they
# determine the toolchain and dependencies the binary is built from.
SRC_HASH=$(
cd "${ACTION_PATH}" &&
{ find tools/cli pkg -type f -name '*.go'; echo go.mod; echo go.sum; } |
LC_ALL=C sort | xargs sum | sum | cut -d' ' -f1
)

{
echo "release-version=${RELEASE_VERSION}"
echo "bin=${RUNNER_TEMP:-/tmp}/codeowners-plus-cli/codeowners-cli"
echo "action-path=${ACTION_PATH}"
echo "src-hash=${SRC_HASH}"
} >>"$GITHUB_OUTPUT"

# RELEASE_VERSION not set -> not a release: build from source (cached).
- name: 'Restore cached built binary'
id: buildcache
if: steps.resolve.outputs.release-version == ''
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.resolve.outputs.bin }}
key: codeowners-cli-build-${{ steps.resolve.outputs.src-hash }}-${{ runner.os }}-${{ runner.arch }}

- name: 'Set up Go'
if: steps.resolve.outputs.release-version == '' && steps.buildcache.outputs.cache-hit != 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: ${{ steps.resolve.outputs.action-path }}/go.mod
cache-dependency-path: ${{ steps.resolve.outputs.action-path }}/go.sum

- name: 'Build codeowners-cli from source'
if: steps.resolve.outputs.release-version == '' && steps.buildcache.outputs.cache-hit != 'true'
shell: bash
env:
ACTION_PATH: ${{ steps.resolve.outputs.action-path }}
BIN: ${{ steps.resolve.outputs.bin }}
run: |
set -euo pipefail
mkdir -p "$(dirname "${BIN}")"
cd "${ACTION_PATH}"
CGO_ENABLED=0 \
go build -trimpath -buildvcs=false -ldflags="-s -w" -o "${BIN}" ./tools/cli

# RELEASE_VERSION set -> a release: download + verify the prebuilt binary (cached).
- name: 'Restore cached release binary'
id: bincache
if: steps.resolve.outputs.release-version != ''
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ steps.resolve.outputs.bin }}
key: codeowners-cli-${{ steps.resolve.outputs.release-version }}-${{ runner.os }}-${{ runner.arch }}

- name: 'Download codeowners-cli release binary'
if: steps.resolve.outputs.release-version != '' && steps.bincache.outputs.cache-hit != 'true'
shell: bash
env:
REPO: ${{ github.action_repository }}
ACTION_PATH: ${{ steps.resolve.outputs.action-path }}
TAG: ${{ steps.resolve.outputs.release-version }}
BIN: ${{ steps.resolve.outputs.bin }}
run: '"${ACTION_PATH}/scripts/install-cli.sh"'

- name: 'Annotate JUnit reports'
shell: bash
env:
BIN: ${{ steps.resolve.outputs.bin }}
INPUT_PATH: ${{ inputs.path }}
INPUT_TYPE: ${{ inputs.type }}
INPUT_ROOT: ${{ inputs.root }}
INPUT_PREFIX: ${{ inputs.prefix }}
run: |
set -euo pipefail

args=(junit --in-place --root "${INPUT_ROOT}" --type "${INPUT_TYPE}")
if [ -n "${INPUT_PREFIX}" ]; then
args+=(--prefix "${INPUT_PREFIX}")
fi
# `path` accepts several values; commas and whitespace are equivalent
# separators so that a YAML list, a multiline block or a
# comma-separated string all work.
# Split on whitespace only: globbing is disabled so that a path
# containing a wildcard is passed through as written rather than
# silently expanding, or vanishing when it matches nothing.
set -f
reports=()
for report in ${INPUT_PATH//,/ }; do
reports+=("${report}")
done
set +f
if [ "${#reports[@]}" -eq 0 ]; then
echo "Error: no reports given in 'path'." >&2
exit 1
fi

"${BIN}" "${args[@]}" "${reports[@]}"
101 changes: 101 additions & 0 deletions scripts/install-cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#! /usr/bin/env bash
# Installs the prebuilt codeowners-cli binary for the current platform,
# verified against the release's checksums.txt.
#
# The companion script install-action.sh does the same for the GitHub Action
# binary. They are kept separate because the two release archives follow
# different goreleaser naming templates.
#
# Local use (all env vars optional):
# scripts/install-cli.sh # latest release -> ./codeowners-cli
# VERSION=v1.11.0 scripts/install-cli.sh # a specific release
# BIN=/usr/local/bin/codeowners-cli scripts/install-cli.sh
#
# Overrides: REPO, VERSION (or TAG), OS, ARCH, BIN. The junit-owners action
# passes REPO/TAG/BIN; OS and ARCH are detected here so the script is
# self-contained.

set -eu

REPO="${REPO:-multimediallc/codeowners-plus}"
BIN="${BIN:-./codeowners-cli}"
TAG="${TAG:-${VERSION:-}}"

# Detect OS unless overridden. The CLI archive title-cases the goreleaser
# {{ .Os }} token, so these are capitalized.
OS="${OS:-}"
if [ -z "${OS}" ]; then
case "$(uname -s)" in
Linux) OS="Linux" ;;
Darwin) OS="Darwin" ;;
*)
echo "Error: unsupported OS '$(uname -s)' (supported: Linux, Darwin)." >&2
exit 1
;;
esac
fi

# Detect ARCH unless overridden. The CLI archive spells amd64 as x86_64.
ARCH="${ARCH:-}"
if [ -z "${ARCH}" ]; then
case "$(uname -m)" in
x86_64 | amd64) ARCH="x86_64" ;;
arm64 | aarch64) ARCH="arm64" ;;
*)
echo "Error: unsupported arch '$(uname -m)' (supported: x86_64, arm64)." >&2
exit 1
;;
esac
fi

# Default to the latest release when no version was requested.
if [ -z "${TAG}" ]; then
TAG="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
| awk -F'"' '/"tag_name":/ {print $4; exit}')"
if [ -z "${TAG}" ]; then
echo "Error: could not determine the latest release of ${REPO}." >&2
exit 1
fi
fi

# The CLI archive embeds the version without its leading "v".
asset="codeowners-cli_${TAG#v}_${OS}_${ARCH}.tar.gz"
binname="codeowners-cli"
base="https://github.com/${REPO}/releases/download/${TAG}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT

echo "Downloading ${asset} from ${REPO} release ${TAG}" >&2
curl -fsSL --retry 3 -o "${tmp}/${asset}" "${base}/${asset}"
curl -fsSL --retry 3 -o "${tmp}/checksums.txt" "${base}/checksums.txt"

echo "Verifying ${asset} against checksums.txt" >&2
expected="$(awk -v a="${asset}" '$2 == a {print $1}' "${tmp}/checksums.txt")"
if [ -z "${expected}" ]; then
echo "Error: ${asset} not found in checksums.txt" >&2
exit 1
fi
# Guard against a malformed digest: '<checker> -c' treats an improperly
# formatted line as a skipped (passing) entry rather than a failure.
if ! printf '%s' "${expected}" | grep -Eq '^[0-9a-f]{64}$'; then
echo "Error: invalid checksum for ${asset} in checksums.txt" >&2
exit 1
fi
# sha256sum is GNU coreutils (Linux); macOS only ships shasum.
if command -v sha256sum >/dev/null 2>&1; then
verify=(sha256sum -c -)
else
verify=(shasum -a 256 -c -)
fi
if ! echo "${expected} ${tmp}/${asset}" | "${verify[@]}"; then
echo "Error: downloaded ${asset} does not match its release checksum" >&2
exit 1
fi

echo "Extracting ${binname} from ${asset}" >&2
tar -xzf "${tmp}/${asset}" -C "${tmp}" "${binname}"

mkdir -p "$(dirname "${BIN}")"
mv "${tmp}/${binname}" "${BIN}"
chmod +x "${BIN}"
echo "Installed ${binname} ${TAG} to ${BIN}" >&2
Loading
Loading