Skip to content

Commit

Permalink
Initial implementation, tests, readme
Browse files Browse the repository at this point in the history
  • Loading branch information
ljharb committed Jul 15, 2024
1 parent 5586949 commit bfcd7d1
Show file tree
Hide file tree
Showing 19 changed files with 583 additions and 3 deletions.
13 changes: 13 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"root": true,

"extends": "@ljharb/eslint-config/node/20",

"rules": {
"complexity": "off",
"max-lines-per-function": "off",
"max-statements": "off",
"no-magic-numbers": "off",
"sort-keys": "off",
},
}
12 changes: 12 additions & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# These are supported funding model platforms

github: [ljharb]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: npm/@ljharb/coauthors
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
7 changes: 7 additions & 0 deletions .github/workflows/node-pretest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: 'Tests: pretest/posttest'

on: [pull_request, push]

jobs:
tests:
uses: ljharb/actions/.github/workflows/pretest.yml@main
11 changes: 11 additions & 0 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
name: 'Tests: node.js >= 22.4'

on: [pull_request, push]

jobs:
tests:
uses: ljharb/actions/.github/workflows/node.yml@main
with:
range: '>= 22.4'
type: minors
command: npm run tests-only
9 changes: 9 additions & 0 deletions .github/workflows/rebase.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: Automatic Rebase

on: [pull_request_target]

jobs:
_:
uses: ljharb/actions/.github/workflows/rebase.yml@main
secrets:
token: ${{ secrets.GITHUB_TOKEN }}
12 changes: 12 additions & 0 deletions .github/workflows/require-allow-edits.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Require “Allow Edits”

on: [pull_request_target]

jobs:
_:
name: "Require “Allow Edits”"

runs-on: ubuntu-latest

steps:
- uses: ljharb/require-allow-edits@main
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,5 @@ dist
npm-shrinkwrap.json
package-lock.json
yarn.lock

.npmignore
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,52 @@
# coauthors
# @ljharb/coauthors <sup>[![Version Badge][npm-version-svg]][package-url]</sup>

[![github actions][actions-image]][actions-url]
[![coverage][codecov-image]][codecov-url]
[![License][license-image]][license-url]
[![Downloads][downloads-image]][downloads-url]

[![npm badge][npm-badge-png]][package-url]

A cli to generate a complete git co-authors list, including existing co-authors, for use in a commit message.

## Usage

```sh
npx @ljharb/coauthors # if not installed

coauthors # if installed and in the PATH
```

```sh
$ coauthors --help
Usage:
commit-to-co-authors <remote>

`remote` defaults to `origin`.
```

## Install

```
npm install --save-dev @ljharb/coauthors
```

## License

MIT

