@tahminator/pipeline 1.0.89 → 1.0.90-beta.gc63a80f4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,143 @@ bun run src/index.ts
30
30
  > [!WARNING]
31
31
  > This repository is iterating quickly & as such may have rough edges. I will always be happy to respond to & fix any issues anyone may have :)
32
32
 
33
+ ## Slash commands action
34
+
35
+ Use [`actions/commands`](./actions/commands/README.md) to run your repository's workflows from PR comments such as `/merge` or `/ai`.
36
+
37
+ Before using it:
38
+
39
+ - Add the GitHub App ID and private key as repository secrets.
40
+ - Include `yargs` and your loader's dependencies in `.github/scripts/package.json`.
41
+ - Replace `<published-commit-sha>` with the reviewed pipeline commit you want to use.
42
+
43
+ Choose one command source: a hardcoded list or a Bun script.
44
+
45
+ | | Hardcoded | Dynamic |
46
+ | ------------ | -------------------------------------- | ------------------------------------------------ |
47
+ | Input | `COMMANDS` | `DYNAMIC_COMMANDS_BUN_SCRIPT` |
48
+ | Command list | Written directly in the workflow. | Printed to stdout by a Bun script. |
49
+ | Source | Default-branch workflow configuration. | Script from the authorized PR head. |
50
+ | Best for | A fixed list of commands. | Computing the list from repository code or data. |
51
+
52
+ Both use the same permission checks and dispatch to repository-owned command workflows.
53
+
54
+ ### Examples
55
+
56
+ Create these two files to add a `/hello` command that replies to the PR.
57
+
58
+ **`.github/workflows/slash.yml`**
59
+
60
+ ```yaml
61
+ name: Slash Command Dispatch
62
+
63
+ on:
64
+ issue_comment:
65
+ types: [created]
66
+
67
+ permissions:
68
+ contents: read
69
+
70
+ jobs:
71
+ dispatch:
72
+ if: >-
73
+ github.event.issue.pull_request &&
74
+ startsWith(github.event.comment.body, '/')
75
+ runs-on: ubuntu-latest
76
+ steps:
77
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
78
+ with:
79
+ # please set these to keep this workflow secure
80
+ ref: ${{ github.event.repository.default_branch }}
81
+ persist-credentials: false
82
+
83
+ - uses: tahminator/pipeline/actions/setup@<published-commit-sha>
84
+ with:
85
+ LINT_CI: false
86
+
87
+ - uses: tahminator/pipeline/actions/commands@<published-commit-sha>
88
+ with:
89
+ APP_ID: ${{ secrets._GITHUB_APP_APP_ID }}
90
+ PRIVATE_KEY: ${{ secrets._GITHUB_APP_PEM_CONTENT }}
91
+ COMMANDS: |
92
+ hello
93
+ merge
94
+ ```
95
+
96
+ **`.github/workflows/hello-command.yml`**
97
+
98
+ ```yaml
99
+ name: Hello command
100
+
101
+ on:
102
+ workflow_dispatch:
103
+ inputs:
104
+ repository:
105
+ description: Repository where the command was posted
106
+ required: true
107
+ type: string
108
+ comment-id:
109
+ description: ID of the slash-command comment
110
+ required: true
111
+ type: string
112
+ prId:
113
+ description: Pull request number
114
+ required: true
115
+ type: string
116
+ author:
117
+ description: Username that posted the command
118
+ required: true
119
+ type: string
120
+
121
+ permissions:
122
+ pull-requests: write
123
+
124
+ jobs:
125
+ hello:
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - name: Reply to the PR
129
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
130
+ with:
131
+ script: |
132
+ const { prId, author } = context.payload.inputs;
133
+ await github.rest.issues.createComment({
134
+ ...context.repo,
135
+ issue_number: Number(prId),
136
+ body: `Hello @${author}! This command ran from \`${context.ref}\`.`,
137
+ });
138
+ ```
139
+
140
+ Once both files are on the default branch, a user with write access can comment `/hello` on an open, same-repository PR. The dispatcher runs `hello-command.yml` from the PR branch, and the handler replies with that branch's name. Replace the reply step with your command's work.
141
+
142
+ ### Commands from a Bun script
143
+
144
+ Keep the same trigger, trusted checkout, and setup steps. Replace the final action step with:
145
+
146
+ ```yaml
147
+ - uses: tahminator/pipeline/actions/commands@<published-commit-sha>
148
+ with:
149
+ APP_ID: ${{ secrets._GITHUB_APP_APP_ID }}
150
+ PRIVATE_KEY: ${{ secrets._GITHUB_APP_PEM_CONTENT }}
151
+ DYNAMIC_COMMANDS_BUN_SCRIPT: .github/scripts/src/load-slash-commands/index.ts
152
+ ```
153
+
154
+ Create that file and print the available command names:
155
+
156
+ ```ts
157
+ const commands = ["merge", "ai", "copy"];
158
+ console.log(commands.join("\n"));
159
+ ```
160
+
161
+ Print only command names to stdout, separated by newlines or commas. Use `console.error` for logs. Empty or invalid output, or a nonzero exit, fails the action.
162
+
163
+ The action checks write access and rejects forks before loading this script from the PR head. Run `setup` first to provide Bun and dependencies; this action installs nothing.
164
+
165
+ > [!IMPORTANT]
166
+ > Do not check out the PR head and install its dependencies before calling this action. The gate cannot protect earlier workflow steps. Keep pre-authorization setup on trusted code, as in the example.
167
+
168
+ See the [action documentation](./actions/commands/README.md) for GitHub App permissions and the full dispatch contract.
169
+
33
170
  ## Available Clients
34
171
 
35
172
  ```ts
