feat(scripts): implement report-status.sh with bats and cucumber tests

POST build status to Gitea REST API. Supports pending, success, failure,
error states. Cross-repo reporting via optional root_commit and root_repo
parameters. Includes mock Gitea API server shared between bats unit tests
and cucumber acceptance tests.
This commit is contained in:
moilanik
2026-06-08 10:47:21 +03:00
parent 9a59cbc185
commit 51922e2954
10 changed files with 1540 additions and 6 deletions
+6 -6
View File
@@ -11,32 +11,32 @@ Feature: Commit status visibility
# Ticket 0001: report-status.sh — Build status reporting to commits
# ---------------------------------------------------------------------------
@ticket-0001 @mock @wip @real
@ticket-0001 @mock @real
Scenario: Build step reports pending status to commit
When a build step starts executing
Then the commit shows a pending status with a description of the step
@ticket-0001 @mock @wip @real
@ticket-0001 @mock @real
Scenario: Build step reports successful completion with a result link
When a build step completes successfully and reports its results
Then the commit shows a success status with a clickable link to the results
@ticket-0001 @mock @wip @real
@ticket-0001 @mock @real
Scenario: Build step reports failure when tests do not pass
When a build step fails
Then the commit shows a failure status with a description of what went wrong
@ticket-0001 @mock @wip @real
@ticket-0001 @mock @real
Scenario: Multiple build steps report distinct statuses on the same commit
When several build steps each report their own status to the same commit
Then each status appears under a unique label on the commit
@ticket-0001 @mock @wip @real
@ticket-0001 @mock @real
Scenario: Deployment step reports status back to the source commit
When a deployment finishes for a commit that originated from another repository
Then the source commit shows the deployment status alongside the build status
@ticket-0001 @mock @wip
@ticket-0001 @mock
Scenario: Status reporting is interrupted when the build system cannot be reached
When a build step tries to report status but the build system is unavailable
Then the pipeline fails with a clear error message
@@ -0,0 +1,131 @@
const { execSync } = require('child_process');
const { When, Then } = require('@cucumber/cucumber');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const MOCK_SCRIPT = path.join(PROJECT_ROOT, 'tests', 'helpers', 'mock-api.sh');
const REPORT_SCRIPT = path.join(PROJECT_ROOT, 'scripts', 'report-status.sh');
function bash(cmd) {
try {
const out = execSync(`bash -c '${cmd}'`, {
cwd: PROJECT_ROOT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
return { status: 0, stdout: out };
} catch (e) {
return { status: e.status, stdout: e.stdout || '', stderr: e.stderr || '' };
}
}
function bashQuiet(cmd) {
execSync(`bash -c '${cmd}'`, {
cwd: PROJECT_ROOT,
stdio: 'ignore',
});
}
function envBlock() {
return [
'export GITEA_API_URL="http://localhost:18080"',
'export GITEA_TOKEN="test-token-abc123"',
'export GITHUB_REPOSITORY="test-owner/test-repo"',
'export GITHUB_SHA="abc123def456789012345678901234567890abcd"',
'export GITHUB_SERVER_URL="https://gitea.example.com"',
'export GITHUB_RUN_ID="42"',
].join('; ');
}
function runReportStatus(args) {
return bash(`${envBlock()}; bash "${REPORT_SCRIPT}" ${args}`);
}
function getMockBody() {
return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_request_body`).stdout.trim();
}
function getMockPath() {
return bash(`source "${MOCK_SCRIPT}" && _get_request_file && mock_get_request_path`).stdout.trim();
}
When('a build step starts executing', function () {
const r = runReportStatus('pending "Building project" "http://example.com/build/42"');
if (r.status !== 0) throw new Error(`Expected exit 0, got ${r.status}: ${r.stderr}`);
});
Then('the commit shows a pending status with a description of the step', function () {
const body = getMockBody();
if (!body.includes('"state":"pending"')) throw new Error('Expected pending status');
if (!body.includes('"description":"Building project"')) throw new Error('Expected description');
});
When('a build step completes successfully and reports its results', function () {
const r = runReportStatus('success "Unit tests OK" "http://example.com/reports/cucumber.html" "unit-test"');
if (r.status !== 0) throw new Error(`Expected exit 0, got ${r.status}`);
});
Then('the commit shows a success status with a clickable link to the results', function () {
const body = getMockBody();
if (!body.includes('"state":"success"')) throw new Error('Expected success status');
if (!body.includes('"target_url":"http://example.com/reports/cucumber.html"')) throw new Error('Expected URL');
});
When('a build step fails', function () {
const r = runReportStatus('failure "Tests failed: 3 of 10" "http://example.com/build/42"');
if (r.status !== 0) throw new Error(`Expected exit 0, got ${r.status}`);
});
Then('the commit shows a failure status with a description of what went wrong', function () {
const body = getMockBody();
if (!body.includes('"state":"failure"')) throw new Error('Expected failure status');
if (!body.includes('"description":"Tests failed: 3 of 10"')) throw new Error('Expected failure description');
});
When('several build steps each report their own status to the same commit', function () {
runReportStatus('pending "Build started" "http://example.com/build/42" "ci-build"');
execSync('sleep 0.3', { stdio: 'ignore' });
runReportStatus('success "Unit tests passed" "http://example.com/reports/unit.html" "unit-test"');
execSync('sleep 0.3', { stdio: 'ignore' });
runReportStatus('success "Integration tests passed" "http://example.com/reports/integration.html" "integration-test"');
});
Then('each status appears under a unique label on the commit', function () {
const requestFile = bash(`source "${MOCK_SCRIPT}" && _get_request_file`).stdout.trim();
if (!requestFile || requestFile === '/dev/null') throw new Error('Mock request file not found');
bashQuiet('sync');
const allRequestsRaw = bash(`cat "${requestFile}"`);
const allRequests = allRequestsRaw.stdout;
const postCount = (allRequests.match(/POST /g) || []).length;
if (postCount < 3) throw new Error(`Expected 3 POST requests, got ${postCount}. Content: ${allRequests.substring(0, 1000)}`);
if (!allRequests.includes('"context":"ci-build"')) throw new Error('Missing ci-build');
if (!allRequests.includes('"context":"unit-test"')) throw new Error('Missing unit-test');
if (!allRequests.includes('"context":"integration-test"')) throw new Error('Missing integration-test');
});
When('a deployment finishes for a commit that originated from another repository', function () {
const r = runReportStatus('success "Deployed to staging" "http://example.com/deploy/42" "deploy-staging" "rootabc123" "services/temperature-store"');
if (r.status !== 0) throw new Error(`Expected exit 0, got ${r.status}`);
});
Then('the source commit shows the deployment status alongside the build status', function () {
const body = getMockBody();
if (!body.includes('"state":"success"')) throw new Error('Expected success');
if (!body.includes('"context":"deploy-staging"')) throw new Error('Expected deploy-staging context');
const pathStr = getMockPath();
if (!pathStr.includes('services/temperature-store')) throw new Error('Expected cross-repo target');
if (!pathStr.includes('rootabc123')) throw new Error('Expected root commit');
});
When('a build step tries to report status but the build system is unavailable', function () {
bashQuiet(`source "${MOCK_SCRIPT}" && mock_stop`);
execSync('sleep 0.3', { stdio: 'ignore' });
bashQuiet(`source "${MOCK_SCRIPT}" && mock_set_response 500 && mock_start`);
const r = runReportStatus('success "Should fail" "http://example.com"');
this.reportStatusFailed = (r.status !== 0);
});
Then('the pipeline fails with a clear error message', function () {
if (!this.reportStatusFailed) throw new Error('Expected pipeline to fail (exit code != 0)');
});
@@ -0,0 +1,28 @@
const { execSync, spawn } = require('child_process');
const { Before, After, Given } = require('@cucumber/cucumber');
const path = require('path');
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const MOCK_SCRIPT = path.join(PROJECT_ROOT, 'tests', 'helpers', 'mock-api.sh');
Before({ tags: '@mock' }, function () {
execSync(`bash -c 'source "${MOCK_SCRIPT}" && mock_start'`, {
cwd: PROJECT_ROOT,
stdio: 'ignore',
});
});
After({ tags: '@mock' }, function () {
try {
execSync(`bash -c 'source "${MOCK_SCRIPT}" && mock_stop'`, {
cwd: PROJECT_ROOT,
stdio: 'ignore',
});
} catch (_) { /* ignore */ }
});
Given('a project repository exists in Gitea', function () {
});
Given('a commit has been pushed to the repository', function () {
});
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
MOCK_PORT=18080
MOCK_PID=""
MOCK_REQUEST_FILE=""
MOCK_RESPONSE_CODE=201
MOCK_STATE_FILE="/tmp/mock_api_state"
_kill_port() {
local pids
pids=$(lsof -ti ":$MOCK_PORT" 2>/dev/null) || true
[ -n "$pids" ] && kill $pids 2>/dev/null || true
sleep 0.2
}
_wait_port_free() {
local i=0
while lsof -ti ":$MOCK_PORT" >/dev/null 2>&1 && [ $i -lt 30 ]; do
sleep 0.1
i=$((i + 1))
done
}
mock_start() {
MOCK_RESPONSE_CODE="${MOCK_RESPONSE_CODE:-201}"
MOCK_REQUEST_FILE=$(mktemp)
echo "$MOCK_REQUEST_FILE" > "$MOCK_STATE_FILE"
_kill_port
_wait_port_free
(
while true; do
printf 'HTTP/1.1 %d OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{"id":1}\n' "$MOCK_RESPONSE_CODE" \
| nc -l "$MOCK_PORT" >> "$MOCK_REQUEST_FILE" 2>/dev/null || break
sleep 0.05
done
) &
MOCK_PID=$!
sleep 0.3
}
mock_stop() {
[ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true
_kill_port
_wait_port_free
[ -n "${MOCK_REQUEST_FILE:-}" ] && rm -f "${MOCK_REQUEST_FILE}" 2>/dev/null || true
rm -f "$MOCK_STATE_FILE"
MOCK_PID=""
}
mock_set_response() {
MOCK_RESPONSE_CODE="${1:-201}"
}
_get_request_file() {
if [ -f "$MOCK_STATE_FILE" ]; then
cat "$MOCK_STATE_FILE"
elif [ -n "${MOCK_REQUEST_FILE:-}" ]; then
echo "$MOCK_REQUEST_FILE"
else
echo "/dev/null"
fi
}
mock_get_request_body() {
tail -1 "$(_get_request_file)" 2>/dev/null || true
}
mock_get_request_path() {
head -1 "$(_get_request_file)" 2>/dev/null | awk '{print $2}' || true
}
mock_get_request_method() {
head -1 "$(_get_request_file)" 2>/dev/null | awk '{print $1}' || true
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bats
setup() {
source tests/helpers/mock-api.sh
export GITEA_API_URL="http://localhost:18080"
export GITEA_TOKEN="test-token-abc123"
export GITHUB_REPOSITORY="test-owner/test-repo"
export GITHUB_SHA="abc123def456789012345678901234567890abcd"
export GITHUB_SERVER_URL="https://gitea.example.com"
export GITHUB_RUN_ID="42"
}
teardown() {
mock_stop
}
@test "pending status is POSTed with correct payload" {
mock_start
run bash scripts/report-status.sh pending "Building project" "http://example.com/build/42"
[ "$status" -eq 0 ]
path=$(mock_get_request_path)
[[ "$path" == "/api/v1/repos/test-owner/test-repo/statuses/abc123def456789012345678901234567890abcd" ]]
body=$(mock_get_request_body)
[[ "$body" == *'"state":"pending"'* ]]
[[ "$body" == *'"description":"Building project"'* ]]
[[ "$body" == *'"target_url":"http://example.com/build/42"'* ]]
method=$(mock_get_request_method)
[[ "$method" == "POST" ]]
}
@test "success status with url and custom key" {
mock_start
run bash scripts/report-status.sh success "Unit tests OK" "http://example.com/reports/cucumber.html" "unit-test"
[ "$status" -eq 0 ]
body=$(mock_get_request_body)
[[ "$body" == *'"state":"success"'* ]]
[[ "$body" == *'"description":"Unit tests OK"'* ]]
[[ "$body" == *'"target_url":"http://example.com/reports/cucumber.html"'* ]]
[[ "$body" == *'"context":"unit-test"'* ]]
}
@test "failure status is POSTed correctly" {
mock_start
run bash scripts/report-status.sh failure "Tests failed: 3 of 10" "http://example.com/build/42"
[ "$status" -eq 0 ]
body=$(mock_get_request_body)
[[ "$body" == *'"state":"failure"'* ]]
[[ "$body" == *'"description":"Tests failed: 3 of 10"'* ]]
}
@test "error status is POSTed correctly" {
mock_start
run bash scripts/report-status.sh error "Build timed out" "http://example.com/build/42"
[ "$status" -eq 0 ]
body=$(mock_get_request_body)
[[ "$body" == *'"state":"error"'* ]]
}
@test "cross-repo: root_commit and root_repo override target" {
mock_start
run bash scripts/report-status.sh success "Deployed to staging" "http://example.com/deploy/42" "deploy-staging" "rootabc123" "services/temperature-store"
[ "$status" -eq 0 ]
path=$(mock_get_request_path)
[[ "$path" == "/api/v1/repos/services/temperature-store/statuses/rootabc123" ]]
body=$(mock_get_request_body)
[[ "$body" == *'"state":"success"'* ]]
[[ "$body" == *'"context":"deploy-staging"'* ]]
}
@test "cross-repo: only root_commit without root_repo is ignored" {
mock_start
run bash scripts/report-status.sh success "Partial cross-repo" "http://example.com" "my-key" "abc"
[ "$status" -eq 0 ]
path=$(mock_get_request_path)
[[ "$path" == "/api/v1/repos/test-owner/test-repo/statuses/abc123def456789012345678901234567890abcd" ]]
}
@test "default key when not provided" {
mock_start
run bash scripts/report-status.sh pending "Build started" "http://example.com/build/42"
[ "$status" -eq 0 ]
body=$(mock_get_request_body)
[[ "$body" == *'"context":"commit-abc123de"'* ]]
}
@test "API returns 500 causes exit 1" {
mock_set_response 500
mock_start
run bash scripts/report-status.sh success "Should fail" "http://example.com"
[ "$status" -eq 1 ]
}
@test "missing GITEA_API_URL causes exit 1 with error message" {
unset GITEA_API_URL
run bash scripts/report-status.sh pending "Test" "http://example.com"
[ "$status" -eq 1 ]
[[ "$output" == *"ERROR"* ]] || [[ "$output" == *"GITEA_API_URL"* ]]
}
@test "missing GITEA_TOKEN causes exit 1 with error message" {
unset GITEA_TOKEN
run bash scripts/report-status.sh pending "Test" "http://example.com"
[ "$status" -eq 1 ]
[[ "$output" == *"ERROR"* ]] || [[ "$output" == *"GITEA_TOKEN"* ]]
}
@test "missing required state argument causes exit 1" {
mock_start
run bash scripts/report-status.sh "" "desc" "http://example.com"
[ "$status" -eq 1 ]
}
@test "missing required description argument causes exit 1" {
mock_start
run bash scripts/report-status.sh pending "" "http://example.com"
[ "$status" -eq 1 ]
}
@test "missing required url argument causes exit 1" {
mock_start
run bash scripts/report-status.sh pending "desc" ""
[ "$status" -eq 1 ]
}