├── .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": "