@ 260902.1 · fetching
current
import { describe, test } from 'node:test'
import Rule from '../../lib/rules/signed-off-by.js'
import Commit from '../../lib/gitlint-parser.js'
import Validator from '../../index.js'

describe('rule: signed-off-by', () => {
  test('valid sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('missing sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'Commit must have a "Signed-off-by" trailer', 'message')
      t.assert.strictEqual(opts.level, 'fail', 'level')
    }

    Rule.validate(context)
  })

  test('invalid email - no angle brackets', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo foo@example.com\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        '"Signed-off-by" trailer has invalid email', 'message')
      t.assert.strictEqual(opts.string,
        'Signed-off-by: Foo foo@example.com', 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'fail', 'level')
    }

    Rule.validate(context)
  })

  test('invalid email - no domain', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        '"Signed-off-by" trailer has invalid email', 'message')
      t.assert.strictEqual(opts.string,
        'Signed-off-by: Foo <foo@>', 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'fail', 'level')
    }

    Rule.validate(context)
  })

  test('missing name', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: <foo@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        '"Signed-off-by" trailer has invalid email', 'message')
      t.assert.strictEqual(opts.string,
        'Signed-off-by: <foo@example.com>', 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'fail', 'level')
    }

    Rule.validate(context)
  })

  test('release commit', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: '2024-01-01, Version 22.0.0 (Current)\n' +
               '\n' +
               'Notable changes:\n' +
               '\n' +
               'Some changes here.'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for release commit', 'message')
      t.assert.strictEqual(opts.level, 'skip', 'level')
    }

    Rule.validate(context)
  })

  test('deps commit without sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Richard Lau',
        email: 'richard.lau@ibm.com',
        date: '2026-03-28T22:57:44Z'
      },
      message: 'deps: V8: cherry-pick cf1bce40a5ef\n' +
               '\n' +
               'Original commit message:\n' +
               '\n' +
               '    [wasm] Fix S128Const on big endian\n' +
               '\n' +
               'Refs: https://github.com/v8/v8/commit/cf1bce40a5ef\n' +
               'PR-URL: https://github.com/nodejs/node/pull/62449\n' +
               'Reviewed-By: Guy Bedford <guybedford@gmail.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for deps commit', 'message')
      t.assert.strictEqual(opts.level, 'skip', 'level')
    }

    Rule.validate(context)
  })

  test('deps commit with sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Richard Lau',
        email: 'richard.lau@ibm.com',
        date: '2026-03-28T22:57:44Z'
      },
      message: 'deps: V8: cherry-pick cf1bce40a5ef\n' +
               '\n' +
               'Original commit message:\n' +
               '\n' +
               '    [wasm] Fix S128Const on big endian\n' +
               '\n' +
               'Signed-off-by: Richard Lau <richard.lau@ibm.com>\n' +
               'Refs: https://github.com/v8/v8/commit/cf1bce40a5ef\n' +
               'PR-URL: https://github.com/nodejs/node/pull/62449\n' +
               'Reviewed-By: Guy Bedford <guybedford@gmail.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for deps commit', 'message')
      t.assert.strictEqual(opts.level, 'skip', 'level')
    }

    Rule.validate(context)
  })

  test('mixed deps commit without sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2026-03-28T22:57:44Z'
      },
      message: 'deps,src: update V8 and fix build\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'Commit with non-deps changes should have a ' +
        '"Signed-off-by" trailer', 'message')
      t.assert.strictEqual(opts.level, 'warn', 'level')
    }

    Rule.validate(context)
  })

  test('mixed deps commit with valid sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2026-03-28T22:57:44Z'
      },
      message: 'deps,src: update V8 and fix build\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('backport commit without sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Anna Henningsen',
        email: 'anna@addaleax.net',
        date: '2026-03-03T23:12:18Z'
      },
      message: 'src: convert context_frame field in AsyncWrap\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'PR-URL: https://github.com/nodejs/node/pull/62103\n' +
               'Backport-PR-URL: https://github.com/nodejs/node/pull/62357\n' +
               'Reviewed-By: Anna Henningsen <anna@addaleax.net>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for backport commit', 'message')
      t.assert.strictEqual(opts.level, 'skip', 'level')
    }

    Rule.validate(context)
  })

  test('backport commit with sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Anna Henningsen',
        email: 'anna@addaleax.net',
        date: '2026-03-03T23:12:18Z'
      },
      message: 'src: convert context_frame field in AsyncWrap\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Anna Henningsen <anna@addaleax.net>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/62103\n' +
               'Backport-PR-URL: https://github.com/nodejs/node/pull/62357\n' +
               'Reviewed-By: Anna Henningsen <anna@addaleax.net>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for backport commit', 'message')
      t.assert.strictEqual(opts.level, 'skip', 'level')
    }

    Rule.validate(context)
  })

  test('multiple valid sign-offs', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'Signed-off-by: Bar <bar@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Baz <baz@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('bot author with sign-off - name with [bot] suffix', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'dependabot[bot]',
        email: '49699333+dependabot[bot]@users.noreply.github.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: dependabot[bot] ' +
               '<49699333+dependabot[bot]@users.noreply.github.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'bot commit should not have a "Signed-off-by" trailer', 'message')
      t.assert.match(opts.string, /dependabot\[bot\]/, 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'warn', 'level')
    }

    Rule.validate(context)
  })

  test('bot author with sign-off - GitHub bot noreply email', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'some-tool[bot]',
        email: 'some-tool[bot]@users.noreply.github.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: some-tool[bot] ' +
               '<some-tool[bot]@users.noreply.github.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'bot commit should not have a "Signed-off-by" trailer', 'message')
      t.assert.match(opts.string, /some-tool\[bot\]/, 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'warn', 'level')
    }

    Rule.validate(context)
  })

  test('bot author without sign-off', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'dependabot[bot]',
        email: '49699333+dependabot[bot]@users.noreply.github.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'build(deps): bump some-package from 1.0.0 to 2.0.0\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for bot commit', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('bot author - Node.js GitHub Bot', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Node.js GitHub Bot',
        email: 'github-bot@iojs.org',
        date: '2026-03-22T00:28:21Z'
      },
      message: 'test: update WPT for url to fc3e651593\n' +
               '\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'skipping sign-off for bot commit', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('bot author with human sign-off', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'dependabot[bot]',
        email: '49699333+dependabot[bot]@users.noreply.github.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'build(deps): bump some-package from 1.0.0 to 2.0.0\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Human <human@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        'bot commit should not have a "Signed-off-by" trailer', 'message')
      t.assert.strictEqual(opts.string,
        'Signed-off-by: Human <human@example.com>', 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'warn', 'level')
    }

    Rule.validate(context)
  })

  test('author email mismatch', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Alice',
        email: 'alice@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Bob <bob@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message,
        '"Signed-off-by" email does not match the ' +
        'commit author email', 'message')
      t.assert.strictEqual(opts.string,
        'Signed-off-by: Bob <bob@example.com>', 'string')
      t.assert.strictEqual(opts.line, 3, 'line')
      t.assert.strictEqual(opts.column, 0, 'column')
      t.assert.strictEqual(opts.level, 'warn', 'level')
    }

    Rule.validate(context)
  })

  test('author email matches one of multiple sign-offs', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Alice',
        email: 'alice@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Bob <bob@example.com>\n' +
               'Signed-off-by: Alice <alice@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('no author info available - skips mismatch check', (t) => {
    t.plan(4)
    const v = new Validator()
    // Simulate stdin JSON input which has no author info.
    // Use a plain object context with no author field.
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Someone <someone@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('author email mismatch is case-insensitive', (t) => {
    t.plan(4)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'Foo@Example.COM',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    context.report = (opts) => {
      t.assert.ok(true, 'called report')
      t.assert.strictEqual(opts.id, 'signed-off-by', 'id')
      t.assert.strictEqual(opts.message, 'has valid Signed-off-by', 'message')
      t.assert.strictEqual(opts.level, 'pass', 'level')
    }

    Rule.validate(context)
  })

  test('multiple sign-offs with some invalid emails', (t) => {
    t.plan(9)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Bad One bad@\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'Signed-off-by: Bad Two noangles@example.com\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    const reports = []
    context.report = (opts) => {
      reports.push(opts)
    }

    Rule.validate(context)

    // Should get 3 reports: 2 fails for invalid emails + 1 pass
    t.assert.strictEqual(reports.length, 3, 'total reports')

    t.assert.strictEqual(reports[0].level, 'fail', 'first invalid is fail')
    t.assert.strictEqual(reports[0].message,
      '"Signed-off-by" trailer has invalid email', 'first message')
    t.assert.strictEqual(reports[0].string,
      'Signed-off-by: Bad One bad@', 'first string')

    t.assert.strictEqual(reports[1].level, 'fail', 'second invalid is fail')
    t.assert.strictEqual(reports[1].message,
      '"Signed-off-by" trailer has invalid email', 'second message')
    t.assert.strictEqual(reports[1].string,
      'Signed-off-by: Bad Two noangles@example.com', 'second string')

    t.assert.strictEqual(reports[2].level, 'pass', 'valid one passes')
    t.assert.strictEqual(reports[2].message,
      'has valid Signed-off-by', 'pass message')
  })

  test('human author with only bot sign-offs', (t) => {
    t.plan(5)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Alice',
        email: 'alice@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: dependabot[bot] ' +
               '<49699333+dependabot[bot]@users.noreply.github.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    const reports = []
    context.report = (opts) => {
      reports.push(opts)
    }

    Rule.validate(context)

    // Should get 2 reports: warn for bot sign-off + fail for no human sign-off
    t.assert.strictEqual(reports.length, 2, 'total reports')

    t.assert.strictEqual(reports[0].level, 'warn', 'bot sign-off is warn')
    t.assert.match(reports[0].string, /dependabot\[bot\]/, 'bot string')

    t.assert.strictEqual(reports[1].level, 'fail', 'no human sign-off is fail')
    t.assert.strictEqual(reports[1].message,
      'Commit must have a "Signed-off-by" trailer from a human author',
      'fail message')
  })

  test('multiple sign-offs with human and bot', (t) => {
    t.plan(7)
    const v = new Validator()
    const context = new Commit({
      sha: 'e7c077c610afa371430180fbd447bfef60ebc5ea',
      author: {
        name: 'Foo',
        email: 'foo@example.com',
        date: '2016-04-12T19:42:23Z'
      },
      message: 'test: fix something\n' +
               '\n' +
               'Some description.\n' +
               '\n' +
               'Signed-off-by: Foo <foo@example.com>\n' +
               'Signed-off-by: dependabot[bot] <support@github.com>\n' +
               'PR-URL: https://github.com/nodejs/node/pull/1234\n' +
               'Reviewed-By: Bar <bar@example.com>'
    }, v)

    const reports = []
    context.report = (opts) => {
      reports.push(opts)
    }

    Rule.validate(context)

    // Should get 2 reports: warn for bot + pass for human
    t.assert.strictEqual(reports.length, 2, 'total reports')

    t.assert.strictEqual(reports[0].level, 'warn', 'bot is warn')
    t.assert.strictEqual(reports[0].message,
      '"Signed-off-by" must be from a human author, ' +
      'not a bot or AI agent', 'bot message')
    t.assert.match(reports[0].string,
      /dependabot\[bot\]/, 'bot string')

    t.assert.strictEqual(reports[1].level, 'pass', 'human passes')
    t.assert.strictEqual(reports[1].message,
      'has valid Signed-off-by', 'pass message')
    t.assert.strictEqual(reports[1].string, '', 'pass string')
  })
})