From 3f3cc044c506b94f28ca308cf16d1c0387bdba54 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:04:19 +0100 Subject: [PATCH 1/5] Allow multiple push mirrors --- Gitea.Declarative.Lib/ConfigSchema.fs | 64 ++++-- Gitea.Declarative.Lib/Gitea.fs | 198 ++++++++++++++---- Gitea.Declarative.Lib/GiteaConfig.schema.json | 13 +- .../SerialisedConfigSchema.fs | 4 +- Gitea.Declarative.Test/TestJsonSchema.fs | 25 ++- 5 files changed, 223 insertions(+), 81 deletions(-) diff --git a/Gitea.Declarative.Lib/ConfigSchema.fs b/Gitea.Declarative.Lib/ConfigSchema.fs index db5da87..13cc773 100644 --- a/Gitea.Declarative.Lib/ConfigSchema.fs +++ b/Gitea.Declarative.Lib/ConfigSchema.fs @@ -30,11 +30,14 @@ type MergeStyle = type PushMirror = { GitHubAddress : Uri + /// Gitea should always tell us a remote name, but a user in their config can't. + RemoteName : string option } static member OfSerialised (s : SerialisedPushMirror) : PushMirror = { GitHubAddress = Uri s.GitHubAddress + RemoteName = None } member this.ToSerialised () : SerialisedPushMirror = @@ -82,7 +85,7 @@ type NativeRepo = AllowRebase : bool option AllowRebaseExplicit : bool option AllowMergeCommits : bool option - Mirror : PushMirror option + Mirrors : PushMirror list ProtectedBranches : ProtectedBranch Set Collaborators : string Set } @@ -103,7 +106,7 @@ type NativeRepo = AllowRebase = Some false AllowRebaseExplicit = Some false AllowMergeCommits = Some false - Mirror = None + Mirrors = [] ProtectedBranches = Set.empty Collaborators = Set.empty } @@ -128,7 +131,7 @@ type NativeRepo = AllowRebase = this.AllowRebase |> Option.orElse NativeRepo.Default.AllowRebase AllowRebaseExplicit = this.AllowRebaseExplicit |> Option.orElse NativeRepo.Default.AllowRebaseExplicit AllowMergeCommits = this.AllowMergeCommits |> Option.orElse NativeRepo.Default.AllowMergeCommits - Mirror = this.Mirror + Mirrors = this.Mirrors ProtectedBranches = this.ProtectedBranches // TODO should this replace null with empty? Collaborators = this.Collaborators } @@ -149,7 +152,12 @@ type NativeRepo = AllowRebase = s.AllowRebase |> Option.ofNullable AllowRebaseExplicit = s.AllowRebaseExplicit |> Option.ofNullable AllowMergeCommits = s.AllowMergeCommits |> Option.ofNullable - Mirror = s.Mirror |> Option.ofNullable |> Option.map PushMirror.OfSerialised + Mirrors = + s.Mirrors + |> Option.ofObj + |> Option.defaultValue [||] + |> List.ofArray + |> List.map PushMirror.OfSerialised ProtectedBranches = match s.ProtectedBranches with | null -> Set.empty @@ -179,10 +187,10 @@ type NativeRepo = AllowRebase = this.AllowRebase |> Option.toNullable AllowRebaseExplicit = this.AllowRebaseExplicit |> Option.toNullable AllowMergeCommits = this.AllowMergeCommits |> Option.toNullable - Mirror = - match this.Mirror with - | None -> Nullable () - | Some mirror -> Nullable (mirror.ToSerialised ()) + Mirrors = + this.Mirrors + |> List.toArray + |> Array.map (fun a -> a.ToSerialised ()) ProtectedBranches = this.ProtectedBranches |> Seq.map (fun b -> b.ToSerialised ()) |> Array.ofSeq Collaborators = Set.toArray this.Collaborators } @@ -239,17 +247,18 @@ type Repo = } |> async.Return else + let repoFullName = u.FullName + async { - let! mirror = + let owner = u.Owner + + let loginName = owner.LoginName + + let! mirrors = getAllPaginated (fun page count -> - client.RepoListPushMirrors (u.Owner.LoginName, u.FullName, Some page, Some count) + client.RepoListPushMirrors (loginName, repoFullName, Some page, Some count) ) - let mirror = - if mirror.Length = 0 then None - elif mirror.Length = 1 then Some mirror.[0] - else failwith "Multiple mirrors not supported yet" - let! (branchProtections : Gitea.BranchProtection[]) = client.RepoListBranchProtection (u.Owner.LoginName, u.FullName) |> Async.AwaitTask @@ -259,16 +268,27 @@ type Repo = client.RepoListCollaborators (u.Owner.LoginName, u.FullName, Some page, Some count) ) + let defaultBranch = u.DefaultBranch + + let collaborators = + collaborators + |> Seq.map (fun user -> + user.LoginName + ) + |> Set.ofSeq + + let description = u.Description + return { - Description = u.Description + Description = description Deleted = None GitHub = None Native = { Private = u.Private - DefaultBranch = u.DefaultBranch + DefaultBranch = defaultBranch IgnoreWhitespaceConflicts = u.IgnoreWhitespaceConflicts HasPullRequests = u.HasPullRequests HasProjects = u.HasProjects @@ -281,11 +301,13 @@ type Repo = AllowRebase = u.AllowRebase AllowRebaseExplicit = u.AllowRebaseExplicit AllowMergeCommits = u.AllowMergeCommits - Mirror = - mirror - |> Option.map (fun m -> + Mirrors = + mirrors + |> Array.toList + |> List.map (fun m -> { GitHubAddress = Uri m.RemoteAddress + RemoteName = Some m.RemoteName } ) ProtectedBranches = @@ -302,7 +324,7 @@ type Repo = } ) |> Set.ofSeq - Collaborators = collaborators |> Seq.map (fun user -> user.LoginName) |> Set.ofSeq + Collaborators = collaborators } |> Some } diff --git a/Gitea.Declarative.Lib/Gitea.fs b/Gitea.Declarative.Lib/Gitea.fs index 08db1b9..9d046a2 100644 --- a/Gitea.Declarative.Lib/Gitea.fs +++ b/Gitea.Declarative.Lib/Gitea.fs @@ -361,34 +361,129 @@ module Gitea = else async.Return () + // Push mirrors do! - match desired.Mirror, actual.Mirror with - | None, None -> async.Return () - | None, Some m -> - async { logger.LogError ("Refusing to delete push mirror for {User}:{Repo}", user, repoName) } - | Some desired, None -> - match githubApiToken with - | None -> - async { - logger.LogCritical ( - "Cannot add push mirror for {User}:{Repo} due to lack of GitHub API token", - user, - repoName + let desired = + desired.Mirrors + |> List.groupBy (fun m -> (m.GitHubAddress : Uri).ToString ()) + |> Map.ofList + + let desired = + desired + |> Map.toSeq + |> Seq.map (fun (name, pm) -> + match pm with + | [] -> failwith "LOGIC ERROR" + | [ pm ] -> pm.GitHubAddress.ToString () + | _ -> + failwith + $"Config validation failed on repo %s{repoName}: multiple push mirrors configured for target %s{name}" + ) + |> Set.ofSeq + + let actual = + actual.Mirrors + |> List.groupBy (fun m -> (m.GitHubAddress : Uri).ToString ()) + |> Map.ofList + + // If any mirror target has multiple push mirrors for it, just delete them all before continuing. + let deleteExisting = + actual + |> Map.toSeq + |> Seq.choose (fun (k, vs) -> + match vs with + | [] -> failwith "LOGIC ERROR" + | [ _ ] -> None + | vs -> + vs + |> List.map (fun pm -> + async { + logger.LogWarning ( + "Multiple push mirrors on repo {Owner}/{RepoName} for target {PushMirrorTarget} found. Deleting them all before recreating.", + user, + repoName, + k + ) + let! ct = Async.CancellationToken + // sigh, domain model - it's *such* a faff to represent this correctly though + do! + client.RepoDeletePushMirror (user, repoName, Option.get pm.RemoteName) + |> Async.AwaitTask + } ) - } - | Some token -> - async { - logger.LogInformation ("Setting up push mirror on {User}:{Repo}", user, repoName) - let pushMirrorOption = createPushMirrorOption desired.GitHubAddress token - let! _ = client.RepoAddPushMirror (user, repoName, pushMirrorOption) |> Async.AwaitTask - return () - } - | Some desired, Some actual -> - if desired <> actual then - async { logger.LogCritical ("Push mirror on {User}:{Repo} differs.", user, repoName) } - else - async.Return () + |> Async.Sequential + |> Async.map (Array.iter id) + |> Some + ) + |> Seq.toList + let actual = + match deleteExisting with + | [] -> actual + | _ -> Map.empty + let distinctActual = actual.Keys |> Set.ofSeq + let presentButNotDesired = Set.difference distinctActual desired + let desiredButNotPresent = Set.difference desired distinctActual + let deleteUndesired = + presentButNotDesired + |> Seq.map (fun toDelete -> + logger.LogWarning ( + "Deleting push mirror on repo {Owner}/{RepoName} for target {PushMirrorTarget}", + user, + repoName, + toDelete + ) + let toDelete = actual.[toDelete] + toDelete + |> Seq.map (fun pm -> + async { + let! ct = Async.CancellationToken + do! + client.RepoDeletePushMirror (user, repoName, Option.get pm.RemoteName) + |> Async.AwaitTask + } + ) + |> Async.Sequential + |> Async.map (Array.iter id) + ) + |> Seq.toList + + let addDesired = + desiredButNotPresent + |> Seq.map (fun toAdd -> + match githubApiToken with + | None -> + async { + logger.LogCritical ( + "Cannot add push mirror for {User}:{Repo} due to lack of GitHub API token", + user, + repoName + ) + } + | Some token -> + async { + logger.LogInformation ("Setting up push mirror on {User}:{Repo}", user, repoName) + let! ct = Async.CancellationToken + let pushMirrorOption = createPushMirrorOption (Uri toAdd) token + + let! _ = + client.RepoAddPushMirror (user, repoName, pushMirrorOption) + |> Async.AwaitTask + + return () + } + ) + |> Seq.toList + if deleteExisting.IsEmpty && deleteUndesired.IsEmpty && addDesired.IsEmpty then + async.Return () + else + async { + do! deleteExisting |> Async.Sequential |> Async.map (Array.iter id) + do! deleteUndesired |> Async.Sequential |> Async.map (Array.iter id) + do! addDesired |> Async.Sequential |> Async.map (Array.iter id) + } + + // Collaborators do! let desiredButNotPresent = Set.difference desired.Collaborators actual.Collaborators let presentButNotDesired = Set.difference actual.Collaborators desired.Collaborators @@ -547,31 +642,42 @@ module Gitea = | Choice2Of2 e -> raise (AggregateException ($"Error creating {user}:{r}", e)) | Choice1Of2 _ -> () - match native.Mirror, githubApiToken with - | None, _ -> () - | Some mirror, None -> failwith "Cannot push to GitHub mirror with an API key" - | Some mirror, Some token -> + match native.Mirrors, githubApiToken with + | [], _ -> () + | _ :: _, None -> failwith "Cannot push to GitHub mirror without an API key" + | mirrors, Some token -> logger.LogInformation ("Setting up push mirror for {User}:{Repo}", user, r) - let options = Gitea.CreatePushMirrorOption () - options.SyncOnCommit <- Some true - options.RemoteAddress <- (mirror.GitHubAddress : Uri).ToString () - options.RemoteUsername <- token - options.RemotePassword <- token - options.Interval <- "8h0m0s" - - let! mirrors = + let! actualMirrors = getAllPaginated (fun page count -> client.RepoListPushMirrors (user, r, Some page, Some count) ) - - match mirrors |> Array.tryFind (fun m -> m.RemoteAddress = options.RemoteAddress) with - | None -> - let! _ = client.RepoAddPushMirror (user, r, options) |> Async.AwaitTask - () - | Some existing -> - if existing.SyncOnCommit <> Some true then - failwith $"sync on commit should have been true for {user}:{r}" - + do! + mirrors + |> List.map (fun mirror -> + let options = Gitea.CreatePushMirrorOption () + options.SyncOnCommit <- Some true + options.RemoteAddress <- (mirror.GitHubAddress : Uri).ToString () + options.RemoteUsername <- token + options.RemotePassword <- token + options.Interval <- "8h0m0s" + + async { + match + actualMirrors + |> Array.tryFind (fun m -> m.RemoteAddress = options.RemoteAddress) + with + | None -> + let! _ = + client.RepoAddPushMirror (user, r, options) |> Async.AwaitTask + + () + | Some existing -> + if existing.SyncOnCommit <> Some true then + failwith $"sync on commit should have been true for {user}:{r}" + } + ) + |> Async.Sequential + |> Async.map (Array.iter id) () | Some github, None -> let options = Gitea.MigrateRepoOptions () diff --git a/Gitea.Declarative.Lib/GiteaConfig.schema.json b/Gitea.Declarative.Lib/GiteaConfig.schema.json index 164c795..82e7f1d 100644 --- a/Gitea.Declarative.Lib/GiteaConfig.schema.json +++ b/Gitea.Declarative.Lib/GiteaConfig.schema.json @@ -188,13 +188,12 @@ "type": "boolean", "description": "either `true` to allow merging pull requests with a merge commit, or `false` to prevent merging pull requests with merge commits." }, - "mirror": { - "description": "Configure a GitHub push mirror to sync this repo to", - "oneOf": [ - { - "$ref": "#/definitions/SerialisedPushMirror" - } - ] + "mirrors": { + "type": "array", + "description": "Configure GitHub push mirrors to sync this repo to", + "items": { + "$ref": "#/definitions/SerialisedPushMirror" + } }, "protectedBranches": { "type": [ diff --git a/Gitea.Declarative.Lib/SerialisedConfigSchema.fs b/Gitea.Declarative.Lib/SerialisedConfigSchema.fs index 610e958..ea360b9 100644 --- a/Gitea.Declarative.Lib/SerialisedConfigSchema.fs +++ b/Gitea.Declarative.Lib/SerialisedConfigSchema.fs @@ -81,8 +81,8 @@ type internal SerialisedNativeRepo = [] AllowMergeCommits : Nullable [] - [] - Mirror : Nullable + [] + Mirrors : SerialisedPushMirror[] [] [] ProtectedBranches : SerialisedProtectedBranch array diff --git a/Gitea.Declarative.Test/TestJsonSchema.fs b/Gitea.Declarative.Test/TestJsonSchema.fs index fc84708..81fe119 100644 --- a/Gitea.Declarative.Test/TestJsonSchema.fs +++ b/Gitea.Declarative.Test/TestJsonSchema.fs @@ -46,15 +46,14 @@ module TestSchema = let schema = reader.ReadToEnd () schema.Contains "SerialisedGiteaConfig" |> shouldEqual true - [] - [] - let ``Update schema file`` () = - let schemaFile = + let schemaFile : Lazy = + lazy Assembly.GetExecutingAssembly().Location |> FileInfo |> fun fi -> fi.Directory |> Utils.findFileAbove "Gitea.Declarative.Lib/GiteaConfig.schema.json" + let computeSchema () = let settings = JsonSchemaGeneratorSettings () settings.SerializerSettings <- @@ -78,4 +77,20 @@ module TestSchema = schema.RequiredProperties.Add "native" serialisedRepoSchema.OneOf.Add schema - File.WriteAllText (schemaFile.FullName, schema.ToJson ()) + schema + + [] + let ``Schema hasn't changed`` () = + let computed = + computeSchema () + |> fun x -> x.ToJson () + let actual = + File.ReadAllText (schemaFile.Force().FullName) + computed + |> shouldEqual actual + + [] + [] + let ``Update schema file`` () = + let schema = computeSchema () + File.WriteAllText (schemaFile.Force().FullName, schema.ToJson ()) From 7220f92bcb833eab5bf8c83ca8d33de31557af14 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:09:40 +0100 Subject: [PATCH 2/5] Format --- Gitea.Declarative.Lib/ConfigSchema.fs | 11 ++--------- Gitea.Declarative.Lib/Gitea.fs | 14 +++++++++++--- Gitea.Declarative.Test/TestJsonSchema.fs | 10 +++------- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/Gitea.Declarative.Lib/ConfigSchema.fs b/Gitea.Declarative.Lib/ConfigSchema.fs index 13cc773..eef92ff 100644 --- a/Gitea.Declarative.Lib/ConfigSchema.fs +++ b/Gitea.Declarative.Lib/ConfigSchema.fs @@ -187,10 +187,7 @@ type NativeRepo = AllowRebase = this.AllowRebase |> Option.toNullable AllowRebaseExplicit = this.AllowRebaseExplicit |> Option.toNullable AllowMergeCommits = this.AllowMergeCommits |> Option.toNullable - Mirrors = - this.Mirrors - |> List.toArray - |> Array.map (fun a -> a.ToSerialised ()) + Mirrors = this.Mirrors |> List.toArray |> Array.map (fun a -> a.ToSerialised ()) ProtectedBranches = this.ProtectedBranches |> Seq.map (fun b -> b.ToSerialised ()) |> Array.ofSeq Collaborators = Set.toArray this.Collaborators } @@ -271,11 +268,7 @@ type Repo = let defaultBranch = u.DefaultBranch let collaborators = - collaborators - |> Seq.map (fun user -> - user.LoginName - ) - |> Set.ofSeq + collaborators |> Seq.map (fun user -> user.LoginName) |> Set.ofSeq let description = u.Description diff --git a/Gitea.Declarative.Lib/Gitea.fs b/Gitea.Declarative.Lib/Gitea.fs index 9d046a2..d57df56 100644 --- a/Gitea.Declarative.Lib/Gitea.fs +++ b/Gitea.Declarative.Lib/Gitea.fs @@ -404,6 +404,7 @@ module Gitea = repoName, k ) + let! ct = Async.CancellationToken // sigh, domain model - it's *such* a faff to represent this correctly though do! @@ -416,13 +417,16 @@ module Gitea = |> Some ) |> Seq.toList + let actual = match deleteExisting with | [] -> actual | _ -> Map.empty + let distinctActual = actual.Keys |> Set.ofSeq let presentButNotDesired = Set.difference distinctActual desired let desiredButNotPresent = Set.difference desired distinctActual + let deleteUndesired = presentButNotDesired |> Seq.map (fun toDelete -> @@ -432,11 +436,14 @@ module Gitea = repoName, toDelete ) + let toDelete = actual.[toDelete] + toDelete |> Seq.map (fun pm -> async { let! ct = Async.CancellationToken + do! client.RepoDeletePushMirror (user, repoName, Option.get pm.RemoteName) |> Async.AwaitTask @@ -465,9 +472,7 @@ module Gitea = let! ct = Async.CancellationToken let pushMirrorOption = createPushMirrorOption (Uri toAdd) token - let! _ = - client.RepoAddPushMirror (user, repoName, pushMirrorOption) - |> Async.AwaitTask + let! _ = client.RepoAddPushMirror (user, repoName, pushMirrorOption) |> Async.AwaitTask return () } @@ -647,10 +652,12 @@ module Gitea = | _ :: _, None -> failwith "Cannot push to GitHub mirror without an API key" | mirrors, Some token -> logger.LogInformation ("Setting up push mirror for {User}:{Repo}", user, r) + let! actualMirrors = getAllPaginated (fun page count -> client.RepoListPushMirrors (user, r, Some page, Some count) ) + do! mirrors |> List.map (fun mirror -> @@ -678,6 +685,7 @@ module Gitea = ) |> Async.Sequential |> Async.map (Array.iter id) + () | Some github, None -> let options = Gitea.MigrateRepoOptions () diff --git a/Gitea.Declarative.Test/TestJsonSchema.fs b/Gitea.Declarative.Test/TestJsonSchema.fs index 81fe119..d768b85 100644 --- a/Gitea.Declarative.Test/TestJsonSchema.fs +++ b/Gitea.Declarative.Test/TestJsonSchema.fs @@ -81,13 +81,9 @@ module TestSchema = [] let ``Schema hasn't changed`` () = - let computed = - computeSchema () - |> fun x -> x.ToJson () - let actual = - File.ReadAllText (schemaFile.Force().FullName) - computed - |> shouldEqual actual + let computed = computeSchema () |> fun x -> x.ToJson () + let actual = File.ReadAllText (schemaFile.Force().FullName) + computed |> shouldEqual actual [] [] From 153a2e9418a1de3f519299267522eda6906311f6 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:10:44 +0100 Subject: [PATCH 3/5] Update example --- Gitea.Declarative.Test/GiteaConfig.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Gitea.Declarative.Test/GiteaConfig.json b/Gitea.Declarative.Test/GiteaConfig.json index 8a18c66..06b0f20 100644 --- a/Gitea.Declarative.Test/GiteaConfig.json +++ b/Gitea.Declarative.Test/GiteaConfig.json @@ -63,9 +63,11 @@ "native": { "defaultBranch": "main", "private": false, - "mirror": { - "gitHubAddress": "https://github.com/MyName/repo-name-3" - }, + "mirrors": [ + { + "gitHubAddress": "https://github.com/MyName/repo-name-3" + } + ], "protectedBranches": [ { "branchName": "main", @@ -83,9 +85,11 @@ "native": { "defaultBranch": "main", "private": false, - "mirror": { - "gitHubAddress": "https://github.com/MyName/repo-name-3" - }, + "mirrors": [ + { + "gitHubAddress": "https://github.com/MyName/repo-name-3" + } + ], "protectedBranches": [ { "branchName": "main", From 37063e1bc108386853f4f29f454cb00685ced5c6 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:13:28 +0100 Subject: [PATCH 4/5] Fix --- Gitea.Declarative.Test/GiteaConfig.json | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Gitea.Declarative.Test/GiteaConfig.json b/Gitea.Declarative.Test/GiteaConfig.json index 06b0f20..c8c791d 100644 --- a/Gitea.Declarative.Test/GiteaConfig.json +++ b/Gitea.Declarative.Test/GiteaConfig.json @@ -38,9 +38,11 @@ "native": { "defaultBranch": "main", "private": false, - "mirror": { - "gitHubAddress": "https://github.com/MyName/repo-name-3" - } + "mirrors": [ + { + "gitHubAddress": "https://github.com/MyName/repo-name-3" + } + ] } }, "new-repo-mirrored-with-branches": { @@ -48,9 +50,11 @@ "native": { "defaultBranch": "main", "private": false, - "mirror": { - "gitHubAddress": "https://github.com/MyName/repo-name-3" - }, + "mirrors": [ + { + "gitHubAddress": "https://github.com/MyName/repo-name-3" + } + ], "protectedBranches": [ { "branchName": "main" From fd2d49d213cba7a2b41c939ec9d9bb9dc0ee5138 Mon Sep 17 00:00:00 2001 From: Smaug123 <3138005+Smaug123@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:17:47 +0100 Subject: [PATCH 5/5] Add custom equality --- Gitea.Declarative.Lib/ConfigSchema.fs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Gitea.Declarative.Lib/ConfigSchema.fs b/Gitea.Declarative.Lib/ConfigSchema.fs index eef92ff..f69c11a 100644 --- a/Gitea.Declarative.Lib/ConfigSchema.fs +++ b/Gitea.Declarative.Lib/ConfigSchema.fs @@ -27,6 +27,8 @@ type MergeStyle = static member toString (m : MergeStyle) = m.ToString () +[] +[] type PushMirror = { GitHubAddress : Uri @@ -34,6 +36,15 @@ type PushMirror = RemoteName : string option } + /// Equality check ignores remote names, which are not known to the user but which Gitea tracks internally. + override this.Equals (other : obj) : bool = + match other with + | :? PushMirror as other -> this.GitHubAddress.ToString () = other.GitHubAddress.ToString () + | _ -> false + + override this.GetHashCode () : int = + this.GitHubAddress.ToString().GetHashCode () + static member OfSerialised (s : SerialisedPushMirror) : PushMirror = { GitHubAddress = Uri s.GitHubAddress