@@ -48,6 +185,7 @@ import {
48
185
  VersioningClient,
49
186
  LocalPostgresClient,
50
187
  LocalRedisClient,
188
+ NotionClient,
51
189
  } from "@tahminator/pipeline";
52
190
  ```
53
191
 
@@ -67,6 +205,7 @@ Jump to client documentation
67
205
  - [`VersioningClient`](#versioningclient)
68
206
  - [`LocalPostgresClient`](#localpostgresclient)
69
207
  - [`LocalRedisClient`](#localredisclient)
208
+ - [`NotionClient`](#notionclient)
70
209
 
71
210
  <!-- tocstop -->
72
211
 
@@ -511,3 +650,26 @@ async function main() {
511
650
 
512
651
  await main();
513
652
  ```
653
+
654
+ ### `NotionClient`
655
+
656
+ Look up a page in a Notion database by its `ID` (`unique_id`) field and update its fields.
657
+
658
+ The integration must be connected to the database (`...` -> `Connections`). The database must have exactly one data source with an `ID` property, since `ID` values are only unique per data source.
659
+
660
+ ```ts
661
+ const notion = new NotionClient({
662
+ auth: {
663
+ token: process.env.NOTION_TOKEN!,
664
+ },
665
+ // database ID or URL
666
+ databaseId: process.env.NOTION_DATABASE_ID!,
667
+ });
668
+
669
+ // for an ID displayed as `TASK-123`, pass `123`; returns `null` if no page matches
670
+ const page = await notion.getPageByUniqueId(123);
671
+
672
+ if (page?.hasField("Status")) {
673
+ await page.saveField("Status", { status: { name: "Done" } });
674
+ }
675
+ ```
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export * from "./postgres";
10
10
  export * from "./redis";
11
11
  export * from "./utils";
12
12
  export * from "./versioning";
13
+ export * from "./notion";
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export * from "./postgres";
10
10
  export * from "./redis";
11
11
  export * from "./utils";
12
12
  export * from "./versioning";
