Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/auto-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT
#
# OMN-6571: Automatically enable auto-merge (squash) on every PR opened
# by jonahgabriel. Ensures PRs do not sit idle in the merge queue waiting
# for manual enrollment.
name: Auto-Merge

on:
pull_request:
types: [opened, reopened, ready_for_review]

permissions:
contents: write
pull-requests: write

jobs:
auto-merge:
name: Enable Auto-Merge
runs-on: ubuntu-latest
if: github.actor == 'jonahgabriel'
steps:
# continue-on-error: merge queue re-triggers this workflow, and
# gh pr merge fails with "already enqueued" — that is expected.
- name: Enable auto-merge (squash)
continue-on-error: true
run: gh pr merge "${{ github.event.pull_request.number }}" --auto --squash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:

jobs:
lint:
Expand Down Expand Up @@ -59,3 +60,40 @@ jobs:
run: uv sync --all-extras
- name: Pytest
run: uv run pytest tests/ -v --tb=short

# ============================================================================
# GATE AGGREGATOR
# Required status check for branch protection and merge queue.
# ============================================================================
ci-summary:
name: CI Summary
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
if: always()
steps:
- name: Check CI results
run: |
echo "=== CI Summary ==="
FAILED=false

for check in \
"lint=${{ needs.lint.result }}" \
"typecheck=${{ needs.typecheck.result }}" \
"test=${{ needs.test.result }}"; do
NAME="${check%%=*}"
RESULT="${check##*=}"
if [ "$RESULT" != "success" ] && [ "$RESULT" != "skipped" ]; then
echo "::error::$NAME failed (result: $RESULT)"
FAILED=true
else
echo "$NAME: $RESULT"
fi
done

if [ "$FAILED" = "true" ]; then
echo ""
echo "CI Summary FAILED"
exit 1
fi
echo ""
echo "CI Summary PASSED"
115 changes: 115 additions & 0 deletions .github/workflows/contract-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

# Contract Validation Gate
#
# Validates ONEX ticket contracts for PRs in omnimarket using the
# reusable composite action from onex_change_control.
#
# The validation is intentionally lenient on missing contracts:
# - If branch name has no OMN-XXXX ticket ID -> skipped
# - If contracts/<ticket_id>.yaml does not exist -> skipped
# - If contract file exists but fails schema validation -> fails

name: Contract Validation

on:
pull_request:
branches: [main]
merge_group:
workflow_dispatch:
inputs:
branch-name:
description: "Branch name to validate contracts for (defaults to current branch)"
required: false
default: ""

concurrency:
group: contract-validation-${{ github.ref }}
cancel-in-progress: true

jobs:
contract-validation:
name: contract-validation
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
- name: Auth preflight — confirm onex_change_control action is accessible
id: auth-preflight
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Checking visibility of OmniNode-ai/onex_change_control..."
if gh api repos/OmniNode-ai/onex_change_control --silent; then
echo "::notice::onex_change_control repo is accessible."
else
echo "::error::Cannot reach OmniNode-ai/onex_change_control. Composite action will not resolve."
exit 1
fi

- name: Resolve branch name
id: resolve-branch
env:
WORKFLOW_INPUT_BRANCH: ${{ inputs.branch-name }}
GITHUB_HEAD_REF: ${{ github.head_ref }}
GITHUB_REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
run: |
if [[ "$EVENT_NAME" == "merge_group" ]]; then
echo "resolved_branch=" >> "$GITHUB_OUTPUT"
echo "skip_validation=true" >> "$GITHUB_OUTPUT"
echo "::notice::merge_group event — contract validation skipped (validated on PR branch instead)."
exit 0
fi

echo "skip_validation=false" >> "$GITHUB_OUTPUT"

if [[ -n "$WORKFLOW_INPUT_BRANCH" ]]; then
BRANCH="$WORKFLOW_INPUT_BRANCH"
elif [[ -n "$GITHUB_HEAD_REF" ]]; then
BRANCH="$GITHUB_HEAD_REF"
else
BRANCH="$GITHUB_REF_NAME"
fi
echo "resolved_branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "::notice::Resolved branch name: $BRANCH"

