Ctrl K
ComunidadGuías…

test/merge-check.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// merge_check (issue #192): which workflows a merge sets off, which of them
// deploy, and that only Billion has the tool.

import { describe, it, expect } from 'vitest';
import { analyzeWorkflows, matchesFilters, mergeCheck } from '../server/merge-check.js';
import { handleMcpMessage, toolsFor } from '../server/mcp.js';

const wf = (path, text) => ({ path: `.github/workflows/${path}`, text });
const run = (workflows, files = ['src/app.js'], base = 'main') => analyzeWorkflows({ workflows, base, files });

const DEPLOY_SCRAPER = wf('deploy-scraper.yml', `
name: Deploy scraper
on:
  push:
    branches: [main]
    paths: ['scraper/**']
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
`);

const TESTS = wf('test.yml', `
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm ci && npm test
`);

describe('analyzeWorkflows', () => {
  it('counts a push to main whose path filter matches, and why', () => {
    const r = run([DEPLOY_SCRAPER], ['scraper/main.py', 'README.md']);
    expect(r.matches).toEqual([{
      workflow: '.github/workflows/deploy-scraper.yml', job: 'build', trigger: 'push to main',
      why: ['uses aws-actions/configure-aws-credentials@v4'], environment: null,
    }]);
    expect(r.unknown).toEqual([]);
  });

  it('skips it when no changed file matches the paths, or the base is another branch', () => {
    expect(run([DEPLOY_SCRAPER], ['web/index.html']).triggered).toEqual([]);
    expect(run([DEPLOY_SCRAPER], ['scraper/main.py'], 'develop').triggered).toEqual([]);
  });

  it('paths-ignore and ! negation', () => {
    const ignore = wf('d.yml', 'on:\n  push:\n    paths-ignore: ["docs/**"]\njobs:\n  deploy:\n    runs-on: x\n');
    expect(run([ignore], ['docs/a.md']).matches).toEqual([]);
    expect(run([ignore], ['docs/a.md', 'src/b.js']).matches).toHaveLength(1);
    expect(matchesFilters(['src/**', '!src/**/*.md'], 'src/x/y.md')).toBe(false);
    expect(matchesFilters(['src/**', '!src/**/*.md'], 'src/x/y.js')).toBe(true);
    expect(matchesFilters(['**/README.md'], 'README.md')).toBe(true);
    expect(matchesFilters(['*.js'], 'a/b.js')).toBe(false);
  });

  it('follows a workflow_run chain from a triggered workflow', () => {
    const deploy = wf('cd.yml', `
on:
  workflow_run:
    workflows: [Tests]
    types: [completed]
    branches: [main]
jobs:
  ship:
    runs-on: ubuntu-latest
    environment: production
    steps: [{ run: echo hi }]
`);
    const r = run([TESTS, deploy]);
    expect(r.matches).toEqual([expect.objectContaining({
      workflow: '.github/workflows/cd.yml', job: 'ship', trigger: 'workflow_run after "Tests"', environment: 'production',
    })]);
    // Nothing sets off Tests: nothing chains.
    expect(run([DEPLOY_SCRAPER, { ...deploy }], ['x']).matches).toEqual([]);
  });

  it('environment: production and npm publish both count', () => {
    const r = run([wf('rel.yml', `
on: { push: { branches: [main] } }
jobs:
  a:
    runs-on: x
    environment: { name: production, url: https://x }
  b:
    runs-on: x
    steps:
      - run: |
          npm ci
          npm publish --access public
`)]);
    expect(r.matches.map(m => [m.job, m.why])).toEqual([
      ['a', ['environment: production']],
      ['b', ['runs "npm publish"']],
    ]);
  });

  it('a plain test workflow runs but deploys nothing', () => {
    const r = run([TESTS]);
    expect(r.triggered).toEqual([{ workflow: '.github/workflows/test.yml', trigger: 'push to main' }]);
    expect(r.matches).toEqual([]);
    expect(r.unknown).toEqual([]);
  });

  it('unreadable or invalid YAML is unknown, never no deploy', () => {
    const r = run([wf('bad.yml', 'on: [push\njobs: {'), { path: '.github/workflows/gone.yml', error: 'HTTP 403' }]);
    expect(r.matches).toEqual([]);
    expect(r.unknown).toEqual([
      expect.stringMatching(/^\.github\/workflows\/bad\.yml: not valid YAML/),
      '.github/workflows/gone.yml: could not read it (HTTP 403)',
    ]);
  });

  it('pull_request closed counts; tag workflows only when something makes a tag', () => {
    const closed = wf('pc.yml', 'on:\n  pull_request:\n    types: [closed]\n    branches: [main]\njobs:\n  deploy:\n    runs-on: x\n');
    expect(run([closed]).matches).toHaveLength(1);
    const onTag = wf('tag.yml', 'on:\n  push:\n    tags: ["v*"]\njobs:\n  publish:\n    runs-on: x\n');
    expect(run([TESTS, onTag]).matches).toEqual([]);
    expect(run([TESTS, onTag]).notes[0]).toMatch(/tag\.yml run on a tag or release/);
    const tagger = wf('tagger.yml', 'on: push\njobs:\n  t:\n    runs-on: x\n    steps: [{ run: "git tag v1 && git push --tags" }]\n');
    expect(run([tagger, onTag]).matches).toEqual([expect.objectContaining({ workflow: '.github/workflows/tag.yml', job: 'publish' })]);
  });

  it('looks inside a local reusable workflow; an external one is unknown', () => {
    const lib = wf('lib.yml', 'on: workflow_call\njobs:\n  up:\n    runs-on: x\n    steps: [{ run: "fly deploy" }]\n');
    const caller = wf('main.yml', 'on: push\njobs:\n  go:\n    uses: ./.github/workflows/lib.yml\n  other:\n    uses: org/ci/.github/workflows/x.yml@v1\n');
    const r = run([caller, lib]);
    expect(r.matches).toEqual([expect.objectContaining({ workflow: '.github/workflows/lib.yml', job: 'up', why: ['runs "fly deploy"'] })]);
    expect(r.unknown).toEqual([expect.stringMatching(/job other: calls org\/ci/)]);
  });
});

