diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4e7b2c..1b51f83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: denoland/setup-deno@v1 with: - deno-version: v1.x + deno-version: v1.34.x - uses: actions/checkout@v3 - uses: cli/gh-extension-precompile@v1 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 830ec66..f2edd11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,17 @@ rules with [deno fmt][deno-fmt], and lint with [deno lint][deno-lint]. We encourage you to [setup your IDE/Editor][deno-env] to automatically apply formatting. +## Release + +To release a new version the RC (release coordinator) should: + +1. Create a new release branch +2. Bump up the version in `version.ts` +3. Commit the changes, push the branch, and prepare a PR +4. Merge the PR to main +5. Pull the changes locally, create a new tag with the correct version and push + it + [deno-install]: https://deno.land/manual@v1.29.1/getting_started/installation [deno-env]: https://deno.land/manual@v1.29.1/getting_started/setup_your_environment [github-prs]: https://github.com/trunklabs/gh-contribution-mate/pulls diff --git a/deno.jsonc b/deno.jsonc index 860e20d..7e049de 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -12,7 +12,7 @@ "models/repository": "./src/models/repository/mod.ts", "models/git": "./src/models/git/mod.ts", "models/config": "./src/models/config/mod.ts", - "utils": "./src/utils/mod.ts" + "lib": "./src/lib/mod.ts" }, "lint": { "files": { diff --git a/deno.lock b/deno.lock index 4ea6d86..3e51073 100644 --- a/deno.lock +++ b/deno.lock @@ -12,6 +12,17 @@ "https://deno.land/std@0.141.0/path/posix.ts": "293cdaec3ecccec0a9cc2b534302dfe308adb6f10861fa183275d6695faace44", "https://deno.land/std@0.141.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", "https://deno.land/std@0.141.0/path/win32.ts": "31811536855e19ba37a999cd8d1b62078235548d67902ece4aa6b814596dd757", + "https://deno.land/std@0.167.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", + "https://deno.land/std@0.167.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", + "https://deno.land/std@0.167.0/path/_constants.ts": "df1db3ffa6dd6d1252cc9617e5d72165cd2483df90e93833e13580687b6083c3", + "https://deno.land/std@0.167.0/path/_interface.ts": "ee3b431a336b80cf445441109d089b70d87d5e248f4f90ff906820889ecf8d09", + "https://deno.land/std@0.167.0/path/_util.ts": "d16be2a16e1204b65f9d0dfc54a9bc472cafe5f4a190b3c8471ec2016ccd1677", + "https://deno.land/std@0.167.0/path/common.ts": "bee563630abd2d97f99d83c96c2fa0cca7cee103e8cb4e7699ec4d5db7bd2633", + "https://deno.land/std@0.167.0/path/glob.ts": "81cc6c72be002cd546c7a22d1f263f82f63f37fe0035d9726aa96fc8f6e4afa1", + "https://deno.land/std@0.167.0/path/mod.ts": "cf7cec7ac11b7048bb66af8ae03513e66595c279c65cfa12bfc07d9599608b78", + "https://deno.land/std@0.167.0/path/posix.ts": "b859684bc4d80edfd4cad0a82371b50c716330bed51143d6dcdbe59e6278b30c", + "https://deno.land/std@0.167.0/path/separator.ts": "fe1816cb765a8068afb3e8f13ad272351c85cbc739af56dacfc7d93d710fe0f9", + "https://deno.land/std@0.167.0/path/win32.ts": "7cebd2bda6657371adc00061a1d23fdd87bcdf64b4843bb148b0b24c11b40f69", "https://deno.land/std@0.170.0/_util/asserts.ts": "d0844e9b62510f89ce1f9878b046f6a57bf88f208a10304aab50efcb48365272", "https://deno.land/std@0.170.0/_util/os.ts": "8a33345f74990e627b9dfe2de9b040004b08ea5146c7c9e8fe9a29070d193934", "https://deno.land/std@0.170.0/encoding/base64.ts": "8605e018e49211efc767686f6f687827d7f5fd5217163e981d8d693105640d7a", diff --git a/script/build.sh b/script/build.sh index 4da5a22..42b344d 100755 --- a/script/build.sh +++ b/script/build.sh @@ -1,2 +1,2 @@ #!/usr/bin/env bash -deno task compile "$@" +deno run --allow-run=deno script/compile.ts "$@" diff --git a/script/compile.ts b/script/compile.ts index 1531a7d..acd1a3f 100644 --- a/script/compile.ts +++ b/script/compile.ts @@ -1,10 +1,10 @@ import { join } from 'https://deno.land/std@0.167.0/path/mod.ts'; -const [version] = Deno.args; +import { VERSION } from '../version.ts'; -if (!version) { +if (!VERSION) { console.error( - `[%cERROR%c] Incorrect version. %cExpected%c format "v*.*.*", %creceived%c "${version}"`, + `[%cERROR%c] Incorrect version. %cExpected%c format "v*.*.*", %creceived%c "${VERSION}"`, 'color: red', 'color: inherit', 'color: green', @@ -79,26 +79,29 @@ for (const { platform, arch } of targets) { continue; } - const filename = `gh-contribution-mate_${platform}-${arch}`; + const filename = `gh-contribution-mate_v${VERSION}_${platform}-${arch}`; - const { success, code } = await Deno.run({ - cmd: [ - 'deno', + const cmd = new Deno.Command('deno', { + args: [ 'compile', + '--allow-run=gh,git', + '--allow-read', + '--allow-write', + `--allow-env=${ + platform === Platform.WINDOWS ? 'APPDATA' : 'XDG_CONFIG_HOME,HOME' + }}`, '-o', join(outDir, filename), '--target', denoSupportedTarget, 'src/main.ts', ], - }).status(); + }); - if (!success) { - console.error( - `[%cERROR%c] Compilation of "${filename}" failed, status code: ${code}`, - 'color: red', - 'color: inherit', - ); - Deno.exit(code); + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) { + console.error(new TextDecoder().decode(cmdOutput.stderr)); + Deno.exit(cmdOutput.code); } } diff --git a/src/commands/add.ts b/src/commands/add.ts new file mode 100644 index 0000000..35bff50 --- /dev/null +++ b/src/commands/add.ts @@ -0,0 +1,47 @@ +import { EOL } from 'std/fs'; +import { basename } from 'std/path'; +import { Checkbox, colors, Command } from 'cliffy'; +import { getConfig, RepoType, setConfig } from '../config.ts'; +import { getAuthors } from '../git.ts'; + +export default new Command() + .description('Add one or more local repositories for syncing.') + .arguments('<...repositories:string>') + .option('-e, --email ', 'Email to sync', { collect: true }) + .action(async (_, ...repoPaths) => { + const config = await getConfig(); + const mutableConfig = { ...config }; + + for (const repoPath of repoPaths) { + const authors = await getAuthors(repoPath); + + const selectedAuthors: string[] = await Checkbox.prompt({ + message: `Select authors of commits to extract from the "${ + basename(repoPath) + } repository". Use arrow keys for navigation, space to select, and enter to submit.`, + options: authors.map((author) => `${author.name} <${author.email}>`), + }); + + const repo: RepoType = { + dir: repoPath, + authors: authors.filter((author) => + selectedAuthors.includes(`${author.name} <${author.email}>`) + ), + }; + + mutableConfig.repos[basename(repoPath)] = repo; + } + + await setConfig(mutableConfig); + + console.log( + '\xa0🎉', + colors.green('You are all set!'), + EOL.LF, + colors.green('ℹī¸\xa0'), + colors.green( + 'You can run the "sync" command to synchronize your commits now.', + ), + colors.green('See more information with the "--help" flag.'), + ); + }); diff --git a/src/commands/config/config.ts b/src/commands/config/config.ts deleted file mode 100644 index 65688cf..0000000 --- a/src/commands/config/config.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { ansi, colors, Input, prompt, Toggle } from 'cliffy'; -import { EOL } from 'std/fs'; -import { compose, endsWith, equals, ifElse, not, pipe, when } from 'rambda'; -import { - createRepository, - getRepository, - RepositoryError, -} from 'models/repository'; -import { - Config, - getConfig, - getConfigPath, - notifyConfigExists, - writeConfig, -} from 'models/config'; -import { getGitAuthor, GitAuthor } from 'models/git'; -import { exists, sanitizeString, validate } from 'utils'; - -export async function configAction(): Promise { - const config: Config = await ifElse( - equals(true), - pipe(notifyConfigExists, promptConfigUpdate, getConfigOrExit), - getDefaultConfig, - )(await exists(getConfigPath())); - - const result: Config = await prompt([ - { - type: Input, - name: 'name', - message: 'Default commit author name:', - hint: 'Different authors can be configured per repository later on.', - transform: sanitizeString, - validate: compose(validate(Config.shape.name.parse), sanitizeString), - default: config.name, - }, - { - type: Input, - name: 'email', - message: 'Default commit author email:', - hint: 'Different emails can be configured per repository later on.', - transform: sanitizeString, - validate: compose(validate(Config.shape.email.parse), sanitizeString), - default: config.email, - after: async ({ email }, next) => { - when( - compose(not, endsWith('@users.noreply.github.com')), - notifyEmailLeak, - )(email ?? ''); - await next(); - }, - }, - { - type: Input, - name: 'repository', - message: 'Base repository for synchronization:', - hint: - 'If you already have a repository on GitHub that you use for synchronization, you can type it here, otherwise we will create a new one for you.', - transform: sanitizeString, - validate: compose( - validate(Config.shape.repository.parse), - sanitizeString, - ), - default: config.repository, - after: async (opts, next) => { - try { - const repository = await getRepository({ - name: opts.repository ?? '', - }); - const confirmed = await Toggle.prompt({ - message: `You already have a ${ - repository.isPrivate ? 'private' : 'public' - } repository named "${repository.name}", use it for synchronization?`, - default: false, - }); - - if (!confirmed) return await next('repository'); - return await next(); - } catch (err) { - if (!(err instanceof RepositoryError)) { - console.error(err); - Deno.exit(1); - } - - const confirmed = await Toggle.prompt({ - message: - `You don't have a repository named "${opts.repository}", create it?`, - default: false, - }); - - if (!confirmed) return await next('repository'); - - getRepository.delete({ name: opts.repository ?? '' }); - const repository = await createRepository({ - name: opts.repository ?? '', - }); - - console.log( - colors.green('\xa0\u2713'), - 'Repository named', - `"${ansi.link(colors.blue(repository.name), repository.url)}"`, - 'has been created and ready for use.', - ); - } - }, - }, - ]); - - await writeConfig(result); - - console.log( - '\xa0🎉', - colors.green('You are all set!'), - EOL.LF, - colors.green('ℹī¸\xa0'), - colors.green( - 'You can add local repositories for synchronization with the "add" command.', - ), - colors.green('See more information with the "--help" flag.'), - ); -} - -async function getDefaultConfig(): Promise { - const { name = '', email = '' }: GitAuthor = await getGitAuthor(); - const repository = 'contribution-mate-sync'; - - return { name, email, repository }; -} - -function promptConfigUpdate(): Promise { - return Toggle.prompt({ - message: 'Would you like to make changes to your configuration?', - default: false, - }); -} - -async function getConfigOrExit( - confirmed: Promise, -): Promise | never { - return ifElse( - equals(true), - getConfig, - (): never => Deno.exit(0), - )(await confirmed); -} - -function notifyEmailLeak() { - console.log( - colors.yellow('\xa0!'), - colors.yellow( - 'It looks like you are using your personal email address for commits authoring.', - ), - EOL.LF, - '\xa0', - colors.yellow('We encourage you to configure the'), - ansi.link( - 'GitHub no-reply', - 'https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address', - ).toString(), - colors.yellow('email address instead.'), - EOL.LF, - '\xa0', - colors.yellow( - 'This will avoid the potential leaking of your email address.', - ), - ); -} diff --git a/src/commands/config/mod.ts b/src/commands/config/mod.ts deleted file mode 100644 index 71f58bf..0000000 --- a/src/commands/config/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Command } from 'cliffy'; -import { configAction } from './config.ts'; - -export const config = new Command() - .name('config') - .description('Setup and update global configuration.') - .action(configAction); diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..af55f12 --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,105 @@ +import { colors, Command, Select } from 'cliffy'; +import { join } from 'std/path'; +import { ghNoReplyCheck, promptAllInputs, validate } from 'lib'; +import { getConfig, getConfigDir, setConfig } from '../config.ts'; +import { + AuthorSchema, + AuthorType, + createCommit, + getCommitsByEmail, + getGitAuthor, + getGitHistory, + pushRepositoryChanges, +} from '../git.ts'; + +import { + cloneRepository, + getRepositories, + pullRepositoryChanges, +} from '../gh.ts'; + +const SYNC_REPO_PATH = join(getConfigDir(), 'sync-repo'); + +export default new Command() + .description('Synchronize commits from local repositories to GitHub profile.') + .action(async () => { + let config = await getConfig(); + + if (!config.syncRepo) { + const repos = await getRepositories(); + + const id = await Select.prompt({ + message: 'Choose a repository to sync commits to:', + hint: + 'Use arrow keys for navigation, space to select, and enter to submit. If you don\'t have a repository yet, create one and start again.', + options: repos.map((repo) => ({ value: repo.id, name: repo.name })), + }); + const selectedRepo = repos.find((repo) => repo.id === id); + await cloneRepository(selectedRepo!, 'sync-repo', getConfigDir()); + await setConfig({ syncRepo: selectedRepo }); + config = await getConfig(); + } else { + await pullRepositoryChanges(SYNC_REPO_PATH); + } + + if (!config.author) { + const currentGitAuthor = await getGitAuthor(); + const [name, email] = await promptAllInputs( + Object.keys(AuthorSchema.shape).map((key) => ({ + message: `Authoring ${key}:`, + hint: + `We will use this ${key} to author commits on the syncing repository.`, + validate: validate( + AuthorSchema.shape[key as keyof typeof AuthorSchema.shape].parse, + ), + default: currentGitAuthor?.[key as keyof typeof currentGitAuthor], + })), + ); + + ghNoReplyCheck(email); + + const author: AuthorType = { name, email }; + await setConfig({ author }); + config = await getConfig(); + } + + const history = await getGitHistory(SYNC_REPO_PATH); + const authorsWithDirs: Array = Object.values( + config.repos, + ).flatMap((repo) => + repo.authors.map((author) => ({ ...author, dir: repo.dir })) + ); + const commits = (await Promise.all( + authorsWithDirs.map((author) => + getCommitsByEmail(author.email, author.dir) + ), + )).flat().filter((commit) => + !history.some((entry) => entry?.hash === commit.hash) + ); + + if (!commits.length) { + console.log('No new commits to synchronize. Exiting...'); + Deno.exit(0); + } + + console.log( + '\xa0🔍', + colors.yellow(`Synchronizing new ${commits.length} commits...`), + ); + + commits.forEach((commit) => { + createCommit( + { ...commit, author: config.author! }, + SYNC_REPO_PATH, + ); + }); + + await pushRepositoryChanges(SYNC_REPO_PATH); + + console.log( + '\xa0🎉', + colors.green( + 'All changes has been synchronized and pushed to your synching repository!', + ), + ); + }); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..420d981 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { join } from 'std/path'; +import { default as dir } from 'dir'; +import { mergeDeepRight } from 'rambda'; +import { ensurePath, exists } from 'lib'; +import { AuthorSchema } from './git.ts'; +import { RepositorySchema } from './gh.ts'; + +const RepoSchema = z.object({ + dir: z.string(), + authors: z.array(AuthorSchema), +}); +const ConfigSchema = z.object({ + author: AuthorSchema.optional(), + syncRepo: RepositorySchema.optional(), + repos: z.record(RepoSchema), +}); + +export type RepoType = z.infer; +export type ConfigType = z.infer; + +function createDefaultConfig(): ConfigType { + return { + repos: {}, + }; +} + +export function getConfigDir(): string { + const base = 'contribution-mate'; + + /** + * On macOS the default behavior is to store config files in $HOME/Library/Preferences, + * but this is not a good practice for CLI tools, so we use $XDG_CONFIG_HOME or $HOME/.config instead. + */ + if (Deno.build.os === 'darwin') { + const home = Deno.env.get('XDG_CONFIG_HOME') ?? + join(dir('home') as string, '.config'); + return join(home, base); + } + + return join(dir('config') as string, base); +} + +function getConfigPath(): string { + return join(getConfigDir(), 'config.json'); +} + +async function ensureConfigFile(): Promise { + await ensurePath(getConfigDir()); + const configPath = getConfigPath(); + const configExists = await exists(configPath); + if (!configExists) { + await Deno.writeTextFile( + configPath, + JSON.stringify(createDefaultConfig(), null, 2), + ); + } +} + +export async function getConfig(): Promise { + await ensureConfigFile(); + const configPath = getConfigPath(); + + const obj: unknown = JSON.parse(await Deno.readTextFile(configPath)); + return ConfigSchema.parse(obj); +} + +export async function setConfig( + config: Partial, +): Promise { + const currentConfig = await getConfig(); + + const mergedConfig = mergeDeepRight( + ConfigSchema.parse(currentConfig), + ConfigSchema.partial().parse(config), + ); + + await Deno.writeTextFile( + getConfigPath(), + JSON.stringify(mergedConfig, null, 2), + ); + + return mergedConfig; +} diff --git a/src/gh.ts b/src/gh.ts new file mode 100644 index 0000000..377ca34 --- /dev/null +++ b/src/gh.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +export const RepositorySchema = z.object({ + id: z.string().trim(), + name: z.string().trim(), + owner: z.object({ + id: z.string().trim(), + login: z.string().trim(), + }), +}); +export type RepositoryType = z.infer; + +export async function getRepositories(): Promise { + const cmd = new Deno.Command('gh', { + args: [ + 'repo', + 'list', + '--source', + '--private=false', + '--no-archived', + '--json', + 'id,name,owner', + ], + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + + const stdout = new TextDecoder().decode(await cmdOutput.stdout).trim(); + return z.array(RepositorySchema).parse(JSON.parse(stdout)); +} + +export async function cloneRepository( + repository: RepositoryType, + name: string, + cwd: string, +): Promise { + const cmd = new Deno.Command('gh', { + args: ['repo', 'clone', repository.name, name], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); +} + +export async function pullRepositoryChanges( + cwd: string, +): Promise { + try { + const cmd = new Deno.Command('gh', { + args: ['repo', 'sync'], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + } catch (err) { + // with a clean repo there are no changes to pull yet + if (typeof err != 'string' || !err.includes('invalid refspec')) throw err; + } +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..673481a --- /dev/null +++ b/src/git.ts @@ -0,0 +1,195 @@ +import { z } from 'zod'; + +export const AuthorSchema = z.object({ + name: z.string().min(1).trim(), + email: z.string().email().trim(), +}); +export const CommitSchema = z.object({ + hash: z.string().min(1).trim(), + timestamp: z.number().int().positive(), +}); + +export type AuthorType = z.infer; +export type CommitType = z.infer; + +async function getGitConfigParam(key: string): Promise { + const cmd = new Deno.Command('git', { + args: ['config', '--global', key], + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + + return new TextDecoder().decode(await cmdOutput.stdout).trim(); +} + +export async function getAuthors(repoPath: string): Promise { + const getDefaultBranchCmd = new Deno.Command('git', { + args: ['symbolic-ref', 'refs/remotes/origin/HEAD'], + cwd: repoPath, + }); + + const defaultBranchCmdOutput = await getDefaultBranchCmd.output(); + + if (defaultBranchCmdOutput.code !== 0) { + throw new TextDecoder().decode(defaultBranchCmdOutput.stderr); + } + + const defaultBranch = new TextDecoder().decode(defaultBranchCmdOutput.stdout) + .trim().split('/').pop() ?? 'main'; + + const getAuthorsCmd = new Deno.Command('git', { + args: ['log', defaultBranch, '--format=%aN <%aE>'], + cwd: repoPath, + }); + + const authorsCmdOutput = await getAuthorsCmd.output(); + + if (authorsCmdOutput.code !== 0) { + throw new TextDecoder().decode(defaultBranchCmdOutput.stderr); + } + + const rawAuthors: string[] = new TextDecoder().decode(authorsCmdOutput.stdout) + .trim().split('\n'); + + const authors: AuthorType[] = rawAuthors.concat().sort().reduce( + (acc, curr) => { + const [name, email] = curr.split(' <').map((str) => str.replace('>', '')); + const existingAuthor = acc.find((author) => author.email == email); + if (existingAuthor) return acc; + + acc.push({ name, email }); + + return acc; + }, + [] as AuthorType[], + ); + + return authors; +} + +export async function getGitAuthor(): Promise { + const name = await getGitConfigParam('user.name'); + const email = await getGitConfigParam('user.email'); + + try { + return AuthorSchema.parse({ name, email }); + } catch (_) { // todo: debugger + return; + } +} + +/** + * Returns a list of commits authored by the given email in reverse chronological order. + */ +export async function getCommitsByEmail( + email: string, + cwd: string, +): Promise { + const cmd = new Deno.Command('git', { + args: [ + 'log', + `--author=${email}`, + '--pretty=format:%H|%cd', + '--date=format-local:%s', + ], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + + return new TextDecoder().decode(await cmdOutput.stdout).trim().split( + '\n', + ).map((line) => { + const [hash, timestamp] = line.split('|'); + return { hash, timestamp: parseInt(timestamp) }; + }); +} + +export async function createCommit( + commit: CommitType & { author: AuthorType }, + cwd: string, +) { + const GIT_AUTHOR_NAME = commit.author.name; + const GIT_AUTHOR_EMAIL = commit.author.email; + const GIT_AUTHOR_DATE = commit.timestamp.toString(); + + const cmd = new Deno.Command('git', { + args: ['commit', '--allow-empty', '-m', commit.hash], + cwd, + env: { + GIT_AUTHOR_NAME, + GIT_AUTHOR_EMAIL, + GIT_AUTHOR_DATE, + GIT_COMMITTER_NAME: GIT_AUTHOR_NAME, + GIT_COMMITTER_EMAIL: GIT_AUTHOR_EMAIL, + GIT_COMMITTER_DATE: GIT_AUTHOR_DATE, + }, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); +} + +async function getMainBranch(cwd: string) { + const cmd = new Deno.Command('git', { + args: ['log', '-1', 'HEAD', '--pretty=format:%D'], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + + return new TextDecoder().decode(await cmdOutput.stdout).split('->')[1].trim(); +} + +export async function pushRepositoryChanges( + cwd: string, + branch?: string, +) { + const cmd = new Deno.Command('git', { + args: ['push', 'origin', branch ?? 'HEAD'], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) { + const stderr = new TextDecoder().decode(cmdOutput.stderr); + + console.log('error', stderr); + + if (stderr.includes('cannot lock ref')) { + const mainBranch = await getMainBranch(cwd); + await pushRepositoryChanges(cwd, mainBranch); + } + + throw stderr; + } +} + +/** + * Will return git history where the hash is the original commit's hash. + */ +export async function getGitHistory(cwd: string): Promise { + const cmd = new Deno.Command('git', { + args: ['log', '--pretty=format:%s|%cd', '--date=format-local:%s'], + cwd, + }); + + const cmdOutput = await cmd.output(); + + if (cmdOutput.code !== 0) throw new TextDecoder().decode(cmdOutput.stderr); + + return new TextDecoder().decode(await cmdOutput.stdout).trim().split( + '\n', + ).map((line) => { + const [hash, timestamp] = line.split('|'); + return { hash, timestamp: parseInt(timestamp) }; + }); +} diff --git a/src/lib/ensurePath.ts b/src/lib/ensurePath.ts new file mode 100644 index 0000000..670f4be --- /dev/null +++ b/src/lib/ensurePath.ts @@ -0,0 +1,6 @@ +import { exists } from './exists.ts'; + +export async function ensurePath(path: string): Promise { + const pathExists = await exists(path); + if (!pathExists) await Deno.mkdir(path, { recursive: true }); +} diff --git a/src/utils/exists.ts b/src/lib/exists.ts similarity index 80% rename from src/utils/exists.ts rename to src/lib/exists.ts index e268bc3..601e8d8 100644 --- a/src/utils/exists.ts +++ b/src/lib/exists.ts @@ -1,6 +1,3 @@ -/** - * Will check if a file or directory exists. - */ export async function exists(path: string): Promise { try { await Deno.stat(path); diff --git a/src/lib/ghNoReplyCheck.ts b/src/lib/ghNoReplyCheck.ts new file mode 100644 index 0000000..62e04b1 --- /dev/null +++ b/src/lib/ghNoReplyCheck.ts @@ -0,0 +1,23 @@ +import { EOL } from 'std/fs'; +import { colors } from 'cliffy'; + +export function ghNoReplyCheck(email: string) { + if (email.endsWith('@users.noreply.github.com')) return; + + console.log( + colors.yellow('\xa0!'), + colors.yellow( + 'It looks like you a personal email address authoring commits.', + ), + EOL.LF, + '\xa0', + colors.yellow( + 'We encourage you to configure GitHub no-reply for better privacy.', + ), + EOL.LF, + '\xa0', + colors.yellow( + 'Read more: https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address', + ), + ); +} diff --git a/src/lib/mod.ts b/src/lib/mod.ts new file mode 100644 index 0000000..8a13d30 --- /dev/null +++ b/src/lib/mod.ts @@ -0,0 +1,5 @@ +export { exists } from './exists.ts'; +export { validate } from './validate.ts'; +export { promptAllInputs } from './promptAllInputs.ts'; +export { ghNoReplyCheck } from './ghNoReplyCheck.ts'; +export { ensurePath } from './ensurePath.ts'; diff --git a/src/lib/promptAllInputs.ts b/src/lib/promptAllInputs.ts new file mode 100644 index 0000000..e215eb3 --- /dev/null +++ b/src/lib/promptAllInputs.ts @@ -0,0 +1,20 @@ +import { Input, InputOptions } from 'cliffy'; + +export async function promptAllInputs( + prompts: InputOptions[], +): Promise { + if (prompts.length === 0) { + return []; + } + + const [currentPrompt, ...remainingPrompts] = prompts; + + const result = await Input.prompt({ + ...currentPrompt, + ...(currentPrompt.default ? { default: currentPrompt.default } : null), + }); + + const remainingResults = await promptAllInputs(remainingPrompts); + + return [result, ...remainingResults]; +} diff --git a/src/utils/validate.ts b/src/lib/validate.ts similarity index 100% rename from src/utils/validate.ts rename to src/lib/validate.ts diff --git a/src/main.ts b/src/main.ts index 3cb7e6a..1b64504 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,30 +1,17 @@ -import { ansi, colors, Command } from 'cliffy'; -import { EOL } from 'std/fs'; +import { Command } from 'cliffy'; import { VERSION } from 'version'; -import { config } from 'commands/config'; +import add from './commands/add.ts'; +import sync from './commands/sync.ts'; await new Command() .name('contribution-mate') .version(VERSION) .description( - 'Sync your contributions from non-GitHub repos to your GitHub profile without revealing the source code or commit messages.', - ) - .meta( - 'Create an alias', - ` - ${EOL.LF}\xa0\xa0To create a short alias for contribution-mate, you can use ${ - ansi.link( - colors.blue('GitHub CLI Aliases'), - 'https://cli.github.com/manual/gh_alias', - ) - }. - - For example: - gh alias set cm 'contribution-mate' - gh cm --help`, + 'Synchronize your contributions from local repositories to your GitHub profile without revealing the source code or commit messages.', ) .action(function (this: Command) { this.showHelp(); }) - .command(config.getName(), config) + .command('add', add) + .command('sync', sync) .parse(Deno.args); diff --git a/src/models/config/config.ts b/src/models/config/config.ts deleted file mode 100644 index 3a503c2..0000000 --- a/src/models/config/config.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { join } from 'std/path'; -import { colors } from 'cliffy'; -import { mergeDeepRight, not } from 'rambda'; -import { default as dir } from 'dir'; -import { z } from 'zod'; -import { exists } from 'utils'; - -export type Config = z.infer; -export const Config = z.object({ - name: z.string().trim().min(1), - email: z.string().email().trim(), - repository: z.string().trim().regex(/^[a-zA-Z0-9-_.]+$/), -}); - -export function getConfigDir(): string { - const base = 'contribution-mate'; - - /** - * On macOS the default behavior is to store config files in $HOME/Library/Preferences, - * but this is not a good practice for CLI tools, so we use $XDG_CONFIG_HOME or $HOME instead. - */ - if (Deno.build.os === 'darwin') { - const home = Deno.env.get('XDG_CONFIG_HOME') ?? - join(dir('home') as string, '.config'); - return join(home, base); - } - - return join(dir('config') as string, base); -} - -export function getConfigPath(): string { - return join(getConfigDir(), 'config.json'); -} - -export async function getConfig(): Promise { - const obj: unknown = JSON.parse( - await Deno.readTextFile(getConfigPath()), - ); - return Config.parse(obj); -} - -export function notifyConfigExists(): void { - console.log( - colors.green('\xa0\u2713'), - colors.green('You have already configured contribution-mate!'), - ); -} - -export async function writeConfig(config: Config): Promise { - await ensureConfigDir(); - - const mergedConfig = mergeDeepRight( - await getConfig(), - Config.parse(config), - ); - - await Deno.writeTextFile( - getConfigPath(), - JSON.stringify(mergedConfig, null, 2), - { create: true }, - ); - - return mergedConfig; -} - -export async function ensureConfigDir(): Promise { - const configExists = await exists(getConfigPath()); - if (not(configExists)) await Deno.mkdir(getConfigDir(), { recursive: true }); -} diff --git a/src/models/config/mod.ts b/src/models/config/mod.ts deleted file mode 100644 index 81ba184..0000000 --- a/src/models/config/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './config.ts'; diff --git a/src/models/git/git.ts b/src/models/git/git.ts deleted file mode 100644 index f880c9f..0000000 --- a/src/models/git/git.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod'; -import { isEmpty } from 'rambda'; - -export type GitAuthor = z.infer; -export const GitAuthor = z.object({ - name: z.string().trim().optional(), - email: z.string().email().trim().optional(), -}); - -async function getGitConfigParam(key: string): Promise { - const proc = Deno.run({ - cmd: ['git', 'config', '--global', key], - stdout: 'piped', - }); - const { success } = await proc.status(); - - if (!success) { - proc.close(); - return; - } - - const result = new TextDecoder().decode(await proc.output()).trim(); - proc.close(); - if (isEmpty(result)) return; - return result; -} - -export async function getGitAuthor(): Promise { - return GitAuthor.parse({ - name: await getGitConfigParam('user.name'), - email: await getGitConfigParam('user.email'), - }); -} diff --git a/src/models/git/mod.ts b/src/models/git/mod.ts deleted file mode 100644 index e912ba0..0000000 --- a/src/models/git/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './git.ts'; diff --git a/src/models/repository/mod.ts b/src/models/repository/mod.ts deleted file mode 100644 index 65f046b..0000000 --- a/src/models/repository/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './repository.ts'; diff --git a/src/models/repository/repository.ts b/src/models/repository/repository.ts deleted file mode 100644 index bed083b..0000000 --- a/src/models/repository/repository.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { z } from 'zod'; -import { isEmpty, tryCatch } from 'rambda'; -import { memoizy } from 'memoizy'; - -export class RepositoryError extends Error { - constructor(message: string = 'Repository error') { - super(message); - this.name = this.constructor.name; - } -} - -export type Repository = z.infer; -export const Repository = z.object({ - name: z.string().trim(), - isFork: z.boolean(), - isPrivate: z.boolean(), - url: z.string().trim().url(), - isInOrganization: z.boolean(), - owner: z.object({ - login: z.string().trim().min(1), - }), -}); - -export function isRepository(obj: unknown): obj is Repository { - try { - Repository.parse(obj); - return true; - } catch (_) { - return false; - } -} - -function constructRepositoryObj( - { name, isFork, isPrivate, url, isInOrganization, owner }: Repository, -): Repository { - return { - name, - isFork, - isPrivate, - url, - isInOrganization, - owner: { login: owner.login }, - }; -} - -// ? Probably not the right place for it. New user model? -async function getUsername() { - const proc = Deno.run({ - cmd: ['bash'], - stdin: 'piped', - stdout: 'piped', - stderr: 'piped', - }); - - const encoder = new TextEncoder(); - const decoder = new TextDecoder(); - - await proc.stdin.write(encoder.encode(`gh api user`)); - await proc.stdin.close(); - const stderr = decoder.decode(await proc.stderrOutput()).trim(); - proc.close(); - - if (!isEmpty(stderr)) { - throw new Error(stderr); - } - - const { login: username } = JSON.parse( - decoder.decode(await proc.output()).trim(), - ); - return username; -} - -export const getRepository = memoizy( - async ({ name }: Pick): Promise => { - /** - * Be aware that json options are not type-safe at the moment. - * In case a change is needed make sure this is aligned with the `Repository` type. - */ - const proc = Deno.run({ - cmd: [ - 'gh', - 'repo', - 'view', - name, - '--json', - 'isFork,isPrivate,name,url,isInOrganization,owner', - ], - stdout: 'piped', - stderr: 'piped', - }); - const decoder = new TextDecoder(); - const stdout = decoder.decode(await proc.output()).trim(); - const stderr = decoder.decode(await proc.stderrOutput()).trim(); - - proc.close(); - - /** - * In case there's an unexpected error from the `gh` command, we want to stop the execution completely. - */ - if (!isEmpty(stderr) && !stderr.includes(name)) { - console.error('ERROR', stderr); - Deno.exit(1); - } - - /** - * In case the repository doesn't exist, we want to throw an error. - */ - if (!isEmpty(stderr) && stderr.includes(name)) { - throw new RepositoryError('Repository not found'); - } - - /** - * In case the repository exists, we want to parse the JSON response and cache it. - * If the parsing fails, we want to stop the execution completely. - */ - const obj: unknown = tryCatch(JSON.parse, () => { - console.error('ERROR', 'Failed to parse JSON response'); - Deno.exit(1); - })(stdout); - - const username = await getUsername(); - - if ( - !isRepository(obj) || obj.isFork || obj.isInOrganization || - obj.owner.login !== username - ) { - console.error( - 'ERROR', - 'Invalid repository - this repository cannot be used', - '\n', - 'Make sure the repository is not a fork, not in an organization and belongs to the current user', - ); - throw new RepositoryError('Invalid repository'); - } - - return constructRepositoryObj(obj); - }, -); - -export async function createRepository( - { name }: Pick, -): Promise { - const proc = Deno.run({ - cmd: [ - 'gh', - 'repo', - 'create', - name, - '--private', - '--description', - 'Sync repository for contribution-mate', - ], - stdout: 'piped', - stderr: 'piped', - }); - - const { success } = await proc.status(); - const stdout = new TextDecoder().decode(await proc.output()).trim(); - - proc.close(); - - if (!success || !stdout.includes(name)) { - console.error('ERROR', 'Failed to create a new repository'); - Deno.exit(1); - } - - return getRepository({ name }); -} diff --git a/src/utils/mod.ts b/src/utils/mod.ts deleted file mode 100644 index 1c5fa0b..0000000 --- a/src/utils/mod.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { exists } from './exists.ts'; -export { sanitizeString } from './sanitize.ts'; -export { validate } from './validate.ts'; diff --git a/src/utils/sanitize.ts b/src/utils/sanitize.ts deleted file mode 100644 index adcf69d..0000000 --- a/src/utils/sanitize.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sanitizeString(value: string): string { - return value.replaceAll(/[<>]/g, '').trim(); -} diff --git a/version.ts b/version.ts index 0c18d5d..059488a 100644 --- a/version.ts +++ b/version.ts @@ -1 +1 @@ -export const VERSION = '0.0.0'; +export const VERSION = '1.0.0';