Skip to content
188 changes: 188 additions & 0 deletions .github/workflows/coverage-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
name: Post Coverage Comment

on:
workflow_run:
workflows: ["Run Unit Tests"]
types:
- completed

permissions:
pull-requests: write
actions: read

jobs:
comment:
runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Download Coverage Artifacts
uses: actions/download-artifact@v4
with:
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
name: coverage-data

- name: Upload Coverage Report
id: upload-report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 14

- name: Post Comment
uses: actions/github-script@v6
env:
ARTIFACT_URL: ${{ steps.upload-report.outputs.artifact-url }}
with:
script: |
const fs = require('fs');

const { owner, repo } = context.repo;
const headSha = context.payload.workflow_run.head_commit.id;

const loadSummary = (path) => {
try {
const data = JSON.parse(fs.readFileSync(path, 'utf8'));
// Map Python coverage.json format to expected internal summary format
if (data.totals && data.files) {
const summary = {
total: {
statements: { pct: data.totals.percent_covered }
}
};
for (const [file, fileData] of Object.entries(data.files)) {
// Python coverage uses absolute paths or relative to project root
// We keep it as is for comparison
summary[file] = {
statements: { pct: fileData.summary.percent_covered }
};
}
return summary;
}
return data;
} catch (e) {
console.log(`Could not read ${path}: ${e}`);
return null;
}
};

const baseSummary = loadSummary('./coverage-base.json');
const prSummary = loadSummary('./coverage-pr.json');

if (!baseSummary || !prSummary) {
console.log("Missing coverage data, skipping comment.");
return;
}

let baseBranch = 'main';
try {
baseBranch = fs.readFileSync('./BASE_BRANCH', 'utf8').trim();
} catch (e) {
console.log("Could not read BASE_BRANCH, defaulting to main.");
}

let markdown = `### 🧪 Code Coverage (vs \`${baseBranch}\`)\n\n`;

markdown += `[⬇️ **Download Full Report**](${process.env.ARTIFACT_URL})\n\n`;

const metric = 'statements';
const getPct = (summaryItem, m) => summaryItem && summaryItem[m] ? Number(summaryItem[m].pct) : 0;

const formatDiff = (oldPct, newPct) => {
const diff = (newPct - oldPct).toFixed(2);

let icon = '';
if (diff > 0) icon = '🟢';
else if (diff < 0) icon = '🔴';
else icon = '⚪️';

const diffStr = diff > 0 ? `+${diff}%` : `${diff}%`;
return `${icon} ${diffStr}`;
};

const fileUrl = (path) => `https://github.com/${owner}/${repo}/blob/${headSha}/${path}`;

const allFiles = new Set([...Object.keys(baseSummary), ...Object.keys(prSummary)]);
allFiles.delete('total');
const workspacePath = process.env.GITHUB_WORKSPACE ? process.env.GITHUB_WORKSPACE + '/' : '';

let changedRows = [];
let newRows = [];

for (const file of allFiles) {
const baseFile = baseSummary[file];
const prFile = prSummary[file];

if (!prFile) continue;

const oldPct = getPct(baseFile, metric);
const newPct = getPct(prFile, metric);

const relativeFilePath = file.replace(workspacePath, '');
const linkedPath = `[${relativeFilePath}](${fileUrl(relativeFilePath)})`;

if (!baseFile && prFile) {
newRows.push(`| ${linkedPath} (**new**) | — | ${newPct.toFixed(2)}% | — |\n`);
} else if (oldPct !== newPct) {
changedRows.push(`| ${linkedPath} | ${oldPct.toFixed(2)}% | ${newPct.toFixed(2)}% | ${formatDiff(oldPct, newPct)} |\n`);
}
}

if (changedRows.length === 0 && newRows.length === 0) {
markdown += `\n_No coverage changes._\n`;
} else {
markdown += `| | Base | PR | Delta |\n`;
markdown += `| :--- | :---: | :---: | :---: |\n`;

if (changedRows.length > 0) {
markdown += changedRows.sort().join('');
}
if (newRows.length > 0) {
markdown += newRows.sort().join('');
}

const oldTotalPct = getPct(baseSummary.total, metric);
const newTotalPct = getPct(prSummary.total, metric);
if (oldTotalPct !== newTotalPct) {
markdown += `| **Total** | ${oldTotalPct.toFixed(2)}% | ${newTotalPct.toFixed(2)}% | ${formatDiff(oldTotalPct, newTotalPct)} |\n`;
}
}

