Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .claude/skills/open-pr/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
name: open-pr
description: Open (or detect) the PR for the current OpenSpec change and link it to its Notion issue. Use when the user asks to open/raise a PR for work planned via OpenSpec.
compatibility: Requires the `gh` CLI (authenticated), `python3`, and a `NOTION_TOKEN` environment variable (an internal Notion integration shared with the issues database).
metadata:
author: notion-issue-sync
version: "1.0"
---

Open the pull request for the current OpenSpec change and attach the PR link to
its Notion issue. The Notion API call is done by the colocated deterministic
script `link_pr.py` (no LLM in the API path); this skill only orchestrates.

## Prerequisites

- `gh` authenticated for the target repo.
- `python3` available.
- `NOTION_TOKEN` set to an internal Notion integration token that has been shared
with the issue's database. Without it, `link_pr.py` exits with a clear error.

## Steps

1. **Identify the change.** Determine the active OpenSpec change (from the current
git branch, the conversation, or `openspec list`). Note its directory:
`openspec/changes/<change>/`. Announce which change you are using.

2. **Read the Notion issue URL.** Read `notion:` from
`openspec/changes/<change>/.openspec.yaml`.
- If the field is **missing or empty**, ask the user for the Notion issue URL
before continuing (do NOT guess or silently skip). Offer to record it under
`notion:` in that `.openspec.yaml` so future runs are automatic.

3. **Ensure a PR exists.** On the current branch:
```bash
gh pr view --json url,number,state
```
- If a PR exists, use its `url`.
- If none exists, create one, drafting the title and body from the change's
`proposal.md` (title = a concise summary of the change; body = the "Why" and
"What Changes"):
```bash
gh pr create --draft --title "<summary>" --body "<from proposal.md>"
```
Capture the resulting PR URL.

4. **Link the PR to the Notion issue.** Run the colocated linker (idempotent):
```bash
python3 "<this skill dir>/link_pr.py" --notion-url "<notion url>" --pr-url "<pr url>" --once
```
- Default behavior posts a comment on the Notion issue with the PR link.
- To write a URL property instead of a comment, add `--property "<Property Name>"`.

5. **Report.** Show the PR URL and the linker's result. If `link_pr.py` exits
non-zero, surface its message verbatim and stop:
- `NOTION_TOKEN environment variable is not set` → ask the user to export it.
- An HTTP error mentioning comment capability → the integration lacks comment
access; retry with `--property "<Property Name>"` or fix the integration.

## Notes

- This skill only **links** the PR. It does **not** change the Notion issue's
status (that is handled separately, on PR merge — out of scope here).
- Re-running is safe: `--once` skips when the PR link is already present.
146 changes: 146 additions & 0 deletions .claude/skills/open-pr/link_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Attach a GitHub PR link to a Notion issue page.

Deterministic, stdlib-only. Given a Notion issue URL (or page id) and a PR URL,
it either posts a comment on the page (default) or writes the PR URL into a URL
property. Auth via the NOTION_TOKEN environment variable.

Usage:
python link_pr.py --notion-url <issue url> --pr-url <pr url>
python link_pr.py --page-id <32-hex or uuid> --pr-url <pr url> --once
python link_pr.py --notion-url <issue url> --pr-url <pr url> --property "Pull Requests"
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request

API = "https://api.notion.com/v1"
NOTION_VERSION = "2022-06-28"

# A Notion page id is either a dashed UUID or a bare 32-char hex run. Match it in
# the ORIGINAL string (never strip dashes globally, or a hex-ending slug like
# "...Continue-<id>" would merge into the id and misalign it). The id trails the
# slug, so take the LAST match.
_ID_RE = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
r"|[0-9a-fA-F]{32}"
)


def _find_id(text):
if not text:
return None
matches = _ID_RE.findall(text)
if not matches:
return None
return matches[-1].replace("-", "").lower()


def extract_page_id(value):
"""Return the page id as a dashed UUID. Handles the clean `.../Title-<id>`
form, a raw id, and the in-database form `.../DB-<dbid>?p=<pageid>` (page id
is the `p` query param; the path holds the database id, which we must NOT use)."""
parsed = urllib.parse.urlparse(value)
query = urllib.parse.parse_qs(parsed.query)
raw = _find_id(query["p"][0]) if query.get("p") else None
if raw is None:
raw = _find_id(parsed.path or value)
if raw is None:
raise ValueError(
"could not find a 32-character Notion page id in: {!r}".format(value)
)
return "{}-{}-{}-{}-{}".format(
raw[0:8], raw[8:12], raw[12:16], raw[16:20], raw[20:32]
)


