forked from molecule-ai/molecule-core
Merge pull request #402 from Molecule-AI/feat/per-agent-git-identity
feat(provisioner): per-agent git identity via GIT_AUTHOR_* env vars
This commit is contained in:
commit
523f9ecb69
71
platform/internal/handlers/agent_git_identity.go
Normal file
71
platform/internal/handlers/agent_git_identity.go
Normal file
@ -0,0 +1,71 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// gitIdentitySlugPattern collapses any run of non-alphanumeric characters
|
||||
// into a single hyphen when deriving an email localpart from a workspace
|
||||
// name. Dots, parentheses, unicode dashes, whitespace — all get squashed.
|
||||
var gitIdentitySlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// gitIdentityEmailDomain is the @-part of generated agent emails. These
|
||||
// addresses are not deliverable — they're identity markers only. Using
|
||||
// the project's canonical domain keeps them attributable without looking
|
||||
// like they belong to a real human inbox. If this changes, also update
|
||||
// docs/authorship.md (when it exists).
|
||||
const gitIdentityEmailDomain = "agents.moleculesai.app"
|
||||
|
||||
// applyAgentGitIdentity sets GIT_AUTHOR_* / GIT_COMMITTER_* env vars so
|
||||
// every commit from this workspace container carries a distinct author
|
||||
// in `git log` and `git blame`. Git reads these env vars before falling
|
||||
// back to `git config user.name` / `user.email`, so this works even if
|
||||
// the container's git config is untouched.
|
||||
//
|
||||
// Idempotent + respectful: if any of the four variables is already set
|
||||
// (e.g. by an operator-supplied workspace_secret), the existing value
|
||||
// wins — this function only fills in the defaults.
|
||||
//
|
||||
// The workspace name is the display name from org.yaml ("Frontend
|
||||
// Engineer", "Product Marketing Manager", "Research Lead"). The email
|
||||
// localpart is the slugified form of that name. Empty workspace names
|
||||
// leave the env untouched — we don't want to emit
|
||||
// `unknown@agents.moleculesai.app` for a provisioning glitch that
|
||||
// dropped the name.
|
||||
func applyAgentGitIdentity(envVars map[string]string, workspaceName string) {
|
||||
if envVars == nil {
|
||||
return
|
||||
}
|
||||
workspaceName = strings.TrimSpace(workspaceName)
|
||||
if workspaceName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
authorName := "Molecule AI " + workspaceName
|
||||
slug := slugifyForEmail(workspaceName)
|
||||
authorEmail := slug + "@" + gitIdentityEmailDomain
|
||||
|
||||
setIfEmpty(envVars, "GIT_AUTHOR_NAME", authorName)
|
||||
setIfEmpty(envVars, "GIT_AUTHOR_EMAIL", authorEmail)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_NAME", authorName)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_EMAIL", authorEmail)
|
||||
}
|
||||
|
||||
// slugifyForEmail collapses a workspace name to a safe email localpart:
|
||||
// lowercase, non-alphanumeric runs → single hyphen, stripped at edges.
|
||||
// "Frontend Engineer" → "frontend-engineer".
|
||||
// "Product Marketing Manager" → "product-marketing-manager".
|
||||
// "UIUX Designer" → "uiux-designer".
|
||||
func slugifyForEmail(name string) string {
|
||||
lowered := strings.ToLower(name)
|
||||
slug := gitIdentitySlugPattern.ReplaceAllString(lowered, "-")
|
||||
return strings.Trim(slug, "-")
|
||||
}
|
||||
|
||||
func setIfEmpty(m map[string]string, key, val string) {
|
||||
if _, ok := m[key]; ok {
|
||||
return
|
||||
}
|
||||
m[key] = val
|
||||
}
|
||||
101
platform/internal/handlers/agent_git_identity_test.go
Normal file
101
platform/internal/handlers/agent_git_identity_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// applyAgentGitIdentity is the platform-level chokepoint for per-agent
|
||||
// commit authorship. These tests pin the generated name/email format
|
||||
// and the operator-override semantics (workspace_secrets wins).
|
||||
|
||||
func TestApplyAgentGitIdentity_FillsFourVars(t *testing.T) {
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "Frontend Engineer")
|
||||
|
||||
cases := map[string]string{
|
||||
"GIT_AUTHOR_NAME": "Molecule AI Frontend Engineer",
|
||||
"GIT_AUTHOR_EMAIL": "frontend-engineer@agents.moleculesai.app",
|
||||
"GIT_COMMITTER_NAME": "Molecule AI Frontend Engineer",
|
||||
"GIT_COMMITTER_EMAIL": "frontend-engineer@agents.moleculesai.app",
|
||||
}
|
||||
for k, want := range cases {
|
||||
if got := env[k]; got != want {
|
||||
t.Errorf("%s: got %q, want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_RespectsOperatorOverride(t *testing.T) {
|
||||
// If a workspace_secret already provides GIT_AUTHOR_NAME (the secret
|
||||
// loader runs before us), that operator intent wins. We only fill in
|
||||
// what isn't already set.
|
||||
env := map[string]string{
|
||||
"GIT_AUTHOR_NAME": "Custom Name",
|
||||
"GIT_AUTHOR_EMAIL": "custom@example.com",
|
||||
}
|
||||
applyAgentGitIdentity(env, "Backend Engineer")
|
||||
|
||||
if env["GIT_AUTHOR_NAME"] != "Custom Name" {
|
||||
t.Errorf("GIT_AUTHOR_NAME should not be overwritten, got %q", env["GIT_AUTHOR_NAME"])
|
||||
}
|
||||
if env["GIT_AUTHOR_EMAIL"] != "custom@example.com" {
|
||||
t.Errorf("GIT_AUTHOR_EMAIL should not be overwritten, got %q", env["GIT_AUTHOR_EMAIL"])
|
||||
}
|
||||
// The COMMITTER pair wasn't pre-set, so defaults fill it in.
|
||||
if env["GIT_COMMITTER_NAME"] != "Molecule AI Backend Engineer" {
|
||||
t.Errorf("GIT_COMMITTER_NAME should be filled, got %q", env["GIT_COMMITTER_NAME"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_EmptyNameIsNoop(t *testing.T) {
|
||||
// A provisioning glitch where the workspace name arrived empty
|
||||
// shouldn't emit `unknown@agents.moleculesai.app` — those commits
|
||||
// are worse than no identity at all (they look like a real misconfig
|
||||
// rather than a recoverable state).
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "")
|
||||
if len(env) != 0 {
|
||||
t.Errorf("empty name should leave env untouched, got %v", env)
|
||||
}
|
||||
// Whitespace-only name also counts as empty.
|
||||
applyAgentGitIdentity(env, " ")
|
||||
if len(env) != 0 {
|
||||
t.Errorf("whitespace name should leave env untouched, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_NilMapIsSafe(t *testing.T) {
|
||||
// Defensive: never panic on a nil map (buildProvisionerConfig signature
|
||||
// doesn't guarantee non-nil). Tests the explicit nil-check.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("applyAgentGitIdentity panicked on nil map: %v", r)
|
||||
}
|
||||
}()
|
||||
applyAgentGitIdentity(nil, "PM")
|
||||
}
|
||||
|
||||
func TestSlugifyForEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Frontend Engineer", "frontend-engineer"},
|
||||
{"Product Marketing Manager", "product-marketing-manager"},
|
||||
{"UIUX Designer", "uiux-designer"},
|
||||
{"PM", "pm"},
|
||||
{"SEO Growth Analyst", "seo-growth-analyst"},
|
||||
{"Social Media Brand", "social-media-brand"},
|
||||
// Odd cases: multiple spaces, punctuation, edge hyphens.
|
||||
{" Extra Spaces ", "extra-spaces"},
|
||||
{"Role (with parens)", "role-with-parens"},
|
||||
{"em—dash", "em-dash"},
|
||||
{"---weird---", "weird"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := slugifyForEmail(tc.in); got != tc.want {
|
||||
t.Errorf("slugifyForEmail(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,17 @@ func (h *WorkspaceHandler) provisionWorkspaceOpts(workspaceID, templatePath stri
|
||||
|
||||
pluginsPath, _ := filepath.Abs(filepath.Join(h.configsDir, "..", "plugins"))
|
||||
awarenessNamespace := h.loadAwarenessNamespace(ctx, workspaceID)
|
||||
|
||||
// Per-agent git identity (Option 3 of agent-separation rollout).
|
||||
// Sets GIT_AUTHOR_* / GIT_COMMITTER_* so commits from each workspace
|
||||
// carry a distinct author in `git log` / `git blame` — instead of
|
||||
// every agent appearing as whoever the shared PAT belongs to. PR +
|
||||
// issue authorship is still tied to GITHUB_TOKEN (shared PAT); that
|
||||
// gets solved by the GitHub App migration (Option 1, follow-up PR).
|
||||
// Runs after secret loads so an operator can still override via a
|
||||
// workspace_secret named GIT_AUTHOR_NAME if they want custom identity.
|
||||
applyAgentGitIdentity(envVars, payload.Name)
|
||||
|
||||
cfg := h.buildProvisionerConfig(workspaceID, templatePath, configFiles, payload, envVars, pluginsPath, awarenessNamespace)
|
||||
cfg.ResetClaudeSession = resetClaudeSession // #12
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user