open-swe/agent/utils/authorship.py

187 lines
6.4 KiB
Python
Raw Normal View History

"""Helpers for collaborative commit and PR attribution."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
logger = logging.getLogger(__name__)
OPEN_SWE_BOT_NAME = "open-swe[bot]"
# Use the open-swe user noreply address: the bot's numeric noreply
# (215916821+open-swe[bot]@...) doesn't resolve to a GitHub account Vercel
# accepts, which broke preview deploys on commits carrying this co-author.
OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com"
PR_ATTRIBUTION_FOOTER = "Made by [Open SWE](https://openswe.vercel.app)"
@dataclass(frozen=True)
class CollaboratorIdentity:
"""Identity used for git trailers and PR attribution."""
display_name: str
commit_name: str
commit_email: str
github_login: str = ""
@property
def pr_attribution_name(self) -> str:
"""Display name with GitHub login when available."""
if self.github_login and self.github_login != self.display_name:
return f"{self.display_name} (@{self.github_login})"
return self.display_name
def _normalize_text(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _github_noreply_email(login: str, user_id: Any = None) -> str:
normalized_login = _normalize_text(login)
if not normalized_login:
return ""
normalized_user_id = str(user_id).strip() if user_id is not None else ""
if normalized_user_id:
return f"{normalized_user_id}+{normalized_login}@users.noreply.github.com"
return f"{normalized_login}@users.noreply.github.com"
def _identity_from_github_token(github_token: str | None) -> CollaboratorIdentity | None:
if not github_token:
return None
try:
response = httpx.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout=5.0,
)
if response.status_code != 200: # noqa: PLR2004
logger.debug("GitHub user lookup returned %s", response.status_code)
return None
payload = response.json()
login = _normalize_text(payload.get("login"))
display_name = _normalize_text(payload.get("name")) or login
commit_email = _github_noreply_email(login, payload.get("id")) or _normalize_text(
payload.get("email")
)
if not display_name or not commit_email:
return None
if commit_email == OPEN_SWE_BOT_EMAIL and display_name == OPEN_SWE_BOT_NAME:
return None
return CollaboratorIdentity(
display_name=display_name,
commit_name=display_name,
commit_email=commit_email,
github_login=login,
)
except httpx.HTTPError:
logger.debug("Failed to resolve GitHub user identity from token", exc_info=True)
return None
def _identity_from_config(config: dict[str, Any]) -> CollaboratorIdentity | None:
configurable = config.get("configurable", {})
slack_thread = configurable.get("slack_thread", {})
linear_issue = configurable.get("linear_issue", {})
display_name = (
_normalize_text(slack_thread.get("triggering_user_name"))
or _normalize_text(linear_issue.get("triggering_user_name"))
or _normalize_text(configurable.get("user_email")).split("@", 1)[0]
)
github_login = _normalize_text(configurable.get("github_login"))
if github_login:
github_user_id = configurable.get("github_user_id")
feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369) * Replace hardcoded GitHub-email map with Store-backed user mapping Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional mapping (GitHub login <-> work email <-> optional Slack ID) with an in-process cache, self-service onboarding, and admin management. - agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id indexes, sync cache readers for hot paths, async fallthrough, and a bulk_import that preserves existing richer records. - Migrate all read sites (auth.py, agent_overrides.py, authorship.py, github_comments.py, webapp.py x2) off the dict. - Unmapped Slack tags now run on the GitHub App installation token (use_installation_token_fallback) and get an ephemeral "link your GitHub account" prompt carrying the Slack id + email via a signed account-link token threaded through the OAuth state. - OAuth callback completes a self-service (org-gated) mapping from that token, falling back to the verified GitHub email. - Admin CRUD endpoints + one-time legacy import; dashboard UI section. - Legacy dict retained only as the import payload (no longer read). Tests: mapping store, account-link round-trip + completion, mapped vs unmapped Slack flows; existing trust-gate tests updated to prime cache. * Address review: cold-cache email resolution + stale alias de-indexing - agent_overrides: add resolve_login_from_email_async that falls through to the Store on a cold cache; use it at the async repo-resolution call sites (Slack repo config, Linear comment, owner-metadata) so a mapped user still resolves to their GitHub login + dashboard default_repo on a fresh worker. - user_mappings.upsert_mapping: de-index the existing login before re-indexing so a changed email/Slack id no longer leaves stale aliases resolving to the login in-process. - Tests for both fixes; update Slack repo-config test to patch the async resolver.
2026-06-01 14:37:19 -07:00
from ..dashboard.user_mappings import cached_email_for_login
commit_email = _github_noreply_email(github_login, github_user_id) or _normalize_text(
feat: Store-backed GitHub/Slack user mapping (self-service + admin) (#1369) * Replace hardcoded GitHub-email map with Store-backed user mapping Move the static GITHUB_USER_EMAIL_MAP to a Store-backed bidirectional mapping (GitHub login <-> work email <-> optional Slack ID) with an in-process cache, self-service onboarding, and admin management. - agent/dashboard/user_mappings.py: Store CRUD + login/email/slack-id indexes, sync cache readers for hot paths, async fallthrough, and a bulk_import that preserves existing richer records. - Migrate all read sites (auth.py, agent_overrides.py, authorship.py, github_comments.py, webapp.py x2) off the dict. - Unmapped Slack tags now run on the GitHub App installation token (use_installation_token_fallback) and get an ephemeral "link your GitHub account" prompt carrying the Slack id + email via a signed account-link token threaded through the OAuth state. - OAuth callback completes a self-service (org-gated) mapping from that token, falling back to the verified GitHub email. - Admin CRUD endpoints + one-time legacy import; dashboard UI section. - Legacy dict retained only as the import payload (no longer read). Tests: mapping store, account-link round-trip + completion, mapped vs unmapped Slack flows; existing trust-gate tests updated to prime cache. * Address review: cold-cache email resolution + stale alias de-indexing - agent_overrides: add resolve_login_from_email_async that falls through to the Store on a cold cache; use it at the async repo-resolution call sites (Slack repo config, Linear comment, owner-metadata) so a mapped user still resolves to their GitHub login + dashboard default_repo on a fresh worker. - user_mappings.upsert_mapping: de-index the existing login before re-indexing so a changed email/Slack id no longer leaves stale aliases resolving to the login in-process. - Tests for both fixes; update Slack repo-config test to patch the async resolver.
2026-06-01 14:37:19 -07:00
cached_email_for_login(github_login)
)
if commit_email:
commit_name = display_name or github_login
return CollaboratorIdentity(
display_name=commit_name,
commit_name=commit_name,
commit_email=commit_email,
github_login=github_login,
)
commit_email = _normalize_text(configurable.get("user_email")) or _normalize_text(
slack_thread.get("triggering_user_email")
)
if display_name and commit_email:
return CollaboratorIdentity(
display_name=display_name,
commit_name=display_name,
commit_email=commit_email,
)
return None
def resolve_triggering_user_identity(
config: dict[str, Any],
github_token: str | None = None,
) -> CollaboratorIdentity | None:
"""Resolve the triggering user's git identity.
Prefer the GitHub account identity derived from the token when available.
Fall back to config metadata when the run originated from GitHub or when
Slack/Linear supplied an explicit user name and email.
"""
return _identity_from_github_token(github_token) or _identity_from_config(config)
feat: open Slack-triggered PRs as the triggering user (#1375) * feat: open Slack-triggered PRs as the triggering user Route the Slack per-user GitHub token through the dashboard OAuth store (the backend the self-service link prompt populates) and block runs that lack a valid user token, prompting the user to (re-)link. Per-user OAuth now wins over bot-token-only mode for mapped Slack/dashboard users. Flip commit/PR authorship across all sources: the triggering user is the commit author (via repo-local git identity using their resolvable GitHub noreply email) and open-swe[bot] is the Co-authored-by collaborator. * fix: address PR review — shell-escape commit identity, fix token cache impersonation - Shell-escape the triggering user's name/email with shlex.quote before embedding them in the repo-setup `git config` command, so a name like O'Connor (or a crafted one) can't break or inject into the command. - Stop consulting the shared thread-metadata token cache in _resolve_dashboard_user_token. Slack thread ids are shared across the conversation, so a cached token from a prior triggering user could be returned for the current github_login. Always resolve by login from the dashboard OAuth store instead. * feat: dashboard self-service user mapping + UI cleanup - Add session-scoped GET/PUT /dashboard/api/my-mapping so users can set their own work email / Slack member ID (keyed by their GitHub login, source=self). - Slack account-link prompt now redirects to Profile Settings after auth. - Rename "My Settings" -> "Profile Settings" and "Cloud Agents" -> "Open SWE Agent"; remove the Integrations tab/section (folded out, low value for now) and redirect /integrations to Profile Settings. - Add a "User mapping" section to Profile Settings (work email used by Slack and Linear, optional Slack member ID). - Make dashboard auth cookies scheme-aware: Secure;SameSite=None over HTTPS, non-Secure;SameSite=Lax over http://localhost so local login works. * feat: self-service Slack account linking via Sign in with Slack (OIDC) Replace the spoofable manual work-email/Slack-ID form with a verified "Sign in with Slack" flow so a logged-in GitHub user can only ever link their own Slack identity. - New agent/dashboard/slack_oauth.py: OIDC authorize URL, code exchange, userInfo identity parse, optional workspace gate, configured check. - routes.py: session-gated GET /slack/login and /slack/callback that upsert the mapping from Slack-verified user_id + email (source=slack_oauth). Remove the spoofable PUT /my-mapping; expose slack_oauth_enabled on /me. - UI: drop the editable inputs; add a Connect Slack button + status to the User mapping section. Admin-managed mappings are unaffected and still resolve at trigger time.
2026-06-02 15:04:20 -07:00
def add_bot_coauthor_trailer(commit_message: str) -> str:
"""Append the open-swe[bot] Co-authored-by trailer.
feat: open Slack-triggered PRs as the triggering user (#1375) * feat: open Slack-triggered PRs as the triggering user Route the Slack per-user GitHub token through the dashboard OAuth store (the backend the self-service link prompt populates) and block runs that lack a valid user token, prompting the user to (re-)link. Per-user OAuth now wins over bot-token-only mode for mapped Slack/dashboard users. Flip commit/PR authorship across all sources: the triggering user is the commit author (via repo-local git identity using their resolvable GitHub noreply email) and open-swe[bot] is the Co-authored-by collaborator. * fix: address PR review — shell-escape commit identity, fix token cache impersonation - Shell-escape the triggering user's name/email with shlex.quote before embedding them in the repo-setup `git config` command, so a name like O'Connor (or a crafted one) can't break or inject into the command. - Stop consulting the shared thread-metadata token cache in _resolve_dashboard_user_token. Slack thread ids are shared across the conversation, so a cached token from a prior triggering user could be returned for the current github_login. Always resolve by login from the dashboard OAuth store instead. * feat: dashboard self-service user mapping + UI cleanup - Add session-scoped GET/PUT /dashboard/api/my-mapping so users can set their own work email / Slack member ID (keyed by their GitHub login, source=self). - Slack account-link prompt now redirects to Profile Settings after auth. - Rename "My Settings" -> "Profile Settings" and "Cloud Agents" -> "Open SWE Agent"; remove the Integrations tab/section (folded out, low value for now) and redirect /integrations to Profile Settings. - Add a "User mapping" section to Profile Settings (work email used by Slack and Linear, optional Slack member ID). - Make dashboard auth cookies scheme-aware: Secure;SameSite=None over HTTPS, non-Secure;SameSite=Lax over http://localhost so local login works. * feat: self-service Slack account linking via Sign in with Slack (OIDC) Replace the spoofable manual work-email/Slack-ID form with a verified "Sign in with Slack" flow so a logged-in GitHub user can only ever link their own Slack identity. - New agent/dashboard/slack_oauth.py: OIDC authorize URL, code exchange, userInfo identity parse, optional workspace gate, configured check. - routes.py: session-gated GET /slack/login and /slack/callback that upsert the mapping from Slack-verified user_id + email (source=slack_oauth). Remove the spoofable PUT /my-mapping; expose slack_oauth_enabled on /me. - UI: drop the editable inputs; add a Connect Slack button + status to the User mapping section. Admin-managed mappings are unaffected and still resolve at trigger time.
2026-06-02 15:04:20 -07:00
Commits are authored by the triggering user (via the repo-local git
identity); open-swe[bot] is credited as the collaborator.
"""
normalized_message = commit_message.rstrip()
trailer = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
if trailer in normalized_message:
return normalized_message
return f"{normalized_message}\n\n{trailer}"
def add_pr_collaboration_note(
pr_body: str,
identity: CollaboratorIdentity | None = None,
) -> str:
"""Append the Open SWE attribution footer to a PR body.
The PR is opened as the triggering user, so the body only credits Open SWE
as the collaborator. Any legacy double-attribution footer is replaced.
"""
normalized_body = pr_body.rstrip()
note = PR_ATTRIBUTION_FOOTER
if note in normalized_body:
return normalized_body
legacy_footers: list[str] = []
if identity is not None:
legacy_footers.append(
f"_Opened collaboratively by {identity.pr_attribution_name} and open-swe._"
)
legacy_footers.append(f"_Opened collaboratively by {identity.display_name} and open-swe._")
for legacy in legacy_footers:
if legacy in normalized_body:
return normalized_body.replace(legacy, note)
if not normalized_body:
return note
return f"{normalized_body}\n\n{note}"