def request(method, path, token, body=None):
url = API + path
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Notion-Version", NOTION_VERSION)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
raise SystemExit(
"Notion API {} {} failed: HTTP {}\n{}".format(method, path, e.code, detail)
)
except urllib.error.URLError as e:
raise SystemExit("Notion API {} {} failed: {}".format(method, path, e.reason))


def already_linked_comment(page_id, pr_url, token):
res = request("GET", "/comments?block_id=" + page_id, token)
for comment in res.get("results", []):
for rt in comment.get("rich_text", []):
if pr_url in rt.get("plain_text", ""):
return True
return False


def post_comment(page_id, pr_url, token):
body = {
"parent": {"page_id": page_id},
"rich_text": [
{"text": {"content": "PR: "}},
{"text": {"content": pr_url, "link": {"url": pr_url}}},
],
}
request("POST", "/comments", token, body)


def property_already_set(page_id, prop, pr_url, token):
res = request("GET", "/pages/" + page_id, token)
current = res.get("properties", {}).get(prop, {})
return current.get("url") == pr_url


def set_url_property(page_id, prop, pr_url, token):
body = {"properties": {prop: {"url": pr_url}}}
request("PATCH", "/pages/" + page_id, token, body)


def main(argv=None):
parser = argparse.ArgumentParser(description="Attach a PR link to a Notion issue page.")
src = parser.add_mutually_exclusive_group(required=True)
src.add_argument("--notion-url", help="Notion issue URL (page id is extracted from it)")
src.add_argument("--page-id", help="Notion page id (32-hex or dashed UUID)")
parser.add_argument("--pr-url", required=True, help="GitHub PR URL to attach")
parser.add_argument("--property", dest="prop", metavar="NAME",
help="write to this URL property instead of posting a comment")
parser.add_argument("--once", action="store_true",
help="skip if the PR link is already present")
args = parser.parse_args(argv)

token = os.environ.get("NOTION_TOKEN")
if not token:
raise SystemExit("NOTION_TOKEN environment variable is not set.")

page_id = extract_page_id(args.notion_url or args.page_id)

if args.prop:
if args.once and property_already_set(page_id, args.prop, args.pr_url, token):
print("Already set on property {!r}; skipping.".format(args.prop))
return 0
set_url_property(page_id, args.prop, args.pr_url, token)
print("Set PR link on property {!r} of page {}.".format(args.prop, page_id))
else:
if args.once and already_linked_comment(page_id, args.pr_url, token):
print("PR already linked in a comment; skipping.")
return 0
post_comment(page_id, args.pr_url, token)
print("Posted PR link comment on page {}.".format(page_id))
return 0