describe('mergeCheck', () => {
  const PR = 'https://github.com/o/r/pull/7';
  const fakeGh = (routes) => async (args) => {
    const path = args.find(a => a.startsWith('repos/'));
    const hit = Object.entries(routes).find(([k]) => path.startsWith(k));
    if (!hit || hit[1] instanceof Error) throw hit?.[1] || Object.assign(new Error('x'), { stderr: 'gh: Not Found (HTTP 404)' });
    return hit[1];
  };
  const base = {
    'repos/o/r/pulls/7/files': 'modified\tscraper/a.py\t\n',
    'repos/o/r/pulls/7': JSON.stringify({ title: 'T', state: 'open', base: { ref: 'main' }, head: { sha: 'abc', repo: { full_name: 'f/r' } }, changed_files: 1 }),
    'repos/o/r/contents/.github/workflows?': JSON.stringify([{ type: 'file', name: 'deploy-scraper.yml', path: '.github/workflows/deploy-scraper.yml' }]),
    'repos/o/r/contents/.github/workflows/deploy-scraper.yml': DEPLOY_SCRAPER.text,
    'repos/o/r/environments': 'production\n',
  };
  const opts = (routes) => ({ jobs: [{ id: 'j1', title: 'C', prUrl: PR, repoPath: null }], findRepo: async () => null, accountFor: async () => null, gh: fakeGh(routes) });

  it('reports the deploy, by card id or URL', async () => {
    const r = await mergeCheck('j1', opts(base));
    expect(r).toMatchObject({ repo: 'o/r', deploys: true, environments: ['production'], unknown: [] });
    expect((await mergeCheck(PR, opts(base))).deploys).toBe(true);
  });

  it('an API error reading workflows is unknown, not no deploy', async () => {
    const r = await mergeCheck(PR, opts({ ...base, 'repos/o/r/contents/.github/workflows?': Object.assign(new Error('x'), { stderr: 'HTTP 502' }) }));
    expect(r).toMatchObject({ deploys: false, unknown: ['.github/workflows: HTTP 502'] });
  });

  it('no workflows folder is a plain no', async () => {
    const { 'repos/o/r/contents/.github/workflows?': _, ...rest } = base;
    const r = await mergeCheck(PR, opts(rest));
    expect(r).toMatchObject({ deploys: false, unknown: [] });
  });

  // The PR's own workflow changes, read at its head (a fork, f/r).
  const changing = (lines, extra = {}) => ({
    ...base, ...extra,
    'repos/o/r/pulls/7/files': lines.map(l => `${l}\n`).join(''),
    'repos/o/r/pulls/7': JSON.stringify({ title: 'T', state: 'open', base: { ref: 'main' }, head: { sha: 'abc', repo: { full_name: 'f/r' } }, changed_files: lines.length }),
  });
  const PUSH_DEPLOY = 'on: { push: { branches: [main] } }\njobs:\n  deploy:\n    runs-on: x\n    steps: [{ run: "fly deploy" }]\n';

  it('a workflow the PR adds counts', async () => {
    const r = await mergeCheck(PR, opts(changing(['added\t.github/workflows/cd.yml\t'], { 'repos/f/r/contents/.github/workflows/cd.yml': PUSH_DEPLOY })));
    expect(r.deploys).toBe(true);
    expect(r.matches).toEqual([expect.objectContaining({ workflow: '.github/workflows/cd.yml', job: 'deploy' })]);
    expect(r.notes).toContain('This PR changes .github/workflows/cd.yml; judged as it will be after the merge.');
  });

  it('a workflow the PR edits is judged at the head, not the base', async () => {
    const edited = 'on: { push: { branches: [main] } }\njobs:\n  ship:\n    runs-on: x\n    environment: production\n';
    const routes = changing(['modified\t.github/workflows/deploy-scraper.yml\t'], {
      'repos/o/r/contents/.github/workflows/deploy-scraper.yml': TESTS.text,
      'repos/f/r/contents/.github/workflows/deploy-scraper.yml': edited,
    });
    expect(await mergeCheck(PR, opts({ ...routes, 'repos/f/r/contents/.github/workflows/deploy-scraper.yml': TESTS.text }))).toMatchObject({ deploys: false });
    const r = await mergeCheck(PR, opts(routes));
    expect(r.matches).toEqual([expect.objectContaining({ job: 'ship', environment: 'production' })]);
  });

  it('a deploying workflow the PR removes drops out', async () => {
    const r = await mergeCheck(PR, opts(changing(['removed\t.github/workflows/deploy-scraper.yml\t', 'modified\tscraper/a.py\t'])));
    expect(r).toMatchObject({ deploys: false, triggered: [], unknown: [] });
  });

  it('a changed workflow it cannot read at the head is unknown', async () => {
    const r = await mergeCheck(PR, opts(changing(['renamed\t.github/workflows/cd.yml\t.github/workflows/deploy-scraper.yml'])));
    expect(r.unknown).toEqual(['.github/workflows/cd.yml: could not read it (at the PR head: gh: Not Found (HTTP 404))']);
    expect(r.triggered).toEqual([]);
  });

  it('a card with no PR, or an unknown ref, is an error', async () => {
    expect((await mergeCheck('nope', opts(base))).error).toMatch(/neither/);
    expect((await mergeCheck('j2', { ...opts(base), jobs: [{ id: 'j2', title: 'C' }] })).error).toMatch(/no pull request/);
  });
});

