name: Propagate Changes Between Branches

on:
  workflow_run:
    workflows: ["Docker Build, Publish & Test"]
    types: [completed]
    branches: [ main, development ]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}
  cancel-in-progress: false

env:
  NODE_VERSION: '24.12.0'

permissions:
  contents: write
  pull-requests: write
  issues: write

jobs:
  propagate:
    name: Create PR to synchronize branches
    runs-on: ubuntu-latest
    if: >-
      github.actor != 'github-actions[bot]' &&
      github.event.workflow_run.conclusion == 'success' &&
      (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'development')
    steps:
      - name: Set up Node (for github-script)
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Propagate Changes
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
        env:
          CURRENT_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
          CURRENT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          CHARON_TOKEN: ${{ secrets.CHARON_TOKEN }}
        with:
          script: |
            const currentBranch = process.env.CURRENT_BRANCH || context.ref.replace('refs/heads/', '');
            let excludedBranch = null;

            // Loop Prevention: Identify if this commit is from a merged PR
            try {
                const associatedPRs = await github.rest.repos.listPullRequestsAssociatedWithCommit({
                owner: context.repo.owner,
                repo: context.repo.repo,
                  commit_sha: process.env.CURRENT_SHA || context.sha,
              });

              // If the commit comes from a PR, we identify the source branch
              // so we don't try to merge changes back into it immediately.
              if (associatedPRs.data.length > 0) {
                excludedBranch = associatedPRs.data[0].head.ref;
                core.info(`Commit ${process.env.CURRENT_SHA || context.sha} is associated with PR #${associatedPRs.data[0].number} coming from '${excludedBranch}'. This branch will be excluded from propagation to prevent loops.`);
              }
            } catch (err) {
              core.warning(`Failed to check associated PRs: ${err.message}`);
            }

            async function createPR(src, base) {
              if (src === base) return;

              core.info(`Checking propagation from ${src} to ${base}...`);

              // Check for existing open PRs
              const { data: pulls } = await github.rest.pulls.list({
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open',
                head: `${context.repo.owner}:${src}`,
                base: base,
              });

              if (pulls.length > 0) {
                core.info(`Existing PR found for ${src} -> ${base}. Skipping.`);
                return;
              }

              // Compare commits to see if src is ahead of base
              try {
                const compare = await github.rest.repos.compareCommits({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  base: base,
                  head: src,
                });

                // If src is not ahead, nothing to merge
                if (compare.data.ahead_by === 0) {
                  core.info(`${src} is not ahead of ${base}. No propagation needed.`);
                  return;
                }

                // If files changed include history-rewrite or other sensitive scripts,
                // avoid automatic propagation. This prevents bypassing checklist validation
                // and manual review for potentially destructive changes.
                let files = (compare.data.files || []).map(f => (f.filename || '').toLowerCase());

                // Fallback: if compare.files is empty/truncated, aggregate files from the commit list
                if (files.length === 0 && Array.isArray(compare.data.commits) && compare.data.commits.length > 0) {
                  for (const commit of compare.data.commits) {
                    const commitData = await github.rest.repos.getCommit({ owner: context.repo.owner, repo: context.repo.repo, ref: commit.sha });
                    for (const f of (commitData.data.files || [])) {
                      files.push((f.filename || '').toLowerCase());
                    }
                  }
                  files = Array.from(new Set(files));
                }

                // Load propagation config (list of sensitive paths) from .github/propagate-config.yml when available
                // NOTE: .github/workflows/ was removed from defaults - workflow updates SHOULD propagate
                // to ensure downstream branches have correct CI/CD configurations
                let configPaths = ['scripts/history-rewrite/', 'data/backups', 'docs/plans/history_rewrite.md'];
                try {
                  const configResp = await github.rest.repos.getContent({ owner: context.repo.owner, repo: context.repo.repo, path: '.github/propagate-config.yml', ref: src });
                  const contentStr = Buffer.from(configResp.data.content, 'base64').toString('utf8');
                  const lines = contentStr.split(/\r?\n/);
                  let inSensitive = false;
                  const parsedPaths = [];
                  for (const line of lines) {
                    const trimmed = line.trim();
                    if (!inSensitive && trimmed.startsWith('sensitive_paths:')) { inSensitive = true; continue; }
                    if (inSensitive) {
                      if (trimmed.startsWith('-')) parsedPaths.push(trimmed.substring(1).trim());
                      else if (trimmed.length === 0) continue; else break;
                    }
                  }
                  if (parsedPaths.length > 0) configPaths = parsedPaths.map(p => p.toLowerCase());
                } catch (err) { core.info('No .github/propagate-config.yml or parse failure; using defaults.'); }

                const sensitive = files.some(fn => configPaths.some(sp => fn.startsWith(sp) || fn.includes(sp)));
                if (sensitive) {
                  const preview = files.slice(0, 25).join(', ');
                  const suffix = files.length > 25 ? ` …(+${files.length - 25} more)` : '';
                  core.info(`${src} -> ${base} contains sensitive changes (${preview}${suffix}). Skipping automatic propagation.`);
                  return;
                }
              } catch (error) {
                // If base branch doesn't exist, etc.
                core.warning(`Error comparing ${src} to ${base}: ${error.message}`);
                return;
              }

              // Create PR
              try {
                const pr = await github.rest.pulls.create({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  title: `Propagate changes from ${src} into ${base}`,
                  head: src,
                  base: base,
                  body: `Automated PR to propagate changes from ${src} into ${base}.\n\nTriggered by push to ${currentBranch}.`,
                  draft: true,
                });
                core.info(`Created PR #${pr.data.number} to merge ${src} into ${base}`);
                // Add an 'auto-propagate' label to the created PR and create the label if missing
                try {
                  try {
                    await github.rest.issues.getLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate' });
                  } catch (e) {
                    await github.rest.issues.createLabel({ owner: context.repo.owner, repo: context.repo.repo, name: 'auto-propagate', color: '7dd3fc', description: 'Automatically created propagate PRs' });
                  }
                  await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.data.number, labels: ['auto-propagate'] });
                } catch (labelErr) {
                  core.warning('Failed to ensure or add auto-propagate label: ' + labelErr.message);
                }
              } catch (error) {
                core.warning(`Failed to create PR from ${src} to ${base}: ${error.message}`);
              }
            }

            if (currentBranch === 'main') {
              // Main -> Development
              // Only propagate if development is not the source (loop prevention)
              if (excludedBranch !== 'development') {
                await createPR('main', 'development');
              } else {
                core.info('Push originated from development (excluded). Skipping propagation back to development.');
              }
            } else if (currentBranch === 'development') {
              // Development -> Feature/Hotfix branches (The Pittsburgh Model)
              // We propagate changes from dev DOWN to features/hotfixes so they stay up to date.

              const branches = await github.paginate(github.rest.repos.listBranches, {
                owner: context.repo.owner,
                repo: context.repo.repo,
              });

              // Filter for feature/* and hotfix/* branches using regex
              // AND exclude the branch that just got merged in (if any)
              const targetBranches = branches
                .map(b => b.name)
                .filter(name => {
                  const isTargetType = /^feature\/|^hotfix\//.test(name);
                  const isExcluded = (name === excludedBranch);
                  return isTargetType && !isExcluded;
                });

              core.info(`Found ${targetBranches.length} target branches (excluding '${excludedBranch || 'none'}'): ${targetBranches.join(', ')}`);

              for (const targetBranch of targetBranches) {
                await createPR('development', targetBranch);
              }
            }
