diff --git a/README.md b/README.md index 4483d5f..9ebba13 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,26 @@ main. The payload is offline and unqualified. It does not call GitHub or a CLI, credential, change a repository or request, grant authority or qualification, or activate a profile. +## Inactive GitLab forge normalizer payload + +`adapters/gitlab-forge/v1/normalize.jq` is the first alternative forge. It +validates one untrusted GitLab merge-request snapshot against caller-supplied +project, merge-request iid, head, base, bot-user, time, instruction, and config +bindings and returns the same canonical generic observation the GitHub forge +returns: open-ready, open-blocked, closed-unmerged, merged, stale, or +inconclusive, with the same output keys, effect boundary, and stale-binding +shape, so a profile can swap one forge for the other. GitLab vocabulary stays at +the edge and is taken as the API reports it: `detailed_merge_status` values such +as `mergeable`, `conflict`, `ci_must_pass`, `security_policy_violations`, or +`checking` decide ready, blocked, or inconclusive; a locked request is inconclusive; a merged request is never +also closed; and the acting identity is the bot user the integration runs as, +since GitLab has no app id. Provider metadata stays opaque data. + +This PR lands only the immutable normalizer payload. A later assembly PR can add +its manifest and profile wiring. The payload is offline and unqualified. It does +not call GitLab or a CLI, use a credential, change a project or merge request, +grant authority or qualification, or activate a profile. + ## Inactive Codex native reviewer normalizer payload `adapters/codex-native-reviewer/v1/normalize.jq` validates one untrusted diff --git a/adapters/gitlab-forge/v1/normalize.jq b/adapters/gitlab-forge/v1/normalize.jq new file mode 100644 index 0000000..1d10aac --- /dev/null +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -0,0 +1,218 @@ +def exact_fields($required; $optional): + . as $value | + type == "object" and + ((keys_unsorted - ($required + $optional)) | length) == 0 and + all($required[]; . as $key | $value | has($key)); + +def id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def content_id_ok: + id_ok and (contains(":") | not) and (contains("/") | not); + +def media_type_ok: + type == "string" and utf8bytelength <= 127 and + test("\\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\\z"); + +def provider_id_ok: + type == "string" and test("\\A[1-9][0-9]{0,19}\\z"); + +def sha256_ok: + type == "string" and test("\\A[0-9a-f]{64}\\z"); + +def time_ok: + type == "string" and + test("\\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\\z") and + (capture("\\A(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2}):(?[0-9]{2})Z\\z") as $parts | + ($parts.year | tonumber) as $year | + ($parts.month | tonumber) as $month | + ($parts.day | tonumber) as $day | + ($parts.hour | tonumber) as $hour | + ($parts.minute | tonumber) as $minute | + ($parts.second | tonumber) as $second | + ($year % 4 == 0 and ($year % 100 != 0 or $year % 400 == 0)) as $leap | + [31,(if $leap then 29 else 28 end),31,30,31,30,31,31,30,31,30,31] as $days | + $month >= 1 and $month <= 12 and + $day >= 1 and $day <= $days[$month - 1] and + $hour >= 0 and $hour <= 23 and + $minute >= 0 and $minute <= 59 and + $second >= 0 and $second <= 59); + +def repository_id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def revision_ok: + exact_fields(["repository_id","hash_algorithm","commit_id"];[]) and + (.repository_id | repository_id_ok) and + (.hash_algorithm == "sha1" or .hash_algorithm == "sha256") and + (if .hash_algorithm == "sha1" + then (.commit_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else (.commit_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) + end); + +def content_ref_ok: + exact_fields(["content_id","media_type","sha256"];[]) and + (.content_id | content_id_ok) and + (.media_type | media_type_ok) and + (.sha256 | sha256_ok); + +# GitLab binds a merge request by project id and iid, and the acting identity by +# the bot user the integration runs as. There is no app id; the bot user id is +# the identity a caller must expect. +def trust_context_ok: + exact_fields( + ["expected_project_id","expected_merge_request_iid","expected_head", + "expected_base","expected_bot_user_id","observation_time", + "instruction_ref","config_ref"]; + []) and + (.expected_project_id | provider_id_ok) and + (.expected_merge_request_iid | provider_id_ok) and + (.expected_head | revision_ok) and + (.expected_base | revision_ok) and + .expected_head.repository_id == .expected_base.repository_id and + (.expected_bot_user_id | provider_id_ok) and + (.observation_time | time_ok) and + (.instruction_ref | content_ref_ok) and + (.config_ref | content_ref_ok); + +def path_ok: + type == "string" and length > 0 and utf8bytelength <= 4096 and + (test("[\\x{0000}-\\x{001f}\\x{007f}-\\x{009f}]") | not) and + (contains("\\") | not) and + (split("/") | all(.[]; . != "" and . != "." and . != "..")); + +def file_ok: + exact_fields(["path","status","patch_sha256"];[]) and + (.path | path_ok) and + (.status | type == "string" and + IN("added","changed","copied","modified","removed","renamed","unchanged")) and + (.patch_sha256 | sha256_ok); + +def files_ok($reported_count; $complete): + type == "array" and length <= 256 and + all(.[]; file_ok) and + (map(.path) as $paths | + $paths == ($paths | sort) and + ($paths | length) == ($paths | unique | length)) and + ($reported_count | type == "number" and . == floor and . >= 0 and . <= 100000) and + (if $complete then length == $reported_count else length <= $reported_count end); + +# GitLab's documented detailed_merge_status values, taken as the API reports +# them. Blocking values make an open request open-blocked; transitional values +# leave it inconclusive; not_open belongs to a closed or merged request. +def ready_merge_status: . == "mergeable"; +def blocking_merge_status: + IN("blocked_status","broken_status","ci_must_pass","commits_status","conflict", + "discussions_not_resolved","draft_status","external_status_checks", + "jira_association_missing","locked_lfs_files","locked_paths","merge_request_blocked", + "merge_time","need_rebase","not_approved","policies_denied","requested_changes", + "security_policy_violations","status_checks_must_pass"); +# A status that can settle on its own with no action, such as a pipeline still +# running, is transitional: the request is neither ready nor blocked yet. +def transitional_merge_status: + IN("approvals_syncing","checking","ci_still_running","preparing","unchecked"); +def merge_status_ok: + type == "string" and + (ready_merge_status or blocking_merge_status or transitional_merge_status or . == "not_open"); + +# GitLab keeps merged and closed apart: a merged request is never also closed, +# and a locked request is one whose merge is in flight. +def state_facts_ok: + if .state == "opened" or .state == "locked" then + .closed == false and .merged == false and + .closed_at == null and .merged_at == null and .detailed_merge_status != "not_open" + elif .state == "closed" then + .closed == true and .merged == false and + (.closed_at | time_ok) and .merged_at == null and .detailed_merge_status == "not_open" + elif .state == "merged" then + .closed == false and .merged == true and + .closed_at == null and (.merged_at | time_ok) and .detailed_merge_status == "not_open" + elif .state == "unknown" then + .closed == false and .merged == false and + .closed_at == null and .merged_at == null and .detailed_merge_status != "not_open" + else false + end; + +def timestamps_ok: + (.created_at | time_ok) and + (.updated_at | time_ok) and + (.observed_at | time_ok) and + .created_at <= .updated_at and .updated_at <= .observed_at and + (if .closed_at == null then true + else .created_at <= .closed_at and .closed_at <= .updated_at end) and + (if .merged_at == null then true + else .created_at <= .merged_at and .merged_at <= .updated_at end); + +def snapshot_ok: + . as $snapshot | + exact_fields( + ["project_id","merge_request_iid","head","base","bot_user_id", + "observed_at","complete","reported_file_count","state","detailed_merge_status", + "closed","merged","created_at","updated_at","closed_at","merged_at", + "files","provider_metadata"]; + []) and + (.project_id | provider_id_ok) and + (.merge_request_iid | provider_id_ok) and + (.head | revision_ok) and + (.base | revision_ok) and + .head.repository_id == .base.repository_id and + (.bot_user_id | provider_id_ok) and + (.observed_at | time_ok) and + (.complete | type == "boolean") and + (.state | IN("opened","closed","merged","locked","unknown")) and + (.detailed_merge_status | merge_status_ok) and + (.closed | type == "boolean") and + (.merged | type == "boolean") and + (.provider_metadata | type == "object") and + (.files | files_ok($snapshot.reported_file_count;$snapshot.complete)) and + state_facts_ok and timestamps_ok; + +def stale_bindings($context; $snapshot): + [ + if $snapshot.base != $context.expected_base then "base" else empty end, + if $snapshot.bot_user_id != $context.expected_bot_user_id then "bot-user" else empty end, + if $snapshot.head != $context.expected_head then "head" else empty end, + if $snapshot.merge_request_iid != $context.expected_merge_request_iid then "merge-request" else empty end, + if $snapshot.observed_at != $context.observation_time then "observation-time" else empty end, + if $snapshot.project_id != $context.expected_project_id then "project" else empty end + ]; + +def normalized_state($snapshot; $stale): + if ($stale | length) > 0 then ["stale","gitlab.binding-stale"] + elif $snapshot.complete == false then ["inconclusive","gitlab.snapshot-incomplete"] + elif $snapshot.state == "unknown" then ["inconclusive","gitlab.state-unknown"] + elif $snapshot.state == "merged" then ["merged","gitlab.merge-request-merged"] + elif $snapshot.state == "closed" then ["closed-unmerged","gitlab.merge-request-closed-unmerged"] + elif $snapshot.state == "locked" then ["inconclusive","gitlab.merge-request-locked"] + elif ($snapshot.detailed_merge_status | ready_merge_status) then + ["open-ready","gitlab.merge-request-open-ready"] + elif ($snapshot.detailed_merge_status | blocking_merge_status) then + ["open-blocked","gitlab.merge-request-open-blocked"] + else ["inconclusive","gitlab.merge-status-unsettled"] + end; + +if (exact_fields(["trust_context","snapshot"];[]) | not) then + error("gitlab-forge.invalid-envelope") +elif (.trust_context | trust_context_ok | not) then + error("gitlab-forge.invalid-trust-context") +elif (.snapshot | snapshot_ok | not) then + error("gitlab-forge.invalid-snapshot") +else + .trust_context as $context | + .snapshot as $snapshot | + stale_bindings($context;$snapshot) as $stale | + normalized_state($snapshot;$stale) as $normalized | + { + schema_version:1, + kind:"adapter_observation", + adapter:{id:"adapter.gitlab-forge.v1",version:"v1",status:"inactive"}, + state:$normalized[0], + reason_id:$normalized[1], + stale_bindings:$stale, + trust_context:$context, + observation:$snapshot, + authority:"none", + qualification:{state:"unavailable",reason_id:"adapter.unqualified"}, + effects:[] + } +end diff --git a/ci/required-files.txt b/ci/required-files.txt index 1996145..9161c28 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -244,6 +244,10 @@ scripts/test/orchestrator-reconciliation-plan.test.sh adapters/github-forge/v1/normalize.jq scripts/test/default-github-forge-adapter.test.sh +# Inactive GitLab forge normalizer payload (first alternative forge) +adapters/gitlab-forge/v1/normalize.jq +scripts/test/default-gitlab-forge-adapter.test.sh + # Inactive Codex native reviewer normalizer payload adapters/codex-native-reviewer/v1/normalize.jq scripts/test/default-codex-native-reviewer-adapter.test.sh diff --git a/scripts/test/default-gitlab-forge-adapter.test.sh b/scripts/test/default-gitlab-forge-adapter.test.sh new file mode 100755 index 0000000..770b565 --- /dev/null +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail +export LC_ALL=C + +root=$(CDPATH='' cd -P -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P) +normalizer="$root/adapters/gitlab-forge/v1/normalize.jq" +github_normalizer="$root/adapters/github-forge/v1/normalize.jq" +tmp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-gitlab-forge.XXXXXX") +trap '/bin/rm -rf -- "$tmp"' EXIT + +sha_file() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } +fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } +passed=0 +pass() { passed=$((passed + 1)); /usr/bin/printf 'ok %s - %s\n' "$passed" "$1"; } + +platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) +case "$platform" in + Darwin:*) asset=jq-osx-amd64; digest=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef ;; + Linux:x86_64) asset=jq-linux64; digest=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 ;; + *) fail "unsupported jq 1.6 proof platform: $platform" ;; +esac +jq_bin="${TMPDIR:-/tmp}/ystack-portable-core-jq16/$asset" +[ -f "$jq_bin" ] && [ "$(sha_file "$jq_bin")" = "$digest" ] || + fail 'verified jq 1.6 cache is required' +jq_command=("$jq_bin") +if [ "$platform" = Darwin:arm64 ]; then jq_command=(/usr/bin/arch -x86_64 "$jq_bin"); fi +[ "$("${jq_command[@]}" --version)" = jq-1.6 ] || fail 'jq version' + +check() { + local name=$1 + shift + "$@" >/dev/null 2>&1 || fail "$name" + pass "$name" +} + +mutate() { + local name=$1 + local filter=$2 + "${jq_command[@]}" -S -c "$filter" "$tmp/baseline.json" >"$tmp/$name.json" +} + +expect_state() { + local name=$1 + local filter=$2 + local expected=$3 + local reason=${4:-} + mutate "$name" "$filter" + "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --arg state "$expected" \ + '.state == $state' "$tmp/$name.out" >/dev/null || fail "$name state" + if [ -n "$reason" ]; then + "${jq_command[@]}" -e --arg reason "$reason" '.reason_id == $reason' "$tmp/$name.out" \ + >/dev/null || fail "$name reason" + fi + pass "$name" +} + +expect_stale() { + local name=$1 + local filter=$2 + local selector=$3 + mutate "$name" "$filter" + "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --arg selector "$selector" \ + '.state == "stale" and .reason_id == "gitlab.binding-stale" and .stale_bindings == [$selector]' \ + "$tmp/$name.out" >/dev/null || fail "$name state" + pass "$name" +} + +expect_reject() { + local name=$1 + local filter=$2 + local error_id=$3 + mutate "$name" "$filter" + if "${jq_command[@]}" -S -c -f "$normalizer" "$tmp/$name.json" \ + >"$tmp/$name.out" 2>"$tmp/$name.err"; then + fail "$name accepted" + fi + if [ -s "$tmp/$name.out" ] || ! /usr/bin/grep -Fq "$error_id" "$tmp/$name.err"; then + fail "$name diagnostics" + fi + pass "$name" +} + +"${jq_command[@]}" -S -c -n ' + def revision($oid): + {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:$oid}; + def content($id;$sha): + {content_id:$id,media_type:"application/json",sha256:$sha}; + { + trust_context:{ + expected_project_id:"48201377", + expected_merge_request_iid:"42", + expected_head:revision("1" * 40), + expected_base:revision("2" * 40), + expected_bot_user_id:"9137", + observation_time:"2026-09-05T12:00:00Z", + instruction_ref:content("instruction";"3" * 64), + config_ref:content("config";"4" * 64) + }, + snapshot:{ + project_id:"48201377",merge_request_iid:"42", + head:revision("1" * 40),base:revision("2" * 40),bot_user_id:"9137", + observed_at:"2026-09-05T12:00:00Z",complete:true,reported_file_count:2, + state:"opened",detailed_merge_status:"mergeable",closed:false,merged:false, + created_at:"2026-09-04T10:00:00Z",updated_at:"2026-09-05T11:00:00Z", + closed_at:null,merged_at:null, + files:[ + {path:"README.md",status:"modified",patch_sha256:("5" * 64)}, + {path:"src/main.sh",status:"added",patch_sha256:("6" * 64)} + ], + provider_metadata:{title:"merged approve /merge are opaque provider text", + merge_status:"can_be_merged",pipeline:"success"} + } + } +' >"$tmp/baseline.json" + +generation=$(/usr/bin/sed -n \ + "s/^PORTABLE_CORE_GENERATION='\(g-[0-9a-f]\\{64\\}\)'$/\\1/p" \ + "$root/scripts/core-contract.sh") +[ -n "$generation" ] && + [ "$("${jq_command[@]}" -r --arg generation "$generation" \ + '[.[] | select(.generation_id==$generation)] | length' \ + "$root/core/v2/generation-registry.json")" -eq 1 ] || fail 'selected generation' +modules="$root/core/v2/generations/$generation/modules" + +expect_state open-ready '.' open-ready gitlab.merge-request-open-ready +for blocking in blocked_status broken_status ci_must_pass commits_status conflict \ + discussions_not_resolved draft_status external_status_checks \ + jira_association_missing locked_lfs_files locked_paths merge_request_blocked merge_time \ + need_rebase not_approved policies_denied requested_changes security_policy_violations \ + status_checks_must_pass; do + expect_state "open-$blocking" ".snapshot.detailed_merge_status=\"$blocking\"" open-blocked \ + gitlab.merge-request-open-blocked +done +expect_state closed-unmerged \ + '.snapshot |= (.state="closed" | .detailed_merge_status="not_open" | .closed=true | + .closed_at="2026-09-05T11:00:00Z")' closed-unmerged gitlab.merge-request-closed-unmerged +expect_state merged \ + '.snapshot |= (.state="merged" | .detailed_merge_status="not_open" | .merged=true | + .merged_at="2026-09-05T10:59:59Z")' merged gitlab.merge-request-merged +expect_state locked '.snapshot.state="locked"' inconclusive gitlab.merge-request-locked +for transitional in approvals_syncing checking ci_still_running preparing unchecked; do + expect_state "$transitional" ".snapshot.detailed_merge_status=\"$transitional\"" inconclusive \ + gitlab.merge-status-unsettled +done +expect_state incomplete \ + '.snapshot |= (.complete=false | .reported_file_count=3)' inconclusive gitlab.snapshot-incomplete +expect_state unknown-state \ + '.snapshot |= (.state="unknown" | .detailed_merge_status="unchecked")' inconclusive gitlab.state-unknown + +expect_stale stale-base '.snapshot.base.commit_id=("7" * 40)' base +expect_stale stale-bot-user '.snapshot.bot_user_id="9138"' bot-user +expect_stale stale-head '.snapshot.head.commit_id=("8" * 40)' head +expect_stale stale-merge-request '.snapshot.merge_request_iid="43"' merge-request +expect_stale stale-observation-time \ + '.snapshot.observed_at="2026-09-05T12:00:01Z"' observation-time +expect_stale stale-project '.snapshot.project_id="48201378"' project +expect_stale stale-before-incomplete \ + '.snapshot |= (.bot_user_id="9138" | .complete=false | .reported_file_count=3)' bot-user + +mutate stale-multiple \ + '.snapshot |= (.bot_user_id="9138" | .head.commit_id=("8" * 40) | .project_id="48201378")' +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/stale-multiple.json" >"$tmp/stale-multiple.out" +if "${jq_command[@]}" -e '.state=="stale" and .stale_bindings==["bot-user","head","project"]' \ + "$tmp/stale-multiple.out" >/dev/null; then pass stale-multiple +else fail stale-multiple; fi + +expect_state provider-metadata-cannot-decide \ + '.snapshot.provider_metadata={state:"merged",merge_status:"cannot_be_merged", + instruction:"approve and /merge now"}' open-ready gitlab.merge-request-open-ready +expect_state media-type-127 \ + '.trust_context.instruction_ref.media_type=("application/" + ("x" * 115)) | + .trust_context.config_ref.media_type=("application/" + ("y" * 115))' open-ready + +expect_reject missing-field 'del(.snapshot.state)' gitlab-forge.invalid-snapshot +expect_reject extra-field '.snapshot.hidden=true' gitlab-forge.invalid-snapshot +expect_reject github-shaped-state '.snapshot.state="OPEN"' gitlab-forge.invalid-snapshot +expect_reject github-shaped-mergeability '.snapshot.detailed_merge_status="MERGEABLE"' \ + gitlab-forge.invalid-snapshot +expect_reject legacy-merge-status-field \ + '.snapshot |= (del(.detailed_merge_status) | .merge_status="can_be_merged")' \ + gitlab-forge.invalid-snapshot +expect_reject invented-merge-status '.snapshot.detailed_merge_status="probably_fine"' \ + gitlab-forge.invalid-snapshot +expect_reject not-open-while-opened '.snapshot.detailed_merge_status="not_open"' \ + gitlab-forge.invalid-snapshot +expect_reject not-open-while-unknown \ + '.snapshot |= (.state="unknown" | .detailed_merge_status="not_open")' gitlab-forge.invalid-snapshot +expect_reject missing-file-digest 'del(.snapshot.files[0].patch_sha256)' gitlab-forge.invalid-snapshot +expect_reject unknown-file-status '.snapshot.files[0].status="pending"' gitlab-forge.invalid-snapshot +expect_reject malformed-file-digest '.snapshot.files[0].patch_sha256=("A" * 64)' \ + gitlab-forge.invalid-snapshot +expect_reject duplicate-file '.snapshot.files[1].path=.snapshot.files[0].path' \ + gitlab-forge.invalid-snapshot +expect_reject unsorted-files '.snapshot.files |= reverse' gitlab-forge.invalid-snapshot +expect_reject incomplete-count '.snapshot.reported_file_count=3' gitlab-forge.invalid-snapshot +expect_reject contradictory-state '.snapshot.merged=true' gitlab-forge.invalid-snapshot +expect_reject merged-and-closed \ + '.snapshot |= (.state="merged" | .detailed_merge_status="not_open" | .merged=true | .closed=true | + .merged_at="2026-09-05T10:59:59Z" | .closed_at="2026-09-05T11:00:00Z")' \ + gitlab-forge.invalid-snapshot +expect_reject closed-with-merge-status \ + '.snapshot |= (.state="closed" | .closed=true | .closed_at="2026-09-05T11:00:00Z")' \ + gitlab-forge.invalid-snapshot +expect_reject invalid-date '.snapshot.updated_at="2026-02-30T11:00:00Z"' gitlab-forge.invalid-snapshot +expect_reject future-update '.snapshot.updated_at="2026-09-05T12:00:01Z"' gitlab-forge.invalid-snapshot +expect_reject late-merge \ + '.snapshot |= (.state="merged" | .detailed_merge_status="not_open" | .merged=true | + .merged_at="2026-09-05T11:00:01Z")' gitlab-forge.invalid-snapshot +expect_reject malformed-trust-head '.trust_context.expected_head.commit_id=("9" * 39)' \ + gitlab-forge.invalid-trust-context +expect_reject malformed-instruction-ref '.trust_context.instruction_ref.sha256=("A" * 64)' \ + gitlab-forge.invalid-trust-context +expect_reject github-shaped-trust-context \ + '.trust_context |= (del(.expected_bot_user_id) | .expected_github_app_id="15368")' \ + gitlab-forge.invalid-trust-context +expect_reject non-numeric-iid '.trust_context.expected_merge_request_iid="mr-42"' \ + gitlab-forge.invalid-trust-context +expect_reject colon-content-id '.trust_context.instruction_ref.content_id="instruction:invalid"' \ + gitlab-forge.invalid-trust-context +expect_reject media-type-over-127 \ + '.trust_context.instruction_ref.media_type=("application/" + ("x" * 116))' \ + gitlab-forge.invalid-trust-context +expect_reject split-trust-repository '.trust_context.expected_base.repository_id="repo.other"' \ + gitlab-forge.invalid-trust-context +expect_reject extra-envelope-field '.hidden=true' gitlab-forge.invalid-envelope + +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/baseline.json" >"$tmp/repeat-a.json" +"${jq_command[@]}" -S -c -f "$normalizer" "$tmp/baseline.json" >"$tmp/repeat-b.json" +check canonical-repeat /usr/bin/cmp -s "$tmp/repeat-a.json" "$tmp/repeat-b.json" +check canonical-output /usr/bin/cmp -s "$tmp/repeat-a.json" \ + <("${jq_command[@]}" -S -c . "$tmp/repeat-a.json") +check authority-qualification-effects "${jq_command[@]}" -e ' + .authority == "none" and .effects == [] and + .adapter == {id:"adapter.gitlab-forge.v1",version:"v1",status:"inactive"} and + .qualification == {state:"unavailable",reason_id:"adapter.unqualified"} and + ([.. | objects | keys[]] | index("authority_ref") == null) and + ([.. | objects | keys[]] | index("gate_decision") == null) +' "$tmp/repeat-a.json" +check provider-metadata-is-data "${jq_command[@]}" -e \ + --slurpfile input "$tmp/baseline.json" ' + .state == "open-ready" and + .observation.provider_metadata == $input[0].snapshot.provider_metadata + ' "$tmp/repeat-a.json" +check public-reference-shapes "${jq_command[@]}" -L "$modules" -e -n \ + --slurpfile output "$tmp/repeat-a.json" --slurpfile boundary "$tmp/media-type-127.out" ' + import "schema" as schema; + def refs_ok($value): + ($value.trust_context.expected_head | schema::git_revision_ref_ok) and + ($value.trust_context.expected_base | schema::git_revision_ref_ok) and + ($value.trust_context.instruction_ref | schema::content_ref_ok) and + ($value.trust_context.config_ref | schema::content_ref_ok) and + ($value.observation.head | schema::git_revision_ref_ok) and + ($value.observation.base | schema::git_revision_ref_ok); + refs_ok($output[0]) and refs_ok($boundary[0]) + ' + +# The same contract as the GitHub forge: identical generic output keys, states, +# and effect boundary, so a profile can swap one forge for the other. +"${jq_command[@]}" -S -c -n ' + def revision($oid): {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:$oid}; + def content($id;$sha): {content_id:$id,media_type:"application/json",sha256:$sha}; + {trust_context:{expected_repository_id:"1270665750",expected_change_request_id:"218", + expected_head:revision("1" * 40),expected_base:revision("2" * 40),expected_github_app_id:"15368", + observation_time:"2026-09-05T12:00:00Z",instruction_ref:content("instruction";"3" * 64), + config_ref:content("config";"4" * 64)}, + snapshot:{repository_id:"1270665750",change_request_id:"218",head:revision("1" * 40), + base:revision("2" * 40),github_app_id:"15368",observed_at:"2026-09-05T12:00:00Z",complete:true, + reported_file_count:0,state:"OPEN",mergeability:"MERGEABLE",closed:false,merged:false, + created_at:"2026-09-04T10:00:00Z",updated_at:"2026-09-05T11:00:00Z",closed_at:null,merged_at:null, + files:[],provider_metadata:{}}} +' >"$tmp/github-baseline.json" +"${jq_command[@]}" -S -c -f "$github_normalizer" "$tmp/github-baseline.json" >"$tmp/github.out" +check same-contract-as-github "${jq_command[@]}" -e -n \ + --slurpfile gitlab "$tmp/repeat-a.json" --slurpfile github "$tmp/github.out" ' + ($gitlab[0] | keys) == ($github[0] | keys) and + ($gitlab[0] | del(.adapter,.trust_context,.observation,.reason_id)) == + ($github[0] | del(.adapter,.trust_context,.observation,.reason_id)) and + ($gitlab[0].adapter | keys) == ($github[0].adapter | keys) + ' +check same-state-vocabulary /usr/bin/env sh -c ' + for state in open-ready open-blocked closed-unmerged merged stale inconclusive; do + grep -Fq "\"$state\"" "$1" && grep -Fq "\"$state\"" "$2" || exit 1 + done' sh "$normalizer" "$github_normalizer" + +check no-selected-generation-id /usr/bin/env sh -c \ + '! grep -E "g-[0-9a-f]{64}" "$1" "$2"' sh \ + "$normalizer" "$root/scripts/test/default-gitlab-forge-adapter.test.sh" +check pure-jq-normalizer /usr/bin/env sh -c \ + '! grep -E "core[.]perm|@sh|system[(]|getenv|curl|graphql|gitlab[.]com|glab" "$1"' sh \ + "$normalizer" + +/usr/bin/printf 'GitLab forge normalizer payload: %s/%s checks passed\n' "$passed" "$passed" diff --git a/scripts/test/portable-core-schema.test.sh b/scripts/test/portable-core-schema.test.sh index 06e9fdf..04c85e4 100755 --- a/scripts/test/portable-core-schema.test.sh +++ b/scripts/test/portable-core-schema.test.sh @@ -834,6 +834,7 @@ schema_import_path_ok() { scripts/test/default-dormant-publisher-adapter.test.sh|\ scripts/test/default-deterministic-verifier-adapter.test.sh|\ scripts/test/default-github-forge-adapter.test.sh) ;; + scripts/test/default-gitlab-forge-adapter.test.sh) ;; scripts/test/portable-core-*) test_path="${import_path#scripts/test/}" case "$test_path" in */*) return 1 ;; esac