if __name__ == "__main__":
sys.exit(main())
66 changes: 66 additions & 0 deletions .claude/skills/open-pr/test_link_pr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Offline tests for link_pr.py (no network / no token needed)."""
import os
import subprocess
import sys

import link_pr

EXPECT = "38bbfd2f-80ba-805f-b7d5-cb4f3a7988f2"
CASES = [
("https://app.notion.com/p/Continue-with-Google-is-failing-38bbfd2f80ba805fb7d5cb4f3a7988f2?pvs=8&n=github_linkback", EXPECT),
("https://www.notion.so/Continue-with-Google-is-failing-38bbfd2f80ba805fb7d5cb4f3a7988f2", EXPECT),
# dashed UUID in path (previously misaligned by global dash-stripping)
("https://www.notion.so/Title-38bbfd2f-80ba-805f-b7d5-cb4f3a7988f2", EXPECT),
# slug ends in a hex char right before the id (previously shifted the window)
("https://www.notion.so/Continue-38bbfd2f80ba805fb7d5cb4f3a7988f2?v=abc", EXPECT),
("38bbfd2f80ba805fb7d5cb4f3a7988f2", EXPECT),
("38bbfd2f-80ba-805f-b7d5-cb4f3a7988f2", EXPECT),
# page opened inside a database view: page id is in ?p=, db id is in the path
("https://www.notion.so/team/Issues-DB-11112222333344445555666677778888?p=38bbfd2f80ba805fb7d5cb4f3a7988f2&pm=s", EXPECT),
]

failures = 0
for value, expected in CASES:
try:
got = link_pr.extract_page_id(value)
except Exception as e: # noqa
got = "ERROR: %s" % e
ok = got == expected
failures += 0 if ok else 1
print("%s %-72s -> %s" % ("ok " if ok else "FAIL", value[:72], got))

try:
link_pr.extract_page_id("https://www.notion.so/just-a-title")
print("FAIL no-id case did not raise")
failures += 1
except ValueError:
print("ok no-id case raises ValueError")

print("\n--- CLI error paths ---")


def run(args, env=None):
return subprocess.run([sys.executable, "link_pr.py"] + args,
capture_output=True, text=True, env=env)


env_no_token = {k: v for k, v in os.environ.items() if k != "NOTION_TOKEN"}
r = run(["--notion-url", "https://www.notion.so/x-38bbfd2f80ba805fb7d5cb4f3a7988f2",
"--pr-url", "https://github.com/o/r/pull/1"], env=env_no_token)
ok = r.returncode != 0 and "NOTION_TOKEN" in r.stderr
failures += 0 if ok else 1
print("%s missing-token exits %s: %s" % ("ok " if ok else "FAIL", r.returncode, r.stderr.strip()))

r = run(["--notion-url", "u", "--page-id", "p", "--pr-url", "x"])
ok = r.returncode != 0
failures += 0 if ok else 1
print("%s mutually-exclusive rejected (exit %s)" % ("ok " if ok else "FAIL", r.returncode))

r = run(["--page-id", "38bbfd2f80ba805fb7d5cb4f3a7988f2"])
ok = r.returncode != 0
failures += 0 if ok else 1
print("%s missing --pr-url rejected (exit %s)" % ("ok " if ok else "FAIL", r.returncode))

print("\n%d failure(s)" % failures)
sys.exit(1 if failures else 0)
8 changes: 0 additions & 8 deletions __tests__/github.test.ts

This file was deleted.

36 changes: 36 additions & 0 deletions __tests__/transitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {expect, test} from '@jest/globals'
import {issueStatusKey, parseUniqueId, prRowStatusKey} from '../src/transitions'

const ev = (action: string, pr: any = {}): any => ({action, pull_request: pr})
const review = (state: string): any => ({action: 'submitted', review: {state}, pull_request: {}})

test('prRowStatusKey maps PR events to the row status', () => {
expect(prRowStatusKey(ev('opened', {draft: false}))).toBe('open')
expect(prRowStatusKey(ev('opened', {draft: true}))).toBe('draft')
expect(prRowStatusKey(ev('converted_to_draft'))).toBe('draft')
expect(prRowStatusKey(ev('ready_for_review', {draft: false}))).toBe('open')
expect(prRowStatusKey(ev('reopened', {draft: false}))).toBe('open')
expect(prRowStatusKey(ev('closed', {merged: true}))).toBe('merged')
expect(prRowStatusKey(ev('closed', {merged: false}))).toBe('closed')
expect(prRowStatusKey(review('changes_requested'))).toBeNull()
})

test('issueStatusKey maps PR events to the issue status', () => {
expect(issueStatusKey(ev('opened', {draft: false}))).toBe('in_pr')
expect(issueStatusKey(ev('opened', {draft: true}))).toBeNull()
expect(issueStatusKey(ev('ready_for_review', {draft: false}))).toBe('in_pr')
expect(issueStatusKey(ev('reopened', {draft: false}))).toBe('in_pr')
expect(issueStatusKey(ev('reopened', {draft: true}))).toBeNull()
expect(issueStatusKey(ev('closed', {merged: true}))).toBe('for_qa')
expect(issueStatusKey(ev('closed', {merged: false}))).toBeNull()
expect(issueStatusKey(review('changes_requested'))).toBe('fixes_required')
expect(issueStatusKey(review('approved'))).toBeNull()
expect(issueStatusKey(review('commented'))).toBeNull()
})

test('parseUniqueId extracts the issue number', () => {
expect(parseUniqueId('body\nNotion: ISSUE-21360')).toBe(21360)
expect(parseUniqueId('see TASK-42 here')).toBe(42)
expect(parseUniqueId('nothing here')).toBeNull()
expect(parseUniqueId(undefined)).toBeNull()
})
13 changes: 5 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
name: Notion Issue Sync
description: Invokes a provided Make web-hook for syncing the state of an issue linked to the active PR
name: Notion PR Status Sync
description: On PR lifecycle events, update the PR row in the Notion Pull Requests DB and the linked issue's Status (opened→In PR, changes-requested→Fixes Required, merged→For QA when the last PR merges).
inputs:
token:
notion_token:
required: true
description: 'Read-scoped GitHub token'
event:
required: true
description: 'Should be toJson(github.event) for extracting PR/review state'
description: Notion internal-integration token, shared with the Tech Issues + Pull Requests databases.
runs:
using: 'node16'
using: 'node20'
main: 'dist/index.js'
Loading
Loading