wolli 0.0.4 → 0.0.6
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 +1 -8
- package/built-in/plugins/github/README.md +148 -0
- package/built-in/plugins/github/github-api.test.ts +260 -0
- package/built-in/plugins/github/github-api.ts +491 -0
- package/built-in/plugins/github/github-chat.ts +216 -0
- package/built-in/plugins/github/github-review.ts +102 -0
- package/built-in/plugins/github/github-workspace.ts +84 -0
- package/built-in/plugins/github/index.ts +795 -0
- package/built-in/plugins/github/package.json +14 -0
- package/built-in/plugins/slack/README.md +144 -0
- package/built-in/plugins/slack/index.ts +360 -0
- package/built-in/plugins/slack/package.json +13 -0
- package/built-in/plugins/slack/slack-chat.ts +59 -0
- package/dist/cli.js +5439 -5441
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,15 +30,8 @@ opens by interviewing you to work out its purpose, then writes its own
|
|
|
30
30
|
`SOUL.md` — its first line becomes the agent's description everywhere. Agents
|
|
31
31
|
and state live under `~/.wolli`.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
Agents
|
|
35
|
-
|
|
36
|
-
→ inbox Triage my email each morning, draft replies to the routine ones, flag what needs me.
|
|
37
|
-
scout Watch the repos and deps we ship; when a release or CVE needs action, open an issue and ping me.
|
|
38
|
-
ledger Track project spend across providers, reconcile invoices weekly, warn me before a budget tips over.
|
|
33
|
+
<img width="1272" height="647" alt="image" src="https://github.com/user-attachments/assets/f524eeef-b1a7-4b5d-babf-4f0b19d8463a" />
|
|
39
34
|
|
|
40
|
-
↑/↓ browse · enter chat · tab details · type to search commands · ctrl+c quit
|
|
41
|
-
```
|
|
42
35
|
|
|
43
36
|
## How it works
|
|
44
37
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# GitHub
|
|
2
|
+
|
|
3
|
+
Connect a wolli agent to GitHub. The integration **polls** GitHub's REST API (wolli has no
|
|
4
|
+
inbound webhook surface), gives each issue/PR conversation its own wolli session, and replies
|
|
5
|
+
in place as comments. Auth is a **GitHub App** — no public URL, no webhook, no TLS.
|
|
6
|
+
|
|
7
|
+
## How the bot behaves
|
|
8
|
+
|
|
9
|
+
The transport polls each watched repo on a timer and emits a generic event per new item; the
|
|
10
|
+
routing workflows decide what to act on based on the triggers you pick during onboarding.
|
|
11
|
+
|
|
12
|
+
| Trigger | Behavior |
|
|
13
|
+
|---------|----------|
|
|
14
|
+
| **mention** | When a comment on an issue or PR @mentions the App (`@<app-slug>`), that conversation gets a wolli session and the comment (mention stripped) is delivered to the agent. The App reacts `eyes` on the summoning comment to acknowledge it. If the mention is on a **pull request**, the PR's head commit is checked out under the agent's `workspace/` (see below) and the agent reviews the real source tree with its own file tools. |
|
|
15
|
+
| **auto** | When a pull request is opened or updated, the PR is checked out and a "review this PR" turn is seeded into the PR's session. Deduped on head SHA, so the same commits are reviewed once. |
|
|
16
|
+
|
|
17
|
+
The agent's reply is posted back as a comment on the same issue/PR.
|
|
18
|
+
|
|
19
|
+
### PR checkout
|
|
20
|
+
|
|
21
|
+
For a pull request, the `checkoutPullRequest` action fetches the head + base commits (`--depth 1`,
|
|
22
|
+
by SHA) into `workspace/reviews/<owner>__<repo>__<number>/` under the agent home, checks out the
|
|
23
|
+
head detached, and **configures no remote**. So the agent gets the full source tree at the head
|
|
24
|
+
commit — it can `read`/`grep` any file and `git diff <base> <head>` to see the change — but there
|
|
25
|
+
is no push target and the App token is never written to disk (it is passed only to the one `fetch`,
|
|
26
|
+
inside the daemon process, never to the agent). The checkout is refreshed in place on later turns.
|
|
27
|
+
|
|
28
|
+
> Note: in the default `local-os` sandbox the agent's writes are jailed to the agent home but the
|
|
29
|
+
> network is not restricted, and the host may carry your own git credentials. Removing the remote
|
|
30
|
+
> makes `push` a deliberate act rather than a footgun; a hard read-only guarantee needs a
|
|
31
|
+
> network-jailed sandbox (`WOLLI_SANDBOX=docker` with egress control) or a reviewer without `bash`. Comments the App itself
|
|
32
|
+
authored (`<app-slug>[bot]`), comments by any Bot account, and comments carrying wolli's own
|
|
33
|
+
`<!-- wolli:github -->` marker are ignored, so the bot never answers itself.
|
|
34
|
+
|
|
35
|
+
### What is polled
|
|
36
|
+
|
|
37
|
+
Four cheap repo-level "since"-pollable streams, each with its own cursor and a conditional
|
|
38
|
+
(ETag) request so an unchanged poll returns `304` and does not count against the rate limit:
|
|
39
|
+
|
|
40
|
+
| Stream | Endpoint |
|
|
41
|
+
|--------|----------|
|
|
42
|
+
| `issue_comment` | `GET /repos/{o}/{r}/issues/comments?since=` |
|
|
43
|
+
| `pull_request_review_comment` | `GET /repos/{o}/{r}/pulls/comments?since=` |
|
|
44
|
+
| `issues` | `GET /repos/{o}/{r}/issues?since=` (PRs filtered out) |
|
|
45
|
+
| `pull_request` | `GET /repos/{o}/{r}/pulls?sort=updated` (deduped by head SHA) |
|
|
46
|
+
|
|
47
|
+
## Session model
|
|
48
|
+
|
|
49
|
+
Each issue/PR conversation gets its **own wolli session**, bound by a `github:thread` tag of
|
|
50
|
+
the form `owner/repo#number`:
|
|
51
|
+
|
|
52
|
+
- **Inbound** — an @mention routes to the session tagged for that conversation. If none exists,
|
|
53
|
+
one is created and tagged on the spot. Histories never bleed across conversations.
|
|
54
|
+
- **Outbound** — the reply rides the producing session's tag, so the answer returns to the
|
|
55
|
+
conversation that started the turn.
|
|
56
|
+
|
|
57
|
+
## Setup
|
|
58
|
+
|
|
59
|
+
### 1. Create a GitHub App
|
|
60
|
+
|
|
61
|
+
Open **Settings → Developer settings → GitHub Apps → New GitHub App** (personal:
|
|
62
|
+
https://github.com/settings/apps/new, or your org's equivalent).
|
|
63
|
+
|
|
64
|
+
- **Webhook:** clear the **Active** checkbox — this integration polls, it does not receive webhooks.
|
|
65
|
+
- **Repository permissions:** **Contents** read-only, **Issues** read & write, **Pull requests**
|
|
66
|
+
read & write, **Metadata** read-only.
|
|
67
|
+
- Create the App, then under **Private keys** generate a key and download the `.pem`.
|
|
68
|
+
- Note the **App ID** shown on the App's settings page.
|
|
69
|
+
- **Install** the App (left sidebar → *Install App*) on the account/org that owns the repos you
|
|
70
|
+
want to watch, and grant it those repositories.
|
|
71
|
+
|
|
72
|
+
> **Proposed: one-click App creation from a manifest.** GitHub can pre-configure an App from a
|
|
73
|
+
> [manifest](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest)
|
|
74
|
+
> — the user clicks a single link, GitHub creates the App with the exact permissions below and
|
|
75
|
+
> **no webhook**, and hands back the App ID + private key automatically. This would replace the
|
|
76
|
+
> whole manual "create App → set permissions → uncheck webhook → generate key → copy App ID"
|
|
77
|
+
> dance above with a guided flow. Not yet implemented; the manifest POST-back needs a transient
|
|
78
|
+
> local callback, so it is tracked as a future onboarding enhancement.
|
|
79
|
+
|
|
80
|
+
### 2. Install and onboard in wolli
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
wolli <agent> plugins install ./built-in/plugins/github
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
On an interactive terminal this runs onboarding immediately. It asks for:
|
|
87
|
+
|
|
88
|
+
1. the **App ID**,
|
|
89
|
+
2. the **private key** — paste the PEM, or (recommended, since a PEM is multi-line) a reference
|
|
90
|
+
like `$GITHUB_APP_PRIVATE_KEY` or `!cat /path/to/key.pem`,
|
|
91
|
+
3. the **repositories** to watch, comma-separated as `owner/repo, owner/repo`,
|
|
92
|
+
4. two yes/no **trigger** questions (react to @mentions? auto-review PRs?).
|
|
93
|
+
|
|
94
|
+
Wolli verifies the credentials with a live `GET /app`, reads the App slug as the bot login, and
|
|
95
|
+
validates that the App is installed on each repo (dropping any it is not). If you installed
|
|
96
|
+
non-interactively, onboard later with `wolli <agent> plugins configure github`.
|
|
97
|
+
|
|
98
|
+
### 3. Restart the agent
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
wolli restart <agent>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The poll producer starts on the next daemon start, so restart once for the bot to come online.
|
|
105
|
+
|
|
106
|
+
## Configuration reference
|
|
107
|
+
|
|
108
|
+
Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under `github`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"github": {
|
|
113
|
+
"appId": "123456",
|
|
114
|
+
"privateKey": "$GITHUB_APP_PRIVATE_KEY",
|
|
115
|
+
"botLogin": "my-app",
|
|
116
|
+
"repositories": ["acme/widgets", "acme/docs"],
|
|
117
|
+
"triggers": ["mention", "auto"],
|
|
118
|
+
"pollIntervalMs": 60000
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Field | Required | Default | Purpose |
|
|
124
|
+
|-------|----------|---------|---------|
|
|
125
|
+
| `appId` | Yes | — | The GitHub App ID. |
|
|
126
|
+
| `privateKey` | Yes | — | The App's PEM private key. A `$ENV` / `!cmd` reference resolves on read. |
|
|
127
|
+
| `botLogin` | Yes | — | The App slug; used for @mention detection and self/loop filtering. Set by onboarding. |
|
|
128
|
+
| `repositories` | Yes | — | Repos to watch, as `owner/repo`. |
|
|
129
|
+
| `triggers` | Yes | — | Any of `"mention"`, `"auto"`. |
|
|
130
|
+
| `pollIntervalMs` | No | `60000` | Poll interval floor in ms. The effective interval also honors GitHub's `X-Poll-Interval`. |
|
|
131
|
+
|
|
132
|
+
## Not supported
|
|
133
|
+
|
|
134
|
+
- **Webhooks / real-time delivery** — the transport polls, so there is up to one poll interval
|
|
135
|
+
of latency, and the daemon must be running to see activity (a restart resumes from the stored
|
|
136
|
+
cursor, so nothing is lost while it was up).
|
|
137
|
+
- **True webhook `action`s** — the `issues` and `pull_request` streams carry a *best-effort*
|
|
138
|
+
`action` (`opened` / `edited` / `closed` / `synchronize`) synthesized from state and
|
|
139
|
+
timestamps; polling cannot see the exact webhook action.
|
|
140
|
+
- **`pull_request_review` and `push`** — no clean repo-level "since" cursor, so they are not polled.
|
|
141
|
+
- **Multi-line PEM in the input prompt** — paste a `$ENV` / `!cmd` reference instead.
|
|
142
|
+
|
|
143
|
+
## Security
|
|
144
|
+
|
|
145
|
+
- The App's private key grants control of the App — never commit it. Prefer a `$ENV` / `!cmd`
|
|
146
|
+
reference over pasting the PEM. Wolli writes `integrations.json` with mode `0600`.
|
|
147
|
+
- The App only sees repos it was explicitly installed on and granted, scoped to the permissions
|
|
148
|
+
above. Anyone who can comment on a watched repo can drive the agent via `@mention`.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { createPublicKey, createVerify, generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
chunkComment,
|
|
5
|
+
clearInstallationTokenCache,
|
|
6
|
+
createAppJwt,
|
|
7
|
+
createInstallationToken,
|
|
8
|
+
extractMention,
|
|
9
|
+
githubRequest,
|
|
10
|
+
GITHUB_COMMENT_MARKER,
|
|
11
|
+
isIgnoredInboundComment,
|
|
12
|
+
normalizeIssue,
|
|
13
|
+
normalizeIssueComment,
|
|
14
|
+
normalizePullRequest,
|
|
15
|
+
normalizeReviewComment,
|
|
16
|
+
parseThreadTag,
|
|
17
|
+
selectPullRequests,
|
|
18
|
+
selectSinceItems,
|
|
19
|
+
} from "./github-api.ts";
|
|
20
|
+
|
|
21
|
+
function keyPair(): { privateKey: string; publicKey: string } {
|
|
22
|
+
const generated = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
23
|
+
return {
|
|
24
|
+
privateKey: generated.privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
|
25
|
+
publicKey: generated.publicKey.export({ format: "pem", type: "spki" }).toString(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("createAppJwt", () => {
|
|
30
|
+
it("produces a valid RS256 JWT with the right iss/iat/exp", () => {
|
|
31
|
+
const { privateKey, publicKey } = keyPair();
|
|
32
|
+
const nowMs = Date.parse("2026-06-01T00:00:00Z");
|
|
33
|
+
const jwt = createAppJwt("12345", privateKey, nowMs);
|
|
34
|
+
const [header, payload, signature] = jwt.split(".");
|
|
35
|
+
|
|
36
|
+
expect(JSON.parse(Buffer.from(header, "base64url").toString("utf8"))).toMatchObject({
|
|
37
|
+
alg: "RS256",
|
|
38
|
+
typ: "JWT",
|
|
39
|
+
});
|
|
40
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
41
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
42
|
+
expect(claims.iss).toBe("12345");
|
|
43
|
+
expect(claims.iat).toBe(nowSeconds - 60);
|
|
44
|
+
expect(claims.exp).toBe(nowSeconds + 600);
|
|
45
|
+
|
|
46
|
+
const verifier = createVerify("RSA-SHA256");
|
|
47
|
+
verifier.update(`${header}.${payload}`);
|
|
48
|
+
expect(verifier.verify(createPublicKey(publicKey), Buffer.from(signature, "base64url"))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("createInstallationToken", () => {
|
|
53
|
+
beforeEach(() => clearInstallationTokenCache());
|
|
54
|
+
|
|
55
|
+
it("exchanges and caches installation tokens", async () => {
|
|
56
|
+
const { privateKey } = keyPair();
|
|
57
|
+
const fetchMock = vi.fn().mockResolvedValue(
|
|
58
|
+
new Response(JSON.stringify({ token: "ghs_installation", expires_at: "2099-06-01T01:00:00Z" })),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const first = await createInstallationToken({ appId: "1", privateKey }, 99, {
|
|
62
|
+
baseUrl: "https://github.test",
|
|
63
|
+
fetchImpl: fetchMock,
|
|
64
|
+
});
|
|
65
|
+
const second = await createInstallationToken({ appId: "1", privateKey }, 99, {
|
|
66
|
+
baseUrl: "https://github.test",
|
|
67
|
+
fetchImpl: fetchMock,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(first).toBe("ghs_installation");
|
|
71
|
+
expect(second).toBe("ghs_installation");
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(fetchMock.mock.calls[0][0]).toBe("https://github.test/app/installations/99/access_tokens");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("githubRequest", () => {
|
|
78
|
+
it("returns a 304 without throwing so a conditional poll can skip", async () => {
|
|
79
|
+
// The Response constructor forbids a 304 (null-body) status, so mock the shape githubRequest reads.
|
|
80
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
81
|
+
ok: false,
|
|
82
|
+
status: 304,
|
|
83
|
+
headers: new Headers(),
|
|
84
|
+
text: async () => "",
|
|
85
|
+
});
|
|
86
|
+
const res = await githubRequest("GET", "/repos/o/r/issues/comments", {
|
|
87
|
+
token: "t",
|
|
88
|
+
etag: '"abc"',
|
|
89
|
+
fetchImpl: fetchMock,
|
|
90
|
+
});
|
|
91
|
+
expect(res.status).toBe(304);
|
|
92
|
+
expect(new Headers(fetchMock.mock.calls[0][1].headers).get("if-none-match")).toBe('"abc"');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("surfaces the X-Poll-Interval header in ms and the etag", async () => {
|
|
96
|
+
const fetchMock = vi
|
|
97
|
+
.fn()
|
|
98
|
+
.mockResolvedValue(new Response("[]", { status: 200, headers: { etag: '"e"', "x-poll-interval": "90" } }));
|
|
99
|
+
const res = await githubRequest("GET", "/x", { fetchImpl: fetchMock });
|
|
100
|
+
expect(res.etag).toBe('"e"');
|
|
101
|
+
expect(res.pollIntervalMs).toBe(90_000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("throws on a non-2xx that is not 304", async () => {
|
|
105
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response("nope", { status: 404 }));
|
|
106
|
+
await expect(githubRequest("GET", "/x", { fetchImpl: fetchMock })).rejects.toThrow(/HTTP 404/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("normalizers", () => {
|
|
111
|
+
it("marks a PR timeline comment and reads the issue number from issue_url", () => {
|
|
112
|
+
const payload = normalizeIssueComment("o/r", {
|
|
113
|
+
id: 5,
|
|
114
|
+
body: "hi",
|
|
115
|
+
html_url: "https://github.com/o/r/pull/12#issuecomment-5",
|
|
116
|
+
issue_url: "https://api.github.com/repos/o/r/issues/12",
|
|
117
|
+
user: { login: "alice", type: "User" },
|
|
118
|
+
});
|
|
119
|
+
expect(payload).toMatchObject({
|
|
120
|
+
repo: "o/r",
|
|
121
|
+
isPullRequest: true,
|
|
122
|
+
issueNumber: 12,
|
|
123
|
+
commentId: 5,
|
|
124
|
+
authorLogin: "alice",
|
|
125
|
+
authorType: "User",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("treats an /issues/ comment as not a pull request", () => {
|
|
130
|
+
const payload = normalizeIssueComment("o/r", {
|
|
131
|
+
id: 6,
|
|
132
|
+
html_url: "https://github.com/o/r/issues/7#issuecomment-6",
|
|
133
|
+
issue_url: "https://api.github.com/repos/o/r/issues/7",
|
|
134
|
+
});
|
|
135
|
+
expect(payload.isPullRequest).toBe(false);
|
|
136
|
+
expect(payload.issueNumber).toBe(7);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads the PR number from a review comment's pull_request_url", () => {
|
|
140
|
+
const payload = normalizeReviewComment("o/r", {
|
|
141
|
+
id: 9,
|
|
142
|
+
in_reply_to_id: 8,
|
|
143
|
+
pull_request_url: "https://api.github.com/repos/o/r/pulls/34",
|
|
144
|
+
user: { login: "bob", type: "User" },
|
|
145
|
+
});
|
|
146
|
+
expect(payload).toMatchObject({ pullRequestNumber: 34, commentId: 9, inReplyToId: 8 });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("synthesizes a best-effort action for issues", () => {
|
|
150
|
+
expect(normalizeIssue("o/r", { number: 1, created_at: "t", updated_at: "t" }).action).toBe("opened");
|
|
151
|
+
expect(normalizeIssue("o/r", { number: 1, created_at: "t", updated_at: "u" }).action).toBe("edited");
|
|
152
|
+
expect(normalizeIssue("o/r", { number: 1, state: "closed" }).action).toBe("closed");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("reads the head SHA and draft flag for pull requests", () => {
|
|
156
|
+
const payload = normalizePullRequest("o/r", {
|
|
157
|
+
number: 3,
|
|
158
|
+
head: { sha: "deadbeef" },
|
|
159
|
+
draft: true,
|
|
160
|
+
state: "open",
|
|
161
|
+
created_at: "t",
|
|
162
|
+
updated_at: "t",
|
|
163
|
+
user: { login: "carol" },
|
|
164
|
+
});
|
|
165
|
+
expect(payload).toMatchObject({ pullRequestNumber: 3, headSha: "deadbeef", draft: true, action: "opened" });
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("mention + loop gating", () => {
|
|
170
|
+
it("strips the bot mention and returns the message", () => {
|
|
171
|
+
expect(extractMention("@mybot please review", "mybot")).toEqual({ message: "please review" });
|
|
172
|
+
expect(extractMention("hey @mybot look here", "mybot")).toEqual({ message: "hey look here" });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns null when the bot is not mentioned", () => {
|
|
176
|
+
expect(extractMention("no mention here", "mybot")).toBeNull();
|
|
177
|
+
expect(extractMention("@other do it", "mybot")).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("ignores our own marker, other bots, and our own bot login", () => {
|
|
181
|
+
expect(isIgnoredInboundComment(`done ${GITHUB_COMMENT_MARKER}`, "someone", "User", "mybot")).toBe(true);
|
|
182
|
+
expect(isIgnoredInboundComment("x", "some-bot", "Bot", "mybot")).toBe(true);
|
|
183
|
+
expect(isIgnoredInboundComment("x", "mybot[bot]", "User", "mybot")).toBe(true);
|
|
184
|
+
expect(isIgnoredInboundComment("@mybot hi", "human", "User", "mybot")).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("chunkComment", () => {
|
|
189
|
+
it("returns a single chunk when short", () => {
|
|
190
|
+
expect(chunkComment("hello", 100)).toEqual(["hello"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("splits over the limit into bounded chunks", () => {
|
|
194
|
+
const body = "a".repeat(250);
|
|
195
|
+
const chunks = chunkComment(body, 100);
|
|
196
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
197
|
+
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("parseThreadTag", () => {
|
|
202
|
+
it("splits owner/repo#number", () => {
|
|
203
|
+
expect(parseThreadTag("acme/widgets#42")).toEqual({ repo: "acme/widgets", number: 42 });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("selectSinceItems", () => {
|
|
208
|
+
it("emits strictly-newer items and advances the cursor", () => {
|
|
209
|
+
const result = selectSinceItems(
|
|
210
|
+
[
|
|
211
|
+
{ id: 1, ts: "2026-01-01T00:00:00Z" },
|
|
212
|
+
{ id: 2, ts: "2026-01-01T00:00:05Z" },
|
|
213
|
+
],
|
|
214
|
+
"2026-01-01T00:00:00Z",
|
|
215
|
+
[1],
|
|
216
|
+
);
|
|
217
|
+
expect(result.toEmit.map((i) => i.id)).toEqual([2]);
|
|
218
|
+
expect(result.nextCursor).toBe("2026-01-01T00:00:05Z");
|
|
219
|
+
expect(result.nextSeenIds).toEqual([2]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("emits a genuinely-new same-second item but not an already-seen one", () => {
|
|
223
|
+
const result = selectSinceItems(
|
|
224
|
+
[
|
|
225
|
+
{ id: 1, ts: "2026-01-01T00:00:00Z" },
|
|
226
|
+
{ id: 2, ts: "2026-01-01T00:00:00Z" },
|
|
227
|
+
],
|
|
228
|
+
"2026-01-01T00:00:00Z",
|
|
229
|
+
[1],
|
|
230
|
+
);
|
|
231
|
+
expect(result.toEmit.map((i) => i.id)).toEqual([2]);
|
|
232
|
+
expect(result.nextCursor).toBe("2026-01-01T00:00:00Z");
|
|
233
|
+
expect(result.nextSeenIds.sort()).toEqual([1, 2]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("emits nothing on a boundary re-fetch of already-seen items", () => {
|
|
237
|
+
const result = selectSinceItems([{ id: 1, ts: "2026-01-01T00:00:00Z" }], "2026-01-01T00:00:00Z", [1]);
|
|
238
|
+
expect(result.toEmit).toEqual([]);
|
|
239
|
+
expect(result.nextCursor).toBe("2026-01-01T00:00:00Z");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("selectPullRequests", () => {
|
|
244
|
+
it("emits new and head-advanced PRs, skips unchanged, and rebuilds the head map", () => {
|
|
245
|
+
const result = selectPullRequests(
|
|
246
|
+
[
|
|
247
|
+
{ number: 1, headSha: "aaa" },
|
|
248
|
+
{ number: 2, headSha: "ccc" },
|
|
249
|
+
],
|
|
250
|
+
{ "1": "aaa", "2": "bbb" },
|
|
251
|
+
);
|
|
252
|
+
expect(result.toEmit.map((p) => p.number)).toEqual([2]);
|
|
253
|
+
expect(result.nextPrHeads).toEqual({ "1": "aaa", "2": "ccc" });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("emits a brand-new PR", () => {
|
|
257
|
+
const result = selectPullRequests([{ number: 9, headSha: "zzz" }], {});
|
|
258
|
+
expect(result.toEmit.map((p) => p.number)).toEqual([9]);
|
|
259
|
+
});
|
|
260
|
+
});
|