diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..16d18a6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## Pull request (PR) description: +> Provide a brief description of the PR here. +> +> It does not need to list what each individual commit does, but rather provide a high-level overview of the PR. + +## Issues resolved: +> Any associated issues should be provided here using the format: 'Resolves #' (e.g. Resolves #1). If there are none, use 'n/a' instead. diff --git a/.github/workflows/pr-ci-report.yml b/.github/workflows/pr-ci-report.yml new file mode 100644 index 0000000..1ed6fe6 --- /dev/null +++ b/.github/workflows/pr-ci-report.yml @@ -0,0 +1,249 @@ +name: Pull Request CI Report + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + python-tests: + name: Python ${{ matrix.python-version }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + if: github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.14", "3.15"] + steps: + - uses: actions/checkout@v7 + + - name: OS version (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + . /etc/os-release + echo "OS_NAME=$NAME" >> "$GITHUB_ENV" + echo "OS_VER=$VERSION_ID" >> "$GITHUB_ENV" + + - name: OS version (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo "OS_NAME=macOS" >> "$GITHUB_ENV" + echo "OS_VER=$(sw_vers -productVersion)" >> "$GITHUB_ENV" + + - name: Install Linux Qt runtime dependencies + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + libegl1 \ + libgl1 \ + libdbus-1-3 \ + libxkbcommon-x11-0 \ + libxcb-cursor0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-randr0 \ + libxcb-render-util0 \ + libxcb-shape0 \ + libxcb-xinerama0 \ + libxcb-xfixes0 \ + xvfb + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + with: + python-version: ${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --all-extras + + - name: Run pytest + id: pytest + continue-on-error: true + run: uv run pytest + + - name: Build package + id: build + continue-on-error: true + run: uv build + + - name: Add venv bin to PATH + shell: bash + run: echo "$PWD/.venv/bin" >> "$GITHUB_PATH" + + - name: Check CLI entry point + id: cli + continue-on-error: true + run: comms --help + + - name: Write result file + if: always() + shell: bash + run: | + mkdir -p result + cat > result/result.json <> "$GITHUB_ENV" + echo "OS_VER=$VERSION_ID" >> "$GITHUB_ENV" + echo "USE_BUNDLED_LIBUV=1" >> "$GITHUB_ENV" + + - name: OS version (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo "OS_NAME=macOS" >> "$GITHUB_ENV" + echo "OS_VER=$(sw_vers -productVersion)" >> "$GITHUB_ENV" + + - name: Install Linux runtime dependencies + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + libcurl4-openssl-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libfribidi-dev \ + libharfbuzz-dev \ + libjpeg-dev \ + libpng-dev \ + libtiff5-dev \ + libwebp-dev + + - name: Set up R + uses: r-lib/actions/setup-r@v2 + with: + r-version: ${{ matrix.r-version }} + use-public-rspm: true + + - name: Install R dependencies + run: | + Rscript -e 'install.packages("testthat", repos = "https://cloud.r-project.org")' + Rscript src/comms/r/deps/install_deps.R + + - name: Run R tests + id: rtests + continue-on-error: true + run: Rscript -e 'testthat::test_dir("tests/r")' + + - name: Write result file + if: always() + shell: bash + run: | + mkdir -p result + cat > result/result.json <&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Read main version + id: main_version + run: | + git fetch origin main --depth=1 + VERSION=$(git show origin/main:pyproject.toml | grep -m1 '^version *= *' | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from main's pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Compare versions + run: | + PR_VERSION="${{ steps.pr_version.outputs.version }}" + MAIN_VERSION="${{ steps.main_version.outputs.version }}" + echo "PR version: $PR_VERSION" + echo "main version: $MAIN_VERSION" + uv run --with packaging python3 - "$PR_VERSION" "$MAIN_VERSION" <<'EOF' + import sys + from packaging.version import Version, InvalidVersion + + pr_raw, main_raw = sys.argv[1], sys.argv[2] + + try: + pr = Version(pr_raw) + main = Version(main_raw) + except InvalidVersion as e: + print(f"Could not parse version as PEP 440: {e}", file=sys.stderr) + sys.exit(1) + + if pr <= main: + print(f'Version in pyproject.toml ({pr_raw}) is not greater than main ({main_raw}). Versions must strictly increase.'', file=sys.stderr) + sys.exit(1) + + print(f'OK: {pr_raw} > {main_raw}'') + EOF \ No newline at end of file diff --git a/.github/workflows/pr_ci_report.py b/.github/workflows/pr_ci_report.py new file mode 100644 index 0000000..2794014 --- /dev/null +++ b/.github/workflows/pr_ci_report.py @@ -0,0 +1,131 @@ +''' +pr_ci_report.py + +Helper script for Pull Request CI Report GitHub Actions workflow +Ingests JSON artifacts containing job results and produces a markdown document with the generated result comment +''' + +# Import external dependencies +import glob, json + +# Define constant icons for pass/partial pass/fail +ICON_PASS = '✅' +ICON_PARTIAL = '⚠️' +ICON_FAIL = '❌' + +# load: given a glob pattern, read all matching JSON files into items and return +def load(pattern): + items = [] + for f in glob.glob(pattern): + with open(f) as fh: + items.append(json.load(fh)) + return items + +# os_key: given an item, return the os_name and os_ver variables (for operating system name/version) +def os_key(item): + return (item.get('os_name', 'Unknown'), item.get('os_ver', 'unknown')) + +# summary_icon: given passed and total jobs, return a summary icon depending on proportion of passed jobs +def summary_icon(passed, total): + if total == 0: + return 'n/a' + if passed == total: + return ICON_PASS + if passed == 0: + return ICON_FAIL + return ICON_PARTIAL + +# load Python and R test results +py = load("results/python/**/result.json") +r = load("results/r/**/result.json") + +# extract all OS keys and sort in descending order +os_keys = sorted(set(os_key(i) for i in py) | set(os_key(i) for i in r), reverse=True) + +# extract all Python versions and sort in ascending order +python_versions = sorted(set(i['python_version'] for i in py), reverse=False) + +# extract all R versions and sort in ascending order +r_versions = sorted(set(i['r_version'] for i in r), reverse=False) + + +# build summary comment as list of lines +lines = [] +lines.append('# Pull Request CI Report') + +# add caution alert about Crux/TRFP tests to comment +lines.append('') +lines.append('> [!CAUTION]') +lines.append('> GitHub Actions runners do not have ThermoRawFileParser or Crux installed. Tests dependent on these binaries are skipped and should instead be run manually before merging.') + +# add summary to comment +lines.append('') +lines.append('
') +lines.append('') +lines.append('## Summary') +lines.append('') +lines.append('Operating System | Source distributions built? | CLI available? | Passed Python tests? | Passed R tests?') +lines.append('--|--|--|--|--') +for name, ver in os_keys: + py_cells = [i for i in py if os_key(i) == (name, ver)] + r_cells = [i for i in r if os_key(i) == (name, ver)] + build_ok = len(py_cells) > 0 and all(c['build'] == 'pass' for c in py_cells) + cli_ok = len(py_cells) > 0 and all(c['cli'] == 'pass' for c in py_cells) + py_passed = sum(1 for c in py_cells if c['tests'] == 'pass') + r_passed = sum(1 for c in r_cells if c['tests'] == 'pass') + build_icon = ICON_PASS if build_ok else ICON_FAIL + cli_icon = ICON_PASS if cli_ok else ICON_FAIL + lines.append(f'{name} ({ver}) | {build_icon} | {cli_icon} | {summary_icon(py_passed, len(py_cells))} | {summary_icon(r_passed, len(r_cells))}') + +# add Python test results to comment +lines.append('') +lines.append('
') +lines.append('') +lines.append('## Python Tests') +lines.append('') +lines.append(f'Python versions tested: {", ".join(python_versions) if python_versions else "none"}') +if python_versions: + lines.append('') + lines.append(' Operating System | ' + ' | '.join(f'Python {v}' for v in python_versions)) + lines.append('-- |' * (len(python_versions) + 1)) + for name, ver in os_keys: + row = [f'{name} ({ver})'] + for v in python_versions: + match = next( + (result for result in py if os_key(result) == (name, ver) and result['python_version'] == v), + None, + ) + row.append('n/a' if match is None else (ICON_PASS if match['tests'] == 'pass' else ICON_FAIL)) + lines.append(' | '.join(row)) + +# add R test results to comment +lines.append('') +lines.append('
') +lines.append('') +lines.append('## R Tests') +lines.append('') +lines.append(f'R versions tested: {", ".join(r_versions) if r_versions else "none"}') +if r_versions: + lines.append('') + lines.append(' Operating System | ' + ' | '.join(f'R {v}' for v in r_versions)) + lines.append('-- |' * (len(r_versions) + 1)) + for name, ver in os_keys: + row = [f'{name} ({ver})'] + for v in r_versions: + match = next( + (result for result in r if os_key(result) == (name, ver) and result['r_version'] == v), + None, + ) + row.append('n/a' if match is None else (ICON_PASS if match['tests'] == 'pass' else ICON_FAIL)) + lines.append(' | '.join(row)) + +# add footer to comment +lines.append('') +lines.append('
') +lines.append('') +lines.append('---') +lines.append('_Auto-generated by the comMS Pull Request CI Report workflow. See full logs in the Actions run for this PR._') + +# write comment to markdown file +with open('pr_ci_report.md', 'w') as f: + f.write('\n'.join(lines)) \ No newline at end of file diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..126d199 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,40 @@ +name: Create comMS Release + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + name: Build and publish release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d + + - name: Read version + id: version + run: | + VERSION=$(grep -m1 '^version *= *' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') + if [ -z "$VERSION" ]; then + echo "Could not read version from pyproject.toml" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Build package + run: uv build + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + generate_release_notes: true + draft: false + files: dist/* \ No newline at end of file diff --git a/README.md b/README.md index 484ee66..c251cf7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Tool | Minimum version | Purpose | Platform notes [ThermoRawFileParser][trfp-url] | 1.4.5 | `.RAW` → `.mzML` conversion | Versions < 2.0.0 require [Mono](https://mono-project.com) on Linux/macOS ### `comms report` dependencies -The `report` command requires R (≥ 4.3.0) and a set of R packages (listed in the [report command documentation](./docs/commands.md#the-report-command)). Check or install the required R packages with: +The `report` command requires R (≥ 4.4.0) and a set of R packages (listed in the [report command documentation](./docs/commands.md#the-report-command)). Check or install the required R packages with: ```bash comms r-utils check comms r-utils install diff --git a/tests/r/test_utils_import.R b/tests/r/test_utils_import.R index ce81684..c52881a 100644 --- a/tests/r/test_utils_import.R +++ b/tests/r/test_utils_import.R @@ -25,11 +25,11 @@ make_cont_csv <- function(tmp_dir) { path <- file.path(tmp_dir, "cont.csv") write_csv(tibble(protein.id="CONT001", protein.annotation="Keratin", protein.reason="skin"), path); path } -make_sc_file <- function(tmp_dir, filename="s1.spectral-counts.target.txt") { +make_sc_file <- function(tmp_dir, filename="s1_dNSAF.spectral-counts.target.txt") { path <- file.path(tmp_dir, filename) write_tsv(tibble( - proteinId = c("Mtrun001", "Mtrun002", "CONT001"), - dNSAF = c(0.6, 0.4, 0.1) + "protein id" = c("Mtrun001", "Mtrun002", "CONT001"), + "dNSAF" = c(0.6, 0.4, 0.1) ), path) path } @@ -92,8 +92,8 @@ test_that("loadSpectralCounts retains dNSAF column", { test_that("mergeResults produces a wide tibble with one dNSAF column per sample", { ref <- loadRefInfo(make_ref_info(tmp)) cont <- loadContInfo(make_cont_csv(tmp)) - s1 <- make_sc_file(tmp, "s1.spectral-counts.target.txt") - s2 <- make_sc_file(tmp, "s2.spectral-counts.target.txt") + s1 <- make_sc_file(tmp, "s1_dNSAF.spectral-counts.target.txt") + s2 <- make_sc_file(tmp, "s2_dNSAF.spectral-counts.target.txt") result <- mergeResults(list( s1=loadSpectralCounts(s1, ref, cont), s2=loadSpectralCounts(s2, ref, cont) @@ -106,7 +106,7 @@ test_that("mergeResults preserves proteinId and proteinAnnotation columns", { ref <- loadRefInfo(make_ref_info(tmp)) cont <- loadContInfo(make_cont_csv(tmp)) result <- mergeResults(list( - s1=loadSpectralCounts(make_sc_file(tmp, "s1.spectral-counts.target.txt"), ref, cont) + s1=loadSpectralCounts(make_sc_file(tmp, "s1_dNSAF.spectral-counts.target.txt"), ref, cont) )) expect_true(all(c("proteinId", "proteinAnnotation") %in% colnames(result))) }) @@ -114,13 +114,13 @@ test_that("mergeResults preserves proteinId and proteinAnnotation columns", { test_that("mergeResults fills absent proteins with 0 rather than NA", { ref <- loadRefInfo(make_ref_info(tmp)) cont <- loadContInfo(make_cont_csv(tmp)) - s2_path <- file.path(tmp, "s2_partial.spectral-counts.target.txt") + s2_path <- file.path(tmp, "s2.partial_dNSAF.spectral-counts.target.txt") write_tsv(tibble( - proteinId = "Mtrun001", - dNSAF = 1.0 + `protein id` = "Mtrun001", + `dNSAF` = 1.0 ), s2_path) result <- mergeResults(list( - s1=loadSpectralCounts(make_sc_file(tmp, "s1.spectral-counts.target.txt"), ref, cont), + s1=loadSpectralCounts(make_sc_file(tmp, "s1_dNSAF.spectral-counts.target.txt"), ref, cont), s2=loadSpectralCounts(s2_path, ref, cont) )) mtrun002_s2 <- result[result$proteinId == "Mtrun002", "dNSAF_s2"][[1]]