Skip to content

Commit bf99297

Browse files
committed
Various improvements to the diff view
1 parent 59b3a3d commit bf99297

27 files changed

+471
-231
lines changed
Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { NextRequest, NextResponse } from "next/server"
2-
import { session, userGitHubClient } from "@/composition"
1+
import { NextRequest, NextResponse } from "next/server"
2+
import { session } from "@/composition"
33
import { makeUnauthenticatedAPIErrorResponse } from "@/common"
4-
import { execSync } from "child_process"
4+
import { diffCalculator } from "@/composition"
55

66
interface GetDiffParams {
77
owner: string
@@ -17,38 +17,30 @@ export async function GET(req: NextRequest, { params }: { params: Promise<GetDif
1717

1818
const { path: paramsPath, owner, repository } = await params
1919
const path = paramsPath.join("/")
20-
21-
const fromRef = req.nextUrl.searchParams.get("from")
20+
2221
const toRef = req.nextUrl.searchParams.get("to")
23-
24-
if (!fromRef || !toRef) {
25-
return NextResponse.json({ error: "Missing from/to parameters" }, { status: 400 })
22+
const baseRefOid = req.nextUrl.searchParams.get("baseRefOid")
23+
24+
if (!toRef) {
25+
return NextResponse.json({ error: "Missing 'to' parameter" }, { status: 400 })
26+
}
27+
28+
if (!baseRefOid) {
29+
return NextResponse.json({ error: "Missing 'baseRefOid' parameter" }, { status: 400 })
2630
}
2731

28-
const fullRepositoryName = repository + "-openapi"
29-
30-
const spec1 = await userGitHubClient.getRepositoryContent({
31-
repositoryOwner: owner,
32-
repositoryName: fullRepositoryName,
33-
path: path,
34-
ref: fromRef
35-
})
36-
37-
const spec2 = await userGitHubClient.getRepositoryContent({
38-
repositoryOwner: owner,
39-
repositoryName: fullRepositoryName,
40-
path: path,
41-
ref: toRef
42-
})
43-
44-
const result = execSync(`oasdiff changelog --format json "${spec1.downloadURL}" "${spec2.downloadURL}"`, { encoding: 'utf8' })
45-
46-
47-
const diffData = JSON.parse(result)
48-
49-
return NextResponse.json({
50-
from: fromRef,
51-
to: toRef,
52-
changes: diffData
53-
})
54-
}
32+
try {
33+
const diff = await diffCalculator.calculateDiff(
34+
owner,
35+
repository,
36+
path,
37+
baseRefOid,
38+
toRef
39+
)
40+
41+
return NextResponse.json(diff)
42+
} catch (error) {
43+
const message = error instanceof Error ? error.message : "Unknown error while calculating diff"
44+
return NextResponse.json({ error: message }, { status: 500 })
45+
}
46+
}

src/common/github/GitHubClient.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import IGitHubClient, {
88
GetPullRequestFilesRequest,
99
AddCommentToPullRequestRequest,
1010
UpdatePullRequestCommentRequest,
11+
CompareCommitsRequest,
12+
CompareCommitsResponse,
1113
RepositoryContent,
1214
PullRequestComment,
1315
PullRequestFile
@@ -119,4 +121,15 @@ export default class GitHubClient implements IGitHubClient {
119121
body: request.body
120122
})
121123
}
124+
125+
async compareCommitsWithBasehead(request: CompareCommitsRequest): Promise<CompareCommitsResponse> {
126+
const oauthToken = await this.oauthTokenDataSource.getOAuthToken()
127+
const octokit = new Octokit({ auth: oauthToken.accessToken })
128+
const response = await octokit.rest.repos.compareCommitsWithBasehead({
129+
owner: request.repositoryOwner,
130+
repo: request.repositoryName,
131+
basehead: `${request.baseRefOid}...${request.headRefOid}`
132+
})
133+
return { mergeBaseSha: response.data.merge_base_commit.sha }
134+
}
122135
}

src/common/github/IGitHubClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,23 @@ export type UpdatePullRequestCommentRequest = {
7070
readonly body: string
7171
}
7272

73+
export type CompareCommitsRequest = {
74+
readonly repositoryOwner: string
75+
readonly repositoryName: string
76+
readonly baseRefOid: string
77+
readonly headRefOid: string
78+
}
79+
80+
export type CompareCommitsResponse = {
81+
readonly mergeBaseSha: string
82+
}
83+
7384
export default interface IGitHubClient {
7485
graphql(request: GraphQLQueryRequest): Promise<GraphQlQueryResponse>
7586
getRepositoryContent(request: GetRepositoryContentRequest): Promise<RepositoryContent>
7687
getPullRequestFiles(request: GetPullRequestFilesRequest): Promise<PullRequestFile[]>
7788
getPullRequestComments(request: GetPullRequestCommentsRequest): Promise<PullRequestComment[]>
7889
addCommentToPullRequest(request: AddCommentToPullRequestRequest): Promise<void>
7990
updatePullRequestComment(request: UpdatePullRequestCommentRequest): Promise<void>
91+
compareCommitsWithBasehead(request: CompareCommitsRequest): Promise<CompareCommitsResponse>
8092
}

