Skip to content

Commit 71387f5

Browse files
Merge pull request #3363 from SwiftPackageIndex/add-forked-from
Add `forked_from` as a pointer either to an existing package or as a reference to a URL
2 parents 1d24166 + 21d2281 commit 71387f5

File tree

9 files changed

+147
-26
lines changed

9 files changed

+147
-26
lines changed

Sources/App/Commands/Ingest.swift

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,16 @@ func ingest(client: Client,
124124
Current.logger().warning("storeS3Readme failed")
125125
s3Readme = .error("\(error)")
126126
}
127+
128+
let fork = await getFork(on: database, parent: metadata.repository?.parent)
127129

128130
try await updateRepository(on: database,
129131
for: repo,
130132
metadata: metadata,
131133
licenseInfo: license,
132134
readmeInfo: readme,
133-
s3Readme: s3Readme)
135+
s3Readme: s3Readme,
136+
fork: fork)
134137
return pkg
135138
}
136139

@@ -177,7 +180,8 @@ func updateRepository(on database: Database,
177180
metadata: Github.Metadata,
178181
licenseInfo: Github.License?,
179182
readmeInfo: Github.Readme?,
180-
s3Readme: S3Readme?) async throws {
183+
s3Readme: S3Readme?,
184+
fork: Fork? = nil) async throws {
181185
guard let repoMetadata = metadata.repository else {
182186
if repository.$package.value == nil {
183187
try await repository.$package.load(on: database)
@@ -208,10 +212,22 @@ func updateRepository(on database: Database,
208212
repository.releases = repoMetadata.releases.nodes.map(Release.init(from:))
209213
repository.stars = repoMetadata.stargazerCount
210214
repository.summary = repoMetadata.description
215+
repository.forkedFrom = fork
211216

212217
try await repository.save(on: database)
213218
}
214219

220+
func getFork(on database: Database, parent: Github.Metadata.Parent?) async -> Fork? {
221+
guard let parentUrl = parent?.normalizedURL else { return nil }
222+
223+
if let packageId = try? await Package.query(on: database)
224+
.filter(\.$url, .custom("ilike"), parentUrl)
225+
.first()?.id {
226+
return .parentId(packageId)
227+
} else {
228+
return .parentURL(parentUrl)
229+
}
230+
}
215231

216232
// Helper to ensure the canonical source for these critical fields is the same in all the places where we need them
217233
private extension Github.Metadata {
@@ -224,3 +240,14 @@ private extension Github.Metadata.Repository {
224240
var repositoryOwner: String? { owner.login }
225241
var repositoryName: String? { name }
226242
}
243+
244+
private extension Github.Metadata.Parent {
245+
// Returns a normalized version of the URL. Adding a `.git` if not present.
246+
var normalizedURL: String? {
247+
guard let url else { return nil }
248+
guard let normalizedURL = URL(string: url)?.normalized?.absoluteString else {
249+
return nil
250+
}
251+
return normalizedURL
252+
}
253+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
extension URL {
18+
var normalized: Self? {
19+
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return nil }
20+
if components.scheme == "http" { components.scheme = "https" }
21+
if !components.path.hasSuffix(".git") { components.path = components.path + ".git" }
22+
return components.url!
23+
}
24+
}

Sources/App/Core/Github.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,9 @@ extension Github {
284284
homepageUrl
285285
isArchived
286286
isFork
287+
parent {
288+
url
289+
}
287290
isInOrganization
288291
licenseInfo {
289292
name
@@ -348,6 +351,7 @@ extension Github {
348351
var isArchived: Bool
349352
// periphery:ignore
350353
var isFork: Bool
354+
var parent: Parent?
351355
var isInOrganization: Bool
352356
var licenseInfo: LicenseInfo?
353357
var mergedPullRequests: IssueNodes
@@ -459,6 +463,10 @@ extension Github {
459463
}
460464
}
461465
}
466+
467+
struct Parent: Decodable, Equatable {
468+
var url: String?
469+
}
462470
}
463471

464472
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright Dave Verwer, Sven A. Schmidt, and other contributors.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Fluent
16+
import SQLKit
17+
18+
19+
struct UpdateRepositoryAddForkedFrom: AsyncMigration {
20+
func prepare(on database: Database) async throws {
21+
try await database.schema("repositories")
22+
.field("forked_from", .json)
23+
// delete old `forked_from_id` field
24+
.deleteField("forked_from_id")
25+
.update()
26+
}
27+
28+
func revert(on database: Database) async throws {
29+
try await database.schema("repositories")
30+
.deleteField("forked_from")
31+
.field("forked_from_id", .uuid,
32+
.references("repositories", "id")).unique(on: "forked_from_id")
33+
.update()
34+
}
35+
}

Sources/App/Models/Repository.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ final class Repository: @unchecked Sendable, Model, Content {
3434

3535
// reference fields
3636

37-
@OptionalParent(key: "forked_from_id") // TODO: remove or implement
38-
var forkedFrom: Repository?
39-
4037
@Parent(key: "package_id")
4138
var package: Package
4239

@@ -53,6 +50,9 @@ final class Repository: @unchecked Sendable, Model, Content {
5350

5451
@Field(key: "first_commit_date")
5552
var firstCommitDate: Date?
53+
54+
@Field(key: "forked_from")
55+
var forkedFrom: Fork?
5656

5757
@Field(key: "forks")
5858
var forks: Int
@@ -135,7 +135,7 @@ final class Repository: @unchecked Sendable, Model, Content {
135135
firstCommitDate: Date? = nil,
136136
forks: Int = 0,
137137
fundingLinks: [FundingLink] = [],
138-
forkedFrom: Repository? = nil,
138+
forkedFrom: Fork? = nil,
139139
homepageUrl: String? = nil,
140140
isArchived: Bool = false,
141141
isInOrganization: Bool = false,
@@ -164,9 +164,7 @@ final class Repository: @unchecked Sendable, Model, Content {
164164
self.commitCount = commitCount
165165
self.firstCommitDate = firstCommitDate
166166
self.forks = forks
167-
if let forkId = forkedFrom?.id {
168-
self.$forkedFrom.id = forkId
169-
}
167+
self.forkedFrom = forkedFrom
170168
self.fundingLinks = fundingLinks
171169
self.homepageUrl = homepageUrl
172170
self.isArchived = isArchived
@@ -273,3 +271,8 @@ enum S3Readme: Codable, Equatable {
273271
}
274272
}
275273
}
274+
275+
enum Fork: Codable, Equatable {
276+
case parentId(Package.Id)
277+
case parentURL(String)
278+
}

Sources/App/configure.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ public func configure(_ app: Application) async throws -> String {
334334
do { // Migration 078 - Add `build_date` and `commit_hash` to `builds`
335335
app.migrations.add(UpdateBuildAddBuildDateCommitHash())
336336
}
337+
do { // Migration 079 - Add `forked_from` to `repositories`
338+
app.migrations.add(UpdateRepositoryAddForkedFrom())
339+
}
337340

338341
app.asyncCommands.use(Analyze.Command(), as: "analyze")
339342
app.asyncCommands.use(CreateRestfileCommand(), as: "create-restfile")

Tests/AppTests/IngestorTests.swift

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ class IngestorTests: AppTestCase {
125125
Date(timeIntervalSince1970: 1),
126126
],
127127
license: .mit,
128-
openIssues: 1,
128+
openIssues: 1,
129+
parentUrl: nil,
129130
openPullRequests: 2,
130131
owner: "foo",
131132
pullRequestsClosedAtDates: [
@@ -155,7 +156,8 @@ class IngestorTests: AppTestCase {
155156
html: "readme html",
156157
htmlUrl: "readme html url",
157158
imagesToCache: []),
158-
s3Readme: .cached(s3ObjectUrl: "url", githubEtag: "etag"))
159+
s3Readme: .cached(s3ObjectUrl: "url", githubEtag: "etag"),
160+
fork: .parentURL("https://github.com/foo/bar.git"))
159161

160162
// validate
161163
do {
@@ -164,6 +166,7 @@ class IngestorTests: AppTestCase {
164166
let repo = try await Repository.query(on: app.db).first().unwrap()
165167
XCTAssertEqual(repo.defaultBranch, "main")
166168
XCTAssertEqual(repo.forks, 1)
169+
XCTAssertEqual(repo.forkedFrom, .parentURL("https://github.com/foo/bar.git"))
167170
XCTAssertEqual(repo.fundingLinks, [
168171
.init(platform: .gitHub, url: "https://github.com/username"),
169172
.init(platform: .customUrl, url: "https://example.com/username1"),
@@ -208,6 +211,7 @@ class IngestorTests: AppTestCase {
208211
issuesClosedAtDates: [],
209212
license: .mit,
210213
openIssues: 1,
214+
parentUrl: nil,
211215
openPullRequests: 2,
212216
owner: "foo",
213217
pullRequestsClosedAtDates: [],
@@ -353,6 +357,7 @@ class IngestorTests: AppTestCase {
353357
issuesClosedAtDates: [],
354358
license: .mit,
355359
openIssues: 0,
360+
parentUrl: nil,
356361
openPullRequests: 0,
357362
owner: "owner",
358363
pullRequestsClosedAtDates: [],
@@ -594,4 +599,29 @@ class IngestorTests: AppTestCase {
594599
let postMigrationFetchedRepo = try await XCTUnwrapAsync(try await Repository.query(on: app.db).first())
595600
XCTAssertEqual(postMigrationFetchedRepo.s3Readme, .cached(s3ObjectUrl: "object-url", githubEtag: ""))
596601
}
602+
603+
func test_getFork() async throws {
604+
try await Package(id: .id0, url: "https://github.com/foo/parent.git".url, processingStage: .analysis).save(on: app.db)
605+
try await Package(url: "https://github.com/bar/forked.git", processingStage: .analysis).save(on: app.db)
606+
607+
// test lookup when package is in the index
608+
let fork = await getFork(on: app.db, parent: .init(url: "https://github.com/foo/parent.git"))
609+
XCTAssertEqual(fork, .parentId(.id0))
610+
611+
// test lookup when package is in the index but with different case in URL
612+
let fork2 = await getFork(on: app.db, parent: .init(url: "https://github.com/Foo/Parent.git"))
613+
XCTAssertEqual(fork2, .parentId(.id0))
614+
615+
// test whem metadata repo url doesn't have `.git` at end
616+
let fork3 = await getFork(on: app.db, parent: .init(url: "https://github.com/Foo/Parent"))
617+
XCTAssertEqual(fork3, .parentId(.id0))
618+
619+
// test lookup when package is not in the index
620+
let fork4 = await getFork(on: app.db, parent: .init(url: "https://github.com/some/other.git"))
621+
XCTAssertEqual(fork4, .parentURL("https://github.com/some/other.git"))
622+
623+
// test lookup when parent url is nil
624+
let fork5 = await getFork(on: app.db, parent: nil)
625+
XCTAssertEqual(fork5, nil)
626+
}
597627
}

Tests/AppTests/Mocks/GithubMetadata+mock.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ extension Github.Metadata {
2525
issuesClosedAtDates: [],
2626
license: .mit,
2727
openIssues: 3,
28+
parentUrl: nil,
2829
openPullRequests: 0,
2930
owner: "packageOwner",
3031
pullRequestsClosedAtDates: [],
@@ -34,14 +35,15 @@ extension Github.Metadata {
3435
stars: 2,
3536
summary: "desc")
3637

37-
static func mock(owner: String, repository: String) -> Self {
38+
static func mock(owner: String, repository: String, parentUrl: String? = nil) -> Self {
3839
return .init(defaultBranch: "main",
3940
forks: owner.count + repository.count,
4041
homepageUrl: nil,
4142
isInOrganization: false,
4243
issuesClosedAtDates: [],
4344
license: .mit,
4445
openIssues: 3,
46+
parentUrl: parentUrl,
4547
openPullRequests: 0,
4648
owner: owner,
4749
pullRequestsClosedAtDates: [],
@@ -60,6 +62,7 @@ extension Github.Metadata {
6062
issuesClosedAtDates: [Date],
6163
license: License,
6264
openIssues: Int,
65+
parentUrl: String?,
6366
openPullRequests: Int,
6467
owner: String,
6568
pullRequestsClosedAtDates: [Date],
@@ -84,7 +87,8 @@ extension Github.Metadata {
8487
fundingLinks: fundingLinks,
8588
homepageUrl: homepageUrl,
8689
isArchived: false,
87-
isFork: false,
90+
isFork: false,
91+
parent: .init(url: parentUrl),
8892
isInOrganization: isInOrganization,
8993
licenseInfo: .init(name: license.fullName, key: license.rawValue),
9094
mergedPullRequests: .init(closedAtDates: []),

Tests/AppTests/RepositoryTests.swift

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -178,19 +178,6 @@ final class RepositoryTests: AppTestCase {
178178
}
179179
}
180180

181-
func test_forkedFrom_relationship() async throws {
182-
let p1 = Package(url: "p1")
183-
try await p1.save(on: app.db)
184-
let p2 = Package(url: "p2")
185-
try await p2.save(on: app.db)
186-
187-
// test forked from link
188-
let parent = try Repository(package: p1)
189-
try await parent.save(on: app.db)
190-
let child = try Repository(package: p2, forkedFrom: parent)
191-
try await child.save(on: app.db)
192-
}
193-
194181
func test_delete_cascade() async throws {
195182
// delete package must delete repository
196183
let pkg = Package(id: UUID(), url: "1")

0 commit comments

Comments
 (0)