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
+1
View File
@@ -2,3 +2,4 @@
.github/copilot-instructions.md .github/copilot-instructions.md
AGENTS.md AGENTS.md
.ai .ai
node_modules/
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
default: {
paths: ['tests/features/*.feature'],
require: ['tests/features/step_definitions/*.steps.js'],
format: ['progress-bar'],
tags: 'not @wip',
},
};
+1098
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "gitea-ci-library",
"version": "1.0.0",
"description": "",
"main": "cucumber.js",
"directories": {
"doc": "docs",
"test": "tests"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "ssh://git@gitea.app.keskikuja.site:30009/niko/gitea-ci-library.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"@cucumber/cucumber": "^13.0.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
[ -z "${GITEA_API_URL:-}" ] && echo "ERROR: GITEA_API_URL is not set" >&2 && exit 1
[ -z "${GITEA_TOKEN:-}" ] && echo "ERROR: GITEA_TOKEN is not set" >&2 && exit 1
STATE="${1:-}"
DESCRIPTION="${2:-}"
URL="${3:-}"
KEY="${4:-commit-${GITHUB_SHA:0:8}}"
ROOT_COMMIT="${5:-}"
ROOT_REPO="${6:-}"
[ -z "$STATE" ] && echo "ERROR: state argument is required" >&2 && exit 1
[ -z "$DESCRIPTION" ] && echo "ERROR: description argument is required" >&2 && exit 1
[ -z "$URL" ] && echo "ERROR: url argument is required" >&2 && exit 1
if [ -n "$ROOT_COMMIT" ] && [ -n "$ROOT_REPO" ]; then
REPO="$ROOT_REPO"
COMMIT="$ROOT_COMMIT"
else
REPO="${GITHUB_REPOSITORY:-}"
COMMIT="${GITHUB_SHA:-}"
fi
[ -z "$REPO" ] && echo "ERROR: GITHUB_REPOSITORY is not set" >&2 && exit 1
[ -z "$COMMIT" ] && echo "ERROR: GITHUB_SHA is not set" >&2 && exit 1
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$GITEA_API_URL/api/v1/repos/$REPO/statuses/$COMMIT" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"state\":\"$STATE\",\"target_url\":\"$URL\",\"description\":\"$DESCRIPTION\",\"context\":\"$KEY\"}")
if [ "$HTTP_CODE" = "201" ]; then
exit 0
fi
if [ -z "$HTTP_CODE" ]; then
echo "ERROR: Failed to connect to Gitea API at $GITEA_API_URL" >&2
else
echo "ERROR: API returned HTTP $HTTP_CODE" >&2
fi
exit 1
+6 -6
View File
@@ -11,32 +11,32 @@ Feature: Commit status visibility
# Ticket 0001: report-status.sh — Build status reporting to commits # 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 Scenario: Build step reports pending status to commit
When a build step starts executing When a build step starts executing
Then the commit shows a pending status with a description of the step 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 Scenario: Build step reports successful completion with a result link
When a build step completes successfully and reports its results When a build step completes successfully and reports its results
Then the commit shows a success status with a clickable link to the 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 Scenario: Build step reports failure when tests do not pass
When a build step fails When a build step fails
Then the commit shows a failure status with a description of what went wrong 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 Scenario: Multiple build steps report distinct statuses on the same commit
When several build steps each report their own status to 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 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 Scenario: Deployment step reports status back to the source commit
When a deployment finishes for a commit that originated from another repository When a deployment finishes for a commit that originated from another repository
Then the source commit shows the deployment status alongside the build status 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 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 When a build step tries to report status but the build system is unavailable
Then the pipeline fails with a clear error message 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 ]
}