src/common/github/OAuthTokenRefreshingGitHubClient.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import IGitHubClient, {
77
AddCommentToPullRequestRequest,
88
UpdatePullRequestCommentRequest,
99
GetPullRequestFilesRequest,
10+
CompareCommitsRequest,
11+
CompareCommitsResponse,
1012
RepositoryContent,
1113
PullRequestComment,
1214
PullRequestFile
@@ -76,7 +78,13 @@ export default class OAuthTokenRefreshingGitHubClient implements IGitHubClient {
7678
return await this.gitHubClient.updatePullRequestComment(request)
7779
})
7880
}
79-
81+
82+
async compareCommitsWithBasehead(request: CompareCommitsRequest): Promise<CompareCommitsResponse> {
83+
return await this.send(async () => {
84+
return await this.gitHubClient.compareCommitsWithBasehead(request)
85+
})
86+
}
87+
8088
private async send<T>(fn: () => Promise<T>): Promise<T> {
8189
const oauthToken = await this.oauthTokenDataSource.getOAuthToken()
8290
try {

src/common/github/RepoRestrictedGitHubClient.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import {
2-
IGitHubClient,
3-
AddCommentToPullRequestRequest,
4-
GetPullRequestCommentsRequest,
5-
GetPullRequestFilesRequest,
6-
GetRepositoryContentRequest,
7-
GraphQLQueryRequest,
8-
GraphQlQueryResponse,
9-
PullRequestComment,
10-
PullRequestFile,
11-
RepositoryContent,
12-
UpdatePullRequestCommentRequest
1+
import {
2+
IGitHubClient,
3+
AddCommentToPullRequestRequest,
4+
GetPullRequestCommentsRequest,
5+
GetPullRequestFilesRequest,
6+
GetRepositoryContentRequest,
7+
GraphQLQueryRequest,
8+
GraphQlQueryResponse,
9+
PullRequestComment,
10+
PullRequestFile,
11+
RepositoryContent,
12+
UpdatePullRequestCommentRequest,
13+
CompareCommitsRequest,
14+
CompareCommitsResponse
1315
} from "@/common";
1416

1517
export class RepoRestrictedGitHubClient implements IGitHubClient {
@@ -54,6 +56,11 @@ export class RepoRestrictedGitHubClient implements IGitHubClient {
5456
return this.gitHubClient.updatePullRequestComment(request);
5557
}
5658

59+
compareCommitsWithBasehead(request: CompareCommitsRequest): Promise<CompareCommitsResponse> {
60+
if (!this.isRepositoryNameValid(request.repositoryName)) return Promise.reject(new Error("Invalid repository name"));
61+
return this.gitHubClient.compareCommitsWithBasehead(request);
62+
}
63+
5764
private isRepositoryNameValid(repositoryName: string): boolean {
5865
return repositoryName.endsWith(this.repositoryNameSuffix);
5966
}

src/composition.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import {
5353
import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHubClient"
5454
import RsaEncryptionService from "./features/encrypt/EncryptionService"
5555
import RemoteConfigEncoder from "./features/projects/domain/RemoteConfigEncoder"
56+
import { OasDiffCalculator } from "./features/diff/data/OasDiffCalculator"
57+
import { IOasDiffCalculator } from "./features/diff/data/IOasDiffCalculator"
5658

5759
const gitHubAppCredentials = {
5860
appId: env.getOrThrow("GITHUB_APP_ID"),
@@ -230,4 +232,6 @@ export const gitHubHookHandler = new GitHubHookHandler({
230232
})
231233
})
232234
})
233-
})
235+
})
236+
237+
export const diffCalculator: IOasDiffCalculator = new OasDiffCalculator(gitHubClient)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { DiffChange } from "../domain/DiffChange";
2+
3+
export interface DiffResult {
4+
from: string
5+
to: string
6+
changes: DiffChange[]
7+
error?: string | null
8+
}
9+
10+
export interface IOasDiffCalculator {
11+
calculateDiff(
12+
owner: string,
13+
repository: string,
14+
path: string,
15+
baseRefOid: string,
16+
toRef: string
17+
): Promise<DiffResult>;
18+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { execFileSync } from "child_process"
2+
import { DiffChange } from "@/features/diff/domain/DiffChange"
3+
import { GitHubClient } from "@/common"
4+
import { DiffResult, IOasDiffCalculator } from "./IOasDiffCalculator"
5+
6+
export class OasDiffCalculator implements IOasDiffCalculator {
7+
constructor(private readonly githubClient: GitHubClient) {}
8+
9+
async calculateDiff(
10+
owner: string,
11+
repository: string,
12+
path: string,
13+
baseRefOid: string,
14+
toRef: string
15+
): Promise<DiffResult> {
16+
// Calculate merge-base for diff
17+
const mergeBaseResult = await this.githubClient.compareCommitsWithBasehead({
18+
repositoryOwner: owner,
19+
repositoryName: repository,
20+
baseRefOid: baseRefOid,
21+
headRefOid: toRef
22+
})
23+
const fromRef = mergeBaseResult.mergeBaseSha
24+
25+
// If comparing same refs, return empty diff
26+
if (fromRef === toRef) {
27+
return {
28+
from: fromRef,
29+
to: toRef,
30+
changes: []
31+
}
32+
}
33+
34+
// Fetch spec content from both refs
35+
const spec1 = await this.githubClient.getRepositoryContent({
36+
repositoryOwner: owner,
37+
repositoryName: repository,
38+
path: path,
39+
ref: fromRef
40+
})
41+
42+
const spec2 = await this.githubClient.getRepositoryContent({
43+
repositoryOwner: owner,
44+
repositoryName: repository,
45+
path: path,
46+
ref: toRef
47+
})
48+
49+
// Execute oasdiff
50+
const diffData = (() => {
51+
try {
52+
const result = execFileSync(
53+
"oasdiffz",
54+
["changelog", "--format", "json", spec1.downloadURL, spec2.downloadURL],
55+
{ encoding: "utf8" }
56+
)
57+
return JSON.parse(result) as DiffChange[]
58+
} catch (error) {
59+
const errorMessage =
60+
error instanceof Error
61+
? error.message
62+
: "Unknown error when executing OpenAPI diff command"
63+
throw new Error(
64+
`Failed to execute OpenAPI diff tool. Please ensure "oasdiff" is installed and available in PATH. (${errorMessage})`
65+
)
66+
}
67+
})()
68+
69+
return {
70+
from: fromRef,
71+
to: toRef,
72+
changes: diffData,
73+
error: null
74+
}
75+
}
76+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { z } from "zod";
2+
3+
export const DiffChangeSchema = z.object({
4+
id: z.string(),
5+
text: z.string(),
6+
level: z.number(),
7+
operation: z.string().optional(),
8+
operationId: z.string().optional(),
9+
path: z.string().optional(),
10+
source: z.string().optional(),
11+
section: z.string().optional(),
12+
});
13+
14+
export type DiffChange = z.infer<typeof DiffChangeSchema>;

src/features/projects/data/GitHubProjectDataSource.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
132132
ref: ref.id
133133
}),
134134
editURL: `https://github.com/${ownerName}/${repositoryName}/edit/${ref.name}/${file.name}`,
135+
diffURL: this.getGitHubDiffURL({
136+
ownerName,
137+
repositoryName,
138+
path: file.name,
139+
baseRefOid: ref.baseRefOid,
140+
headRefOid: ref.id
141+
}),
135142
isDefault: false // initial value
136143
}
137144
}).sort((a, b) => a.name.localeCompare(b.name))
@@ -141,7 +148,6 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
141148
specifications: specifications,
142149
url: `https://github.com/${ownerName}/${repositoryName}/tree/${ref.name}`,
143150
isDefault: isDefaultRef || false,
144-
baseRef: ref.baseRef,
145151
}
146152
}
147153

@@ -164,6 +170,26 @@ export default class GitHubProjectDataSource implements IProjectDataSource {
164170
}): string {
165171
return `/api/blob/${ownerName}/${repositoryName}/${path}?ref=${ref}`
166172
}
173+
174+
private getGitHubDiffURL({
175+
ownerName,
176+
repositoryName,
177+
path,
178+
baseRefOid,
179+
headRefOid
180+
}: {
181+
ownerName: string;
182+
repositoryName: string;
183+
path: string;
184+
baseRefOid: string | undefined;
185+
headRefOid: string }
186+
): string | undefined {
187+
if (!baseRefOid) {
188+
return undefined
189+
} else {
190+
return `/api/diff/${ownerName}/${repositoryName}/${path}?baseRefOid=${baseRefOid}&to=${headRefOid}`
191+
}
192+
}
167193

168194
private addRemoteVersions(
169195
existingVersions: Version[],

0 commit comments

Comments
 (0)