Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import (
// the --mint-url flag.
const DefaultMintURL = "https://fullsend-mint-gljhbkcloq-uc.a.run.app"

Comment thread
ggallen marked this conversation as resolved.
Comment thread
ggallen marked this conversation as resolved.
const defaultScaffoldPRBody = "This PR adds the fullsend scaffold files for per-repo installation.\n\n" +
Comment thread
ggallen marked this conversation as resolved.
"Merge this PR to activate fullsend workflows."

func newAdminCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "admin",
Expand Down Expand Up @@ -1014,10 +1017,9 @@ func runPerRepoInstall(ctx context.Context, c perRepoInstallConfig) error {
}
return fmt.Errorf("getting repo info: %w", repoErr)
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
commitMsg := "chore: initialize fullsend per-repo installation"
prTitle := "chore: initialize fullsend per-repo installation"
prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n\n" +
"Merge this PR to activate fullsend workflows."
prBody := defaultScaffoldPRBody
if direct {
printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)",
owner, repo, targetRepo.DefaultBranch))
Expand Down Expand Up @@ -1200,19 +1202,17 @@ func applyPerRepoScaffold(ctx context.Context, client forge.Client, printer *ui.
}
return fmt.Errorf("getting repo info: %w", err)
}
commitMsg := fmt.Sprintf("chore: initialize fullsend-%s per-repo installation", version)
commitMsg := "chore: initialize fullsend per-repo installation"
if direct {
printer.StepStart(fmt.Sprintf("Committing scaffold files to %s/%s (%s branch)",
owner, repo, targetRepo.DefaultBranch))
} else {
printer.StepStart(fmt.Sprintf("Creating scaffold PR for %s/%s (target: %s)",
owner, repo, targetRepo.DefaultBranch))
}
prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n\n" +
"Merge this PR to activate fullsend workflows."
if _, err := layers.CommitScaffoldFiles(ctx, client, printer,
owner, repo, targetRepo.DefaultBranch,
commitMsg, "chore: initialize fullsend per-repo installation", prBody, files, direct, os.Stdin); err != nil {
commitMsg, "chore: initialize fullsend per-repo installation", defaultScaffoldPRBody, files, direct, os.Stdin); err != nil {
return err
}

Expand Down
3 changes: 1 addition & 2 deletions internal/cli/repos.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,8 +421,7 @@ func runReposInstall(ctx context.Context, opts *reposInstallConfig) error {
}
commitMsg := "chore: initialize fullsend per-repo installation"
prTitle := "chore: initialize fullsend per-repo installation"
prBody := "This PR adds the fullsend scaffold files for per-repo installation.\n\n" +
"Merge this PR to activate fullsend workflows."
prBody := defaultScaffoldPRBody
_, commitErr := layers.CommitScaffoldFiles(ctx, client, printer, owner, repo,
targetRepo.DefaultBranch, commitMsg, prTitle, prBody, files, direct, nil)
return commitErr
Expand Down
104 changes: 104 additions & 0 deletions internal/layers/commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"regexp"
"strings"
"time"

Expand All @@ -27,6 +28,8 @@ func CommitScaffoldFiles(ctx context.Context, client forge.Client, printer *ui.P
owner, repo, defaultBranch, commitMsg, prTitle, prBody string,
files []forge.TreeFile, direct bool, in io.Reader) (bool, error) {

commitMsg = adaptCommitMsg(ctx, client, printer, owner, repo, commitMsg)

if direct {
return commitScaffoldDirect(ctx, client, printer,
owner, repo, defaultBranch, commitMsg, prTitle, prBody, files, in)
Expand Down Expand Up @@ -66,6 +69,15 @@ func commitScaffoldViaPR(ctx context.Context, client forge.Client, printer *ui.P
commitMsg, prTitle, prBody, files)
}

// Non-owner with write access can push directly — avoids fork trust
// gates (/ok-to-test) that block CI on cross-fork PRs.
if hasWriteAccess(ctx, client, owner, repo, user) {
printer.StepInfo(fmt.Sprintf("User %s has write access — pushing directly to %s/%s", user, owner, repo))
return commitBranchAndPR(ctx, client, printer,
owner, repo, owner, repo, defaultScaffoldBranch, defaultBranch,
commitMsg, prTitle, prBody, files)
}

// Non-owner: check for existing fork first.
forkOwner, forkRepo, err := client.FindExistingFork(ctx, owner, repo)
if err != nil {
Expand Down Expand Up @@ -421,3 +433,95 @@ func commitScaffoldDirect(ctx context.Context, client forge.Client, printer *ui.

return committed, nil
}

Comment thread
ggallen marked this conversation as resolved.
// adaptCommitMsg checks the target repo for a .gitlint title-match-regex. If
// the current commit message doesn't match, it tries common conventional-commit
// alternatives (chore:, ci:, build:, bare description). Falls back to warning when no
// alternative matches — we can't fabricate a valid message for an arbitrary regex.
func adaptCommitMsg(ctx context.Context, client forge.Client, printer *ui.Printer, owner, repo, commitMsg string) string {
re := gitlintTitleRegex(ctx, client, owner, repo)
if re == nil {
return commitMsg
}
title := strings.SplitN(commitMsg, "\n", 2)[0]
if re.MatchString(title) {
return commitMsg
}

desc := title
if idx := strings.Index(title, ": "); idx >= 0 {
desc = title[idx+2:]
}
alternatives := []string{
"chore: " + desc,
"ci: " + desc,
"build: " + desc,
desc,
}
var body string
if parts := strings.SplitN(commitMsg, "\n", 2); len(parts) == 2 {
body = parts[1]
}
for _, alt := range alternatives {
if alt == title {
continue
}
if re.MatchString(alt) {
adapted := alt
if body != "" {
adapted += "\n" + body
}
printer.StepInfo(fmt.Sprintf("Adapted scaffold commit message to match .gitlint: %q", alt))
return adapted
}
}

printer.StepWarn(fmt.Sprintf("Scaffold commit message %q does not match this repo's .gitlint title-match-regex (%s) — commit-lint CI may fail",
title, re.String()))
return commitMsg
}

// gitlintTitleRegex reads .gitlint from the target repo and extracts the
// title-match-regex value. Returns nil if the file doesn't exist or has no
// title-match-regex rule.
func gitlintTitleRegex(ctx context.Context, client forge.Client, owner, repo string) *regexp.Regexp {
content, err := client.GetFileContent(ctx, owner, repo, ".gitlint")
if err != nil {
return nil
}
inSection := false
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inSection = strings.EqualFold(line, "[title-match-regex]")
Comment thread
ggallen marked this conversation as resolved.
continue
}
if !inSection {
continue
}
if parts := strings.SplitN(line, "=", 2); len(parts) == 2 && strings.TrimSpace(parts[0]) == "regex" {
val := strings.TrimSpace(parts[1])
re, err := regexp.Compile(val)
if err != nil {
return nil
}
return re
}
}
return nil
}

// hasWriteAccess checks whether the user has write or admin permission on the
// repo. Returns false on any error (or non-GitHub forges) so the caller falls
// through to the fork path.
func hasWriteAccess(ctx context.Context, client forge.Client, owner, repo, user string) bool {
ghExt, ok := client.(forge.GitHubExtensions)
if !ok {
return false
}
role, err := ghExt.GetCollaboratorPermission(ctx, owner, repo, user)
if err != nil {
return false
}
return role == "write" || role == "maintain" || role == "admin"
}
Loading
Loading