diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97a0af5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# v1.1.0 +## Features +- Assign issues to TODO authors on GitHub + +# v1.0.0 +## Initial release +See [README.md](https://github.com/salsita/todo2issue/blob/v1.0.0/README.md) for usage instructions. diff --git a/README.md b/README.md index 7ffefaf..3c9c0e4 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ At the first run, it: - finds TODOs and FIXMEs in your code - groups them using their text - creates a GitHub issue for each group with a specific label +- optionally assigns a user to the issue based on the author of the label (see `authorsByEmail` configuration option) - references the issue in the code-base like `TODO(#123) Do something` When ran again, it: @@ -56,7 +57,10 @@ Then you should configure behavior of the tool using `todo2issue` property: "**/*.js?(x)" ], "issueLabel": "TODO", - "branch": "develop" + "branch": "develop", + "authorsByEmail": { + "jirist@salsitasoft.com": "goce-cz" + } }, // ... } @@ -71,6 +75,9 @@ Then you should configure behavior of the tool using `todo2issue` property: - this label **MUST NOT** be assigned / unassigned manually - `branch` - name of the branch to be used when referencing code from the generated issues - defaults to active Git branch, but it is highly recommended fixing this in the config +- `authorsByEmail` - mapping between email of a commit author and a GitHub username + - if present, authors of TODOs are assigned to the created issues + - if present, and a mapping for particular email is missing, the sync fails (can be overridden by `--ignore-unresolved-users`) ### GitHub token The tool needs GitHub personal access token to interact with GitHub API on your behalf. diff --git a/package-lock.json b/package-lock.json index 0fdd99a..a964b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "todo2issue", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a622ae6..7728078 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "todo2issue", - "version": "1.0.0", + "version": "1.1.0", "description": "CLI tool for in-code TODO synchronization to GitHub issues", "main": "lib/index.js", "scripts": { @@ -30,12 +30,12 @@ "homepage": "https://github.com/salsita/todo2issue#readme", "dependencies": { "@expo/spawn-async": "^1.5.0", + "@octokit/rest": "^18.12.0", "dotenv": "^10.0.0", "escape-string-regexp": "^4.0.0", "git-url-parse": "^11.6.0", "globby": "^11.0.4", "leasot": "^12.0.0", - "@octokit/rest": "^18.12.0", "read-package-json": "^4.1.1", "tslib": "^2.3.1", "yargs": "^17.2.1" @@ -47,5 +47,15 @@ "jest": "^27.2.5", "ts-jest": "^27.0.6", "typescript": "^4.4.4" + }, + "todo2issue": { + "filePatterns": [ + "sample/**/*.ts?(x)", + "sample/**/*.js?(x)" + ], + "branch": "master", + "authorsByEmail": { + "jirist@salsitasoft.com": "goce-cz" + } } } diff --git a/sample/package.json b/sample/package.json deleted file mode 100644 index c375262..0000000 --- a/sample/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "repository": { - "type": "git", - "url": "git+https://github.com/salsita/todo2issue.git" - }, - "todo2issue": { - "filePatterns": [ - "**/*.ts?(x)", - "**/*.js?(x)" - ], - "branch": "master" - } -} diff --git a/src/__snapshots__/config.spec.ts.snap b/src/__snapshots__/config.spec.ts.snap index 707d175..b88c185 100644 --- a/src/__snapshots__/config.spec.ts.snap +++ b/src/__snapshots__/config.spec.ts.snap @@ -2,10 +2,13 @@ exports[`readRepositoryConfig reads 1`] = ` Object { + "authorsByEmail": Object { + "jirist@salsitasoft.com": "goce-cz", + }, "branch": "master", "filePatterns": Array [ - "**/*.ts?(x)", - "**/*.js?(x)", + "sample/**/*.ts?(x)", + "sample/**/*.js?(x)", ], "issueLabel": "TODO", "repo": Object { diff --git a/src/__snapshots__/find-authors.spec.ts.snap b/src/__snapshots__/find-authors.spec.ts.snap new file mode 100644 index 0000000..1a4bfba --- /dev/null +++ b/src/__snapshots__/find-authors.spec.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`findAuthors finds authors 1`] = ` +Array [ + Object { + "author": "goce-cz", + "filename": "sample/src/index.ts", + "line": 1, + "tag": "TODO", + "text": "A", + }, + Object { + "author": "goce-cz", + "filename": "sample/src/index.ts", + "line": 4, + "tag": "TODO", + "text": "B", + }, + Object { + "author": "goce-cz", + "filename": "sample/src/helpers/helper.ts", + "line": 1, + "tag": "TODO", + "text": "C", + }, + Object { + "author": "goce-cz", + "filename": "sample/src/index.ts", + "line": 16, + "tag": "TODO", + "text": "line comments", + }, +] +`; diff --git a/src/__snapshots__/generate-issue-body.spec.ts.snap b/src/__snapshots__/generate-issue-body.spec.ts.snap new file mode 100644 index 0000000..565062d --- /dev/null +++ b/src/__snapshots__/generate-issue-body.spec.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`generateIssueBody generates description of issue 1`] = ` +"--- +_This issue origins from a TODO within the code-base and was synchronized automatically._ + +## Occurrences + +### \`sample/src/index.ts\` + +- [line 1](https://github.com/salsita/todo2issue/blob/master/sample/src/index.ts#L1) - Single line todo comment +- [line 2](https://github.com/salsita/todo2issue/blob/master/sample/src/index.ts#L2) - Single line todo comment with reference + +---" +`; diff --git a/src/__snapshots__/git.spec.ts.snap b/src/__snapshots__/git.spec.ts.snap new file mode 100644 index 0000000..a925941 --- /dev/null +++ b/src/__snapshots__/git.spec.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`parseBlameInfo parses blame info 1`] = ` +Array [ + Object { + "author": Object { + "email": "jirist@salsitasoft.com", + "name": "Jiří Staniševský", + "time": 1634279203, + "timeZone": "+0200", + }, + "commitHash": "aa73a7deb88f1487c0006abcd3b684bf91c2266e", + "committer": Object { + "email": "", + "name": "Jiří Staniševský", + "time": 1634279340, + "timeZone": "+0200", + }, + "filename": "sample/src/index.ts", + "lineEnd": 1, + "lineStart": 1, + "summary": "Add sample \\"code-base\\"", + "text": Array [ + " // TODO Single line todo comment", + ], + }, + Object { + "author": Object { + "email": "jirist@salsitasoft.com", + "name": "Jiří Staniševský", + "time": 1634279489, + "timeZone": "+0200", + }, + "commitHash": "be1ec0218067e7152167afaf1de0f84e6c55fd43", + "committer": Object { + "email": "", + "name": "Jiří Staniševský", + "time": 1634281973, + "timeZone": "+0200", + }, + "filename": "sample/src/index.ts", + "lineEnd": 4, + "lineStart": 4, + "summary": "Scanning WiP", + "text": Array [ + " // TODO(#123) Same reference different comment", + ], + }, +] +`; diff --git a/src/__snapshots__/group-todos-to-issues.spec.ts.snap b/src/__snapshots__/group-todos-to-issues.spec.ts.snap new file mode 100644 index 0000000..92132d3 --- /dev/null +++ b/src/__snapshots__/group-todos-to-issues.spec.ts.snap @@ -0,0 +1,153 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`groupTodosToIssues groups 1`] = ` +Array [ + Object { + "issueNumber": 123, + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": 123, + "line": 2, + "tag": "TODO", + "text": "Single line todo comment with reference", + }, + Object { + "filename": "src/index.ts", + "issueNumber": 123, + "line": 4, + "tag": "TODO", + "text": "Same reference different comment", + }, + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 5, + "tag": "TODO", + "text": "Same reference different comment", + }, + ], + }, + Object { + "issueNumber": 124, + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": 124, + "line": 12, + "tag": "TODO", + "text": "Multiline todo", + }, + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 7, + "tag": "TODO", + "text": "Multiline todo", + }, + Object { + "filename": "src/helpers/helper.ts", + "issueNumber": undefined, + "line": 4, + "tag": "TODO", + "text": "Multiline todo", + }, + ], + }, + Object { + "issueNumber": 125, + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": 125, + "line": 19, + "tag": "TODO", + "text": "Multiple single", + }, + Object { + "filename": "src/helpers/helper.ts", + "issueNumber": 125, + "line": 7, + "tag": "TODO", + "text": "Related issue", + }, + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 15, + "tag": "TODO", + "text": "Multiple single", + }, + ], + }, + Object { + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 1, + "tag": "TODO", + "text": "Single line todo comment", + }, + ], + }, + Object { + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 16, + "tag": "TODO", + "text": "line comments", + }, + ], + }, + Object { + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 20, + "tag": "TODO", + "text": "line comments with reference", + }, + ], + }, + Object { + "todos": Array [ + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 22, + "tag": "TODO", + "text": "With colon", + }, + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 24, + "tag": "TODO", + "text": "With colon", + }, + Object { + "filename": "src/index.ts", + "issueNumber": undefined, + "line": 26, + "tag": "TODO", + "text": "With colon", + }, + ], + }, + Object { + "todos": Array [ + Object { + "filename": "src/helpers/helper.ts", + "issueNumber": undefined, + "line": 1, + "tag": "TODO", + "text": "This one is from a sub-folder", + }, + ], + }, +] +`; diff --git a/src/__snapshots__/scan-for-todos.spec.ts.snap b/src/__snapshots__/scan-for-todos.spec.ts.snap index 7aeb426..b36078c 100644 --- a/src/__snapshots__/scan-for-todos.spec.ts.snap +++ b/src/__snapshots__/scan-for-todos.spec.ts.snap @@ -3,91 +3,91 @@ exports[`scanForTodos finds all TODOs 1`] = ` Array [ Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 1, "tag": "TODO", "text": "Single line todo comment", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": 123, "line": 2, "tag": "TODO", "text": "Single line todo comment with reference", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": 123, "line": 4, "tag": "TODO", "text": "Same reference different comment", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 5, "tag": "TODO", "text": "Same reference different comment", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 7, "tag": "TODO", "text": "Multiline todo", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": 124, "line": 12, "tag": "TODO", "text": "Multiline todo", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 15, "tag": "TODO", "text": "Multiple single", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 16, "tag": "TODO", "text": "line comments", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": 125, "line": 19, "tag": "TODO", "text": "Multiple single", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 20, "tag": "TODO", "text": "line comments with reference", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 22, "tag": "TODO", "text": "With colon", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 24, "tag": "TODO", "text": "With colon", }, Object { - "filename": "src/index.ts", + "filename": "sample/src/index.ts", "issueNumber": undefined, "line": 26, "tag": "TODO", diff --git a/src/__snapshots__/sync-with-github.spec.ts.snap b/src/__snapshots__/sync-with-github.spec.ts.snap index 6446a0a..09a8bd1 100644 --- a/src/__snapshots__/sync-with-github.spec.ts.snap +++ b/src/__snapshots__/sync-with-github.spec.ts.snap @@ -6,7 +6,7 @@ Array [ { \\"todos\\": [ { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 1, \\"tag\\": \\"TODO\\", \\"text\\": \\"Single line todo comment\\" @@ -17,7 +17,7 @@ Array [ { \\"todos\\": [ { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 16, \\"tag\\": \\"TODO\\", \\"text\\": \\"line comments\\" @@ -28,7 +28,7 @@ Array [ { \\"todos\\": [ { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 20, \\"tag\\": \\"TODO\\", \\"text\\": \\"line comments with reference\\" @@ -39,19 +39,19 @@ Array [ { \\"todos\\": [ { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 22, \\"tag\\": \\"TODO\\", \\"text\\": \\"With colon\\" }, { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 24, \\"tag\\": \\"TODO\\", \\"text\\": \\"With colon\\" }, { - \\"filename\\": \\"src/index.ts\\", + \\"filename\\": \\"sample/src/index.ts\\", \\"line\\": 26, \\"tag\\": \\"TODO\\", \\"text\\": \\"With colon\\" @@ -62,7 +62,7 @@ Array [ { \\"todos\\": [ { - \\"filename\\": \\"src/helpers/helper.ts\\", + \\"filename\\": \\"sample/src/helpers/helper.ts\\", \\"line\\": 1, \\"tag\\": \\"TODO\\", \\"text\\": \\"This one is from a sub-folder\\" diff --git a/src/bin.ts b/src/bin.ts index 1fd3a04..878bb50 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -9,6 +9,7 @@ import { updateReferences } from './update-references' import { readConfig } from './config' import { RestGithubClient } from './github' import { WriteMockGithubClient } from './mock-github' +import { findAuthors } from './find-authors' async function run (args: string[]) { const { argv } = yargs(args).options({ @@ -27,16 +28,32 @@ async function run (args: string[]) { default: false, alias: 'dry-run', description: 'simulate GitHub operations; generates new issue numbers locally' + }, + 'o': { + type: 'boolean', + alias: 'overwrite', + description: 'overwrite existing issues (body and assignments)' + }, + 'i': { + type: 'boolean', + alias: 'ignore-unresolved-authors', + description: 'ignore authors that can be resolved using the mapping in `package.json`' } }) const token = argv['token'] const root = argv['root'] ?? process.cwd() const dryRun = argv['dry-run'] - const overwriteBody = argv['overwrite-body'] + const overwrite = argv['overwrite'] + const ignoreUnresolvedAuthors = argv['ignore-unresolved-authors'] const config = await readConfig(root, token) const files = await findFiles(root, config.filePatterns) const todos = await scanForTodos(root, files) + + if (config.authorsByEmail) { + await findAuthors(root, todos, config.authorsByEmail, ) + } + const issues = groupTodosToIssues(todos) const githubClient = new RestGithubClient(config.repo, config.githubToken) @@ -48,7 +65,8 @@ async function run (args: string[]) { config.repo, config.issueLabel, config.branch, - overwriteBody + !!config.authorsByEmail, + overwrite ) } catch (e) { console.error(e) diff --git a/src/config.spec.ts b/src/config.spec.ts index 140839c..b32c1f8 100644 --- a/src/config.spec.ts +++ b/src/config.spec.ts @@ -3,7 +3,7 @@ import { resolve } from 'path' describe('readRepositoryConfig', () => { it('reads', async () => { - const { githubToken, ...rest } = await readConfig(resolve(__dirname, '../sample')) + const { githubToken, ...rest } = await readConfig(resolve(__dirname, '../')) expect(githubToken?.length).toBeGreaterThan(5) expect(rest).toMatchSnapshot() }) diff --git a/src/config.ts b/src/config.ts index e545269..71c11b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,7 @@ export interface Config { repo: GitRepository issueLabel: string branch: string + authorsByEmail?: Record } const defaultConfig: Partial = { @@ -33,7 +34,7 @@ async function readGithubToken (root: string): Promise { } const envFile = resolve(root, '.env') const dotenv = existsSync(envFile) && parseDotenv(await readFile(envFile)) - return dotenv?.GITHUB_TOKEN + return (dotenv && dotenv.GITHUB_TOKEN) || undefined } export async function readConfig (root: string, githubTokenOverride?: string): Promise { @@ -47,7 +48,8 @@ export async function readConfig (root: string, githubTokenOverride?: string): P todo2issue: { issueLabel = defaultConfig.issueLabel, filePatterns = defaultConfig.filePatterns, - branch = await getCurrentBranchName(root) + branch = await getCurrentBranchName(root), + authorsByEmail } = defaultConfig, } = await readJsonAsync(resolve(root, 'package.json')) @@ -74,6 +76,7 @@ export async function readConfig (root: string, githubTokenOverride?: string): P branch, filePatterns, issueLabel, + authorsByEmail, repo: { name, owner } } } diff --git a/src/find-authors.spec.ts b/src/find-authors.spec.ts new file mode 100644 index 0000000..be776b1 --- /dev/null +++ b/src/find-authors.spec.ts @@ -0,0 +1,38 @@ +import { resolve } from 'path' +import { Todo } from './model' +import { findAuthors } from './find-authors' + +describe('findAuthors', () => { + it('finds authors', async () => { + + const root = resolve(__dirname, '../') + const todos: Todo[] = [ + { + line: 1, + text: 'A', + tag: 'TODO', + filename: 'sample/src/index.ts' + }, + { + line: 4, + text: 'B', + tag: 'TODO', + filename: 'sample/src/index.ts' + }, + { + line: 1, + text: 'C', + tag: 'TODO', + filename: 'sample/src/helpers/helper.ts' + }, + { + line: 16, + text: 'line comments', + tag: 'TODO', + filename: 'sample/src/index.ts' + } + ] + await findAuthors(root, todos, {'jirist@salsitasoft.com': 'goce-cz'}) + expect(todos).toMatchSnapshot() + }) +}) diff --git a/src/find-authors.ts b/src/find-authors.ts new file mode 100644 index 0000000..94262f1 --- /dev/null +++ b/src/find-authors.ts @@ -0,0 +1,35 @@ +import { groupTodosByFile, Todo } from './model' +import { blameFile } from './git' + +const range = (start: number, endInclusive: number) => new Array(endInclusive - start + 1).fill(null).map((_, index) => start + index) + +export async function findAuthors (root: string, todos: Todo[], authorsByEmail: Record, ignoreUnresolvedAuthors: boolean = false) { + const todosByFile = groupTodosByFile(todos) + const missingAuthorEmails = new Set() + for (const [file, fileTodos] of todosByFile.entries()) { + const blameInfos = await blameFile(root, file, fileTodos.map(todo => todo.line)) + const blamesPerLine = new Map( + blameInfos.flatMap(blameInfo => + range(blameInfo.lineStart, blameInfo.lineEnd).map(line => [line, blameInfo]) + ) + ) + todos.forEach(todo => { + const blameInfo = blamesPerLine.get(todo.line) + if(blameInfo && blameInfo.author.email !== 'not.committed.yet') { + todo.author = authorsByEmail[blameInfo.author.email] + if(!todo.author) { + missingAuthorEmails.add(blameInfo.author.email) + } + } + }) + } + + if(missingAuthorEmails.size > 0) { + const message = `cannot resolve GitHub users from emails:\n\t${Array.from(missingAuthorEmails).join('\n\t')}` + if(ignoreUnresolvedAuthors) { + console.warn(message) + } else { + throw new Error(message) + } + } +} diff --git a/src/generate-issue-body.spec.ts b/src/generate-issue-body.spec.ts index abbd64b..f19cf9c 100644 --- a/src/generate-issue-body.spec.ts +++ b/src/generate-issue-body.spec.ts @@ -5,7 +5,7 @@ import { generateIssueBody } from './generate-issue-body' describe('generateIssueBody', () => { it('generates description of issue', async () => { - const config = await readConfig(resolve(__dirname, '../sample')) + const config = await readConfig(resolve(__dirname, '../')) const body = generateIssueBody({ todos: [ { diff --git a/src/git.spec.ts b/src/git.spec.ts index dea9b1d..4ad1ee6 100644 --- a/src/git.spec.ts +++ b/src/git.spec.ts @@ -1,10 +1,71 @@ -import { getCurrentBranchName } from './git' +import { getCurrentBranchName, parseBlameInfo } from './git' import { resolve } from 'path' describe('getCurrentBranchName', () => { it('gets something', async () => { - const branchName = await getCurrentBranchName(resolve(__dirname,'../sample')) + const branchName = await getCurrentBranchName(resolve(__dirname, '../')) expect(typeof branchName).toBe('string') expect(branchName.includes(' ')).toBe(false) }) }) + +describe('parseBlameInfo', () => { + it('parses blame info', async () => { + const mockBlameOutput = `aa73a7deb88f1487c0006abcd3b684bf91c2266e 1 1 2 +author Jiří Staniševský +author-mail +author-time 1634279203 +author-tz +0200 +committer Jiří Staniševský +committer-mail +committer-time 1634279340 +committer-tz +0200 +summary Add sample "code-base" +boundary +filename sample/src/index.ts +\t// TODO Single line todo comment +aa73a7deb88f1487c0006abcd3b684bf91c2266e 2 2 +author Jiří Staniševský +author-mail +author-time 1634279203 +author-tz +0200 +committer Jiří Staniševský +committer-mail +committer-time 1634279340 +committer-tz +0200 +summary Add sample "code-base" +boundary +filename sample/src/index.ts +\t// TODO(#123) Single line todo comment with reference +be1ec0218067e7152167afaf1de0f84e6c55fd43 4 4 2 +author Jiří Staniševský +author-mail +author-time 1634279489 +author-tz +0200 +committer Jiří Staniševský +committer-mail +committer-time 1634281973 +committer-tz +0200 +summary Scanning WiP +previous aa73a7deb88f1487c0006abcd3b684bf91c2266e sample/src/index.ts +filename sample/src/index.ts +\t// TODO(#123) Same reference different comment +be1ec0218067e7152167afaf1de0f84e6c55fd43 5 5 +author Jiří Staniševský +author-mail +author-time 1634279489 +author-tz +0200 +committer Jiří Staniševský +committer-mail +committer-time 1634281973 +committer-tz +0200 +summary Scanning WiP +previous aa73a7deb88f1487c0006abcd3b684bf91c2266e sample/src/index.ts +filename sample/src/index.ts +\t// TODO Same reference different comment` + + expect(parseBlameInfo(mockBlameOutput)).toMatchSnapshot() + }) +}) + + diff --git a/src/git.ts b/src/git.ts index 6494557..1fbcceb 100644 --- a/src/git.ts +++ b/src/git.ts @@ -6,3 +6,114 @@ export async function getCurrentBranchName (root: string) { }) return stdout.trim() } + +export interface Contribution { + name: string + email: string + time: number + timeZone: string +} + +export interface BlameInfo { + commitHash: string + filename: string + lineStart: number + lineEnd: number + author: Contribution + committer: Contribution + summary: string + text: string[] +} + +const leadingLinePattern = /^([a-z0-9]{40}) ([0-9]+) ([0-9]+) ([0-9]+)$/ +const keyValuePattern = /^([a-z\-]+) (.+)$/ + +const unwrapEmail = (email: string) => (/^<(.+)>$/.exec(email) ?? [undefined, email] as const)[1] + +export function parseBlameInfo (blameOutput: string): BlameInfo[] { + const results: BlameInfo[] = [] + let lastInfo: BlameInfo | null = null + for (const line of blameOutput.split('\n')) { + const [, commitHash, lineStart, lineEnd] = leadingLinePattern.exec(line) ?? [] + if (commitHash) { + if (lastInfo != null) { + results.push(lastInfo as BlameInfo) + } + lastInfo = { + commitHash, + lineStart: Number(lineStart), + lineEnd: Number(lineEnd), + text: [], + author: {} as Contribution, + committer: {} as Contribution, + filename: undefined, + summary: '' + } + continue + } + + if (lastInfo == null) { + continue + } + + if (lastInfo.filename != undefined) { + if (lastInfo.text.length <= (lastInfo.lineEnd - lastInfo.lineStart)) { + lastInfo.text.push(line) + } + continue + } + + const [, key, value] = keyValuePattern.exec(line) ?? [] + switch (key) { + case 'author': + lastInfo.author.name = value + break + case 'author-mail': + lastInfo.author.email = unwrapEmail(value) + break + case 'author-time': + lastInfo.author.time = Number(value) + break + case 'author-tz': + lastInfo.author.timeZone = value + break + case 'committer': + lastInfo.committer.name = unwrapEmail(value) + break + case 'committer-mail': + lastInfo.committer.email = value + break + case 'committer-time': + lastInfo.committer.time = Number(value) + break + case 'committer-tz': + lastInfo.committer.timeZone = value + break + case 'summary': + lastInfo.summary = value + break + case 'filename': + lastInfo.filename = value + break + } + } + + if (lastInfo != null) { + results.push(lastInfo) + } + + return results +} + +export async function blameFile (root: string, filename: string, lines: number[]) { + const args = [ + 'blame', + filename, + ...lines.flatMap(line => ['-L', `${line},${line}`]), + '--line-porcelain' + ] + const { stdout } = await spawnAsync('git', args, { + cwd: root + }) + return parseBlameInfo(stdout.trim()) +} diff --git a/src/github.spec.ts b/src/github.spec.ts index 67b3073..15ddd1d 100644 --- a/src/github.spec.ts +++ b/src/github.spec.ts @@ -1,6 +1,8 @@ import { RestGithubClient } from './github' import { readConfig } from './config' import { resolve } from 'path' +import { collectAssignees } from './sync-with-github' +import { Issue } from './model' describe('listOpenTodoIssueNumbers', () => { it.skip('lists issues', async () => { @@ -14,7 +16,7 @@ describe('listOpenTodoIssueNumbers', () => { describe('createIssue', () => { it.skip('create issue', async () => { - const config = await readConfig(resolve(__dirname, '../sample')) + const config = await readConfig(resolve(__dirname, '../')) const client = new RestGithubClient(config.repo, config.githubToken) const issueNumber = await client.createIssue({ todos: [ @@ -31,7 +33,35 @@ describe('createIssue', () => { 'text': 'Single line todo comment with reference' } ] - }, config.issueLabel, 'master') + }, config.issueLabel, 'master', undefined) + console.log(issueNumber) + }) +}) + +describe('updateIssue', () => { + it.skip('update issue', async () => { + const config = await readConfig(resolve(__dirname, '../')) + const client = new RestGithubClient(config.repo, config.githubToken) + const issue: Issue = { + issueNumber: 19, + todos: [ + { + filename: 'sample/src/index.ts', + line: 1, + tag: 'TODO', + text: 'Single line todo comment', + author: 'goce-cz' + }, + { + filename: 'sample/src/index.ts', + line: 2, + tag: 'TODO', + text: 'Single line todo comment with reference', + author: 'goce-cz' + } + ] + } + const issueNumber = await client.updateIssue(issue, 'master', collectAssignees(issue.todos)) console.log(issueNumber) }) }) diff --git a/src/github.ts b/src/github.ts index a0d19a4..7c77a88 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,4 +1,4 @@ -import { groupTodosByFile, Issue } from './model' +import { Issue } from './model' import { Octokit } from '@octokit/rest' import { GitRepository } from './config' @@ -8,13 +8,13 @@ export interface PartialGithubIssue { } export interface GithubClient { - createIssue (issue: Issue, issueLabel: string, body: string): Promise + createIssue (issue: Issue, issueLabel: string, body: string, assignees: string[] | undefined): Promise - updateIssue (issueNumber: number, body: string): Promise + updateIssue (issue: Issue, body: string, assignees: string[] | undefined): Promise listOpenTodoIssues (issueLabel: string): Promise - closeIssue (issueNumber: number) + closeIssue (issueNumber: number): Promise } export class RestGithubClient implements GithubClient { @@ -36,25 +36,26 @@ export class RestGithubClient implements GithubClient { }) } - async createIssue (issue: Issue, issueLabel: string, body: string): Promise { + async createIssue (issue: Issue, issueLabel: string, body: string, assignees: string[] | undefined): Promise { const { data } = await this.octokit.rest.issues.create({ owner: this.repo.owner, repo: this.repo.name, title: issue.todos[0].text, labels: [issueLabel], + assignees, body }) return data.number } - async updateIssue (issueNumber: number, body: string): Promise { - const { data } = await this.octokit.rest.issues.update({ - issue_number: issueNumber, + async updateIssue (issue: Issue, body: string, assignees: string[] | undefined): Promise { + await this.octokit.rest.issues.update({ + issue_number: issue.issueNumber, owner: this.repo.owner, repo: this.repo.name, + assignees, body }) - return data.number } async listOpenTodoIssues (issueLabel: string): Promise { @@ -66,12 +67,12 @@ export class RestGithubClient implements GithubClient { const { data } = await this.octokit.rest.issues.listForRepo({ owner: this.repo.owner, repo: this.repo.name, - labels: [issueLabel], + labels: issueLabel, per_page: 100, page }) lastPageSize = data.length - partialIssues.push(...data.map(({ number, body }) => ({ issueNumber: number, body }))) + partialIssues.push(...data.map(({ number, body }) => ({ issueNumber: number, body: body ?? '' }))) } return partialIssues diff --git a/src/group-todos-to-issues.ts b/src/group-todos-to-issues.ts index 0f947f6..133f2ce 100644 --- a/src/group-todos-to-issues.ts +++ b/src/group-todos-to-issues.ts @@ -6,10 +6,10 @@ export function groupTodosToIssues(todos: Todo[]): Issue[] { todos .filter((todo) => !!todo.issueNumber) .forEach((todo) => { - let issue = issuesByNumber.get(todo.issueNumber) + let issue = issuesByNumber.get(todo.issueNumber!) if (!issue) { issue = { todos: [], issueNumber: todo.issueNumber } - issuesByNumber.set(todo.issueNumber, issue) + issuesByNumber.set(todo.issueNumber!, issue) issues.push(issue) } diff --git a/src/mock-github.ts b/src/mock-github.ts index 93b3ddd..8195589 100644 --- a/src/mock-github.ts +++ b/src/mock-github.ts @@ -17,8 +17,8 @@ export class MockGithubClient implements GithubClient { return issueNumber } - async updateIssue (issueNumber: number, body: string): Promise { - this.log.push(`updated issue #${issueNumber}`) + async updateIssue (issue: Issue, body: string): Promise { + this.log.push(`updated issue #${issue.issueNumber}`) } async listOpenTodoIssues (issueLabel: string): Promise { diff --git a/src/model.ts b/src/model.ts index 7ed9e42..7fbf3ca 100644 --- a/src/model.ts +++ b/src/model.ts @@ -4,11 +4,13 @@ export interface Todo { text: string tag: string issueNumber?: number + author?: string } export interface Issue { todos: Todo[] issueNumber?: number + assignees?: string[] } diff --git a/src/scan-for-todos.spec.ts b/src/scan-for-todos.spec.ts index 7b7b389..6c6ab52 100644 --- a/src/scan-for-todos.spec.ts +++ b/src/scan-for-todos.spec.ts @@ -3,8 +3,7 @@ import { resolve } from 'path' describe('scanForTodos', () => { it('finds all TODOs', async () => { - const todos = await scanForTodos(resolve(__dirname, '../sample'), ['src/index.ts']) - console.log(JSON.stringify(todos, null, 2)) + const todos = await scanForTodos(resolve(__dirname, '../'), ['sample/src/index.ts']) expect(todos).toMatchSnapshot() }) }) diff --git a/src/sync-with-github.spec.ts b/src/sync-with-github.spec.ts index e5cc841..64347ca 100644 --- a/src/sync-with-github.spec.ts +++ b/src/sync-with-github.spec.ts @@ -8,7 +8,7 @@ import { readConfig } from './config' describe('syncWithGithub', () => { it('syncs', async () => { - const root = resolve(__dirname, '../sample') + const root = resolve(__dirname, '../') const config = await readConfig(root) const files = await findFiles(root, config.filePatterns) const todos = await scanForTodos(root, files) @@ -19,7 +19,7 @@ describe('syncWithGithub', () => { { issueNumber: 125, body: 'Completely else' }, { issueNumber: 126, body: 'Different league' } ]) - await syncWithGitHub(issues, mockGithubClient, config.repo, 'TODO', 'master') + await syncWithGitHub(issues, mockGithubClient, config.repo, 'TODO', 'master', false) expect(mockGithubClient.log).toMatchSnapshot() }) }) diff --git a/src/sync-with-github.ts b/src/sync-with-github.ts index d265fcd..0527022 100644 --- a/src/sync-with-github.ts +++ b/src/sync-with-github.ts @@ -1,16 +1,21 @@ -import { Issue } from './model' +import { Issue, Todo } from './model' import { GithubClient } from './github' import { generateIssueBody } from './generate-issue-body' import { GitRepository } from './config' import { createBody, updateBody } from './update-issue-body' +export const collectAssignees = (todos: Todo[]) => Array.from( + new Set(todos.map(todo => todo.author).filter(Boolean)) +) + export async function syncWithGitHub ( issues: Issue[], githubClient: GithubClient, repo: GitRepository, issueLabel: string, branch: string, - overwriteBody: boolean = false + assignAuthors: boolean, + overwrite: boolean = false ) { const issuesToUpdate = issues.filter(issue => issue.issueNumber !== undefined) const issuesToCreate = issues.filter(issue => issue.issueNumber === undefined) @@ -18,7 +23,8 @@ export async function syncWithGitHub ( for (const issue of issuesToCreate) { const generatedContent = generateIssueBody(issue, repo, branch) const body = createBody(generatedContent) - issue.issueNumber = await githubClient.createIssue(issue, issueLabel, body) + const assignees = (assignAuthors && collectAssignees(issue.todos)) || undefined + issue.issueNumber = await githubClient.createIssue(issue, issueLabel, body, assignees) console.log(`created issue #${issue.issueNumber}`) } @@ -26,9 +32,9 @@ export async function syncWithGitHub ( const existingIssuesByNumber = new Map(existingIssues.map(issue => [issue.issueNumber, issue])) for (const issue of issuesToUpdate) { - const existingIssue = existingIssuesByNumber.get(issue.issueNumber) + const existingIssue = existingIssuesByNumber.get(issue.issueNumber!) if (!existingIssue) { - console.warn(`failed to update issue #${issue.issueNumber}, because it isn't present in GitHub or the '${issueLabel}' has been removed, consider removing the TODO`) + console.warn(`failed to update issue #${issue.issueNumber}, because it isn't present in GitHub or the '${issueLabel}' label has been removed, consider removing the TODO`) continue } @@ -36,19 +42,23 @@ export async function syncWithGitHub ( const { updated, body - } = overwriteBody + } = overwrite ? { updated: true, body: createBody(newGeneratedContent) } : updateBody(existingIssue.body, newGeneratedContent) if (!updated) { continue } - - await githubClient.updateIssue(issue.issueNumber, body) + const assignees = ( + overwrite && // resetting assignments should not normally happen + assignAuthors && + collectAssignees(issue.todos) + ) || undefined + await githubClient.updateIssue(issue, body, assignees) console.log(`updated issue #${issue.issueNumber}`) } - issues.forEach(issue => existingIssuesByNumber.delete(issue.issueNumber)) + issues.forEach(issue => existingIssuesByNumber.delete(issue.issueNumber!)) for (const obsoleteIssueNumber of existingIssuesByNumber.keys()) { await githubClient.closeIssue(obsoleteIssueNumber) console.log(`closed issue #${obsoleteIssueNumber}`)