@evanpurkhiser/tooling-personal 1.44.0 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/assignees.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.selectAssignee = exports.AssigneeType = void 0;
7
+ exports.getAssignees = getAssignees;
7
8
  const chalk_1 = __importDefault(require("chalk"));
8
9
  const fast_merge_async_iterators_1 = __importDefault(require("fast-merge-async-iterators"));
9
10
  const graphql_request_1 = require("graphql-request");
package/dist/blame.js ADDED
@@ -0,0 +1,178 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseDiff = parseDiff;
7
+ exports.blameFile = blameFile;
8
+ exports.aggregateBlame = aggregateBlame;
9
+ const simple_git_1 = __importDefault(require("simple-git"));
10
+ function hunkFromHeader(line, contextWindow) {
11
+ const match = line.match(/^@@ -(\d+)(?:,(\d+))?/);
12
+ if (!match) {
13
+ return null;
14
+ }
15
+ const oldStart = Number(match[1]);
16
+ const oldCount = match[2] === undefined ? 1 : Number(match[2]);
17
+ if (oldCount === 0) {
18
+ return {
19
+ start: Math.max(1, oldStart - contextWindow + 1),
20
+ end: oldStart + contextWindow,
21
+ };
22
+ }
23
+ return { start: oldStart, end: oldStart + oldCount - 1 };
24
+ }
25
+ function parseFileSection(section, contextWindow) {
26
+ const lines = section.split('\n');
27
+ const fromLine = lines.find(l => l.startsWith('--- '));
28
+ const toLine = lines.find(l => l.startsWith('+++ '));
29
+ if (!fromLine || !toLine) {
30
+ return null;
31
+ }
32
+ const fromPath = fromLine.slice(4);
33
+ const toPath = toLine.slice(4);
34
+ // Skip added files (no prior history) and deleted files.
35
+ if (fromPath === '/dev/null' || toPath === '/dev/null') {
36
+ return null;
37
+ }
38
+ // Blame the old path at `rev` so rename-with-edits hits the right file.
39
+ const file = fromPath.startsWith('a/') ? fromPath.slice(2) : fromPath;
40
+ const hunks = lines
41
+ .filter(l => l.startsWith('@@'))
42
+ .map(l => hunkFromHeader(l, contextWindow))
43
+ .filter((r) => r !== null);
44
+ return hunks.length > 0 ? { file, hunks } : null;
45
+ }
46
+ /**
47
+ * Parse a unified diff into per-file old-side line ranges. Pure insertions
48
+ * expand to a small context window so nearby lines still contribute. Added
49
+ * files (from /dev/null) are skipped.
50
+ */
51
+ function parseDiff(diff, contextWindow = 3) {
52
+ return diff
53
+ .split(/^diff --git /m)
54
+ .slice(1)
55
+ .map(section => parseFileSection(section, contextWindow))
56
+ .filter((f) => f !== null);
57
+ }
58
+ function mergeRanges(ranges) {
59
+ if (ranges.length === 0) {
60
+ return [];
61
+ }
62
+ const sorted = [...ranges].sort((a, b) => a.start - b.start);
63
+ const merged = [{ ...sorted[0] }];
64
+ for (let i = 1; i < sorted.length; i++) {
65
+ const last = merged[merged.length - 1];
66
+ const curr = sorted[i];
67
+ if (curr.start <= last.end + 1) {
68
+ last.end = Math.max(last.end, curr.end);
69
+ }
70
+ else {
71
+ merged.push({ ...curr });
72
+ }
73
+ }
74
+ return merged;
75
+ }
76
+ function lineInRanges(line, ranges) {
77
+ return ranges.some(r => line >= r.start && line <= r.end);
78
+ }
79
+ /**
80
+ * Blame the full file at `rev`, tallying per-author totals over the whole
81
+ * file and the subset of lines inside the given hunk ranges.
82
+ */
83
+ async function blameFile(file, hunks, rev) {
84
+ const empty = {
85
+ fileLines: new Map(),
86
+ hunkLines: new Map(),
87
+ authorNames: new Map(),
88
+ fileTotal: 0,
89
+ hunkTotal: 0,
90
+ };
91
+ let output;
92
+ try {
93
+ output = await (0, simple_git_1.default)().raw(['blame', '--porcelain', rev, '--', file]);
94
+ }
95
+ catch {
96
+ return empty;
97
+ }
98
+ const merged = mergeRanges(hunks);
99
+ const shaToEmail = new Map();
100
+ const shaToName = new Map();
101
+ const entries = [];
102
+ // 01. Parse porcelain into per-line blame entries + per-sha author headers
103
+ for (const line of output.split('\n')) {
104
+ const shaMatch = line.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/);
105
+ if (shaMatch) {
106
+ entries.push({ sha: shaMatch[1], finalLine: Number(shaMatch[2]) });
107
+ continue;
108
+ }
109
+ const last = entries[entries.length - 1];
110
+ if (!last) {
111
+ continue;
112
+ }
113
+ if (line.startsWith('author-mail ')) {
114
+ const email = line.slice('author-mail '.length).replace(/^<|>$/g, '');
115
+ if (!shaToEmail.has(last.sha)) {
116
+ shaToEmail.set(last.sha, email);
117
+ }
118
+ }
119
+ else if (line.startsWith('author ')) {
120
+ const name = line.slice('author '.length);
121
+ if (!shaToName.has(last.sha)) {
122
+ shaToName.set(last.sha, name);
123
+ }
124
+ }
125
+ }
126
+ // 02. Tally per-author counts, splitting hunk vs file totals
127
+ const fileLines = new Map();
128
+ const hunkLines = new Map();
129
+ const authorNames = new Map();
130
+ let fileTotal = 0;
131
+ let hunkTotal = 0;
132
+ for (const { sha, finalLine } of entries) {
133
+ const email = shaToEmail.get(sha);
134
+ if (!email) {
135
+ continue;
136
+ }
137
+ fileLines.set(email, (fileLines.get(email) ?? 0) + 1);
138
+ fileTotal++;
139
+ if (lineInRanges(finalLine, merged)) {
140
+ hunkLines.set(email, (hunkLines.get(email) ?? 0) + 1);
141
+ hunkTotal++;
142
+ }
143
+ const name = shaToName.get(sha);
144
+ if (name && !authorNames.has(email)) {
145
+ authorNames.set(email, name);
146
+ }
147
+ }
148
+ return { fileLines, hunkLines, authorNames, fileTotal, hunkTotal };
149
+ }
150
+ /**
151
+ * Blame every file in parallel and fold the per-file results together.
152
+ */
153
+ async function aggregateBlame(fileHunks, rev) {
154
+ const results = await Promise.all(fileHunks.map(({ file, hunks }) => blameFile(file, hunks, rev)));
155
+ const agg = {
156
+ fileLines: new Map(),
157
+ hunkLines: new Map(),
158
+ authorNames: new Map(),
159
+ fileTotal: 0,
160
+ hunkTotal: 0,
161
+ };
162
+ for (const r of results) {
163
+ agg.fileTotal += r.fileTotal;
164
+ agg.hunkTotal += r.hunkTotal;
165
+ for (const [email, n] of r.fileLines) {
166
+ agg.fileLines.set(email, (agg.fileLines.get(email) ?? 0) + n);
167
+ }
168
+ for (const [email, n] of r.hunkLines) {
169
+ agg.hunkLines.set(email, (agg.hunkLines.get(email) ?? 0) + n);
170
+ }
171
+ for (const [email, name] of r.authorNames) {
172
+ if (!agg.authorNames.has(email)) {
173
+ agg.authorNames.set(email, name);
174
+ }
175
+ }
176
+ }
177
+ return agg;
178
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.cherryPickOnto = cherryPickOnto;
7
+ const simple_git_1 = __importDefault(require("simple-git"));
8
+ /**
9
+ * Build a commit object equivalent to cherry-picking `sha` onto `base`, without
10
+ * touching the working tree or any ref.
11
+ *
12
+ * Uses `git merge-tree --write-tree` (git >= 2.38) to produce the merged tree
13
+ * in the object database, then `git commit-tree` to wrap it in a commit whose
14
+ * parent is `base`. Author info and the full commit message are preserved from
15
+ * the original commit; the committer is the current user (mirrors the way
16
+ * `git rebase` / `git cherry-pick` work).
17
+ *
18
+ * Throws if the three-way merge has conflicts.
19
+ */
20
+ async function cherryPickOnto(sha, base) {
21
+ const git = (0, simple_git_1.default)();
22
+ const parentSha = (await git.revparse([`${sha}^`])).trim();
23
+ const baseSha = (await git.revparse([base])).trim();
24
+ let treeOid;
25
+ try {
26
+ treeOid = (await git.raw([
27
+ 'merge-tree',
28
+ '--write-tree',
29
+ '--no-messages',
30
+ `--merge-base=${parentSha}`,
31
+ baseSha,
32
+ sha,
33
+ ])).trim();
34
+ }
35
+ catch (error) {
36
+ throw new Error(`Conflicts applying ${sha.slice(0, 8)} onto ${base}. Rebase locally and retry.\n${error}`);
37
+ }
38
+ const info = await git.raw(['show', '-s', '--format=%an%n%ae%n%aI%n%B', sha]);
39
+ const lines = info.split('\n');
40
+ const authorName = lines[0];
41
+ const authorEmail = lines[1];
42
+ const authorDate = lines[2];
43
+ const message = lines.slice(3).join('\n').replace(/\n+$/, '');
44
+ const newCommit = await git
45
+ .env({
46
+ ...process.env,
47
+ GIT_AUTHOR_NAME: authorName,
48
+ GIT_AUTHOR_EMAIL: authorEmail,
49
+ GIT_AUTHOR_DATE: authorDate,
50
+ })
51
+ .raw(['commit-tree', treeOid, '-p', baseSha, '-m', message]);
52
+ return newCommit.trim();
53
+ }
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.prCreate = prCreate;
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const open_1 = __importDefault(require("open"));
9
+ const simple_git_1 = __importDefault(require("simple-git"));
10
+ const assignees_1 = require("../assignees");
11
+ const cherry_pick_1 = require("../cherry-pick");
12
+ const pulls_1 = require("../pulls");
13
+ const utils_1 = require("../utils");
14
+ async function readStdin() {
15
+ if (process.stdin.isTTY) {
16
+ return '';
17
+ }
18
+ process.stdin.setEncoding('utf8');
19
+ let data = '';
20
+ for await (const chunk of process.stdin) {
21
+ data += chunk;
22
+ }
23
+ return data.trim();
24
+ }
25
+ function parseSlugs(input) {
26
+ return (input ?? '')
27
+ .split(',')
28
+ .map(s => s.trim().replace(/^@/, ''))
29
+ .filter(Boolean);
30
+ }
31
+ async function resolveReviewers(repo, slugs) {
32
+ if (slugs.length === 0) {
33
+ return [];
34
+ }
35
+ const want = new Set(slugs.map(s => s.toLowerCase()));
36
+ const found = [];
37
+ for await (const assignee of (0, assignees_1.getAssignees)(repo)) {
38
+ const key = assignee.slug.toLowerCase();
39
+ if (want.has(key)) {
40
+ found.push(assignee);
41
+ want.delete(key);
42
+ if (want.size === 0) {
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ if (want.size > 0) {
48
+ throw new Error(`Could not resolve reviewer(s): ${[...want].join(', ')}`);
49
+ }
50
+ return found;
51
+ }
52
+ async function prCreate(argv) {
53
+ if (!argv.title) {
54
+ throw new Error('--title is required');
55
+ }
56
+ const username = await (0, utils_1.getEmailUsername)();
57
+ const repo = await (0, utils_1.getRepoKey)();
58
+ const { head, origin } = await (0, utils_1.getBranchNames)();
59
+ if (head === null) {
60
+ throw new Error('Cannot determine HEAD branch name');
61
+ }
62
+ if (origin === null) {
63
+ throw new Error('Cannot determine upstream HEAD branch name');
64
+ }
65
+ const body = await readStdin();
66
+ const unpublished = (0, simple_git_1.default)().log({ from: 'HEAD', to: origin });
67
+ const [repoDetails, commits, prs] = await Promise.all([
68
+ (0, pulls_1.getRepoInfo)(repo),
69
+ unpublished,
70
+ (0, pulls_1.getPulls)(repo),
71
+ ]);
72
+ if (repoDetails === null) {
73
+ throw new Error('Failed to get repository ID');
74
+ }
75
+ const commit = commits.all.find(c => c.hash.startsWith(argv.sha));
76
+ if (!commit) {
77
+ throw new Error(`Commit ${argv.sha} not found in unpublished commits`);
78
+ }
79
+ const branchName = (0, utils_1.branchFromMessage)(username, commit.message);
80
+ const existingPr = prs.find(p => p.headRefName === branchName);
81
+ if (argv.updateOnly && !existingPr) {
82
+ throw new Error(`No existing PR for branch ${branchName}`);
83
+ }
84
+ const reviewerSlugs = parseSlugs(argv.reviewer);
85
+ // Reviewers only apply on PR creation (matches `pt pr` behavior).
86
+ const reviewers = existingPr ? [] : await resolveReviewers(repo, reviewerSlugs);
87
+ console.error(chalk_1.default.gray(`Cherry-picking ${commit.hash.slice(0, 8)} onto ${origin}`));
88
+ const newSha = await (0, cherry_pick_1.cherryPickOnto)(commit.hash, origin);
89
+ console.error(chalk_1.default.gray(`Pushing ${branchName}`));
90
+ await (0, simple_git_1.default)().push(['--force', 'origin', `${newSha}:refs/heads/${branchName}`]);
91
+ if (existingPr) {
92
+ const url = `https://github.com/${repo.fullName}/pull/${existingPr.number}`;
93
+ console.error(chalk_1.default.green(`Updated existing PR #${existingPr.number}: ${existingPr.title}`));
94
+ console.log(url);
95
+ return;
96
+ }
97
+ console.error(chalk_1.default.gray('Creating Pull Request'));
98
+ const { createPullRequest } = await (0, pulls_1.createPull)({
99
+ baseRefName: repoDetails.defaultBranch,
100
+ headRefName: branchName,
101
+ repositoryId: repoDetails.repoId,
102
+ draft: argv.draft,
103
+ title: argv.title,
104
+ body,
105
+ });
106
+ const pr = createPullRequest.pullRequest;
107
+ if (argv.autoMerge) {
108
+ try {
109
+ await (0, pulls_1.enableAutoMerge)({ pullRequestId: pr.id, mergeMethod: 'SQUASH' });
110
+ }
111
+ catch {
112
+ console.error(chalk_1.default.yellow('Auto merge not available, skipping'));
113
+ }
114
+ }
115
+ if (reviewers.length > 0) {
116
+ await (0, pulls_1.requestReview)({
117
+ pullRequestId: pr.id,
118
+ userIds: reviewers.filter(r => r.type === assignees_1.AssigneeType.User).map(r => r.id),
119
+ teamIds: reviewers.filter(r => r.type === assignees_1.AssigneeType.Team).map(r => r.id),
120
+ });
121
+ }
122
+ console.error(chalk_1.default.green(`Created PR: ${pr.url}`));
123
+ console.log(pr.url);
124
+ if (!argv.noOpen) {
125
+ await (0, open_1.default)(pr.url);
126
+ }
127
+ }
package/dist/cmd/pr.js CHANGED
@@ -9,6 +9,7 @@ const listr2_1 = require("listr2");
9
9
  const open_1 = __importDefault(require("open"));
10
10
  const simple_git_1 = __importDefault(require("simple-git"));
11
11
  const assignees_1 = require("../assignees");
12
+ const cherry_pick_1 = require("../cherry-pick");
12
13
  const editor_1 = require("../editor");
13
14
  const fzf_1 = require("../fzf");
14
15
  const pulls_1 = require("../pulls");
@@ -62,8 +63,9 @@ async function pr(argv) {
62
63
  },
63
64
  });
64
65
  const { repoId, defaultBranch, commits, prs } = await collectInfoTask.run();
65
- const selectCommits = () => (0, fzf_1.fzfSelect)({
66
- prompt: 'Select commit(s) for PR:',
66
+ const pickCommit = () => (0, fzf_1.fzfSelect)({
67
+ prompt: 'Select commit for PR:',
68
+ multi: false,
67
69
  genValues: addOption => commits.all.forEach(commit => {
68
70
  const branchName = (0, utils_1.branchFromMessage)(username, commit.message);
69
71
  const pr = prs.find(pr => pr.headRefName === branchName);
@@ -73,68 +75,44 @@ async function pr(argv) {
73
75
  addOption({ label, id: commit.hash, ...commit });
74
76
  }),
75
77
  });
76
- // 01. Select which commits to turn into PRs
77
- const selectedCommits = commits.total === 1
78
- ? [{ id: commits.all[0].hash, ...commits.all[0] }]
79
- : await selectCommits();
80
- const selectedShas = selectedCommits.map(option => option.hash);
81
- // 02. Re-order our commits when there are multiple
82
- // commits and the selected commits are not already at
83
- // the end of the list.
84
- const rebaseContents = [
85
- ...selectedShas,
86
- ...commits.all
87
- .filter(c => !selectedShas.includes(c.hash))
88
- .map(c => c.hash)
89
- .reverse(),
90
- ]
91
- .map(sha => `pick ${sha}`)
92
- .join('\n');
93
- const targetCommit = selectedCommits[selectedCommits.length - 1];
94
- const branchName = (0, utils_1.branchFromMessage)(username, targetCommit.message);
78
+ // 01. Select which commit to turn into a PR
79
+ const selectedCommit = commits.total === 1
80
+ ? { id: commits.all[0].hash, ...commits.all[0] }
81
+ : (await pickCommit())[0];
82
+ if (!selectedCommit) {
83
+ console.log(chalk_1.default.red `No commit selected, aborting`);
84
+ process.exit(1);
85
+ }
86
+ const branchName = (0, utils_1.branchFromMessage)(username, selectedCommit.message);
95
87
  const willOpenPr = prs.some(pr => pr.headRefName === branchName);
96
- // 03. Rebase and push the selected commits
97
- const doRebase = async () => {
98
- const rebase = (0, simple_git_1.default)()
99
- .env({ ...process.env, GIT_SEQUENCE_EDITOR: `echo "${rebaseContents}" >` })
100
- .rebase(['--interactive', '--autostash', origin]);
101
- try {
102
- await rebase;
103
- }
104
- catch (error) {
105
- // Abort a failed rebase
106
- await (0, simple_git_1.default)().rebase(['--abort']);
107
- throw new Error(`Failed to rebase\n${error}`);
108
- }
109
- };
88
+ // 02. Build a branch-tip commit by cherry-picking the selected commit onto
89
+ // origin/<default> via plumbing, then push that commit. Local main is
90
+ // never touched.
110
91
  const doPush = async () => {
111
- const newCommits = await getCommits(origin);
112
- const commitIdx = newCommits.all.length - selectedCommits.length;
113
- const rebaseTargetCommit = newCommits.all[commitIdx];
114
- const refSpec = `${rebaseTargetCommit.hash}:refs/heads/${branchName}`;
92
+ const newSha = await (0, cherry_pick_1.cherryPickOnto)(selectedCommit.hash, origin);
93
+ const refSpec = `${newSha}:refs/heads/${branchName}`;
115
94
  await (0, simple_git_1.default)().push(['--force', 'origin', refSpec]);
116
95
  };
117
- const rebaseAndPushTask = new listr2_1.Listr([], { rendererOptions });
118
- rebaseAndPushTask.add({ title: 'Rebasing commits', task: doRebase });
119
- rebaseAndPushTask.add({ title: 'Pushing to GitHub', task: doPush });
120
- // 04. Nothing left to do if we just updated an existing
96
+ const pushTask = new listr2_1.Listr([], { rendererOptions });
97
+ pushTask.add({ title: 'Building and pushing commit', task: doPush });
98
+ // 03. Nothing left to do if we just updated an existing
121
99
  // pull request.
122
100
  if (willOpenPr) {
123
- await rebaseAndPushTask.run();
101
+ await pushTask.run();
124
102
  return;
125
103
  }
126
104
  // Cork stdout to avoid listr output polluting vim. We'll uncork after we
127
105
  // close vim
128
106
  process.stdout.cork();
129
- // 05. Open an editor to write the pull request
130
- const { editor, editorResult } = await (0, editor_1.editPullRequest)(targetCommit);
131
- const rebaseAndPush = rebaseAndPushTask.run();
132
- rebaseAndPush.catch(() => editor.kill());
107
+ // 04. Open an editor to write the pull request
108
+ const { editor, editorResult } = await (0, editor_1.editPullRequest)(selectedCommit);
109
+ const pushPromise = pushTask.run();
110
+ pushPromise.catch(() => editor.kill());
133
111
  const { title, body } = await editorResult;
134
112
  // XXX: We cork stdout here to avoid listr from corrupting vims output
135
113
  process.stdout.uncork();
136
114
  try {
137
- await rebaseAndPush;
115
+ await pushPromise;
138
116
  }
139
117
  catch {
140
118
  process.exit(1);
@@ -164,12 +142,12 @@ async function pr(argv) {
164
142
  }
165
143
  };
166
144
  const prTasks = new listr2_1.Listr([], { rendererOptions });
167
- // 06-a. Create a Pull Request
145
+ // 05-a. Create a Pull Request
168
146
  prTasks.add({
169
147
  title: 'Creating Pull Request',
170
148
  task: createPrTask,
171
149
  });
172
- // 06-a. Enable auto merge
150
+ // 05-b. Enable auto merge
173
151
  prTasks.add({
174
152
  enabled: !!argv.autoMerge,
175
153
  title: 'Enabling auto merge',
@@ -181,7 +159,7 @@ async function pr(argv) {
181
159
  const reviewers = await (0, assignees_1.selectAssignee)(repo);
182
160
  const { pr } = await asyncPrTasks;
183
161
  process.stdout.uncork();
184
- // 07. Request reviews
162
+ // 06. Request reviews
185
163
  const reviewRequestTask = new listr2_1.Listr([], { rendererOptions });
186
164
  reviewRequestTask.add({
187
165
  enabled: () => reviewers.length > 0,
@@ -193,6 +171,6 @@ async function pr(argv) {
193
171
  }),
194
172
  });
195
173
  await reviewRequestTask.run();
196
- // 08. Open in browser
174
+ // 07. Open in browser
197
175
  (0, open_1.default)(pr.url);
198
176
  }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.suggestAssignees = suggestAssignees;
7
+ const simple_git_1 = __importDefault(require("simple-git"));
8
+ const blame_1 = require("../blame");
9
+ const config_1 = require("../config");
10
+ const identity_1 = require("../identity");
11
+ const utils_1 = require("../utils");
12
+ async function getDiffSource(commit) {
13
+ const git = (0, simple_git_1.default)();
14
+ if (commit) {
15
+ const diff = await git.raw(['show', '-U0', '--format=', commit]);
16
+ const rev = (await git.revparse([`${commit}^`])).trim();
17
+ return { diff, rev, label: `commit ${commit}` };
18
+ }
19
+ const staged = await git.diff(['-U0', '--cached']);
20
+ if (staged.trim()) {
21
+ return { diff: staged, rev: 'HEAD', label: 'staged changes' };
22
+ }
23
+ const unstaged = await git.diff(['-U0']);
24
+ if (unstaged.trim()) {
25
+ return { diff: unstaged, rev: 'HEAD', label: 'unstaged changes' };
26
+ }
27
+ throw new Error('No staged or unstaged changes to suggest assignees for');
28
+ }
29
+ async function suggestAssignees(argv) {
30
+ const repo = await (0, utils_1.getRepoKey)();
31
+ const selfEmail = (await (0, simple_git_1.default)().raw('config', '--get', 'user.email')).trim();
32
+ // 01. Figure out what we're scoring against (staged, unstaged, or a commit)
33
+ const { diff, rev, label } = await getDiffSource(argv.commit);
34
+ const fileHunks = (0, blame_1.parseDiff)(diff);
35
+ if (fileHunks.length === 0) {
36
+ console.error(`No files with prior history in ${label}`);
37
+ if (argv.format === 'slugs') {
38
+ console.log('');
39
+ }
40
+ else {
41
+ console.log(JSON.stringify({ source: label, fileTotal: 0, hunkTotal: 0, suggestions: [], unresolved: [] }, null, 2));
42
+ }
43
+ return;
44
+ }
45
+ // 02. Blame every file + resolve the repo's assignable user directory
46
+ const [blame, resolver] = await Promise.all([
47
+ (0, blame_1.aggregateBlame)(fileHunks, rev),
48
+ (0, identity_1.buildResolver)(repo),
49
+ ]);
50
+ // 03. Score each author: weighted combination of hunk and file ownership
51
+ const ignoreRegexes = config_1.config.get('ignoreAssignees').map(value => new RegExp(value));
52
+ const emails = new Set([...blame.fileLines.keys(), ...blame.hunkLines.keys()]);
53
+ const scored = [];
54
+ for (const email of emails) {
55
+ if (email.toLowerCase() === selfEmail.toLowerCase()) {
56
+ continue;
57
+ }
58
+ const login = resolver.resolve(email, blame.authorNames.get(email));
59
+ if (login && ignoreRegexes.some(r => r.test(login))) {
60
+ continue;
61
+ }
62
+ const fileLines = blame.fileLines.get(email) ?? 0;
63
+ const hunkLines = blame.hunkLines.get(email) ?? 0;
64
+ const fileShare = blame.fileTotal > 0 ? fileLines / blame.fileTotal : 0;
65
+ const hunkShare = blame.hunkTotal > 0 ? hunkLines / blame.hunkTotal : 0;
66
+ const score = hunkShare * argv.hunkWeight + fileShare * (1 - argv.hunkWeight);
67
+ scored.push({
68
+ email,
69
+ name: blame.authorNames.get(email),
70
+ login,
71
+ fileLines,
72
+ hunkLines,
73
+ fileShare,
74
+ hunkShare,
75
+ score,
76
+ });
77
+ }
78
+ scored.sort((a, b) => b.score - a.score);
79
+ const resolved = scored.filter(s => s.login !== null);
80
+ const unresolved = scored.filter(s => s.login === null);
81
+ const top = resolved.slice(0, argv.limit);
82
+ // 04. Render
83
+ if (argv.format === 'slugs') {
84
+ console.log(top.map(s => s.login).join(','));
85
+ return;
86
+ }
87
+ console.log(JSON.stringify({
88
+ source: label,
89
+ fileTotal: blame.fileTotal,
90
+ hunkTotal: blame.hunkTotal,
91
+ suggestions: top,
92
+ unresolved,
93
+ }, null, 2));
94
+ }
package/dist/fzf.js CHANGED
@@ -2,15 +2,18 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fzfSelect = fzfSelect;
4
4
  const child_process_1 = require("child_process");
5
- async function fzfSelect({ prompt, genValues, }) {
6
- const fzf = (0, child_process_1.spawn)('fzf', [
5
+ async function fzfSelect({ prompt, multi = true, genValues, }) {
6
+ const fzfArgs = [
7
7
  '--ansi',
8
8
  '--height=40%',
9
9
  '--reverse',
10
10
  `--header="${prompt}"`,
11
11
  '--with-nth=2..',
12
- '-m',
13
- ], { shell: true, stdio: ['pipe', 'pipe', 'inherit'] });
12
+ ];
13
+ if (multi) {
14
+ fzfArgs.push('-m');
15
+ }
16
+ const fzf = (0, child_process_1.spawn)('fzf', fzfArgs, { shell: true, stdio: ['pipe', 'pipe', 'inherit'] });
14
17
  fzf.stdin.setDefaultEncoding('utf-8');
15
18
  const options = {};
16
19
  const valuesDone = genValues(option => {
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadAssignableUsers = loadAssignableUsers;
7
+ exports.buildResolver = buildResolver;
8
+ const graphql_request_1 = require("graphql-request");
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ const path_1 = require("path");
11
+ const graphql_1 = require("./graphql");
12
+ function cachePath(repo) {
13
+ const base = process.env.XDG_CACHE_HOME || (0, path_1.join)(process.env.HOME ?? '', '.cache');
14
+ return (0, path_1.join)(base, 'pt', `assignees-${repo.owner}-${repo.repo}.json`);
15
+ }
16
+ async function readCache(repo) {
17
+ try {
18
+ const raw = await promises_1.default.readFile(cachePath(repo), 'utf8');
19
+ return JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ async function writeCache(repo, users) {
26
+ const path = cachePath(repo);
27
+ await promises_1.default.mkdir((0, path_1.join)(path, '..'), { recursive: true });
28
+ await promises_1.default.writeFile(path, JSON.stringify(users));
29
+ }
30
+ /**
31
+ * Fetch every assignable user for a repo. Cached to disk indefinitely; pass
32
+ * `refresh` to force a refetch.
33
+ */
34
+ async function loadAssignableUsers(repo, opts = {}) {
35
+ if (!opts.refresh) {
36
+ const cached = await readCache(repo);
37
+ if (cached) {
38
+ return cached;
39
+ }
40
+ }
41
+ // We don't request the `email` field here — it requires the
42
+ // `user:email` scope, which `gh auth token` doesn't grant by default.
43
+ const query = (0, graphql_request_1.gql) `
44
+ query userAssignees($owner: String!, $repo: String!, $cursor: String) {
45
+ repository(owner: $owner, name: $repo) {
46
+ assignableUsers(first: 100, after: $cursor) {
47
+ nodes {
48
+ login
49
+ name
50
+ }
51
+ pageInfo {
52
+ endCursor
53
+ hasNextPage
54
+ }
55
+ }
56
+ }
57
+ }
58
+ `;
59
+ const pages = (0, graphql_1.paginatedRequest)(query, { ...repo }, (obj) => obj.repository.assignableUsers.pageInfo);
60
+ const users = [];
61
+ for await (const page of pages) {
62
+ for (const node of page.repository.assignableUsers.nodes ?? []) {
63
+ if (!node) {
64
+ continue;
65
+ }
66
+ users.push({ login: node.login, name: node.name ?? null });
67
+ }
68
+ }
69
+ await writeCache(repo, users);
70
+ return users;
71
+ }
72
+ /**
73
+ * Extract the login from a GitHub noreply email.
74
+ *
75
+ * `<id>+<login>@users.noreply.github.com`
76
+ * `<login>@users.noreply.github.com`
77
+ */
78
+ function parseNoreplyLogin(email) {
79
+ const m = email.match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/);
80
+ return m ? m[1] : null;
81
+ }
82
+ /**
83
+ * Strip emoji / punctuation, collapse whitespace, lowercase. Keeps letters
84
+ * from any script plus spaces and hyphens.
85
+ */
86
+ function normalizeName(name) {
87
+ return name
88
+ .replace(/[^\p{L}\p{N}\s-]/gu, ' ')
89
+ .replace(/\s+/g, ' ')
90
+ .trim()
91
+ .toLowerCase();
92
+ }
93
+ function nameTokens(name) {
94
+ return new Set(normalizeName(name)
95
+ .split(/[\s-]+/)
96
+ .filter(t => t.length >= 2));
97
+ }
98
+ /**
99
+ * Build a resolver mapping git author emails to GitHub logins. Tries in
100
+ * order: noreply parse, exact name-to-login, normalized name match,
101
+ * unambiguous token-subset match.
102
+ */
103
+ async function buildResolver(repo) {
104
+ const users = await loadAssignableUsers(repo);
105
+ const loginIndex = new Map();
106
+ const nameIndex = new Map();
107
+ const tokenizedUsers = [];
108
+ for (const u of users) {
109
+ loginIndex.set(u.login.toLowerCase(), u.login);
110
+ if (!u.name) {
111
+ continue;
112
+ }
113
+ const key = normalizeName(u.name);
114
+ if (key) {
115
+ // Mark as ambiguous on collision so we never guess the wrong user.
116
+ nameIndex.set(key, nameIndex.has(key) ? null : u.login);
117
+ }
118
+ const tokens = nameTokens(u.name);
119
+ if (tokens.size > 0) {
120
+ tokenizedUsers.push({ login: u.login, tokens });
121
+ }
122
+ }
123
+ function resolveByTokens(authorName) {
124
+ const authorTokens = nameTokens(authorName);
125
+ if (authorTokens.size < 2) {
126
+ return null;
127
+ }
128
+ const matches = tokenizedUsers.filter(u => [...authorTokens].every(t => u.tokens.has(t)));
129
+ return matches.length === 1 ? matches[0].login : null;
130
+ }
131
+ return {
132
+ resolve(email, authorName) {
133
+ const noreply = parseNoreplyLogin(email);
134
+ if (noreply) {
135
+ return noreply;
136
+ }
137
+ if (!authorName) {
138
+ return null;
139
+ }
140
+ const byLogin = loginIndex.get(authorName.toLowerCase().trim());
141
+ if (byLogin) {
142
+ return byLogin;
143
+ }
144
+ const byName = nameIndex.get(normalizeName(authorName));
145
+ if (byName) {
146
+ return byName;
147
+ }
148
+ return resolveByTokens(authorName);
149
+ },
150
+ };
151
+ }
package/dist/index.js CHANGED
@@ -7,7 +7,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const yargs_1 = __importDefault(require("yargs"));
9
9
  const pr_1 = require("./cmd/pr");
10
+ const pr_create_1 = require("./cmd/pr-create");
10
11
  const select_commit_1 = require("./cmd/select-commit");
12
+ const suggest_assignees_1 = require("./cmd/suggest-assignees");
11
13
  (0, yargs_1.default)(process.argv.slice(2))
12
14
  .option('color', {
13
15
  boolean: true,
@@ -29,6 +31,64 @@ const select_commit_1 = require("./cmd/select-commit");
29
31
  boolean: true,
30
32
  desc: 'Enable auto merge for the PR',
31
33
  }), pr_1.pr)
34
+ .command('pr-create <sha>', 'Non-interactively create or update a PR for a single commit. Body is read from stdin.', y => y
35
+ .positional('sha', {
36
+ type: 'string',
37
+ demandOption: true,
38
+ desc: 'Commit SHA (full or prefix) to publish as a PR',
39
+ })
40
+ .option('title', {
41
+ type: 'string',
42
+ demandOption: true,
43
+ desc: 'PR title',
44
+ })
45
+ .option('reviewer', {
46
+ type: 'string',
47
+ desc: 'Comma-separated list of reviewers (user logins or org/team slugs)',
48
+ })
49
+ .option('draft', {
50
+ alias: 'd',
51
+ boolean: true,
52
+ desc: 'Create PR as a draft',
53
+ })
54
+ .option('autoMerge', {
55
+ alias: 'm',
56
+ boolean: true,
57
+ desc: 'Enable auto merge for the PR',
58
+ })
59
+ .option('updateOnly', {
60
+ boolean: true,
61
+ desc: 'Only update an existing PR; fail if none exists for the generated branch',
62
+ })
63
+ .option('noOpen', {
64
+ boolean: true,
65
+ desc: 'Do not open the PR in the browser after creation',
66
+ }), pr_create_1.prCreate)
67
+ .command('suggest-assignees', 'Suggest reviewers for current diff based on blame ownership', y => y
68
+ .option('commit', {
69
+ type: 'string',
70
+ desc: 'Analyze the files changed in this commit instead of staged changes',
71
+ })
72
+ .option('limit', {
73
+ type: 'number',
74
+ default: 3,
75
+ desc: 'Max number of suggestions',
76
+ })
77
+ .option('format', {
78
+ type: 'string',
79
+ choices: ['slugs', 'json'],
80
+ default: 'slugs',
81
+ desc: 'Output format',
82
+ })
83
+ .option('hunkWeight', {
84
+ type: 'number',
85
+ default: 0.7,
86
+ desc: 'Weight of hunk-level ownership vs whole-file ownership (0..1)',
87
+ })
88
+ .option('refresh', {
89
+ boolean: true,
90
+ desc: 'Ignore the cached assignable-users list and refetch',
91
+ }), suggest_assignees_1.suggestAssignees)
32
92
  .command('select-commit', 'Select a commit hash', select_commit_1.selectCommit)
33
93
  .demandCommand(1, '')
34
94
  .parse();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@evanpurkhiser/tooling-personal",
4
- "version": "1.44.0",
4
+ "version": "1.46.0",
5
5
  "description": "Evan Purkhiser's personal tooling",
6
6
  "license": "MIT",
7
7
  "author": "Evan Purkhiser",
@@ -16,7 +16,7 @@
16
16
  "scripts": {
17
17
  "build": "tsc && chmod +x ./dist/index.js",
18
18
  "start": "ts-node ./src/index.ts",
19
- "lint": "oxlint src/**/*.ts",
19
+ "lint": "oxlint src",
20
20
  "format": "oxfmt .",
21
21
  "format:check": "oxfmt --check ."
22
22
  },