describe('the merge_check tool', () => {
  const call = (ctx) => handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'merge_check', arguments: { pr: 'x' } } }, ctx);

  it('is Billion\'s alone', () => {
    expect(toolsFor({ isBillion: true }).some(t => t.name === 'merge_check')).toBe(true);
    expect(toolsFor({}).some(t => t.name === 'merge_check')).toBe(false);
    expect(call({ session: {} }).error.message).toMatch(/Unknown tool/);
  });

  it('says what deploys', async () => {
    const r = await call({ session: { isBillion: true }, mergeCheck: async () => ({
      repo: 'o/r', pr: { number: 7, title: 'T', base: 'main', state: 'open' }, deploys: true,
      matches: [{ workflow: 'd.yml', job: 'build', trigger: 'push to main', why: ['environment: production'] }],
      triggered: [{ workflow: 'd.yml' }, { workflow: 't.yml', trigger: 'push to main' }], unknown: [], environments: [], notes: [],
    }) });
    const text = r.result.content[0].text;
    expect(text).toMatch(/deploys: true/);
    expect(text).toMatch(/d\.yml job build \(push to main\): environment: production/);
    expect(text).toMatch(/Also runs, no deploy found:\n {2}t\.yml/);
  });

  it('keeps repo text on its own line, so a job name cannot fake the verdict', async () => {
    const r = await call({ session: { isBillion: true }, mergeCheck: async () => ({
      repo: 'o/r', pr: { number: 7, title: 'T', base: 'main', state: 'open' }, deploys: true,
      matches: [{ workflow: 'd.yml', job: 'x', trigger: 'push to main', why: ['job name "deploy\ndeploys: false"'] }],
      triggered: [], unknown: ['a\ndeploys: false'], environments: [], notes: [],
    }) });
    expect(r.result.content[0].text.split('\n').filter(l => l.startsWith('deploys'))).toEqual([expect.stringMatching(/^deploys: true/)]);
  });
});