diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ab4370 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ['**'] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Lint + run: yarn lint + + - name: Build + run: yarn build diff --git a/src/dcc-cli.ts b/src/dcc-cli.ts index 905dd57..e789604 100644 --- a/src/dcc-cli.ts +++ b/src/dcc-cli.ts @@ -38,14 +38,6 @@ function print(...args: string[]) { logger.info(args.join(' ')) } -function format(s: string, n: number) { - if (s.length > n) { - return s.substr(0, n) - } - - return s.padEnd(n) -} - function launch(f: (a: T) => Promise) { return (args: T & { dir?: string }) => { if (args.dir) { @@ -99,17 +91,6 @@ async function catchUp(mode: 'SILENT' | 'CHATTY') { return mainBranch } -async function listOngoing() { - const d = await githubOps.listPrs() - for (const curr of d) { - print( - `${curr.updatedAt} ${('#' + curr.number).padStart(6)} ${format(curr.user, 10)} ${format(curr.title, 60)} ${ - curr.url - }`, - ) - } -} - async function createNew(a: { branch: string }) { await gitOps.noUncommittedChanges() const [currBranch, mainBranch] = await Promise.all([gitOps.getBranch(), gitOps.mainBranch()]) @@ -132,6 +113,18 @@ async function pending() { } } +async function restore(a: { files: string[] }) { + await gitOps.noUncommittedChanges() + const mainBranch = await gitOps.mainBranch() + const baselineCommit = await gitOps.findBaselineCommit(`origin/${mainBranch}`) + await gitOps.restoreFiles(baselineCommit, a.files) + const shortSha = await gitOps.shortSha(baselineCommit) + const fileList = a.files.join(' ') + const longMessage = `restore from ${shortSha}: ${fileList}` + const message = longMessage.length <= 100 ? longMessage : `restore ${a.files.length} files from ${shortSha}` + await gitOps.commitAll(message) +} + function prIsUpToDate(pr: CurrentPrInfo) { if (!pr.lastCommit) { throw new Error(`Failed to retreive information about the PR's latest commit`) @@ -191,22 +184,7 @@ async function submit() { await githubOps.merge(pr.number) print('merged') - const mainBranch = await catchUp('SILENT') - // Order is important here: merge will work only if we have switched to the main branch. - await gitOps.switchToMainBranch() - await gitOps.merge('origin', mainBranch) -} - -async function listClosed(args: { user?: string }) { - const d = await githubOps.listMerged(args.user) - - for (const curr of d) { - print( - `${curr.mergedAt} ${('#' + curr.number).padStart(6)} ${format(curr.user, 10)} ${format(curr.title, 60)} ${ - curr.url - }`, - ) - } + await catchUp('CHATTY') } async function push(args: { title?: string; submit?: boolean }) { @@ -288,6 +266,15 @@ async function status() { } async function openPr() { + const [branch, mainBranch] = await Promise.all([gitOps.getBranch(), gitOps.mainBranch()]) + if (branch.name === mainBranch) { + const repo = await gitOps.getRepo() + const url = `https://github.com/${repo.owner}/${repo.name}/commits/${mainBranch}` + print(`🌐 Opening ${url}`) + await open(url) + return + } + const pr = await graphqlOps.getCurrentPr() if (!pr) { print('🚫 No PR found for this branch') @@ -358,11 +345,11 @@ yargs(hideBin(process.argv)) .version(currentVersion) .option('dir', { alias: 'd', - describe: 'directroy to run at', + describe: 'directory to run at', type: 'string', }) .command( - [STATUS_COMMAND, '*'], + [STATUS_COMMAND, 's', '*'], 'Show the status of the current PR', a => a, async argv => { @@ -375,7 +362,7 @@ yargs(hideBin(process.argv)) }, ) .command( - 'push [title..]', + ['push [title..]', 'p [title..]'], 'Push your changes to GitHub (creates a PR, if a title is specified)', yargs => yargs.positional('title', { @@ -394,19 +381,7 @@ yargs(hideBin(process.argv)) await catchUp('CHATTY') }), ) - .command('list-ongoing', 'List currently open PRs', a => a, launch(listOngoing)) - .command( - 'list-closed', - 'List recently merged PRs', - yargs => - yargs.option('user', { - alias: 'u', - describe: 'Shows only PR from that GitHub user ID. If omiited shows from all users.', - type: 'string', - }), - launch(listClosed), - ) - .command(['pending', 'p'], `Lists all changes files (compared to branch's baseline commit)`, a => a, launch(pending)) + .command(['files', 'f'], `List all changed files (compared to branch's baseline commit)`, a => a, launch(pending)) .command(['diff', 'd'], `Diffs against the branch's baseline commit`, a => a, launch(diff)) .command( ['difftool', 'dt'], @@ -425,6 +400,18 @@ yargs(hideBin(process.argv)) }), launch(createNew), ) + .command( + ['restore ', 'r '], + `Restore files to their state at the branch's baseline commit`, + yargs => + yargs.positional('files', { + type: 'string', + array: true, + describe: 'Files to restore', + demandOption: true, + }), + launch((a: { files: string[] }) => restore(a)), + ) .command(['open', 'o'], 'Open the current PR files page in your browser', a => a, launch(openPr)) .command( ['checkout ', 'co '], diff --git a/src/git-ops.ts b/src/git-ops.ts index 7afab3a..29d9f39 100644 --- a/src/git-ops.ts +++ b/src/git-ops.ts @@ -192,4 +192,30 @@ export class GitOps { async diff(commit: string, useDifftool: boolean): Promise { await execa('git', [useDifftool ? 'difftool' : 'diff', commit], { stdout: 'inherit' }) } + + async restoreFiles(commit: string, files: string[]): Promise { + for (const file of files) { + let existsInBaseline = true + try { + await this.git.raw(['cat-file', '-e', `${commit}:${file}`]) + } catch { + existsInBaseline = false + } + + if (existsInBaseline) { + await this.git.checkout([commit, '--', file]) + } else { + await this.git.rm([file]) + } + } + } + + async shortSha(commit: string): Promise { + const out = await this.git.raw(['rev-parse', '--short', commit]) + return out.trim() + } + + async commitAll(message: string): Promise { + await this.git.raw(['commit', '-m', message]) + } } diff --git a/src/github-ops.ts b/src/github-ops.ts index 6457874..06ff3bf 100644 --- a/src/github-ops.ts +++ b/src/github-ops.ts @@ -2,49 +2,6 @@ import { GitOps } from './git-ops.js' import { Octokit } from '@octokit/rest' import { logger } from './logger.js' -interface PrInfo { - url: string - updatedAt: string - number: number - user: string - title: string -} - -interface CheckStatusInfo { - context: string - required: boolean - state: string - createdAt: string - updatedAt: string -} - -interface CheckCommitInfo { - ordinal: number - data: { hash: string; message: string } -} - -interface CheckInfo { - statuses: CheckStatusInfo[] - state: string - sha: string - commit: CheckCommitInfo -} - -interface MergedPrInfo { - mergedAt: string | null - title: string - number: number - url: string - user: string -} - -function reify(t: T | null | undefined): T { - if (t === null || t === undefined) { - throw new Error(`got a falsy value`) - } - return t -} - export type Check = | { tag: 'FAILING' @@ -87,31 +44,6 @@ export class GithubOps { return [...pending, ...passing, ...failing] } - async getUser(): Promise { - const d = await this.kit.users.getAuthenticated() - return d.data.login - } - - async listPrs(): Promise { - const [repo, user] = await Promise.all([this.gitOps.getRepo(), this.getUser()]) - - const respB = await this.kit.search.issuesAndPullRequests({ - q: `type:pr author:${user} state:open repo:${repo.owner}/${repo.name} sort:updated-desc`, - }) - const prs = respB.data.items.map(curr => ({ - user: reify(curr.user?.login), - title: curr.title, - url: `https://github.com/${repo.owner}/${repo.name}/pull/${curr.number}`, - body: curr.body, - updatedAt: curr.updated_at, - createdAt: curr.created_at, - number: curr.number, - state: curr.state, - })) - - return prs.filter(curr => curr.user === user) - } - async merge(prNumber: number): Promise { const r = await this.gitOps.getRepo() await this.kit.pulls.merge({ owner: r.owner, repo: r.name, pull_number: prNumber, merge_method: 'squash' }) @@ -127,72 +59,6 @@ export class GithubOps { }) } - async listChecks(): Promise { - const r = await this.gitOps.getRepo() - const b = await this.gitOps.getBranch() - const statusPromise = this.kit.repos.getCombinedStatusForRef({ - owner: r.owner, - repo: r.name, - ref: b.name, - }) - - const branchPromise = await this.kit.repos.getBranch({ - owner: r.owner, - repo: r.name, - branch: await this.gitOps.mainBranch(), - }) - - const [status, branch] = await Promise.all([statusPromise, branchPromise]) - const required = new Set(reify(branch.data.protection.required_status_checks?.contexts)) - const statuses = status.data.statuses.map(s => ({ - context: s.context, - required: required.has(s.context), - state: s.state, - createdAt: s.created_at, - updatedAt: s.updated_at, - })) - - const d = await this.gitOps.describeCommit(status.data.sha) - if (!d) { - throw new Error(`Could not find sha ${status.data.sha} in git log`) - } - - return { statuses, state: status.data.state, sha: status.data.sha, commit: d } - } - - async listMerged(user?: string): Promise { - const r = await this.gitOps.getRepo() - - const pageSize = user ? 100 : 40 - const resp = await this.kit.pulls.list({ - owner: r.owner, - repo: r.name, - state: 'closed', - sort: 'updated', - direction: 'desc', - per_page: pageSize, - }) - let prs = resp.data.map(curr => ({ - user: reify(curr.user?.login), - title: curr.title, - url: `https://github.com/${r.owner}/${r.name}/pull/${curr.number}`, - body: curr.body, - branch: curr.head.ref, - updatedAt: curr.updated_at, - createdAt: curr.created_at, - mergedAt: curr.merged_at, - number: curr.number, - state: curr.state, - })) - - prs = prs.filter(curr => Boolean(curr.mergedAt)) - - if (user) { - prs = prs.filter(curr => curr.user === user) - } - return prs - } - async updatePrTitle(prNumber: number, newTitle: string): Promise { const b = await this.gitOps.getRepo() await this.kit.pulls.update({ owner: b.owner, repo: b.name, pull_number: prNumber, title: newTitle })