2026-03-25 13:39:33 -07:00
|
|
|
"""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]"
|
2026-06-04 19:28:16 -07:00
|
|
|
# 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"
|
2026-03-25 13:39:33 -07:00
|
|
|
|
2026-06-16 09:50:18 -07:00
|
|
|
PR_ATTRIBUTION_TEXT = "Made by [Open SWE]"
|
|
|
|
|
PR_ATTRIBUTION_DEFAULT_URL = "https://openswe.vercel.app"
|
|
|
|
|
PR_ATTRIBUTION_FOOTER = f"{PR_ATTRIBUTION_TEXT}({PR_ATTRIBUTION_DEFAULT_URL})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_pr_attribution_footer(thread_url: str | None = None) -> str:
|
|
|
|
|
"""Build the Open SWE PR footer, linking the run's thread when available."""
|
|
|
|
|
url = thread_url.strip() if isinstance(thread_url, str) and thread_url.strip() else ""
|
|
|
|
|
return f"{PR_ATTRIBUTION_TEXT}({url or PR_ATTRIBUTION_DEFAULT_URL})"
|
2026-06-02 19:52:27 -07:00
|
|
|
|
2026-03-25 13:39:33 -07:00
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class CollaboratorIdentity:
|
|
|
|
|
"""Identity used for git trailers and PR attribution."""
|
|
|
|
|
|
|
|
|
|
display_name: str
|
|
|
|
|
commit_name: str
|
|
|
|
|
commit_email: str
|
2026-06-02 09:27:51 -07:00
|
|
|
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
|
2026-03-25 13:39:33 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-02 09:27:51 -07:00
|
|
|
github_login=login,
|
2026-03-25 13:39:33 -07:00
|
|
|
)
|
|
|
|
|
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", {})
|
2026-06-02 09:27:51 -07:00
|
|
|
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]
|
|
|
|
|
)
|
2026-03-25 13:39:33 -07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-03-25 13:39:33 -07:00
|
|
|
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)
|
2026-03-25 13:39:33 -07:00
|
|
|
)
|
|
|
|
|
if commit_email:
|
2026-06-02 09:27:51 -07:00
|
|
|
commit_name = display_name or github_login
|
2026-03-25 13:39:33 -07:00
|
|
|
return CollaboratorIdentity(
|
2026-06-02 09:27:51 -07:00
|
|
|
display_name=commit_name,
|
|
|
|
|
commit_name=commit_name,
|
2026-03-25 13:39:33 -07:00
|
|
|
commit_email=commit_email,
|
2026-06-02 09:27:51 -07:00
|
|
|
github_login=github_login,
|
2026-03-25 13:39:33 -07:00
|
|
|
)
|
|
|
|
|
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.
|
2026-03-25 13:39:33 -07:00
|
|
|
|
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}>"
|
2026-03-25 13:39:33 -07:00
|
|
|
if trailer in normalized_message:
|
|
|
|
|
return normalized_message
|
|
|
|
|
return f"{normalized_message}\n\n{trailer}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_pr_collaboration_note(
|
|
|
|
|
pr_body: str,
|
2026-06-02 19:52:27 -07:00
|
|
|
identity: CollaboratorIdentity | None = None,
|
2026-06-16 09:50:18 -07:00
|
|
|
thread_url: str | None = None,
|
2026-03-25 13:39:33 -07:00
|
|
|
) -> str:
|
2026-06-02 19:52:27 -07:00
|
|
|
"""Append the Open SWE attribution footer to a PR body.
|
2026-03-25 13:39:33 -07:00
|
|
|
|
2026-06-02 19:52:27 -07:00
|
|
|
The PR is opened as the triggering user, so the body only credits Open SWE
|
2026-06-16 09:50:18 -07:00
|
|
|
as the collaborator. The footer links the run's thread when available. Any
|
|
|
|
|
legacy double-attribution footer is replaced.
|
2026-03-25 13:39:33 -07:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
normalized_body = pr_body.rstrip()
|
2026-06-16 09:50:18 -07:00
|
|
|
note = build_pr_attribution_footer(thread_url)
|
2026-03-25 13:39:33 -07:00
|
|
|
if note in normalized_body:
|
|
|
|
|
return normalized_body
|
2026-06-16 09:50:18 -07:00
|
|
|
if PR_ATTRIBUTION_TEXT in normalized_body:
|
|
|
|
|
return normalized_body
|
2026-06-02 19:52:27 -07:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-25 13:39:33 -07:00
|
|
|
if not normalized_body:
|
|
|
|
|
return note
|
|
|
|
|
return f"{normalized_body}\n\n{note}"
|