---
title: "Automate GitHub Issue Sync with Notion Workers"
description: "Give a Notion Worker your GitHub issues endpoint and sync them into a Notion database with a stable key, API pagination, and replace-mode cleanup."
canonical: "https://adamtheautomator.com/notion-workers-github-issue-sync/"
---

# Automate GitHub Issue Sync with Notion Workers

> Give a Notion Worker your GitHub issues endpoint and sync them into a Notion database with a stable key, API pagination, and replace-mode cleanup.

Source: https://adamtheautomator.com/notion-workers-github-issue-sync/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![Automate GitHub Issue Sync with Notion Workers](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b81d1ab64dfa279a5857d/85b63aa642603bdd12b4fca256ee8c837736b9fabea4ca8078d1ba31916f346f.webp)

# Automate GitHub Issue Sync with Notion Workers

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)13 October 202611 min. read

Categories: [DevOps](/category/devops/)

Tags:[Notion](/tag/notion/)[GitHub](/tag/github/)[TypeScript](/tag/typescript/)[API](/tag/api/)

Table of Contents

*   [Prerequisites](#prerequisites)
*   [What a Worker Sync Does, and What It Will Not Do](#what-a-worker-sync-does-and-what-it-will-not-do)
*   [Scaffold the Worker and Declare a Managed Database](#scaffold-the-worker-and-declare-a-managed-database)
*   [Choose Managed or Attached](#choose-managed-or-attached)
*   [Set the Primary Key](#set-the-primary-key)
*   [Map GitHub Issue Fields to Notion Properties](#map-github-issue-fields-to-notion-properties)
*   [Pick a Key That Does Not Move](#pick-a-key-that-does-not-move)
*   [Write the execute Function: Fetch, Filter, Upsert](#write-the-execute-function-fetch-filter-upsert)
*   [Why the Fetch Uses state=all](#why-the-fetch-uses-stateall)
*   [Filter Out Pull Requests](#filter-out-pull-requests)
*   [Keep the Token in a Secret](#keep-the-token-in-a-secret)
*   [Paginate Past GitHub’s 100-Issue Page Limit](#paginate-past-githubs-100-issue-page-limit)
*   [Keep Synced Columns Read-Only and Local Columns Editable](#keep-synced-columns-read-only-and-local-columns-editable)
*   [Let Replace Mode Delete Stale Rows, Then Turn On the Schedule](#let-replace-mode-delete-stale-rows-then-turn-on-the-schedule)
*   [Confirm Deletions Only Happen After a Clean Run](#confirm-deletions-only-happen-after-a-clean-run)
*   [Deploy and Set the Cadence](#deploy-and-set-the-cadence)
*   [Run It Twice: Watch Edits and Closures Land](#run-it-twice-watch-edits-and-closures-land)
*   [Troubleshoot a Sync That Runs but Writes Nothing](#troubleshoot-a-sync-that-runs-but-writes-nothing)
*   [Match the Symptom to the Cause](#match-the-symptom-to-the-cause)
*   [Where to Take the Sync Next](#where-to-take-the-sync-next)

Give a Notion Worker your repository’s issues endpoint, and stop maintaining the same backlog twice by hand. You are about to build a one-way inventory: GitHub stays the system of record for issues, a scheduled Worker reads them, and Notion holds a live copy you can filter, comment on, and link from project docs.

The build runs through five stages. You scaffold a Worker and declare a managed database, then map GitHub issue fields onto Notion properties and give every row a stable key. You write the fetch, filter, and upsert logic and teach it to page through GitHub’s 100-issue limit. You confirm what replace mode does to rows that vanish upstream before you let the schedule run, and you trigger the sync twice so you can watch an edited title and a closed issue land. Nothing here needs a server you manage; the Notion CLI deploys the whole thing.

## Prerequisites

If you want to follow along hands-on, you need:

*   A Notion workspace on the **Business** or **Enterprise** plan, because [Notion Workers](https://developers.notion.com/workers/get-started/overview) only run on those plans, and a workspace owner has to enable Worker creation. On a Personal plan you can read along, but you can’t deploy.
    
*   The [Notion CLI](https://developers.notion.com/cli/get-started/overview) (`ntn`) installed, along with Node.js and TypeScript, since a Worker is a TypeScript project you build and deploy from a terminal.
    
*   A GitHub test repository and a token that can read its issues. A [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) scoped to that one repository with **Issues: Read** and **Metadata: Read** is enough, and a classic token with broad `repo` scope is more access than this needs.
    

This walkthrough was built and tested with the `ntn` CLI and the `@notionhq/workers` SDK, pinned to the GitHub REST API version `2026-03-10`.

* * *

_**Key Insight: Notion runs Workers as a public beta with no run charge during the beta. The _**[_**sync limits reference**_](https://developers.notion.com/workers/reference/limits)**_ is worth a look before you schedule anything: sync runs are capped at 600 per hour with a 60 per minute burst per workspace, and database writes are capped separately at 1,000,000 operations per hour.**_

* * *

A small repository sits well under both ceilings, so those numbers mainly tell you when this pattern stops fitting.

## What a Worker Sync Does, and What It Will Not Do

A [Notion Worker](https://developers.notion.com/workers/get-started/overview) is a self-contained TypeScript program that Notion hosts and runs for you. Notion’s docs call Workers “small Node/TypeScript programs that extend Notion,” and a Worker registers one or more capabilities: [syncs](https://developers.notion.com/workers/guides/syncs), [tools](https://developers.notion.com/workers/guides/tools), and [webhooks](https://developers.notion.com/workers/guides/webhooks). This post uses a sync, which pulls data from an external API on a schedule and writes it into a Notion database whose schema you declare in code.

A sync moves data in one direction. Your code reads GitHub, decides what each row should say, and hands Notion a batch of changes. The [Notion sync documentation](https://developers.notion.com/workers/guides/syncs) states the contract directly: “Once a database is under sync control, the sync is the only writer to the data it owns.” GitHub stays the source of truth, so treat the Notion database as a readable copy.

The one-way direction of a Notion Worker sync shapes what you can promise your team. An engineer who comments on a Notion issue page, or drags a row into a different view, doesn’t push anything back to GitHub. Comments and any column you add outside the sync’s schema survive, and the synced columns get rewritten on every run. Design the inventory so the columns that must stay trustworthy are the ones the sync owns.

## Scaffold the Worker and Declare a Managed Database

Start from the CLI. This command scaffolds a TypeScript project with a starter `src/index.ts`, following the [Workers quickstart](https://developers.notion.com/workers/get-started/quickstart):

```bash
ntn workers new github-issues-sync
cd github-issues-sync
```

Open `src/index.ts` and replace the starter with a Worker that declares one database. Every sync needs a target database, and a managed database lets Notion create it from the schema you write:

```typescript
import { Worker } from "@notionhq/workers";
import * as Builder from "@notionhq/workers/builder";
import * as Schema from "@notionhq/workers/schema";

const worker = new Worker();
export default worker;

const issues = worker.database("githubIssues", {
  type: "managed",
  initialTitle: "GitHub Issue Inventory",
  primaryKeyProperty: "Issue ID",
  schema: {
    properties: {
      Title: Schema.title(),
      "Issue ID": Schema.richText(),
      State: Schema.select([{ name: "open" }, { name: "closed" }]),
      Author: Schema.richText(),
      Labels: Schema.richText(),
      "Created At": Schema.date(),
      URL: Schema.url(),
    },
  },
});
```

Every field in the `worker.database(...)` declaration above matters. `type: "managed"` tells Notion to create and own the database, `initialTitle` names it, `schema.properties` becomes the columns, and `primaryKeyProperty` names the column that has to identify each row uniquely.

### Choose Managed or Attached

A managed database is the right default for a fresh inventory, because Notion creates it from your schema and owns the columns. An attached database binds the sync to a database you already built by hand, which suits a schema that predates the sync. The difference is ownership, and it settles which side owns the columns.

| Database type | Who creates it | Column ownership | Best fit |
| --- | --- | --- | --- |
| Managed | Notion, from your schema | The sync owns every column you declare | A fresh inventory |
| Attached | You, by hand | The sync writes into your existing schema | A schema built before the sync |

### Set the Primary Key

The `primaryKeyProperty` is the single most important line in the file. Notion’s [sync behavior reference](https://developers.notion.com/workers/guides/syncs) explains the behavior: “When your sync emits a record with the same `key`, Notion updates the existing row instead of creating a duplicate.” A wrong key makes every scheduled run add a fresh set of rows.

Once the first run completes, open the database and eyeball the columns. You should see Issue ID, State, Author, Labels, Created At, and URL beside the title, as the picture below shows.

![Synced issue table](https://adamtheautomator.com/wp-content/uploads/publisher/adc7919f6b701e7b1ad602330ffaeba2bad43106a232e830744e4691b9ac9860.png)

## Map GitHub Issue Fields to Notion Properties

GitHub returns far more than a title and a state. Map the fields your team actually triages, and leave the rest out, because every property you declare in `schema.properties` becomes read-only. The schema helpers and their value builders come in matching pairs, documented in the [Workers schema and builders reference](https://developers.notion.com/workers/reference/schema).

| GitHub issue field | Notion property | Schema declaration | Builder value |
| --- | --- | --- | --- |
| `title` | Title | `Schema.title()` | `Builder.title(issue.title)` |
| `number` | Issue ID | `Schema.richText()` | `Builder.richText(String(issue.number))` |
| `state` | State | `Schema.select([...])` | `Builder.select(issue.state)` |
| `user.login` | Author | `Schema.richText()` | `Builder.richText(issue.user.login)` |
| `labels[].name` | Labels | `Schema.richText()` | `Builder.richText(names.join(", "))` |
| `created_at` | Created At | `Schema.date()` | `Builder.dateTime(issue.created_at)` |
| `html_url` | URL | `Schema.url()` | `Builder.url(issue.html_url)` |

### Pick a Key That Does Not Move

`issue.number` is the value a human reads in the issue URL, and it stays fixed for the life of the issue, which makes it a good primary key for a single repository. GitHub also exposes `issue.id`, the global identifier it assigns across all repositories. The number-based key collides once a database spans two repositories, because both can have an issue 42. A composite key like `${owner}/${repo}#${issue.number}` fixes that; the raw number can stay in a separate display column.

Labels and author are stored as rich text. GitHub labels vary per repository, and rich text avoids a schema migration every time someone adds a label. You lose Notion’s built-in filter-by-tag for Labels in exchange, and a text search over the column takes its place.

## Write the execute Function: Fetch, Filter, Upsert

The `execute` function is where the Worker meets GitHub. It runs on every scheduled trigger, fetches one page of issues, and returns the rows to write. Register it alongside a pacer, which throttles outbound requests so a fast schedule can’t exhaust your GitHub API budget:

```typescript
const githubPacer = worker.pacer("githubPacer", {
  allowedRequests: 10,
  intervalMs: 1000,
});

worker.sync("githubIssuesSync", {
  database: issues,
  mode: "replace",
  schedule: "30m",
  execute: async (state) => {
    const page = state?.page ?? 1;
    const perPage = 100;

    await githubPacer.wait();

    const response = await fetch(
      `https://api.github.com/repos/${process.env.GITHUB_OWNER}/${process.env.GITHUB_REPO}/issues?state=all&per_page=${perPage}&page=${page}`,
      {
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
          "X-GitHub-Api-Version": "2026-03-10",
        },
      },
    );

    if (!response.ok) {
      throw new Error(`GitHub API error ${response.status}`);
    }

    const body = await response.json();
    const rows = body.filter((issue) => !issue.pull_request);

    const changes = rows.map((issue) => ({
      type: "upsert",
      key: String(issue.number),
      properties: {
        Title: Builder.title(issue.title),
        "Issue ID": Builder.richText(String(issue.number)),
        State: Builder.select(issue.state),
        Author: Builder.richText(issue.user.login),
        Labels: Builder.richText(issue.labels.map((label) => label.name).join(", ")),
        "Created At": Builder.dateTime(issue.created_at),
        URL: Builder.url(issue.html_url),
      },
    }));

    const hasMore = body.length === perPage;

    return {
      changes,
      hasMore,
      nextState: hasMore ? { page: page + 1 } : undefined,
    };
  },
});
```

The `key` on each change has to match the `primaryKeyProperty` you declared earlier, which is why both read `issue.number`. `mode: "replace"` and `schedule: "30m"` are covered further down.

### Why the Fetch Uses state=all

Querying with `state=all` keeps closed issues in the dataset so their `State` can flip from `open` to `closed`. With the API’s default of `state=open`, a closed issue vanishes from the response, which in replace mode means Notion deletes the row. Both designs are valid, and they answer different questions: an always-open backlog, or a historical inventory that records what closed.

### Filter Out Pull Requests

GitHub’s issue endpoints return more than issues. The [GitHub REST issues documentation](https://docs.github.com/en/rest/issues/issues) warns: “GitHub’s REST API considers every pull request an issue, but not every issue is a pull request. For this reason, "Issues" endpoints may return both issues and pull requests in the response.” Those pull requests carry a `pull_request` key, so the one-line `body.filter((issue) => !issue.pull_request)` strips them before anything reaches Notion. Without it, your issue inventory fills with pull requests.

### Keep the Token in a Secret

The fetch reads `GITHUB_TOKEN` from the environment, and you store that value as a Worker secret:

```bash
ntn workers env set GITHUB_TOKEN=<fine-grained-pat>
```

The [secrets guide](https://developers.notion.com/workers/guides/secrets) covers how the runtime injects these values at execution time. A read-only repository token is the simplest path here; when you need per-user authorization, the [OAuth guide](https://developers.notion.com/workers/guides/oauth) shows how to register an OAuth capability and call `accessToken()`.

## Paginate Past GitHub’s 100-Issue Page Limit

A single call to the issues endpoint returns at most 100 issues. GitHub’s [pagination documentation](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api) sets the default page size at 30 and the maximum `per_page` at 100, and a larger value is capped back to 100. The [Workers SDK reference](https://developers.notion.com/workers/reference/sdk) describes how the runtime drives the loop for you: return `hasMore: true` with a `nextState`, and the runtime calls `execute` again with that state as the first argument until you return `hasMore: false`.

The pagination state lives in `nextState: { page: page + 1 }`, and the termination test lives in `hasMore`. A wrong termination test is worse than a slow sync.

* * *

_**Warning: Build _**`hasMore`**_ from the raw _**`body.length`**_. A full page of pull requests filters down to an empty _**`rows`**_ array, and a _**`hasMore`**_ that reads the filtered _**`rows.length`**_ stops the run early. In replace mode, an early stop corrupts the result: Notion treats the pages it saw as the whole dataset and sweeps every row it did not see.**_

* * *

## Keep Synced Columns Read-Only and Local Columns Editable

Once a database is under sync control, the [sync boundary documentation](https://developers.notion.com/workers/guides/syncs) is explicit: “Synced columns are read-only. You cannot manually edit any property declared in the sync’s `schema.properties`.” Every column you declared, from State to URL, is written by the sync and by nothing else.

Columns you add in Notion that the schema never declares behave differently. Notion leaves them editable, so a Triage Notes or Owner column you create by hand survives every run. Those columns carry the human layer, holding your notes, assignments, and follow-up dates. An edit to Title doesn’t survive, because the next upsert overwrites it with whatever GitHub reports.

Rows follow the same synced-database rule as columns. You can’t add or delete a row by hand in a synced database; only the sync creates rows through upserts and removes them through replace mode or an explicit `type: "delete"` change. The row rule keeps the inventory honest, since no one can accidentally fork the record set the sync is managing.

## Let Replace Mode Delete Stale Rows, Then Turn On the Schedule

Replace mode is the deletion story. Notion’s [sync data model guide](https://developers.notion.com/workers/guides/syncs) describes the sweep: “Each sync cycle returns the full dataset. After the final `hasMore: false`, any rows not seen during that cycle are automatically deleted.” In practice, the first cycle upserts every issue it fetches, and later cycles update the rows that changed while deleting the ones that no longer appear.

### Confirm Deletions Only Happen After a Clean Run

The sweep waits for the final page. If the run throws partway through, nothing gets deleted, which is deliberate: a failed fetch should not be able to wipe your inventory. That is the safety valve you want, and it is also why a sync that keeps dying on page three never cleans up after itself.

### Deploy and Set the Cadence

Deploy the Worker, then let the schedule registered in the sync take over:

```bash
ntn workers deploy
```

The `schedule: "30m"` value in the sync sets a 30-minute cadence. The [sync schedule reference](https://developers.notion.com/workers/guides/syncs) lists the allowed range: a minimum of `"5m"`, a maximum of `"7d"`, and a default of 30 minutes when you omit the field. Deploying a new build doesn’t reset the sync’s position, so a redeploy resumes from where the last run stopped.

## Run It Twice: Watch Edits and Closures Land

Waiting on a 30-minute schedule makes a slow feedback loop. The [Notion CLI command reference](https://developers.notion.com/cli/reference/commands) documents the `ntn workers sync` subcommands, and triggering a run by hand skips the wait:

```bash
ntn workers sync trigger githubIssuesSync
```

Open the Notion database and confirm your issues are there with their real titles and states. Now change something on GitHub to prove the sync is live. Rename an existing issue’s title, then close a different issue. Trigger the sync again, then give the run a moment to finish:

```bash
ntn workers sync trigger githubIssuesSync
ntn workers runs list
```

Two things should change. The renamed issue keeps its Issue ID while the upsert rewrites its Title in place. The closed issue keeps its row while State flips from `open` to `closed`, because `state=all` kept it in the dataset. Any comment your team already left on that Notion page is still there, because the upsert only rewrites synced columns.

The before-and-after below shows the same row across two runs, with the title edit and the state change landing together.

![Edit and closure](https://adamtheautomator.com/wp-content/uploads/publisher/9ae692b30931003bc983e37c426778bf9677954effc68c20dc0019b92a7205dc.png)

## Troubleshoot a Sync That Runs but Writes Nothing

The `--preview` flag is the fastest way to see what your `execute` function actually returns, without writing to the database:

```bash
ntn workers sync trigger githubIssuesSync --preview
```

If the preview is empty, the bug lives in the fetch code.

### Match the Symptom to the Cause

The [sync troubleshooting section](https://developers.notion.com/workers/guides/syncs) covers the common failure modes:

*   **No rows appear.** An empty preview points at the data fetch, or at a `key` that does not match the property named by `primaryKeyProperty`.
    
*   **Rows are duplicated.** Each row needs a unique `key`. If your key changes between runs, Notion cannot match the existing row and inserts a new one.
    
*   **Stale rows are not deleted in replace mode.** Replace mode only clears stale rows after the final page returns `hasMore: false`, so a sync that errors partway deletes nothing.
    
*   **The sync is stuck or out of date.** Run `ntn workers sync status` to see the last run time, then reset with `ntn workers sync state reset githubIssuesSync` if the stored state is corrupt.
    
*   **You need the run history.** `ntn workers runs list` lists recent runs, and `ntn workers runs logs <runId>` prints one run’s execution log.
    

The terminal capture below shows a healthy `ntn workers sync status` beside a short run history.

![Sync status output](https://adamtheautomator.com/wp-content/uploads/publisher/3577a6e00e33813d151a675f174f46982d47661605b8f29f2b6793e4c33f31bf.png)

## Where to Take the Sync Next

You now have a scheduled, one-way inventory that preserves stable issue IDs, pages past GitHub’s 100-issue limit, and reflects edits and closures on the next run. The pieces that make it work are the managed database, the `primaryKeyProperty`, `state=all`, and the `hasMore`/`nextState` loop, and each one carries a decision you can revisit as the inventory grows.

From here, the obvious upgrades line up with the same Worker. Add a second repository and switch to the composite key so issue numbers stay unique. Replace the shared token with [OAuth](https://developers.notion.com/workers/guides/oauth) when different people need to authorize their own GitHub access. Register a [webhook](https://developers.notion.com/workers/guides/webhooks) when a 30-minute delay is too slow, and expose a [tool](https://developers.notion.com/workers/guides/tools) so a Notion Custom Agent can answer “what is still open” without a manual filter. Watch the [limits reference](https://developers.notion.com/workers/reference/limits) as you scale, since sync runs and database writes share a workspace budget.

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fnotion-workers-github-issue-sync%2F&text=Automate%20GitHub%20Issue%20Sync%20with%20Notion%20Workers)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fnotion-workers-github-issue-sync%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fnotion-workers-github-issue-sync%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b811ca960ed14d86985b1/923eae6c1f8fdcc5d36226cfc9f1bbf87f972302e969a84b03b2bf71088b8ae9.webp)

### [Stop Runaway Notion Agent Costs with the Agent API](/notion-agent-api-cost-tracking/)

Use the Notion Agent API to read credits and runs per agent, compute cost per run, and gate runaway spending with status and credit-limit controls.

![](https://adamtheautomator.com/wp-content/uploads/2026/08/featured_image-5.webp)

### [Azure DevOps Boards: Trace Every Commit to Deployment](/azure-devops-boards-traceability/)

Configure Azure DevOps Boards process templates, backlogs, and Kanban WIP limits, then trace every work item from commit to deployment.

![](https://adamtheautomator.com/wp-content/uploads/2024/12/featured-image-8.webp)

### [Creating Custom GitHub Actions: A Complete Guide for DevOps Teams](/custom-github-actions-guide/)

Learn how to build custom JavaScript GitHub Actions to share standardized, reusable code across your organization's workflows. This hands-on tutorial walks through creating authentication and secret management actions.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
