├── .all-contributorsrc ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── 01_bug_report.yml │ ├── 02_feature_request.yml │ └── config.yml └── workflows │ ├── release.yml │ ├── test.yml │ └── update-prettier.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── api ├── errors.js ├── items.add-draft.js ├── items.add.js ├── items.archive-by-content-id.js ├── items.archive-by-content-repository-and-number.js ├── items.archive.js ├── items.get-by-content-id.js ├── items.get-by-content-repository-and-number.js ├── items.get.js ├── items.list.js ├── items.remove-by-content-id.js ├── items.remove-by-content-repository-and-name.js ├── items.remove.js ├── items.update-by-content-id.js ├── items.update-by-content-repository-and-number.js ├── items.update.js ├── lib │ ├── archive-project-item.js │ ├── default-match-function.js │ ├── default-truncate-function.js │ ├── get-fields-update-query-and-fields.js │ ├── get-state-with-project-fields.js │ ├── handle-not-found-graphql-error.js │ ├── item-fields-nodes-to-fields-map.js │ ├── project-fields-nodes-to-fields-map.js │ ├── project-item-node-to-github-project-item.js │ ├── project-node-to-properties.js │ ├── queries.js │ ├── remove-object-keys.js │ ├── remove-project-item.js │ └── update-project-item-fields.js └── project.getProperties.js ├── index.d.ts ├── index.js ├── index.test-d.ts ├── jsconfig.json ├── package-lock.json ├── package.json ├── test.js.md └── test ├── constructor.test.js ├── recorded.test.js ├── recorded ├── _lib │ ├── clear-test-project.js │ ├── create-test-repository.js │ ├── delete-all-test-repositories.js │ └── octokit.js ├── api.getProperties-field-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.getProperties-project-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.getProperties │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-custom-truncate-function │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-empty-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-invalid-date │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-loaded-items │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft-with-too-long-text │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-draft │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-existing-item-after-api.items.list │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-existing-item-with-fields-after-api.items.list │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-multiple-for-same-issue │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-pull-request │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-with-custom-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-with-optional-non-existing-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-with-quotes-in-value │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add-without-configuring-custom-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.add │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.archive-by-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.archive-by-content-repository-and-number │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.archive │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-archived │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-by-content-content-repository-and-number │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-by-content-id-with-non-optional-missing-user-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-by-content-id-with-optional-user-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-by-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-draft-item │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get-using-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.get │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-multiple-calls │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-then-api.items.add-then-api.items.get-by-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-then-api.items.remove-clears-cache │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-then-api.items.remove-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-then-api.items.update │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-with-fields-using-wrong-capitalization │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-with-match-field-name-option │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-with-pagination │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list-without-custom-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.list │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove-by-content-id-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove-by-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove-by-repository-and-number-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove-by-repository-and-number │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.remove │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-built-in-read-only-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-id-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-id-optional-non-existing-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-id │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-repository-and-number-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-repository-and-number-optional-non-existing-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-by-content-repository-and-number │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-iteration │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-optional-non-existing-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-status-with-spaces-in-field-keys │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-status-without-user-fields │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-unsetting-single-select-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-unsetting-text-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-emoji-alias │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-empty-string │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-invalid-field-option │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-match-field-option-value-constructor-option │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-undefined-value │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-unknown-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update-with-user-defined-status-field │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── api.items.update │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── getInstance-field-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── getInstance-project-not-found │ ├── fixtures.json │ ├── prepare.js │ └── test.js ├── getInstance │ ├── fixtures.json │ ├── prepare.js │ └── test.js └── record-fixtures.js ├── smoke.test.js └── snapshots ├── recorded.test.js.md └── recorded.test.js.snap /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "gr2m", 12 | "name": "Gregor Martynus", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/39992?v=4", 14 | "profile": "https://dev.to/gr2m", 15 | "contributions": [ 16 | "ideas", 17 | "code", 18 | "test", 19 | "review", 20 | "maintenance", 21 | "infra" 22 | ] 23 | }, 24 | { 25 | "login": "mikesurowiec", 26 | "name": "Mike Surowiec", 27 | "avatar_url": "https://avatars.githubusercontent.com/u/821435?v=4", 28 | "profile": "https://mikesurowiec.com", 29 | "contributions": [ 30 | "code", 31 | "test" 32 | ] 33 | }, 34 | { 35 | "login": "tmelliottjr", 36 | "name": "Tom Elliott", 37 | "avatar_url": "https://avatars.githubusercontent.com/u/13594679?v=4", 38 | "profile": "https://github.com/tmelliottjr", 39 | "contributions": [ 40 | "code", 41 | "test", 42 | "review" 43 | ] 44 | }, 45 | { 46 | "login": "maxisam", 47 | "name": "Sam Lin", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/456807?v=4", 49 | "profile": "http://maxisam.github.io/", 50 | "contributions": [ 51 | "code", 52 | "test" 53 | ] 54 | }, 55 | { 56 | "login": "Ebonsignori", 57 | "name": "Evan Bonsignori", 58 | "avatar_url": "https://avatars.githubusercontent.com/u/17055832?v=4", 59 | "profile": "http://evan.bio", 60 | "contributions": [ 61 | "code", 62 | "test", 63 | "doc" 64 | ] 65 | }, 66 | { 67 | "login": "blombard", 68 | "name": "Baptiste Lombard", 69 | "avatar_url": "https://avatars.githubusercontent.com/u/17877656?v=4", 70 | "profile": "https://www.leexi.ai/", 71 | "contributions": [ 72 | "code", 73 | "test" 74 | ] 75 | } 76 | ], 77 | "contributorsPerLine": 7, 78 | "skipCi": true, 79 | "repoType": "github", 80 | "repoHost": "https://github.com", 81 | "projectName": "github-project", 82 | "projectOwner": "gr2m" 83 | } 84 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # used for recording fixtures 2 | TEST_REPOSITORY_NAME_PREFIX= 3 | PROJECT_NUMBER= 4 | GH_PROJECT_FIXTURES_APP_ID= 5 | GH_PROJECT_FIXTURES_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- 6 | ... 7 | -----END RSA PRIVATE KEY-----" 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_bug_report.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | description: "Something isn't working as expected 🤔" 4 | labels: bug 5 | body: 6 | - type: checkboxes 7 | id: search 8 | attributes: 9 | label: Please avoid duplicates 10 | options: 11 | - label: I checked [all open bugs](https://github.com/gr2m/github-project/issues?q=is%3Aissue+is%3Aopen+label%3Abug) and none of them matched my problem. 12 | required: true 13 | - type: input 14 | id: testcase 15 | attributes: 16 | label: Reproducible test case 17 | description: | 18 | If possible, please create a minimal test case that reproduces your problem. For TypeScript-only problems, you can create TypeScript playground ([example](https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAbzgeQMYwga2PAvnAMyghDgCIABCdLHAelWgFMyAoAgVwDt1gIu4wAM4BlGFGBcA5gAoA7gAsAhjABccIeMlSAlIlytWjLprjUM2eAF44XJnJQ1LMnYeOmkAExVK4+G0pySjhmTjgAdFBMAI4cTJoyZADiAKIAKnB0ZK6sdHRwFDBCALRMAB5gTOilUMRQcABWHKa+nkwgEKzCYhLSMt4wSuGeEPEA+lwQMGPlwjA6QA)). For code-related issues, please create a RunKit Notebook ([example](https://runkit.com/gr2m/octokit-auth-oauth-app-js-178/1.0.0)). 19 | - type: checkboxes 20 | id: environment 21 | attributes: 22 | label: Please select the environment(s) that are relevant to your bug report 23 | options: 24 | - label: Browsers 25 | - label: Node 26 | - label: Deno 27 | - type: textarea 28 | id: versions 29 | attributes: 30 | label: Versions 31 | description: Please share the versions of `github-project` and Node/Deno/Browser that you are using. 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: summary 36 | attributes: 37 | label: What happened? 38 | description: Please share any other relevant information not mentioned above. What did you expect to happen? What do you think the problem might be? 39 | - type: checkboxes 40 | id: contributor 41 | attributes: 42 | label: Would you be interested in contributing a fix? 43 | options: 44 | - label: "yes" 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_feature_request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🧚‍♂️ Feature Request" 3 | description: "Wouldn’t it be nice if 💭" 4 | labels: feature 5 | body: 6 | - type: checkboxes 7 | id: search 8 | attributes: 9 | label: Please avoid duplicates 10 | options: 11 | - label: I checked [all open feature requests](https://github.com/gr2m/github-project/issues?q=is%3Aissue+is%3Aopen+label%3Afeature) and none of them matched my problem. 12 | required: true 13 | - type: textarea 14 | id: summary 15 | attributes: 16 | label: What’s missing? 17 | description: Describe your feature idea 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: why 22 | attributes: 23 | label: Why? 24 | description: Describe the problem you are facing 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: alternatives 29 | attributes: 30 | label: Alternatives you tried 31 | description: Describe the workarounds you tried so far and how they worked for you 32 | validations: 33 | required: true 34 | - type: checkboxes 35 | id: contributor 36 | attributes: 37 | label: Would you be interested in contributing the feature? 38 | options: 39 | - label: "yes" 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 🆘 I need Help 4 | url: https://github.com/gr2m/github-projects/discussions 5 | about: Got a question? An idea? Feedback? Start here. 6 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - "*.x" 6 | - main 7 | - next 8 | - beta 9 | jobs: 10 | release: 11 | name: release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: lts/* 18 | cache: npm 19 | - run: npm ci 20 | - run: npx semantic-release 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize] 8 | 9 | jobs: 10 | test_matrix: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node_version: 15 | - 18 16 | - 20 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node_version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node_version }} 24 | cache: npm 25 | - run: npm ci 26 | - run: npm run test:code 27 | 28 | test: 29 | runs-on: ubuntu-latest 30 | needs: test_matrix 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: ${{ matrix.node_version }} 36 | cache: npm 37 | - run: npm ci 38 | - run: npm run test:tsc 39 | - run: npm run test:tsd 40 | - run: npm run lint 41 | -------------------------------------------------------------------------------- /.github/workflows/update-prettier.yml: -------------------------------------------------------------------------------- 1 | name: Update Prettier 2 | "on": 3 | push: 4 | branches: 5 | - renovate/prettier-* 6 | workflow_dispatch: {} 7 | jobs: 8 | update_prettier: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | cache: npm 15 | node-version: lts/* 16 | - run: npm ci 17 | - run: npm run lint:fix 18 | - run: | 19 | git config user.name github-actions 20 | git config user.email github-actions@github.com 21 | git add . 22 | git commit -m "style: prettier" && git push || true 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | coverage 3 | node_modules -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at coc+github-project@martynus.net. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering to contribute to `github-project` 💖 4 | 5 | Please note that this project is released with a [Contributor Code of Conduct](./CODE_OF_CONDUCT.md). 6 | By participating you agree to abide by its terms. 7 | 8 | ## Setup 9 | 10 | Node.js 16 or higher is required. Install it from https://nodejs.org/en/. [GitHub's `gh` CLI](https://cli.github.com/) is recommended for the initial setup 11 | 12 | 1. Fork this repository and clone it to your local machine. Using `gh` you can do this 13 | 14 | ``` 15 | gh repo fork gr2m/github-project 16 | ``` 17 | 18 | 2. After cloning and changing into the `github-project` directory, install dependencies and run the tests 19 | 20 | ``` 21 | npm install 22 | npm test 23 | ``` 24 | 25 | Few notes 26 | 27 | - `npm test` runs all kind of tests. You can run the code tests in isolation with `npm run test:code`. Use `npm run` to see all available scripts. 28 | - If coverage drops, run `npm run coverage` to open a coverage report in your browser. 29 | - Make sure that update types in `index.d.ts` that reflect any features / fixes you might have implemented. 30 | 31 | ## Issues before pull requests 32 | 33 | Unless the change is trivial such as a type, please [open an issue first](https://github.com/gr2m/github-project/issues/new) before starting a pull request for a bug fix or a new feature. 34 | 35 | After you cloned your fork, create a new branch and implement the changes in them. To start a pull request, you can use the [`gh` CLI](https://cli.github.com/) 36 | 37 | ``` 38 | gh pr create 39 | ``` 40 | 41 | ## Recording fixtures for testing 42 | 43 | Most parts of `github-project` are tested using full integration tests, using fixtures for GraphQL requests and responses. You can see all the tests with their fixtures in [`test/recorded/`](test/recorded/). 44 | 45 | If you changed how `github-project` is working or added a feature that is not covered by the existing tests, you need to update the fixtures. 46 | 47 | We record the fixtures using a dedicated GitHub organization: [@github-project-fixtures](https://github.com/github-project-fixtures/). We can invite you to the organization so that you can record your own fixtures without setting up your own GitHub organization and app, just let us know. 48 | 49 | But in case you prefer to use your own organization, you'll need to 50 | 51 | 1. Create your own organization on GitHub 52 | 2. Register a GitHub App for that organization with the following permissions 53 | - administration: 'write', 54 | - contents: 'write', 55 | - issues: 'write', 56 | - metadata: 'read', 57 | - organization_projects: 'admin', 58 | - pull_requests: 'write' 59 | 60 | In either case, create a new project (beta) and add the following fields: 61 | 62 | - Text (type: text) 63 | - Number (type: number) 64 | - Date (type: date) 65 | - Single Select (type: single select) with options: "One", "Two", "Three" 66 | 67 | Then copy the `.env.example` file to `.env` and fill in the values. 68 | 69 | Then you can record fixtures for all tests in `test/recorded/*` using 70 | 71 | ``` 72 | node test/recorded/record-fixtures.js 73 | ``` 74 | 75 | If you only want to record fixtures for selected tests, pass the folder names as CLI arguments, e.g. 76 | 77 | ``` 78 | node test/recorded/record-fixtures.js api.items.add api.items.get 79 | ``` 80 | 81 | To test a single `test/recorded/*/test.js` file, run 82 | 83 | ``` 84 | # only test test/recorded/api.items.get/test.js 85 | npx ava test/recorded.test.js --match api.items.get 86 | ``` 87 | 88 | If a test snapshot needs to be updated, run `ava` with `--update-snapshots`, e.g. 89 | 90 | ``` 91 | # update snapshot for test/recorded/api.items.get/test.js 92 | npx ava test/recorded.test.js --match api.items.get --update-snapshots 93 | ``` 94 | 95 | To create a new test, copy an existing folder and delete the `fixtures.json` file in it. Update the `prepare.js` and `test.js` files to what's needed for your test. The `prepare.js` file is only used when recording fixtures using [`test/recorded/record-fixtures.js`](test/recorded/record-fixtures.js), this is where you create the state you need for your tests, e.g. create issues and add them as project items with custom properties. Any requests made in `prepare.js` are not part of the test fixtures. 96 | 97 | When recording fixtures, all requests made in the `test.js` code are recorded and later replayed when running tests via [`test/recorded.test.js`](test/recorded.test.js). 98 | 99 | The `project` instance passed to both the `test()` and `prepare()` functions is setting the default options. If you need a customized `project` instance for your test, you can do the following: 100 | 101 | ```js 102 | // @ts-check 103 | 104 | import GitHubProject from "../../../index.js"; 105 | 106 | /** 107 | * @param {import("../../..").default} defaultTestProject 108 | * @param {string} [contentId] 109 | */ 110 | export async function test(defaultTestProject, contentId = "I_1") { 111 | const project = new GitHubProject({ 112 | owner: defaultTestProject.owner, 113 | number: defaultTestProject.number, 114 | octokit: defaultTestProject.octokit, 115 | fields: { 116 | ...defaultTestProject.fields, 117 | nonExistingField: { name: "Nope", optional: false }, 118 | }, 119 | }); 120 | 121 | return project.items.getByContentId(contentId).then( 122 | () => new Error("should have thrown"), 123 | (error) => error, 124 | ); 125 | } 126 | ``` 127 | 128 | The above example also shows how to test for errors: simply return an error instance, without throwing it. That way it's tested with a snapshot, the same way as all the other tests. 129 | 130 | ## Maintainers only 131 | 132 | ### Merging the Pull Request & releasing a new version 133 | 134 | Releases are automated using [semantic-release](https://github.com/semantic-release/semantic-release). 135 | The following commit message conventions determine which version is released: 136 | 137 | 1. `fix: ...` or `fix(scope name): ...` prefix in subject: bumps fix version, e.g. `1.2.3` → `1.2.4` 138 | 2. `feat: ...` or `feat(scope name): ...` prefix in subject: bumps feature version, e.g. `1.2.3` → `1.3.0` 139 | 3. `BREAKING CHANGE:` in body: bumps breaking version, e.g. `1.2.3` → `2.0.0` 140 | 141 | Only one version number is bumped at a time, the highest version change trumps the others. 142 | Besides, publishing a new version to npm, semantic-release also creates a git tag and release 143 | on GitHub, generates changelogs from the commit messages and puts them into the release notes. 144 | 145 | If the pull request looks good but does not follow the commit conventions, update the pull request title and use the Squash & merge button, at which point you can set a custom commit message. 146 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Gregor Martynus 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /api/errors.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | export class GitHubProjectError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = this.constructor.name; 7 | this.details = {}; 8 | } 9 | /* c8 ignore start */ 10 | toHumanMessage() { 11 | return this.message; 12 | } 13 | /* c8 ignore stop */ 14 | } 15 | 16 | export class GitHubProjectNotFoundError extends GitHubProjectError { 17 | constructor(details) { 18 | super("Project cannot be found"); 19 | this.details = details; 20 | } 21 | 22 | toHumanMessage() { 23 | return `Project #${this.details.number} could not be found for @${this.details.owner}`; 24 | } 25 | } 26 | 27 | export class GitHubProjectUnknownFieldError extends GitHubProjectError { 28 | constructor(details) { 29 | super("Project field cannot be found"); 30 | this.details = details; 31 | } 32 | 33 | toHumanMessage() { 34 | const projectFieldNames = this.details.projectFieldNames 35 | .map((node) => `"${node.name}"`) 36 | .join(", "); 37 | return `"${this.details.userFieldName}" could not be matched with any of the existing field names: ${projectFieldNames}. If the field should be considered optional, then set it to "${this.details.userFieldNameAlias}: { name: "${this.details.userFieldName}", optional: true}`; 38 | } 39 | } 40 | 41 | export class GitHubProjectInvalidValueError extends GitHubProjectError { 42 | constructor(details) { 43 | super("User value is incompatible with project field type"); 44 | this.details = details; 45 | } 46 | 47 | toHumanMessage() { 48 | return `"${this.details.userValue}" is not compatible with the "${this.details.field.name}" project field which expects a value of type "${this.details.field.type}"`; 49 | } 50 | } 51 | 52 | export class GitHubProjectUnknownFieldOptionError extends GitHubProjectInvalidValueError { 53 | constructor(details) { 54 | super(details); 55 | this.message = "Project field option cannot be found"; 56 | this.details = details; 57 | } 58 | 59 | toHumanMessage() { 60 | const existingOptionsString = this.details.field.options 61 | .map((option) => `"${option.name}"`) 62 | .join(", "); 63 | 64 | return `"${this.details.userValue}" is an invalid option for "${this.details.field.name}".\n\nKnown options are:\n${existingOptionsString}`; 65 | } 66 | } 67 | 68 | export class GitHubProjectUpdateReadOnlyFieldError extends GitHubProjectError { 69 | constructor(details) { 70 | super("Project read-only field cannot be updated"); 71 | this.details = details; 72 | } 73 | 74 | toHumanMessage() { 75 | return `Cannot update read-only fields: ${this.details.fields 76 | .map(({ userValue, userName }) => `"${userValue}" (.${userName})`) 77 | .join(", ")}`; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /api/items.add-draft.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { addDraftIssueToProjectMutation } from "./lib/queries.js"; 4 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 5 | import { getStateWithProjectFields } from "./lib/get-state-with-project-fields.js"; 6 | import { removeObjectKeys } from "./lib/remove-object-keys.js"; 7 | import { updateItemFields } from "./lib/update-project-item-fields.js"; 8 | 9 | /** 10 | * Creates draft item in project. 11 | * 12 | * @param {import("..").default} project 13 | * @param {import("..").GitHubProjectState} state 14 | * @param {import("..").DraftItemContent} content 15 | * @param {Record} [fields] 16 | * 17 | * @returns {Promise} 18 | */ 19 | export async function addDraftItem(project, state, content, fields) { 20 | const stateWithFields = await getStateWithProjectFields(project, state); 21 | 22 | const { 23 | addProjectV2DraftIssue: { projectItem: itemNode }, 24 | } = await project.octokit.graphql(addDraftIssueToProjectMutation, { 25 | projectId: stateWithFields.id, 26 | title: content.title, 27 | body: content.body, 28 | assigneeIds: content.assigneeIds, 29 | }); 30 | 31 | const draftItem = projectItemNodeToGitHubProjectItem( 32 | stateWithFields, 33 | itemNode 34 | ); 35 | 36 | if (!fields) return draftItem; 37 | 38 | const nonExistingProjectFields = Object.entries(stateWithFields.fields) 39 | .filter(([, field]) => field.existsInProject === false) 40 | .map(([key]) => key); 41 | 42 | const fieldsAfterUpdate = await updateItemFields( 43 | project, 44 | state, 45 | draftItem.id, 46 | fields 47 | ); 48 | 49 | if (!fieldsAfterUpdate) { 50 | return { 51 | ...draftItem, 52 | // @ts-expect-error - complaints that built-in fields `title` and `status` might not exist, but we are good here 53 | fields: removeObjectKeys(draftItem.fields, nonExistingProjectFields), 54 | }; 55 | } 56 | 57 | return { 58 | ...draftItem, 59 | fields: fieldsAfterUpdate, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /api/items.add.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { addIssueToProjectMutation } from "./lib/queries.js"; 4 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 5 | import { getStateWithProjectFields } from "./lib/get-state-with-project-fields.js"; 6 | import { removeObjectKeys } from "./lib/remove-object-keys.js"; 7 | import { updateItemFields } from "./lib/update-project-item-fields.js"; 8 | 9 | /** 10 | * Adds new item to project. 11 | * 12 | * @param {import("../").default} project 13 | * @param {import("..").GitHubProjectState} state 14 | * @param {string} contentNodeId 15 | * @param {Record} fields 16 | * 17 | * @returns {Promise} 18 | */ 19 | export async function addItem(project, state, contentNodeId, fields) { 20 | const stateWithFields = await getStateWithProjectFields(project, state); 21 | 22 | const { 23 | addProjectV2ItemById: { item }, 24 | } = await project.octokit.graphql(addIssueToProjectMutation, { 25 | projectId: stateWithFields.id, 26 | contentId: contentNodeId, 27 | }); 28 | 29 | const newItem = projectItemNodeToGitHubProjectItem(stateWithFields, item); 30 | 31 | if (!fields) return newItem; 32 | 33 | const nonExistingProjectFields = Object.entries(stateWithFields.fields) 34 | .filter(([, field]) => field.existsInProject === false) 35 | .map(([key]) => key); 36 | 37 | const fieldsAfterUpdate = await updateItemFields( 38 | project, 39 | state, 40 | newItem.id, 41 | fields 42 | ); 43 | 44 | if (!fieldsAfterUpdate) { 45 | return { 46 | ...newItem, 47 | // @ts-expect-error - complaints that built-in fields `title` and `status` might not exist, but we are good here 48 | fields: removeObjectKeys(newItem.fields, nonExistingProjectFields), 49 | }; 50 | } 51 | 52 | return { 53 | ...newItem, 54 | fields: fieldsAfterUpdate, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /api/items.archive-by-content-id.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentId } from "./items.get-by-content-id.js"; 4 | import { archiveProjectItem } from "./lib/archive-project-item.js"; 5 | 6 | /** 7 | * Archives an item based on content ID. Resolves with the archived item 8 | * or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} contentId 13 | * 14 | * @returns {Promise} 15 | */ 16 | export async function archiveItemByContentId(project, state, contentId) { 17 | const item = await getItemByContentId(project, state, contentId); 18 | if (!item) return; 19 | 20 | if (item.isArchived) return item; 21 | 22 | await archiveProjectItem(project, state, item.id); 23 | return { ...item, isArchived: true }; 24 | } 25 | -------------------------------------------------------------------------------- /api/items.archive-by-content-repository-and-number.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentRepositoryAndNumber } from "./items.get-by-content-repository-and-number.js"; 4 | import { archiveProjectItem } from "./lib/archive-project-item.js"; 5 | 6 | /** 7 | * Removes an item based on content repository name and number. 8 | * Resolves with the archived item or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} repositoryName 13 | * @param {number} issueOrPullRequestNumber 14 | * 15 | * @returns {Promise} 16 | */ 17 | export async function archiveItemByContentRepositoryAndNumber( 18 | project, 19 | state, 20 | repositoryName, 21 | issueOrPullRequestNumber 22 | ) { 23 | const item = await getItemByContentRepositoryAndNumber( 24 | project, 25 | state, 26 | repositoryName, 27 | issueOrPullRequestNumber 28 | ); 29 | if (!item) return; 30 | 31 | if (item.isArchived) return item; 32 | 33 | await archiveProjectItem(project, state, item.id); 34 | return { ...item, isArchived: true }; 35 | } 36 | -------------------------------------------------------------------------------- /api/items.archive.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItem } from "./items.get.js"; 4 | import { archiveProjectItem } from "./lib/archive-project-item.js"; 5 | 6 | /** 7 | * Archives an item if it exists. Resolves with the archived item 8 | * or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} itemNodeId 13 | * 14 | * @returns {Promise} 15 | */ 16 | export async function archiveItem(project, state, itemNodeId) { 17 | const item = await getItem(project, state, itemNodeId); 18 | if (!item) return; 19 | 20 | if (item.isArchived) return item; 21 | 22 | await archiveProjectItem(project, state, item.id); 23 | return { ...item, isArchived: true }; 24 | } 25 | -------------------------------------------------------------------------------- /api/items.get-by-content-id.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getStateWithProjectFields } from "./lib/get-state-with-project-fields.js"; 4 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 5 | import { getItemByContentIdQuery } from "./lib/queries.js"; 6 | import { handleNotFoundGraphqlError } from "./lib/handle-not-found-graphql-error.js"; 7 | 8 | /** 9 | * Attempts to find an item based on the issues/pull request node id. 10 | * Resolves with undefined if item could not be found. 11 | * 12 | * @param {import("..").default} project 13 | * @param {import("..").GitHubProjectState} state 14 | * @param {string} contentId 15 | * 16 | * @returns {Promise} 17 | */ 18 | export async function getItemByContentId(project, state, contentId) { 19 | const stateWithFields = await getStateWithProjectFields(project, state); 20 | 21 | const result = await project.octokit 22 | .graphql(getItemByContentIdQuery, { 23 | id: contentId, 24 | }) 25 | .catch(handleNotFoundGraphqlError); 26 | 27 | const node = result?.node.projectItems?.nodes.find( 28 | (node) => node.project.number === project.number 29 | ); 30 | 31 | // TODO: add test where an item is added to two projects in order to cover the line below 32 | /* c8 ignore next */ 33 | if (!node) return; 34 | 35 | return projectItemNodeToGitHubProjectItem(stateWithFields, node); 36 | } 37 | -------------------------------------------------------------------------------- /api/items.get-by-content-repository-and-number.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getStateWithProjectFields } from "./lib/get-state-with-project-fields.js"; 4 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 5 | import { getItemByContentRepositoryAndNameQuery } from "./lib/queries.js"; 6 | import { handleNotFoundGraphqlError } from "./lib/handle-not-found-graphql-error.js"; 7 | 8 | /** 9 | * Find an item based on the repository and issues/pull request number. 10 | * Resolves with `undefined` if item could not be found. 11 | * 12 | * @param {import("..").default} project 13 | * @param {import("..").GitHubProjectState} state 14 | * @param {string} repositoryName 15 | * @param {number} issueOrPullRequestNumber 16 | * 17 | * @returns {Promise} 18 | */ 19 | export async function getItemByContentRepositoryAndNumber( 20 | project, 21 | state, 22 | repositoryName, 23 | issueOrPullRequestNumber 24 | ) { 25 | const stateWithFields = await getStateWithProjectFields(project, state); 26 | 27 | const result = await project.octokit 28 | .graphql(getItemByContentRepositoryAndNameQuery, { 29 | owner: project.owner, 30 | repositoryName: repositoryName, 31 | number: issueOrPullRequestNumber, 32 | }) 33 | .catch(handleNotFoundGraphqlError); 34 | 35 | const node = 36 | result?.repositoryOwner.repository.issueOrPullRequest.projectItems.nodes.find( 37 | (node) => node.project.number === project.number 38 | ); 39 | 40 | if (!node) return; 41 | 42 | return projectItemNodeToGitHubProjectItem(stateWithFields, node); 43 | } 44 | -------------------------------------------------------------------------------- /api/items.get.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 4 | import { getStateWithProjectFields } from "./lib/get-state-with-project-fields.js"; 5 | import { getItemQuery } from "./lib/queries.js"; 6 | import { handleNotFoundGraphqlError } from "./lib/handle-not-found-graphql-error.js"; 7 | 8 | /** 9 | * Attempts to find an item based on the issues/pull request node id. 10 | * Resolves with undefined if item could not be found. 11 | * 12 | * @param {import("..").default} project 13 | * @param {import("..").GitHubProjectState} state 14 | * @param {string} itemId 15 | * 16 | * @returns {Promise} 17 | */ 18 | export async function getItem(project, state, itemId) { 19 | const stateWithFields = await getStateWithProjectFields(project, state); 20 | 21 | const result = await project.octokit 22 | .graphql(getItemQuery, { 23 | id: itemId, 24 | }) 25 | .catch(handleNotFoundGraphqlError); 26 | 27 | if (!result?.node.id) return; 28 | 29 | return projectItemNodeToGitHubProjectItem(stateWithFields, result.node); 30 | } 31 | -------------------------------------------------------------------------------- /api/items.list.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { 4 | getProjectWithItemsQuery, 5 | getProjectItemsPaginatedQuery, 6 | } from "./lib/queries.js"; 7 | import { projectFieldsNodesToFieldsMap } from "./lib/project-fields-nodes-to-fields-map.js"; 8 | import { projectItemNodeToGitHubProjectItem } from "./lib/project-item-node-to-github-project-item.js"; 9 | 10 | /** 11 | * Load all project items 12 | * 13 | * @param {import("..").default} project 14 | * @param {import("..").GitHubProjectState} state 15 | * @returns {Promise} 16 | */ 17 | export async function listItems(project, state) { 18 | const { 19 | userOrOrganization: { projectV2 }, 20 | } = await project.octokit.graphql(getProjectWithItemsQuery, { 21 | owner: project.owner, 22 | number: project.number, 23 | }); 24 | 25 | const fields = projectFieldsNodesToFieldsMap( 26 | state, 27 | project, 28 | projectV2.fields.nodes 29 | ); 30 | 31 | const items = projectV2.items.nodes.map((node) => { 32 | // @ts-expect-error - for simplicity only pass fields instead of a full state 33 | return projectItemNodeToGitHubProjectItem({ fields }, node); 34 | }); 35 | 36 | // Recursively fetch all additional items for this project 37 | if (projectV2.items.pageInfo.hasNextPage) { 38 | await fetchProjectItems(project, fields, { 39 | cursor: projectV2.items.pageInfo.endCursor, 40 | results: items, 41 | }); 42 | } 43 | 44 | const { id, title, url } = projectV2; 45 | 46 | // mutate current state 47 | Object.assign(state, { 48 | didLoadFields: true, 49 | id, 50 | title, 51 | url, 52 | fields, 53 | }); 54 | 55 | return items; 56 | } 57 | 58 | /** 59 | * This method recursively executes a paginated query to gather all project items for a project 60 | * It collects the items into options.results, and can start from an arbitrary GraphQL cursor 61 | * 62 | * @param {import("..").default} project 63 | * @param {import("..").ProjectFieldMap} fields 64 | * @param {{ cursor?: string | undefined, results?: Array }} options 65 | * 66 | * @returns {Promise} 67 | */ 68 | async function fetchProjectItems( 69 | project, 70 | fields, 71 | { cursor = undefined, results = [] } = {} 72 | ) { 73 | const { 74 | userOrOrganization: { 75 | projectV2: { items }, 76 | }, 77 | } = await project.octokit.graphql(getProjectItemsPaginatedQuery, { 78 | owner: project.owner, 79 | number: project.number, 80 | first: 100, 81 | after: cursor, 82 | }); 83 | 84 | results.push( 85 | ...items.nodes.map((node) => { 86 | // @ts-expect-error - for simplicity only pass fields instead of a full state 87 | return projectItemNodeToGitHubProjectItem({ fields }, node); 88 | }) 89 | ); 90 | 91 | if (items.pageInfo.hasNextPage) { 92 | await fetchProjectItems(project, fields, { 93 | results, 94 | cursor: items.pageInfo.endCursor, 95 | }); 96 | } 97 | 98 | return results; 99 | } 100 | -------------------------------------------------------------------------------- /api/items.remove-by-content-id.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentId } from "./items.get-by-content-id.js"; 4 | import { removeProjectItem } from "./lib/remove-project-item.js"; 5 | 6 | /** 7 | * Removes an item based on content ID. Resolves with the removed item 8 | * or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} contentId 13 | * 14 | * @returns {Promise} 15 | */ 16 | export async function removeItemByContentId(project, state, contentId) { 17 | const item = await getItemByContentId(project, state, contentId); 18 | if (!item) return; 19 | 20 | await removeProjectItem(project, state, item.id); 21 | return item; 22 | } 23 | -------------------------------------------------------------------------------- /api/items.remove-by-content-repository-and-name.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentRepositoryAndNumber } from "./items.get-by-content-repository-and-number.js"; 4 | import { removeProjectItem } from "./lib/remove-project-item.js"; 5 | 6 | /** 7 | * Removes an item based on content repository name and number. 8 | * Resolves with the removed item or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} repositoryName 13 | * @param {number} issueOrPullRequestNumber 14 | * 15 | * @returns {Promise} 16 | */ 17 | export async function removeItemByContentRepositoryAndNumber( 18 | project, 19 | state, 20 | repositoryName, 21 | issueOrPullRequestNumber 22 | ) { 23 | const item = await getItemByContentRepositoryAndNumber( 24 | project, 25 | state, 26 | repositoryName, 27 | issueOrPullRequestNumber 28 | ); 29 | if (!item) return; 30 | 31 | await removeProjectItem(project, state, item.id); 32 | return item; 33 | } 34 | -------------------------------------------------------------------------------- /api/items.remove.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItem } from "./items.get.js"; 4 | import { removeProjectItem } from "./lib/remove-project-item.js"; 5 | 6 | /** 7 | * Removes an item if it exists. Resolves with the removed item 8 | * or with `undefined` if item was not found. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} itemNodeId 13 | * 14 | * @returns {Promise} 15 | */ 16 | export async function removeItem(project, state, itemNodeId) { 17 | const item = await getItem(project, state, itemNodeId); 18 | if (!item) return; 19 | 20 | await removeProjectItem(project, state, item.id); 21 | return item; 22 | } 23 | -------------------------------------------------------------------------------- /api/items.update-by-content-id.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentId } from "./items.get-by-content-id.js"; 4 | import { updateItemFields } from "./lib/update-project-item-fields.js"; 5 | 6 | /** 7 | * Updates item fields if the item can be found. 8 | * In order to find an item by content ID, all items need to be loaded first. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} contentNodeId 13 | * @param {Record} fields 14 | * 15 | * @returns {Promise} 16 | */ 17 | export async function updateItemByContentId( 18 | project, 19 | state, 20 | contentNodeId, 21 | fields 22 | ) { 23 | const item = await getItemByContentId(project, state, contentNodeId); 24 | if (!item) return; 25 | 26 | const newFields = await updateItemFields(project, state, item.id, fields); 27 | if (!newFields) return item; 28 | 29 | return { 30 | ...item, 31 | fields: newFields, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /api/items.update-by-content-repository-and-number.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItemByContentRepositoryAndNumber } from "./items.get-by-content-repository-and-number.js"; 4 | import { updateItemFields } from "./lib/update-project-item-fields.js"; 5 | 6 | /** 7 | * Updates item fields if the item can be found. 8 | * In order to find an item by content ID, all items need to be loaded first. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} repositoryName 13 | * @param {number} issueOrPullRequestNumber 14 | * @param {Record} fields 15 | * 16 | * @returns {Promise} 17 | */ 18 | export async function updateItemByContentRepositoryAndNumber( 19 | project, 20 | state, 21 | repositoryName, 22 | issueOrPullRequestNumber, 23 | fields 24 | ) { 25 | const item = await getItemByContentRepositoryAndNumber( 26 | project, 27 | state, 28 | repositoryName, 29 | issueOrPullRequestNumber 30 | ); 31 | if (!item) return; 32 | 33 | const newFields = await updateItemFields(project, state, item.id, fields); 34 | if (!newFields) return item; 35 | 36 | return { 37 | ...item, 38 | fields: newFields, 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /api/items.update.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getItem } from "./items.get.js"; 4 | import { updateItemFields } from "./lib/update-project-item-fields.js"; 5 | 6 | /** 7 | * Updates item fields if the item can be found and returns the full item 8 | * with all fields and content properties. 9 | * 10 | * @param {import("..").default} project 11 | * @param {import("..").GitHubProjectState} state 12 | * @param {string} itemNodeId 13 | * @param {Record} fields 14 | * 15 | * @returns {Promise} 16 | */ 17 | export async function updateItem(project, state, itemNodeId, fields) { 18 | const item = await getItem(project, state, itemNodeId); 19 | if (!item) return; 20 | 21 | const newFields = await updateItemFields(project, state, itemNodeId, fields); 22 | if (!newFields) return item; 23 | 24 | return { 25 | ...item, 26 | fields: newFields, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /api/lib/archive-project-item.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getStateWithProjectFields } from "./get-state-with-project-fields.js"; 4 | import { handleNotFoundGraphqlError } from "./handle-not-found-graphql-error.js"; 5 | import { archiveItemMutation } from "./queries.js"; 6 | 7 | /** 8 | * Helper method to archive an item from a project which is used 9 | * by all the `project.items.archive*` methods. 10 | * 11 | * @param {import("../..").default} project 12 | * @param {import("../..").GitHubProjectState} state 13 | * @param {string} itemNodeId 14 | * 15 | * @returns {Promise} 16 | */ 17 | export async function archiveProjectItem(project, state, itemNodeId) { 18 | const stateWithFields = await getStateWithProjectFields(project, state); 19 | 20 | await project.octokit 21 | .graphql(archiveItemMutation, { 22 | projectId: stateWithFields.id, 23 | itemId: itemNodeId, 24 | }) 25 | .catch(handleNotFoundGraphqlError); 26 | } 27 | -------------------------------------------------------------------------------- /api/lib/default-match-function.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} projectValue 3 | * @param {string} userValue 4 | * 5 | * @returns boolean 6 | */ 7 | export function defaultMatchFunction(projectValue, userValue) { 8 | return projectValue === userValue; 9 | } 10 | -------------------------------------------------------------------------------- /api/lib/default-truncate-function.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Maximum text field value length is 1024 bytes. By default, we don't do anything. 3 | * 4 | * @param {string} text 5 | * @returns {string} 6 | */ 7 | export function defaultTruncateFunction(text) { 8 | return text; 9 | } 10 | -------------------------------------------------------------------------------- /api/lib/get-state-with-project-fields.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getProjectCoreDataQuery } from "./queries.js"; 4 | import { projectFieldsNodesToFieldsMap } from "./project-fields-nodes-to-fields-map.js"; 5 | import { GitHubProjectNotFoundError } from "../../index.js"; 6 | 7 | /** 8 | * This method assures that the project fields are loaded. It returns the new 9 | * state for simpler TypeScript IntelliSense, but also mutates the state directly 10 | * 11 | * @param {import("../..").default} project 12 | * @param {import("../..").GitHubProjectState} state 13 | * 14 | * @returns {Promise} 15 | */ 16 | export async function getStateWithProjectFields(project, state) { 17 | if (state.didLoadFields) { 18 | return state; 19 | } 20 | 21 | const response = await getProjectCoreData(project); 22 | 23 | const { 24 | userOrOrganization: { projectV2 }, 25 | } = response; 26 | 27 | const fields = projectFieldsNodesToFieldsMap( 28 | state, 29 | project, 30 | projectV2.fields.nodes 31 | ); 32 | 33 | const { id, title, url, databaseId } = projectV2; 34 | 35 | // mutate current state and return it 36 | // @ts-expect-error - TS can't handle Object.assign 37 | return Object.assign(state, { 38 | didLoadFields: true, 39 | id, 40 | title, 41 | url, 42 | databaseId, 43 | fields, 44 | }); 45 | } 46 | 47 | /** 48 | * 49 | * @param {import("../..").default} project 50 | * @returns {Promise} 51 | */ 52 | async function getProjectCoreData(project) { 53 | try { 54 | return await project.octokit.graphql(getProjectCoreDataQuery, { 55 | owner: project.owner, 56 | number: project.number, 57 | }); 58 | } catch (error) { 59 | /* c8 ignore next */ 60 | if (error?.response?.errors[0]?.type !== "NOT_FOUND") throw error; 61 | 62 | throw new GitHubProjectNotFoundError({ 63 | owner: project.owner, 64 | number: project.number, 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /api/lib/handle-not-found-graphql-error.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @param {any} error - error object thrown by `octokit.graphql()` 5 | * @returns void 6 | */ 7 | export function handleNotFoundGraphqlError(error) { 8 | /* c8 ignore next */ 9 | if (!error.errors) throw error; 10 | if (error.errors[0].type === "NOT_FOUND") return; 11 | /* c8 ignore next 2 */ 12 | throw error; 13 | } 14 | -------------------------------------------------------------------------------- /api/lib/item-fields-nodes-to-fields-map.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Take GraphQL project item fieldValues nodes and turn them into 3 | * an object using the user-defined field names. 4 | * 5 | * @param {import("../..").GitHubProjectStateWithFields} state 6 | * @param {import("../..").ProjectFieldValueNode[]} nodes 7 | * 8 | * @returns {Record & Record} 9 | */ 10 | export function itemFieldsNodesToFieldsMap(state, nodes) { 11 | return Object.entries(state.fields).reduce( 12 | (acc, [projectFieldName, projectField]) => { 13 | // don't set optional fields on items that don't exist in project 14 | if (projectField.existsInProject === false) return acc; 15 | 16 | const node = nodes.find((node) => node.field?.id === projectField.id); 17 | const value = projectFieldValueNodeToValue(projectField, node); 18 | 19 | return { 20 | ...acc, 21 | [projectFieldName]: value, 22 | }; 23 | }, 24 | {} 25 | ); 26 | } 27 | 28 | /** 29 | * @param {import("../..").ProjectField} projectField 30 | * @param {import("../..").ProjectFieldValueNode} node 31 | * @returns {string} 32 | */ 33 | function projectFieldValueNodeToValue(projectField, node) { 34 | if (!node) return null; 35 | 36 | switch (node.__typename) { 37 | case "ProjectV2ItemFieldDateValue": 38 | return node.date; 39 | case "ProjectV2ItemFieldNumberValue": 40 | // we currently only work with strings 41 | return String(node.number); 42 | case "ProjectV2ItemFieldSingleSelectValue": 43 | return projectField.optionsById[node.optionId]; 44 | case "ProjectV2ItemFieldTextValue": 45 | return node.text; 46 | case "ProjectV2ItemFieldIterationValue": 47 | return node.title; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /api/lib/project-fields-nodes-to-fields-map.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { GitHubProjectUnknownFieldError } from "../../index.js"; 4 | 5 | /** 6 | * Takes `project.fields` and the list of project item fieldValues nodes 7 | * from the GraphQL query result: 8 | * 9 | * ``` 10 | * fieldValues(...) { 11 | * nodes { 12 | * id 13 | * name 14 | * settings 15 | * } 16 | * } 17 | * ``` 18 | * 19 | * and turns them into a map 20 | * 21 | * ``` 22 | * { 23 | * "title": { 24 | * "id": "", 25 | * "name": "Title", 26 | * }, 27 | * "status": { 28 | * "id": "", 29 | * "name": "Status", 30 | * "optionsByValue": { 31 | * "In Progress": "