- name: Run contract validation
id: validate-contract
if: steps.resolve-branch.outputs.skip_validation != 'true'
uses: OmniNode-ai/onex_change_control/.github/actions/validate-contract@main
Comment thread
jonahgabriel marked this conversation as resolved.
with:
branch-name: ${{ steps.resolve-branch.outputs.resolved_branch }}
contracts-path: contracts

- name: Report validation result
if: always()
env:
TICKET_ID: ${{ steps.validate-contract.outputs.ticket-id }}
VALIDATION_STATUS: ${{ steps.validate-contract.outputs.validation-status }}
SKIP_VALIDATION: ${{ steps.resolve-branch.outputs.skip_validation }}
run: |
if [[ "$SKIP_VALIDATION" == "true" ]]; then
echo "::notice::Contract validation SKIPPED — merge_group event."
exit 0
fi
case "$VALIDATION_STATUS" in
passed)
echo "::notice::Contract validation PASSED for ticket $TICKET_ID"
;;
skipped)
if [[ -z "$TICKET_ID" ]]; then
echo "::notice::Contract validation SKIPPED — no OMN-XXXX ticket ID found in branch name."
else
echo "::notice::Contract validation SKIPPED — no contract file found for $TICKET_ID in contracts/."
fi
;;
failed)
echo "::error::Contract validation FAILED for ticket $TICKET_ID. See validate-contract step for details."
exit 1
;;
*)
echo "::warning::Unexpected validation status: '$VALIDATION_STATUS'. Treating as skipped."
;;
Comment thread
jonahgabriel marked this conversation as resolved.
esac
186 changes: 186 additions & 0 deletions .github/workflows/linear-close-on-merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc.
# SPDX-License-Identifier: MIT

# linear-close-on-merge.yml
#
# Auto-close Linear tickets when their associated PR merges.
#
# Fires on pull_request closed events. When the PR was actually merged
# (not just closed), extracts the OMN-XXXX ticket ID from the branch name
# and calls the Linear API to mark the ticket as Done.
#
# Branch name conventions supported:
# jonahgabriel/omn-XXXX-description (Linear default)
# jonah/omn-XXXX-description (short username variant)
# epic/OMN-XXXX/OMN-YYYY/run-id (epic-team branches, closes child OMN-YYYY)
# OMN-XXXX-description (bare prefix variant)
#
# Implements OMN-3262.

name: Close Linear Ticket on PR Merge

on:
pull_request:
types: [closed]
branches:
- main
- develop

jobs:
close-linear-ticket:
name: Close Linear Ticket
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest

steps:
- name: Extract ticket ID from branch name
id: extract
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
shell: bash
run: |
echo "Branch: $BRANCH"

TICKET_ID=""

# Try to extract from epic branch: epic/OMN-XXXX/OMN-YYYY/...
if printf '%s' "$BRANCH" | grep -qiE "^epic/OMN-[0-9]+/(OMN-[0-9]+)(/|$)"; then
TICKET_ID=$(printf '%s' "$BRANCH" | grep -ioE "^epic/OMN-[0-9]+/(OMN-[0-9]+)(/|$)" | grep -ioE "OMN-[0-9]+" | tail -1)
fi

# Try standard branch: */omn-XXXX-* or */OMN-XXXX-*
if [[ -z "$TICKET_ID" ]]; then
TICKET_ID=$(printf '%s' "$BRANCH" | grep -ioE "(OMN|omn)-[0-9]+" | head -1 | tr '[:lower:]' '[:upper:]')
fi

if [[ -z "$TICKET_ID" ]]; then
echo "No OMN-XXXX ticket ID found in branch '$BRANCH' — skipping."
echo "ticket_id=" >> "$GITHUB_OUTPUT"
else
echo "Extracted ticket ID: $TICKET_ID"
echo "ticket_id=$TICKET_ID" >> "$GITHUB_OUTPUT"
fi

