From 25667e9a82d34c728c9470f41f354c8962e619a0 Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 15:33:33 -0400 Subject: [PATCH 1/6] Roadmap item 6: Add inactive GitLab forge normalizer payload First alternative forge. adapters/gitlab-forge/v1/normalize.jq 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: a locked request and a checking or unchecked merge status are inconclusive, a merged request is never also closed, a closed or merged request carries no merge status, and the acting identity is the bot user the integration runs as, since GitLab has no app id. Provider metadata stays opaque data; GitHub-shaped states, mergeability values, and trust contexts are refused. Pure jq, offline, unqualified: no GitLab or CLI call, credential, project or merge-request change, authority, qualification, or profile activation. The test proves the contract equals the GitHub forge's output contract key for key. Proof: scripts/test/default-gitlab-forge-adapter.test.sh 54/54, shellcheck 0.11.0 clean, rename gate clean. Co-Authored-By: Claude Fable 5.1 --- README.md | 19 ++ adapters/gitlab-forge/v1/normalize.jq | 199 ++++++++++++ ci/required-files.txt | 4 + .../test/default-gitlab-forge-adapter.test.sh | 286 ++++++++++++++++++ 4 files changed, 508 insertions(+) create mode 100644 adapters/gitlab-forge/v1/normalize.jq create mode 100755 scripts/test/default-gitlab-forge-adapter.test.sh diff --git a/README.md b/README.md index 5a7015f..58ff758 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,25 @@ 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: a locked request and a checking or unchecked merge status are +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..fedac35 --- /dev/null +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -0,0 +1,199 @@ +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 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 + elif .state == "closed" then + .closed == true and .merged == false and + (.closed_at | time_ok) and .merged_at == null and .merge_status == "unknown" + elif .state == "merged" then + .closed == false and .merged == true and + .closed_at == null and (.merged_at | time_ok) and .merge_status == "unknown" + elif .state == "unknown" then + .closed == false and .merged == false and + .closed_at == null and .merged_at == null and .merge_status == "unknown" + 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","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 + (.merge_status | IN("mergeable","conflict","blocked","checking","unchecked","unknown")) 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.merge_status == "mergeable" then ["open-ready","gitlab.merge-request-open-ready"] + elif $snapshot.merge_status == "conflict" or $snapshot.merge_status == "blocked" then + ["open-blocked","gitlab.merge-request-open-blocked"] + else ["inconclusive","gitlab.merge-status-unknown"] + 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 c7dac9b..a2f9da3 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..c57a9c1 --- /dev/null +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -0,0 +1,286 @@ +#!/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",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", + detailed_merge_status:"mergeable",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 +expect_state open-conflict '.snapshot.merge_status="conflict"' open-blocked \ + gitlab.merge-request-open-blocked +expect_state open-blocked-status '.snapshot.merge_status="blocked"' open-blocked \ + gitlab.merge-request-open-blocked +expect_state closed-unmerged \ + '.snapshot |= (.state="closed" | .merge_status="unknown" | .closed=true | + .closed_at="2026-09-05T11:00:00Z")' closed-unmerged gitlab.merge-request-closed-unmerged +expect_state merged \ + '.snapshot |= (.state="merged" | .merge_status="unknown" | .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 +expect_state checking '.snapshot.merge_status="checking"' inconclusive gitlab.merge-status-unknown +expect_state unchecked '.snapshot.merge_status="unchecked"' inconclusive gitlab.merge-status-unknown +expect_state unknown-merge-status '.snapshot.merge_status="unknown"' inconclusive \ + gitlab.merge-status-unknown +expect_state incomplete \ + '.snapshot |= (.complete=false | .reported_file_count=3)' inconclusive gitlab.snapshot-incomplete +expect_state unknown-state \ + '.snapshot |= (.state="unknown" | .merge_status="unknown")' 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",detailed_merge_status:"mergeable", + 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.merge_status="MERGEABLE"' \ + 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" | .merge_status="unknown" | .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" | .merge_status="unknown" | .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" From ade23b7fc1becf0fb36231203e83e389ae02c16a Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 15:34:41 -0400 Subject: [PATCH 2/6] Register the GitLab forge test in the schema import allowlist The test imports the public schema module to check reference shapes, the same way the GitHub forge test does, so it joins that closed allowlist. Co-Authored-By: Claude Fable 5.1 --- scripts/test/portable-core-schema.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test/portable-core-schema.test.sh b/scripts/test/portable-core-schema.test.sh index 8a9928b..ab7669d 100755 --- a/scripts/test/portable-core-schema.test.sh +++ b/scripts/test/portable-core-schema.test.sh @@ -832,6 +832,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 From fea2ff96d8ef03e34f8fc3a54e2e785a26c69e7c Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 16:33:28 -0400 Subject: [PATCH 3/6] Take GitLab's detailed_merge_status values as the API reports them Review finding on the previous head: the snapshot used an invented merge-status vocabulary, so an ordinary GitLab merge request could not be normalized. The snapshot now carries detailed_merge_status with GitLab's documented values. mergeable is open-ready; the fourteen blocking values are open-blocked; the four transitional values are inconclusive (gitlab.merge-status-unsettled); not_open belongs only to a closed or merged request. An invented value, the legacy merge_status field, a GitHub-shaped value, or not_open on an open request is refused. scripts/test/default-gitlab-forge-adapter.test.sh 71/71, shellcheck 0.11.0 clean. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 ++-- adapters/gitlab-forge/v1/normalize.jq | 34 +++++++++++---- .../test/default-gitlab-forge-adapter.test.sh | 43 +++++++++++-------- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 58ff758..02c9b8c 100644 --- a/README.md +++ b/README.md @@ -245,10 +245,11 @@ 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: a locked request and a checking or unchecked merge status are -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. +the edge and is taken as the API reports it: `detailed_merge_status` values such +as `mergeable`, `conflict`, `ci_must_pass`, 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 diff --git a/adapters/gitlab-forge/v1/normalize.jq b/adapters/gitlab-forge/v1/normalize.jq index fedac35..983b4e5 100644 --- a/adapters/gitlab-forge/v1/normalize.jq +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -97,21 +97,36 @@ def files_ok($reported_count; $complete): ($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","ci_still_running","commits_status", + "conflict","discussions_not_resolved","draft_status","external_status_checks", + "jira_association_missing","need_rebase","not_approved","policies_denied", + "requested_changes"); +def transitional_merge_status: + IN("approvals_syncing","checking","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 + .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 .merge_status == "unknown" + (.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 .merge_status == "unknown" + .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 .merge_status == "unknown" + .closed_at == null and .merged_at == null else false end; @@ -129,7 +144,7 @@ def snapshot_ok: . as $snapshot | exact_fields( ["project_id","merge_request_iid","head","base","bot_user_id", - "observed_at","complete","reported_file_count","state","merge_status", + "observed_at","complete","reported_file_count","state","detailed_merge_status", "closed","merged","created_at","updated_at","closed_at","merged_at", "files","provider_metadata"]; []) and @@ -142,7 +157,7 @@ def snapshot_ok: (.observed_at | time_ok) and (.complete | type == "boolean") and (.state | IN("opened","closed","merged","locked","unknown")) and - (.merge_status | IN("mergeable","conflict","blocked","checking","unchecked","unknown")) and + (.detailed_merge_status | merge_status_ok) and (.closed | type == "boolean") and (.merged | type == "boolean") and (.provider_metadata | type == "object") and @@ -166,10 +181,11 @@ def normalized_state($snapshot; $stale): 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.merge_status == "mergeable" then ["open-ready","gitlab.merge-request-open-ready"] - elif $snapshot.merge_status == "conflict" or $snapshot.merge_status == "blocked" then + 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-unknown"] + else ["inconclusive","gitlab.merge-status-unsettled"] end; if (exact_fields(["trust_context","snapshot"];[]) | not) then diff --git a/scripts/test/default-gitlab-forge-adapter.test.sh b/scripts/test/default-gitlab-forge-adapter.test.sh index c57a9c1..b63182e 100755 --- a/scripts/test/default-gitlab-forge-adapter.test.sh +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -107,7 +107,7 @@ expect_reject() { 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",merge_status:"mergeable",closed:false,merged:false, + 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:[ @@ -115,7 +115,7 @@ expect_reject() { {path:"src/main.sh",status:"added",patch_sha256:("6" * 64)} ], provider_metadata:{title:"merged approve /merge are opaque provider text", - detailed_merge_status:"mergeable",pipeline:"success"} + merge_status:"can_be_merged",pipeline:"success"} } } ' >"$tmp/baseline.json" @@ -130,25 +130,27 @@ generation=$(/usr/bin/sed -n \ modules="$root/core/v2/generations/$generation/modules" expect_state open-ready '.' open-ready gitlab.merge-request-open-ready -expect_state open-conflict '.snapshot.merge_status="conflict"' open-blocked \ - gitlab.merge-request-open-blocked -expect_state open-blocked-status '.snapshot.merge_status="blocked"' open-blocked \ - gitlab.merge-request-open-blocked +for blocking in blocked_status broken_status ci_must_pass ci_still_running commits_status \ + conflict discussions_not_resolved draft_status external_status_checks \ + jira_association_missing need_rebase not_approved policies_denied requested_changes; 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" | .merge_status="unknown" | .closed=true | + '.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" | .merge_status="unknown" | .merged=true | + '.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 -expect_state checking '.snapshot.merge_status="checking"' inconclusive gitlab.merge-status-unknown -expect_state unchecked '.snapshot.merge_status="unchecked"' inconclusive gitlab.merge-status-unknown -expect_state unknown-merge-status '.snapshot.merge_status="unknown"' inconclusive \ - gitlab.merge-status-unknown +for transitional in approvals_syncing checking 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" | .merge_status="unknown")' inconclusive gitlab.state-unknown + '.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 @@ -168,7 +170,7 @@ if "${jq_command[@]}" -e '.state=="stale" and .stale_bindings==["bot-user","head else fail stale-multiple; fi expect_state provider-metadata-cannot-decide \ - '.snapshot.provider_metadata={state:"merged",detailed_merge_status:"mergeable", + '.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)) | @@ -177,7 +179,14 @@ expect_state media-type-127 \ 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.merge_status="MERGEABLE"' \ +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 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 @@ -189,7 +198,7 @@ expect_reject unsorted-files '.snapshot.files |= reverse' gitlab-forge.invalid-s 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" | .merge_status="unknown" | .merged=true | .closed=true | + '.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 \ @@ -198,7 +207,7 @@ expect_reject closed-with-merge-status \ 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" | .merge_status="unknown" | .merged=true | + '.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 From 4b7365db7ee8548e4ef9d6b2d8c12b79a9e306da Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 16:37:42 -0400 Subject: [PATCH 4/6] Accept every documented GitLab blocking merge status Review finding on the previous head: security_policy_violations, a documented GitLab detailed_merge_status value, was refused as an invalid snapshot. The blocking set now carries all twenty documented blocking values, including security_policy_violations, status_checks_must_pass, merge_request_blocked, merge_time, locked_paths, and locked_lfs_files; each normalizes an open request to open-blocked. scripts/test/default-gitlab-forge-adapter.test.sh 76/76, shellcheck 0.11.0 clean. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 ++-- adapters/gitlab-forge/v1/normalize.jq | 5 +++-- scripts/test/default-gitlab-forge-adapter.test.sh | 4 +++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 02c9b8c..1883f3c 100644 --- a/README.md +++ b/README.md @@ -246,8 +246,8 @@ 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`, or `checking` decide ready, blocked, -or inconclusive; a locked request is inconclusive; a merged request is never +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. diff --git a/adapters/gitlab-forge/v1/normalize.jq b/adapters/gitlab-forge/v1/normalize.jq index 983b4e5..0735041 100644 --- a/adapters/gitlab-forge/v1/normalize.jq +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -104,8 +104,9 @@ def ready_merge_status: . == "mergeable"; def blocking_merge_status: IN("blocked_status","broken_status","ci_must_pass","ci_still_running","commits_status", "conflict","discussions_not_resolved","draft_status","external_status_checks", - "jira_association_missing","need_rebase","not_approved","policies_denied", - "requested_changes"); + "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"); def transitional_merge_status: IN("approvals_syncing","checking","preparing","unchecked"); def merge_status_ok: diff --git a/scripts/test/default-gitlab-forge-adapter.test.sh b/scripts/test/default-gitlab-forge-adapter.test.sh index b63182e..e458905 100755 --- a/scripts/test/default-gitlab-forge-adapter.test.sh +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -132,7 +132,9 @@ 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 ci_still_running commits_status \ conflict discussions_not_resolved draft_status external_status_checks \ - jira_association_missing need_rebase not_approved policies_denied requested_changes; do + 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 From 633603a9cc712253dffbcd5ceb54a4321aa1afdc Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 16:42:09 -0400 Subject: [PATCH 5/6] Treat a still-running pipeline as transitional, not blocked Review finding on the previous head: ci_still_running was normalized to open-blocked, but it settles on its own once the pipeline finishes, like checking or preparing. It is now transitional and normalizes to inconclusive (gitlab.merge-status-unsettled); ci_must_pass stays blocking because it names a failed requirement. scripts/test/default-gitlab-forge-adapter.test.sh 76/76, shellcheck 0.11.0 clean. Co-Authored-By: Claude Fable 5.1 --- adapters/gitlab-forge/v1/normalize.jq | 8 +++++--- scripts/test/default-gitlab-forge-adapter.test.sh | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/adapters/gitlab-forge/v1/normalize.jq b/adapters/gitlab-forge/v1/normalize.jq index 0735041..1671d96 100644 --- a/adapters/gitlab-forge/v1/normalize.jq +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -102,13 +102,15 @@ def files_ok($reported_count; $complete): # 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","ci_still_running","commits_status", - "conflict","discussions_not_resolved","draft_status","external_status_checks", + 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","preparing","unchecked"); + 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"); diff --git a/scripts/test/default-gitlab-forge-adapter.test.sh b/scripts/test/default-gitlab-forge-adapter.test.sh index e458905..488fb14 100755 --- a/scripts/test/default-gitlab-forge-adapter.test.sh +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -130,8 +130,8 @@ generation=$(/usr/bin/sed -n \ 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 ci_still_running commits_status \ - conflict discussions_not_resolved draft_status external_status_checks \ +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 @@ -145,7 +145,7 @@ 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 preparing unchecked; do +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 From 2c610657f31db0e5dfc7309d9010663a105f8b09 Mon Sep 17 00:00:00 2001 From: ci Date: Sat, 5 Sep 2026 22:07:56 -0400 Subject: [PATCH 6/6] Refuse not_open on an unknown-state merge request Review finding: an unknown state accepted detailed_merge_status not_open, although that value belongs only to a closed or merged request. The unknown branch now refuses it, like the opened and locked branches. Regression added. default-gitlab-forge-adapter 77/77, shellcheck 0.11.0 clean. Co-Authored-By: Claude Fable 5.1 --- adapters/gitlab-forge/v1/normalize.jq | 2 +- scripts/test/default-gitlab-forge-adapter.test.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/adapters/gitlab-forge/v1/normalize.jq b/adapters/gitlab-forge/v1/normalize.jq index 1671d96..1d10aac 100644 --- a/adapters/gitlab-forge/v1/normalize.jq +++ b/adapters/gitlab-forge/v1/normalize.jq @@ -129,7 +129,7 @@ def state_facts_ok: .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 + .closed_at == null and .merged_at == null and .detailed_merge_status != "not_open" else false end; diff --git a/scripts/test/default-gitlab-forge-adapter.test.sh b/scripts/test/default-gitlab-forge-adapter.test.sh index 488fb14..770b565 100755 --- a/scripts/test/default-gitlab-forge-adapter.test.sh +++ b/scripts/test/default-gitlab-forge-adapter.test.sh @@ -190,6 +190,8 @@ expect_reject invented-merge-status '.snapshot.detailed_merge_status="probably_f 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)' \