[package-url]: https://npmjs.org/package/@ljharb/coauthors
[npm-version-svg]: https://versionbadg.es/ljharb/coauthors.svg
[deps-svg]: https://david-dm.org/ljharb/coauthors.svg
[deps-url]: https://david-dm.org/ljharb/coauthors
[dev-deps-svg]: https://david-dm.org/ljharb/coauthors/dev-status.svg
[dev-deps-url]: https://david-dm.org/ljharb/coauthors#info=devDependencies
[npm-badge-png]: https://nodei.co/npm/@ljharb/coauthors.png?downloads=true&stars=true
[license-image]: https://img.shields.io/npm/l/@ljharb/coauthors.svg
[license-url]: LICENSE
[downloads-image]: https://img.shields.io/npm/dm/@ljharb/coauthors.svg
[downloads-url]: https://npm-stat.com/charts.html?package=@ljharb/coauthors
[codecov-image]: https://codecov.io/gh/ljharb/coauthors/branch/main/graphs/badge.svg
[codecov-url]: https://app.codecov.io/gh/ljharb/coauthors/
[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/ljharb/coauthors
[actions-url]: https://github.com/ljharb/coauthors/actions
52 changes: 52 additions & 0 deletions bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#! /usr/bin/env node

import { readFile } from 'fs/promises';
import path from 'path';

import validateRemote from './validateRemote.mjs';
import getResults from './results.mjs';

import pargs from './pargs.mjs';

async function getHelpText() {
return `${await readFile(path.join(import.meta.dirname, './help.txt'), 'utf-8')}`;
}

const {
values: { help },
positionals,
errors,
} = await pargs(
import.meta.filename,
{
options: {
help: { type: 'boolean' },
},
allowPositionals: 1,
},
);

const remote = validateRemote(positionals[0] ?? 'origin');

if (typeof remote !== 'string') {
errors.push(remote.error);
}

if (help || errors.length > 0) {
const helpText = await getHelpText();
if (errors.length === 0) {
console.log(helpText);
} else {
console.error(`${helpText}${errors.length === 0 ? '' : '\n'}`);

process.exitCode ||= parseInt('1'.repeat(errors.length), 2);
errors.forEach((error) => console.error(error));
}

process.exit();
}

// eslint-disable-next-line no-extra-parens
const results = Array.from(getResults(/** @type {string} */ (remote)), (x) => `Co-authored-by: ${x}`);

console.log(results.join('\n'));
10 changes: 10 additions & 0 deletions getDefaultBranch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { execSync } from 'child_process';

/** @type {(remote: string) => string} */
export default function getDefaultBranch(remote) {
const gitResult = `${execSync(`git rev-parse --abbrev-ref ${remote}/HEAD`)}`.trim();

const match = (/\/(?<defaultBranch>\S+)$/).exec(gitResult);

return match?.groups?.defaultBranch ?? 'main';
}
4 changes: 4 additions & 0 deletions help.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Usage:
commit-to-co-authors <remote>

`remote` defaults to `origin`.
68 changes: 66 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,23 @@
"name": "@ljharb/coauthors",
"version": "0.0.0",
"description": "A cli to generate a complete git co-authors list, including existing co-authors, for use in a commit message.",
"bin": "./bin.mjs",
"exports": {
"./package.json": "./package.json"
},
"sideEffects": false,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"prepack": "npmignore --auto --commentLines=autogenerated",
"prepublish": "not-in-publish || npm run prepublishOnly",
"prepublishOnly": "safe-publish-latest",
"lint": "eslint --ext=js,mjs .",
"postlint": "tsc",
"pretest": "npm run lint",
"tests-only": "c8 tape 'test/**/*.*js'",
"test": "npm run tests-only",
"posttest": "aud --production",
"version": "auto-changelog && git add CHANGELOG.md",
"postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\""
},
"repository": {
"type": "git",
Expand All @@ -21,5 +36,54 @@
"bugs": {
"url": "https://github.com/ljharb/coauthors/issues"
},
"homepage": "https://github.com/ljharb/coauthors#readme"
"homepage": "https://github.com/ljharb/coauthors#readme",
"dependencies": {
"commit-to-co-authors": "^0.1.0"
},
"engines": {
"node": ">= 22.4"
},
"devDependencies": {
"@ljharb/eslint-config": "^21.1.1",
"@ljharb/tsconfig": "^0.2.0",
"@types/node": "^20.14.10",
"@types/tape": "^5.6.4",
"aud": "^2.0.4",
"auto-changelog": "^2.4.0",
"c8": "^10.1.2",
"eslint": "^8.8.0",
"esmock": "^2.6.6",
"in-publish": "^2.0.1",
"npmignore": "^0.3.1",
"safe-publish-latest": "^2.0.0",
"tape": "^5.8.1",
"typescript": "next"
},
"c8": {
"all": true,
"reporters": [
"html",
"text",
"lcov"
],
"exclude": [
"coverage",
"./pargs.mjs"
]
},
"auto-changelog": {
"output": "CHANGELOG.md",
"template": "keepachangelog",
"unreleased": false,
"commitLimit": false,
"backfillLimit": false,
"hideCredit": true
},
"publishConfig": {
"ignore": [
".github/workflows",
".eslintrc",
"test"
]
}
}
119 changes: 119 additions & 0 deletions pargs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { parseArgs } from 'util';
import { realpathSync } from 'fs';

/** @typedef {import('util').ParseArgsConfig} ParseArgsConfig */

