Skip to content

Commit 0e3f571

Browse files
committed
feat(github-adapter): issue-write helpers (title, milestone, assignees, state)
1 parent 8f88ec3 commit 0e3f571

3 files changed

Lines changed: 252 additions & 0 deletions

File tree

packages/integrations/github/src/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import type {
1313
UpdateLabelsInput,
1414
} from "./types"
1515
import { listRepoLabels, listAssignableUsers, listMilestones } from "./repo-read"
16+
export {
17+
updateIssueTitle,
18+
updateIssueMilestone,
19+
addAssignees,
20+
removeAssignees,
21+
updateIssueState,
22+
} from "./issue-writes"
23+
export type { IssueStateUpdate } from "./issue-writes"
1624

1725
export function createInstallationClient(
1826
opts: InstallationClientOptions,
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// packages/integrations/github/src/issue-writes.test.ts
2+
import { describe, expect, test } from "bun:test"
3+
import type { Octokit } from "@octokit/rest"
4+
import {
5+
addAssignees,
6+
removeAssignees,
7+
updateIssueMilestone,
8+
updateIssueState,
9+
updateIssueTitle,
10+
} from "./issue-writes"
11+
12+
/** Build a minimal fake Octokit that records calls */
13+
function makeFakeOctokit() {
14+
const calls: {
15+
update: Array<Record<string, unknown>>
16+
addAssignees: Array<Record<string, unknown>>
17+
removeAssignees: Array<Record<string, unknown>>
18+
} = { update: [], addAssignees: [], removeAssignees: [] }
19+
20+
const octokit = {
21+
rest: {
22+
issues: {
23+
update: async (args: Record<string, unknown>) => {
24+
calls.update.push(args)
25+
return { data: {} }
26+
},
27+
addAssignees: async (args: Record<string, unknown>) => {
28+
calls.addAssignees.push(args)
29+
return { data: {} }
30+
},
31+
removeAssignees: async (args: Record<string, unknown>) => {
32+
calls.removeAssignees.push(args)
33+
return { data: {} }
34+
},
35+
},
36+
},
37+
} as unknown as Octokit
38+
39+
return { octokit, calls }
40+
}
41+
42+
describe("updateIssueTitle", () => {
43+
test("calls update with owner, repo, issue_number, title", async () => {
44+
const { octokit, calls } = makeFakeOctokit()
45+
await updateIssueTitle(octokit, "acme", "frontend", 42, "New title")
46+
expect(calls.update.length).toBe(1)
47+
expect(calls.update[0]).toMatchObject({
48+
owner: "acme",
49+
repo: "frontend",
50+
issue_number: 42,
51+
title: "New title",
52+
})
53+
})
54+
})
55+
56+
describe("updateIssueMilestone", () => {
57+
test("calls update with milestone number", async () => {
58+
const { octokit, calls } = makeFakeOctokit()
59+
await updateIssueMilestone(octokit, "acme", "frontend", 42, 7)
60+
expect(calls.update.length).toBe(1)
61+
expect(calls.update[0]).toMatchObject({
62+
owner: "acme",
63+
repo: "frontend",
64+
issue_number: 42,
65+
milestone: 7,
66+
})
67+
})
68+
69+
test("calls update with null to clear milestone", async () => {
70+
const { octokit, calls } = makeFakeOctokit()
71+
await updateIssueMilestone(octokit, "acme", "frontend", 42, null)
72+
expect(calls.update.length).toBe(1)
73+
expect(calls.update[0]).toMatchObject({
74+
owner: "acme",
75+
repo: "frontend",
76+
issue_number: 42,
77+
milestone: null,
78+
})
79+
})
80+
})
81+
82+
describe("addAssignees", () => {
83+
test("calls addAssignees with provided logins", async () => {
84+
const { octokit, calls } = makeFakeOctokit()
85+
await addAssignees(octokit, "acme", "frontend", 42, ["alice", "bob"])
86+
expect(calls.addAssignees.length).toBe(1)
87+
expect(calls.addAssignees[0]).toMatchObject({
88+
owner: "acme",
89+
repo: "frontend",
90+
issue_number: 42,
91+
assignees: ["alice", "bob"],
92+
})
93+
})
94+
95+
test("no-op on empty logins", async () => {
96+
const { octokit, calls } = makeFakeOctokit()
97+
await addAssignees(octokit, "acme", "frontend", 42, [])
98+
expect(calls.addAssignees.length).toBe(0)
99+
})
100+
})
101+
102+
describe("removeAssignees", () => {
103+
test("calls removeAssignees with provided logins", async () => {
104+
const { octokit, calls } = makeFakeOctokit()
105+
await removeAssignees(octokit, "acme", "frontend", 42, ["carol"])
106+
expect(calls.removeAssignees.length).toBe(1)
107+
expect(calls.removeAssignees[0]).toMatchObject({
108+
owner: "acme",
109+
repo: "frontend",
110+
issue_number: 42,
111+
assignees: ["carol"],
112+
})
113+
})
114+
115+
test("no-op on empty logins", async () => {
116+
const { octokit, calls } = makeFakeOctokit()
117+
await removeAssignees(octokit, "acme", "frontend", 42, [])
118+
expect(calls.removeAssignees.length).toBe(0)
119+
})
120+
})
121+
122+
describe("updateIssueState", () => {
123+
test("closed with state_reason passed through", async () => {
124+
const { octokit, calls } = makeFakeOctokit()
125+
await updateIssueState(octokit, "acme", "frontend", 42, {
126+
state: "closed",
127+
stateReason: "not_planned",
128+
})
129+
expect(calls.update.length).toBe(1)
130+
expect(calls.update[0]).toMatchObject({
131+
owner: "acme",
132+
repo: "frontend",
133+
issue_number: 42,
134+
state: "closed",
135+
state_reason: "not_planned",
136+
})
137+
})
138+
139+
test("open with reopened reason", async () => {
140+
const { octokit, calls } = makeFakeOctokit()
141+
await updateIssueState(octokit, "acme", "frontend", 42, {
142+
state: "open",
143+
stateReason: "reopened",
144+
})
145+
expect(calls.update.length).toBe(1)
146+
expect(calls.update[0]).toMatchObject({
147+
owner: "acme",
148+
repo: "frontend",
149+
issue_number: 42,
150+
state: "open",
151+
state_reason: "reopened",
152+
})
153+
})
154+
155+
test("open with null stateReason omits state_reason from call", async () => {
156+
const { octokit, calls } = makeFakeOctokit()
157+
await updateIssueState(octokit, "acme", "frontend", 42, {
158+
state: "open",
159+
stateReason: null,
160+
})
161+
expect(calls.update.length).toBe(1)
162+
expect(calls.update[0]?.state).toBe("open")
163+
expect(calls.update[0]?.state_reason).toBeUndefined()
164+
})
165+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// packages/integrations/github/src/issue-writes.ts
2+
import type { Octokit } from "@octokit/rest"
3+
4+
export async function updateIssueTitle(
5+
client: Octokit,
6+
owner: string,
7+
repo: string,
8+
issueNumber: number,
9+
title: string,
10+
): Promise<void> {
11+
await client.rest.issues.update({ owner, repo, issue_number: issueNumber, title })
12+
}
13+
14+
export async function updateIssueMilestone(
15+
client: Octokit,
16+
owner: string,
17+
repo: string,
18+
issueNumber: number,
19+
milestoneNumber: number | null,
20+
): Promise<void> {
21+
await client.rest.issues.update({
22+
owner,
23+
repo,
24+
issue_number: issueNumber,
25+
milestone: milestoneNumber,
26+
})
27+
}
28+
29+
export async function addAssignees(
30+
client: Octokit,
31+
owner: string,
32+
repo: string,
33+
issueNumber: number,
34+
logins: string[],
35+
): Promise<void> {
36+
if (logins.length === 0) return
37+
await client.rest.issues.addAssignees({
38+
owner,
39+
repo,
40+
issue_number: issueNumber,
41+
assignees: logins,
42+
})
43+
}
44+
45+
export async function removeAssignees(
46+
client: Octokit,
47+
owner: string,
48+
repo: string,
49+
issueNumber: number,
50+
logins: string[],
51+
): Promise<void> {
52+
if (logins.length === 0) return
53+
await client.rest.issues.removeAssignees({
54+
owner,
55+
repo,
56+
issue_number: issueNumber,
57+
assignees: logins,
58+
})
59+
}
60+
61+
export type IssueStateUpdate =
62+
| { state: "open"; stateReason: "reopened" | null }
63+
| { state: "closed"; stateReason: "completed" | "not_planned" }
64+
65+
export async function updateIssueState(
66+
client: Octokit,
67+
owner: string,
68+
repo: string,
69+
issueNumber: number,
70+
update: IssueStateUpdate,
71+
): Promise<void> {
72+
await client.rest.issues.update({
73+
owner,
74+
repo,
75+
issue_number: issueNumber,
76+
state: update.state,
77+
state_reason: update.stateReason ?? undefined,
78+
})
79+
}

0 commit comments

Comments
 (0)