13
+ export * from "./notion";
@@ -0,0 +1,7 @@
1
+ /** Exactly one source: inline names or a repository-owned Bun loader. */
2
+ export declare function parseCommands(input: string, file: string): {
3
+ commands: string;
4
+ file: string;
5
+ };
6
+ /** Runs only after the action has checked write permission and rejected fork heads. */
7
+ export declare function loadCommands(file: string, workspace: string): Promise<string>;
@@ -0,0 +1,44 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /** Exactly one source: inline names or a repository-owned Bun loader. */
4
+ export function parseCommands(input, file) {
5
+ if (Boolean(input.trim()) === Boolean(file.trim())) {
6
+ throw new Error("Set exactly one of COMMANDS or DYNAMIC_COMMANDS_BUN_SCRIPT");
7
+ }
8
+ if (file.trim()) {
9
+ if (/[\r\n]/.test(file) ||
10
+ path.isAbsolute(file) ||
11
+ file.split(/[\\/]/).includes("..")) {
12
+ throw new Error("DYNAMIC_COMMANDS_BUN_SCRIPT must be a repository-relative file path");
13
+ }
14
+ return { commands: "", file };
15
+ }
16
+ const commands = input.split(/[\s,]+/).filter(Boolean);
17
+ if (!commands.length ||
18
+ commands.some((name) => !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name))) {
19
+ throw new Error("COMMANDS must contain command names separated by commas or newlines");
20
+ }
21
+ return { commands: commands.join("\n"), file: "" };
22
+ }
23
+ /** Runs only after the action has checked write permission and rejected fork heads. */
24
+ export async function loadCommands(file, workspace) {
25
+ const root = await realpath(workspace);
26
+ const source = await realpath(path.resolve(root, file));
27
+ const relative = path.relative(root, source);
28
+ if (relative.startsWith("..") ||
29
+ path.isAbsolute(relative) ||
30
+ !(await stat(source)).isFile()) {
31
+ throw new Error("The command loader must be a file inside the repository");
32
+ }
33
+ const child = Bun.spawn([process.execPath, "run", "--no-install", source], {
34
+ cwd: root,
35
+ stdout: "pipe",
36
+ stderr: "inherit",
37
+ });
38
+ const stdout = await new Response(child.stdout).text();
39
+ const exitCode = await child.exited;
40
+ if (exitCode !== 0) {
41
+ throw new Error(`Command loader exited with code ${exitCode}`);
42
+ }
43
+ return parseCommands(stdout, "").commands;
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { appendFile } from "node:fs/promises";
2
+ import yargs from "yargs";
3
+ import { loadCommands, parseCommands } from "./helper";
4
+ const { commands, dynamicCommandsBunScript, file, workspace } = await yargs(process.argv.slice(2))
5
+ .option("commands", {
6
+ type: "string",
7
+ describe: "Hardcoded command names separated by commas or newlines",
8
+ })
9
+ .option("dynamicCommandsBunScript", {
10
+ type: "string",
11
+ describe: "Repository-relative Bun loader path",
12
+ })
13
+ .option("file", {
14
+ type: "string",
15
+ describe: "Execute the previously validated Bun loader",
16
+ })
17
+ .option("workspace", {
18
+ type: "string",
19
+ default: process.env.GITHUB_WORKSPACE,
20
+ describe: "Repository root for loader execution",
21
+ })
22
+ .conflicts("file", ["commands", "dynamicCommandsBunScript"])
23
+ .strict()
24
+ .parse();
25
+ async function main() {
26
+ const output = process.env.GITHUB_OUTPUT;
27
+ if (!output)
28
+ throw new Error("GITHUB_OUTPUT is required");
29
+ let result;
30
+ if (file) {
31
+ if (!workspace)
32
+ throw new Error("--workspace or GITHUB_WORKSPACE is required");
33
+ result = { commands: await loadCommands(file, workspace), file: "" };
34
+ }
35
+ else {
36
+ result = parseCommands(commands ?? "", dynamicCommandsBunScript ?? "");
37
+ }
38
+ await appendFile(output, `commands<<__COMMANDS__\n${result.commands}\n__COMMANDS__\nfile=${result.file}\n`);
39
+ }
40
+ await main();
@@ -0,0 +1,21 @@
1
+ import type { NotionClientOpts } from "./types";
2
+ import { NotionPageObject } from "./page";
3
+ /**
4
+ * Interface with a single Notion database: look up its pages by their `ID` field & update their fields.
5
+ *
6
+ * @note the database must have exactly one data source with an `ID` (`unique_id`) property, since `ID`
7
+ * values are only unique within a single data source.
8
+ */
9
+ export declare class NotionClient {
10
+ private readonly client;
11
+ private readonly databaseId;
12
+ private dataSource?;
13
+ constructor(opts: NotionClientOpts);
14
+ /**
15
+ * Returns the page whose `ID` (`unique_id`) field equals `id`, or `null` if none exists.
16
+ * For an ID displayed as `TASK-123`, pass `123`.
17
+ */
18
+ getPageByUniqueId(id: number): Promise<NotionPageObject | null>;
19
+ private getDataSource;
20
+ private resolveDataSource;
21
+ }
@@ -0,0 +1,71 @@
1
+ import { Client, extractDatabaseId, isFullDatabase, isFullDataSource, isFullPage, } from "@notionhq/client";
2
+ import { NotionPageObject } from "./page";
3
+ /**
4
+ * Interface with a single Notion database: look up its pages by their `ID` field & update their fields.
5
+ *
6
+ * @note the database must have exactly one data source with an `ID` (`unique_id`) property, since `ID`
7
+ * values are only unique within a single data source.
8
+ */
9
+ export class NotionClient {
10
+ client;
11
+ databaseId;
12
+ dataSource;
13
+ constructor(opts) {
14
+ this.client = new Client({ auth: opts.auth.token });
15
+ this.databaseId = extractDatabaseId(opts.databaseId) ?? opts.databaseId;
16
+ }
17
+ /**
18
+ * Returns the page whose `ID` (`unique_id`) field equals `id`, or `null` if none exists.
19
+ * For an ID displayed as `TASK-123`, pass `123`.
20
+ */
21
+ async getPageByUniqueId(id) {
22
+ const { dataSourceId, uniqueIdProperty } = await this.getDataSource();
23
+ const { results } = await this.client.dataSources.query({
24
+ data_source_id: dataSourceId,
25
+ filter: {
26
+ property: uniqueIdProperty,
27
+ unique_id: { equals: id },
28
+ },
29
+ page_size: 1,
30
+ });
31
+ const page = results.find(isFullPage);
32
+ return page ? new NotionPageObject(this.client, page) : null;
33
+ }
34
+ getDataSource() {
35
+ this.dataSource ??= this.resolveDataSource().catch((e) => {
36
+ this.dataSource = undefined;
37
+ throw e;
38
+ });
39
+ return this.dataSource;
40
+ }
41
+ async resolveDataSource() {
42
+ const database = await this.client.databases.retrieve({
43
+ database_id: this.databaseId,
44
+ });
45
+ if (!isFullDatabase(database)) {
46
+ throw new Error(`Notion database ${this.databaseId} is not accessible by this integration`);
47
+ }
48
+ const dataSources = await Promise.all(database.data_sources.map(({ id }) => this.client.dataSources.retrieve({ data_source_id: id })));
49
+ const resolved = dataSources.flatMap((dataSource) => {
50
+ if (!isFullDataSource(dataSource))
51
+ return [];
52
+ const uniqueIdProperty = Object.values(dataSource.properties).find((property) => property.type === "unique_id");
53
+ return uniqueIdProperty ?
54
+ [
55
+ {
56
+ dataSourceId: dataSource.id,
57
+ uniqueIdProperty: uniqueIdProperty.name,
58
+ },
59
+ ]
60
+ : [];
61
+ });
62
+ const [only, ...rest] = resolved;
63
+ if (!only) {
64
+ throw new Error(`Notion database ${this.databaseId} has no "ID" (unique_id) property`);
65
+ }
66
+ if (rest.length > 0) {
67
+ throw new Error(`Notion database ${this.databaseId} has ${resolved.length} data sources with an "ID" (unique_id) property; exactly one is supported`);
68
+ }
69
+ return only;
70
+ }
71
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./client";
2
+ export * from "./page";
3
+ export * from "./types";
@@ -0,0 +1,3 @@
1
+ export * from "./client";
2
+ export * from "./page";
3
+ export * from "./types";
@@ -0,0 +1,32 @@
1
+ import type { Client, PageObjectResponse } from "@notionhq/client";
2
+ import type { NotionPagePropertyUpdate } from "./types";
3
+ /**
4
+ * Wraps a single Notion page (a row in a database) and allows reading & updating its fields.
5
+ */
6
+ export declare class NotionPageObject {
7
+ private readonly client;
8
+ private page;
9
+ constructor(client: Client, page: PageObjectResponse);
10
+ get id(): string;
11
+ get url(): string;
12
+ /**
13
+ * Raw property values of the page, keyed by field name. Reflects the latest successful `saveField`.
14
+ */
15
+ get properties(): PageObjectResponse["properties"];
16
+ /**
17
+ * @returns `true` if the page has a field (property) named `name`.
18
+ */
19
+ hasField(name: string): boolean;
20
+ /**
21
+ * Persist a new value for the field `name` to Notion.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * await page.saveField("Status", { status: { name: "Done" } });
26
+ * await page.saveField("PR", { url: "https://github.com/tahminator/pipeline/pull/1" });
27
+ * ```
28
+ *
29
+ * @throws if the page has no field named `name`.
30
+ */
31
+ saveField(name: string, value: NotionPagePropertyUpdate): Promise<void>;
32
+ }
@@ -0,0 +1,53 @@
1
+ import { isFullPage } from "@notionhq/client";
2
+ /**
3
+ * Wraps a single Notion page (a row in a database) and allows reading & updating its fields.
4
+ */
5
+ export class NotionPageObject {
6
+ client;
7
+ page;
8
+ constructor(client, page) {
9
+ this.client = client;
10
+ this.page = page;
11
+ }
12
+ get id() {
13
+ return this.page.id;
14
+ }
15
+ get url() {
16
+ return this.page.url;
17
+ }
18
+ /**
19
+ * Raw property values of the page, keyed by field name. Reflects the latest successful `saveField`.
20
+ */
21
+ get properties() {
22
+ return this.page.properties;
23
+ }
24
+ /**
25
+ * @returns `true` if the page has a field (property) named `name`.
26
+ */
27
+ hasField(name) {
28
+ return Object.hasOwn(this.page.properties, name);
29
+ }
30
+ /**
31
+ * Persist a new value for the field `name` to Notion.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * await page.saveField("Status", { status: { name: "Done" } });
36
+ * await page.saveField("PR", { url: "https://github.com/tahminator/pipeline/pull/1" });
37
+ * ```
38
+ *
39
+ * @throws if the page has no field named `name`.
40
+ */
41
+ async saveField(name, value) {
42
+ if (!this.hasField(name)) {
43
+ throw new Error(`Notion page ${this.page.id} has no field named "${name}"`);
44
+ }
45
+ const updated = await this.client.pages.update({
46
+ page_id: this.page.id,
47
+ properties: { [name]: value },
48
+ });
49
+ if (isFullPage(updated)) {
50
+ this.page = updated;
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,19 @@
1
+ import type { UpdatePageParameters } from "@notionhq/client";
2
+ /**
3
+ * Value accepted by Notion when updating a single page property, e.g. `{ checkbox: true }`.
4
+ */
5
+ export type NotionPagePropertyUpdate = NonNullable<UpdatePageParameters["properties"]>[string];
6
+ export type NotionClientOpts = {
7
+ auth: {
8
+ /**
9
+ * Internal integration secret from https://www.notion.so/profile/integrations
10
+ *
11
+ * @note the integration must be added to the database via `...` -> `Connections`.
12
+ */
13
+ token: string;
14
+ };
15
+ /**
16
+ * Database ID or URL, e.g. the 32-char ID in `https://www.notion.so/<workspace>/<databaseId>?v=...`
17
+ */
18
+ databaseId: string;
19
+ };
File without changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "type": "module",
4
4
  "author": "Tahmid Ahmed",
5
5
  "description": "A collection of Bun shell scripts that can be re-used in various CICD pipelines.",
6
- "version": "1.0.89",
6
+ "version": "1.0.90-beta.gc63a80f4",
7
7
  "repository": {
8
8
  "url": "git+https://github.com/tahminator/pipeline.git"
9
9
  },
@@ -47,6 +47,7 @@
47
47
  "typescript": "^5"
48
48
  },
49
49
  "dependencies": {
50
+ "@notionhq/client": "^5.27.0",
50
51
  "@octokit/auth-app": "^8.2.0",
51
52
  "@octokit/rest": "^22.0.1",
52
53
  "@pulumi/pulumi": "^3.258.0",