[
  {
    "path": ".github/workflows/push-process-todo-comments.yml",
    "content": "on:\n  push:\n    branches:\n      - master\nname: Process TODO comments\njobs:\n  collectTODO:\n    name: Collect TODO\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@master\n    - name: Collect TODO\n      uses: ./\n      env:\n        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        TODO_ACTIONS_MONGO_URL: ${{ secrets.TODO_ACTIONS_MONGO_URL }}\n"
  },
  {
    "path": ".github/workflows/push-test.yml",
    "content": "on: push\nname: Test\njobs:\n  yarnInstall:\n    name: yarn install\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@master\n    - name: yarn install\n      uses: Borales/actions-yarn@7f2a9167277e57a749fc97441aec0056d2b13948\n      with:\n        args: install\n    - name: typecheck\n      uses: Borales/actions-yarn@7f2a9167277e57a749fc97441aec0056d2b13948\n      with:\n        args: tsc\n    - name: test\n      uses: Borales/actions-yarn@7f2a9167277e57a749fc97441aec0056d2b13948\n      with:\n        args: test\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# TypeScript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# next.js build output\n.next\n/lib/"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"singleQuote\": true,\n  \"semi\": false,\n  \"trailingComma\": \"all\"\n}\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:12.6.0\n\nLABEL \"com.github.actions.name\"=\"todo-actions\"\nLABEL \"com.github.actions.description\"=\"Convert TODO comments into issues\"\nLABEL \"com.github.actions.icon\"=\"alert-circle\"\nLABEL \"com.github.actions.color\"=\"gray-dark\"\n\nLABEL \"repository\"=\"http://github.com/dtinth/todo-actions\"\nLABEL \"homepage\"=\"http://github.com/dtinth/todo-actions\"\nLABEL \"maintainer\"=\"dtinth <dtinth@spacet.me>\"\n\nENV GIT_COMMITTER_NAME=TODO\nENV GIT_AUTHOR_NAME=TODO\nENV EMAIL=todo-actions[bot]@users.noreply.github.com\n\nRUN mkdir -p /app\nADD entrypoint.sh package.json tsconfig.json yarn.lock /app/\nRUN cd /app && yarn --frozen-lockfile\nADD src /app/src\nRUN cd /app && yarn build\nENTRYPOINT [\"/app/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 Thai Pangsakulyanont\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# todo-actions\n\nTurn TODO comments inside source code into GitHub issues and closes them when they are gone. Runs on GitHub Actions. This project is hugely inspired by [0pdd](https://www.yegor256.com/2017/04/05/pdd-in-action.html).\n\n## Features\n\n- Turns TODO comments into GitHub issues.\n\n  A TODO comment looks like this:\n\n  ```js\n  // TODO: Add integration test for TodoActionsMain.\n  //\n  // Code that interface with external data have been separated into their own modules.\n  // These includes:\n  //\n  // - `DataStore`\n  // - `CodeRepository`\n  // - `TaskManagementSystem`\n  //\n  // They can be mocked by creating a mock version using `__mocks__` folder.\n  // https://jestjs.io/docs/en/manual-mocks\n  ```\n\n  …and it gets turned into an issue like this:\n\n  > [<img src=\"./docs/images/issue.png\" width=\"782\" alt=\"Screenshot\" />](https://github.com/dtinth/todo-actions/issues/35)\n\n  The first line is the title. The rest becomes the issue body.\n\n- The GitHub issue is updated whenever the text inside the TODO comment changes.\n  This allows elaboration and collaboration on TODO comments.\n\n- Once the TODO comment is removed, the corresponding issue is automatically closed.\n  This allows fine-grained task management, and also allows new contributors to easily contribute to the code base.\n\n  > <img src=\"./docs/images/pulse.png\" width=\"740\" alt=\"Screenshot\" />\n\n  As a case study, when we [used](https://wonderful.software/elect-live/pdd/) the [0pdd](./docs/images/elect-live-example.png) tool on [codeforthailand/election-live](https://github.com/codeforthailand/election-live) project, it helped us attract 20+ contributors and visualized the work that got done in just 7 days:\n\n  > <img src=\"./docs/images/elect-live-pdd.png\" width=\"740\" alt=\"Screenshot\" />\n\n## Usage\n\n### Before You Start\n\n**Before you begin, you'll need a running MongoDB instance** This action uses MongoDB to keep track of TODO comments and their associated issues.\n\nYou can get a free instance on [MongoDB Atlas](https://www.mongodb.com/cloud/atlas). The same MongoDB database can be used with multiple repositories.\n\n1. Once you have a MongoDB instance running, you need to get a URL (known as a “connection string on MongoDB’s Cloud service) to connect to your database. Follow [MongoDB’s instructions](https://docs.atlas.mongodb.com/connect-to-cluster/) for how to connect to a cluster.\n2. Once you have the connection string, copy it and go to your repository’s “Settings” tab, then to “Secrets”\n   > ![Screenshot of a repository’s “Secrets” page, inside the Settings tab.](./docs/images/github_secrets_screenshot.png)\n3. Click “Add a new secret”, give it the name TODO_ACTIONS_MONGO_URL, and paste in the MongoDB connection sctring.\n\n### Setting up\n\n1. In the repository where you want to set up this action, click the “Actions” tab\n\n   > <img src=\"./docs/images/install1.png\" alt=\"Screenshot of a repository's navigation tabs, with “Actions” highlighted\" />\n\n2. On the Actions page, click “Set up a workflow yourself”\n   (If you already have actions set up, click “New workflow” in the left sidebar first.)\n\n   > <img src=\"./docs/images/install2.png\" alt=\"The “Actions” page for a repository, with an outline drawn around the “Set up a workflow yourself” button\" />\n\n3. This will bring you to the GitHub workflow editor. Copy the below code into the editor:\n\n   ```yml\n   name: Create issues from todos\n\n   on:\n     push:\n       branches:\n        - master\n\n   jobs:\n     todos:\n       runs-on: ubuntu-latest\n\n         steps:\n           - uses: actions/checkout@v1\n           - name: todo-actions\n             uses: dtinth/todo-actions@master\n             env:\n               GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n               TODO_ACTIONS_MONGO_URL: ${{ secrets.TODO_ACTIONS_MONGO_URL }}\n   ```\n\n   _Recommended: Rename `main.yml` to something else, such as `todos.yml`_\n\n4. Complete the workflow creation by clicking “Start commit” and committing the new `yml` file to your repo.\n\n5. Commit your changes. You should see the workflow running on GitHub under **Actions** tab.\n\n## Development\n\n### Glossary\n\nThis tool is designed to be task management system-agnostic.\nThat is, in the future it may be used with tools other than GitHub issues.\nTherefore, inside the code base, instead of “issues,” `todo-actions` calls them tasks.\n\n- **TODO comment:** A TODO comment inside the source code.\n  It begins with a _TODO marker_, and followed by a block of text whose first line is the title and the rest is the body.\n\n  ```\n  // TODO: Title here\n  // Body here\n  ```\n\n  A TODO comment may be in one of 3 stages:\n\n  - **new:** This TODO comment is newly added.\n    To ensure that we can reliably track the TODO comment, even when its title or body changes,\n    we need to assign a unique identifier to it.\n  - **identified:** This TODO comment has been identified.\n    However a _Task_ has not been created for this TODO comment yet.\n  - **associated:** A _Task_ has been created for this TODO comment.\n\n- **TODO marker:** The text that denotes a TODO comment.\n  It begins with the word `TODO`, may contain a _reference_ inside square brackets, and ends with a colon.\n  In order for the marker to be recognized, it must follow a whitespace, and no alphanumeric character may precede it.\n\n  | Stage      | Example marker                      |\n  | ---------- | ----------------------------------- |\n  | new        | `TODO:`                             |\n  | identified | `TODO [$5d20dc8e6a26d44c2afd08c6]:` |\n  | associated | `TODO [#1]:`                        |\n\n- **Repository:** A GitHub repository. Don't use the word “project” when you mean “repository.”\n\n- **Task Management System:** e.g. GitHub Issues, GitHub Projects, Trello, Taskworld, JIRA, etc.\n\n- **Task:** A work item inside a _Task Management System_ that can be created and completed by `todo-actions`. e.g. an issue, a card, a ticket, or a task.\n\n  - **To complete a task** means “to close an issue,” “to move a card to done,” or “to mark as completed/resolved,” depending on the task management system you use.\n\n### Implementation overview\n\n1. A `push` event causes the action to run in GitHub Actions. If the current branch is master, it continues. Otherwise, it is aborted.\n\n2. The action scans for `TODO` comments.\n\n   ```\n   // TODO: implement this thing\n   ```\n\n3. Each new TODO marker is then replaced with a unique ID.\n\n   ```\n   // TODO [$5d20dc8e6a26d44c2afd08c6]: implement this thing\n   ```\n\n4. The change is committed and pushed to the repository. If the push is successful, then we have successfully uniquely identified each to-do comment. Otherwise, someone else has made another commit to the repository, and the action is aborted.\n\n5. For each `TODO` marker, create a GitHub issue. Then replace the marker with the issue number.\n\n   ```\n   // TODO [#1]: implement this thing\n   ```\n\n6. The change is committed and pushed to the repository. If the push is successful, then it is done. Otherwise, someone else has made another commit to the repository, the action on that commit will take care of committing.\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/sh\n\nsh -c \"node /app/lib/CLIEntrypoint.js $*\""
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {\n  moduleFileExtensions: ['ts', 'tsx', 'js'],\n  transform: {\n    '^.+\\\\.tsx?$': 'ts-jest',\n  },\n  testMatch: ['**/src/**/*.test.+(ts|tsx|js)'],\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"devDependencies\": {\n    \"@types/jest\": \"^24.0.15\",\n    \"@types/lodash.sortby\": \"^4.7.6\",\n    \"@types/mongodb\": \"^3.1.28\",\n    \"@types/node\": \"^12.0.12\",\n    \"jest\": \"^24.8.0\",\n    \"madge\": \"^3.4.4\",\n    \"ts-jest\": \"^24.0.2\",\n    \"ts-node\": \"^8.3.0\",\n    \"typescript\": \"^3.5.2\"\n  },\n  \"scripts\": {\n    \"test\": \"jest\",\n    \"build\": \"rm -rf lib && tsc\",\n    \"dev\": \"ts-node -r dotenv/config src/CLIEntrypoint.ts\"\n  },\n  \"dependencies\": {\n    \"@octokit/graphql\": \"^3.0.1\",\n    \"@octokit/rest\": \"^16.28.4\",\n    \"dotenv\": \"^8.0.0\",\n    \"lodash.sortby\": \"^4.7.0\",\n    \"mongodb\": \"^3.2.7\",\n    \"tkt\": \"1.1.0\"\n  }\n}\n"
  },
  {
    "path": "src/CLIEntrypoint.ts",
    "content": "import { cli } from 'tkt'\nimport { runMain } from './TodoActionsMain'\n\nimport * as MongoDB from './MongoDB'\n\ncli()\n  .command('$0', 'Collect TODOs and create issues', {}, async args => {\n    await runMain()\n    await MongoDB.close()\n  })\n  .parse()\n"
  },
  {
    "path": "src/CodeRepository.ts",
    "content": "import { existsSync, readFileSync } from 'fs'\nimport { logger, invariant } from 'tkt'\nimport { execSync, execFileSync } from 'child_process'\nimport { IFile } from './types'\nimport { File } from './File'\n\nconst log = logger('CodeRepository')\n\nconst event =\n  process.env.GITHUB_EVENT_PATH && existsSync(process.env.GITHUB_EVENT_PATH)\n    ? (log.debug('Found GitHub Action event file'),\n      JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')))\n    : (log.debug('No GitHub Action event file found'), null)\n\nexport const repoContext = {\n  repositoryNodeId:\n    process.env.GITHUB_REPO_NODE_ID ||\n    (event && event.repository && event.repository.node_id) ||\n    invariant(\n      false,\n      'GitHub Repo Node ID not found, either in GitHub Action event payload and GITHUB_REPO_NODE_ID environment variable.',\n    ),\n  repositoryOwner:\n    process.env.GITHUB_REPO_OWNER ||\n    (event && event.repository && event.repository.full_name.split('/')[0]) ||\n    invariant(\n      false,\n      'GitHub Repo Owner not found, either in GitHub Action event payload and GITHUB_REPO_OWNER environment variable.',\n    ),\n  repositoryName:\n    process.env.GITHUB_REPO_NAME ||\n    (event && event.repository && event.repository.full_name.split('/')[1]) ||\n    invariant(\n      false,\n      'GitHub Repo Name not found, either in GitHub Action event payload and GITHUB_REPO_NAME environment variable.',\n    ),\n  defaultBranch:\n    process.env.GITHUB_REPO_DEFAULT_BRANCH ||\n    (event && event.repository && event.repository.default_branch) ||\n    invariant(\n      false,\n      'GitHub Repo Default Branch not found, either in GitHub Action event payload and GITHUB_REPO_DEFAULT_BRANCH environment variable.',\n    ),\n}\n\ntype CodeRepositoryState = {\n  files: IFile[]\n  saveChanges(commitMessage: string): Promise<void>\n}\n\nexport async function scanCodeRepository(): Promise<CodeRepositoryState> {\n  log.info('Search for files with TODO tags...')\n  const filesWithTodoMarker = execSync('git grep -Il TODO', {\n    encoding: 'utf8',\n  })\n    .split('\\n')\n    .filter(name => name)\n  const files: IFile[] = []\n  log.info('Parsing TODO tags...')\n  for (const filePath of filesWithTodoMarker) {\n    const file = new File(filePath)\n    files.push(file)\n  }\n  return {\n    files,\n    async saveChanges(commitMessage) {\n      const changedFiles = files.filter(file => file.contents.changed)\n      log.info('Files changed: %s', changedFiles.length)\n      if (changedFiles.length === 0) {\n        return\n      }\n      for (const file of changedFiles) {\n        file.save()\n      }\n      execFileSync('git', ['add', ...changedFiles.map(file => file.fileName)])\n      execFileSync('git', ['commit', '-m', commitMessage], {\n        stdio: 'inherit',\n      })\n      if (!process.env.GITHUB_TOKEN) {\n        throw `Maybe you forgot to enable the GITHUB_TOKEN secret?`\n      }\n      execSync(\n        'git push \"https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git\" HEAD:\"$GITHUB_REF\"',\n        { stdio: 'inherit' },\n      )\n    },\n  }\n}\n"
  },
  {
    "path": "src/DataStore.ts",
    "content": "import { logger, invariant } from 'tkt'\nimport { ITodo, ITaskState } from './types'\nimport { ObjectId } from 'mongodb'\n\nimport { getMongoDb } from './MongoDB'\nimport { currentProcessId } from './ProcessId'\n\nconst log = logger('DataStore')\n\ntype TaskResolutionProcedure =\n  | { existingTaskReference: string }\n  | { acquireTaskCreationLock(): Promise<TaskCreationLock> }\n\ntype TaskCreationLock = {\n  finish(taskReference: string, state: ITaskState): Promise<void>\n}\n\ntype Task = {\n  taskReference: string\n  state: ITaskState\n  markAsCompleted(): Promise<void>\n  updateState(newState: ITaskState): Promise<void>\n}\n\nexport async function beginTaskResolution(\n  todoUniqueKey: string,\n  repositoryId: string,\n  todo: ITodo,\n): Promise<TaskResolutionProcedure> {\n  const db = await getMongoDb()\n  const _id = ObjectId.createFromHexString(todoUniqueKey)\n\n  // Ensure a task exists in the database.\n  const task = await db.tasks.findOneAndUpdate(\n    { _id: _id },\n    {\n      $setOnInsert: {\n        _id: _id,\n        repositoryId: repositoryId,\n        taskReference: null,\n        createdAt: new Date(),\n        ownerProcessId: null,\n        ownerProcessTimestamp: null,\n      },\n    },\n    { upsert: true, returnOriginal: false },\n  )\n  if (!task.value) {\n    throw new Error('Failed to upsert a task.')\n  }\n  if (task.value.taskReference) {\n    log.debug(\n      'Found already-existing identifier %s for TODO %s.',\n      task.value.taskReference,\n      todoUniqueKey,\n    )\n    return { existingTaskReference: task.value.taskReference }\n  }\n\n  return {\n    async acquireTaskCreationLock() {\n      // Acquire a lock...\n      log.debug(\n        'Acquiring lock for TODO %s (currentProcessId=%s).',\n        todoUniqueKey,\n        currentProcessId,\n      )\n      const lockedTask = await db.tasks.findOneAndUpdate(\n        {\n          _id: _id,\n          $or: [\n            { ownerProcessTimestamp: null },\n            { ownerProcessTimestamp: { $lt: new Date(Date.now() - 60e3) } },\n          ],\n        },\n        {\n          $set: {\n            ownerProcessId: currentProcessId,\n            ownerProcessTimestamp: new Date(),\n          },\n        },\n        { returnOriginal: false },\n      )\n      if (!lockedTask.value) {\n        throw new Error('Failed to acquire a lock for this task.')\n      }\n      return {\n        async finish(taskReference, state) {\n          // Associate\n          log.debug(\n            'Created task %s for TODO %s. Saving changes.',\n            taskReference,\n            todoUniqueKey,\n          )\n          await db.tasks.findOneAndUpdate(\n            { _id: _id },\n            { $set: { taskReference: taskReference, hash: state.hash } },\n          )\n        },\n      }\n    },\n  }\n}\n\nexport async function findAllUncompletedTasks(\n  repositoryId: string,\n): Promise<Task[]> {\n  const db = await getMongoDb()\n  const result = await db.tasks\n    .find({\n      repositoryId: repositoryId,\n      completed: { $ne: true },\n      taskReference: { $ne: null },\n    })\n    .toArray()\n\n  return result.map(taskData => {\n    return {\n      taskReference:\n        taskData.taskReference ||\n        invariant(false, 'Unexpected unassociated task.'),\n      state: {\n        hash: taskData.hash || '',\n      },\n      async markAsCompleted() {\n        await db.tasks.findOneAndUpdate(\n          { _id: taskData._id },\n          { $set: { completed: true } },\n        )\n      },\n      async updateState(newState) {\n        await db.tasks.findOneAndUpdate(\n          { _id: taskData._id },\n          { $set: { hash: newState.hash } },\n        )\n      },\n    } as Task\n  })\n}\n"
  },
  {
    "path": "src/File.ts",
    "content": "import { IFile, IFileContents } from './types'\n\nexport class File implements IFile {\n  fileName: string\n  contents: FileContents\n\n  constructor(fileName: string) {\n    this.fileName = fileName\n    this.contents = new FileContents(\n      require('fs').readFileSync(fileName, 'utf8'),\n    )\n  }\n\n  save() {\n    if (this.contents.changed) {\n      require('fs').writeFileSync(\n        this.fileName,\n        this.contents.toString(),\n        'utf8',\n      )\n      this.contents.changed = false\n    }\n  }\n}\n\n/**\n * A mock file.\n */\nexport class MockFile implements IFile {\n  fileName: string\n  contents: FileContents\n\n  constructor(fileName: string, contents: string) {\n    this.fileName = fileName\n    this.contents = new FileContents(contents)\n  }\n\n  save() {\n    this.contents.changed = false\n  }\n}\n\nexport class FileContents implements IFileContents {\n  lines: string[]\n  changed: boolean\n\n  constructor(contents: string) {\n    this.lines = contents.split('\\n')\n    this.changed = false\n  }\n\n  changeLine(lineIndex: number, newLineContents: string) {\n    this.lines[lineIndex] = newLineContents\n    this.changed = true\n  }\n\n  toString() {\n    return this.lines.join('\\n')\n  }\n}\n"
  },
  {
    "path": "src/MongoDB.ts",
    "content": "import { Collection, ObjectId, MongoClient } from 'mongodb'\nimport { invariant, logger } from 'tkt'\n\ntype TaskSchema = {\n  /**\n   * Globally-unique ID for the task.\n   */\n  _id: ObjectId\n\n  /**\n   * String identifying the repository.\n   * This should be stable, i.e. does not change even though project is renamed.\n   */\n  repositoryId: string\n\n  /**\n   * The identifier of the associated task.\n   */\n  taskReference: string | null\n\n  /**\n   * `true` if issue is completed.\n   */\n  completed?: boolean\n\n  /**\n   * When the task is created.\n   */\n  createdAt: Date\n\n  /**\n   * ID of the process creating it.\n   */\n  ownerProcessId: string | null\n\n  /**\n   * Timestamp at which the lock was acquired.\n   */\n  ownerProcessTimestamp: Date | null\n\n  /**\n   * Hash of the task body contents\n   */\n  hash?: string\n}\n\nlet mongoPromise: Promise<{\n  client: MongoClient\n  tasks: Collection<TaskSchema>\n}>\n\nconst log = logger('mongo')\n\nexport async function getMongoDb() {\n  if (mongoPromise) return mongoPromise\n  return (mongoPromise = (async () => {\n    const { MongoClient } = await import('mongodb')\n    log.info('Connecting...')\n\n    const client = new MongoClient(\n      process.env.TODO_ACTIONS_MONGO_URL ||\n        invariant(\n          false,\n          'Missing environment variable: TODO_ACTIONS_MONGO_URL',\n        ),\n    )\n    await client.connect()\n    log.info('Connected!')\n\n    const db = client.db()\n    const tasks = db.collection<TaskSchema>('tasks')\n    tasks.createIndex({ repositoryId: 1 })\n\n    return {\n      client,\n      tasks: tasks,\n    }\n  })())\n}\n\nexport async function close() {\n  if (!mongoPromise) return\n  const mongo = await mongoPromise\n  mongo.client.close()\n}\n"
  },
  {
    "path": "src/ProcessId.ts",
    "content": "import { ObjectId } from 'bson'\n\nexport const currentProcessId = new ObjectId().toHexString()\n"
  },
  {
    "path": "src/TaskInformationGenerator.ts",
    "content": "import { ITodo, ITaskState } from './types'\nimport { createHash } from 'crypto'\nimport { repoContext } from './CodeRepository'\n\ntype TaskInformation = {\n  state: ITaskState\n  title: string\n  body: string\n}\n\nexport function generateTaskInformationFromTodo(todo: ITodo): TaskInformation {\n  const title = todo.title\n\n  const file = todo.file.fileName\n  // TODO [#31]: Also link to end line in addition to just the starting line.\n  // This requires changing `IFile` interface and `File` class to also keep track of where the TODO comment ends.\n  const line = todo.startLine\n  const owner = repoContext.repositoryOwner\n  const repo = repoContext.repositoryName\n  const defaultBranch = repoContext.defaultBranch\n\n  const url = `https://github.com/${owner}/${repo}/blob/${defaultBranch}/${file}#L${line}`\n  const link = `[${file}:${line}](${url})`\n  const body = [\n    todo.body,\n    '',\n    '---',\n    `_` +\n      `This issue has been automatically created by [todo-actions](https://github.com/apps/todo-actions) based on a TODO comment found in ${link}. ` +\n      `It will automatically be closed when the TODO comment is removed from the default branch (${defaultBranch}).` +\n      `_`,\n  ].join('\\n')\n\n  return {\n    state: {\n      hash: createHash('md5')\n        .update(title)\n        .update(body)\n        .digest('hex'),\n    },\n    title,\n    body,\n  }\n}\n"
  },
  {
    "path": "src/TaskManagementSystem.ts",
    "content": "import { invariant, logger } from 'tkt'\n\nimport * as CodeRepository from './CodeRepository'\n\nconst log = logger('TaskManagementSystem')\n\ntype TaskInformation = {\n  title: string\n  body: string\n}\n\nexport async function createTask(\n  information: TaskInformation,\n): Promise<string> {\n  const graphql = require('@octokit/graphql').defaults({\n    headers: {\n      authorization: `token ${process.env.GITHUB_TOKEN ||\n        invariant(false, 'Required GITHUB_TOKEN variable.')}`,\n    },\n  })\n  const result = await graphql(\n    `\n      mutation CreateIssue($input: CreateIssueInput!) {\n        createIssue(input: $input) {\n          issue {\n            number\n          }\n        }\n      }\n    `,\n    {\n      input: {\n        repositoryId: CodeRepository.repoContext.repositoryNodeId,\n        title: information.title,\n        body: information.body,\n      },\n    },\n  )\n  log.debug('Create issue result:', result)\n  return result.createIssue.issue.number\n    ? `#${result.createIssue.issue.number}`\n    : invariant(\n        false,\n        'Failed to get issue number out of createIssue API call.',\n      )\n}\n\nexport async function completeTask(taskReference: string): Promise<void> {\n  const Octokit = (await import('@octokit/rest')).default\n  const octokit = new Octokit({\n    auth: `token ${process.env.GITHUB_TOKEN ||\n      invariant(false, 'Required GITHUB_TOKEN variable.')}`,\n  })\n  const result = await octokit.issues.update({\n    owner: CodeRepository.repoContext.repositoryOwner,\n    repo: CodeRepository.repoContext.repositoryName,\n    issue_number: +taskReference.substr(1),\n    state: 'closed',\n  })\n  log.debug('Issue close result:', result.data)\n}\n\nexport async function updateTask(\n  taskReference: string,\n  information: TaskInformation,\n): Promise<void> {\n  const Octokit = (await import('@octokit/rest')).default\n  const octokit = new Octokit({\n    auth: `token ${process.env.GITHUB_TOKEN ||\n      invariant(false, 'Required GITHUB_TOKEN variable.')}`,\n  })\n  const result = await octokit.issues.update({\n    owner: CodeRepository.repoContext.repositoryOwner,\n    repo: CodeRepository.repoContext.repositoryName,\n    issue_number: +taskReference.substr(1),\n    title: information.title,\n    body: information.body,\n  })\n  log.debug('Issue update result:', result.data)\n}\n"
  },
  {
    "path": "src/TaskUpdater.ts",
    "content": "import { invariant, logger } from 'tkt'\nimport { ITodo } from './types'\n\nimport * as CodeRepository from './CodeRepository'\nimport * as TaskManagementSystem from './TaskManagementSystem'\nimport * as DataStore from './DataStore'\nimport * as TaskInformationGenerator from './TaskInformationGenerator'\n\nconst log = logger('TaskUpdater')\n\nexport async function ensureAllTodosAreAssociated(todos: ITodo[]) {\n  const references: string[] = []\n  for (const todo of todos) {\n    const reference =\n      todo.reference || invariant(false, 'Unexpected unidentified TODO marker')\n    const unassociated = reference.startsWith('$')\n    if (unassociated) {\n      // TODO [#37]: Isolate error when creating tasks\n      // Failure to create a task should not prevent the action from progressing forward.\n      // We can simply skip processing this comment for now.\n      // Since this script is designed to be idempotent, it can be retried later.\n      const todoUniqueKey = reference.substr(1)\n      log.debug('Found unresolved TODO %s, resolving task...', todoUniqueKey)\n      const taskReference = await resolveTask(todoUniqueKey, todo)\n      log.debug('Resolved TODO %s => task %s', todoUniqueKey, taskReference)\n      todo.reference = taskReference\n      references.push(taskReference)\n    }\n  }\n  return references\n}\n\nexport async function reconcileTasks(todos: ITodo[]) {\n  const uncompletedTasks = await DataStore.findAllUncompletedTasks(\n    CodeRepository.repoContext.repositoryNodeId,\n  )\n  log.info(\n    'Number of registered uncompleted tasks: %s',\n    uncompletedTasks.length,\n  )\n\n  for (const todo of todos) {\n    const reference =\n      todo.reference || invariant(false, 'Unexpected unidentified TODO marker')\n    invariant(\n      !reference.startsWith('$'),\n      'Expected all TODO comments to be associated by now.',\n    )\n    const task = uncompletedTasks.find(t => t.taskReference === reference)\n    if (!task) {\n      log.warn(\n        'Cannot find a matching task for TODO comment with reference \"%s\"',\n        reference,\n      )\n      continue\n    }\n    // TODO [#38]: Isolate error when updating tasks\n    // Failure to update a task should not prevent the action from progressing forward.\n    // We can simply skip processing this task for now.\n    // Since this script is designed to be idempotent, it can be retried later.\n    const {\n      title,\n      body,\n      state,\n    } = TaskInformationGenerator.generateTaskInformationFromTodo(todo)\n    if (task.state.hash !== state.hash) {\n      log.info(\n        'Hash for \"%s\" changed: \"%s\" => \"%s\" -- must update task.',\n        reference,\n        task.state.hash,\n        state.hash,\n      )\n      await TaskManagementSystem.updateTask(reference, { title, body })\n      await task.updateState(state)\n    } else {\n      log.info(\n        'Hash for \"%s\" remains unchanged: \"%s\".',\n        reference,\n        task.state.hash,\n      )\n    }\n  }\n\n  for (const task of uncompletedTasks) {\n    if (todos.find(todo => todo.reference === task.taskReference)) continue\n    log.info(\n      'TODO for task \"%s\" is gone -- completing task!',\n      task.taskReference,\n    )\n    // TODO [#39]: Isolate error when completing tasks\n    // Failure to complete a task should not prevent the action from progressing forward.\n    // We can simply skip processing this task for now.\n    // Since this script is designed to be idempotent, it can be retried later.\n    await TaskManagementSystem.completeTask(task.taskReference)\n    await task.markAsCompleted()\n  }\n}\n\nexport async function resolveTask(\n  todoUniqueKey: string,\n  todo: ITodo,\n): Promise<string> {\n  const resolution = await DataStore.beginTaskResolution(\n    todoUniqueKey,\n    CodeRepository.repoContext.repositoryNodeId,\n    todo,\n  )\n  if ('existingTaskReference' in resolution) {\n    return resolution.existingTaskReference\n  }\n  const taskCreationLock = await resolution.acquireTaskCreationLock()\n  log.debug('Lock acquired. Now creating task for TODO %s.', todoUniqueKey)\n  const {\n    title,\n    body,\n    state,\n  } = TaskInformationGenerator.generateTaskInformationFromTodo(todo)\n  const taskReference = await TaskManagementSystem.createTask({ title, body })\n  taskCreationLock.finish(taskReference, state)\n  return taskReference\n}\n"
  },
  {
    "path": "src/TodoActionsMain.test.ts",
    "content": "import { runMain } from './TodoActionsMain'\nimport { resetMockWorld } from './__mocks__/World'\nimport sortBy from 'lodash.sortby'\n\njest.mock('./DataStore')\njest.mock('./CodeRepository')\njest.mock('./TaskManagementSystem')\n\nconst MARKER = 'TODO'\n\nit('works', async () => {\n  const world = resetMockWorld()\n\n  // Round 1: Arrange\n  world.file(\n    'main.js',\n    `\n      // ${MARKER}: Hello world\n      // This is great!\n\n      <!--\n        - ${MARKER}:\n        - Somebody once told me\n        - the world is gonna roll me\n        -->\n    `,\n  )\n\n  // Round 1: Act\n  await runMain()\n\n  // Round 1: Assert commits\n  expect(world.commits.length).toEqual(2)\n  expect(world.commits[0].files.get('main.js')).toMatch(\n    new RegExp(`${MARKER} \\\\[\\\\$\\\\w+\\\\]: Hello world`),\n  )\n  expect(world.commits[1].files.get('main.js')).toMatch(\n    new RegExp(`${MARKER} \\\\[#\\\\d+\\\\]: Hello world`),\n  )\n  expect(world.commits[1].message).toMatch(/#1/)\n  expect(world.commits[1].message).toMatch(/#2/)\n\n  // Round 1: Assert tasks\n  expect(world.tasks.length).toEqual(2)\n  expect(sortBy(world.tasks.map(t => t.title))).toEqual([\n    'Hello world',\n    'Somebody once told me',\n  ])\n\n  // Idempotent check\n  await runMain()\n  expect(world.commits.length).toEqual(2)\n  expect(world.tasks.length).toEqual(2)\n\n  // Round 2: Arrange\n  const task1 = world.tasks.find(t => t.title === 'Hello world')!\n  const task2 = world.tasks.find(t => t.title === 'Somebody once told me')!\n  world.file(\n    'main.js',\n    `\n      <!--\n        - ${MARKER} [#${task2.number}]:\n        - Somebody once told me?\n        - the world is gonna roll me\n        -->\n    `,\n  )\n\n  // Round 2: Act\n  await runMain()\n\n  // Round 2: Assert commits\n  // No new commits expected\n  expect(world.commits.length).toEqual(2)\n\n  // Round 2: Assert tasks\n  expect(task1.completed).toBe(true)\n  expect(task2.completed).toBe(false)\n  expect(task2.title).toBe('Somebody once told me?')\n})\n"
  },
  {
    "path": "src/TodoActionsMain.ts",
    "content": "import { invariant } from 'tkt'\nimport { logger } from 'tkt'\nimport { ObjectId } from 'bson'\nimport { ITodo } from './types'\n\nimport * as TodoParser from './TodoParser'\nimport * as TaskUpdater from './TaskUpdater'\nimport * as CodeRepository from './CodeRepository'\n\nconst log = logger('main')\n\nexport async function runMain() {\n  log.info('Search for files with TODO tags...')\n  const { files, saveChanges } = await CodeRepository.scanCodeRepository()\n\n  const todoComments: ITodo[] = []\n  for (const file of files) {\n    // TODO [#22]: Implement ignoring paths\n    if (file.fileName === 'README.md') continue\n    const todos = TodoParser.parseTodos(file)\n    log.info('%s: %s found', file.fileName, todos.length)\n    todoComments.push(...todos)\n  }\n\n  log.info('Total TODOs found: %s', todoComments.length)\n  const todosWithoutReference = todoComments.filter(todo => !todo.reference)\n  log.info('TODOs without references: %s', todosWithoutReference.length)\n\n  if (todosWithoutReference.length > 0) {\n    for (const todo of todosWithoutReference) {\n      todo.reference = `$${new ObjectId().toHexString()}`\n    }\n    await saveChanges('Collect TODO comments')\n  }\n\n  // Every TODO must have a reference by now.\n  for (const todo of todoComments) {\n    invariant(\n      todo.reference,\n      'TODO \"%s\" at %s must have a reference by now!',\n      todo.title,\n      todo.file.fileName,\n    )\n  }\n\n  // Update all the tasks according to the TODO state.\n  const associated = await TaskUpdater.ensureAllTodosAreAssociated(todoComments)\n  await saveChanges('Update TODO references: ' + associated.join(', '))\n\n  // Reconcile all tasks\n  await TaskUpdater.reconcileTasks(todoComments)\n}\n"
  },
  {
    "path": "src/TodoParser.test.ts",
    "content": "import { parseTodos } from './TodoParser'\nimport { MockFile } from './File'\n\nconst MARKER = 'TODO'\n\ndescribe('parseTodos', () => {\n  it('is a function', () => {\n    expect(parseTodos).toBeInstanceOf(Function)\n  })\n\n  it('can parse todo', () => {\n    const file = new MockFile(\n      'main.js',\n      `\n        // ${MARKER}: Item 1\n\n        // ${MARKER}: Item 2\n        // Body\n\n        // ${MARKER}: Item 3\n        //\n        // Extended body\n\n        // Not part of TODO\n\n        /*\n         * ${MARKER}: Item 4\n         * Body\n         *\n         * Extended body\n         */\n\n        <!--\n          - ${MARKER}: Item 5\n          - Body\n          -\n          - Extended body\n\n          Not part of TODO\n          -->\n        \n          # ${MARKER}: Item 6\n          # Body\n          #\n          # Extended body\n          #-\n          # Not part of TODO\n      `,\n    )\n    const result = parseTodos(file)\n    expect(result).toHaveLength(6)\n    expect(result[0].file).toBe(file)\n    expect(result[0].title).toBe('Item 1')\n    expect(result[1].title).toBe('Item 2')\n    expect(result[2].title).toBe('Item 3')\n    expect(result[3].title).toBe('Item 4')\n    expect(result[4].title).toBe('Item 5')\n    expect(result[5].title).toBe('Item 6')\n\n    expect(result[0].body).toBe('')\n    expect(result[1].body).toBe('Body')\n    expect(result[2].body).toBe('Extended body')\n    expect(result[3].body).toBe('Body\\n\\nExtended body')\n    expect(result[4].body).toBe('Body\\n\\nExtended body')\n    expect(result[5].body).toBe('Body\\n\\nExtended body')\n  })\n\n  it('detects marker with reference', () => {\n    const file = new MockFile(\n      'main.js',\n      `\n      // ${MARKER} [#1]: Item 1\n\n      // ${MARKER} [$wow]: Item 2\n\n      // ${MARKER} [todo-actions#1]: Item 3\n\n      // ${MARKER} [https://github.com/dtinth/todo-actions/issues/1]: Item 4\n      `,\n    )\n    const result = parseTodos(file)\n    expect(result).toHaveLength(4)\n    expect(result[0].reference).toBe('#1')\n    expect(result[1].reference).toBe('$wow')\n    expect(result[2].reference).toBe('todo-actions#1')\n    expect(result[3].reference).toBe(\n      'https://github.com/dtinth/todo-actions/issues/1',\n    )\n  })\n\n  it('allows title on the next line', () => {\n    const file = new MockFile(\n      'main.js',\n      `\n        // ${MARKER}:\n        // Title\n        // Body\n      `,\n    )\n    const result = parseTodos(file)\n    expect(result).toHaveLength(1)\n    expect(result[0].file).toBe(file)\n    expect(result[0].title).toBe('Title')\n    expect(result[0].body).toBe('Body')\n  })\n})\n"
  },
  {
    "path": "src/TodoParser.ts",
    "content": "import { IFile, ITodo } from './types'\n\nexport function parseTodos(file: IFile): ITodo[] {\n  const out: Todo[] = []\n\n  let currentTodo: Todo | undefined\n  for (const [lineIndex, line] of file.contents.lines.entries()) {\n    const match = line.match(/^(\\W+\\s)TODO(?: \\[([^\\]\\s]+)\\])?:(.*)/)\n    if (match) {\n      const todo = new Todo(file, lineIndex, match[1], match[2], match[3])\n      currentTodo = todo\n      out.push(todo)\n    } else if (currentTodo) {\n      const beforePrefix = line.substr(0, currentTodo.prefix.length)\n      const afterPrefix = line.substr(currentTodo.prefix.length)\n      if (\n        beforePrefix.trimRight() === currentTodo.prefix.trimRight() &&\n        (!afterPrefix || beforePrefix.match(/\\s$/))\n      ) {\n        currentTodo.handleLine(afterPrefix)\n      } else {\n        currentTodo = undefined\n      }\n    }\n  }\n  return out\n}\n\nclass Todo implements ITodo {\n  prefix: string\n  line: number\n  suffix: string\n  body: string\n  title: string\n\n  private currentReference: string | null\n\n  constructor(\n    public file: IFile,\n    line: number,\n    prefix: string,\n    reference: string | null,\n    suffix: string,\n  ) {\n    this.line = line\n    this.prefix = prefix\n    this.currentReference = reference\n    this.suffix = suffix\n    this.title = suffix.trim()\n    this.body = ''\n  }\n\n  get reference(): string | null {\n    return this.currentReference\n  }\n  set reference(newRef) {\n    this.currentReference = newRef\n    this.file.contents.changeLine(\n      this.line,\n      `${this.prefix}TODO${newRef ? ` [${newRef}]` : ''}:${this.suffix}`,\n    )\n  }\n\n  get startLine(): number {\n    return this.line + 1\n  }\n\n  handleLine(line: string) {\n    if (!this.title) {\n      this.title = line\n    } else if (this.body || line) {\n      this.body += (this.body ? '\\n' : '') + line\n    }\n  }\n}\n"
  },
  {
    "path": "src/__mocks__/CodeRepository.ts",
    "content": "import { mockWorld } from './World'\n\ntype Real = typeof import('../CodeRepository')\n\nexport const repoContext: Real['repoContext'] = {\n  repositoryNodeId: '__GITHUB_REPO_NODE_ID__',\n  repositoryOwner: '_dtinth',\n  repositoryName: '_todo-actions',\n  defaultBranch: 'master',\n}\n\nexport const scanCodeRepository: Real['scanCodeRepository'] = async () => {\n  const files = [...mockWorld.files.values()]\n  return {\n    files: files,\n    async saveChanges(commitMessage) {\n      if (!files.some(f => f.contents.changed)) return\n      files.forEach(f => f.save())\n      mockWorld.commits.push({\n        message: commitMessage,\n        files: new Map(files.map(f => [f.fileName, f.contents.toString()])),\n      })\n    },\n  }\n}\n"
  },
  {
    "path": "src/__mocks__/DataStore.ts",
    "content": "import { mockWorld } from './World'\n\ntype Real = typeof import('../DataStore')\n\nexport const beginTaskResolution: Real['beginTaskResolution'] = async (\n  todoUniqueKey,\n  repositoryId,\n) => {\n  const existing = mockWorld.store.find(entry => entry._id === todoUniqueKey)\n  if (existing) {\n    return { existingTaskReference: existing.reference }\n  }\n\n  return {\n    async acquireTaskCreationLock() {\n      return {\n        async finish(taskReference, state) {\n          mockWorld.store.push({\n            _id: todoUniqueKey,\n            reference: taskReference,\n            state: state,\n            completed: false,\n          })\n        },\n      }\n    },\n  }\n}\n\nexport const findAllUncompletedTasks: Real['findAllUncompletedTasks'] = async repositoryId => {\n  return mockWorld.store\n    .filter(entry => !entry.completed)\n    .map(entry => {\n      return {\n        taskReference: entry.reference,\n        state: entry.state,\n        async markAsCompleted() {\n          entry.completed = true\n        },\n        async updateState(newState) {\n          entry.state = newState\n        },\n      }\n    })\n}\n"
  },
  {
    "path": "src/__mocks__/TaskManagementSystem.ts",
    "content": "import { mockWorld, MockTask } from './World'\n\ntype Real = typeof import('../TaskManagementSystem')\n\nexport const createTask: Real['createTask'] = async information => {\n  const number = mockWorld.tasks.length + 1\n  const task: MockTask = { ...information, number, completed: false }\n  mockWorld.tasks.push(task)\n  return `#${task.number}`\n}\n\nexport const completeTask: Real['completeTask'] = async taskReference => {\n  getTask(taskReference).completed = true\n}\n\nexport const updateTask: Real['updateTask'] = async (\n  taskReference,\n  information,\n) => {\n  Object.assign(getTask(taskReference), information)\n}\n\nfunction getTask(taskReference: string) {\n  return mockWorld.tasks.find(t => `#${t.number}` === taskReference)!\n}\n"
  },
  {
    "path": "src/__mocks__/World.ts",
    "content": "import { IFile, ITaskState } from '../types'\nimport { MockFile } from '../File'\n\nexport let mockWorld: MockWorld\n\nexport type MockTask = {\n  title: string\n  body: string\n  number: number\n  completed: boolean\n}\n\nexport type MockDataStoreEntry = {\n  _id: string\n  completed: boolean\n  reference: string\n  state: ITaskState\n}\n\nexport type MockCommit = {\n  message: string\n  files: Map<string, string>\n}\n\nexport function resetMockWorld() {\n  mockWorld = new MockWorld()\n  return mockWorld\n}\n\nclass MockWorld {\n  files: Map<string, IFile> = new Map()\n  branch = 'master'\n  store: MockDataStoreEntry[] = []\n  tasks: MockTask[] = []\n  commits: MockCommit[] = []\n\n  file(fileName: string, contents: string) {\n    this.files.set(fileName, new MockFile(fileName, contents))\n  }\n}\n"
  },
  {
    "path": "src/types.ts",
    "content": "/**\n * A representation of a file being processed,\n * with mutable contents.\n */\nexport interface IFile {\n  fileName: string\n  contents: IFileContents\n  /**\n   * Saves the file back into the file system.\n   */\n  save(): void\n}\n\nexport interface IFileContents {\n  changed: boolean\n\n  /**\n   * File contents as array of lines.\n   * The newline character has been stripped.\n   * May be mutated to change the contents of the file.\n   */\n  lines: ReadonlyArray<string>\n\n  /**\n   * Change a line\n   */\n  changeLine(lineIndex: number, newLineContents: string): void\n}\n\nexport interface ITodo {\n  file: IFile\n  startLine: number\n  reference: string | null\n  title: string\n  body: string\n}\n\nexport interface ITaskState {\n  hash: string\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"module\": \"commonjs\",\n    \"target\": \"es2016\",\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"rootDir\": \"src\",\n    \"outDir\": \"lib\",\n    \"sourceMap\": true,\n    \"esModuleInterop\": true\n  },\n  \"include\": [\"src\"],\n  \"exclude\": [\"node_modules\", \"**/node_modules/*\"]\n}\n"
  }
]