- name: Close Linear ticket
if: steps.extract.outputs.ticket_id != ''
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
TICKET_ID: ${{ steps.extract.outputs.ticket_id }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
shell: bash
run: |
if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set — skipping Linear ticket close"
exit 0
fi

# Validate LINEAR_API_KEY format (alphanumeric only)
if ! printf '%s' "$LINEAR_API_KEY" | grep -qE '^[A-Za-z0-9_-]+$'; then
echo "LINEAR_API_KEY appears malformed — skipping Linear ticket close"
exit 0
fi

# Validate extracted IDs are safe for JSON interpolation
if ! echo "$TICKET_ID" | grep -qE '^(OMN|omn)-[0-9]+$'; then
echo "Invalid TICKET_ID format: $TICKET_ID — skipping"
exit 0
fi

echo "Marking $TICKET_ID as Done (PR #$PR_NUMBER merged in $REPO)"

SEARCH_RESPONSE=$(curl -s --max-time 30 -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"query\": \"{ issue(id: \\\"$TICKET_ID\\\") { id identifier title state { name } } }\"}" \
2>&1)

echo "Linear API search: $(echo "$SEARCH_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print('found' if d.get('data',{}).get('issue') else 'not found')" 2>/dev/null || echo "status-unknown")"

ISSUE_ID=$(echo "$SEARCH_RESPONSE" | python3 -c "
import json, sys
data = json.load(sys.stdin)
issue = data.get('data', {}).get('issue')
if issue:
print(issue['id'])
else:
print('')
" 2>/dev/null || echo "")

if [ -z "$ISSUE_ID" ]; then
echo "Could not extract issue ID from Linear response — skipping"
exit 0
fi

if ! echo "$ISSUE_ID" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
echo "ISSUE_ID is not a valid UUID: $ISSUE_ID — skipping"
exit 0
fi

CURRENT_STATE=$(echo "$SEARCH_RESPONSE" | python3 -c "
import json, sys
data = json.load(sys.stdin)
issue = data.get('data', {}).get('issue')
if issue:
print(issue.get('state', {}).get('name', ''))
else:
print('')
" 2>/dev/null || echo "")

echo "Issue $TICKET_ID (ID: $ISSUE_ID) current state: $CURRENT_STATE"

if echo "$CURRENT_STATE" | grep -qiE "^(done|cancelled|duplicate|completed|merged|released|shipped|closed)$"; then
echo "$TICKET_ID is already in state '$CURRENT_STATE' — no update needed."
exit 0
fi

TEAM_RESPONSE=$(curl -s --max-time 30 -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"query\": \"{ issue(id: \\\"$TICKET_ID\\\") { team { id states { nodes { id name type } } } } }\"}" \
2>&1)

DONE_STATE_ID=$(echo "$TEAM_RESPONSE" | python3 -c "
import json, sys
data = json.load(sys.stdin)
states = (data.get('data', {})
.get('issue', {})
.get('team', {})
.get('states', {})
.get('nodes', []))
completed = [s for s in states if s.get('type', '').upper() == 'COMPLETED']
done = next((s for s in completed if s.get('name', '').lower() == 'done'), None)
if done:
print(done['id'])
elif completed:
print(sorted(completed, key=lambda s: s.get('name', ''))[0]['id'])
" 2>/dev/null || echo "")

if [ -z "$DONE_STATE_ID" ]; then
echo "Could not find Done state ID for this team — skipping"
exit 0
fi

if ! echo "$DONE_STATE_ID" | grep -qE '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'; then
echo "DONE_STATE_ID is not a valid UUID: $DONE_STATE_ID — skipping"
exit 0
fi

UPDATE_RESPONSE=$(curl -s --max-time 30 -X POST https://api.linear.app/graphql \
-H "Authorization: $LINEAR_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"query\": \"mutation { issueUpdate(id: \\\"$ISSUE_ID\\\", input: { stateId: \\\"$DONE_STATE_ID\\\" }) { success issue { id state { name } } } }\"}" \
2>&1)

SUCCESS=$(echo "$UPDATE_RESPONSE" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data.get('data', {}).get('issueUpdate', {}).get('success', False))
" 2>/dev/null || echo "False")

if [[ "$SUCCESS" == "True" ]]; then
echo "Successfully marked $TICKET_ID as Done."
else
echo "WARNING: Failed to mark $TICKET_ID as Done."
exit 0
fi
Loading
Loading