/** @typedef {(Error | TypeError) & { code: 'ERR_PARSE_ARGS_UNKNOWN_OPTION' | 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' | 'ERR_INVALID_ARG_TYPE' | 'ERR_INVALID_ARG_VALUE' | 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL'}} ParseArgsError */

/** @type {(e: unknown) => e is ParseArgsError} */
function isParseArgsError(e) {
return !!e
&& typeof e === 'object'
&& 'code' in e
&& (
e.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION'
|| e.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE'
|| e.code === 'ERR_INVALID_ARG_TYPE'
|| e.code === 'ERR_INVALID_ARG_VALUE'
|| e.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL'
);
}
/** @typedef {Omit<ParseArgsConfig, 'args' | 'strict' | 'allowPositionals'> & { allowPositionals?: boolean | number }} PargsConfig */

/** @type {(entrypointPath: ImportMeta['filename'], obj: PargsConfig) => Promise<{ errors: string[] } & ReturnType<typeof parseArgs>>} */
export default async function pargs(entrypointPath, obj) {
const argv = process.argv.flatMap((arg) => {
try {
const realpathedArg = realpathSync(arg);
if (
realpathedArg === process.execPath
|| realpathedArg === entrypointPath
) {
return [];
}
} catch (e) { /**/ }
return arg;
});

if ('help' in obj) {
throw new TypeError('The "help" option is reserved');
}

/** @type {ParseArgsConfig & { tokens: true }} */
const newObj = {
args: argv,
...obj,
options: {
...obj.options,
help: {
default: false,
type: 'boolean',
},
},
tokens: true,
// @ts-expect-error blocked on @types/node v22
allowNegative: true,
allowPositionals: typeof obj.allowPositionals !== 'undefined',
strict: true,
};

const errors = [];

try {
const { tokens, ...results } = parseArgs(newObj);

const posCount = typeof obj.allowPositionals === 'number' ? obj.allowPositionals : obj.allowPositionals ? Infinity : 0;
if (results.positionals.length > posCount) {
errors.push(`Only ${posCount} positional arguments allowed; got ${results.positionals.length}`);
}

/** @typedef {Extract<typeof tokens[number], { kind: 'option' }>} OptionToken */
const optionTokens = tokens.filter(/** @type {(token: typeof tokens[number]) => token is OptionToken} */ (token) => token.kind === 'option');

const bools = obj.options ? Object.entries(obj.options).filter(([, { type }]) => type === 'boolean') : [];
const boolMap = new Map(bools);
for (let i = 0; i < optionTokens.length; i += 1) {
const { name, value } = optionTokens[i];
if (boolMap.has(name) && typeof value !== 'boolean' && typeof value !== 'undefined') {
errors.push(`Error: Argument --${name} must be a boolean`);
}
}

const passedArgs = new Set(optionTokens.map(({ name, rawName }) => (rawName === '--no-help' ? rawName : name)));

const groups = Object.groupBy(passedArgs, (x) => x.replace(/^no-/, ''));
for (let i = 0; i < bools.length; i++) {
const [key] = bools[i];
if ((groups[key]?.length ?? 0) > 1) {
errors.push(`Error: Arguments \`--${key}\` and \`--no-${key}\` are mutually exclusive`);
}
if (passedArgs.has(`no-${key}`)) {
// @ts-expect-error
results.values[key] = !results.values[`no-${key}`];
}
// @ts-expect-error
delete results.values[`no-${key}`];
}

const knownOptions = obj.options ? Object.keys(obj.options) : [];
const unknownArgs = knownOptions.length > 0 ? passedArgs.difference(new Set(knownOptions)) : passedArgs;
if (unknownArgs.size > 0) {
errors.push(`Error: Unknown option(s): ${Array.from(unknownArgs, (x) => `\`${x}\``).join(', ')}`);
}

return {
errors,
...results,
...obj.tokens && { tokens },
};
} catch (e) {
if (isParseArgsError(e)) {
return {
values: {},
positionals: [],
errors: [`Error: ${e.message}`],
};
}
throw e;
}
}
Loading

0 comments on commit bfcd7d1

Please sign in to comment.