├── .eslintignore ├── .eslintrc.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── feature_improvement.yml │ └── feature_requests.yml ├── labels.yml └── workflows │ ├── build.yml │ ├── ci.yml │ ├── labels.yml │ ├── milestones.yml │ └── versioning.yml ├── .gitignore ├── .prettierrc.yml ├── .release-it.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── __tests__ ├── common.test.ts └── properties.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── action.ts ├── api-types.ts ├── common.ts ├── index.ts ├── properties.ts └── sync.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - './node_modules/gts/' 3 | rules: 4 | '@typescript-eslint/no-namespace': off 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us improve 3 | labels: [bug] 4 | body: 5 | - type: input 6 | attributes: 7 | label: Describe the bug 8 | description: A clear and concise description of what the bug is. 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Steps to reproduce 14 | description: Steps to reproduce the bug. 15 | placeholder: | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Expected behavior 25 | description: A clear and concise description of what you expected to happen. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Additional context 31 | description: Add any other context or screenshots about the feature request here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_improvement.yml: -------------------------------------------------------------------------------- 1 | name: Feature improvement 2 | description: Suggest an idea for improving an existing feature 3 | body: 4 | - type: textarea 5 | attributes: 6 | label: Is your feature improvement related to a problem? Please describe. 7 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 8 | validations: 9 | required: true 10 | - type: textarea 11 | attributes: 12 | label: Describe the solution you'd like 13 | description: A clear and concise description of what you want to happen. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe alternatives you've considered 19 | description: A clear and concise description of any alternative solutions or features you've considered. 20 | - type: textarea 21 | attributes: 22 | label: Additional context 23 | description: Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_requests.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | labels: ['feature request'] 4 | body: 5 | - type: textarea 6 | attributes: 7 | label: Is your feature request related to a problem? Please describe. 8 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | validations: 10 | required: true 11 | - type: textarea 12 | attributes: 13 | label: Describe the solution you'd like 14 | description: A clear and concise description of what you want to happen. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Describe alternatives you've considered 20 | description: A clear and concise description of any alternative solutions or features you've considered. 21 | - type: textarea 22 | attributes: 23 | label: Additional context 24 | description: Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # The GitHub labels to be used by this repo 2 | - name: bug 3 | description: If this is a bug 4 | color: 800000 5 | - name: security/privacy 6 | description: If it involves securing user information 7 | color: 000000 8 | - name: design 9 | description: If it needs a large amount of design 10 | color: f032e6 11 | - name: optimization 12 | description: If it's related to performance 13 | color: ffe119 14 | - name: copy 15 | description: If it's only a copy change, or needs a large amount of copy 16 | color: 469990 17 | - name: onboarding 18 | description: If it has to do with educating users on using the product 19 | color: aaffc3 20 | - name: feature request 21 | description: If it's a feature request from a user 22 | color: e6beff 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out Git repository 15 | uses: actions/checkout@v2 16 | with: 17 | # Custom token to allow commits trigger other workflows. 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v2.1.5 22 | with: 23 | node-version: 12 24 | 25 | - name: Cache node modules 26 | uses: actions/cache@v2 27 | env: 28 | cache-name: cache-node-modules 29 | with: 30 | # npm cache files are stored in `~/.npm` on Linux/macOS 31 | path: ~/.npm 32 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-build-${{ env.cache-name }}- 35 | ${{ runner.os }}-build- 36 | ${{ runner.os }}- 37 | 38 | - name: Install Dependencies 39 | run: npm install 40 | 41 | - name: Build dist 42 | run: npm run build 43 | 44 | - name: Commit dist 45 | uses: EndBug/add-and-commit@v7 46 | with: 47 | add: "dist" 48 | author_name: github-actions[bot] 49 | author_email: github-actions[bot]@users.noreply.github.com 50 | message: "[auto] Update compiled version" 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '14' 17 | - run: npm ci 18 | - run: npm run lint 19 | 20 | jest: 21 | name: Jest 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions/setup-node@v2 26 | with: 27 | node-version: '14' 28 | - run: npm ci 29 | - run: npm test 30 | 31 | build: 32 | name: Build 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v2 36 | - uses: actions/setup-node@v2 37 | with: 38 | node-version: '14' 39 | - run: npm ci 40 | - run: npm run build 41 | -------------------------------------------------------------------------------- /.github/workflows/labels.yml: -------------------------------------------------------------------------------- 1 | name: Put labels on autopilot 2 | on: 3 | push: 4 | branches: 5 | - master 6 | paths: 7 | - .github/labels.yml 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: micnncim/action-label-syncer@v1 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | manifest: .github/labels.yml 18 | -------------------------------------------------------------------------------- /.github/workflows/milestones.yml: -------------------------------------------------------------------------------- 1 | name: "Milestones on autopilot" 2 | on: 3 | schedule: 4 | - cron: "*/20 * * * *" 5 | 6 | jobs: 7 | milestones: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: instantish/memorable-milestones@2.0.1 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/versioning.yml: -------------------------------------------------------------------------------- 1 | name: Update major-version tags 2 | 3 | on: 4 | release: 5 | types: [published, edited] 6 | 7 | jobs: 8 | actions-tagger: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: Actions-R-Us/actions-tagger@v2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.idea/ 3 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: true 3 | trailingComma: es5 4 | printWidth: 100 5 | arrowParens: avoid 6 | bracketSpacing: false 7 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}" 4 | }, 5 | "github": { 6 | "release": true 7 | }, 8 | "npm": { 9 | "publish": false 10 | } 11 | } 12 | 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | support@itsinstantish.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Instantish, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion x GitHub Action 2 | 3 | [![Code Style: Google](https://img.shields.io/badge/code%20style-google-blueviolet.svg)](https://github.com/google/gts) 4 | 5 | Connect your GitHub issues to a Notion database. 6 | 7 | **Like this GitHub Action?** Give us a ⭐️ and [follow us on Twitter for more drops 🪂](https://twitter.com/tryfabric). 8 | 9 | --- 10 | 11 | ## Quick Start 12 | 13 | 1. [Create a new internal Notion integration](https://www.notion.so/my-integrations) and note the value of the Internal Integration Token. 14 | 2. In your GitHub repository, go to `Settings` > `Secrets`, and add a `New repository secret`. Set the `Name` to `NOTION_TOKEN` and the `Value` to the Internal Integration Token you created in the previous step. 15 | 3. Set up your Notion Database. Use [this template](https://www.notion.so/tryfabric/067329ee12044ec49b7db6b39b26920a?v=38dd7e968874436c810a5e604d2cf12c) and duplicate it to your workspace. Screen Shot 2021-06-14 at 11 37 51 AM 16 | 4. In your Notion Database page's `Share` menu, add the Notion integration you created as a member with the `Can edit` privilege. You may have to type your integration's name in the `Invite` field. Screen Shot 2021-06-14 at 11 41 25 AM 17 | 5. Find the ID of your Database by copying the link to it. The link will have the format 18 | 19 | ``` 20 | https://www.notion.so/abc?v=123 21 | ``` 22 | 23 | where `abc` is the database id. 24 | 25 | 6. Add the Database's ID as a repository secret for your GitHub repository. Set the `Name` to `NOTION_DATABASE` and the `Value` to the id of your Database. 26 | 27 | 7. In your GitHub repository, create a GitHub workflow file at the path `.github/workflows/issues-notion-sync.yml`. 28 | 29 | ```yaml 30 | name: Notion Sync 31 | 32 | on: 33 | workflow_dispatch: 34 | issues: 35 | types: 36 | [ 37 | opened, 38 | edited, 39 | labeled, 40 | unlabeled, 41 | assigned, 42 | unassigned, 43 | milestoned, 44 | demilestoned, 45 | reopened, 46 | closed, 47 | ] 48 | 49 | jobs: 50 | notion_job: 51 | runs-on: ubuntu-latest 52 | name: Add GitHub Issues to Notion 53 | steps: 54 | - name: Add GitHub Issues to Notion 55 | uses: tryfabric/notion-github-action@v1 56 | with: 57 | notion-token: ${{ secrets.NOTION_TOKEN }} 58 | notion-db: ${{ secrets.NOTION_DATABASE }} 59 | ``` 60 | 61 | 8. (Optional) If your Github repository has any preexisting issues that you would like to sync to your new Notion Database you can trigger a manual workflow. Make sure your organization's default `GITHUB_TOKEN` has [read and write permissions](https://docs.github.com/en/organizations/managing-organization-settings/disabling-or-limiting-github-actions-for-your-organization#setting-the-permissions-of-the-github_token-for-your-organization) then follow [these intructions](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) to run the `Notion Job` workflow. 62 | 63 | _Note: The manual workflow will only work on Notion Databases created from the templated linked above._ 64 | 65 | ## Using `release-it` 66 | 67 | 1. Locally, on `master` (make sure it's up to date), execute `GITHUB_TOKEN= release-it`. (Alternatively, set `GITHUB_TOKEN` as a system environment variable) 68 | 2. Follow the interactive prompts, selecting `Yes` for all options. 69 | 3. When selecting the increment, choose `patch` when the release is only bug fixes. For new features, choose `minor`. For major changes, choose `major`. 70 | 71 | Release-It will then automatically generate a GitHub release with the changelog inside. 72 | 73 | --- 74 | 75 | Built with 💙 by the team behind [Fabric](https://tryfabric.com). 76 | -------------------------------------------------------------------------------- /__tests__/common.test.ts: -------------------------------------------------------------------------------- 1 | import {common, RICH_TEXT_CONTENT_CHARACTERS_LIMIT} from '../src/common'; 2 | 3 | describe('richText', () => { 4 | const res = common.richText(Array(2000).fill('a').join(''), { 5 | annotations: { 6 | // @ts-expect-error test flag 7 | color: 'custom', 8 | }, 9 | url: 'abc', 10 | })[0]; 11 | 12 | it('should have have a proper format', () => { 13 | expect(res.type).toBe('text'); 14 | expect(typeof res.annotations).toBe('object'); 15 | expect(res.annotations?.color).toBe('custom'); 16 | if ('text' in res) { 17 | expect(typeof res.text).toBe('object'); 18 | expect(typeof res.text.content).toBe('string'); 19 | expect(typeof res.text.link).toBe('object'); 20 | expect(res.text.link?.url).toBe('abc'); 21 | } else fail('res did not contain the "text" key'); 22 | }); 23 | 24 | it('should have truncated long text', () => { 25 | if ('text' in res) expect(res.text.content.length).toBe(RICH_TEXT_CONTENT_CHARACTERS_LIMIT); 26 | else fail('res did not contain the "text" key'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /__tests__/properties.test.ts: -------------------------------------------------------------------------------- 1 | import {RichTextItemRequest} from '../src/api-types'; 2 | import {RICH_TEXT_CONTENT_CHARACTERS_LIMIT} from '../src/common'; 3 | import {properties} from '../src/properties'; 4 | 5 | describe('text', () => { 6 | it('should convert a string to RichText', () => { 7 | const res = properties.text('abc'); 8 | 9 | expect(typeof res).toBe('object'); 10 | expect(res.type).toBe('rich_text'); 11 | expect(res.rich_text).toBeInstanceOf(Array); 12 | expect(res.rich_text.length).toBe(1); 13 | expect(res.rich_text[0].type).toBe('text'); 14 | }); 15 | 16 | it('should truncate long strings', () => { 17 | const veryLongString = Array(2000).fill('a').join(''); 18 | const res = properties.text(veryLongString); 19 | 20 | expect(typeof res).toBe('object'); 21 | expect(res.type).toBe('rich_text'); 22 | expect(res.rich_text).toBeInstanceOf(Array); 23 | expect(res.rich_text.length).toBe(1); 24 | if ('text' in res.rich_text[0]) 25 | expect(res.rich_text[0].text.content.length).toBe(RICH_TEXT_CONTENT_CHARACTERS_LIMIT); 26 | else fail('res.rich_text[0] did not contain the "text" key'); 27 | }); 28 | }); 29 | 30 | describe('richText', () => { 31 | it('should correctly handle rich text', () => { 32 | const annotations = { 33 | bold: false, 34 | strikethrough: false, 35 | underline: false, 36 | italic: false, 37 | code: false, 38 | color: 'default', 39 | } as const; 40 | const input: RichTextItemRequest[] = [ 41 | { 42 | type: 'text', 43 | text: { 44 | content: 'abc', 45 | }, 46 | annotations, 47 | }, 48 | { 49 | type: 'equation', 50 | annotations, 51 | equation: { 52 | expression: 'abc', 53 | }, 54 | }, 55 | ]; 56 | 57 | const res = properties.richText(input); 58 | expect(typeof res).toBe('object'); 59 | expect(res.type).toBe('rich_text'); 60 | expect(res.rich_text).toBeInstanceOf(Array); 61 | expect(res.rich_text.length).toBe(2); 62 | expect(res.rich_text[0].type).toBe('text'); 63 | expect(res.rich_text[1].type).toBe('equation'); 64 | }); 65 | }); 66 | 67 | describe('title', () => { 68 | it('should convert a string to a Notion title', () => { 69 | const res = properties.title('abc'); 70 | 71 | expect(typeof res).toBe('object'); 72 | expect(res.type).toBe('title'); 73 | expect(res.title).toBeInstanceOf(Array); 74 | expect(res.title.length).toBe(1); 75 | expect(res.title[0].type).toBe('text'); 76 | if ('text' in res.title[0]) expect(res.title[0].text.content).toBe('abc'); 77 | else fail('res.rich_text[0] did not contain the "text" key'); 78 | }); 79 | }); 80 | 81 | describe('number', () => { 82 | it('should convert a number to a Notion number', () => { 83 | const res = properties.number(123); 84 | 85 | expect(typeof res).toBe('object'); 86 | expect(res.type).toBe('number'); 87 | expect(typeof res.number).toBe('number'); 88 | }); 89 | }); 90 | 91 | describe('date', () => { 92 | it('should convert a string to a Notion date', () => { 93 | const res = properties.date('abc'); 94 | 95 | expect(typeof res).toBe('object'); 96 | expect(res.type).toBe('date'); 97 | expect(typeof res.date).toBe('object'); 98 | expect(res.date?.start).toBe('abc'); 99 | }); 100 | }); 101 | 102 | describe('getStatusSelectOption', () => { 103 | for (const status of ['open', 'closed'] as const) { 104 | it(`should return a select for ${status}`, () => { 105 | const res = properties.getStatusSelectOption(status); 106 | 107 | expect(typeof res).toBe('object'); 108 | expect(res.type).toBe('select'); 109 | expect(typeof res.select).toBe('object'); 110 | expect(typeof res.select?.name).toBe('string'); 111 | expect(typeof res.select?.color).toBe('string'); 112 | }); 113 | } 114 | }); 115 | 116 | describe('select', () => { 117 | it('should correctly create the desidered Select element', () => { 118 | const res = properties.select('abc', 'default'); 119 | 120 | expect(typeof res).toBe('object'); 121 | expect(res.type).toBe('select'); 122 | expect(typeof res.select).toBe('object'); 123 | expect(res.select?.name).toBe('abc'); 124 | expect(res.select?.color).toBe('default'); 125 | }); 126 | }); 127 | 128 | describe('multiSelect', () => { 129 | it('should correctly create the desidered multi_select element', () => { 130 | const arr = ['first', 'second', 'third']; 131 | const res = properties.multiSelect(arr); 132 | 133 | expect(typeof res).toBe('object'); 134 | expect(res.type).toBe('multi_select'); 135 | expect(res.multi_select).toBeInstanceOf(Array); 136 | arr.forEach(element => { 137 | expect(res.multi_select).toContainEqual({name: element}); 138 | }); 139 | }); 140 | }); 141 | 142 | describe('url', () => { 143 | it('should correctly create the desidered url element', () => { 144 | const res = properties.url('abc'); 145 | 146 | expect(typeof res).toBe('object'); 147 | expect(res.type).toBe('url'); 148 | expect(res.url).toBe('abc'); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Notion x GitHub Action' 2 | description: 'Sync GitHub issues to a Notion database' 3 | inputs: 4 | notion-token: 5 | description: 'Your Notion API Token' 6 | required: true 7 | notion-db: 8 | description: 'The Notion database id' 9 | required: true 10 | github-token: 11 | description: 'Your GitHub personal access token' 12 | required: false 13 | default: ${{ github.token }} 14 | 15 | runs: 16 | using: 'node12' 17 | main: 'dist/index.js' 18 | branding: 19 | icon: zap 20 | color: gray-dark 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest', 9 | }, 10 | verbose: true, 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@instantish/notion-github-action", 3 | "version": "1.2.3", 4 | "private": true, 5 | "description": "A GitHub Action that syncs issues to a Notion database. Multi-repository friendly.", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "ncc build src/index.ts", 10 | "lint": "gts lint src/*", 11 | "release": "release-it" 12 | }, 13 | "dependencies": { 14 | "@actions/core": "^1.4.0", 15 | "@actions/github": "^5.0.0", 16 | "@notionhq/client": "^0.4.13", 17 | "@octokit/webhooks-definitions": "^3.67.3", 18 | "@tryfabric/martian": "^1.1.1", 19 | "octokit": "^1.1.0" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^27.0.3", 23 | "@types/node": "^15.12.2", 24 | "@vercel/ncc": "^0.28.6", 25 | "gts": "^3.1.0", 26 | "jest": "^27.4.5", 27 | "jest-circus": "^27.4.5", 28 | "prettier": "^2.3.1", 29 | "release-it": "^14.11.6", 30 | "ts-jest": "^27.1.2", 31 | "typescript": "^4.3.2" 32 | }, 33 | "author": "Richard Robinson", 34 | "license": "MIT", 35 | "keywords": [ 36 | "notion", 37 | "github", 38 | "issues", 39 | "issue-management", 40 | "notion-api" 41 | ], 42 | "repository": { 43 | "type": "git", 44 | "url": "git+https://github.com/instantish/notion-github-action.git" 45 | }, 46 | "bugs": { 47 | "url": "https://github.com/instantish/notion-github-action/issues" 48 | }, 49 | "homepage": "https://github.com/instantish/notion-github-action#readme" 50 | } 51 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | import {Client, LogLevel} from '@notionhq/client/build/src'; 2 | import * as core from '@actions/core'; 3 | import type {IssuesEvent, IssuesOpenedEvent} from '@octokit/webhooks-definitions/schema'; 4 | import type {WebhookPayload} from '@actions/github/lib/interfaces'; 5 | import {CustomValueMap, properties} from './properties'; 6 | import {createIssueMapping, syncNotionDBWithGitHub} from './sync'; 7 | import {Octokit} from 'octokit'; 8 | import {markdownToRichText} from '@tryfabric/martian'; 9 | import {CustomTypes, RichTextItemResponse} from './api-types'; 10 | import {CreatePageParameters} from '@notionhq/client/build/src/api-endpoints'; 11 | 12 | function removeHTML(text?: string): string { 13 | return text?.replace(/<.*>.*<\/.*>/g, '') ?? ''; 14 | } 15 | 16 | interface PayloadParsingOptions { 17 | payload: IssuesEvent; 18 | octokit: Octokit; 19 | possibleProject?: ProjectData; 20 | } 21 | async function parsePropertiesFromPayload(options: PayloadParsingOptions): Promise { 22 | const {payload, octokit, possibleProject} = options; 23 | 24 | payload.issue.labels?.map(label => label.color); 25 | 26 | const projectData = await getProjectData({ 27 | octokit, 28 | githubRepo: payload.repository.full_name, 29 | issueNumber: payload.issue.number, 30 | possible: possibleProject, 31 | }); 32 | 33 | core.debug(`Current project data: ${JSON.stringify(projectData, null, 2)}`); 34 | 35 | const result: CustomValueMap = { 36 | Name: properties.title(payload.issue.title), 37 | Status: properties.getStatusSelectOption(payload.issue.state!), 38 | Organization: properties.text(payload.organization?.login ?? ''), 39 | Repository: properties.text(payload.repository.name), 40 | Number: properties.number(payload.issue.number), 41 | Body: properties.richText(parseBodyRichText(payload.issue.body)), 42 | Assignees: properties.multiSelect(payload.issue.assignees.map(assignee => assignee.login)), 43 | Milestone: properties.text(payload.issue.milestone?.title ?? ''), 44 | Labels: properties.multiSelect(payload.issue.labels?.map(label => label.name) ?? []), 45 | Author: properties.text(payload.issue.user.login), 46 | Created: properties.date(payload.issue.created_at), 47 | Updated: properties.date(payload.issue.updated_at), 48 | ID: properties.number(payload.issue.id), 49 | Link: properties.url(payload.issue.html_url), 50 | Project: properties.text(projectData?.name || ''), 51 | 'Project Column': properties.text(projectData?.columnName || ''), 52 | }; 53 | 54 | return result; 55 | } 56 | 57 | interface ProjectData { 58 | name?: string; 59 | columnName?: string; 60 | } 61 | interface GetProjectDataOptions { 62 | octokit: Octokit; 63 | githubRepo: string; 64 | issueNumber: number; 65 | possible?: ProjectData; 66 | } 67 | export async function getProjectData( 68 | options: GetProjectDataOptions 69 | ): Promise { 70 | const {octokit, githubRepo, issueNumber, possible} = options; 71 | 72 | const projects = 73 | ( 74 | await octokit.rest.projects.listForRepo({ 75 | owner: githubRepo.split('/')[0], 76 | repo: githubRepo.split('/')[1], 77 | }) 78 | ).data || []; 79 | projects.sort(p => (p.name === possible?.name ? -1 : 1)); 80 | 81 | core.debug(`Found ${projects.length} projects.`); 82 | 83 | for (const project of projects) { 84 | const columns = (await octokit.rest.projects.listColumns({project_id: project.id})).data || []; 85 | if (possible?.name === project.name) 86 | columns.sort(c => (c.name === possible.columnName ? -1 : 1)); 87 | 88 | for (const column of columns) { 89 | const cards = (await octokit.rest.projects.listCards({column_id: column.id})).data, 90 | card = 91 | cards && cards.find(c => Number(c.content_url?.split('/issues/')[1]) === issueNumber); 92 | 93 | if (card) 94 | return { 95 | name: project.name, 96 | columnName: column.name, 97 | }; 98 | } 99 | } 100 | 101 | return undefined; 102 | } 103 | 104 | export function parseBodyRichText(body: string) { 105 | try { 106 | return markdownToRichText(removeHTML(body)) as CustomTypes.RichText['rich_text']; 107 | } catch { 108 | return []; 109 | } 110 | } 111 | 112 | function getBodyChildrenBlocks(body: string): Exclude { 113 | // We're currently using only one paragraph block, but this could be extended to multiple kinds of blocks. 114 | return [ 115 | { 116 | type: 'paragraph', 117 | paragraph: { 118 | text: parseBodyRichText(body), 119 | }, 120 | }, 121 | ]; 122 | } 123 | 124 | interface IssueOpenedOptions { 125 | notion: { 126 | client: Client; 127 | databaseId: string; 128 | }; 129 | payload: IssuesOpenedEvent; 130 | octokit: Octokit; 131 | } 132 | 133 | async function handleIssueOpened(options: IssueOpenedOptions) { 134 | const {notion, payload} = options; 135 | 136 | core.info(`Creating page for issue #${payload.issue.number}`); 137 | 138 | await notion.client.pages.create({ 139 | parent: { 140 | database_id: notion.databaseId, 141 | }, 142 | properties: await parsePropertiesFromPayload({ 143 | payload, 144 | octokit: options.octokit, 145 | }), 146 | children: getBodyChildrenBlocks(payload.issue.body), 147 | }); 148 | } 149 | 150 | interface IssueEditedOptions { 151 | notion: { 152 | client: Client; 153 | databaseId: string; 154 | }; 155 | payload: IssuesEvent; 156 | octokit: Octokit; 157 | } 158 | 159 | async function handleIssueEdited(options: IssueEditedOptions) { 160 | const {notion, payload, octokit} = options; 161 | 162 | core.info(`Querying database for page with github id ${payload.issue.id}`); 163 | 164 | const query = await notion.client.databases.query({ 165 | database_id: notion.databaseId, 166 | filter: { 167 | property: 'ID', 168 | number: { 169 | equals: payload.issue.id, 170 | }, 171 | }, 172 | page_size: 1, 173 | }); 174 | 175 | const bodyBlocks = getBodyChildrenBlocks(payload.issue.body); 176 | 177 | if (query.results.length > 0) { 178 | const pageId = query.results[0].id; 179 | 180 | core.info(`Query successful: Page ${pageId}`); 181 | core.info(`Updating page for issue #${payload.issue.number}`); 182 | 183 | await notion.client.pages.update({ 184 | page_id: pageId, 185 | properties: await parsePropertiesFromPayload({payload, octokit}), 186 | }); 187 | 188 | const existingBlocks = ( 189 | await notion.client.blocks.children.list({ 190 | block_id: pageId, 191 | }) 192 | ).results; 193 | 194 | const overlap = Math.min(bodyBlocks.length, existingBlocks.length); 195 | 196 | await Promise.all( 197 | bodyBlocks.slice(0, overlap).map((block, index) => 198 | notion.client.blocks.update({ 199 | block_id: existingBlocks[index].id, 200 | ...block, 201 | }) 202 | ) 203 | ); 204 | 205 | if (bodyBlocks.length > existingBlocks.length) { 206 | await notion.client.blocks.children.append({ 207 | block_id: pageId, 208 | children: bodyBlocks.slice(overlap), 209 | }); 210 | } else if (bodyBlocks.length < existingBlocks.length) { 211 | await Promise.all( 212 | existingBlocks 213 | .slice(overlap) 214 | .map(block => notion.client.blocks.delete({block_id: block.id})) 215 | ); 216 | } 217 | } else { 218 | core.warning(`Could not find page with github id ${payload.issue.id}, creating a new one`); 219 | 220 | await notion.client.pages.create({ 221 | parent: { 222 | database_id: notion.databaseId, 223 | }, 224 | properties: await parsePropertiesFromPayload({payload, octokit}), 225 | children: bodyBlocks, 226 | }); 227 | } 228 | 229 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 230 | const result = query.results[0] as any, 231 | pageId = result.id, 232 | possible: ProjectData | undefined = result 233 | ? { 234 | name: ((result.properties as CustomValueMap).Project.rich_text[0] as RichTextItemResponse) 235 | ?.plain_text, 236 | columnName: ( 237 | (result.properties as CustomValueMap)['Project Column'] 238 | .rich_text[0] as RichTextItemResponse 239 | )?.plain_text, 240 | } 241 | : undefined; 242 | 243 | core.info(`Query successful: Page ${pageId}`); 244 | core.info(`Updating page for issue #${payload.issue.number}`); 245 | 246 | await notion.client.pages.update({ 247 | page_id: pageId, 248 | properties: await parsePropertiesFromPayload({ 249 | payload, 250 | octokit: options.octokit, 251 | possibleProject: possible, 252 | }), 253 | }); 254 | } 255 | 256 | interface Options { 257 | notion: { 258 | token: string; 259 | databaseId: string; 260 | }; 261 | github: { 262 | payload: WebhookPayload; 263 | eventName: string; 264 | token: string; 265 | }; 266 | } 267 | 268 | export async function run(options: Options) { 269 | const {notion, github} = options; 270 | 271 | core.info('Starting...'); 272 | 273 | const notionClient = new Client({ 274 | auth: notion.token, 275 | logLevel: core.isDebug() ? LogLevel.DEBUG : LogLevel.WARN, 276 | }); 277 | const octokit = new Octokit({auth: github.token}); 278 | 279 | if (github.payload.action === 'opened') { 280 | await handleIssueOpened({ 281 | notion: { 282 | client: notionClient, 283 | databaseId: notion.databaseId, 284 | }, 285 | payload: github.payload as IssuesOpenedEvent, 286 | octokit, 287 | }); 288 | } else if (github.eventName === 'workflow_dispatch') { 289 | const notion = new Client({auth: options.notion.token}); 290 | const {databaseId} = options.notion; 291 | const issuePageIds = await createIssueMapping(notion, databaseId); 292 | if (!github.payload.repository?.full_name) { 293 | throw new Error('Unable to find repository name in github webhook context'); 294 | } 295 | const githubRepo = github.payload.repository.full_name; 296 | await syncNotionDBWithGitHub(issuePageIds, octokit, notion, databaseId, githubRepo); 297 | } else { 298 | await handleIssueEdited({ 299 | notion: { 300 | client: notionClient, 301 | databaseId: notion.databaseId, 302 | }, 303 | payload: github.payload as IssuesEvent, 304 | octokit, 305 | }); 306 | } 307 | 308 | core.info('Complete!'); 309 | } 310 | -------------------------------------------------------------------------------- /src/api-types.ts: -------------------------------------------------------------------------------- 1 | export namespace CustomTypes { 2 | export type RichText = { 3 | rich_text: Array; 4 | type?: 'rich_text'; 5 | }; 6 | export type Title = { 7 | title: Array; 8 | type?: 'title'; 9 | }; 10 | export type Number = { 11 | number: number | null; 12 | type?: 'number'; 13 | }; 14 | export type Date = { 15 | date: DateRequest | null; 16 | type?: 'date'; 17 | }; 18 | export type Select = { 19 | select: 20 | | { 21 | id: StringRequest; 22 | name?: StringRequest; 23 | color?: SelectColor; 24 | } 25 | | null 26 | | { 27 | name: StringRequest; 28 | id?: StringRequest; 29 | color?: SelectColor; 30 | } 31 | | null; 32 | type?: 'select'; 33 | }; 34 | export type MultiSelect = { 35 | multi_select: Array< 36 | | { 37 | id: StringRequest; 38 | name?: StringRequest; 39 | color?: SelectColor; 40 | } 41 | | { 42 | name: StringRequest; 43 | id?: StringRequest; 44 | color?: SelectColor; 45 | } 46 | >; 47 | type?: 'multi_select'; 48 | }; 49 | export type URL = { 50 | url: TextRequest | null; 51 | type?: 'url'; 52 | }; 53 | } 54 | 55 | export type RichTextItemRequest = 56 | | { 57 | text: { 58 | content: string; 59 | link?: { 60 | url: TextRequest; 61 | } | null; 62 | }; 63 | type?: 'text'; 64 | annotations?: { 65 | bold?: boolean; 66 | italic?: boolean; 67 | strikethrough?: boolean; 68 | underline?: boolean; 69 | code?: boolean; 70 | color?: 71 | | 'default' 72 | | 'gray' 73 | | 'brown' 74 | | 'orange' 75 | | 'yellow' 76 | | 'green' 77 | | 'blue' 78 | | 'purple' 79 | | 'pink' 80 | | 'red' 81 | | 'gray_background' 82 | | 'brown_background' 83 | | 'orange_background' 84 | | 'yellow_background' 85 | | 'green_background' 86 | | 'blue_background' 87 | | 'purple_background' 88 | | 'pink_background' 89 | | 'red_background'; 90 | }; 91 | } 92 | | { 93 | mention: 94 | | { 95 | user: 96 | | { 97 | id: IdRequest; 98 | } 99 | | { 100 | person: { 101 | email?: string; 102 | }; 103 | id: IdRequest; 104 | type?: 'person'; 105 | name?: string | null; 106 | avatar_url?: string | null; 107 | object?: 'user'; 108 | } 109 | | { 110 | bot: 111 | | EmptyObject 112 | | { 113 | owner: 114 | | { 115 | type: 'user'; 116 | user: 117 | | { 118 | type: 'person'; 119 | person: { 120 | email: string; 121 | }; 122 | name: string | null; 123 | avatar_url: string | null; 124 | id: IdRequest; 125 | object: 'user'; 126 | } 127 | | { 128 | id: IdRequest; 129 | object: 'user'; 130 | }; 131 | } 132 | | { 133 | type: 'workspace'; 134 | workspace: true; 135 | }; 136 | }; 137 | id: IdRequest; 138 | type?: 'bot'; 139 | name?: string | null; 140 | avatar_url?: string | null; 141 | object?: 'user'; 142 | }; 143 | } 144 | | { 145 | date: DateRequest; 146 | } 147 | | { 148 | page: { 149 | id: IdRequest; 150 | }; 151 | } 152 | | { 153 | database: { 154 | id: IdRequest; 155 | }; 156 | }; 157 | type?: 'mention'; 158 | annotations?: { 159 | bold?: boolean; 160 | italic?: boolean; 161 | strikethrough?: boolean; 162 | underline?: boolean; 163 | code?: boolean; 164 | color?: 165 | | 'default' 166 | | 'gray' 167 | | 'brown' 168 | | 'orange' 169 | | 'yellow' 170 | | 'green' 171 | | 'blue' 172 | | 'purple' 173 | | 'pink' 174 | | 'red' 175 | | 'gray_background' 176 | | 'brown_background' 177 | | 'orange_background' 178 | | 'yellow_background' 179 | | 'green_background' 180 | | 'blue_background' 181 | | 'purple_background' 182 | | 'pink_background' 183 | | 'red_background'; 184 | }; 185 | } 186 | | { 187 | equation: { 188 | expression: TextRequest; 189 | }; 190 | type?: 'equation'; 191 | annotations?: { 192 | bold?: boolean; 193 | italic?: boolean; 194 | strikethrough?: boolean; 195 | underline?: boolean; 196 | code?: boolean; 197 | color?: 198 | | 'default' 199 | | 'gray' 200 | | 'brown' 201 | | 'orange' 202 | | 'yellow' 203 | | 'green' 204 | | 'blue' 205 | | 'purple' 206 | | 'pink' 207 | | 'red' 208 | | 'gray_background' 209 | | 'brown_background' 210 | | 'orange_background' 211 | | 'yellow_background' 212 | | 'green_background' 213 | | 'blue_background' 214 | | 'purple_background' 215 | | 'pink_background' 216 | | 'red_background'; 217 | }; 218 | }; 219 | 220 | export type RichTextItemResponse = 221 | | { 222 | type: 'text'; 223 | text: { 224 | content: string; 225 | link: { 226 | url: TextRequest; 227 | } | null; 228 | }; 229 | annotations: { 230 | bold: boolean; 231 | italic: boolean; 232 | strikethrough: boolean; 233 | underline: boolean; 234 | code: boolean; 235 | color: 236 | | 'default' 237 | | 'gray' 238 | | 'brown' 239 | | 'orange' 240 | | 'yellow' 241 | | 'green' 242 | | 'blue' 243 | | 'purple' 244 | | 'pink' 245 | | 'red' 246 | | 'gray_background' 247 | | 'brown_background' 248 | | 'orange_background' 249 | | 'yellow_background' 250 | | 'green_background' 251 | | 'blue_background' 252 | | 'purple_background' 253 | | 'pink_background' 254 | | 'red_background'; 255 | }; 256 | plain_text: string; 257 | href: string | null; 258 | } 259 | | { 260 | type: 'mention'; 261 | mention: 262 | | { 263 | type: 'user'; 264 | user: PartialUserObjectResponse; 265 | } 266 | | { 267 | type: 'date'; 268 | date: DateResponse; 269 | } 270 | | { 271 | type: 'link_preview'; 272 | link_preview: { 273 | url: TextRequest; 274 | }; 275 | } 276 | | { 277 | type: 'page'; 278 | page: { 279 | id: IdRequest; 280 | }; 281 | } 282 | | { 283 | type: 'database'; 284 | database: { 285 | id: IdRequest; 286 | }; 287 | }; 288 | annotations: { 289 | bold: boolean; 290 | italic: boolean; 291 | strikethrough: boolean; 292 | underline: boolean; 293 | code: boolean; 294 | color: 295 | | 'default' 296 | | 'gray' 297 | | 'brown' 298 | | 'orange' 299 | | 'yellow' 300 | | 'green' 301 | | 'blue' 302 | | 'purple' 303 | | 'pink' 304 | | 'red' 305 | | 'gray_background' 306 | | 'brown_background' 307 | | 'orange_background' 308 | | 'yellow_background' 309 | | 'green_background' 310 | | 'blue_background' 311 | | 'purple_background' 312 | | 'pink_background' 313 | | 'red_background'; 314 | }; 315 | plain_text: string; 316 | href: string | null; 317 | } 318 | | { 319 | type: 'equation'; 320 | equation: { 321 | expression: TextRequest; 322 | }; 323 | annotations: { 324 | bold: boolean; 325 | italic: boolean; 326 | strikethrough: boolean; 327 | underline: boolean; 328 | code: boolean; 329 | color: 330 | | 'default' 331 | | 'gray' 332 | | 'brown' 333 | | 'orange' 334 | | 'yellow' 335 | | 'green' 336 | | 'blue' 337 | | 'purple' 338 | | 'pink' 339 | | 'red' 340 | | 'gray_background' 341 | | 'brown_background' 342 | | 'orange_background' 343 | | 'yellow_background' 344 | | 'green_background' 345 | | 'blue_background' 346 | | 'purple_background' 347 | | 'pink_background' 348 | | 'red_background'; 349 | }; 350 | plain_text: string; 351 | href: string | null; 352 | }; 353 | 354 | declare type UserObjectResponse = 355 | | { 356 | type: 'person'; 357 | person: { 358 | email?: string; 359 | }; 360 | name: string | null; 361 | avatar_url: string | null; 362 | id: IdRequest; 363 | object: 'user'; 364 | } 365 | | { 366 | type: 'bot'; 367 | bot: 368 | | EmptyObject 369 | | { 370 | owner: 371 | | { 372 | type: 'user'; 373 | user: 374 | | { 375 | type: 'person'; 376 | person: { 377 | email: string; 378 | }; 379 | name: string | null; 380 | avatar_url: string | null; 381 | id: IdRequest; 382 | object: 'user'; 383 | } 384 | | { 385 | id: IdRequest; 386 | object: 'user'; 387 | }; 388 | } 389 | | { 390 | type: 'workspace'; 391 | workspace: true; 392 | }; 393 | }; 394 | name: string | null; 395 | avatar_url: string | null; 396 | id: IdRequest; 397 | object: 'user'; 398 | }; 399 | 400 | declare type PartialUserObjectResponse = 401 | | { 402 | id: IdRequest; 403 | object: 'user'; 404 | } 405 | | UserObjectResponse; 406 | 407 | export type DateRequest = { 408 | start: string; 409 | end?: string | null; 410 | time_zone?: TimeZoneRequest | null; 411 | }; 412 | 413 | declare type DateResponse = { 414 | start: string; 415 | end: string | null; 416 | time_zone: TimeZoneRequest | null; 417 | }; 418 | 419 | export type StringRequest = string; 420 | 421 | export type SelectColor = 422 | | 'default' 423 | | 'gray' 424 | | 'brown' 425 | | 'orange' 426 | | 'yellow' 427 | | 'green' 428 | | 'blue' 429 | | 'purple' 430 | | 'pink' 431 | | 'red'; 432 | 433 | export type IdRequest = string | string; 434 | 435 | export type EmptyObject = Record; 436 | 437 | export type TextRequest = string; 438 | 439 | export type TimeZoneRequest = 440 | | 'Africa/Abidjan' 441 | | 'Africa/Accra' 442 | | 'Africa/Addis_Ababa' 443 | | 'Africa/Algiers' 444 | | 'Africa/Asmara' 445 | | 'Africa/Asmera' 446 | | 'Africa/Bamako' 447 | | 'Africa/Bangui' 448 | | 'Africa/Banjul' 449 | | 'Africa/Bissau' 450 | | 'Africa/Blantyre' 451 | | 'Africa/Brazzaville' 452 | | 'Africa/Bujumbura' 453 | | 'Africa/Cairo' 454 | | 'Africa/Casablanca' 455 | | 'Africa/Ceuta' 456 | | 'Africa/Conakry' 457 | | 'Africa/Dakar' 458 | | 'Africa/Dar_es_Salaam' 459 | | 'Africa/Djibouti' 460 | | 'Africa/Douala' 461 | | 'Africa/El_Aaiun' 462 | | 'Africa/Freetown' 463 | | 'Africa/Gaborone' 464 | | 'Africa/Harare' 465 | | 'Africa/Johannesburg' 466 | | 'Africa/Juba' 467 | | 'Africa/Kampala' 468 | | 'Africa/Khartoum' 469 | | 'Africa/Kigali' 470 | | 'Africa/Kinshasa' 471 | | 'Africa/Lagos' 472 | | 'Africa/Libreville' 473 | | 'Africa/Lome' 474 | | 'Africa/Luanda' 475 | | 'Africa/Lubumbashi' 476 | | 'Africa/Lusaka' 477 | | 'Africa/Malabo' 478 | | 'Africa/Maputo' 479 | | 'Africa/Maseru' 480 | | 'Africa/Mbabane' 481 | | 'Africa/Mogadishu' 482 | | 'Africa/Monrovia' 483 | | 'Africa/Nairobi' 484 | | 'Africa/Ndjamena' 485 | | 'Africa/Niamey' 486 | | 'Africa/Nouakchott' 487 | | 'Africa/Ouagadougou' 488 | | 'Africa/Porto-Novo' 489 | | 'Africa/Sao_Tome' 490 | | 'Africa/Timbuktu' 491 | | 'Africa/Tripoli' 492 | | 'Africa/Tunis' 493 | | 'Africa/Windhoek' 494 | | 'America/Adak' 495 | | 'America/Anchorage' 496 | | 'America/Anguilla' 497 | | 'America/Antigua' 498 | | 'America/Araguaina' 499 | | 'America/Argentina/Buenos_Aires' 500 | | 'America/Argentina/Catamarca' 501 | | 'America/Argentina/ComodRivadavia' 502 | | 'America/Argentina/Cordoba' 503 | | 'America/Argentina/Jujuy' 504 | | 'America/Argentina/La_Rioja' 505 | | 'America/Argentina/Mendoza' 506 | | 'America/Argentina/Rio_Gallegos' 507 | | 'America/Argentina/Salta' 508 | | 'America/Argentina/San_Juan' 509 | | 'America/Argentina/San_Luis' 510 | | 'America/Argentina/Tucuman' 511 | | 'America/Argentina/Ushuaia' 512 | | 'America/Aruba' 513 | | 'America/Asuncion' 514 | | 'America/Atikokan' 515 | | 'America/Atka' 516 | | 'America/Bahia' 517 | | 'America/Bahia_Banderas' 518 | | 'America/Barbados' 519 | | 'America/Belem' 520 | | 'America/Belize' 521 | | 'America/Blanc-Sablon' 522 | | 'America/Boa_Vista' 523 | | 'America/Bogota' 524 | | 'America/Boise' 525 | | 'America/Buenos_Aires' 526 | | 'America/Cambridge_Bay' 527 | | 'America/Campo_Grande' 528 | | 'America/Cancun' 529 | | 'America/Caracas' 530 | | 'America/Catamarca' 531 | | 'America/Cayenne' 532 | | 'America/Cayman' 533 | | 'America/Chicago' 534 | | 'America/Chihuahua' 535 | | 'America/Coral_Harbour' 536 | | 'America/Cordoba' 537 | | 'America/Costa_Rica' 538 | | 'America/Creston' 539 | | 'America/Cuiaba' 540 | | 'America/Curacao' 541 | | 'America/Danmarkshavn' 542 | | 'America/Dawson' 543 | | 'America/Dawson_Creek' 544 | | 'America/Denver' 545 | | 'America/Detroit' 546 | | 'America/Dominica' 547 | | 'America/Edmonton' 548 | | 'America/Eirunepe' 549 | | 'America/El_Salvador' 550 | | 'America/Ensenada' 551 | | 'America/Fort_Nelson' 552 | | 'America/Fort_Wayne' 553 | | 'America/Fortaleza' 554 | | 'America/Glace_Bay' 555 | | 'America/Godthab' 556 | | 'America/Goose_Bay' 557 | | 'America/Grand_Turk' 558 | | 'America/Grenada' 559 | | 'America/Guadeloupe' 560 | | 'America/Guatemala' 561 | | 'America/Guayaquil' 562 | | 'America/Guyana' 563 | | 'America/Halifax' 564 | | 'America/Havana' 565 | | 'America/Hermosillo' 566 | | 'America/Indiana/Indianapolis' 567 | | 'America/Indiana/Knox' 568 | | 'America/Indiana/Marengo' 569 | | 'America/Indiana/Petersburg' 570 | | 'America/Indiana/Tell_City' 571 | | 'America/Indiana/Vevay' 572 | | 'America/Indiana/Vincennes' 573 | | 'America/Indiana/Winamac' 574 | | 'America/Indianapolis' 575 | | 'America/Inuvik' 576 | | 'America/Iqaluit' 577 | | 'America/Jamaica' 578 | | 'America/Jujuy' 579 | | 'America/Juneau' 580 | | 'America/Kentucky/Louisville' 581 | | 'America/Kentucky/Monticello' 582 | | 'America/Knox_IN' 583 | | 'America/Kralendijk' 584 | | 'America/La_Paz' 585 | | 'America/Lima' 586 | | 'America/Los_Angeles' 587 | | 'America/Louisville' 588 | | 'America/Lower_Princes' 589 | | 'America/Maceio' 590 | | 'America/Managua' 591 | | 'America/Manaus' 592 | | 'America/Marigot' 593 | | 'America/Martinique' 594 | | 'America/Matamoros' 595 | | 'America/Mazatlan' 596 | | 'America/Mendoza' 597 | | 'America/Menominee' 598 | | 'America/Merida' 599 | | 'America/Metlakatla' 600 | | 'America/Mexico_City' 601 | | 'America/Miquelon' 602 | | 'America/Moncton' 603 | | 'America/Monterrey' 604 | | 'America/Montevideo' 605 | | 'America/Montreal' 606 | | 'America/Montserrat' 607 | | 'America/Nassau' 608 | | 'America/New_York' 609 | | 'America/Nipigon' 610 | | 'America/Nome' 611 | | 'America/Noronha' 612 | | 'America/North_Dakota/Beulah' 613 | | 'America/North_Dakota/Center' 614 | | 'America/North_Dakota/New_Salem' 615 | | 'America/Ojinaga' 616 | | 'America/Panama' 617 | | 'America/Pangnirtung' 618 | | 'America/Paramaribo' 619 | | 'America/Phoenix' 620 | | 'America/Port-au-Prince' 621 | | 'America/Port_of_Spain' 622 | | 'America/Porto_Acre' 623 | | 'America/Porto_Velho' 624 | | 'America/Puerto_Rico' 625 | | 'America/Punta_Arenas' 626 | | 'America/Rainy_River' 627 | | 'America/Rankin_Inlet' 628 | | 'America/Recife' 629 | | 'America/Regina' 630 | | 'America/Resolute' 631 | | 'America/Rio_Branco' 632 | | 'America/Rosario' 633 | | 'America/Santa_Isabel' 634 | | 'America/Santarem' 635 | | 'America/Santiago' 636 | | 'America/Santo_Domingo' 637 | | 'America/Sao_Paulo' 638 | | 'America/Scoresbysund' 639 | | 'America/Shiprock' 640 | | 'America/Sitka' 641 | | 'America/St_Barthelemy' 642 | | 'America/St_Johns' 643 | | 'America/St_Kitts' 644 | | 'America/St_Lucia' 645 | | 'America/St_Thomas' 646 | | 'America/St_Vincent' 647 | | 'America/Swift_Current' 648 | | 'America/Tegucigalpa' 649 | | 'America/Thule' 650 | | 'America/Thunder_Bay' 651 | | 'America/Tijuana' 652 | | 'America/Toronto' 653 | | 'America/Tortola' 654 | | 'America/Vancouver' 655 | | 'America/Virgin' 656 | | 'America/Whitehorse' 657 | | 'America/Winnipeg' 658 | | 'America/Yakutat' 659 | | 'America/Yellowknife' 660 | | 'Antarctica/Casey' 661 | | 'Antarctica/Davis' 662 | | 'Antarctica/DumontDUrville' 663 | | 'Antarctica/Macquarie' 664 | | 'Antarctica/Mawson' 665 | | 'Antarctica/McMurdo' 666 | | 'Antarctica/Palmer' 667 | | 'Antarctica/Rothera' 668 | | 'Antarctica/South_Pole' 669 | | 'Antarctica/Syowa' 670 | | 'Antarctica/Troll' 671 | | 'Antarctica/Vostok' 672 | | 'Arctic/Longyearbyen' 673 | | 'Asia/Aden' 674 | | 'Asia/Almaty' 675 | | 'Asia/Amman' 676 | | 'Asia/Anadyr' 677 | | 'Asia/Aqtau' 678 | | 'Asia/Aqtobe' 679 | | 'Asia/Ashgabat' 680 | | 'Asia/Ashkhabad' 681 | | 'Asia/Atyrau' 682 | | 'Asia/Baghdad' 683 | | 'Asia/Bahrain' 684 | | 'Asia/Baku' 685 | | 'Asia/Bangkok' 686 | | 'Asia/Barnaul' 687 | | 'Asia/Beirut' 688 | | 'Asia/Bishkek' 689 | | 'Asia/Brunei' 690 | | 'Asia/Calcutta' 691 | | 'Asia/Chita' 692 | | 'Asia/Choibalsan' 693 | | 'Asia/Chongqing' 694 | | 'Asia/Chungking' 695 | | 'Asia/Colombo' 696 | | 'Asia/Dacca' 697 | | 'Asia/Damascus' 698 | | 'Asia/Dhaka' 699 | | 'Asia/Dili' 700 | | 'Asia/Dubai' 701 | | 'Asia/Dushanbe' 702 | | 'Asia/Famagusta' 703 | | 'Asia/Gaza' 704 | | 'Asia/Harbin' 705 | | 'Asia/Hebron' 706 | | 'Asia/Ho_Chi_Minh' 707 | | 'Asia/Hong_Kong' 708 | | 'Asia/Hovd' 709 | | 'Asia/Irkutsk' 710 | | 'Asia/Istanbul' 711 | | 'Asia/Jakarta' 712 | | 'Asia/Jayapura' 713 | | 'Asia/Jerusalem' 714 | | 'Asia/Kabul' 715 | | 'Asia/Kamchatka' 716 | | 'Asia/Karachi' 717 | | 'Asia/Kashgar' 718 | | 'Asia/Kathmandu' 719 | | 'Asia/Katmandu' 720 | | 'Asia/Khandyga' 721 | | 'Asia/Kolkata' 722 | | 'Asia/Krasnoyarsk' 723 | | 'Asia/Kuala_Lumpur' 724 | | 'Asia/Kuching' 725 | | 'Asia/Kuwait' 726 | | 'Asia/Macao' 727 | | 'Asia/Macau' 728 | | 'Asia/Magadan' 729 | | 'Asia/Makassar' 730 | | 'Asia/Manila' 731 | | 'Asia/Muscat' 732 | | 'Asia/Nicosia' 733 | | 'Asia/Novokuznetsk' 734 | | 'Asia/Novosibirsk' 735 | | 'Asia/Omsk' 736 | | 'Asia/Oral' 737 | | 'Asia/Phnom_Penh' 738 | | 'Asia/Pontianak' 739 | | 'Asia/Pyongyang' 740 | | 'Asia/Qatar' 741 | | 'Asia/Qostanay' 742 | | 'Asia/Qyzylorda' 743 | | 'Asia/Rangoon' 744 | | 'Asia/Riyadh' 745 | | 'Asia/Saigon' 746 | | 'Asia/Sakhalin' 747 | | 'Asia/Samarkand' 748 | | 'Asia/Seoul' 749 | | 'Asia/Shanghai' 750 | | 'Asia/Singapore' 751 | | 'Asia/Srednekolymsk' 752 | | 'Asia/Taipei' 753 | | 'Asia/Tashkent' 754 | | 'Asia/Tbilisi' 755 | | 'Asia/Tehran' 756 | | 'Asia/Tel_Aviv' 757 | | 'Asia/Thimbu' 758 | | 'Asia/Thimphu' 759 | | 'Asia/Tokyo' 760 | | 'Asia/Tomsk' 761 | | 'Asia/Ujung_Pandang' 762 | | 'Asia/Ulaanbaatar' 763 | | 'Asia/Ulan_Bator' 764 | | 'Asia/Urumqi' 765 | | 'Asia/Ust-Nera' 766 | | 'Asia/Vientiane' 767 | | 'Asia/Vladivostok' 768 | | 'Asia/Yakutsk' 769 | | 'Asia/Yangon' 770 | | 'Asia/Yekaterinburg' 771 | | 'Asia/Yerevan' 772 | | 'Atlantic/Azores' 773 | | 'Atlantic/Bermuda' 774 | | 'Atlantic/Canary' 775 | | 'Atlantic/Cape_Verde' 776 | | 'Atlantic/Faeroe' 777 | | 'Atlantic/Faroe' 778 | | 'Atlantic/Jan_Mayen' 779 | | 'Atlantic/Madeira' 780 | | 'Atlantic/Reykjavik' 781 | | 'Atlantic/South_Georgia' 782 | | 'Atlantic/St_Helena' 783 | | 'Atlantic/Stanley' 784 | | 'Australia/ACT' 785 | | 'Australia/Adelaide' 786 | | 'Australia/Brisbane' 787 | | 'Australia/Broken_Hill' 788 | | 'Australia/Canberra' 789 | | 'Australia/Currie' 790 | | 'Australia/Darwin' 791 | | 'Australia/Eucla' 792 | | 'Australia/Hobart' 793 | | 'Australia/LHI' 794 | | 'Australia/Lindeman' 795 | | 'Australia/Lord_Howe' 796 | | 'Australia/Melbourne' 797 | | 'Australia/NSW' 798 | | 'Australia/North' 799 | | 'Australia/Perth' 800 | | 'Australia/Queensland' 801 | | 'Australia/South' 802 | | 'Australia/Sydney' 803 | | 'Australia/Tasmania' 804 | | 'Australia/Victoria' 805 | | 'Australia/West' 806 | | 'Australia/Yancowinna' 807 | | 'Brazil/Acre' 808 | | 'Brazil/DeNoronha' 809 | | 'Brazil/East' 810 | | 'Brazil/West' 811 | | 'CET' 812 | | 'CST6CDT' 813 | | 'Canada/Atlantic' 814 | | 'Canada/Central' 815 | | 'Canada/Eastern' 816 | | 'Canada/Mountain' 817 | | 'Canada/Newfoundland' 818 | | 'Canada/Pacific' 819 | | 'Canada/Saskatchewan' 820 | | 'Canada/Yukon' 821 | | 'Chile/Continental' 822 | | 'Chile/EasterIsland' 823 | | 'Cuba' 824 | | 'EET' 825 | | 'EST' 826 | | 'EST5EDT' 827 | | 'Egypt' 828 | | 'Eire' 829 | | 'Etc/GMT' 830 | | 'Etc/GMT+0' 831 | | 'Etc/GMT+1' 832 | | 'Etc/GMT+10' 833 | | 'Etc/GMT+11' 834 | | 'Etc/GMT+12' 835 | | 'Etc/GMT+2' 836 | | 'Etc/GMT+3' 837 | | 'Etc/GMT+4' 838 | | 'Etc/GMT+5' 839 | | 'Etc/GMT+6' 840 | | 'Etc/GMT+7' 841 | | 'Etc/GMT+8' 842 | | 'Etc/GMT+9' 843 | | 'Etc/GMT-0' 844 | | 'Etc/GMT-1' 845 | | 'Etc/GMT-10' 846 | | 'Etc/GMT-11' 847 | | 'Etc/GMT-12' 848 | | 'Etc/GMT-13' 849 | | 'Etc/GMT-14' 850 | | 'Etc/GMT-2' 851 | | 'Etc/GMT-3' 852 | | 'Etc/GMT-4' 853 | | 'Etc/GMT-5' 854 | | 'Etc/GMT-6' 855 | | 'Etc/GMT-7' 856 | | 'Etc/GMT-8' 857 | | 'Etc/GMT-9' 858 | | 'Etc/GMT0' 859 | | 'Etc/Greenwich' 860 | | 'Etc/UCT' 861 | | 'Etc/UTC' 862 | | 'Etc/Universal' 863 | | 'Etc/Zulu' 864 | | 'Europe/Amsterdam' 865 | | 'Europe/Andorra' 866 | | 'Europe/Astrakhan' 867 | | 'Europe/Athens' 868 | | 'Europe/Belfast' 869 | | 'Europe/Belgrade' 870 | | 'Europe/Berlin' 871 | | 'Europe/Bratislava' 872 | | 'Europe/Brussels' 873 | | 'Europe/Bucharest' 874 | | 'Europe/Budapest' 875 | | 'Europe/Busingen' 876 | | 'Europe/Chisinau' 877 | | 'Europe/Copenhagen' 878 | | 'Europe/Dublin' 879 | | 'Europe/Gibraltar' 880 | | 'Europe/Guernsey' 881 | | 'Europe/Helsinki' 882 | | 'Europe/Isle_of_Man' 883 | | 'Europe/Istanbul' 884 | | 'Europe/Jersey' 885 | | 'Europe/Kaliningrad' 886 | | 'Europe/Kiev' 887 | | 'Europe/Kirov' 888 | | 'Europe/Lisbon' 889 | | 'Europe/Ljubljana' 890 | | 'Europe/London' 891 | | 'Europe/Luxembourg' 892 | | 'Europe/Madrid' 893 | | 'Europe/Malta' 894 | | 'Europe/Mariehamn' 895 | | 'Europe/Minsk' 896 | | 'Europe/Monaco' 897 | | 'Europe/Moscow' 898 | | 'Europe/Nicosia' 899 | | 'Europe/Oslo' 900 | | 'Europe/Paris' 901 | | 'Europe/Podgorica' 902 | | 'Europe/Prague' 903 | | 'Europe/Riga' 904 | | 'Europe/Rome' 905 | | 'Europe/Samara' 906 | | 'Europe/San_Marino' 907 | | 'Europe/Sarajevo' 908 | | 'Europe/Saratov' 909 | | 'Europe/Simferopol' 910 | | 'Europe/Skopje' 911 | | 'Europe/Sofia' 912 | | 'Europe/Stockholm' 913 | | 'Europe/Tallinn' 914 | | 'Europe/Tirane' 915 | | 'Europe/Tiraspol' 916 | | 'Europe/Ulyanovsk' 917 | | 'Europe/Uzhgorod' 918 | | 'Europe/Vaduz' 919 | | 'Europe/Vatican' 920 | | 'Europe/Vienna' 921 | | 'Europe/Vilnius' 922 | | 'Europe/Volgograd' 923 | | 'Europe/Warsaw' 924 | | 'Europe/Zagreb' 925 | | 'Europe/Zaporozhye' 926 | | 'Europe/Zurich' 927 | | 'GB' 928 | | 'GB-Eire' 929 | | 'GMT' 930 | | 'GMT+0' 931 | | 'GMT-0' 932 | | 'GMT0' 933 | | 'Greenwich' 934 | | 'HST' 935 | | 'Hongkong' 936 | | 'Iceland' 937 | | 'Indian/Antananarivo' 938 | | 'Indian/Chagos' 939 | | 'Indian/Christmas' 940 | | 'Indian/Cocos' 941 | | 'Indian/Comoro' 942 | | 'Indian/Kerguelen' 943 | | 'Indian/Mahe' 944 | | 'Indian/Maldives' 945 | | 'Indian/Mauritius' 946 | | 'Indian/Mayotte' 947 | | 'Indian/Reunion' 948 | | 'Iran' 949 | | 'Israel' 950 | | 'Jamaica' 951 | | 'Japan' 952 | | 'Kwajalein' 953 | | 'Libya' 954 | | 'MET' 955 | | 'MST' 956 | | 'MST7MDT' 957 | | 'Mexico/BajaNorte' 958 | | 'Mexico/BajaSur' 959 | | 'Mexico/General' 960 | | 'NZ' 961 | | 'NZ-CHAT' 962 | | 'Navajo' 963 | | 'PRC' 964 | | 'PST8PDT' 965 | | 'Pacific/Apia' 966 | | 'Pacific/Auckland' 967 | | 'Pacific/Bougainville' 968 | | 'Pacific/Chatham' 969 | | 'Pacific/Chuuk' 970 | | 'Pacific/Easter' 971 | | 'Pacific/Efate' 972 | | 'Pacific/Enderbury' 973 | | 'Pacific/Fakaofo' 974 | | 'Pacific/Fiji' 975 | | 'Pacific/Funafuti' 976 | | 'Pacific/Galapagos' 977 | | 'Pacific/Gambier' 978 | | 'Pacific/Guadalcanal' 979 | | 'Pacific/Guam' 980 | | 'Pacific/Honolulu' 981 | | 'Pacific/Johnston' 982 | | 'Pacific/Kiritimati' 983 | | 'Pacific/Kosrae' 984 | | 'Pacific/Kwajalein' 985 | | 'Pacific/Majuro' 986 | | 'Pacific/Marquesas' 987 | | 'Pacific/Midway' 988 | | 'Pacific/Nauru' 989 | | 'Pacific/Niue' 990 | | 'Pacific/Norfolk' 991 | | 'Pacific/Noumea' 992 | | 'Pacific/Pago_Pago' 993 | | 'Pacific/Palau' 994 | | 'Pacific/Pitcairn' 995 | | 'Pacific/Pohnpei' 996 | | 'Pacific/Ponape' 997 | | 'Pacific/Port_Moresby' 998 | | 'Pacific/Rarotonga' 999 | | 'Pacific/Saipan' 1000 | | 'Pacific/Samoa' 1001 | | 'Pacific/Tahiti' 1002 | | 'Pacific/Tarawa' 1003 | | 'Pacific/Tongatapu' 1004 | | 'Pacific/Truk' 1005 | | 'Pacific/Wake' 1006 | | 'Pacific/Wallis' 1007 | | 'Pacific/Yap' 1008 | | 'Poland' 1009 | | 'Portugal' 1010 | | 'ROC' 1011 | | 'ROK' 1012 | | 'Singapore' 1013 | | 'Turkey' 1014 | | 'UCT' 1015 | | 'US/Alaska' 1016 | | 'US/Aleutian' 1017 | | 'US/Arizona' 1018 | | 'US/Central' 1019 | | 'US/East-Indiana' 1020 | | 'US/Eastern' 1021 | | 'US/Hawaii' 1022 | | 'US/Indiana-Starke' 1023 | | 'US/Michigan' 1024 | | 'US/Mountain' 1025 | | 'US/Pacific' 1026 | | 'US/Pacific-New' 1027 | | 'US/Samoa' 1028 | | 'UTC' 1029 | | 'Universal' 1030 | | 'W-SU' 1031 | | 'WET' 1032 | | 'Zulu'; 1033 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import {CustomTypes, RichTextItemRequest} from './api-types'; 2 | 3 | // https://developers.notion.com/reference/errors#limits-for-property-values 4 | export const RICH_TEXT_CONTENT_CHARACTERS_LIMIT = 1000; 5 | 6 | function truncateTextContent(text: string): string { 7 | return text.length > RICH_TEXT_CONTENT_CHARACTERS_LIMIT 8 | ? text.substring(0, RICH_TEXT_CONTENT_CHARACTERS_LIMIT - 1) + '…' 9 | : text; 10 | } 11 | 12 | export namespace common { 13 | export interface RichTextOptions { 14 | annotations?: RichTextItemRequest['annotations']; 15 | url?: string; 16 | } 17 | 18 | export function richText( 19 | content: string, 20 | options: RichTextOptions = {} 21 | ): CustomTypes.RichText['rich_text'] { 22 | const annotations = options.annotations ?? {}; 23 | const truncated = truncateTextContent(content); 24 | 25 | return [ 26 | { 27 | type: 'text', 28 | annotations: { 29 | bold: false, 30 | strikethrough: false, 31 | underline: false, 32 | italic: false, 33 | code: false, 34 | color: 'default', 35 | ...annotations, 36 | }, 37 | text: { 38 | content: truncated, 39 | link: options.url ? {url: options.url} : undefined, 40 | }, 41 | }, 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as github from '@actions/github'; 3 | import {run} from './action'; 4 | 5 | const INPUTS = { 6 | NOTION_TOKEN: 'notion-token', 7 | NOTION_DB: 'notion-db', 8 | GITHUB_TOKEN: 'github-token', 9 | }; 10 | 11 | async function start() { 12 | try { 13 | const notionToken = core.getInput(INPUTS.NOTION_TOKEN, {required: true}); 14 | const notionDb = core.getInput(INPUTS.NOTION_DB, {required: true}); 15 | const githubToken = core.getInput(INPUTS.GITHUB_TOKEN, {required: true}); 16 | 17 | core.info(`context event: ${github.context.eventName}`); 18 | core.info(`context action: ${github.context.action}`); 19 | core.info(`payload action: ${github.context.payload.action}`); 20 | const options = { 21 | notion: { 22 | token: notionToken, 23 | databaseId: notionDb, 24 | }, 25 | github: { 26 | payload: github.context.payload, 27 | eventName: github.context.eventName, 28 | token: githubToken, 29 | }, 30 | }; 31 | 32 | await run(options); 33 | } catch (e) { 34 | core.setFailed(e instanceof Error ? e.message : e + ''); 35 | } 36 | } 37 | 38 | (async () => { 39 | await start(); 40 | })(); 41 | -------------------------------------------------------------------------------- /src/properties.ts: -------------------------------------------------------------------------------- 1 | import {CustomTypes, SelectColor} from './api-types'; 2 | import {common} from './common'; 3 | 4 | export type CustomValueMap = { 5 | Name: CustomTypes.Title; 6 | Status: CustomTypes.Select; 7 | Organization: CustomTypes.RichText; 8 | Repository: CustomTypes.RichText; 9 | Number: CustomTypes.Number; 10 | Body: CustomTypes.RichText; 11 | Assignees: CustomTypes.MultiSelect; 12 | Milestone: CustomTypes.RichText; 13 | Labels: CustomTypes.MultiSelect; 14 | Author: CustomTypes.RichText; 15 | Created: CustomTypes.Date; 16 | Updated: CustomTypes.Date; 17 | ID: CustomTypes.Number; 18 | Link: CustomTypes.URL; 19 | Project: CustomTypes.RichText; 20 | 'Project Column': CustomTypes.RichText; 21 | }; 22 | 23 | export namespace properties { 24 | export function text(text: string): CustomTypes.RichText { 25 | return { 26 | type: 'rich_text', 27 | rich_text: text ? common.richText(text) : [], 28 | }; 29 | } 30 | 31 | export function richText(text: CustomTypes.RichText['rich_text']): CustomTypes.RichText { 32 | return { 33 | type: 'rich_text', 34 | rich_text: text, 35 | }; 36 | } 37 | 38 | export function title(text: string): CustomTypes.Title { 39 | return { 40 | type: 'title', 41 | title: [ 42 | { 43 | type: 'text', 44 | text: { 45 | content: text, 46 | }, 47 | }, 48 | ], 49 | }; 50 | } 51 | 52 | export function number(number: number): CustomTypes.Number { 53 | return { 54 | type: 'number', 55 | number: number, 56 | }; 57 | } 58 | 59 | export function date(time: string): CustomTypes.Date { 60 | return { 61 | type: 'date', 62 | date: { 63 | start: time, 64 | }, 65 | }; 66 | } 67 | 68 | export function getStatusSelectOption(state: 'open' | 'closed'): CustomTypes.Select { 69 | switch (state) { 70 | case 'open': 71 | return select('Open', 'green'); 72 | case 'closed': 73 | return select('Closed', 'red'); 74 | } 75 | } 76 | 77 | export function select(name: string, color: SelectColor = 'default'): CustomTypes.Select { 78 | return { 79 | type: 'select', 80 | select: { 81 | name: name, 82 | color: color, 83 | }, 84 | }; 85 | } 86 | 87 | export function multiSelect(names: string[]): CustomTypes.MultiSelect { 88 | return { 89 | type: 'multi_select', 90 | multi_select: names.map(name => { 91 | return { 92 | name: name, 93 | }; 94 | }), 95 | }; 96 | } 97 | 98 | export function url(url: string): CustomTypes.URL { 99 | return { 100 | type: 'url', 101 | url, 102 | }; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/sync.ts: -------------------------------------------------------------------------------- 1 | import {Client} from '@notionhq/client/build/src'; 2 | import {Issue} from '@octokit/webhooks-types/schema'; 3 | import * as core from '@actions/core'; 4 | import {Octokit} from 'octokit'; 5 | import {CustomValueMap, properties} from './properties'; 6 | import {getProjectData} from './action'; 7 | import {QueryDatabaseResponse} from '@notionhq/client/build/src/api-endpoints'; 8 | import {CustomTypes} from './api-types'; 9 | import {parseBodyRichText} from './action'; 10 | 11 | type PageIdAndIssueNumber = { 12 | pageId: string; 13 | issueNumber: number; 14 | }; 15 | 16 | export async function createIssueMapping( 17 | notion: Client, 18 | databaseId: string 19 | ): Promise> { 20 | const issuePageIds = new Map(); 21 | const issuesAlreadyInNotion: { 22 | pageId: string; 23 | issueNumber: number; 24 | }[] = await getIssuesAlreadyInNotion(notion, databaseId); 25 | for (const {pageId, issueNumber} of issuesAlreadyInNotion) { 26 | issuePageIds.set(issueNumber, pageId); 27 | } 28 | return issuePageIds; 29 | } 30 | 31 | export async function syncNotionDBWithGitHub( 32 | issuePageIds: Map, 33 | octokit: Octokit, 34 | notion: Client, 35 | databaseId: string, 36 | githubRepo: string 37 | ) { 38 | const issues = await getGitHubIssues(octokit, githubRepo); 39 | const pagesToCreate = getIssuesNotInNotion(issuePageIds, issues); 40 | await createPages(notion, databaseId, pagesToCreate, octokit); 41 | } 42 | 43 | // Notion SDK for JS: https://developers.notion.com/reference/post-database-query 44 | async function getIssuesAlreadyInNotion( 45 | notion: Client, 46 | databaseId: string 47 | ): Promise { 48 | core.info('Checking for issues already in the database...'); 49 | const pages: QueryDatabaseResponse['results'] = []; 50 | let cursor = undefined; 51 | let next_cursor: string | null = 'true'; 52 | while (next_cursor) { 53 | const response: QueryDatabaseResponse = await notion.databases.query({ 54 | database_id: databaseId, 55 | start_cursor: cursor, 56 | }); 57 | next_cursor = response.next_cursor; 58 | const results = response.results; 59 | pages.push(...results); 60 | if (!next_cursor) { 61 | break; 62 | } 63 | cursor = next_cursor; 64 | } 65 | 66 | const res: PageIdAndIssueNumber[] = []; 67 | 68 | pages.forEach(page => { 69 | if ('properties' in page) { 70 | let num: number | null = null; 71 | num = (page.properties['Number'] as CustomTypes.Number).number as number; 72 | if (typeof num !== 'undefined') 73 | res.push({ 74 | pageId: page.id, 75 | issueNumber: num, 76 | }); 77 | } 78 | }); 79 | 80 | return res; 81 | } 82 | 83 | // https://docs.github.com/en/rest/reference/issues#list-repository-issues 84 | async function getGitHubIssues(octokit: Octokit, githubRepo: string): Promise { 85 | core.info('Finding Github Issues...'); 86 | const issues: Issue[] = []; 87 | const iterator = octokit.paginate.iterator(octokit.rest.issues.listForRepo, { 88 | owner: githubRepo.split('/')[0], 89 | repo: githubRepo.split('/')[1], 90 | state: 'all', 91 | per_page: 100, 92 | }); 93 | for await (const {data} of iterator) { 94 | for (const issue of data) { 95 | if (!issue.pull_request) { 96 | issues.push(issue); 97 | } 98 | } 99 | } 100 | return issues; 101 | } 102 | 103 | function getIssuesNotInNotion(issuePageIds: Map, issues: Issue[]): Issue[] { 104 | const pagesToCreate: Issue[] = []; 105 | for (const issue of issues) { 106 | if (!issuePageIds.has(issue.number)) { 107 | pagesToCreate.push(issue); 108 | } 109 | } 110 | return pagesToCreate; 111 | } 112 | 113 | // Notion SDK for JS: https://developers.notion.com/reference/post-page 114 | async function createPages( 115 | notion: Client, 116 | databaseId: string, 117 | pagesToCreate: Issue[], 118 | octokit: Octokit 119 | ): Promise { 120 | core.info('Adding Github Issues to Notion...'); 121 | await Promise.all( 122 | pagesToCreate.map(async issue => 123 | notion.pages.create({ 124 | parent: {database_id: databaseId}, 125 | properties: await getPropertiesFromIssue(issue, octokit), 126 | }) 127 | ) 128 | ); 129 | } 130 | 131 | /* 132 | * For the `Assignees` field in the Notion DB we want to send only issues.assignees.login 133 | * For the `Labels` field in the Notion DB we want to send only issues.labels.name 134 | */ 135 | function createMultiSelectObjects(issue: Issue): { 136 | assigneesObject: string[]; 137 | labelsObject: string[] | undefined; 138 | } { 139 | const assigneesObject = issue.assignees.map((assignee: {login: string}) => assignee.login); 140 | const labelsObject = issue.labels?.map((label: {name: string}) => label.name); 141 | return {assigneesObject, labelsObject}; 142 | } 143 | 144 | async function getPropertiesFromIssue(issue: Issue, octokit: Octokit): Promise { 145 | const { 146 | number, 147 | title, 148 | state, 149 | id, 150 | milestone, 151 | created_at, 152 | updated_at, 153 | body, 154 | repository_url, 155 | user, 156 | html_url, 157 | } = issue; 158 | const author = user?.login; 159 | const {assigneesObject, labelsObject} = createMultiSelectObjects(issue); 160 | const urlComponents = repository_url.split('/'); 161 | const org = urlComponents[urlComponents.length - 2]; 162 | const repo = urlComponents[urlComponents.length - 1]; 163 | 164 | const projectData = await getProjectData({ 165 | octokit, 166 | githubRepo: `${org}/${repo}`, 167 | issueNumber: issue.number, 168 | }); 169 | 170 | // These properties are specific to the template DB referenced in the README. 171 | return { 172 | Name: properties.title(title), 173 | Status: properties.getStatusSelectOption(state!), 174 | Organization: properties.text(org), 175 | Repository: properties.text(repo), 176 | Body: properties.richText(parseBodyRichText(body || '')), 177 | Number: properties.number(number), 178 | Assignees: properties.multiSelect(assigneesObject), 179 | Milestone: properties.text(milestone ? milestone.title : ''), 180 | Labels: properties.multiSelect(labelsObject ? labelsObject : []), 181 | Author: properties.text(author), 182 | Created: properties.date(created_at), 183 | Updated: properties.date(updated_at), 184 | ID: properties.number(id), 185 | Link: properties.url(html_url), 186 | Project: properties.text(projectData?.name || ''), 187 | 'Project Column': properties.text(projectData?.columnName || ''), 188 | }; 189 | } 190 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2019" 5 | ], 6 | "module": "commonjs", 7 | "target": "es6", 8 | "sourceMap": true, 9 | "strict": true, 10 | "outDir": "dist", 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node", 17 | "esModuleInterop" : true 18 | }, 19 | "exclude": [ 20 | "node_modules" 21 | ], 22 | } 23 | --------------------------------------------------------------------------------