From a220fcaf2b5604a50df1fc2bd17a8cec1206f87a Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 18 May 2026 13:42:03 +0200 Subject: [PATCH] feat(routing): create GitHub destination repo before configuring push-mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea's push-mirror cannot push to a non-existent remote — it just runs 'git push' against whatever URL it's given. So a project_create flow that only configures the mirror leaves the GitHub side as an unfulfillable URL. New internal/githubclient package: single-purpose client that POSTs /user/repos to create an empty private repo (auto_init=false so the first mirror push doesn't conflict with a generated README). Treats 422 'name already exists' as idempotent success via ErrAlreadyExists. 401/403 are surfaced as 'PAT missing repo scope or invalid' so the operator sees the real cause instead of a vague upstream error. Skill wiring: - New stepCreateGitHub between stepCreateRepo and stepMirror in the orchestrator. - Skipped entirely when Config.GitHub is nil (degraded mode — the routing pod runs without GITHUB_PAT, mirror config still lands, but the actual sync to github fails until the repo exists). - cmd/routing/main.go constructs githubclient.New(GitHubPAT) only when the PAT is set; the skill receives nil otherwise. Tests: - happy path: fake github 201 + assertions that the 'reached' array is [create_repo, create_github_repo, mirror, infra_commit, issue]. - github 422 already-exists: idempotent, all gitea steps still run. - github 401: returns failed_step=create_github_repo, no mirror or later steps. - degraded mode (Config.GitHub nil): reached omits create_github_repo, rest of the flow runs unchanged. Updated existing tests to read [skill, gh] from newSkill instead of just skill, and adjusted reached-array expectations to include the new step. Tracks #10. --- cmd/routing/main.go | 6 ++ internal/githubclient/client.go | 108 +++++++++++++++++++ internal/githubclient/client_test.go | 71 ++++++++++++ internal/skills/project/handlers.go | 31 +++++- internal/skills/project/handlers_test.go | 131 ++++++++++++++++++++--- internal/skills/project/skill.go | 18 +++- 6 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 internal/githubclient/client.go create mode 100644 internal/githubclient/client_test.go diff --git a/cmd/routing/main.go b/cmd/routing/main.go index 202e29d..2ece8bc 100644 --- a/cmd/routing/main.go +++ b/cmd/routing/main.go @@ -17,6 +17,7 @@ import ( "github.com/mathiasbq/supervisor/internal/auth" "github.com/mathiasbq/supervisor/internal/config" iexec "github.com/mathiasbq/supervisor/internal/exec" + "github.com/mathiasbq/supervisor/internal/githubclient" "github.com/mathiasbq/supervisor/internal/mcp" "github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/registry" @@ -102,8 +103,13 @@ func main() { })) if cfg.GiteaMCPURL != "" { + var ghClient *githubclient.Client + if cfg.GitHubPAT != "" { + ghClient = githubclient.New(cfg.GitHubPAT) + } reg.Register(project.New(project.Config{ Client: mcpclient.New(cfg.GiteaMCPURL, cfg.GiteaMCPToken), + GitHub: ghClient, GiteaOwner: cfg.GiteaOwner, GitHubOwner: cfg.GitHubOwner, GitHubPAT: cfg.GitHubPAT, diff --git a/internal/githubclient/client.go b/internal/githubclient/client.go new file mode 100644 index 0000000..7b8729e --- /dev/null +++ b/internal/githubclient/client.go @@ -0,0 +1,108 @@ +// Package githubclient is a minimal GitHub REST API client. The hyperguild +// project_create flow is gitea-first; this client exists only to create an +// empty repo on GitHub before the gitea→github push-mirror is configured, +// since the mirror cannot push to a non-existent remote. +package githubclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const defaultAPI = "https://api.github.com" + +type Client struct { + api string + token string + http *http.Client +} + +// New returns a Client with the given personal access token (repo scope). +func New(token string) *Client { + return &Client{ + api: defaultAPI, + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// WithBaseURL overrides the API base (test injection). +func (c *Client) WithBaseURL(u string) *Client { + c.api = u + return c +} + +// Repo is the subset of GitHub's repo response we surface upstream. +type Repo struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url"` + CloneURL string `json:"clone_url"` + Private bool `json:"private"` +} + +type createRepoArgs struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Private bool `json:"private"` + AutoInit bool `json:"auto_init"` +} + +// ErrAlreadyExists is returned by CreateRepo when GitHub responds 422 with +// "name already exists". Callers treat it as idempotent success. +var ErrAlreadyExists = fmt.Errorf("github repo already exists") + +// CreateRepo creates a repo under the authenticated user's account. +// auto_init is always false — the push-mirror will populate the repo from +// gitea, so an auto-generated README would conflict on first push. +func (c *Client) CreateRepo(ctx context.Context, name, description string, private bool) (*Repo, error) { + if c.token == "" { + return nil, fmt.Errorf("github pat not configured") + } + body, _ := json.Marshal(createRepoArgs{ + Name: name, + Description: description, + Private: private, + AutoInit: false, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.api+"/user/repos", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, _ := io.ReadAll(resp.Body) + switch resp.StatusCode { + case http.StatusCreated: + var r Repo + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &r, nil + case http.StatusUnprocessableEntity: + // 422 covers "name already exists" + a handful of other validation + // errors. Treat any 422 that mentions "already exists" as idempotent + // success; everything else surfaces verbatim. + if bytes.Contains(raw, []byte("already exists")) { + return nil, ErrAlreadyExists + } + return nil, fmt.Errorf("github 422: %s", string(raw)) + case http.StatusUnauthorized, http.StatusForbidden: + return nil, fmt.Errorf("github auth %d: PAT missing repo scope or invalid", resp.StatusCode) + default: + return nil, fmt.Errorf("github %d: %s", resp.StatusCode, string(raw)) + } +} diff --git a/internal/githubclient/client_test.go b/internal/githubclient/client_test.go new file mode 100644 index 0000000..4c25aab --- /dev/null +++ b/internal/githubclient/client_test.go @@ -0,0 +1,71 @@ +package githubclient_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/mathiasbq/supervisor/internal/githubclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateRepo_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/user/repos", r.URL.Path) + assert.Equal(t, "token ghp_test", r.Header.Get("Authorization")) + var args map[string]any + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &args) + assert.Equal(t, "test-repo", args["name"]) + assert.Equal(t, true, args["private"]) + assert.Equal(t, false, args["auto_init"]) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"full_name":"mathiasb/test-repo","html_url":"https://github.com/mathiasb/test-repo","clone_url":"https://github.com/mathiasb/test-repo.git","private":true}`)) + })) + defer srv.Close() + + c := githubclient.New("ghp_test").WithBaseURL(srv.URL) + r, err := c.CreateRepo(context.Background(), "test-repo", "desc", true) + require.NoError(t, err) + assert.Equal(t, "mathiasb/test-repo", r.FullName) + assert.True(t, r.Private) +} + +func TestCreateRepo_AlreadyExists(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"resource":"Repository","code":"custom","field":"name","message":"name already exists on this account"}]}`)) + })) + defer srv.Close() + + c := githubclient.New("ghp_test").WithBaseURL(srv.URL) + _, err := c.CreateRepo(context.Background(), "x", "", false) + require.Error(t, err) + assert.True(t, errors.Is(err, githubclient.ErrAlreadyExists)) +} + +func TestCreateRepo_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer srv.Close() + + c := githubclient.New("ghp_test").WithBaseURL(srv.URL) + _, err := c.CreateRepo(context.Background(), "x", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "PAT missing repo scope") +} + +func TestCreateRepo_NoToken(t *testing.T) { + c := githubclient.New("") + _, err := c.CreateRepo(context.Background(), "x", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "github pat not configured") +} diff --git a/internal/skills/project/handlers.go b/internal/skills/project/handlers.go index 2f82495..a3279fa 100644 --- a/internal/skills/project/handlers.go +++ b/internal/skills/project/handlers.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/mathiasbq/supervisor/internal/githubclient" "github.com/mathiasbq/supervisor/internal/mcpclient" ) @@ -40,10 +41,11 @@ func errUnknownTool(name string) error { return fmt.Errorf("unknown tool: %s", n // step names — must match what we surface in failed_step / reached. const ( - stepCreateRepo = "create_repo" - stepMirror = "mirror" - stepInfraCommit = "infra_commit" - stepIssue = "issue" + stepCreateRepo = "create_repo" + stepCreateGitHub = "create_github_repo" + stepMirror = "mirror" + stepInfraCommit = "infra_commit" + stepIssue = "issue" ) func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.RawMessage, error) { @@ -73,7 +75,18 @@ func (s *Skill) handleCreate(ctx context.Context, raw json.RawMessage) (json.Raw } res.Reached = append(res.Reached, stepCreateRepo) - // Step 2: configure push mirror to GitHub. Idempotent: if a mirror with + // Step 2: create empty GitHub repo. Gitea's push-mirror cannot push + // to a non-existent remote, so the destination must exist before + // step 3 configures the mirror. Skipped when GitHub client is unset + // (degraded mode — see Config.GitHub doc). + if s.cfg.GitHub != nil { + if err := s.callCreateGitHubRepo(ctx, args); err != nil && !errors.Is(err, githubclient.ErrAlreadyExists) { + return marshalPartial(res, stepCreateGitHub, err) + } + res.Reached = append(res.Reached, stepCreateGitHub) + } + + // Step 3: configure push mirror to GitHub. Idempotent: if a mirror with // the same remote already exists, gitea-mcp returns Conflict; we swallow it. if err := s.callMirror(ctx, args.Name); err != nil { if !isConflict(err) { @@ -135,6 +148,14 @@ func (s *Skill) callCreateRepo(ctx context.Context, args createArgs, template st return false, err } +// callCreateGitHubRepo creates the empty destination repo on GitHub. +// auto_init=false in githubclient so first push from gitea doesn't conflict +// with an auto-generated README. +func (s *Skill) callCreateGitHubRepo(ctx context.Context, args createArgs) error { + _, err := s.cfg.GitHub.CreateRepo(ctx, args.Name, args.Description, args.Private) + return err +} + // callMirror configures the push mirror to GitHub. func (s *Skill) callMirror(ctx context.Context, name string) error { remote := fmt.Sprintf("https://github.com/%s/%s.git", s.cfg.GitHubOwner, name) diff --git a/internal/skills/project/handlers_test.go b/internal/skills/project/handlers_test.go index 84100b0..e1de228 100644 --- a/internal/skills/project/handlers_test.go +++ b/internal/skills/project/handlers_test.go @@ -9,12 +9,42 @@ import ( "sync" "testing" + "github.com/mathiasbq/supervisor/internal/githubclient" "github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/skills/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// fakeGitHub captures POST /user/repos calls. +type fakeGitHub struct { + mu sync.Mutex + Calls []map[string]any + ReturnError int // 0 = 201 Created, 422 = already exists, etc. +} + +func (g *fakeGitHub) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var args map[string]any + _ = json.NewDecoder(r.Body).Decode(&args) + g.mu.Lock() + g.Calls = append(g.Calls, args) + code := g.ReturnError + g.mu.Unlock() + switch code { + case 0: + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"full_name":"mathiasb/x","html_url":"https://github.com/mathiasb/x","clone_url":"https://github.com/mathiasb/x.git"}`)) + case 422: + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"errors":[{"message":"name already exists on this account"}]}`)) + default: + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"message":"boom"}`)) + } + }) +} + // fakeGiteaMCP implements just enough of the JSON-RPC tools/call surface // to drive project_create end-to-end without an actual gitea-mcp server. type fakeGiteaMCP struct { @@ -85,7 +115,28 @@ func (f *fakeGiteaMCP) handler() http.Handler { }) } -func newSkill(t *testing.T, f *fakeGiteaMCP) *project.Skill { +func newSkill(t *testing.T, f *fakeGiteaMCP) (*project.Skill, *fakeGitHub) { + t.Helper() + srv := httptest.NewServer(f.handler()) + t.Cleanup(srv.Close) + + gh := &fakeGitHub{} + ghSrv := httptest.NewServer(gh.handler()) + t.Cleanup(ghSrv.Close) + + return project.New(project.Config{ + Client: mcpclient.New(srv.URL, ""), + GitHub: githubclient.New("ghp_test").WithBaseURL(ghSrv.URL), + GiteaOwner: "mathias", + GitHubOwner: "mathiasb", + GitHubPAT: "ghp_test", + InfraRepo: "infra", + }), gh +} + +// newSkillNoGitHub builds a skill with the GitHub client unset — degraded +// mode where the github-repo-creation step is skipped. +func newSkillNoGitHub(t *testing.T, f *fakeGiteaMCP) *project.Skill { t.Helper() srv := httptest.NewServer(f.handler()) t.Cleanup(srv.Close) @@ -93,7 +144,6 @@ func newSkill(t *testing.T, f *fakeGiteaMCP) *project.Skill { Client: mcpclient.New(srv.URL, ""), GiteaOwner: "mathias", GitHubOwner: "mathiasb", - GitHubPAT: "ghp_test", InfraRepo: "infra", }) } @@ -115,7 +165,7 @@ func TestProjectCreate_HappyPath(t *testing.T) { "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, }, } - skill := newSkill(t, f) + skill, gh := newSkill(t, f) out, err := skill.Handle(context.Background(), "project_create", happyArgs()) require.NoError(t, err) @@ -128,13 +178,19 @@ func TestProjectCreate_HappyPath(t *testing.T) { assert.Contains(t, res["next_steps"], "cd ~/dev/AGENTS/my-experiment") assert.Contains(t, res["next_steps"], "git remote add origin") - // All four steps in order. + // All 4 gitea-mcp calls in order. require.Len(t, f.Calls, 4) assert.Equal(t, "create_project_from_template", f.Calls[0].Tool) assert.Equal(t, "repo_mirror_push", f.Calls[1].Tool) assert.Equal(t, "file_write_branch", f.Calls[2].Tool) assert.Equal(t, "issue_create", f.Calls[3].Tool) + // GitHub repo created between create_project_from_template and mirror. + require.Len(t, gh.Calls, 1) + assert.Equal(t, "my-experiment", gh.Calls[0]["name"]) + assert.Equal(t, true, gh.Calls[0]["private"]) + assert.Equal(t, false, gh.Calls[0]["auto_init"]) + // template selection wired from stack assert.Equal(t, "template-go-agent", f.Calls[0].Args["template_name"]) // mirror config @@ -147,6 +203,55 @@ func TestProjectCreate_HappyPath(t *testing.T) { assert.Contains(t, f.Calls[2].Args["content"], "managed-by: hyperguild") // PAT must NOT appear in the response assert.NotContains(t, string(out), "ghp_test") + + // reached records the github step too. + reached := res["reached"].([]any) + assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror", "infra_commit", "issue"}, reached) +} + +func TestProjectCreate_GitHubExists_Idempotent(t *testing.T) { + f := &fakeGiteaMCP{ + Responses: map[string]any{ + "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, + }, + } + skill, gh := newSkill(t, f) + gh.ReturnError = 422 // already exists + + _, err := skill.Handle(context.Background(), "project_create", happyArgs()) + require.NoError(t, err, "422 already-exists should be idempotent") + require.Len(t, f.Calls, 4, "all gitea steps still run despite github 422") +} + +func TestProjectCreate_GitHubFails(t *testing.T) { + f := &fakeGiteaMCP{} + skill, gh := newSkill(t, f) + gh.ReturnError = 401 // bad PAT + + out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + require.Error(t, err) + var res map[string]any + require.NoError(t, json.Unmarshal(out, &res)) + assert.Equal(t, "create_github_repo", res["failed_step"]) + assert.Equal(t, []any{"create_repo"}, res["reached"]) + require.Len(t, f.Calls, 1, "mirror + later steps must not run when github creation fails") +} + +func TestProjectCreate_NoGitHubClient_DegradedMode(t *testing.T) { + f := &fakeGiteaMCP{ + Responses: map[string]any{ + "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, + }, + } + skill := newSkillNoGitHub(t, f) + + out, err := skill.Handle(context.Background(), "project_create", happyArgs()) + require.NoError(t, err) + var res map[string]any + require.NoError(t, json.Unmarshal(out, &res)) + // reached does NOT include create_github_repo when client is nil. + reached := res["reached"].([]any) + assert.Equal(t, []any{"create_repo", "mirror", "infra_commit", "issue"}, reached) } func TestProjectCreate_Idempotent_RepoExists(t *testing.T) { @@ -158,7 +263,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) { "issue_create": map[string]any{"html_url": "http://gitea.d-ma.be/mathias/my-experiment/issues/1"}, }, } - skill := newSkill(t, f) + skill, _ := newSkill(t, f) out, err := skill.Handle(context.Background(), "project_create", happyArgs()) require.NoError(t, err) @@ -168,7 +273,7 @@ func TestProjectCreate_Idempotent_RepoExists(t *testing.T) { assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment", res["gitea_url"]) assert.Equal(t, "http://gitea.d-ma.be/mathias/my-experiment/issues/1", res["issue_url"]) - // Still ran all 4 steps; idempotent flow falls through the conflict. + // Still ran all 4 gitea-mcp steps; idempotent flow falls through. require.Len(t, f.Calls, 4) } @@ -178,7 +283,7 @@ func TestProjectCreate_MirrorFails(t *testing.T) { "repo_mirror_push": {Code: -32000, Message: "github unreachable"}, }, } - skill := newSkill(t, f) + skill, _ := newSkill(t, f) out, err := skill.Handle(context.Background(), "project_create", happyArgs()) require.Error(t, err) @@ -188,9 +293,9 @@ func TestProjectCreate_MirrorFails(t *testing.T) { require.NoError(t, json.Unmarshal(out, &res)) assert.Equal(t, "mirror", res["failed_step"]) reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo"}, reached) + assert.Equal(t, []any{"create_repo", "create_github_repo"}, reached) - // Only steps 1 + 2 actually called. + // Steps 1 (create) + 2 (mirror attempt) reached gitea; github made 1 call. require.Len(t, f.Calls, 2) } @@ -200,7 +305,7 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) { "file_write_branch": {Code: -32000, Message: "write rejected"}, }, } - skill := newSkill(t, f) + skill, _ := newSkill(t, f) out, err := skill.Handle(context.Background(), "project_create", happyArgs()) require.Error(t, err) @@ -209,13 +314,13 @@ func TestProjectCreate_InfraCommitFails(t *testing.T) { require.NoError(t, json.Unmarshal(out, &res)) assert.Equal(t, "infra_commit", res["failed_step"]) reached := res["reached"].([]any) - assert.Equal(t, []any{"create_repo", "mirror"}, reached) + assert.Equal(t, []any{"create_repo", "create_github_repo", "mirror"}, reached) require.Len(t, f.Calls, 3) } func TestProjectCreate_ValidationErrors(t *testing.T) { f := &fakeGiteaMCP{} - skill := newSkill(t, f) + skill, _ := newSkill(t, f) cases := []struct { name string body string @@ -238,7 +343,7 @@ func TestProjectCreate_ValidationErrors(t *testing.T) { func TestProjectCreate_UnknownTool(t *testing.T) { f := &fakeGiteaMCP{} - skill := newSkill(t, f) + skill, _ := newSkill(t, f) _, err := skill.Handle(context.Background(), "nope", happyArgs()) require.Error(t, err) } diff --git a/internal/skills/project/skill.go b/internal/skills/project/skill.go index c223bf4..9793a7a 100644 --- a/internal/skills/project/skill.go +++ b/internal/skills/project/skill.go @@ -9,17 +9,26 @@ import ( "context" "encoding/json" + "github.com/mathiasbq/supervisor/internal/githubclient" "github.com/mathiasbq/supervisor/internal/mcpclient" "github.com/mathiasbq/supervisor/internal/registry" ) // Config holds the orchestration dependencies for the project skill. type Config struct { - // Client talks to the gitea-mcp server. project_create makes 4 sequential - // calls (create_project_from_template, repo_mirror_push, file_write_branch, - // issue_create) through this client. + // Client talks to the gitea-mcp server. project_create makes + // sequential calls (create_project_from_template, repo_mirror_push, + // file_write_branch, issue_create) through this client. Client *mcpclient.Client + // GitHub is the client used to create the empty destination repo on + // GitHub before the push-mirror is configured. Gitea's push-mirror + // cannot push to a non-existent remote, so this step is mandatory + // when GitHubPAT is set. Pass nil to skip github repo creation + // entirely (degraded mode — mirror config will land but the actual + // sync to github will fail until the repo exists). + GitHub *githubclient.Client + // GiteaOwner is the org/user that owns the new repo and the infra repo // the namespace manifest is committed to (typically "mathias"). GiteaOwner string @@ -29,7 +38,8 @@ type Config struct { GitHubOwner string // GitHubPAT is the personal access token used as the push-mirror - // password. Must have `repo` scope. Never logged. + // password and to create the destination repo on GitHub. Must have + // `repo` scope. Never logged. GitHubPAT string // InfraRepo is the name of the infra repo on Gitea where the