diff --git a/.github/workflows/calendar-sync.yml b/.github/workflows/calendar-sync.yml new file mode 100644 index 0000000..f6c79e9 --- /dev/null +++ b/.github/workflows/calendar-sync.yml @@ -0,0 +1,39 @@ +name: Sync Google Calendar + +on: + push: + branches: [main] + paths: + - 'calendar/**' + +# Never run two syncs at once +concurrency: + group: calendar-sync + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # the push's previous commit is needed to diff against + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci --no-audit --no-fund + working-directory: calendar/scripts + - name: Sync + run: | + # Only sync what changed since the previous tip of main. + # On the very first push there is no previous commit: diff against the empty tree. + if [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + export BASE_SHA=4b825dc642cb6eb9a060e54bf8d69288fbee4904 + fi + node calendar/scripts/sync.mjs + env: + BASE_SHA: ${{ github.event.before }} + GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }} + GOOGLE_CALENDAR_ID: ${{ secrets.GOOGLE_CALENDAR_ID }} diff --git a/.github/workflows/calendar-validate.yml b/.github/workflows/calendar-validate.yml new file mode 100644 index 0000000..0214942 --- /dev/null +++ b/.github/workflows/calendar-validate.yml @@ -0,0 +1,18 @@ +name: Validate calendar + +on: + pull_request: + paths: + - 'calendar/**' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm ci --no-audit --no-fund + working-directory: calendar/scripts + - run: node calendar/scripts/sync.mjs --validate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78f2710 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.idea/ diff --git a/calendar/README.md b/calendar/README.md new file mode 100644 index 0000000..fe7c50d --- /dev/null +++ b/calendar/README.md @@ -0,0 +1,78 @@ +# Working sessions calendar + +This folder holds data to modify the calendar at https://calendar.graphql.org + +Every event of the working sessions calendar lives in this folder as a small YAML file. +When a change to this folder is merged to `main`, a GitHub Action updates the shared Google Calendar automatically. **Never edit the Google Calendar by hand**: manual changes to synced events are overwritten on the next sync. + +## One file per event + +``` +calendar//--.yaml +``` + +For example `calendar/2026/11-04-working-session.yaml` is an event on 4 November 2026. Month and day are always two digits. The date of the event comes from the file name. + +The optional slug (lowercase letters, digits and dashes) lets you have **several events on the same day**: `11-04-working-session.yaml` and `11-04-social.yaml` are two separate events. A day can have at most one event without a slug (`11-04.yaml`), and each slug can be used once per day. + +The slug is not shown in the calendar, but it identifies the event: **renaming a file to change its slug removes the old calendar event and creates a new one**. + +## File format + +```yaml +startTime: "16:00" +endTime: "17:00" +timeZone: Europe/Paris +title: GraphQL Working Session +location: https://zoom.us/j/0000000000 +description: Agenda and notes for this session. +link: https://github.com/graphql/working-sessions +``` + +| Property | Description | +| ------------- | ------------------------------------------------------------------------ | +| `startTime` | Start time, 24h `HH:MM`, in `timeZone`. **Put it in quotes.** | +| `endTime` | End time, 24h `HH:MM`, in `timeZone`. Must be after `startTime`. | +| `timeZone` | [IANA time zone name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones), e.g. `America/New_York`, `Europe/Paris`, `UTC`. | +| `title` | Event title. | +| `location` | Free text: a video call URL, an address, etc. | +| `description` | Free text description. | +| `link` | `http(s)` URL with more information (agenda, notes, ...). It is also appended to the calendar event description. | + +All properties are required and no other property is allowed. + +## Adding an event + +1. Create `calendar//-[-].yaml` with the properties above. +2. Open a pull request. A check validates your file and reports errors on the offending files. +3. Once the PR is merged, the event shows up in the Google Calendar within a minute or so. + +## Editing an event + +Edit the file and open a pull request. The existing calendar event is updated in place. + +To **move an event to another day** (or change its slug), rename the file. The old event is removed and a new one is created. + +## Removing an event + +Delete the file in a pull request. The event is removed from the Google Calendar on merge. + +## Validation + +You can check your changes locally (Node 20+): + +```sh +cd calendar/scripts && npm ci && cd - +node calendar/scripts/sync.mjs --validate +``` + +## Maintainers: setup + +The sync workflow (`.github/workflows/calendar-sync.yml`) needs two repository secrets: + +- `GOOGLE_SERVICE_ACCOUNT_KEY`: the JSON key of a Google Cloud service account (with the Google Calendar API enabled). +- `GOOGLE_CALENDAR_ID`: the ID of the target calendar (Calendar settings → "Integrate calendar"). + +Share the target calendar with the service account's email address with the **"Make changes to events"** permission. + +The workflow is incremental: on each push to `main` it only creates, updates or deletes the events of the files that changed in that push. Events not backed by a file in this folder are never touched. diff --git a/calendar/scripts/package-lock.json b/calendar/scripts/package-lock.json new file mode 100644 index 0000000..bdfe7e8 --- /dev/null +++ b/calendar/scripts/package-lock.json @@ -0,0 +1,624 @@ +{ + "name": "calendar-sync", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calendar-sync", + "dependencies": { + "googleapis": "^144.0.0", + "js-yaml": "^4.1.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "144.0.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-144.0.0.tgz", + "integrity": "sha512-ELcWOXtJxjPX4vsKMh+7V+jZvgPwYMlEhQFiu2sa9Qmt5veX8nwXPksOWGGN6Zk4xCiLygUyaz7xGtcMO+Onxw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/qs": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/calendar/scripts/package.json b/calendar/scripts/package.json new file mode 100644 index 0000000..2eeac5f --- /dev/null +++ b/calendar/scripts/package.json @@ -0,0 +1,9 @@ +{ + "name": "calendar-sync", + "private": true, + "type": "module", + "dependencies": { + "googleapis": "^144.0.0", + "js-yaml": "^4.1.0" + } +} diff --git a/calendar/scripts/sync.mjs b/calendar/scripts/sync.mjs new file mode 100644 index 0000000..2c23e7d --- /dev/null +++ b/calendar/scripts/sync.mjs @@ -0,0 +1,194 @@ +// Keeps a Google Calendar in sync with the YAML files in /calendar. +// +// node sync.mjs --validate only validate all the files (no credentials needed) +// BASE_SHA= node sync.mjs sync the files added/changed/removed since +// +// Every file maps to exactly one event with a deterministic ID, so syncing is idempotent. +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readdirSync, readFileSync, existsSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { google } from 'googleapis' +import yaml from 'js-yaml' + +const repoRoot = resolve(fileURLToPath(import.meta.url), '../../..') +const calendarDir = join(repoRoot, 'calendar') +const FIELDS = ['startTime', 'endTime', 'timeZone', 'title', 'location', 'description', 'link'] + +function listEventFiles() { + const files = [] + if (!existsSync(calendarDir)) return files + for (const year of readdirSync(calendarDir, { withFileTypes: true })) { + if (!year.isDirectory() || !/^\d{4}$/.test(year.name)) continue + for (const f of readdirSync(join(calendarDir, year.name), { withFileTypes: true })) { + if (f.isFile() && f.name.endsWith('.yaml')) files.push(join(calendarDir, year.name, f.name)) + else if (f.isFile() && f.name.endsWith('.yml')) files.push(join(calendarDir, year.name, f.name)) + } + } + return files.sort() +} + +// calendar//-
[-].yaml. Several events can share a day if their slugs differ. +const PATH_RE = /^calendar\/(\d{4})\/(\d{2})-(\d{2})(?:-([a-z0-9]+(?:-[a-z0-9]+)*))?\.yaml$/ + +function parsePath(path) { + const m = PATH_RE.exec(path) + if (!m) return { error: 'path must look like calendar//-
.yaml or calendar//-
-.yaml (slug: lowercase letters, digits and dashes)' } + const [, y, mo, d, slug = ''] = m + const dt = new Date(Date.UTC(+y, +mo - 1, +d)) + if (dt.getUTCFullYear() !== +y || dt.getUTCMonth() !== +mo - 1 || dt.getUTCDate() !== +d) { + return { error: `${y}-${mo}-${d} is not a valid date` } + } + return { date: `${y}-${mo}-${d}`, slug } +} + +function parseEvent(file) { + const path = relative(repoRoot, file) + const errors = [] + const { date, slug, error } = parsePath(path) + if (error) errors.push(error) + + let data + try { + data = yaml.load(readFileSync(file, 'utf8')) + } catch (e) { + return { path, errors: [...errors, `invalid YAML: ${e.message}`] } + } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { path, errors: [...errors, 'file must contain a YAML mapping'] } + } + + for (const k of Object.keys(data)) { + if (!FIELDS.includes(k)) errors.push(`unknown property "${k}"`) + } + for (const k of FIELDS) { + if (typeof data[k] !== 'string' || data[k].trim() === '') { + errors.push(`"${k}" is required and must be a non-empty string (quote times, e.g. "14:00")`) + } + } + if (errors.length) return { path, errors } + + const time = /^([01]\d|2[0-3]):[0-5]\d$/ + for (const k of ['startTime', 'endTime']) { + if (!time.test(data[k])) errors.push(`"${k}" must be HH:MM in 24h format, got "${data[k]}"`) + } + if (time.test(data.startTime) && time.test(data.endTime) && data.endTime <= data.startTime) { + errors.push('"endTime" must be after "startTime" (events cannot span midnight)') + } + try { + new Intl.DateTimeFormat('en', { timeZone: data.timeZone }) + } catch { + errors.push(`"timeZone" must be an IANA time zone such as "Europe/Paris", got "${data.timeZone}"`) + } + try { + const u = new URL(data.link) + if (!['http:', 'https:'].includes(u.protocol)) throw new Error() + } catch { + errors.push(`"link" must be an http(s) URL, got "${data.link}"`) + } + if (errors.length) return { path, errors } + + return { path, errors, date, slug, ...data } +} + +// Google event IDs: 5-1024 chars of a-v and 0-9. A hex digest fits. +// The ID depends on the date and slug only. +const eventId = (date, slug) => 'gql' + createHash('sha1').update(`${date}/${slug}`).digest('hex') + +function toGoogleEvent(e) { + return { + id: eventId(e.date, e.slug), + summary: e.title.trim(), + location: e.location.trim(), + description: `${e.description.trim()}\n\n${e.link}`, + source: { title: 'GraphQL Working Sessions', url: e.link }, + start: { dateTime: `${e.date}T${e.startTime}:00`, timeZone: e.timeZone }, + end: { dateTime: `${e.date}T${e.endTime}:00`, timeZone: e.timeZone }, + } +} + +function getCalendar() { + const calendarId = process.env.GOOGLE_CALENDAR_ID + const key = process.env.GOOGLE_SERVICE_ACCOUNT_KEY + if (!calendarId || !key) { + throw new Error('GOOGLE_CALENDAR_ID and GOOGLE_SERVICE_ACCOUNT_KEY must be set') + } + const auth = new google.auth.GoogleAuth({ + credentials: JSON.parse(key), + scopes: ['https://www.googleapis.com/auth/calendar.events'], + }) + return { calendarId, cal: google.calendar({ version: 'v3', auth }) } +} + +// Creates the event or, if its ID already exists (even as a cancelled event), updates it. +async function upsert({ cal, calendarId }, body) { + try { + await cal.events.insert({ calendarId, requestBody: body }) + return 'created' + } catch (err) { + if (err.code !== 409) throw err + await cal.events.update({ calendarId, eventId: body.id, requestBody: { ...body, status: 'confirmed' } }) + return 'updated' + } +} + +async function remove({ cal, calendarId }, id) { + try { + await cal.events.delete({ calendarId, eventId: id }) + return true + } catch (err) { + if (err.code === 404 || err.code === 410) return false // already gone + throw err + } +} + +// Only touches the files that changed between `base` and HEAD. +async function syncIncremental(base) { + const diff = execFileSync( + 'git', + ['diff', '--name-status', '--no-renames', '-z', base, 'HEAD', '--', 'calendar'], + { cwd: repoRoot, encoding: 'utf8' }, + ) + // -z output: STATUS \0 path \0 STATUS \0 path ... + const parts = diff.split('\0').filter(Boolean) + const changes = [] + for (let i = 0; i + 1 < parts.length; i += 2) { + if (/^calendar\/\d{4}\/.*\.ya?ml$/.test(parts[i + 1])) changes.push({ status: parts[i], path: parts[i + 1] }) + } + if (!changes.length) return console.log('No event file changed, nothing to sync') + + const toUpsert = changes.filter((c) => c.status !== 'D').map((c) => parseEvent(join(repoRoot, c.path))) + failOnInvalid(toUpsert) + + const client = getCalendar() + for (const c of changes.filter((c) => c.status === 'D')) { + const { date, slug } = parsePath(c.path) + if (!date) continue + const gone = await remove(client, eventId(date, slug)) + console.log(`${c.path}: ${gone ? 'deleted' : 'was not in the calendar'}`) + } + for (const e of toUpsert) console.log(`${e.path}: ${await upsert(client, toGoogleEvent(e))}`) +} + +function failOnInvalid(events) { + const invalid = events.filter((e) => e.errors.length) + for (const e of invalid) { + for (const msg of e.errors) console.error(`::error file=${e.path}::${msg}`) + } + if (invalid.length) { + console.error(`${invalid.length} invalid event file(s)`) + process.exit(1) + } +} + +if (process.argv.includes('--validate')) { + const events = listEventFiles().map(parseEvent) + failOnInvalid(events) + console.log(`${events.length} event file(s) valid`) +} else if (process.env.BASE_SHA) { + await syncIncremental(process.env.BASE_SHA) +} else { + console.error('usage: sync.mjs --validate, or set BASE_SHA to the git ref to sync from') + process.exit(2) +}