Skip to content

Commit

Permalink
refactor: the Repos class
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Aug 23, 2021
1 parent 5180588 commit 7db7695
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 60 deletions.
1 change: 0 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ module.exports = {
'@typescript-eslint/unified-signatures': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
camelcase: 'off',
},
ignorePatterns: ['*.js'],
};
20 changes: 3 additions & 17 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
import * as path from 'path';
import { mkdir } from 'fs/promises';
import { URL } from 'url';
import { exec } from 'shelljs';
import * as chalk from 'chalk';
import { Command, Flags } from '@oclif/core';
import { Config } from '../config';
import { Repos } from '../repos';
import { Directory } from '../directory';

async function clone(url: string, dir: string): Promise<void> {
await mkdir(dir, { recursive: true });
exec(`git -C ${dir} clone ${url}`, { silent: true });
}
import { CloneMethod, Repos } from '../repos';

function parseOrgAndRepo(entity: string): { org: string; repo: string | null } {
if (entity.startsWith('https://')) {
Expand Down Expand Up @@ -53,19 +44,14 @@ export class Add extends Command {

public async run(): Promise<void> {
const { flags, args } = await this.parse(Add);
const config = await Config.create();
const directory = await Directory.create({ name: config.get('directory') });
const repos = await Repos.create();

const info = parseOrgAndRepo(args.entity);
const repositories = await repos.fetch(info.org, info.repo);
this.log(`Cloning repositories into ${path.join(directory.name, info.org)}`);
this.log(`Cloning repositories into ${path.join(repos.directory.name, info.org)}`);
for (const repo of repositories) {
const url = flags.method === 'ssh' ? repo.ssh_url : repo.clone_url;
this.log(` * ${chalk.bold(repo.name)}`);
const dir = path.join(directory.name, repo.owner.login);
await clone(url, dir);
repos.set(repo.name, repo);
await repos.clone(repo, flags.method as CloneMethod);
}
await repos.write();
}
Expand Down
12 changes: 9 additions & 3 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ import { Command } from '@oclif/core';
import * as chalk from 'chalk';
import { cli } from 'cli-ux';
import { groupBy, sortBy } from 'lodash';
import { Repos } from '../repos';
import { Repos, Repository } from '../repos';

export class List extends Command {
public static readonly description = 'List all added repositories.';
public static readonly flags = {};
public static readonly aliases = ['ls'];

public async run(): Promise<void> {
const repositories = (await Repos.create()).getContents();
const grouped = groupBy(repositories, 'owner.login');
if (Object.keys(repositories).length === 0) {
process.exitCode = 1;
throw new Error('No repositories have been added yet.');
}
const grouped = groupBy(repositories, 'org');
for (const [org, repos] of Object.entries(grouped)) {
const columns = {
name: { header: 'Name' },
html_url: { header: 'URL' },
url: { header: 'URL', get: (r: Repository): string => r.urls.html },
version: { header: 'Version', get: (r: Repository): string => r.npm.version },
};
const sorted = sortBy(Object.values(repos), 'name');
cli.table(sorted, columns, { title: chalk.cyan.bold(`${org} Respositories`) });
Expand Down
7 changes: 6 additions & 1 deletion src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ export class Open extends Command {
required: true,
},
];
public static readonly aliases = ['o'];

public async run(): Promise<void> {
const { args } = await this.parse(Open);
const repo = (await Repos.create()).get(args.repo);
await open(repo.html_url, { wait: false });
if (!repo) {
process.exitCode = 1;
throw new Error(`${args.repo as string} has not been added yet.`);
}
await open(repo.urls.html, { wait: false });
}
}
9 changes: 7 additions & 2 deletions src/commands/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ export class View extends Command {
required: true,
},
];
public static readonly aliases = ['v'];

public async run(): Promise<void> {
const { args } = await this.parse(View);
const repos = await Repos.create();
const repo = repos.get(args.repo);
if (!repo) {
process.exitCode = 1;
throw new Error(`${args.repo as string} has not been added yet.`);
}
const columns = { key: {}, value: {} };
const data = [
{ key: 'name', value: repo.name },
{ key: 'organization', value: repo.owner.login },
{ key: 'url', value: repo.html_url },
{ key: 'organization', value: repo.org },
{ key: 'url', value: repo.urls.html },
];
cli.table(data, columns);
}
Expand Down
18 changes: 15 additions & 3 deletions src/configFile.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { readFile, writeFile } from 'fs/promises';
import { access, readFile, stat, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { Stats } from 'fs';
import { AsyncOptionalCreatable } from '@salesforce/kit';
import { exists } from './util';
import { Directory } from './directory';

export interface JsonMap<T = unknown> {
Expand All @@ -13,6 +13,8 @@ export abstract class ConfigFile<T extends JsonMap> extends AsyncOptionalCreatab
public static MPM_DIR_NAME = '.mpm';
public static MPM_DIR = path.join(os.homedir(), ConfigFile.MPM_DIR_NAME);

public stats: Stats;

private contents: T;
private filepath: string;

Expand All @@ -22,7 +24,7 @@ export abstract class ConfigFile<T extends JsonMap> extends AsyncOptionalCreatab
}

public async read(): Promise<T> {
if (await exists(this.filepath)) {
if (await this.exists(this.filepath)) {
const config = JSON.parse(await readFile(this.filepath, 'utf-8')) as T;
this.contents = config;
return this.contents;
Expand All @@ -49,9 +51,19 @@ export abstract class ConfigFile<T extends JsonMap> extends AsyncOptionalCreatab
this.contents[key] = value;
}

public async exists(filepath: string): Promise<boolean> {
try {
await access(filepath);
return true;
} catch {
return false;
}
}

protected async init(): Promise<void> {
await Directory.create({ name: ConfigFile.MPM_DIR });
this.contents = await this.read();
this.stats = await stat(this.filepath);
}

protected make(): T {
Expand Down
123 changes: 119 additions & 4 deletions src/repos.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
/* eslint-disable camelcase */

import * as path from 'path';
import { mkdir, readFile } from 'fs/promises';
import { Octokit } from '@octokit/core';
import { Duration } from '@salesforce/kit';
import { exec } from 'shelljs';
import { ConfigFile, JsonMap } from './configFile';
import { getToken } from './util';
import { Config } from './config';
import { Directory } from './directory';

export type CloneMethod = 'ssh' | 'https';

export type Repository = {
name: string;
fullName: string;
org: string;
urls: {
html: string;
clone: string;
ssh: string;
};
updated: string;
archived: boolean;
defaultBranch: string;
location?: string;
npm?: {
name: string;
version?: string;
tags?: Record<string, string>;
};
};

export type RepositoryResponse = {
name: string;
full_name: string;
owner: {
Expand All @@ -11,31 +41,116 @@ export type Repository = {
html_url: string;
clone_url: string;
ssh_url: string;
updated_at: string;
archived: boolean;
default_branch: string;
};

export interface RepoIndex extends JsonMap {
[key: string]: Repository;
}

export class Repos extends ConfigFile<RepoIndex> {
public static REFRESH_TIME = Duration.hours(2);
public directory!: Directory;
private octokit!: Octokit;

public constructor() {
super('repos.json');
}

public async fetch(org: string, repo: string | null): Promise<Repository[]> {
public async fetch(org: string, repo?: string | null): Promise<Repository[]> {
if (repo) {
const response = await this.octokit.request('GET /repos/{owner}/{repo}', { owner: org, repo });
return [response.data] as Repository[];
let transformed = this.transform(response.data);
if (transformed.location) {
transformed = await this.addAdditionalInfo(transformed);
}
return [transformed];
} else {
const response = await this.octokit.request('GET /orgs/{org}/repos', { org });
return response.data as Repository[];
const transformed = response.data.map((r) => this.transform(r as RepositoryResponse));
const promises = transformed.map(async (t) => {
if (t.location) return this.addAdditionalInfo(t);
else return t;
});
return Promise.all(promises);
}
}

public async clone(repo: Repository, method: CloneMethod = 'ssh'): Promise<void> {
const orgDir = path.join(this.directory.name, repo.org);
await mkdir(orgDir, { recursive: true });
const url = method === 'ssh' ? repo.urls.ssh : repo.urls.clone;
exec(`git -C ${orgDir} clone ${url}`, { silent: true });

try {
this.set(repo.name, await this.addAdditionalInfo(repo));
} catch {
// do nothing
}
}
protected async init(): Promise<void> {
this.octokit = new Octokit({ auth: getToken() });
await super.init();
this.octokit = new Octokit({ auth: getToken() });
const config = await Config.create();
this.directory = await Directory.create({ name: config.get('directory') });

if (this.needsRefresh()) {
await this.refresh();
}
}

private needsRefresh(): boolean {
return new Date().getTime() - this.stats.mtime.getTime() > Repos.REFRESH_TIME.milliseconds;
}

private async refresh(): Promise<void> {
const originalRepos = Object.keys(this.getContents());
const orgs = Array.from(new Set(Object.values(this.getContents()).map((r) => r.org)));
for (const org of orgs) {
const orgRepos = await this.fetch(org);
for (const repo of orgRepos) {
if (originalRepos.includes(repo.name)) {
this.set(repo.name, await this.addAdditionalInfo(repo));
}
}
}
await this.write();
}

private transform(repo: RepositoryResponse): Repository {
const transformed = {
name: repo.name,
fullName: repo.full_name,
org: repo.owner.login,
archived: repo.archived,
updated: repo.updated_at,
urls: {
clone: repo.clone_url,
html: repo.html_url,
ssh: repo.ssh_url,
},
defaultBranch: repo.default_branch,
};
return transformed;
}

private async addAdditionalInfo(repo: Repository): Promise<Repository> {
const location = repo.location || path.join(this.directory.name, repo.org, repo.name);
const pkgJsonPath = path.join(location, 'package.json');
const pkgJson = JSON.parse(await readFile(pkgJsonPath, 'utf-8')) as { name: string };
repo.npm = {
name: pkgJson.name,
};
const npmInfoRaw = exec(`npm view ${pkgJson.name} --json`, { silent: true }).stdout;
const npmInfo = JSON.parse(npmInfoRaw) as {
'dist-tags': Record<string, string>;
versions: string[];
};
repo.npm.version = npmInfo['dist-tags']['latest'] ?? npmInfo.versions.reverse()[0];
repo.npm.tags = npmInfo['dist-tags'];
repo.location = location;
return repo;
}
}
29 changes: 0 additions & 29 deletions src/util.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,9 @@
import * as os from 'os';
import * as path from 'path';
import { access, readFile } from 'fs/promises';
import { Repository } from './repos';

export const MPM_DIR_NAME = '.mpm';
export const MPM_DIR = path.join(os.homedir(), MPM_DIR_NAME);

export async function readRepos(): Promise<Record<string, Repository>> {
const filepath = path.join(MPM_DIR, 'repos.json');
try {
return JSON.parse(await readFile(filepath, 'utf-8')) as Record<string, Repository>;
} catch {
throw new Error('No repos have been added.');
}
}

export async function readRepo(repo: string): Promise<Repository> {
const repos = await readRepos();
if (repos[repo]) {
return repos[repo];
} else {
throw new Error(`No repo named ${repo} found.`);
}
}

export async function exists(filepath: string): Promise<boolean> {
try {
await access(filepath);
return true;
} catch {
return false;
}
}

export function getToken(): string {
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
if (!token) {
Expand Down

0 comments on commit 7db7695

Please sign in to comment.