├── CODEOWNERS ├── examples ├── template │ ├── tsconfig.json │ └── pack.ts ├── github │ ├── .coda-pack.json │ ├── test │ │ ├── github_integration.ts │ │ └── github_test.ts │ ├── types.ts │ ├── README.md │ ├── helpers.ts │ ├── schemas.ts │ └── pack.ts ├── box │ ├── types.ts │ ├── README.md │ ├── helpers.ts │ └── pack.ts ├── trigonometry │ ├── README.md │ ├── test │ │ └── trigonometry_test.ts │ └── pack.ts ├── vonage │ ├── credentials.ts │ ├── README.md │ └── pack.ts ├── dictionary │ ├── test │ │ ├── dictionary_integration.ts │ │ └── dictionary_test.ts │ ├── schemas.ts │ ├── README.md │ ├── helpers.ts │ ├── types.ts │ └── pack.ts └── google-tables │ ├── README.md │ ├── types.ts │ ├── helpers.ts │ ├── pack.ts │ └── convert.ts ├── .gitignore ├── .eslint-plugin-local.js ├── .prettierrc ├── tools └── eslint │ ├── coda_rules │ ├── index.js │ ├── package.json │ └── gen_rules │ │ ├── coda_import_style_rule.js │ │ └── coda_import_ordering_rule.js │ └── base_rules.js ├── .mocharc.json ├── .github ├── renovate.json └── workflows │ └── stale.yml ├── lib └── test_utils.ts ├── LICENSE.txt ├── .circleci └── config.yml ├── package.json ├── eslint.config.mjs ├── README.md └── tsconfig.json /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @coda/packs 2 | -------------------------------------------------------------------------------- /examples/template/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2020"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/template/pack.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | 3 | export const pack = coda.newPack(); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coda 2 | .coda.json 3 | .coda-credentials.json 4 | .coda-pack.json 5 | .DS_Store 6 | .vscode 7 | node_modules 8 | -------------------------------------------------------------------------------- /examples/github/.coda-pack.json: -------------------------------------------------------------------------------- 1 | { 2 | "packId": -1, 3 | "environmentPackIds": { 4 | "dev.coda.io:8080": 2000000001 5 | } 6 | } -------------------------------------------------------------------------------- /.eslint-plugin-local.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const {rules} = require('./tools/eslint/coda_rules'); 4 | 5 | module.exports.rules = rules; 6 | -------------------------------------------------------------------------------- /examples/box/types.ts: -------------------------------------------------------------------------------- 1 | export interface Folder { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export interface User { 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 80, 4 | "singleQuote": false, 5 | "bracketSpacing": false, 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /tools/eslint/coda_rules/index.js: -------------------------------------------------------------------------------- 1 | module.exports.rules = { 2 | 'coda-import-ordering': require('./gen_rules/coda_import_ordering_rule').rule, 3 | 'coda-import-style': require('./gen_rules/coda_import_style_rule').rule, 4 | }; 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "max-old-space-size": 1536, 3 | "v8-expose-gc": true, 4 | "check-leaks": true, 5 | "full-trace": true, 6 | "v8-trace-warnings": true, 7 | "exit": true, 8 | "recursive": true, 9 | "reporter": "dot", 10 | "slow": 75, 11 | "timeout": 3000, 12 | "require": ["ts-node/register"] 13 | } 14 | 15 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>coda/renovate-config" 5 | ], 6 | 7 | "packageRules": [ 8 | { 9 | "description": "Pin the cimg/node version to 14.* to match package.json", 10 | "matchPackageNames": ["cimg/node"], 11 | "allowedVersions": "14.*" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tools/eslint/coda_rules/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-coda-rules", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "index.js", 6 | "description": "Lint rules for ESLint.", 7 | "directories": { 8 | "src": "src", 9 | "test": "test" 10 | }, 11 | "author": "Stephen Marquis ", 12 | "license": "./LICENSE" 13 | } 14 | -------------------------------------------------------------------------------- /lib/test_utils.ts: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | 3 | export async function willBeRejected( 4 | promise: Promise, 5 | ): Promise { 6 | try { 7 | await promise; 8 | } catch (err: any) { 9 | return err; 10 | } 11 | 12 | throw new Error('Promise unexpectedly resolved'); 13 | } 14 | 15 | export async function willBeRejectedWith( 16 | promise: Promise, 17 | matcher?: RegExp, 18 | ): Promise { 19 | const error = await willBeRejected(promise); 20 | if (matcher) { 21 | assert.match(error, matcher, 'Promise was rejected with unexpected error.'); 22 | } 23 | return error as ErrorT; 24 | } 25 | -------------------------------------------------------------------------------- /examples/trigonometry/README.md: -------------------------------------------------------------------------------- 1 | # Trigonometry Pack 2 | 3 | This is one of the simplest possible Packs, it just creates Coda wrappers for built-in 4 | JavaScript formulas to expose them in Coda. 5 | 6 | It defines a handful of formulas which each take a numeric parameter and return a number. 7 | The `execute` implementation, which is the substance of the formula, is very simple in these cases, 8 | delegating to a built-in JavaScript `Math` function or performing a simple calculation. 9 | 10 | ## Running the Tests 11 | 12 | Run the test just for this Pack by using: 13 | 14 | ```bash 15 | mocha --require ts-node/register examples/trigonometry/test/trigonometry_test.ts 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/vonage/credentials.ts: -------------------------------------------------------------------------------- 1 | // A file to hold the credentials used by the Pack. 2 | 3 | // It's not possible to use the SDK's system authentication and store these in 4 | // Coda directly since you need direct access to the values in order to create 5 | // the JWT. 6 | // We recommend you don't check this file into your code repository, for example 7 | // by adding an entry for it in your .gitignore file. 8 | 9 | // Create an application and private key at: https://dashboard.nexmo.com/ 10 | 11 | // The ID of your Vonage application. 12 | export const ApplicationId = "..."; 13 | 14 | // The private key generated for your Vonage application. 15 | export const PrivateKey = ` 16 | -----BEGIN PRIVATE KEY----- 17 | ... 18 | -----END PRIVATE KEY----- 19 | `.trim(); 20 | -------------------------------------------------------------------------------- /examples/dictionary/test/dictionary_integration.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {describe} from "mocha"; 3 | import {executeFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 4 | import {it} from "mocha"; 5 | import {pack} from "../pack"; 6 | 7 | describe("Dictionary pack integration test", () => { 8 | it("executes Define", async () => { 9 | // Here we execute the formula using a real http fetcher. Since this pack 10 | // requires authentication, this requires that you've already run 11 | // `coda auth examples/dictionary/pack.ts` to set up an API key. 12 | let response = await executeFormulaFromPackDef( 13 | pack, 14 | "Define", 15 | ["coda"], 16 | undefined, 17 | undefined, 18 | { 19 | useRealFetcher: true, 20 | manifestPath: require.resolve("../pack"), 21 | }, 22 | ); 23 | 24 | assert.isAtLeast(response.length, 1); 25 | assert.equal(response[0].Id, "coda"); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /examples/dictionary/schemas.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | 3 | export const DefinitionSchema = coda.makeObjectSchema({ 4 | properties: { 5 | id: {type: coda.ValueType.String, required: true}, 6 | definitions: { 7 | type: coda.ValueType.Array, 8 | items: {type: coda.ValueType.String}, 9 | required: true, 10 | }, 11 | headword: {type: coda.ValueType.String, required: true}, 12 | partOfSpeech: {type: coda.ValueType.String}, 13 | firstUse: {type: coda.ValueType.String}, 14 | offensive: {type: coda.ValueType.Boolean, required: true}, 15 | }, 16 | // The display property tells Coda which of your object's properties 17 | // should be used as a label for your object. Pack objects will be rendered 18 | // as a chip showing the label, and the rest of the fields will show up 19 | // when hovering over the chip. 20 | displayProperty: "headword", 21 | }); 22 | 23 | export const DefinitionArraySchema = coda.makeSchema({ 24 | type: coda.ValueType.Array, 25 | items: DefinitionSchema, 26 | }); 27 | -------------------------------------------------------------------------------- /examples/trigonometry/test/trigonometry_test.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "chai"; 2 | import {describe} from "mocha"; 3 | import {executeFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 4 | import {it} from "mocha"; 5 | import {pack} from "../pack"; 6 | 7 | describe("Trigonometry pack", () => { 8 | it("executes Cosine", async () => { 9 | // Since our pack doesn't make any fetcher calls, we can very simply 10 | // invoke formulas directly with the default mock execution context. 11 | assert.equal(1, await executeFormulaFromPackDef(pack, "Cosine", [0])); 12 | assert.approximately( 13 | 1 / Math.sqrt(2), 14 | await executeFormulaFromPackDef(pack, "Cosine", [Math.PI / 4]), 15 | 1e6, 16 | ); 17 | }); 18 | 19 | it("converts degrees and radians", async () => { 20 | assert.equal( 21 | Math.PI / 2, 22 | await executeFormulaFromPackDef(pack, "ToRadians", [90]), 23 | ); 24 | assert.equal( 25 | 90, 26 | await executeFormulaFromPackDef(pack, "ToDegrees", [Math.PI / 2]), 27 | ); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /examples/vonage/README.md: -------------------------------------------------------------------------------- 1 | # Vonage 2 | 3 | This is an example Pack that syncs data from Vonage. The primary purpose of this example is to demonstrate how to perform JWT (JSON Web Token) authentication in a Pack. 4 | 5 | Coda doesn't yet have native support for JWT authentication, but when the API uses a shared set of system credentials you can approximate it. This example stores the shared credentials in a file and uses the `jsrsasign` library to generate the JWT. When using this pattern in your own Packs make sure not to check the credentials file into your version control system. 6 | 7 | ## Setup 8 | 9 | To run the example code and actually connect to Vonage, you'll need to sign up for a Vonage account and create a new application. This can be done using the [Vonage API Dashboard](https://dashboard.nexmo.com/). After you have created the application, enter the generated application ID and private key in to the `credentials.ts` file. 10 | 11 | 12 | ## Running the Example 13 | 14 | To sync the list of conversations run: 15 | 16 | ```bash 17 | coda execute examples/vonage/pack.ts Conversations 18 | ``` 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Coda Project, Inc. (https://coda.io) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/dictionary/README.md: -------------------------------------------------------------------------------- 1 | # Dictionary 2 | 3 | This example returns definitions and other metadata for a given word, using Merriam-Webster's 4 | Collegiate Dictionary API: https://dictionaryapi.com/. This example also demonstrates 5 | authentication using an API key. You can register an API key for free for non-commercial use 6 | with Merriam-Webster. 7 | 8 | ## Running the Example 9 | 10 | Run `coda auth examples/dictionary/pack.ts` and enter your API key. 11 | 12 | Then run `coda execute examples/dictionary/pack.ts Define coda` to look 13 | up definitions for a word, in this case `coda`. 14 | 15 | ## Running the Tests 16 | 17 | Run the unittests just for this Pack by using: 18 | 19 | ```bash 20 | mocha --require ts-node/register examples/dictionary/test/dictionary_test.ts 21 | ``` 22 | 23 | The unittests use a mock http fetcher and do not connect to a real API. 24 | 25 | There is also an integration test, which makes real http requests to the API. 26 | To run successfully, this test requires that you've set up an API key already 27 | using `coda auth examples/dictionary/pack.ts`. 28 | 29 | ```bash 30 | mocha --require ts-node/register examples/dictionary/test/dictionary_integration.ts 31 | ``` 32 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | default_build_environment: 5 | docker: 6 | - image: cimg/node:18.16.0 7 | auth: 8 | username: codainternaltools 9 | password: $DOCKERHUB_PASSWORD 10 | 11 | jobs: 12 | build: 13 | executor: default_build_environment 14 | working_directory: ~/repo 15 | 16 | steps: 17 | - checkout 18 | - restore_cache: 19 | keys: 20 | - v2-dependencies-{{ checksum "package.json" }}-{{ checksum "package-lock.json" }} 21 | - run: 22 | name: 'Install deps' 23 | command: npm install 24 | - save_cache: 25 | paths: 26 | - node_modules 27 | key: v2-dependencies-{{ checksum "package.json" }}-{{ checksum "package-lock.json" }} 28 | - run: 29 | name: 'Compile' 30 | command: npm run compile 31 | - run: 32 | name: 'Lint' 33 | command: npm run lint 34 | - run: 35 | name: 'Validate' 36 | command: npm run validate 37 | 38 | workflows: 39 | version: 2 40 | commit_validation: 41 | jobs: 42 | - build: 43 | context: 44 | - dockerhub 45 | -------------------------------------------------------------------------------- /examples/dictionary/helpers.ts: -------------------------------------------------------------------------------- 1 | import type * as coda from "@codahq/packs-sdk"; 2 | import type * as types from "./types"; 3 | 4 | const API_VERSION = "v3"; 5 | 6 | export async function lookupDefinition( 7 | context: coda.ExecutionContext, 8 | word: string, 9 | ): Promise { 10 | let escapedWord = encodeURIComponent(word); 11 | let url = `https://www.dictionaryapi.com/api/${API_VERSION}/references/collegiate/json/${escapedWord}`; 12 | let response = await context.fetcher.fetch({method: "GET", url: url}); 13 | // The API returns an array of 0 or more dictionary entries for the 14 | // given word. We have created types for that response structure to make 15 | // the code easier to understand and maintain. 16 | let entries = response.body as types.APIEntry[]; 17 | // We simply transform each entry from the raw structure returned 18 | // by the API to a more user-friendly structure that we have defined 19 | // ourselves. 20 | return entries.map(parseEntry); 21 | } 22 | 23 | function parseEntry(entry: types.APIEntry): types.CodaDefinition { 24 | let {shortdef, fl, hwi, date, meta} = entry; 25 | return { 26 | id: meta.id, 27 | definitions: shortdef, 28 | partOfSpeech: fl, 29 | headword: hwi.hw, 30 | firstUse: date, 31 | offensive: meta.offensive, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /examples/dictionary/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions for objects returned by the dictionary API. 3 | */ 4 | 5 | // A single response entry as described at https://dictionaryapi.com/products/json. 6 | // The http response from the API consists of an array of these objects. 7 | // For simplicity, we only include the subset of fields that care about for 8 | // this pack. 9 | export interface APIEntry { 10 | // One or more practical definitions of the word. 11 | shortdef: string[]; 12 | 13 | // The part of speech. 14 | fl?: string; 15 | 16 | // The actual word or phrase being defined in this entry. For example, 17 | // if an alternate tense of a word was searched for, the definition 18 | // returned may be for the present tense form of the word. 19 | hwi: {hw: string}; 20 | 21 | // A year or description about the approximate first known use. 22 | date?: string; 23 | 24 | // Metadata about the entry. 25 | meta: { 26 | id: string; 27 | offensive: boolean; 28 | }; 29 | } 30 | 31 | /** 32 | * Type definitions for the transformed objects returned by the pack. 33 | * Generally these types match the schema(s) you define in schemas.ts. 34 | */ 35 | 36 | export interface CodaDefinition { 37 | id: string; 38 | definitions: string[]; 39 | headword: string; 40 | partOfSpeech?: string; 41 | firstUse?: string; 42 | offensive: boolean; 43 | } 44 | -------------------------------------------------------------------------------- /examples/google-tables/README.md: -------------------------------------------------------------------------------- 1 | # Google Tables 2 | 3 | This is an example Pack that syncs data from Google Tables (an Area 120 project). The primary purpose of this example is to demonstrate how to build a dynamic sync table with two-way sync enabled. 4 | 5 | ## Setup 6 | 7 | To run the example code and actually connect to Google Tables, you'll need to create a Google Cloud project and get a client id and client secret. This can be done using the [Google Cloud console](https://console.cloud.google.com). 8 | 9 | When configuring the OAuth settings you'll need to add **`http://localhost:3000/oauth`** as your "Redirect URIs" in order to run these examples locally. To run them on Coda after uploading & releasing, your authorization callback URL must change to be **`https://coda.io/packsAuth/oauth2/{PACK_ID}`**. 10 | 11 | Run `coda auth examples/google-tables/pack.ts`. You'll be prompted to enter your client id and client secret, and then your browser will open and begin Google's OAuth flow. After the flow completes, the access token for that account will saved locally for use when executing formulas and syncs, so you only have to do this once. But you can run the command again to change to a different account, or to update your token if it becomes invalid. 12 | 13 | ## Running the Example 14 | 15 | To sync a table run: 16 | 17 | ```bash 18 | coda execute examples/google-tables/pack.ts Table --dynamicUrl="https://area120tables.googleapis.com/v1alpha1/tables/{TABLE_ID}" 19 | ``` 20 | 21 | To test two-way sync you'll need to upload the Pack and test it in a live doc. 22 | -------------------------------------------------------------------------------- /examples/dictionary/pack.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | import * as helpers from "./helpers"; 3 | import * as schemas from "./schemas"; 4 | 5 | export const pack = coda.newPack(); 6 | 7 | // The Merriam-Webster API uses an API token, which should be included in 8 | // request urls in a "key=" parameter, so we configure that here. When 9 | // running `coda auth examples/dictionary/pack.ts` you will be prompted 10 | // to enter your API key to use when using `coda execute` to exercise formulas 11 | // in this pack. Users would be prompted to enter an API key when installing 12 | // this pack in the Coda UI. 13 | pack.setUserAuthentication({ 14 | type: coda.AuthenticationType.QueryParamToken, 15 | paramName: "key", 16 | }); 17 | 18 | // This tells Coda which domain the pack make requests to. Any fetcher 19 | // requests to other domains won't be allowed. 20 | pack.addNetworkDomain("dictionaryapi.com"); 21 | 22 | pack.addFormula({ 23 | resultType: coda.ValueType.Object, 24 | name: "Define", 25 | description: "Returns the definition and other metadata for a given word.", 26 | parameters: [ 27 | coda.makeParameter({ 28 | type: coda.ParameterType.String, 29 | name: "word", 30 | description: "A word to define.", 31 | }), 32 | ], 33 | schema: schemas.DefinitionArraySchema, 34 | examples: [ 35 | { 36 | params: ["hello"], 37 | result: { 38 | id: "hello", 39 | definitions: ["definition of hello"], 40 | partOfSpeech: "noun", 41 | headword: "hello", 42 | firstUse: "1834", 43 | offensive: false, 44 | }, 45 | }, 46 | ], 47 | execute: async function ([word], context) { 48 | return helpers.lookupDefinition(context, word); 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns stale PRs after 7 days 2 | # Closes stale PRs after 30 days 3 | # Marks renovate PRs stale after 30 days -- no closure 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '0 10 * * *' # 10 AM UTC -> 2 AM PST 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - name: Mark Codan PRs As Stale 22 | uses: actions/stale@v9 23 | with: 24 | repo-token: ${{ secrets.GITHUB_TOKEN }} 25 | stale-pr-message: 'This pull request has been inactive for 7 days: labeled as stale. To avoid auto-closure in 7 days, please do one of the following: Add `keep-active` label, comment on PR, push new commit on PR.' 26 | stale-pr-label: 'stale' 27 | days-before-stale: 7 # mark as stale 28 | days-before-close: 7 # close stale 29 | close-pr-message: 'This stale PR has been inactive for 14 days; Closing PR.' 30 | exempt-pr-labels: 'keep-active,dependencies' 31 | operations-per-run: 1000 32 | remove-pr-stale-when-updated: true 33 | - name: Mark Renovate PRs As Stale 34 | uses: actions/stale@v9 # mark dependencies labeled as stale after 30 days (auto labeled by renovate) 35 | with: 36 | repo-token: ${{ secrets.GITHUB_TOKEN }} 37 | stale-pr-message: 'This pull request has been inactive for 30 days: labeled as stale. Please either merge or close or add the label `keep-active`' 38 | stale-pr-label: 'stale' 39 | days-before-stale: 30 # mark as stale 40 | any-of-pr-labels: 'dependencies' #only to process the pull requests that contains these labels 41 | operations-per-run: 1000 42 | exempt-pr-labels: 'keep-active' -------------------------------------------------------------------------------- /examples/box/README.md: -------------------------------------------------------------------------------- 1 | # Box 2 | 3 | This is a small example Pack that demonstrates how to create an action formula that uploads a file to Box. The primary purpose of this example is to demonstrate how to use the `form-data` NPM library to send `multipart/form-data` payloads with the Fetcher. 4 | 5 | ## Setup 6 | 7 | To run the example code and actually connect to Box, you'll need to create a Custom App 8 | with Box and get a client id and client secret. This can be done using the [Box developer console](https://app.box.com/developers/console). 9 | 10 | When configuring the OAuth settings you'll need to add **`http://localhost:3000/oauth`** 11 | as your "Redirect URIs" in order to run these examples locally. To run them 12 | on Coda after uploading & releasing, your authorization callback URL must change to be 13 | **`https://coda.io/packsAuth/oauth2/{PACK_ID}`**. 14 | 15 | Run `coda auth examples/box/pack.ts`. You'll be prompted to enter your client id 16 | and client secret, and then your browser will open and begin Box's OAuth flow. 17 | After the flow completes, the access token for that account will saved locally 18 | for use when executing formulas and syncs, so you only have to do this once. But you can 19 | run the command again to change to a different account, or to update your token if it 20 | becomes invalid. 21 | 22 | ## Running the Example 23 | 24 | To upload a file run: 25 | 26 | ```bash 27 | coda execute examples/box/pack.ts UploadFile "{FILE_URL}" 28 | ``` 29 | 30 | The file URL must be hosted on `codahosted.io`, which is where Coda stores uploaded files and images. You can test with this value: 31 | 32 | ``` 33 | https://codahosted.io/docs/usaAjFrOkA/blobs/bl-HBxFdR_Yjg/5e2a110296bd8591bfa6734a0ba3aac501b82e60b8090800a7306e9162e4c8102ff1de6e18ade5843174e548e64a7349ba6ac653b54bb3d4e001e2414731f6fb48736bf80f1e2f35a05c9edefe7a25e7037b2c071104e173ba73449a28f2b1e953f8b962 34 | ``` 35 | -------------------------------------------------------------------------------- /examples/github/test/github_integration.ts: -------------------------------------------------------------------------------- 1 | import {PullRequestStateFilter} from "../types"; 2 | import {assert} from "chai"; 3 | import {describe} from "mocha"; 4 | import {executeSyncFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 5 | import {it} from "mocha"; 6 | import {pack} from "../pack"; 7 | 8 | describe("GitHub pack integration test", () => { 9 | it("executes PullRequests sync", async () => { 10 | // Here we execute the sync using a real http fetcher. Since this pack 11 | // requires authentication, this requires that you've already run 12 | // `coda auth examples/github/packs.ts` to set up your OAuth credentials 13 | // for GitHub. 14 | let response = await executeSyncFormulaFromPackDef( 15 | pack, 16 | "PullRequests", 17 | // This integration test assumes you have access to the packs-examples 18 | // repo, which you should if you're looking at this example! 19 | [ 20 | "https://github.com/coda/packs-examples", 21 | "", 22 | PullRequestStateFilter.All, 23 | ], 24 | undefined, 25 | undefined, 26 | { 27 | useRealFetcher: true, 28 | manifestPath: require.resolve("../pack"), 29 | }, 30 | ); 31 | 32 | // Reliably assertions here are tricky since this test uses live data that 33 | // is subject to change. Above we used the All parameter to ensure we're 34 | // syncing all PRs and not just open ones, to guarantee we have at least 35 | // one PR to examine here. 36 | assert.isAtLeast(response.length, 1); 37 | assert.equal(response[0].Repo.Name, "packs-examples"); 38 | }); 39 | 40 | // You could similarly write an integration for the ReviewPullRequest 41 | // formula. Since that is an action formula that mutates data, you may want 42 | // to have a dummy PR around for testing purposes that you keep appending 43 | // comments to. 44 | }); 45 | -------------------------------------------------------------------------------- /examples/box/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as ContentDisposition from "content-disposition"; 2 | import type {Folder} from "./types"; 3 | import type {User} from "./types"; 4 | import * as coda from "@codahq/packs-sdk"; 5 | import * as mime from "mime-types"; 6 | 7 | export async function searchFolders( 8 | context: coda.ExecutionContext, 9 | search: string, 10 | ): Promise { 11 | let url = coda.withQueryParams("https://api.box.com/2.0/search", { 12 | type: "folder", 13 | // If no query has been entered, search for "folder" to find all folders. 14 | query: search || "folder", 15 | limit: 100, 16 | fields: "id,name", 17 | }); 18 | let response = await context.fetcher.fetch({ 19 | method: "GET", 20 | url: url, 21 | }); 22 | return response.body.entries; 23 | } 24 | 25 | export async function getUser(context: coda.ExecutionContext): Promise { 26 | let response = await context.fetcher.fetch({ 27 | method: "GET", 28 | url: "https://api.box.com/2.0/users/me", 29 | }); 30 | return response.body; 31 | } 32 | 33 | export function getFilename( 34 | fileUrl: string, 35 | headers: Record, 36 | ): string { 37 | let contentType = headers["content-type"] as string; 38 | let contentDisposition = headers["content-disposition"] as string; 39 | // Use the original filename, if present. 40 | if (contentDisposition) { 41 | let parsed = ContentDisposition.parse(contentDisposition); 42 | if (parsed.parameters.filename) { 43 | return parsed.parameters.filename; 44 | } 45 | } 46 | // Fallback to last segment of the URL as the name. 47 | let name = fileUrl.split("/").pop() as string; 48 | // Add an appropriate file extension, if the content type is known. 49 | if (contentType) { 50 | let extension = mime.extension(contentType); 51 | if (extension) { 52 | name += `.${extension}`; 53 | } 54 | } 55 | return name; 56 | } 57 | -------------------------------------------------------------------------------- /examples/trigonometry/pack.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | 3 | export const pack = coda.newPack(); 4 | 5 | const DegreesParameter = coda.makeParameter({ 6 | type: coda.ParameterType.Number, 7 | name: "angle", 8 | description: "An angle measured in degrees.", 9 | }); 10 | 11 | const RadiansParameter = coda.makeParameter({ 12 | type: coda.ParameterType.Number, 13 | name: "angle", 14 | description: "An angle measured in radians.", 15 | }); 16 | 17 | pack.addFormula({ 18 | resultType: coda.ValueType.Number, 19 | name: "Sine", 20 | description: "Returns the angle (in radians) whose sine is the given number", 21 | parameters: [RadiansParameter], 22 | examples: [{params: [0], result: 0}], 23 | execute: ([value]) => Math.sin(value), 24 | }); 25 | 26 | pack.addFormula({ 27 | resultType: coda.ValueType.Number, 28 | name: "Cosine", 29 | description: "Returns the cosine of a number (in radians).", 30 | parameters: [RadiansParameter], 31 | examples: [{params: [0], result: 1}], 32 | execute: ([value]) => Math.cos(value), 33 | }); 34 | 35 | pack.addFormula({ 36 | resultType: coda.ValueType.Number, 37 | name: "Tangent", 38 | description: "Returns the tangent of a number (in radians).", 39 | parameters: [RadiansParameter], 40 | examples: [{params: [0], result: 0}], 41 | execute: ([value]) => Math.tan(value), 42 | }); 43 | 44 | pack.addFormula({ 45 | resultType: coda.ValueType.Number, 46 | name: "ToRadians", 47 | description: "Converts degrees to radians.", 48 | parameters: [DegreesParameter], 49 | examples: [{params: [180], result: 3.14}], 50 | execute: ([value]) => (value * Math.PI) / 180, 51 | }); 52 | 53 | pack.addFormula({ 54 | resultType: coda.ValueType.Number, 55 | name: "ToDegrees", 56 | description: "Converts radians to degrees.", 57 | parameters: [RadiansParameter], 58 | examples: [{params: [3.14159], result: 180}], 59 | execute: ([value]) => (value * 180) / Math.PI, 60 | }); 61 | -------------------------------------------------------------------------------- /examples/google-tables/types.ts: -------------------------------------------------------------------------------- 1 | import type * as coda from "@codahq/packs-sdk"; 2 | 3 | // Google Tables API types. 4 | 5 | export interface Table { 6 | name: string; 7 | displayName: string; 8 | columns: Column[]; 9 | timeZone: string; 10 | } 11 | 12 | export interface Column { 13 | name: string; 14 | dataType: string; 15 | id: string; 16 | readonly?: boolean; 17 | // Type-specific. 18 | multipleValuesDisallowed: boolean; 19 | labels: Label[]; 20 | dateDetails: DateDetails; 21 | lookupDetails: LookupDetails; 22 | relationshipDetails: RelationshipDetails; 23 | } 24 | 25 | export interface Row { 26 | name: string; 27 | values: Record; 28 | } 29 | 30 | interface Label { 31 | name: string; 32 | id: string; 33 | } 34 | 35 | interface DateDetails { 36 | hasTime: boolean; 37 | } 38 | 39 | interface LookupDetails { 40 | relationshipColumn: string; 41 | } 42 | 43 | interface RelationshipDetails { 44 | linkedTable: string; 45 | } 46 | 47 | export interface TablesDateTime { 48 | year: number; 49 | month: number; 50 | day: number; 51 | hours: number; 52 | minutes: number; 53 | seconds: number; 54 | nanos: number; 55 | } 56 | 57 | export interface TablesDate { 58 | year: number; 59 | month: number; 60 | day: number; 61 | } 62 | 63 | export interface DriveFile { 64 | mimeType: string; 65 | id: string; 66 | } 67 | 68 | export interface TablesFile { 69 | url: string; 70 | } 71 | 72 | export interface TablesLocation { 73 | address: string; 74 | } 75 | 76 | export interface TablesTimestamp { 77 | seconds: number; 78 | nanos: number; 79 | } 80 | 81 | // Custom types for this Pack. 82 | 83 | export interface CodaRow extends Record { 84 | name: string; 85 | rowLabel: string; 86 | updateTime?: string; 87 | } 88 | 89 | export interface RowsContinuation extends coda.Continuation { 90 | pageToken: string; 91 | rowNumber: number; 92 | } 93 | 94 | export interface PersonReference { 95 | email: string; 96 | } 97 | 98 | export interface RowReference { 99 | name: string; 100 | rowLabel: string; 101 | } 102 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codahq/packs-examples", 3 | "version": "0.0.1", 4 | "description": "Examples of Coda (coda.io) packs.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --require ts-node/register examples/**/*_test.ts", 8 | "integration": "mocha --require ts-node/register examples/**/*_integration.ts", 9 | "compile": "tsc --noEmit", 10 | "lint": "find . -name \"*.ts\" | grep -v /node_modules/ | grep -v .d.ts | xargs node_modules/.bin/eslint", 11 | "lint-fix": "find . -name \"*.ts\" | grep -v /node_modules/ | grep -v .d.ts | xargs node_modules/.bin/eslint --fix", 12 | "validate": "find examples/**/pack.ts -name '*.ts' | xargs -n1 -I {} sh -c 'NODE_OPTIONS=\"--no-deprecation\" coda validate {} || echo \"While validating {}\n\";'" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/coda/packs-examples.git" 17 | }, 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/coda/packs-examples/issues" 21 | }, 22 | "homepage": "https://github.com/coda/packs-examples#readme", 23 | "engines": { 24 | "npm": ">=9", 25 | "node": ">=18" 26 | }, 27 | "dependencies": { 28 | "@codahq/packs-sdk": "^1.9.1", 29 | "content-disposition": "0.5.4", 30 | "form-data": "4.0.1", 31 | "jsrsasign": "11.1.0", 32 | "luxon": "3.5.0", 33 | "mime-types": "2.1.35" 34 | }, 35 | "devDependencies": { 36 | "@types/chai": "4.3.16", 37 | "@types/content-disposition": "0.5.8", 38 | "@types/jsrsasign": "10.5.15", 39 | "@types/luxon": "3.4.2", 40 | "@types/mime-types": "2.1.4", 41 | "@types/mocha": "10.0.10", 42 | "@types/node": "22.13.4", 43 | "@types/sinon": "17.0.3", 44 | "@typescript-eslint/eslint-plugin": "8.24.0", 45 | "@typescript-eslint/parser": "8.24.0", 46 | "chai": "4.5.0", 47 | "eslint": "9.20.1", 48 | "eslint-plugin-ban": "2.0.0", 49 | "eslint-plugin-filenames": "1.3.2", 50 | "eslint-plugin-local": "6.0.0", 51 | "eslint-plugin-prefer-let": "4.0.0", 52 | "eslint-plugin-prettier": "5.2.3", 53 | "json-schema": "0.4.0", 54 | "mocha": "11.1.0", 55 | "prettier": "3.5.1", 56 | "sinon": "19.0.2", 57 | "ts-node": "10.9.2", 58 | "typescript": "5.7.3" 59 | }, 60 | "resolutions": { 61 | "xml2js": "0.6.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tools/eslint/coda_rules/gen_rules/coda_import_style_rule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', {value: true}); 3 | exports.rule = exports.MessageIds = void 0; 4 | const typescript_estree_1 = require('@typescript-eslint/typescript-estree'); 5 | var MessageIds; 6 | (function (MessageIds) { 7 | MessageIds['MultipleSpecifiersFound'] = 'MultipleSpecifiersFound'; 8 | })(MessageIds || (exports.MessageIds = MessageIds = {})); 9 | exports.rule = Object.freeze({ 10 | defaultOptions: [], 11 | meta: { 12 | type: 'problem', 13 | docs: { 14 | description: 'Requires a single specifier per import statement.', 15 | url: '', 16 | }, 17 | fixable: 'code', 18 | messages: { 19 | [MessageIds.MultipleSpecifiersFound]: 'Imports from the same module should be split into multiple statements', 20 | }, 21 | schema: [], 22 | }, 23 | create: context => { 24 | return { 25 | ImportDeclaration: node => { 26 | if (node.specifiers.length > 1) { 27 | context.report({ 28 | messageId: MessageIds.MultipleSpecifiersFound, 29 | node, 30 | fix: fixer => { 31 | const imports = node.specifiers.map(spec => { 32 | const importName = spec.local.name; 33 | let importText = 'import '; 34 | switch (spec.type) { 35 | case typescript_estree_1.AST_NODE_TYPES.ImportDefaultSpecifier: 36 | importText += `${importName} from `; 37 | break; 38 | case typescript_estree_1.AST_NODE_TYPES.ImportNamespaceSpecifier: 39 | importText += `* as ${importName} from `; 40 | break; 41 | case typescript_estree_1.AST_NODE_TYPES.ImportSpecifier: 42 | const importSourceName = 43 | spec.imported.type === typescript_estree_1.AST_NODE_TYPES.Identifier 44 | ? spec.imported.name 45 | : spec.imported.value; 46 | if (importSourceName !== importName) { 47 | importText += `{${importSourceName} as ${importName}} from `; 48 | } else { 49 | importText += `{${importName}} from `; 50 | } 51 | break; 52 | } 53 | importText += `${node.source.raw};`; 54 | return importText; 55 | }); 56 | return fixer.replaceText(node, imports.join('\n')); 57 | }, 58 | }); 59 | } 60 | }, 61 | }; 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /examples/github/types.ts: -------------------------------------------------------------------------------- 1 | // These are the allowable values for the `event` field of a pull request 2 | // review API request as dodcumented by GitHub: 3 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#submit-a-review-for-a-pull-request 4 | export enum GitHubReviewEvent { 5 | Approve = "APPROVE", 6 | Comment = "COMMENT", 7 | RequestChanges = "REQUEST_CHANGES", 8 | } 9 | 10 | // The valid values you can pass in the `state` filter parameter when 11 | // listing PRs. 12 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#list-pull-requests 13 | export enum PullRequestStateFilter { 14 | Open = "open", 15 | Closed = "closed", 16 | All = "all", 17 | } 18 | 19 | // Below are types for GitHub object responses. These only include the 20 | // subset of fields we 21 | // care about for this pack. The actual responses are much larger. 22 | 23 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#get-a-pull-request 24 | export interface GitHubPullRequest { 25 | title: string; 26 | user: GitHubUser; 27 | number: number; 28 | html_url: string; 29 | created_at: string; 30 | updated_at: string; 31 | closed_at?: string; 32 | merged_at?: string; 33 | merge_commit_sha?: string; 34 | body: string; 35 | labels: GitHubLabel[]; 36 | state: string; 37 | additions: number; 38 | deletions: number; 39 | changed_files: number; 40 | merged_by?: GitHubUser; 41 | base: GitHubCommit; 42 | head: GitHubCommit; 43 | assignees: GitHubUser[]; 44 | requested_reviewers: GitHubUser[]; 45 | requested_teams: GitHubTeam[]; 46 | } 47 | 48 | interface GitHubCommit { 49 | repo: GitHubRepo; 50 | ref: string; 51 | } 52 | 53 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/repos#get-a-repository 54 | export interface GitHubRepo { 55 | id: number; 56 | name: string; 57 | full_name: string; 58 | description: string; 59 | html_url: string; 60 | } 61 | 62 | interface GitHubLabel { 63 | name: string; 64 | } 65 | 66 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/users#get-a-user 67 | export interface GitHubUser { 68 | id: number; 69 | login: string; 70 | avatar_url: string; 71 | html_url: string; 72 | } 73 | 74 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/teams#get-a-team-by-name 75 | interface GitHubTeam { 76 | id: number; 77 | name: string; 78 | html_url: string; 79 | } 80 | 81 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#create-a-review-for-a-pull-request 82 | export interface PullRequestReviewResponse { 83 | id: number; 84 | user: GitHubUser; 85 | body: string; 86 | commit_id: string; 87 | state: string; 88 | html_url: string; 89 | } 90 | -------------------------------------------------------------------------------- /examples/github/README.md: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | This is a more substantive example Pack that connects to GitHub using OAuth2 authentication, 4 | and implements a table that syncs various pull requests for one or more repos that the 5 | user has access to, as well as an action formula to post a pull request review. 6 | 7 | To run the example code and actually connect to GitHub, you'll need to create an OAuth app 8 | with GitHub and get a client id and client secret. GitHub's documentation for OAuth apps 9 | lives at https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-an-oauth-app. 10 | 11 | When creating a GitHub OAuth app you'll need to set **`http://localhost:3000/oauth`** 12 | as your "Authorization callback URL" in order to run these examples locally. To run them 13 | on Coda after uploading & releasing, your authorization callback URL must change to be 14 | **`https://coda.io/packsAuth/oauth2/{PACK_ID}`**. 15 | 16 | ## Running the Example 17 | 18 | Run `coda auth examples/github/pack.ts`. You'll be prompted to enter you client id 19 | and client secret, and then your browser will open and begin GitHub's OAuth flow. 20 | After the flow completes, the access token for that account will saved locally 21 | for use when executing formulas and syncs, so you only have to do this once. But you can 22 | run the command again to change to a different account, or to update your token if it 23 | becomes invalid. 24 | 25 | To sync pull requests and output them to the console, you can run: 26 | 27 | ```bash 28 | coda execute examples/github/pack.ts PullRequests https://github.com// 29 | ``` 30 | 31 | To create a review on a pull request, you can run: 32 | 33 | ```bash 34 | coda execute examples/github/pack.ts ReviewPullRequest https://github.com///pull/ COMMENT "Some comment" 35 | ``` 36 | 37 | Note that this will actually update your pull request in GitHub! So be careful and make 38 | sure you don't inadvertently e.g. approve a real PR if you're just exploring. 39 | 40 | To execute the example `PullRequests` table sync, run: 41 | 42 | ```bash 43 | coda execute examples/github/pack.ts PullRequests https://github.com// 44 | ``` 45 | 46 | This will fetch all of the pull requests in that repo, using multiple requests if there are multiple result 47 | pages, combine them into one big array, and log them to the console. 48 | 49 | (By default, it will request only open PRs. You can request all PRs or closed PRs using the optional third 50 | parameter to the sync.) 51 | 52 | ## Running the Tests 53 | 54 | Run the unittests just for this Pack by using: 55 | 56 | ```bash 57 | mocha --require ts-node/register examples/github/test/github_test.ts 58 | ``` 59 | 60 | The unittests use a mock http fetcher and do not connect to a real API. 61 | 62 | There is also an integration test, which makes real http requests to the API. 63 | To run successfully, this test requires that you've already setup an access token 64 | using `coda auth examples/github/pack.ts`. 65 | 66 | ```bash 67 | mocha --require ts-node/register examples/github/test/github_integration.ts 68 | ``` 69 | -------------------------------------------------------------------------------- /examples/vonage/pack.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | import * as credentials from "./credentials"; 3 | import * as rs from "jsrsasign"; 4 | 5 | export const pack = coda.newPack(); 6 | 7 | const ConversationSchema = coda.makeObjectSchema({ 8 | properties: { 9 | id: { 10 | type: coda.ValueType.String, 11 | }, 12 | name: { 13 | type: coda.ValueType.String, 14 | }, 15 | display_name: { 16 | type: coda.ValueType.String, 17 | }, 18 | image: { 19 | type: coda.ValueType.String, 20 | codaType: coda.ValueHintType.ImageReference, 21 | fromKey: "image_url", 22 | }, 23 | }, 24 | displayProperty: "name", 25 | idProperty: "id", 26 | featuredProperties: ["display_name", "image"], 27 | }); 28 | 29 | pack.addNetworkDomain("nexmo.com"); 30 | 31 | pack.addSyncTable({ 32 | name: "Conversations", 33 | description: "Lists the conversations created by the application.", 34 | identityName: "Conversation", 35 | schema: ConversationSchema, 36 | formula: { 37 | name: "SyncConversations", 38 | description: "Syncs the conversations.", 39 | parameters: [], 40 | execute: async function (args, context) { 41 | // Use the next URL if available, or default to the base URL. 42 | let url = context.sync.continuation?.nextUrl as string; 43 | if (!url) { 44 | url = "https://api.nexmo.com/v1/conversations"; 45 | } 46 | 47 | // Create a JWT for authentication. 48 | let jwt = createJwt(context); 49 | 50 | // Fetch the list of conversations. 51 | let response = await context.fetcher.fetch({ 52 | method: "GET", 53 | url: "https://api.nexmo.com/v1/conversations", 54 | headers: { 55 | Authorization: `Bearer ${jwt}`, 56 | }, 57 | }); 58 | let conversations = response.body._embedded.conversations; 59 | let nextUrl = response.body._links.next; 60 | 61 | // Create a continuation, if there are more conversations available. 62 | let continuation; 63 | if (nextUrl) { 64 | continuation = { 65 | nextUrl: nextUrl, 66 | }; 67 | } 68 | 69 | return { 70 | result: conversations, 71 | continuation: continuation, 72 | }; 73 | }, 74 | }, 75 | }); 76 | 77 | // Create a JWT using the application ID and private key in credentials.ts. 78 | // See https://developer.vonage.com/en/getting-started/concepts/authentication#json-web-tokens 79 | function createJwt(context: coda.ExecutionContext) { 80 | let now = Date.now() / 1000; 81 | let header = { 82 | alg: "RS256", 83 | typ: "JWT", 84 | }; 85 | let payload = { 86 | application_id: credentials.ApplicationId, 87 | iat: now, 88 | nbf: now, 89 | exp: now + 600, // Expires in 10 minutes. 90 | jti: context.invocationToken, 91 | acl: { 92 | paths: { 93 | "/*/conversations/**": { 94 | methods: ["GET"], 95 | }, 96 | }, 97 | }, 98 | }; 99 | return rs.KJUR.jws.JWS.sign("RS256", header, payload, credentials.PrivateKey); 100 | } 101 | -------------------------------------------------------------------------------- /examples/dictionary/test/dictionary_test.ts: -------------------------------------------------------------------------------- 1 | import type {APIEntry} from "../types"; 2 | import type {MockExecutionContext} from "@codahq/packs-sdk/dist/development"; 3 | import {assert} from "chai"; 4 | import {describe} from "mocha"; 5 | import {executeFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 6 | import {it} from "mocha"; 7 | import {newJsonFetchResponse} from "@codahq/packs-sdk/dist/development"; 8 | import {newMockExecutionContext} from "@codahq/packs-sdk/dist/development"; 9 | import {pack} from "../pack"; 10 | import * as sinon from "sinon"; 11 | 12 | describe("Dictionary pack", () => { 13 | let context: MockExecutionContext; 14 | 15 | beforeEach(() => { 16 | // Before each test, we create a brand new execution context. 17 | // This will allow us to register fake fetcher responses. 18 | context = newMockExecutionContext(); 19 | }); 20 | 21 | it("executes Define", async () => { 22 | // We create a fake API response and set up our mock fetcher to return it. 23 | // Because we've defined an APIEntry type that specifies the structure of 24 | // the API response, it's easy to create a fake response that has all 25 | // the necessary fields, because TypeScript will help us. 26 | let fakeEntry: APIEntry = { 27 | shortdef: ["definition of foo"], 28 | fl: "noun", 29 | hwi: {hw: "foo"}, 30 | date: "1970", 31 | meta: { 32 | id: "foo", 33 | offensive: false, 34 | }, 35 | }; 36 | let fakeEntries = [fakeEntry]; 37 | context.fetcher.fetch.resolves(newJsonFetchResponse(fakeEntries)); 38 | 39 | // This is the heart of the test, where we actually execute the formula on a 40 | // given set of parameters, using our mock execution context. 41 | let response = await executeFormulaFromPackDef( 42 | pack, 43 | "Define", 44 | ["foo"], 45 | context, 46 | ); 47 | 48 | assert.equal(1, response.length); 49 | // The response object has gone through normalization, standardizing the 50 | // capitalization and formatting of object propery names to be consistent 51 | // across packs. 52 | assert.deepEqual(response[0], { 53 | Id: "foo", 54 | Definitions: ["definition of foo"], 55 | PartOfSpeech: "noun", 56 | Headword: "foo", 57 | FirstUse: "1970", 58 | Offensive: false, 59 | }); 60 | sinon.assert.calledOnceWithExactly(context.fetcher.fetch, { 61 | method: "GET", 62 | url: "https://www.dictionaryapi.com/api/v3/references/collegiate/json/foo", 63 | }); 64 | }); 65 | 66 | // It's important to verify how your pack behaves in edges cases. We've 67 | // checked that the real API returns an empty list as a response when it 68 | // cannot find the input word, so we simulate that here, and make sure 69 | // that our implementation doesn't throw any errors. 70 | it("executes with an empty response", async () => { 71 | context.fetcher.fetch.resolves(newJsonFetchResponse([])); 72 | let response = await executeFormulaFromPackDef( 73 | pack, 74 | "Define", 75 | ["unknown"], 76 | context, 77 | ); 78 | assert.deepEqual([], response); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /examples/box/pack.ts: -------------------------------------------------------------------------------- 1 | import FormData from "form-data"; 2 | import * as coda from "@codahq/packs-sdk"; 3 | import {getFilename} from "./helpers"; 4 | import {getUser} from "./helpers"; 5 | import {searchFolders} from "./helpers"; 6 | 7 | export const pack = coda.newPack(); 8 | 9 | pack.addNetworkDomain("box.com"); 10 | 11 | pack.setUserAuthentication({ 12 | type: coda.AuthenticationType.OAuth2, 13 | authorizationUrl: "https://account.box.com/api/oauth2/authorize", 14 | tokenUrl: "https://api.box.com/oauth2/token", 15 | getConnectionName: async function (context) { 16 | let user = await getUser(context); 17 | return user.name; 18 | }, 19 | }); 20 | 21 | pack.addFormula({ 22 | name: "UploadFile", 23 | description: "Uploads a file to Box.", 24 | parameters: [ 25 | coda.makeParameter({ 26 | type: coda.ParameterType.File, 27 | name: "file", 28 | description: "The file to upload.", 29 | }), 30 | coda.makeParameter({ 31 | type: coda.ParameterType.String, 32 | name: "filename", 33 | description: 34 | "The filename to upload to. Default: the original filename of the file.", 35 | optional: true, 36 | }), 37 | coda.makeParameter({ 38 | type: coda.ParameterType.String, 39 | name: "folderId", 40 | description: 41 | "The ID of the folder to upload to. Default: the root folder.", 42 | optional: true, 43 | autocomplete: async function (context, search) { 44 | let folders = await searchFolders(context, search); 45 | return folders.map(folder => { 46 | return { 47 | display: folder.name, 48 | value: folder.id, 49 | }; 50 | }); 51 | }, 52 | }), 53 | ], 54 | resultType: coda.ValueType.String, 55 | isAction: true, 56 | execute: async function (args, context) { 57 | let [fileUrl, filename, folderId = "0"] = args; 58 | 59 | // Download the file from the URL. 60 | let download = await context.fetcher.fetch({ 61 | method: "GET", 62 | url: fileUrl, 63 | isBinaryResponse: true, 64 | disableAuthentication: true, 65 | }); 66 | let file = download.body; 67 | if (!filename) { 68 | filename = getFilename(fileUrl, download.headers); 69 | } 70 | let contentType = download.headers["content-type"] as string; 71 | 72 | // Define the file attributes to send to Box. 73 | let attributes = { 74 | name: filename, 75 | parent: { 76 | id: folderId, 77 | }, 78 | }; 79 | 80 | // Bundle the attributes and the file as form data. 81 | let form = new FormData(); 82 | form.append("attributes", JSON.stringify(attributes)); 83 | form.append("file", file, { 84 | contentType: contentType, 85 | filename: filename, 86 | }); 87 | 88 | // Send the form data to box to complete the upload. 89 | let upload = await context.fetcher.fetch({ 90 | method: "POST", 91 | url: "https://upload.box.com/api/2.0/files/content", 92 | headers: { 93 | ...form.getHeaders(), 94 | }, 95 | body: form.getBuffer(), 96 | }); 97 | 98 | // Return the ID of the uploaded file. 99 | return upload.body.entries[0].id; 100 | }, 101 | }); 102 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import ban from "eslint-plugin-ban"; 2 | import filenames from "eslint-plugin-filenames"; 3 | import local from "eslint-plugin-local"; 4 | import preferLet from "eslint-plugin-prefer-let"; 5 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 6 | import prettier from "eslint-plugin-prettier"; 7 | import globals from "globals"; 8 | import tsParser from "@typescript-eslint/parser"; 9 | import path from "node:path"; 10 | import { fileURLToPath } from "node:url"; 11 | import js from "@eslint/js"; 12 | import { FlatCompat } from "@eslint/eslintrc"; 13 | 14 | const __filename = fileURLToPath(import.meta.url); 15 | const __dirname = path.dirname(__filename); 16 | const compat = new FlatCompat({ 17 | baseDirectory: __dirname, 18 | recommendedConfig: js.configs.recommended, 19 | allConfig: js.configs.all 20 | }); 21 | 22 | export default [...compat.extends("./tools/eslint/base_rules.js"), { 23 | files: ["**/*.ts"], 24 | 25 | plugins: { 26 | ban, 27 | filenames, 28 | local, 29 | "@typescript-eslint": typescriptEslint, 30 | }, 31 | 32 | languageOptions: { 33 | globals: { 34 | ...globals.browser, 35 | ...globals.commonjs, 36 | ...globals.node, 37 | ...globals.mocha, 38 | ...globals.jest, 39 | }, 40 | 41 | parser: tsParser, 42 | ecmaVersion: 5, 43 | sourceType: "module", 44 | 45 | parserOptions: { 46 | project: ["./tsconfig.json"], 47 | }, 48 | }, 49 | 50 | rules: { 51 | "@typescript-eslint/restrict-plus-operands": "error", 52 | }, 53 | 54 | settings: {}, 55 | }, { 56 | files: ["examples/**/*.ts"], 57 | 58 | plugins: { 59 | "prefer-let": preferLet, 60 | "@typescript-eslint": typescriptEslint, 61 | prettier, 62 | }, 63 | 64 | rules: { 65 | "@typescript-eslint/no-unused-vars": ["error", { 66 | varsIgnorePattern: "_.*|response|datasourceUrl|MySchema", 67 | argsIgnorePattern: "_.*|context|param", 68 | }], 69 | 70 | "object-shorthand": ["error", "never"], 71 | 72 | "max-len": ["error", { 73 | code: 80, 74 | ignoreUrls: true, 75 | ignoreRegExpLiterals: true, 76 | ignoreStrings: true, 77 | ignorePattern: "^import ", 78 | }], 79 | 80 | quotes: ["error", "double", { 81 | avoidEscape: true, 82 | }], 83 | 84 | "prefer-const": "off", 85 | "prefer-let/prefer-let": 2, 86 | "prefer-template": "off", 87 | "comma-dangle": ["error", "always-multiline"], 88 | semi: ["error", "always"], 89 | "prettier/prettier": "error", 90 | }, 91 | }, { 92 | files: ["**/*_test.{ts,tsx}"], 93 | 94 | rules: { 95 | "@typescript-eslint/no-non-null-assertion": "off", 96 | }, 97 | }, { 98 | files: ["**/*.d.ts"], 99 | 100 | rules: { 101 | "@typescript-eslint/no-unused-vars": "off", 102 | camelcase: "off", 103 | }, 104 | }, { 105 | files: ["**/types.ts"], 106 | 107 | rules: { 108 | camelcase: "off", 109 | }, 110 | }, { 111 | files: ["examples/template/**/*.ts"], 112 | 113 | rules: { 114 | "@typescript-eslint/consistent-type-imports": "off", 115 | }, 116 | }]; 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coda Packs Examples 2 | 3 | This repository contains some example code and templates for Coda Packs built with [Coda's Packs SDK][docs_home]. These sample Packs assume you are developing locally using the Pack CLI, as they include multiple source files and tests. 4 | 5 | A more complete set of examples, including those which are compatible with the Pack Studio web editor, are available in the [Samples page][docs_samples] of the documentation. 6 | 7 | ## Prerequisites 8 | 9 | Make sure you have `node`, `typescript`, `npm` and `yarn` installed. 10 | 11 | ## Setup 12 | 13 | To be able to work with the examples in this repo, simply run `yarn` to install dependencies. 14 | 15 | ## Running Examples 16 | 17 | Use the `coda` command line tool to execute formulas directly. For example: 18 | 19 | ```bash 20 | npx coda execute examples/trigonometry/pack.ts Cosine 0 21 | ``` 22 | 23 | See the guide [Using the command line interface][docs_cli] for more information on how to use the command line tool. 24 | 25 | ## Running Example Tests 26 | 27 | Each of the accompanying examples include sample unit tests. You can run them all with `yarn test`. 28 | Each example's readme explains how to run those tests individually. 29 | 30 | There is also an integration test suite that runs tests that actually connect to the 31 | APIs used in the example Packs. You can run this with `yarn run integration`. For this 32 | to run successfully, you must have set up credentials for each Pack. See the readme 33 | for each example for instructions on how to set up credentials, and how to 34 | run that example's integration test individually. 35 | 36 | ## Example Walkthroughs 37 | 38 | Several example Packs are provided in the `examples` directory. Each example has its 39 | own readme with more details. 40 | 41 | The `template` Pack is minimal boilerplate for creating 42 | a new Pack from scratch. The contents of this example are automatically copied to your 43 | working directory if you run the `coda init` command, which is our recommended way to get 44 | started, rather than manually copying this example. 45 | 46 | The [`trigonometry`](examples/trigonometry/README.md) Pack is one of the simplest meaningful 47 | Packs. It exposes formulas for common trigonometric functions like sine and cosine by wrapping 48 | the existing JavaScript implementations of these functions. It's a good way to ease into 49 | understanding the structure and execution of a Pack. 50 | 51 | The [`dictionary`](examples/dictionary/README.md) Pack is a simple example that uses authentication 52 | (an API key in this case) and make http requests to a third-party API service. It's a good 53 | starting point for understanding how Packs make http requests and use authentication, 54 | and to try out the `coda auth` command for setting up authentication locally for development. 55 | 56 | The [`github`](examples/github/README.md) Pack is a relatively full-featured Pack that uses 57 | OAuth authentication to get user-specific data, and implements both an action formula 58 | (a formula that can be connected to Coda button that updates a third-party service) 59 | as well as a sync table. 60 | 61 | The [`box`](examples/box/README.md) Pack is a small example that demonstrates how to create an action formula that uploads a file to Box. The primary purpose of this example is to demonstrate how to use the `form-data` NPM library to send `multipart/form-data` payloads with the Fetcher. 62 | 63 | 64 | [docs_home]: https://coda.io/packs/build/latest/ 65 | [docs_samples]: https://coda.io/packs/build/latest/samples/ 66 | [docs_cli]: https://coda.io/packs/build/latest/guides/development/cli/ 67 | -------------------------------------------------------------------------------- /examples/google-tables/helpers.ts: -------------------------------------------------------------------------------- 1 | import type {CodaRow} from "./types"; 2 | import type {Column} from "./types"; 3 | import type {Row} from "./types"; 4 | import type {Table} from "./types"; 5 | import * as coda from "@codahq/packs-sdk"; 6 | import {getConverter} from "./convert"; 7 | 8 | const BaseUrl = "https://area120tables.googleapis.com/v1alpha1"; 9 | const PageSize = 100; 10 | const ShortCacheTimeSecs = 60; 11 | 12 | export function getTableUrl(tableName: string): string { 13 | return coda.joinUrl(BaseUrl, tableName); 14 | } 15 | 16 | // Get the available tables from the API. 17 | export async function getTables( 18 | context: coda.ExecutionContext, 19 | ): Promise { 20 | let url = coda.withQueryParams(coda.joinUrl(BaseUrl, "tables"), { 21 | pageSize: PageSize, 22 | }); 23 | let response = await context.fetcher.fetch({ 24 | method: "GET", 25 | url: url, 26 | cacheTtlSecs: ShortCacheTimeSecs, 27 | }); 28 | return response.body.tables as Table[]; 29 | } 30 | 31 | // Get a specific table from the API. 32 | export async function getTable( 33 | context: coda.ExecutionContext, 34 | tableUrl: string, 35 | ): Promise { 36 | let response = await context.fetcher.fetch({ 37 | method: "GET", 38 | url: tableUrl, 39 | cacheTtlSecs: ShortCacheTimeSecs, 40 | }); 41 | return response.body; 42 | } 43 | 44 | // Get a page of table rows from the API. 45 | export async function getRows( 46 | context: coda.ExecutionContext, 47 | tableUrl: string, 48 | pageToken?: string, 49 | ): Promise<{rows: Row[]; nextPageToken?: string}> { 50 | let url = coda.withQueryParams(coda.joinUrl(tableUrl, "rows"), { 51 | view: "COLUMN_ID_VIEW", 52 | pageSize: PageSize, 53 | pageToken: pageToken, 54 | }); 55 | let response = await context.fetcher.fetch({ 56 | method: "GET", 57 | url: url, 58 | }); 59 | return response.body; 60 | } 61 | 62 | // Update a table row using the API. 63 | export async function updateRow( 64 | context: coda.UpdateSyncExecutionContext, 65 | row: Row, 66 | ) { 67 | let url = coda.withQueryParams(coda.joinUrl(BaseUrl, row.name), { 68 | view: "COLUMN_ID_VIEW", 69 | }); 70 | try { 71 | let response = await context.fetcher.fetch({ 72 | method: "PATCH", 73 | url: url, 74 | headers: { 75 | "Content-Type": "application/json", 76 | }, 77 | body: JSON.stringify(row), 78 | }); 79 | return response.body; 80 | } catch (error) { 81 | if ( 82 | coda.StatusCodeError.isStatusCodeError(error) && 83 | // Don't swallow 401's, since they are needed to trigger an OAuth refresh. 84 | error.statusCode !== 401 85 | ) { 86 | if (error.body?.error) { 87 | throw new coda.UserVisibleError(error.body.error.message); 88 | } 89 | } 90 | throw error; 91 | } 92 | } 93 | 94 | export function getPropertySchema( 95 | column: Column, 96 | table: Table, 97 | context: coda.ExecutionContext, 98 | ): (coda.Schema & coda.ObjectSchemaProperty) | undefined { 99 | let converter = getConverter(context, column, table); 100 | return converter.getSchema(); 101 | } 102 | 103 | export function formatRowForSchema( 104 | row: Row, 105 | table: Table, 106 | context: coda.ExecutionContext, 107 | label: string, 108 | ): CodaRow { 109 | let result: CodaRow = { 110 | name: row.name, 111 | rowLabel: label, 112 | }; 113 | for (let [columnId, value] of Object.entries(row.values)) { 114 | let column = table.columns.find(column => column.id === columnId); 115 | if (!column) { 116 | throw new Error(`Cannot find column: ${columnId}`); 117 | } 118 | let converter = getConverter(context, column, table); 119 | if (converter.formatValueForSchema) { 120 | value = converter.formatValueForSchema(value); 121 | } 122 | result[columnId] = value; 123 | } 124 | return result; 125 | } 126 | 127 | export function formatRowForApi( 128 | row: CodaRow, 129 | table: Table, 130 | context: coda.ExecutionContext, 131 | ): Row { 132 | let result: Row = { 133 | name: row.name, 134 | values: {}, 135 | }; 136 | for (let column of table.columns) { 137 | let value = row[column.id]; 138 | if (value) { 139 | let converter = getConverter(context, column, table); 140 | if (converter.formatValueForApi) { 141 | value = converter.formatValueForApi(value); 142 | } 143 | } 144 | result.values[column.id] = value; 145 | } 146 | return result; 147 | } 148 | -------------------------------------------------------------------------------- /tools/eslint/coda_rules/gen_rules/coda_import_ordering_rule.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | Object.defineProperty(exports, '__esModule', {value: true}); 3 | var MessageIds; 4 | (function(MessageIds) { 5 | MessageIds['NonCompliantOrdering'] = 'NonCompliantOrdering'; 6 | })((MessageIds = exports.MessageIds || (exports.MessageIds = {}))); 7 | exports.rule = { 8 | meta: { 9 | type: 'problem', 10 | docs: { 11 | category: 'Stylistic Issues', 12 | description: `Requires and auto-fixes import ordering to meet Coda's style guidelines.`, 13 | recommended: false, 14 | url: '', 15 | }, 16 | fixable: 'code', 17 | messages: { 18 | [MessageIds.NonCompliantOrdering]: 'Imports must follow Coda style guidelines.', 19 | }, 20 | schema: [], 21 | }, 22 | create: context => { 23 | /* 24 | Sort order is 25 | - side effect test helpers 26 | - main 'testHelper' 27 | - supplementary testHelpers 28 | - side effect imports 29 | - remaining imports 30 | */ 31 | function getSortKeyFromImport(node) { 32 | // Take the first specifier. Multiple specifiers are handled by 'coda-import-style' 33 | const specifier = node.specifiers[0]; 34 | const importName = specifier ? specifier.local.name : ''; 35 | const moduleName = node.source.raw; 36 | let precedence = -1; 37 | if (moduleName.endsWith(`/test_helper'`) || moduleName.endsWith(`_test_helper'`)) { 38 | if (!importName) { 39 | precedence = 4; 40 | } else if (importName === 'testHelper') { 41 | precedence = 3; 42 | } else if (importName.endsWith('TestHelper')) { 43 | precedence = 2; 44 | } 45 | } 46 | if (precedence === -1) { 47 | precedence = importName ? 0 : 1; 48 | } 49 | const sortKey = `${importName}-${moduleName}`; 50 | if (precedence > 0) { 51 | return `${'!'.repeat(precedence)}${sortKey}`; 52 | } 53 | return sortKey; 54 | } 55 | let State; 56 | (function(State) { 57 | State[(State['Start'] = 0)] = 'Start'; 58 | State[(State['ProcessingImports'] = 1)] = 'ProcessingImports'; 59 | State[(State['TraversingImport'] = 2)] = 'TraversingImport'; 60 | State[(State['FinishedImports'] = 3)] = 'FinishedImports'; 61 | })(State || (State = {})); 62 | const sourceLines = context.getSourceCode().lines; 63 | const imports = []; 64 | let importStart = 1; // Lines are 1-indexed 65 | let state = State.Start; 66 | return { 67 | ImportDeclaration: node => { 68 | if (state === State.FinishedImports) { 69 | return; 70 | } 71 | state = State.TraversingImport; 72 | const importEnd = node.loc.end.line + 1; 73 | // Take all lines preceding the import since the last import 74 | // So we preserve comment placement (e.g., eslint ignore comments and such) 75 | imports.push({ 76 | lines: sourceLines.slice(importStart - 1, importEnd - 1), 77 | sortKey: getSortKeyFromImport(node), 78 | start: node.loc.start, 79 | end: node.loc.end, 80 | }); 81 | importStart = importEnd; 82 | }, 83 | 'ImportDeclaration:exit': () => { 84 | if (state === State.TraversingImport) { 85 | state = State.ProcessingImports; 86 | } 87 | }, 88 | ':not(ImportDeclaration)': () => { 89 | if (state !== State.TraversingImport && state !== State.Start) { 90 | state = State.FinishedImports; 91 | } 92 | }, 93 | 'Program:exit': node => { 94 | if (state === State.Start) { 95 | // No imports were found 96 | return; 97 | } 98 | const sortedImports = [...imports].sort((a, b) => (a.sortKey < b.sortKey ? -1 : a.sortKey > b.sortKey ? 1 : 0)); 99 | if (!sortedImports.every((imp, i) => imp === imports[i])) { 100 | context.report({ 101 | messageId: MessageIds.NonCompliantOrdering, 102 | node, 103 | loc: {start: imports[0].start, end: imports[imports.length - 1].end}, 104 | fix: fixer => { 105 | const fullText = sortedImports.map(imp => imp.lines.join('\n')).join('\n'); 106 | // Since we just grabbed the lines as-is, fullText should be the same length as the text we're replacing 107 | return fixer.replaceTextRange([0, fullText.length], fullText); 108 | }, 109 | }); 110 | } 111 | }, 112 | }; 113 | }, 114 | }; 115 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */, 6 | "lib": [ 7 | /* Specify library files to be included in the compilation: */ 8 | "es2022" 9 | ], 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | "declaration": true /* Generates corresponding '.d.ts' file. */, 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./dist" /* Redirect output structure to the directory. */, 17 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | "skipLibCheck": true /* Work around type collisions btwn Chai and Jest. */, 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | "resolveJsonModule": true /* Resolve types from imported JSON files. */, 32 | "esModuleInterop": true /* Handle CommonJS module imports as expected. */, 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true /* Report errors on unused locals. */, 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 42 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 43 | "paths": { 44 | "base64url": ["./node_modules/base64url/dist/base64url.d.ts"] 45 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | 57 | /* Experimental Options */ 58 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "include": ["./**/*.ts"], 62 | "exclude": ["./node_modules"] 63 | } 64 | -------------------------------------------------------------------------------- /tools/eslint/base_rules.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | '@typescript-eslint/adjacent-overload-signatures': 'error', 4 | '@typescript-eslint/array-type': ['error', {default: 'array-simple', readonly: 'array-simple'}], 5 | 'ban/ban': [ 6 | 'error', 7 | { 8 | types: { 9 | Object: 'Avoid using the `Object` type. Did you mean `object`?', 10 | Function: 'Avoid using the `Function` type. Prefer a specific function type, like `() => void`.', 11 | Boolean: 'Avoid using the `Boolean` type. Did you mean `boolean`?', 12 | Number: 'Avoid using the `Number` type. Did you mean `number`?', 13 | String: 'Avoid using the `String` type. Did you mean `string`?', 14 | Symbol: 'Avoid using the `Symbol` type. Did you mean `symbol`?', 15 | }, 16 | extendDefaults: false, 17 | }, 18 | ], 19 | '@typescript-eslint/consistent-type-imports': 'error', 20 | '@typescript-eslint/explicit-member-accessibility': [ 21 | 'error', 22 | { 23 | accessibility: 'no-public', 24 | overrides: { 25 | constructors: 'off', 26 | }, 27 | }, 28 | ], 29 | '@typescript-eslint/indent': 'off', 30 | '@typescript-eslint/interface-name-prefix': 'off', 31 | '@typescript-eslint/member-delimiter-style': 'off', 32 | '@typescript-eslint/consistent-type-assertions': [ 33 | 'error', 34 | { 35 | assertionStyle: 'as', 36 | objectLiteralTypeAssertions: 'allow', 37 | }, 38 | ], 39 | '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], 40 | '@typescript-eslint/naming-convention': [ 41 | 'error', 42 | { 43 | selector: 'variable', 44 | format: ['camelCase', 'UPPER_CASE', 'PascalCase'], 45 | leadingUnderscore: 'allow', 46 | }, 47 | { 48 | selector: 'class', 49 | format: ['PascalCase'], 50 | }, 51 | ], 52 | '@typescript-eslint/no-empty-interface': 'off', 53 | '@typescript-eslint/no-explicit-any': 'off', 54 | '@typescript-eslint/no-floating-promises': ['error', {ignoreVoid: true}], 55 | '@typescript-eslint/no-misused-new': 'error', 56 | '@typescript-eslint/no-namespace': ['error', {allowDeclarations: true}], 57 | // TODO: re-enable once violations are fixed and we have dev consensus. 58 | '@typescript-eslint/no-non-null-assertion': 'off', 59 | '@typescript-eslint/no-unused-expressions': ['error', {allowShortCircuit: true, allowTernary: true}], 60 | '@typescript-eslint/no-unused-vars': [ 61 | 'error', 62 | {vars: 'all', ignoreRestSiblings: true, varsIgnorePattern: '_.*', argsIgnorePattern: '_.*'}, 63 | ], 64 | '@typescript-eslint/no-use-before-declare': 'off', 65 | '@typescript-eslint/no-var-requires': 'off', 66 | '@typescript-eslint/parameter-properties': ['error', {}], 67 | '@typescript-eslint/prefer-for-of': 'error', 68 | '@typescript-eslint/prefer-function-type': 'error', 69 | '@typescript-eslint/prefer-namespace-keyword': 'error', 70 | '@typescript-eslint/restrict-plus-operands': 'error', 71 | '@typescript-eslint/triple-slash-reference': ['error', {path: 'never', types: 'never', lib: 'never'}], 72 | '@typescript-eslint/type-annotation-spacing': 'off', 73 | '@typescript-eslint/unified-signatures': 'error', 74 | 75 | // eslint-plugin-ban 76 | 'ban/ban': [ 77 | 'error', 78 | { 79 | name: ['Promise', 'race'], 80 | message: 'Avoid Promise.race since it can lead to memory leaks.', 81 | }, 82 | { 83 | name: ['*', 'spread'], 84 | message: 'Use Promise#then(([...]) => ...) instead of Promise#spread for correctly-inferred types.', 85 | }, 86 | { 87 | name: ['it', 'only'], 88 | message: 'Do not commit Mocha it.only', 89 | }, 90 | { 91 | name: ['describe', 'only'], 92 | message: 'Do not commit Mocha describe.only', 93 | }, 94 | ], 95 | 96 | // eslint-plugin-filenames 97 | // TODO(jonathan): Re-enable once we find the violating files. 98 | // 'filenames/match-regex': ['error', '^[a-z][a-z0-9_.]+$', true], 99 | 100 | // ESLint Built-ins 101 | 'arrow-parens': ['error', 'as-needed'], 102 | camelcase: ['error', {ignoreDestructuring: true, properties: 'never'}], 103 | complexity: 'off', 104 | 'constructor-super': 'error', 105 | curly: 'error', 106 | 'dot-notation': 'error', 107 | 'eol-last': 'off', 108 | eqeqeq: ['error', 'always', {null: 'ignore'}], 109 | 'guard-for-in': 'error', 110 | 'jsx-quotes': ['error', 'prefer-double'], 111 | 'linebreak-style': 'off', 112 | 'max-len': [ 113 | 'error', 114 | { 115 | code: 120, 116 | tabWidth: 2, 117 | ignoreUrls: true, 118 | ignoreStrings: true, 119 | ignoreTemplateLiterals: true, 120 | ignoreRegExpLiterals: true, 121 | ignorePattern: '^import ', 122 | }, 123 | ], 124 | 'max-classes-per-file': 'off', 125 | 'member-ordering': 'off', 126 | 'new-parens': 'off', 127 | 'newline-per-chained-call': 'off', 128 | 'no-bitwise': 'error', 129 | 'no-caller': 'error', 130 | 'no-cond-assign': 'error', 131 | 'no-console': 'error', 132 | 'no-debugger': 'error', 133 | 'no-duplicate-case': 'error', 134 | 'no-empty': 'off', 135 | 'no-empty-functions': 'off', 136 | 'no-eval': 'error', 137 | 'no-extra-semi': 'off', 138 | 'no-fallthrough': 'off', 139 | 'no-invalid-this': 'off', 140 | 'no-irregular-whitespace': 'off', 141 | 'no-multiple-empty-lines': 'off', 142 | 'no-new-wrappers': 'error', 143 | 'no-return-await': 'error', 144 | 'no-throw-literal': 'off', 145 | 'no-undef-init': 'error', 146 | 'no-unsafe-finally': 'error', 147 | 'no-unused-labels': 'error', 148 | 'no-var': 'error', 149 | 'object-shorthand': 'error', 150 | 'one-var': ['error', 'never'], 151 | 'prefer-arrow-callback': 'error', 152 | 'prefer-const': 'error', 153 | 'quote-props': ['error', 'as-needed'], 154 | quotes: ['error', 'single', {avoidEscape: true, allowTemplateLiterals: true}], 155 | radix: 'error', 156 | 'space-before-function-paren': 'off', 157 | 'spaced-comment': ['error', 'always', {exceptions: ['*']}], 158 | 'use-isnan': 'error', 159 | 'valid-typeof': 'off', 160 | 161 | // ESLint coda rules 162 | 'local/coda-import-style': 'error', 163 | 'local/coda-import-ordering': 'error', 164 | }, 165 | }; 166 | -------------------------------------------------------------------------------- /examples/github/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | import type * as types from "./types"; 3 | 4 | const PULL_REQUEST_URL_REGEX = 5 | /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/; 6 | const REPO_URL_REGEX = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)$/; 7 | 8 | const DEFAULT_PAGE_SIZE = 100; 9 | 10 | // This is a simple wrapper that takes a relative GitHub url and turns into 11 | // an absolute url. We recommend having helpers to generate API urls 12 | // particularly when APIs use versioned urls, so that if you need to update 13 | // to a new version of the API it is easy to do so in one place. Here 14 | // GitHub's API urls do not have version identifiers so this is less 15 | // meaningful but we do so to future-proof things. 16 | export function apiUrl(path: string, params?: Record): string { 17 | let url = `https://api.github.com${path}`; 18 | return params ? coda.withQueryParams(url, params) : url; 19 | } 20 | 21 | // This formula is used in the authentication definition in pack.ts. 22 | // It returns a simple label for the current user's account so the account 23 | // can be identified in the UI. 24 | export async function getConnectionName(context: coda.ExecutionContext) { 25 | let request: coda.FetchRequest = { 26 | method: "GET", 27 | url: apiUrl("/user"), 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | }; 32 | let response = await context.fetcher.fetch(request); 33 | return (response.body as types.GitHubUser).login; 34 | } 35 | 36 | // The user-facing formula uses a pull request url to identify PRs in a 37 | // user-friendly way. We parse such a url into its identifiers. 38 | export function parsePullUrl(url: string): { 39 | owner: string; 40 | repo: string; 41 | pullNumber: string; 42 | } { 43 | let match = coda.ensureExists( 44 | PULL_REQUEST_URL_REGEX.exec(url), 45 | "Received an invalid pull request URL", 46 | ); 47 | return { 48 | owner: match[1], 49 | repo: match[2], 50 | pullNumber: match[3], 51 | }; 52 | } 53 | 54 | // The user-facing formula uses a url to identify repos in a user-friendly way. 55 | // We parse such a url into its identifiers. 56 | export function parseRepoUrl(url: string): {owner: string; repo: string} { 57 | let match = coda.ensureExists( 58 | REPO_URL_REGEX.exec(url), 59 | "Received an invalid repo URL", 60 | ); 61 | return { 62 | owner: match[1], 63 | repo: match[2], 64 | }; 65 | } 66 | 67 | // Get a single page of repos that the user has access to. The caller can 68 | // call this repeatedly with a continuation if the user has access to more 69 | // repos than can fit on one page (100). 70 | export async function getRepos( 71 | context: coda.ExecutionContext, 72 | continuation?: coda.Continuation, 73 | ) { 74 | let url = continuation?.nextUrl 75 | ? (continuation.nextUrl as string) 76 | : apiUrl("/user/repos", {per_page: DEFAULT_PAGE_SIZE}); 77 | 78 | let result = await context.fetcher.fetch({ 79 | url: url, 80 | method: "GET", 81 | }); 82 | 83 | let nextUrl = nextUrlFromLinkHeader(result); 84 | return { 85 | result: result.body as types.GitHubRepo[], 86 | continuation: nextUrl ? {nextUrl: nextUrl} : undefined, 87 | }; 88 | } 89 | 90 | // The meat of the implementation of the PullRequests sync table. 91 | // Fetches a page of pull requests matching the given param filters, 92 | // optionally returning a continuation indicating where to pick up 93 | // to fetch a subsequent result page. 94 | export async function getPullRequests( 95 | [repoUrl, base, state]: any[], 96 | context: coda.SyncExecutionContext, 97 | ): Promise { 98 | let {continuation} = context.sync; 99 | let {owner, repo} = parseRepoUrl(repoUrl); 100 | let params = {per_page: DEFAULT_PAGE_SIZE, base: base, state: state}; 101 | let url = apiUrl(`/repos/${owner}/${repo}/pulls`, params); 102 | if (continuation) { 103 | url = continuation.nextUrl as string; 104 | } 105 | 106 | let response = await context.fetcher.fetch({method: "GET", url: url}); 107 | 108 | let results = response.body?.map(parsePullRequest); 109 | let nextUrl = nextUrlFromLinkHeader(response); 110 | return { 111 | result: results, 112 | continuation: nextUrl ? {nextUrl: nextUrl} : undefined, 113 | }; 114 | } 115 | 116 | // This does some basic transformation and unnesting of a pull request 117 | // response object to make the response match the schema declared in 118 | // schemas.ts. Most of the property name remapping happens automatically 119 | // with the packs infrastructure because the schema declares 120 | // `fromKey` properties in order to rename keys, so this function exists 121 | // mostly to do massaging beyond what can be done with `fromKey`. 122 | function parsePullRequest(pr: types.GitHubPullRequest) { 123 | return { 124 | ...pr, 125 | repo: pr.head && pr.head.repo, 126 | labels: (pr.labels || []).map(label => label.name), 127 | sourceBranch: pr.head && pr.head.ref, 128 | targetBranch: pr.base && pr.base.ref, 129 | }; 130 | } 131 | 132 | // See if GitHub has given us the url of a next page of results. 133 | function nextUrlFromLinkHeader( 134 | result: coda.FetchResponse, 135 | ): string | undefined { 136 | let parsedHeader = 137 | typeof result.headers.link === "string" 138 | ? parseLinkHeader(result.headers.link) 139 | : undefined; 140 | if (parsedHeader && parsedHeader.next) { 141 | return parsedHeader.next; 142 | } 143 | } 144 | 145 | // GitHub uses the somewhat-standard `Link` http response to header to indicate 146 | // if there are subsequent or previous result pages available. 147 | // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link 148 | // for an explanation of the format. 149 | // GitHub's `Link` header value looks like: 150 | // `; rel="next", ; rel="prev"` 151 | // This function parses the link header and returns object matching the 152 | // `rel` label to the corresponding url, in this case: 153 | // { 154 | // next: ' { 161 | let result: Record = {}; 162 | let parts = header.split(/,\s*/); 163 | parts.map(part => { 164 | let match = part.match(/<([^>]*)>; rel="(.+)"/); 165 | if (match) { 166 | result[match[2]] = match[1]; 167 | } 168 | }); 169 | return result; 170 | } 171 | -------------------------------------------------------------------------------- /examples/google-tables/pack.ts: -------------------------------------------------------------------------------- 1 | import type {CodaRow} from "./types"; 2 | import type {RowsContinuation} from "./types"; 3 | import * as coda from "@codahq/packs-sdk"; 4 | import {formatRowForApi} from "./helpers"; 5 | import {formatRowForSchema} from "./helpers"; 6 | import {getPropertySchema} from "./helpers"; 7 | import {getRows} from "./helpers"; 8 | import {getTable} from "./helpers"; 9 | import {getTableUrl} from "./helpers"; 10 | import {getTables} from "./helpers"; 11 | import {updateRow} from "./helpers"; 12 | 13 | export const pack = coda.newPack(); 14 | 15 | // A base schema, extended with additional properties depending on the table. 16 | export const BaseRowSchema = coda.makeObjectSchema({ 17 | properties: { 18 | rowId: { 19 | description: "Internal ID of the row.", 20 | type: coda.ValueType.String, 21 | fromKey: "name", 22 | required: true, 23 | }, 24 | rowLabel: { 25 | type: coda.ValueType.String, 26 | required: true, 27 | }, 28 | lastUpdated: { 29 | type: coda.ValueType.String, 30 | codaType: coda.ValueHintType.DateTime, 31 | fromKey: "updateTime", 32 | }, 33 | }, 34 | displayProperty: "rowLabel", 35 | idProperty: "rowId", 36 | featuredProperties: [], 37 | }); 38 | 39 | pack.addDynamicSyncTable({ 40 | name: "Table", 41 | description: "Syncs records from a Google Tables table.", 42 | identityName: "Table", 43 | placeholderSchema: BaseRowSchema, 44 | listDynamicUrls: async function (context) { 45 | let tables = await getTables(context); 46 | return tables.map(table => { 47 | return { 48 | display: table.displayName, 49 | value: getTableUrl(table.name), 50 | }; 51 | }); 52 | }, 53 | searchDynamicUrls: async function (context, search) { 54 | let tables = await getTables(context); 55 | let results = tables.map(table => { 56 | return { 57 | display: table.displayName, 58 | value: getTableUrl(table.name), 59 | }; 60 | }); 61 | return coda.autocompleteSearchObjects(search, results, "display", "value"); 62 | }, 63 | getName: async function (context) { 64 | let tableUrl = context.sync!.dynamicUrl!; 65 | let table = await getTable(context, tableUrl); 66 | return table.displayName; 67 | }, 68 | getSchema: async function (context) { 69 | let tableUrl = context.sync!.dynamicUrl!; 70 | let table = await getTable(context, tableUrl); 71 | let schema: coda.GenericObjectSchema = { 72 | ...BaseRowSchema, 73 | properties: { 74 | ...BaseRowSchema.properties, 75 | }, 76 | }; 77 | for (let column of table.columns) { 78 | let property = getPropertySchema(column, table, context); 79 | if (!property) { 80 | continue; 81 | } 82 | schema.properties[column.name] = property; 83 | schema.featuredProperties!.push(column.name); 84 | } 85 | return schema; 86 | }, 87 | getDisplayUrl: async function (context) { 88 | let tableUrl = context.sync!.dynamicUrl!; 89 | let tableId = tableUrl.split("/").pop() as string; 90 | return coda.joinUrl("https://tables.area120.google.com/table", tableId); 91 | }, 92 | // Get the options for select list columns. 93 | propertyOptions: async function (context) { 94 | let tableUrl = context.sync!.dynamicUrl!; 95 | let columnId = context.propertyName; 96 | let table = await getTable(context, tableUrl); 97 | let column = table.columns.find(column => column.id === columnId); 98 | if (!column) { 99 | throw new Error(`Cannot find column: ${columnId}`); 100 | } 101 | if (column.labels) { 102 | return column.labels.map(label => label.name); 103 | } 104 | }, 105 | formula: { 106 | name: "SyncTable", 107 | description: "Syncs the data.", 108 | parameters: [], 109 | execute: async function (args, context) { 110 | let {pageToken, rowNumber = 1} = (context.sync.continuation ?? 111 | {}) as RowsContinuation; 112 | let tableUrl = context.sync.dynamicUrl!; 113 | let table = await getTable(context, tableUrl); 114 | let {rows, nextPageToken} = await getRows(context, tableUrl, pageToken); 115 | let formattedRows = rows.map(row => { 116 | return formatRowForSchema(row, table, context, String(rowNumber++)); 117 | }); 118 | let continuation: RowsContinuation | undefined; 119 | if (nextPageToken) { 120 | continuation = { 121 | pageToken: nextPageToken, 122 | rowNumber: rowNumber, 123 | }; 124 | } 125 | return { 126 | result: formattedRows, 127 | continuation: continuation, 128 | }; 129 | }, 130 | maxUpdateBatchSize: 10, 131 | executeUpdate: async function (args, updates, context) { 132 | let tableUrl = context.sync.dynamicUrl!; 133 | let table = await getTable(context, tableUrl); 134 | 135 | // Create an async job for each update. 136 | let jobs = updates.map(async update => { 137 | // Convert the row back to an API format. 138 | let row = formatRowForApi(update.newValue as CodaRow, table, context); 139 | 140 | // Prune unchanged values. 141 | for (let columnId of Object.keys(row.values)) { 142 | if (!update.updatedFields.includes(columnId)) { 143 | delete row.values[columnId]; 144 | } 145 | } 146 | // Update the row. 147 | let finalRow = await updateRow(context, row); 148 | 149 | // Convert the final row back to the schema format. 150 | let label = update.previousValue.rowLabel as string; 151 | return formatRowForSchema(finalRow, table, context, label); 152 | }); 153 | 154 | // Wait for all of the jobs to finish . 155 | let completed = await Promise.allSettled(jobs); 156 | 157 | // For each update, return either the updated row or an error if the 158 | // update failed. 159 | let results = completed.map(job => { 160 | if (job.status === "fulfilled") { 161 | return job.value; 162 | } else { 163 | return job.reason; 164 | } 165 | }); 166 | 167 | // Return the results. 168 | return { 169 | result: results, 170 | }; 171 | }, 172 | }, 173 | }); 174 | 175 | pack.setUserAuthentication({ 176 | type: coda.AuthenticationType.OAuth2, 177 | authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", 178 | tokenUrl: "https://oauth2.googleapis.com/token", 179 | scopes: ["profile", "https://www.googleapis.com/auth/tables"], 180 | additionalParams: { 181 | access_type: "offline", 182 | prompt: "consent", 183 | }, 184 | getConnectionName: async function (context) { 185 | let response = await context.fetcher.fetch({ 186 | method: "GET", 187 | url: "https://www.googleapis.com/oauth2/v1/userinfo", 188 | }); 189 | let user = response.body; 190 | return user.name; 191 | }, 192 | }); 193 | 194 | pack.addNetworkDomain("googleapis.com"); 195 | -------------------------------------------------------------------------------- /examples/github/schemas.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | 3 | // A user associated with an entity or action. This is a child property 4 | // in many other GitHub objects. 5 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/users#get-a-user 6 | export const UserSchema = coda.makeObjectSchema({ 7 | // We list only the subset of fields we care about, the actual user objects 8 | // are much larger. 9 | properties: { 10 | id: {type: coda.ValueType.Number, required: true}, 11 | login: {type: coda.ValueType.String, required: true}, 12 | // Using fromKey is a shortcut to avoid writing code to transform 13 | // GitHub's response to an object matching our schema. We simply specify 14 | // the name of the field in the GitHub response using fromKey, and Coda 15 | // will map it to the name of property declared here. 16 | avatar: { 17 | type: coda.ValueType.String, 18 | fromKey: "avatar_url", 19 | // We return the image url of the GitHub user's avatar but declare it as 20 | // codaType: ImageAttachment, which instructs Coda to download the image 21 | // and host it from Coda for use in Coda docs. 22 | codaType: coda.ValueHintType.ImageAttachment, 23 | }, 24 | url: { 25 | type: coda.ValueType.String, 26 | fromKey: "html_url", 27 | codaType: coda.ValueHintType.Url, 28 | required: true, 29 | }, 30 | }, 31 | // This is the property that will render as a label on the object in the UI. 32 | displayProperty: "login", 33 | // This is the property that will be a unique key for the row. 34 | idProperty: "id", 35 | }); 36 | 37 | // The tiny subset of fields for a Team that we care about. We don't fetch 38 | // teams directly but they are embedded in other responses, representing 39 | // e.g. the teams requested to review a PR. 40 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/teams#get-a-team-by-name 41 | export const TeamSchema = coda.makeObjectSchema({ 42 | // We list only the subset of fields we care about, the actual team objects 43 | // are much larger. 44 | properties: { 45 | id: {type: coda.ValueType.Number, required: true}, 46 | name: {type: coda.ValueType.String, required: true}, 47 | url: { 48 | type: coda.ValueType.String, 49 | fromKey: "html_url", 50 | codaType: coda.ValueHintType.Url, 51 | required: true, 52 | }, 53 | }, 54 | // This is the property that will render as a label on the object in the UI. 55 | displayProperty: "name", 56 | // This is the property that will be a unique key for the row. 57 | idProperty: "id", 58 | }); 59 | 60 | // The response when creating a pull review request. 61 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#create-a-review-for-a-pull-request 62 | export const PullRequestReviewResponseSchema = coda.makeObjectSchema({ 63 | // We list only the subset of fields we care about. 64 | properties: { 65 | id: {type: coda.ValueType.Number, required: true}, 66 | user: UserSchema, 67 | body: {type: coda.ValueType.String, required: true}, 68 | commitId: { 69 | type: coda.ValueType.String, 70 | fromKey: "commit_id", 71 | required: true, 72 | }, 73 | state: {type: coda.ValueType.String, required: true}, 74 | url: { 75 | type: coda.ValueType.String, 76 | codaType: coda.ValueHintType.Url, 77 | fromKey: "html_url", 78 | required: true, 79 | }, 80 | }, 81 | // This is the property that will render as a label on the object in the UI. 82 | displayProperty: "body", 83 | // This is the property that will be a unique key for the row. 84 | idProperty: "id", 85 | }); 86 | 87 | // The handful of fields we care about for a Repo object. These are embedded 88 | // in PR objects. 89 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/repos#get-a-repository 90 | export const RepoSchema = coda.makeObjectSchema({ 91 | properties: { 92 | id: {type: coda.ValueType.Number, required: true}, 93 | name: {type: coda.ValueType.String, required: true}, 94 | fullName: { 95 | type: coda.ValueType.String, 96 | fromKey: "full_name", 97 | required: true, 98 | }, 99 | description: {type: coda.ValueType.String}, 100 | url: { 101 | type: coda.ValueType.String, 102 | fromKey: "html_url", 103 | codaType: coda.ValueHintType.Url, 104 | required: true, 105 | }, 106 | }, 107 | displayProperty: "name", 108 | idProperty: "id", 109 | }); 110 | 111 | // A pull request object, as we would like to present it to Coda users. Many 112 | // of the fields are renamed from GitHub's raw response objects 113 | // (using `fromKey`) while other field are remapped or rearranged to eliminate 114 | // nesting. This is also only a subset of the fields returned by GitHub. 115 | // 116 | // Since this is the top-level schema in our PullRequests sync table definition, 117 | // the `id`, `primary`, `identity`, and `featured` fields which are sometimes 118 | // optional, are all required and important for creating a user-friendly table. 119 | // 120 | // https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#get-a-pull-request 121 | export const PullRequestSchema = coda.makeObjectSchema({ 122 | properties: { 123 | title: {type: coda.ValueType.String, required: true}, 124 | author: { 125 | ...UserSchema, 126 | fromKey: "user", 127 | }, 128 | pullRequestNumber: { 129 | type: coda.ValueType.Number, 130 | fromKey: "number", 131 | required: true, 132 | }, 133 | url: { 134 | type: coda.ValueType.String, 135 | fromKey: "html_url", 136 | codaType: coda.ValueHintType.Url, 137 | required: true, 138 | }, 139 | created: { 140 | type: coda.ValueType.String, 141 | fromKey: "created_at", 142 | codaType: coda.ValueHintType.Date, 143 | required: true, 144 | }, 145 | modified: { 146 | type: coda.ValueType.String, 147 | fromKey: "updated_at", 148 | codaType: coda.ValueHintType.Date, 149 | required: true, 150 | }, 151 | closed: { 152 | type: coda.ValueType.String, 153 | fromKey: "closed_at", 154 | codaType: coda.ValueHintType.Date, 155 | }, 156 | merged: { 157 | type: coda.ValueType.String, 158 | fromKey: "merged_at", 159 | codaType: coda.ValueHintType.Date, 160 | }, 161 | mergeCommitSha: {type: coda.ValueType.String, fromKey: "merge_commit_sha"}, 162 | body: {type: coda.ValueType.String, codaType: coda.ValueHintType.Markdown}, 163 | labels: {type: coda.ValueType.Array, items: {type: coda.ValueType.String}}, 164 | state: {type: coda.ValueType.String, required: true}, 165 | sourceBranch: {type: coda.ValueType.String, required: true}, 166 | targetBranch: {type: coda.ValueType.String, required: true}, 167 | addedLineCount: {type: coda.ValueType.Number, fromKey: "additions"}, 168 | deletedLineCount: {type: coda.ValueType.Number, fromKey: "deletions"}, 169 | changedFileCount: {type: coda.ValueType.Number, fromKey: "changed_files"}, 170 | mergedBy: {...UserSchema, fromKey: "merged_by"}, 171 | repo: {...RepoSchema, required: true}, 172 | assignees: { 173 | type: coda.ValueType.Array, 174 | items: UserSchema, 175 | }, 176 | reviewerUsers: { 177 | type: coda.ValueType.Array, 178 | items: UserSchema, 179 | fromKey: "requested_reviewers", 180 | }, 181 | reviewerTeams: { 182 | type: coda.ValueType.Array, 183 | items: TeamSchema, 184 | fromKey: "requested_teams", 185 | }, 186 | }, 187 | // This is the property that will render as a label on the object in the UI. 188 | displayProperty: "title", 189 | // In a sync table, the ID property is used to uniquely identify the object 190 | // across multiple syncs. When this table is re-synced any row that matches 191 | // this id will be replaced, rather than appending the object as a new row. 192 | idProperty: "url", 193 | // These are the subset of the `properties` above that should be automatically 194 | // created as columns when this table is first created in the UI. The 195 | // remainder of the fields can be easily added as columns manually by the 196 | // user at any time. We choose only to feature a handful of highly-relevant 197 | // columns to keep tables manageable at creation time and avoid overwhelming 198 | // users with too many fields. 199 | featuredProperties: [ 200 | "url", 201 | "author", 202 | "created", 203 | "modified", 204 | "closed", 205 | "state", 206 | "body", 207 | ], 208 | }); 209 | -------------------------------------------------------------------------------- /examples/github/test/github_test.ts: -------------------------------------------------------------------------------- 1 | import type {GitHubPullRequest} from "../types"; 2 | import type {GitHubRepo} from "../types"; 3 | import {GitHubReviewEvent} from "../types"; 4 | import type {MockExecutionContext} from "@codahq/packs-sdk/dist/development"; 5 | import type {MockSyncExecutionContext} from "@codahq/packs-sdk/dist/development"; 6 | import type {PullRequestReviewResponse} from "../types"; 7 | import {RepoUrlParameter} from "../pack"; 8 | import {assert} from "chai"; 9 | import {describe} from "mocha"; 10 | import {executeFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 11 | import {executeMetadataFormula} from "@codahq/packs-sdk/dist/development"; 12 | import {executeSyncFormulaFromPackDef} from "@codahq/packs-sdk/dist/development"; 13 | import {it} from "mocha"; 14 | import {newJsonFetchResponse} from "@codahq/packs-sdk/dist/development"; 15 | import {newMockExecutionContext} from "@codahq/packs-sdk/dist/development"; 16 | import {newMockSyncExecutionContext} from "@codahq/packs-sdk/dist/development"; 17 | import {pack} from "../pack"; 18 | import * as sinon from "sinon"; 19 | import {willBeRejectedWith} from "../../../lib/test_utils"; 20 | 21 | describe("Github pack", () => { 22 | describe("ReviewPullRequest", () => { 23 | let context: MockExecutionContext; 24 | 25 | beforeEach(() => { 26 | // Before each test, we create a brand new execution context. 27 | // This will allow us to register fake fetcher responses. 28 | context = newMockExecutionContext(); 29 | }); 30 | 31 | it("executes ReviewPullRequest", async () => { 32 | // Create a fake response body, using the type we defined 33 | // as a guide for what fields should be included. 34 | let fakeReviewResponse: PullRequestReviewResponse = { 35 | id: 789, 36 | user: { 37 | id: 456, 38 | login: "user@example.com", 39 | avatar_url: "https://some-avatar.com", 40 | html_url: "https://some-user-url.com", 41 | }, 42 | body: "review body", 43 | commit_id: "asdf", 44 | state: "CHANGES_REQUESTED", 45 | html_url: "https://some-review-url.com", 46 | }; 47 | // Register the fake response with our mock fetcher. 48 | context.fetcher.fetch.resolves(newJsonFetchResponse(fakeReviewResponse)); 49 | 50 | // This is the heart of the test, where we actually execute the 51 | // formula on a given set of parameters, using our mock execution context. 52 | let response = await executeFormulaFromPackDef( 53 | pack, 54 | "ReviewPullRequest", 55 | [ 56 | "https://github.com/some-org/some-repo/pull/234", 57 | GitHubReviewEvent.RequestChanges, 58 | "review body", 59 | ], 60 | context, 61 | ); 62 | 63 | // Check the actual response matches what we'd expect from our fake 64 | // response after it goes through normalization. Since this is an action 65 | // formula, the response is not nearly as interesting as the request 66 | // itself, which we verify below. 67 | assert.deepEqual(response, { 68 | Id: 789, 69 | User: { 70 | Id: 456, 71 | Login: "user@example.com", 72 | Avatar: "https://some-avatar.com", 73 | Url: "https://some-user-url.com", 74 | }, 75 | Body: "review body", 76 | CommitId: "asdf", 77 | State: "CHANGES_REQUESTED", 78 | Url: "https://some-review-url.com", 79 | }); 80 | // Ensure that the API call we made to post a review 81 | // comment was constructed properly. 82 | sinon.assert.calledOnceWithExactly(context.fetcher.fetch, { 83 | method: "POST", 84 | url: "https://api.github.com/repos/some-org/some-repo/pulls/234/reviews", 85 | body: JSON.stringify({ 86 | body: "review body", 87 | event: GitHubReviewEvent.RequestChanges, 88 | }), 89 | }); 90 | }); 91 | 92 | it("comment request requires comment body", async () => { 93 | // Our formula implementation has custom validation that if you use a 94 | // Comment action, that you actually send a comment body, so we make sure 95 | // that validation is working as expected. 96 | let responsePromise = executeFormulaFromPackDef( 97 | pack, 98 | "ReviewPullRequest", 99 | [ 100 | "https://github.com/some-org/some-repo/pull/234", 101 | GitHubReviewEvent.Comment, 102 | ], 103 | context, 104 | ); 105 | await willBeRejectedWith( 106 | responsePromise, 107 | /Comment parameter must be provided for Comment or Request Changes actions\./, 108 | ); 109 | }); 110 | }); 111 | 112 | describe("PullRequests sync table", () => { 113 | let syncContext: MockSyncExecutionContext; 114 | 115 | beforeEach(() => { 116 | // Before each test, we create a brand new sync execution context. 117 | // This will allow us to register fake fetcher responses. 118 | // This context object is slightly different than a MockExectionContext 119 | // that we use with non-sync formuls. 120 | syncContext = newMockSyncExecutionContext(); 121 | }); 122 | 123 | // A helper function to create valid dummy data, since the GitHub 124 | // objects are quite large. This allows us to specify a handful of 125 | // specific fields of interest and populate the rest with defaults. 126 | function makeFakePullRequest( 127 | overrides: Partial, 128 | ): GitHubPullRequest { 129 | let repo: GitHubRepo = { 130 | id: 573, 131 | name: "repo-name", 132 | full_name: "repo full name", 133 | description: "repo description", 134 | html_url: "https://some-repo-url.com", 135 | }; 136 | let defaults: GitHubPullRequest = { 137 | title: "pull request title", 138 | user: { 139 | id: 435, 140 | login: "pr-author@example.com", 141 | avatar_url: "https://some-avatar.com", 142 | html_url: "https://some-user.com", 143 | }, 144 | number: 123, 145 | html_url: "https://some-pr-url.com", 146 | created_at: "2021-01-13T00:08:37.572Z", 147 | updated_at: "2021-01-14T00:08:37.572Z", 148 | body: "pr body", 149 | labels: [{name: "label 1"}], 150 | state: "some-state", 151 | additions: 23, 152 | deletions: 7, 153 | changed_files: 4, 154 | base: { 155 | ref: "base-branch", 156 | repo: repo, 157 | }, 158 | head: { 159 | ref: "change-branch", 160 | repo: repo, 161 | }, 162 | assignees: [], 163 | requested_reviewers: [], 164 | requested_teams: [ 165 | {id: 765, name: "some team", html_url: "https://some-team-url.com"}, 166 | ], 167 | }; 168 | return {...defaults, ...overrides}; 169 | } 170 | 171 | // A helper function to generate the http header that GitHub uses to 172 | // tell us that there is a next page or previous page of results. 173 | function makeLinkHeader(opts: {next?: string; prev?: string}): string { 174 | let parts = Object.entries(opts) 175 | .filter(([_rel, url]) => url) 176 | .map(([rel, url]) => `<${url}>; rel="${rel}"`); 177 | return parts.join(", "); 178 | } 179 | 180 | // A simple test to make sure all the plumbing works, that doesn't 181 | // deal with pagination. 182 | it("syncs a single page", async () => { 183 | let pr1 = makeFakePullRequest({title: "pull request 1", number: 111}); 184 | let pr2 = makeFakePullRequest({title: "pull request 2", number: 222}); 185 | // Set up our mock fetcher to return a valdi result page with 2 fake PRs. 186 | syncContext.fetcher.fetch.resolves(newJsonFetchResponse([pr1, pr2])); 187 | 188 | // This actually executes the entire sync. 189 | let syncedObjects = await executeSyncFormulaFromPackDef( 190 | pack, 191 | "PullRequests", 192 | ["https://github.com/some-org/some-repo"], 193 | syncContext, 194 | ); 195 | 196 | // Make sure the sync actually pulled in our fake objects 197 | // from the fetcher. The result object fields have gone 198 | // through normalization, which is why they're capitalized. 199 | assert.equal(syncedObjects.length, 2); 200 | assert.equal(syncedObjects[0].Title, "pull request 1"); 201 | assert.equal(syncedObjects[1].Title, "pull request 2"); 202 | 203 | // Make sure we only made one http request to get results, 204 | // and that the url was what we expected it to me. 205 | sinon.assert.calledOnceWithExactly(syncContext.fetcher.fetch, { 206 | method: "GET", 207 | url: "https://api.github.com/repos/some-org/some-repo/pulls?per_page=100", 208 | }); 209 | }); 210 | 211 | // Similar to the previous test but makes sure our pagination logic works. 212 | it("syncs multiple pages", async () => { 213 | let pr1 = makeFakePullRequest({title: "pull request 1", number: 111}); 214 | let pr2 = makeFakePullRequest({title: "pull request 2", number: 222}); 215 | // Create a response to our first http request, that returns a PR 216 | // but includes an http header that tells us there is a next page 217 | // of results. 218 | let page1Response = newJsonFetchResponse([pr1], 200, { 219 | link: makeLinkHeader({next: "https://api.github.com/next-page-url"}), 220 | }); 221 | // Create a response for our second http request, that doesn't 222 | // have a next page header, which means our sync should terminate 223 | // because there aren't any more pages to request. 224 | let page2Response = newJsonFetchResponse([pr2]); 225 | // Register these responses with our mock fetcher. The mock 226 | // fetcher will check the incoming arguments and return the appropriate 227 | // one of its multiple registered responses. 228 | syncContext.fetcher.fetch 229 | .withArgs({ 230 | method: "GET", 231 | url: "https://api.github.com/repos/some-org/some-repo/pulls?per_page=100", 232 | }) 233 | .resolves(page1Response) 234 | .withArgs({ 235 | method: "GET", 236 | url: "https://api.github.com/next-page-url", 237 | }) 238 | .resolves(page2Response); 239 | 240 | // Now actually execute the sync. This will keep fetching additional 241 | // pages of results until there is not continuation returned. 242 | let syncedObjects = await executeSyncFormulaFromPackDef( 243 | pack, 244 | "PullRequests", 245 | ["https://github.com/some-org/some-repo"], 246 | syncContext, 247 | ); 248 | 249 | // Make sure the results from each page got concatenated together 250 | // into one big list of results. 251 | assert.equal(syncedObjects.length, 2); 252 | assert.equal(syncedObjects[0].Title, "pull request 1"); 253 | assert.equal(syncedObjects[1].Title, "pull request 2"); 254 | 255 | // Make sure we did indeed make 2 http requests. 256 | sinon.assert.calledTwice(syncContext.fetcher.fetch); 257 | }); 258 | 259 | // We can even test the metadata formulas in the supporting 260 | // parts of our pack. The repo url parameter to this sync table 261 | // has an autocomplete metadata formula to make it easier for users 262 | // to select the repo they want to sync from. This test exercises 263 | // that formula, which makes http requests to GitHub to listen 264 | // repos that the user has access to. 265 | it("repo url autocomplete", async () => { 266 | // Create a mock execution context, some fake repos, and register 267 | // an http response that returns those repos. 268 | let context: MockExecutionContext = newMockExecutionContext(); 269 | let repo1: GitHubRepo = { 270 | id: 123, 271 | name: "repo 1", 272 | full_name: "repo full name", 273 | description: "repo description", 274 | html_url: "https://repo-1-url,", 275 | }; 276 | let repo2: GitHubRepo = { 277 | ...repo1, 278 | id: 456, 279 | name: "repo 2 matches some-query", 280 | html_url: "https://repo-2-url", 281 | }; 282 | context.fetcher.fetch.resolves(newJsonFetchResponse([repo1, repo2])); 283 | 284 | // Invoke the metadata formula simulating that the user has searched 285 | // in the UI for 'some-query'. 286 | let results = await executeMetadataFormula( 287 | RepoUrlParameter.autocomplete!, 288 | {search: "some-query"}, 289 | context, 290 | ); 291 | 292 | // The metadata formula should fetch our fake repos but filter 293 | // them to only those that match the search query. 294 | assert.equal(results.length, 1); 295 | assert.deepEqual(results[0], { 296 | value: "https://repo-2-url", 297 | display: "repo 2 matches some-query", 298 | }); 299 | 300 | sinon.assert.calledOnceWithExactly(context.fetcher.fetch, { 301 | method: "GET", 302 | url: "https://api.github.com/user/repos?per_page=100", 303 | }); 304 | }); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /examples/github/pack.ts: -------------------------------------------------------------------------------- 1 | import * as coda from "@codahq/packs-sdk"; 2 | import * as helpers from "./helpers"; 3 | import * as schemas from "./schemas"; 4 | import * as types from "./types"; 5 | 6 | export const pack = coda.newPack(); 7 | 8 | // This tells Coda which domain the pack make requests to. Any fetcher 9 | // requests to other domains won't be allowed. 10 | pack.addNetworkDomain("github.com"); 11 | 12 | // The GitHub pack uses OAuth authentication, to allow each user to login 13 | // to GitHub via the browser when installing the pack. The pack will 14 | // operate on their personal data. 15 | pack.setUserAuthentication({ 16 | type: coda.AuthenticationType.OAuth2, 17 | // As outlined in https://docs.github.com/en/free-pro-team@latest/developers/apps/authorizing-oauth-apps, 18 | // these are the urls for initiating OAuth authentication and doing 19 | // token exchange. 20 | authorizationUrl: "https://github.com/login/oauth/authorize", 21 | tokenUrl: "https://github.com/login/oauth/access_token", 22 | // When making authorized http requests, most services ask you to pass 23 | // a header of this form: 24 | // `Authorization: Bearer ` 25 | // but GitHub asks you use: 26 | // `Authorization: token ` 27 | // so we specify a non-default tokenPrefix here. 28 | tokenPrefix: "token", 29 | // These are the GitHub-specific scopes the user will be prompted to 30 | // authorize in order for the functionality in this pack to succeed. 31 | scopes: ["read:user", "repo"], 32 | // This is a simple formula that makes an API call to GitHub to find 33 | // the name of the user associated with the OAuth access token. This 34 | // name is used to label the Coda account connection associated with 35 | // these credentials throughout the Coda UI. For example, a user may 36 | // connect both a personal GitHub account and a work GitHub account to 37 | // Coda, and this formula will help those accounts be clearly labeled 38 | // in Coda without direct input from the user. 39 | getConnectionName: helpers.getConnectionName, 40 | }); 41 | 42 | // A parameter that identifies a PR to review using its url. 43 | const PullRequestUrlParameter = coda.makeParameter({ 44 | type: coda.ParameterType.String, 45 | name: "pullRequestUrl", 46 | description: 47 | 'The URL of the pull request. For example, "https://github.com/[org]/[repo]/pull/[id]".', 48 | }); 49 | 50 | // A parameter that indicates what action to take on the review. 51 | const PullRequestReviewActionTypeParameter = coda.makeParameter({ 52 | type: coda.ParameterType.String, 53 | name: "actionType", 54 | description: 55 | "Type of review action. One of Approve, Comment or Request Changes", 56 | // Since there are only an enumerated set of valid values that GitHub 57 | // allows, we add an autocomplete function to populate a searchable 58 | // dropdown in the Coda UI. We can provided a hardcoded set of 59 | // options. The `display` value will be shown to users in the UI, 60 | // while the `value` will be what is passed to the formula. 61 | autocomplete: [ 62 | {display: "Approve", value: types.GitHubReviewEvent.Approve}, 63 | {display: "Comment", value: types.GitHubReviewEvent.Comment}, 64 | {display: "Request Changes", value: types.GitHubReviewEvent.RequestChanges}, 65 | ], 66 | }); 67 | 68 | // Free-form text to be included as the review comment, if this is a Comment 69 | // or Request Changes action. 70 | const PullRequestReviewCommentParameter = coda.makeParameter({ 71 | type: coda.ParameterType.String, 72 | name: "comment", 73 | description: 74 | "Comment for review. Required if review action type is Comment or Request Changes.", 75 | optional: true, 76 | }); 77 | 78 | // We use makeObjectFormula because this formula will return a structured 79 | // object with multiple pieces of data about the submitted rview. 80 | pack.addFormula({ 81 | resultType: coda.ValueType.Object, 82 | name: "ReviewPullRequest", 83 | description: "Review a pull request.", 84 | schema: schemas.PullRequestReviewResponseSchema, 85 | // This formula is an action: it changes the status of PR in GitHub. 86 | // Declaring this means this formula will be made available as a button action 87 | // in the Coda UI. 88 | isAction: true, 89 | parameters: [ 90 | PullRequestUrlParameter, 91 | PullRequestReviewActionTypeParameter, 92 | PullRequestReviewCommentParameter, 93 | ], 94 | execute: async function ([pullRequestUrl, actionType, comment], context) { 95 | if (actionType !== types.GitHubReviewEvent.Approve && !comment) { 96 | // You can throw a UserVisibleError at any point in a formula to provide 97 | // an error message to be displayed to the user in the UI. 98 | throw new coda.UserVisibleError( 99 | "Comment parameter must be provided for Comment or Request Changes actions.", 100 | ); 101 | } 102 | 103 | let payload = {body: comment, event: actionType}; 104 | let {owner, repo, pullNumber} = helpers.parsePullUrl(pullRequestUrl); 105 | let request: coda.FetchRequest = { 106 | method: "POST", 107 | url: helpers.apiUrl( 108 | `/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, 109 | ), 110 | body: JSON.stringify(payload), 111 | }; 112 | 113 | try { 114 | let result = await context.fetcher.fetch(request); 115 | // The response is useful to return almost as-is. Our schema definition 116 | // above will re-map some fields to clearer names and remove extraneous 117 | // properties without us having to do that manually in code here though. 118 | return result.body as types.PullRequestReviewResponse; 119 | } catch (e: any) { 120 | if (e.statusCode === 422) { 121 | // Some http errors are common usage mistakes that we wish to surface to 122 | // the user in a clear way, so we detect those and re-map them to 123 | // user-visible errors, rather than letting these fall through as 124 | // uncaught errors. 125 | if (e.message.includes("Can not approve your own pull request")) { 126 | throw new coda.UserVisibleError( 127 | "Can not approve your own pull request", 128 | ); 129 | } else if ( 130 | e.message.includes("Can not request changes on your own pull request") 131 | ) { 132 | throw new coda.UserVisibleError( 133 | "Can not request changes on your own pull request", 134 | ); 135 | } 136 | } 137 | throw e; 138 | } 139 | }, 140 | examples: [ 141 | { 142 | params: [ 143 | "https://github.com/coda/packs-examples/pull/123", 144 | "COMMENT", 145 | "Some comment", 146 | ], 147 | result: { 148 | Id: 12345, 149 | User: { 150 | Login: "someuser", 151 | Id: 98765, 152 | Avatar: "https://avatars2.githubusercontent.com/u/12345", 153 | Url: "https://github.com/someuser", 154 | }, 155 | Body: "Some comment", 156 | State: "COMMENTED", 157 | Url: "https://github.com/coda/packs-examples/pull/123", 158 | CommitId: "ff3d90e1d62c37b93994078fad0dad37d3e", 159 | }, 160 | }, 161 | ], 162 | }); 163 | 164 | // This formula demonstrates the use of incremental/progressive OAuth scopes. 165 | // Calling this formula requires more permissions from GitHub than the pack 166 | // requests at its initial installation, and Coda will guide a user through 167 | // an additional authorization when they try to use a formula with greater 168 | // permissions like this and get an error. 169 | pack.addFormula({ 170 | name: "UserEmail", 171 | description: 172 | "Returns the primary email address used on this user's GitHub account.", 173 | resultType: coda.ValueType.String, 174 | // This formula will need this additional OAuth permission to get the email 175 | // address of the user. 176 | extraOAuthScopes: ["user:email"], 177 | parameters: [], 178 | execute: async function ([_index], context) { 179 | let request: coda.FetchRequest = { 180 | method: "GET", 181 | url: helpers.apiUrl("/user/emails"), 182 | }; 183 | // If this fetch is attempted without the extra OAuth scope, GitHub will 184 | // give a 404 error, which will bubble up to Coda because there is no 185 | // try/catch on this fetch. When Coda sees an error from a formula like 186 | // that, it looks at the scopes the user is currently authenticated with 187 | // compared to what scopes are requested by the pack's manifest and the 188 | // formula. Here, Coda will see the extraOAuthScopes field on this 189 | // formula and replace the 404 error with an instruction to the user 190 | // to sign in again. 191 | let result = await context.fetcher.fetch(request); 192 | // Return only the one email marked as primary. 193 | return result.body.find((emailObject: any) => emailObject.primary).email; 194 | }, 195 | }); 196 | 197 | // A parameter that identifies a repo to sync data from using the repo's url. 198 | // For each sync configuration, the user must select a single repo from which 199 | // to sync, since GitHub's API does not return entities across repos 200 | // However, a user can set up multiple sync configurations 201 | // and each one can individually sync from a separate repo. 202 | // (This is exported so that we can unittest the autocomplete formula.) 203 | export const RepoUrlParameter = coda.makeParameter({ 204 | type: coda.ParameterType.String, 205 | name: "repoUrl", 206 | description: 207 | 'The URL of the repository to list pull requests from. For example, "https://github.com/[org]/[repo]".', 208 | // This autocomplete formula will list all of the repos that the current 209 | // user has access to and expose them as a searchable dropdown in the UI. 210 | // It fetches the GitHub repo objects and then runs a simple text search 211 | // over the repo name. 212 | autocomplete: async (context, search) => { 213 | let results: types.GitHubRepo[] = []; 214 | let continuation: coda.Continuation | undefined; 215 | do { 216 | let response = await helpers.getRepos(context, continuation); 217 | results = results.concat(...response.result); 218 | ({continuation} = response); 219 | } while (continuation && continuation.nextUrl); 220 | // This helper function can implement most autocomplete use cases. It 221 | // takes the user's current search (if any) and an array of arbitrary 222 | // objects. The final arguments are the property name of a label field to 223 | // search over, and finally the property name that should be used as the 224 | // value when a user selects a result. 225 | // So here, this is saying "search the `name` field of reach result, and 226 | // use the html_url as the value once selected". 227 | return coda.autocompleteSearchObjects(search, results, "name", "html_url"); 228 | }, 229 | }); 230 | 231 | const BaseParameterOptional = coda.makeParameter({ 232 | type: coda.ParameterType.String, 233 | name: "base", 234 | description: 'The name of the base branch. For example, "main".', 235 | optional: true, 236 | }); 237 | 238 | const PullRequestStateOptional = coda.makeParameter({ 239 | type: coda.ParameterType.String, 240 | name: "state", 241 | description: 242 | 'Returns pull requests in the given state. If unspecified, defaults to "open".', 243 | optional: true, 244 | autocomplete: [ 245 | { 246 | display: "Open pull requests only", 247 | value: types.PullRequestStateFilter.Open, 248 | }, 249 | { 250 | display: "Closed pull requests only", 251 | value: types.PullRequestStateFilter.Closed, 252 | }, 253 | {display: "All pull requests", value: types.PullRequestStateFilter.All}, 254 | ], 255 | }); 256 | 257 | pack.addSyncTable({ 258 | // This is the name of the sync table, which will show in the UI. 259 | name: "PullRequests", 260 | // This the unique id of the table, used internally. By convention, it's 261 | // often the singular form the display name defined right above. 262 | // Other sync tables and formulas can return references to rows in this 263 | // table, by defining an `Identity` object in their response schemas that 264 | // points to this value, e.g. `identity: {name: 'PullRequest'}`. 265 | identityName: "PullRequest", 266 | // This is the schema of a single entity (row) being synced. The formula 267 | // that implements this sync must return an array of objects matching this 268 | // schema. Each such object will be a row in the resulting table. 269 | schema: schemas.PullRequestSchema, 270 | formula: { 271 | // This is the name of the formula that implements the sync. By convention 272 | // it should be the same as the name of the sync table. This will be 273 | // removed in a future version of the SDK. 274 | name: "PullRequests", 275 | // A description to show in the UI. 276 | description: "Sync pull requests from GitHub.", 277 | parameters: [ 278 | RepoUrlParameter, 279 | BaseParameterOptional, 280 | PullRequestStateOptional, 281 | ], 282 | // The implementation of the sync, which must return an array of objects 283 | // that fit the pullRequestSchema above, representing a single page of 284 | // results, and optionally a `continuation` if there are subsequent pages 285 | // of results to fetch. 286 | execute: async function (params, context) { 287 | return helpers.getPullRequests(params, context); 288 | }, 289 | }, 290 | }); 291 | -------------------------------------------------------------------------------- /examples/google-tables/convert.ts: -------------------------------------------------------------------------------- 1 | import {BaseRowSchema} from "./pack"; 2 | import type {Column} from "./types"; 3 | import {DateTime} from "luxon"; 4 | import type {DriveFile} from "./types"; 5 | import type {PersonReference} from "./types"; 6 | import type {RowReference} from "./types"; 7 | import type {Table} from "./types"; 8 | import type {TablesDate} from "./types"; 9 | import type {TablesDateTime} from "./types"; 10 | import type {TablesFile} from "./types"; 11 | import type {TablesLocation} from "./types"; 12 | import type {TablesTimestamp} from "./types"; 13 | import * as coda from "@codahq/packs-sdk"; 14 | import {getTableUrl} from "./helpers"; 15 | 16 | const DriveOpenUrl = "https://drive.google.com/open"; 17 | const NanosPerMilli = 1000000; 18 | const MillisPerSecond = 1000; 19 | 20 | // Gets the column converter for a given column. 21 | export function getConverter( 22 | context: coda.ExecutionContext, 23 | column: Column, 24 | table: Table, 25 | ): ColumnConverter { 26 | switch (column.dataType) { 27 | case "text": 28 | case "row_id": 29 | return new TextColumnConverter(context, column, table); 30 | case "number": 31 | case "auto_id": 32 | return new NumberColumnConverter(context, column, table); 33 | case "boolean": 34 | return new BooleanColumnConverter(context, column, table); 35 | case "date": 36 | if (!column.dateDetails?.hasTime) { 37 | return new DateColumnConverter(context, column, table); 38 | } 39 | return new DateTimeColumnConverter(context, column, table); 40 | case "person": 41 | case "creator": 42 | case "updater": 43 | return new PersonColumnConverter(context, column, table); 44 | case "person_list": 45 | let personConverter = new PersonColumnConverter(context, column, table); 46 | if (column.multipleValuesDisallowed) { 47 | // We want to display this as a single person selector in Coda, but the 48 | // value come back from the API as a list of one. 49 | return new UnwrapColumnConverter(personConverter); 50 | } 51 | return new ListColumnConverter(personConverter); 52 | case "dropdown": 53 | return new SelectListColumnConverter(context, column, table); 54 | case "check_list": 55 | case "tags_list": 56 | return new MultiSelectListColumnConverter(context, column, table); 57 | case "drive_attachment_list": 58 | return new DriveFilesColumnConverter(context, column, table); 59 | case "file_attachment_list": 60 | return new FilesColumnConverter(context, column, table); 61 | case "location": 62 | return new LocationColumnConverter(context, column, table); 63 | case "relationship": 64 | return new RelationshipColumnConverter(context, column, table); 65 | case "create_timestamp": 66 | case "update_timestamp": 67 | case "comment_timestamp": 68 | return new TimestampColumnConverter(context, column, table); 69 | default: 70 | if (column.dataType.endsWith("_list")) { 71 | // Handle lists of basic types. 72 | let baseType = column.dataType.slice(0, -5); 73 | let baseColumn: Column = {...column, dataType: baseType}; 74 | let baseConverter = getConverter(context, baseColumn, table); 75 | if (baseConverter) { 76 | return new ListColumnConverter(baseConverter); 77 | } 78 | } 79 | // eslint-disable-next-line no-console 80 | console.error(`No converter found for column type: ${column.dataType}`); 81 | return new UnknownColumnConverter(context, column, table); 82 | } 83 | } 84 | 85 | // Abstract class that all converter classes extend. 86 | abstract class ColumnConverter { 87 | column: Column; 88 | table: Table; 89 | context: coda.ExecutionContext; 90 | 91 | constructor(context: coda.ExecutionContext, column: Column, table: Table) { 92 | this.context = context; 93 | this.column = column; 94 | this.table = table; 95 | } 96 | 97 | getSchema(): coda.Schema & coda.ObjectSchemaProperty { 98 | let schema = this._getBaseSchema(); 99 | schema.fromKey = this.column.id; 100 | schema.fixedId = this.column.id; 101 | schema.displayName = this.column.name; 102 | 103 | // Determine mutability. 104 | if (this.column.lookupDetails) { 105 | // This is a column that depends on a relationship, so it can't be edited 106 | // directly. 107 | let relationship = this.column.lookupDetails.relationshipColumn; 108 | schema.description = 109 | `This is a lookup column, using the relationship "${relationship}". ` + 110 | "To change the value, edit the corresponding relationship column."; 111 | schema.mutable = false; 112 | } 113 | // If mutability hasn't been specified by either the converter or the lookup 114 | // logic above, fallback to the readonly field of the column. 115 | if (schema.mutable === undefined) { 116 | schema.mutable = !this.column.readonly; 117 | } 118 | 119 | return schema; 120 | } 121 | 122 | // Each implementation must define the base property schema. 123 | abstract _getBaseSchema(): coda.Schema & coda.ObjectSchemaProperty; 124 | 125 | // Default to passing through the value as-is, in both directions. 126 | formatValueForSchema(value: T): C { 127 | return value as any; 128 | } 129 | formatValueForApi(value: C): T { 130 | return value as any; 131 | } 132 | } 133 | 134 | class TextColumnConverter extends ColumnConverter { 135 | _getBaseSchema() { 136 | return coda.makeSchema({ 137 | type: coda.ValueType.String, 138 | }); 139 | } 140 | } 141 | 142 | class NumberColumnConverter extends ColumnConverter { 143 | _getBaseSchema() { 144 | return coda.makeSchema({ 145 | type: coda.ValueType.Number, 146 | }); 147 | } 148 | } 149 | 150 | class BooleanColumnConverter extends ColumnConverter { 151 | _getBaseSchema() { 152 | return coda.makeSchema({ 153 | type: coda.ValueType.Boolean, 154 | }); 155 | } 156 | } 157 | 158 | class DateColumnConverter extends ColumnConverter { 159 | _getBaseSchema() { 160 | return coda.makeSchema({ 161 | type: coda.ValueType.String, 162 | codaType: coda.ValueHintType.Date, 163 | }); 164 | } 165 | 166 | formatValueForSchema(value: TablesDate) { 167 | // Return it without a timezone, so it always represents the same day. 168 | return `${value.year}-${value.month}-${value.day}`; 169 | } 170 | 171 | formatValueForApi(value: string) { 172 | let dateTime = DateTime.fromISO(value, { 173 | zone: this.context.timezone, 174 | }); 175 | let {year, month, day} = dateTime.toObject(); 176 | return { 177 | year: Number(year), 178 | month: Number(month), 179 | day: Number(day), 180 | }; 181 | } 182 | } 183 | 184 | class DateTimeColumnConverter extends ColumnConverter { 185 | _getBaseSchema() { 186 | return coda.makeSchema({ 187 | type: coda.ValueType.String, 188 | codaType: coda.ValueHintType.DateTime, 189 | }); 190 | } 191 | 192 | formatValueForSchema(value: TablesDateTime) { 193 | let dateTime = DateTime.fromObject( 194 | { 195 | year: value.year, 196 | month: value.month, 197 | day: value.day, 198 | hour: value.hours, 199 | minute: value.minutes, 200 | second: value.seconds, 201 | millisecond: value.nanos / NanosPerMilli, 202 | }, 203 | { 204 | zone: this.table.timeZone, 205 | }, 206 | ); 207 | return dateTime.toISO()!; 208 | } 209 | 210 | formatValueForApi(value: string) { 211 | let dateTime = DateTime.fromISO(value, { 212 | zone: this.table.timeZone, 213 | }); 214 | let {year, month, day, hour, minute, second, millisecond} = 215 | dateTime.toObject(); 216 | return { 217 | year: year!, 218 | month: month!, 219 | day: day!, 220 | hours: hour!, 221 | minutes: minute!, 222 | seconds: second!, 223 | nanos: millisecond! * NanosPerMilli, 224 | }; 225 | } 226 | } 227 | 228 | class PersonColumnConverter extends ColumnConverter { 229 | _getBaseSchema() { 230 | return coda.makeObjectSchema({ 231 | codaType: coda.ValueHintType.Person, 232 | properties: { 233 | name: {type: coda.ValueType.String}, 234 | email: {type: coda.ValueType.String, required: true}, 235 | }, 236 | displayProperty: "name", 237 | idProperty: "email", 238 | }); 239 | } 240 | 241 | formatValueForSchema(value: string) { 242 | return { 243 | name: "Unknown", 244 | email: value, 245 | }; 246 | } 247 | 248 | formatValueForApi(value: PersonReference) { 249 | return value.email; 250 | } 251 | } 252 | 253 | class SelectListColumnConverter extends ColumnConverter { 254 | _getBaseSchema() { 255 | return coda.makeSchema({ 256 | type: coda.ValueType.String, 257 | codaType: coda.ValueHintType.SelectList, 258 | options: coda.OptionsType.Dynamic, 259 | }); 260 | } 261 | } 262 | 263 | class MultiSelectListColumnConverter extends ColumnConverter< 264 | string[], 265 | string[] 266 | > { 267 | selectListConverter: SelectListColumnConverter; 268 | 269 | constructor(context: coda.ExecutionContext, column: Column, table: Table) { 270 | super(context, column, table); 271 | this.selectListConverter = new SelectListColumnConverter( 272 | context, 273 | column, 274 | table, 275 | ); 276 | } 277 | 278 | _getBaseSchema() { 279 | return coda.makeSchema({ 280 | type: coda.ValueType.Array, 281 | items: this.selectListConverter._getBaseSchema(), 282 | }); 283 | } 284 | } 285 | 286 | class DriveFilesColumnConverter extends ColumnConverter { 287 | _getBaseSchema() { 288 | return coda.makeSchema({ 289 | type: coda.ValueType.Array, 290 | items: { 291 | type: coda.ValueType.String, 292 | codaType: coda.ValueHintType.Url, 293 | }, 294 | // Can't reasonably edit these in Coda. 295 | mutable: false, 296 | }); 297 | } 298 | 299 | formatValueForSchema(value: DriveFile[]) { 300 | return value.map(driveFile => { 301 | return coda.withQueryParams(DriveOpenUrl, { 302 | id: driveFile.id, 303 | }); 304 | }); 305 | } 306 | } 307 | 308 | class FilesColumnConverter extends ColumnConverter { 309 | _getBaseSchema() { 310 | return coda.makeSchema({ 311 | type: coda.ValueType.Array, 312 | items: { 313 | type: coda.ValueType.String, 314 | codaType: coda.ValueHintType.Attachment, 315 | }, 316 | // Can't reasonably edit these in Coda. 317 | mutable: false, 318 | }); 319 | } 320 | 321 | formatValueForSchema(value: TablesFile[]) { 322 | return value.map(file => file.url); 323 | } 324 | } 325 | 326 | class LocationColumnConverter extends ColumnConverter { 327 | _getBaseSchema() { 328 | return coda.makeSchema({ 329 | type: coda.ValueType.String, 330 | // Can't reasonably edit these in Coda. 331 | mutable: false, 332 | }); 333 | } 334 | 335 | formatValueForSchema(value: TablesLocation) { 336 | return value.address; 337 | } 338 | } 339 | 340 | class RelationshipColumnConverter extends ColumnConverter< 341 | string, 342 | RowReference 343 | > { 344 | _getBaseSchema() { 345 | let referenceSchema = coda.makeReferenceSchemaFromObjectSchema( 346 | BaseRowSchema, 347 | "Table", 348 | ); 349 | referenceSchema.identity!.dynamicUrl = getTableUrl( 350 | this.column.relationshipDetails.linkedTable, 351 | ); 352 | return referenceSchema; 353 | } 354 | 355 | formatValueForSchema(value: string) { 356 | return { 357 | name: value, 358 | rowLabel: "Not found", 359 | }; 360 | } 361 | 362 | formatValueForApi(value: RowReference) { 363 | return value.name; 364 | } 365 | } 366 | 367 | class TimestampColumnConverter extends ColumnConverter< 368 | TablesTimestamp, 369 | string 370 | > { 371 | _getBaseSchema() { 372 | return coda.makeSchema({ 373 | type: coda.ValueType.String, 374 | codaType: coda.ValueHintType.DateTime, 375 | }); 376 | } 377 | 378 | formatValueForSchema(value: TablesTimestamp) { 379 | let milliseconds = 380 | value.seconds * MillisPerSecond + value.nanos / NanosPerMilli; 381 | let date = new Date(milliseconds); 382 | return date.toISOString(); 383 | } 384 | 385 | formatValueForApi(value: string) { 386 | let date = new Date(value); 387 | let milliseconds = date.getTime(); 388 | return { 389 | seconds: Math.floor(milliseconds / MillisPerSecond), 390 | nanos: (milliseconds % MillisPerSecond) * NanosPerMilli, 391 | }; 392 | } 393 | } 394 | 395 | class UnknownColumnConverter extends ColumnConverter { 396 | _getBaseSchema() { 397 | return coda.makeSchema({ 398 | type: coda.ValueType.String, 399 | mutable: false, 400 | }); 401 | } 402 | 403 | formatValueForSchema(value: any) { 404 | return String(value); 405 | } 406 | } 407 | 408 | class ListColumnConverter extends ColumnConverter { 409 | baseConverter: ColumnConverter; 410 | 411 | constructor(baseConverter: ColumnConverter) { 412 | super(baseConverter.context, baseConverter.column, baseConverter.table); 413 | this.baseConverter = baseConverter; 414 | } 415 | 416 | _getBaseSchema() { 417 | return coda.makeSchema({ 418 | type: coda.ValueType.Array, 419 | items: this.baseConverter._getBaseSchema(), 420 | }); 421 | } 422 | 423 | formatValueForSchema(list: any[]) { 424 | return list.map((value: any) => 425 | this.baseConverter.formatValueForSchema(value), 426 | ); 427 | } 428 | 429 | formatValueForApi(list: any[]) { 430 | return list.map((value: any) => 431 | this.baseConverter.formatValueForApi(value), 432 | ); 433 | } 434 | } 435 | 436 | class UnwrapColumnConverter extends ColumnConverter { 437 | baseConverter: ColumnConverter; 438 | 439 | constructor(baseConverter: ColumnConverter) { 440 | super(baseConverter.context, baseConverter.column, baseConverter.table); 441 | this.baseConverter = baseConverter; 442 | } 443 | 444 | _getBaseSchema() { 445 | return this.baseConverter._getBaseSchema(); 446 | } 447 | 448 | formatValueForSchema(list: any[]) { 449 | return this.baseConverter.formatValueForSchema(list[0]); 450 | } 451 | 452 | formatValueForApi(value: any) { 453 | return [this.baseConverter.formatValueForApi(value)]; 454 | } 455 | } 456 | --------------------------------------------------------------------------------