splent-feature-github 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- splent_feature_github-0.1.0.dist-info/METADATA +99 -0
- splent_feature_github-0.1.0.dist-info/RECORD +23 -0
- splent_feature_github-0.1.0.dist-info/WHEEL +5 -0
- splent_feature_github-0.1.0.dist-info/licenses/COPYING +674 -0
- splent_feature_github-0.1.0.dist-info/licenses/COPYING.LESSER +165 -0
- splent_feature_github-0.1.0.dist-info/top_level.txt +1 -0
- splent_io/splent_feature_github/__init__.py +30 -0
- splent_io/splent_feature_github/assets/css/github.css +70 -0
- splent_io/splent_feature_github/config.py +18 -0
- splent_io/splent_feature_github/hooks.py +58 -0
- splent_io/splent_feature_github/migrations/alembic.ini +36 -0
- splent_io/splent_feature_github/migrations/env.py +9 -0
- splent_io/splent_feature_github/migrations/script.py.mako +24 -0
- splent_io/splent_feature_github/migrations/versions/7ace6050fb3f_splent_feature_github.py +43 -0
- splent_io/splent_feature_github/models.py +31 -0
- splent_io/splent_feature_github/repositories.py +11 -0
- splent_io/splent_feature_github/routes.py +110 -0
- splent_io/splent_feature_github/services.py +305 -0
- splent_io/splent_feature_github/signals.py +14 -0
- splent_io/splent_feature_github/templates/hooks/github_login_button.html +7 -0
- splent_io/splent_feature_github/templates/hooks/github_profile_row.html +31 -0
- splent_io/splent_feature_github/translations/es/LC_MESSAGES/messages.mo +0 -0
- splent_io/splent_feature_github/translations/es/LC_MESSAGES/messages.po +28 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
|
|
3
|
+
from flask import current_app
|
|
4
|
+
from splent_framework.services.BaseService import BaseService
|
|
5
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
6
|
+
|
|
7
|
+
from splent_io.splent_feature_auth.models import User
|
|
8
|
+
from splent_io.splent_feature_auth.repositories import UserRepository
|
|
9
|
+
from splent_io.splent_feature_github.repositories import GithubRepository
|
|
10
|
+
from splent_io.splent_feature_identities.oauth import oauth_for
|
|
11
|
+
from splent_io.splent_feature_identities.services import IdentitiesService
|
|
12
|
+
from splent_io.splent_feature_profile.repositories import UserProfileRepository
|
|
13
|
+
|
|
14
|
+
PROVIDER = "github"
|
|
15
|
+
# The name people know the provider by, in the messages they read.
|
|
16
|
+
PROVIDER_NAME = "GitHub"
|
|
17
|
+
|
|
18
|
+
# The refusal in a product without signup, when identities is too old to
|
|
19
|
+
# word it (see new_account_refusal).
|
|
20
|
+
CLOSED_REGISTRATION_FALLBACK = (
|
|
21
|
+
"New accounts are opened by an administrator. Ask an administrator for access."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def opens_accounts(identities=None) -> bool:
|
|
26
|
+
"""Whether signing in with GitHub may open a new account here.
|
|
27
|
+
|
|
28
|
+
identities decides, for every provider alike: only a product that
|
|
29
|
+
installs signup opens accounts. registration_is_open is new in the
|
|
30
|
+
identities released with this version of github. Against an older
|
|
31
|
+
identities, which has no such method, the same question is asked here
|
|
32
|
+
(is signup's blueprint registered) instead of raising AttributeError.
|
|
33
|
+
"""
|
|
34
|
+
identities = IdentitiesService() if identities is None else identities
|
|
35
|
+
if hasattr(identities, "registration_is_open"):
|
|
36
|
+
return identities.registration_is_open()
|
|
37
|
+
return "signup" in current_app.blueprints
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def new_account_refusal(identities=None) -> str | None:
|
|
41
|
+
"""None when signing in with GitHub may open an account here, otherwise
|
|
42
|
+
the translated message that refuses it.
|
|
43
|
+
|
|
44
|
+
The message is identities', the same for every provider.
|
|
45
|
+
closed_registration_message is new in the identities released with this
|
|
46
|
+
version of github, so against an older one, which has no such method,
|
|
47
|
+
the refusal is a plain English sentence instead of an AttributeError.
|
|
48
|
+
"""
|
|
49
|
+
identities = IdentitiesService() if identities is None else identities
|
|
50
|
+
if opens_accounts(identities):
|
|
51
|
+
return None
|
|
52
|
+
if hasattr(identities, "closed_registration_message"):
|
|
53
|
+
return identities.closed_registration_message(PROVIDER_NAME)
|
|
54
|
+
return CLOSED_REGISTRATION_FALLBACK
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_github_client(app):
|
|
58
|
+
"""Register GitHub on the product's Authlib registry, once per app."""
|
|
59
|
+
oauth_for(app).register(
|
|
60
|
+
name=PROVIDER,
|
|
61
|
+
client_id=app.config.get("GITHUB_CLIENT_ID") or None,
|
|
62
|
+
client_secret=app.config.get("GITHUB_CLIENT_SECRET") or None,
|
|
63
|
+
access_token_url="https://github.com/login/oauth/access_token",
|
|
64
|
+
authorize_url="https://github.com/login/oauth/authorize",
|
|
65
|
+
client_kwargs={"scope": "user:email"},
|
|
66
|
+
)
|
|
67
|
+
if not app.config.get("GITHUB_CLIENT_ID") or not app.config.get(
|
|
68
|
+
"GITHUB_CLIENT_SECRET"
|
|
69
|
+
):
|
|
70
|
+
app.logger.warning(
|
|
71
|
+
"GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET not configured; GitHub login disabled"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GithubService(BaseService):
|
|
76
|
+
def __init__(self):
|
|
77
|
+
super().__init__(GithubRepository())
|
|
78
|
+
self.user_repository = UserRepository()
|
|
79
|
+
self.user_profile_repository = UserProfileRepository()
|
|
80
|
+
self.identities = IdentitiesService()
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def github_client(self):
|
|
84
|
+
return oauth_for(current_app).create_client(PROVIDER)
|
|
85
|
+
|
|
86
|
+
def new_account_refusal(self) -> str | None:
|
|
87
|
+
return new_account_refusal(self.identities)
|
|
88
|
+
|
|
89
|
+
def get_github_user_info(self, token):
|
|
90
|
+
try:
|
|
91
|
+
resp = self.github_client.get("https://api.github.com/user", token=token)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
current_app.logger.exception("GitHub userinfo request failed: %s", exc)
|
|
94
|
+
return None, "Could not reach GitHub. Please try again."
|
|
95
|
+
|
|
96
|
+
if not resp:
|
|
97
|
+
current_app.logger.error("GitHub userinfo empty response")
|
|
98
|
+
return None, "GitHub did not return user information. Please try again."
|
|
99
|
+
|
|
100
|
+
if resp.status_code != 200:
|
|
101
|
+
current_app.logger.error(
|
|
102
|
+
"GitHub userinfo failed (%s): %s",
|
|
103
|
+
resp.status_code,
|
|
104
|
+
getattr(resp, "text", None),
|
|
105
|
+
)
|
|
106
|
+
if resp.status_code == 429:
|
|
107
|
+
return (
|
|
108
|
+
None,
|
|
109
|
+
"GitHub is rate-limiting requests. Please try again in a minute.",
|
|
110
|
+
)
|
|
111
|
+
return None, "GitHub user information request failed. Please try again."
|
|
112
|
+
|
|
113
|
+
data = resp.json() or {}
|
|
114
|
+
github_id = data.get("id")
|
|
115
|
+
if not github_id:
|
|
116
|
+
current_app.logger.error("GitHub userinfo missing 'id': %s", data)
|
|
117
|
+
return None, "GitHub did not provide a user ID. Please try again."
|
|
118
|
+
|
|
119
|
+
# A GitHub profile email is private by default, so /user often omits it.
|
|
120
|
+
# Without an email there is nothing to converge an existing account on,
|
|
121
|
+
# so fall back to the verified primary address from /user/emails (the
|
|
122
|
+
# granted ``user:email`` scope covers this).
|
|
123
|
+
if not data.get("email"):
|
|
124
|
+
data["email"] = self.get_primary_verified_email(token)
|
|
125
|
+
|
|
126
|
+
return data, None
|
|
127
|
+
|
|
128
|
+
def get_primary_verified_email(self, token):
|
|
129
|
+
try:
|
|
130
|
+
resp = self.github_client.get(
|
|
131
|
+
"https://api.github.com/user/emails", token=token
|
|
132
|
+
)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
current_app.logger.warning("GitHub user/emails request failed: %s", exc)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
if not resp or resp.status_code != 200:
|
|
138
|
+
current_app.logger.warning(
|
|
139
|
+
"GitHub user/emails failed (%s)", getattr(resp, "status_code", None)
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
emails = resp.json() or []
|
|
144
|
+
if not isinstance(emails, list):
|
|
145
|
+
return None
|
|
146
|
+
entries = [e for e in emails if isinstance(e, dict)]
|
|
147
|
+
primary = next(
|
|
148
|
+
(e.get("email") for e in entries if e.get("primary") and e.get("verified")),
|
|
149
|
+
None,
|
|
150
|
+
)
|
|
151
|
+
return primary or next(
|
|
152
|
+
(e.get("email") for e in entries if e.get("verified")), None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _existing_user_for_github(self, github_id: int):
|
|
156
|
+
github_record = self.repository.get_by_github_id(github_id)
|
|
157
|
+
if not github_record:
|
|
158
|
+
return None, github_record
|
|
159
|
+
profile = self.user_profile_repository.get_by_id(github_record.profile_id)
|
|
160
|
+
user = self.user_repository.get_by_id(profile.user_id) if profile else None
|
|
161
|
+
return user, github_record
|
|
162
|
+
|
|
163
|
+
def get_or_create_user(self, user_info):
|
|
164
|
+
if not user_info:
|
|
165
|
+
return None, "Missing GitHub user information."
|
|
166
|
+
|
|
167
|
+
github_id = user_info.get("id")
|
|
168
|
+
if not github_id:
|
|
169
|
+
return None, "Missing GitHub ID."
|
|
170
|
+
|
|
171
|
+
github_login = (user_info.get("login") or "").strip()
|
|
172
|
+
name = (user_info.get("name") or "").strip()
|
|
173
|
+
if not name:
|
|
174
|
+
name = github_login
|
|
175
|
+
|
|
176
|
+
email = (
|
|
177
|
+
(user_info.get("email") or "").strip().lower()
|
|
178
|
+
if user_info.get("email")
|
|
179
|
+
else None
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# 1. Check if this GitHub ID already exists
|
|
184
|
+
user = self.identities.linked_user(PROVIDER, github_id)
|
|
185
|
+
if user:
|
|
186
|
+
return user, None
|
|
187
|
+
|
|
188
|
+
# 2. A product without signup is closed: an administrator opens
|
|
189
|
+
# its accounts, and GitHub is not a way around that. Asked before
|
|
190
|
+
# the email, so the refusal says nothing about who has an account.
|
|
191
|
+
refusal = self.new_account_refusal()
|
|
192
|
+
if refusal:
|
|
193
|
+
return None, refusal
|
|
194
|
+
|
|
195
|
+
# 3. Never link to an existing account by email. GitHub has verified
|
|
196
|
+
# the address, but nothing verified the account's own email (a
|
|
197
|
+
# password signup or an ORCID sign-in can claim any address), so
|
|
198
|
+
# linking would hand this GitHub user someone else's account, or the
|
|
199
|
+
# other way round. Linking goes through the connect flow instead.
|
|
200
|
+
if self.identities.email_in_use(email):
|
|
201
|
+
return None, (
|
|
202
|
+
"An account with this email already exists. Sign in with your usual method "
|
|
203
|
+
"and connect GitHub from your profile."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# 4. Create new user if nothing found
|
|
207
|
+
user = self.user_repository.create(
|
|
208
|
+
commit=False,
|
|
209
|
+
password=secrets.token_urlsafe(24),
|
|
210
|
+
active=True,
|
|
211
|
+
email=email,
|
|
212
|
+
)
|
|
213
|
+
profile = self.user_profile_repository.create(
|
|
214
|
+
commit=False, user_id=user.id, name=name, surname=""
|
|
215
|
+
)
|
|
216
|
+
self.repository.create(
|
|
217
|
+
commit=False,
|
|
218
|
+
github_id=github_id,
|
|
219
|
+
github_login=github_login,
|
|
220
|
+
profile_id=profile.id,
|
|
221
|
+
)
|
|
222
|
+
# The username goes in here too, not only in the connect flow, or the
|
|
223
|
+
# profile would show the numeric id after a GitHub sign-up.
|
|
224
|
+
self.identities.add_identity(
|
|
225
|
+
user.id,
|
|
226
|
+
PROVIDER,
|
|
227
|
+
github_id,
|
|
228
|
+
provider_username=github_login,
|
|
229
|
+
email=email,
|
|
230
|
+
commit=False,
|
|
231
|
+
)
|
|
232
|
+
self.repository.session.commit()
|
|
233
|
+
return user, None
|
|
234
|
+
except IntegrityError as exc:
|
|
235
|
+
current_app.logger.warning(
|
|
236
|
+
"IntegrityError creating GitHub user (%s): %s", github_id, exc
|
|
237
|
+
)
|
|
238
|
+
self.repository.session.rollback()
|
|
239
|
+
user, _ = self._existing_user_for_github(github_id)
|
|
240
|
+
if user:
|
|
241
|
+
return user, None
|
|
242
|
+
return (
|
|
243
|
+
None,
|
|
244
|
+
"Could not create your account due to a concurrency issue. Please try again.",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
except SQLAlchemyError as exc:
|
|
248
|
+
current_app.logger.exception(
|
|
249
|
+
"Database error creating GitHub user (%s): %s", github_id, exc
|
|
250
|
+
)
|
|
251
|
+
self.repository.session.rollback()
|
|
252
|
+
return (
|
|
253
|
+
None,
|
|
254
|
+
"Could not create your account due to a database error. Please try again.",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def link_identity(self, user: User, user_info):
|
|
258
|
+
"""Attach a GitHub identity to an already-authenticated user.
|
|
259
|
+
|
|
260
|
+
Writes both the identity row (used to resolve future logins) and the
|
|
261
|
+
``Github`` domain row (used by ``profile.get_github``), so the connect
|
|
262
|
+
flow leaves the account in the same shape a GitHub login would.
|
|
263
|
+
"""
|
|
264
|
+
github_id = user_info.get("id")
|
|
265
|
+
if not github_id:
|
|
266
|
+
return None, "Missing GitHub ID."
|
|
267
|
+
|
|
268
|
+
github_login = (user_info.get("login") or "").strip()
|
|
269
|
+
email = (user_info.get("email") or "").strip().lower() or None
|
|
270
|
+
|
|
271
|
+
existing = self.identities.get_identity(PROVIDER, github_id)
|
|
272
|
+
if existing and existing.user_id and existing.user_id != user.id:
|
|
273
|
+
return None, "This GitHub account is already linked to another user."
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
if not existing:
|
|
277
|
+
self.identities.add_identity(
|
|
278
|
+
user.id,
|
|
279
|
+
PROVIDER,
|
|
280
|
+
github_id,
|
|
281
|
+
provider_username=github_login,
|
|
282
|
+
email=email,
|
|
283
|
+
commit=False,
|
|
284
|
+
)
|
|
285
|
+
profile = getattr(user, "profile", None)
|
|
286
|
+
if profile and not self.repository.get_by_github_id(github_id):
|
|
287
|
+
self.repository.create(
|
|
288
|
+
commit=False,
|
|
289
|
+
github_id=github_id,
|
|
290
|
+
github_login=github_login,
|
|
291
|
+
profile_id=profile.id,
|
|
292
|
+
)
|
|
293
|
+
self.repository.session.commit()
|
|
294
|
+
return user, None
|
|
295
|
+
except (IntegrityError, SQLAlchemyError) as exc:
|
|
296
|
+
current_app.logger.warning("Could not link GitHub %s: %s", github_id, exc)
|
|
297
|
+
self.repository.session.rollback()
|
|
298
|
+
return None, "Could not connect GitHub. Please try again."
|
|
299
|
+
|
|
300
|
+
def forget(self, user: User, github_id: int) -> None:
|
|
301
|
+
"""Drop the profile's GitHub row after the identity was disconnected."""
|
|
302
|
+
record = self.repository.get_by_github_id(github_id)
|
|
303
|
+
profile = getattr(user, "profile", None)
|
|
304
|
+
if record and profile and record.profile_id == profile.id:
|
|
305
|
+
self.repository.delete(record.id)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Signal handlers for splent_feature_github."""
|
|
2
|
+
|
|
3
|
+
from splent_framework.signals.signal_utils import connect_signal
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@connect_signal("identity-disconnected", "splent_feature_github")
|
|
7
|
+
def on_identity_disconnected(sender, user, provider, provider_id, **kwargs):
|
|
8
|
+
# Drop the profile's GitHub row too, or a later sign-in with the same
|
|
9
|
+
# account would still resolve this user through it.
|
|
10
|
+
if provider != "github" or not str(provider_id).isdigit():
|
|
11
|
+
return
|
|
12
|
+
from splent_io.splent_feature_github.services import GithubService
|
|
13
|
+
|
|
14
|
+
GithubService().forget(user, int(provider_id))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{# In auth.login.providers and signup.providers. GitHub's dark grey comes
|
|
2
|
+
from assets/css/github.css (.btn-github), so the button looks like
|
|
3
|
+
GitHub's in any shell. #}
|
|
4
|
+
<a href="{{ url_for('github.login', next=next_url) if next_url else url_for('github.login') }}"
|
|
5
|
+
class="btn btn-github d-flex align-items-center">
|
|
6
|
+
GitHub
|
|
7
|
+
</a>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{# A section of profile's edit page (profile.edit.sections), below the
|
|
2
|
+
profile form. The disconnect button opens identities' modal, which posts
|
|
3
|
+
the provider and account id it carries. The colours are in
|
|
4
|
+
assets/css/github.css, and the mark is drawn in currentColor. #}
|
|
5
|
+
{% if identity %}
|
|
6
|
+
<div class="mb-4 profile-identity" data-identity="github">
|
|
7
|
+
<label class="form-label fw-semibold">GitHub</label>
|
|
8
|
+
<div class="form-control d-flex align-items-center gap-2 github-identity__field">
|
|
9
|
+
<svg class="github-mark" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
10
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
11
|
+
</svg>
|
|
12
|
+
<span class="github-identity__value">{{ identity.provider_username or identity.provider_id }}</span>
|
|
13
|
+
<button type="button" class="btn btn-sm p-0 github-identity__disconnect" title="{{ _('Disconnect') }}" aria-label="{{ _('Disconnect') }}" data-bs-toggle="modal" data-bs-target="#disconnectModal" data-provider="github" data-provider-id="{{ identity.provider_id }}">
|
|
14
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
15
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
16
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
17
|
+
</svg>
|
|
18
|
+
</button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
{% else %}
|
|
22
|
+
<div class="mb-4 profile-identity" data-identity="github">
|
|
23
|
+
<label class="form-label fw-semibold">GitHub</label>
|
|
24
|
+
<a href="{{ url_for('github.connect_github') }}" class="form-control text-decoration-none d-flex align-items-center gap-2 github-identity__field github-identity__connect">
|
|
25
|
+
<svg class="github-mark" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
26
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
|
27
|
+
</svg>
|
|
28
|
+
<span>{{ _('Connect GitHub') }}</span>
|
|
29
|
+
</a>
|
|
30
|
+
</div>
|
|
31
|
+
{% endif %}
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Spanish translations for PROJECT.
|
|
2
|
+
# Copyright (C) 2026 ORGANIZATION
|
|
3
|
+
# This file is distributed under the same license as the PROJECT project.
|
|
4
|
+
# FIRST AUTHOR <EMAIL@ADDRESS>, 2026.
|
|
5
|
+
#
|
|
6
|
+
msgid ""
|
|
7
|
+
msgstr ""
|
|
8
|
+
"Project-Id-Version: PROJECT VERSION\n"
|
|
9
|
+
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
|
10
|
+
"POT-Creation-Date: 2026-10-21 00:49+0200\n"
|
|
11
|
+
"PO-Revision-Date: 2026-10-21 00:18+0200\n"
|
|
12
|
+
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
|
13
|
+
"Language: es\n"
|
|
14
|
+
"Language-Team: es <LL@li.org>\n"
|
|
15
|
+
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
|
16
|
+
"MIME-Version: 1.0\n"
|
|
17
|
+
"Content-Type: text/plain; charset=utf-8\n"
|
|
18
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
19
|
+
"Generated-By: Babel 2.18.0\n"
|
|
20
|
+
|
|
21
|
+
#: src/splent_io/splent_feature_github/templates/hooks/github_profile_row.html:12
|
|
22
|
+
msgid "Disconnect"
|
|
23
|
+
msgstr "Desconectar"
|
|
24
|
+
|
|
25
|
+
#: src/splent_io/splent_feature_github/templates/hooks/github_profile_row.html:27
|
|
26
|
+
msgid "Connect GitHub"
|
|
27
|
+
msgstr "Conectar GitHub"
|
|
28
|
+
|