markdown += `\n\n_Generated by [coverage-comment.yml](https://github.com/${owner}/${repo}/actions/workflows/coverage-comment.yml)_`;

const prNumber = fs.readFileSync('./PR_NUMBER', 'utf8').trim();

if (!prNumber) {
console.log("No PR number found.");
return;
}

const comments = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
});

const existingComment = comments.data.find(c =>
c.body.includes('Generated by [coverage-comment.yml]') &&
c.user.type === 'Bot'
);

if (existingComment) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: markdown
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: markdown
});
}
37 changes: 19 additions & 18 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,22 @@ jobs:
name: release-dists
path: dist/

pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
id-token: write

steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v8
with:
name: release-dists
path: dist/

- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
# pypi-publish:
# runs-on: ubuntu-latest
# if: github.event.release.prerelease == false
# needs:
# - release-build
# permissions:
# id-token: write

# steps:
# - name: Retrieve release distributions
# uses: actions/download-artifact@v8
# with:
# name: release-dists
# path: dist/

# - name: Publish release distributions to PyPI
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
# packages-dir: dist/
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ jobs:
steps:
- uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.A2A_BOT_PAT }}
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
release-type: python
74 changes: 67 additions & 7 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
---
name: Run Unit Tests
on:
pull_request:
push:
branches: [main]
pull_request:
permissions:
contents: read

jobs:
test:
name: Test with Python ${{ matrix.python-version }}
runs-on: ubuntu-latest

if: github.repository == 'a2aproject/a2a-python'
services:
postgres:
image: postgres:15-alpine
Expand All @@ -33,28 +33,88 @@ jobs:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h localhost -u root -proot" --health-interval=10s --health-timeout=5s --health-retries=5

strategy:
matrix:
python-version: ['3.10', '3.13']
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4

- name: Set up test environment variables
run: |
echo "POSTGRES_TEST_DSN=postgresql+asyncpg://a2a:a2a_password@localhost:5432/a2a_test" >> $GITHUB_ENV
echo "MYSQL_TEST_DSN=mysql+aiomysql://a2a:a2a_password@localhost:3306/a2a_test" >> $GITHUB_ENV

- name: Install uv for Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}

- name: Add uv to PATH
run: |
echo "$HOME/.cargo/bin" >> $GITHUB_PATH

- name: Install dependencies
run: uv sync --locked
- name: Run tests and check coverage

# Coverage comparison for PRs (only on Python 3.13 to avoid duplicate work)
- name: Checkout Base Branch
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref || 'main' }}
clean: true

- name: Run coverage (Base)
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
run: |
uv run pytest --cov=a2a --cov-report=json --cov-report=html:coverage
mv coverage.json /tmp/coverage-base.json

- name: Checkout PR Branch (Restore)
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
uses: actions/checkout@v4
with:
clean: true

- name: Run coverage (PR)
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
run: |
uv run pytest --cov=a2a --cov-report=json --cov-report=html:coverage --cov-report=term --cov-fail-under=88
mv coverage.json coverage-pr.json
cp /tmp/coverage-base.json coverage-base.json

- name: Save Metadata
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
run: |
echo ${{ github.event.number }} > ./PR_NUMBER
echo ${{ github.event.pull_request.base.ref || 'main' }} > ./BASE_BRANCH

- name: Upload Coverage Artifacts
uses: actions/upload-artifact@v4
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
with:
name: coverage-data
path: |
coverage-base.json
coverage-pr.json
coverage/
PR_NUMBER
BASE_BRANCH
retention-days: 1

# Run standard tests (for matrix items that didn't run coverage PR)
- name: Run tests (Standard)
if: matrix.python-version != '3.13' || github.event_name != 'pull_request'
run: uv run pytest --cov=a2a --cov-report term --cov-fail-under=88

- name: Upload Artifact (base)
uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request' && matrix.python-version == '3.13'
with:
name: coverage-report
path: coverage
retention-days: 14

- name: Show coverage summary in log
run: uv run coverage report
Loading
Loading