gitwebhooks 2.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.
- gitwebhooks/__init__.py +18 -0
- gitwebhooks/__main__.py +10 -0
- gitwebhooks/auth/__init__.py +20 -0
- gitwebhooks/auth/custom.py +40 -0
- gitwebhooks/auth/factory.py +73 -0
- gitwebhooks/auth/gitee.py +86 -0
- gitwebhooks/auth/github.py +52 -0
- gitwebhooks/auth/gitlab.py +29 -0
- gitwebhooks/auth/verifier.py +34 -0
- gitwebhooks/cli/__init__.py +107 -0
- gitwebhooks/cli/config.py +216 -0
- gitwebhooks/cli/init_wizard.py +701 -0
- gitwebhooks/cli/prompts.py +275 -0
- gitwebhooks/cli/service.py +374 -0
- gitwebhooks/config/__init__.py +17 -0
- gitwebhooks/config/loader.py +170 -0
- gitwebhooks/config/models.py +145 -0
- gitwebhooks/config/registry.py +104 -0
- gitwebhooks/config/server.py +85 -0
- gitwebhooks/handlers/__init__.py +22 -0
- gitwebhooks/handlers/base.py +107 -0
- gitwebhooks/handlers/custom.py +73 -0
- gitwebhooks/handlers/factory.py +83 -0
- gitwebhooks/handlers/gitee.py +113 -0
- gitwebhooks/handlers/github.py +43 -0
- gitwebhooks/handlers/gitlab.py +43 -0
- gitwebhooks/handlers/request.py +248 -0
- gitwebhooks/logging/__init__.py +10 -0
- gitwebhooks/logging/setup.py +57 -0
- gitwebhooks/main/__init__.py +117 -0
- gitwebhooks/main/__main__.py +12 -0
- gitwebhooks/models/__init__.py +14 -0
- gitwebhooks/models/provider.py +22 -0
- gitwebhooks/models/request.py +61 -0
- gitwebhooks/models/result.py +40 -0
- gitwebhooks/server.py +128 -0
- gitwebhooks/utils/__init__.py +14 -0
- gitwebhooks/utils/constants.py +55 -0
- gitwebhooks/utils/exceptions.py +34 -0
- gitwebhooks/utils/executor.py +34 -0
- gitwebhooks/utils/systemd.py +274 -0
- gitwebhooks-2.1.0.dist-info/METADATA +494 -0
- gitwebhooks-2.1.0.dist-info/RECORD +47 -0
- gitwebhooks-2.1.0.dist-info/WHEEL +5 -0
- gitwebhooks-2.1.0.dist-info/entry_points.txt +2 -0
- gitwebhooks-2.1.0.dist-info/licenses/LICENSE +21 -0
- gitwebhooks-2.1.0.dist-info/top_level.txt +1 -0
gitwebhooks/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Git Webhooks Server - Modular package structure.
|
|
2
|
+
|
|
3
|
+
Automated deployment server supporting GitHub, Gitee, GitLab, and custom webhooks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = '2.0.0'
|
|
7
|
+
|
|
8
|
+
# Export main classes for convenient import paths
|
|
9
|
+
from .server import WebhookServer
|
|
10
|
+
from .models.provider import Provider
|
|
11
|
+
from .models.request import WebhookRequest
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'__version__',
|
|
15
|
+
'WebhookServer',
|
|
16
|
+
'Provider',
|
|
17
|
+
'WebhookRequest',
|
|
18
|
+
]
|
gitwebhooks/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Authentication and signature verification module
|
|
2
|
+
|
|
3
|
+
Verifies signatures and tokens for webhook requests.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .verifier import SignatureVerifier
|
|
7
|
+
from .github import GithubSignatureVerifier
|
|
8
|
+
from .gitee import GiteeSignatureVerifier
|
|
9
|
+
from .gitlab import GitlabTokenVerifier
|
|
10
|
+
from .custom import CustomTokenVerifier
|
|
11
|
+
from .factory import VerifierFactory
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'SignatureVerifier',
|
|
15
|
+
'GithubSignatureVerifier',
|
|
16
|
+
'GiteeSignatureVerifier',
|
|
17
|
+
'GitlabTokenVerifier',
|
|
18
|
+
'CustomTokenVerifier',
|
|
19
|
+
'VerifierFactory',
|
|
20
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Custom token verifier
|
|
2
|
+
|
|
3
|
+
Verifies tokens from custom webhooks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from gitwebhooks.auth.verifier import SignatureVerifier
|
|
7
|
+
from gitwebhooks.models.result import SignatureVerificationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CustomTokenVerifier(SignatureVerifier):
|
|
11
|
+
"""Custom token verifier"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, verify_enabled: bool = True):
|
|
14
|
+
"""Initialize verifier
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
verify_enabled: Whether verification is enabled (False = always return success)
|
|
18
|
+
"""
|
|
19
|
+
self.verify_enabled = verify_enabled
|
|
20
|
+
|
|
21
|
+
def verify(self, payload: bytes, signature: str, secret: str,
|
|
22
|
+
**kwargs) -> SignatureVerificationResult:
|
|
23
|
+
"""Verify custom token
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
payload: Raw request body bytes (unused)
|
|
27
|
+
signature: Token value from custom header
|
|
28
|
+
secret: Configured secret key
|
|
29
|
+
**kwargs: Unused
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
SignatureVerificationResult instance
|
|
33
|
+
"""
|
|
34
|
+
if not self.verify_enabled:
|
|
35
|
+
return SignatureVerificationResult.success()
|
|
36
|
+
|
|
37
|
+
if signature == secret:
|
|
38
|
+
return SignatureVerificationResult.success()
|
|
39
|
+
else:
|
|
40
|
+
return SignatureVerificationResult.failure('Invalid token')
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Verifier factory class
|
|
2
|
+
|
|
3
|
+
Creates corresponding verifier instances based on provider type.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from gitwebhooks.models.provider import Provider
|
|
7
|
+
from gitwebhooks.auth.verifier import SignatureVerifier
|
|
8
|
+
from gitwebhooks.auth.github import GithubSignatureVerifier
|
|
9
|
+
from gitwebhooks.auth.gitee import GiteeSignatureVerifier
|
|
10
|
+
from gitwebhooks.auth.gitlab import GitlabTokenVerifier
|
|
11
|
+
from gitwebhooks.auth.custom import CustomTokenVerifier
|
|
12
|
+
from gitwebhooks.utils.exceptions import UnsupportedProviderError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VerifierFactory:
|
|
16
|
+
"""Verifier factory class
|
|
17
|
+
|
|
18
|
+
Creates corresponding verifier instances based on provider type
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_verifiers = {
|
|
22
|
+
Provider.GITHUB: GithubSignatureVerifier(),
|
|
23
|
+
Provider.GITEE: GiteeSignatureVerifier(),
|
|
24
|
+
Provider.GITLAB: GitlabTokenVerifier(),
|
|
25
|
+
Provider.CUSTOM: None, # Custom verifier created dynamically
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def get_verifier(cls, provider: Provider,
|
|
30
|
+
verify_enabled: bool = True) -> SignatureVerifier:
|
|
31
|
+
"""Get verifier for the specified provider
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
provider: Provider type
|
|
35
|
+
verify_enabled: Whether verification is enabled (CUSTOM only)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
SignatureVerifier instance
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
UnsupportedProviderError: Unsupported provider
|
|
42
|
+
"""
|
|
43
|
+
if provider == Provider.CUSTOM:
|
|
44
|
+
return CustomTokenVerifier(verify_enabled=verify_enabled)
|
|
45
|
+
|
|
46
|
+
verifier = cls._verifiers.get(provider)
|
|
47
|
+
if verifier is None:
|
|
48
|
+
raise UnsupportedProviderError(f'No verifier for provider: {provider}')
|
|
49
|
+
return verifier
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def create_github_verifier(cls) -> GithubSignatureVerifier:
|
|
53
|
+
"""Create GitHub verifier (convenience method)"""
|
|
54
|
+
return GithubSignatureVerifier()
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def create_gitee_verifier(cls) -> GiteeSignatureVerifier:
|
|
58
|
+
"""Create Gitee verifier (convenience method)"""
|
|
59
|
+
return GiteeSignatureVerifier()
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def create_gitlab_verifier(cls) -> GitlabTokenVerifier:
|
|
63
|
+
"""Create GitLab verifier (convenience method)"""
|
|
64
|
+
return GitlabTokenVerifier()
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create_custom_verifier(cls, verify_enabled: bool = True) -> CustomTokenVerifier:
|
|
68
|
+
"""Create custom verifier (convenience method)
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
verify_enabled: Whether verification is enabled
|
|
72
|
+
"""
|
|
73
|
+
return CustomTokenVerifier(verify_enabled=verify_enabled)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Gitee HMAC-SHA256/password verifier
|
|
2
|
+
|
|
3
|
+
Verifies signatures or passwords from Gitee webhooks.
|
|
4
|
+
|
|
5
|
+
According to Gitee official documentation:
|
|
6
|
+
https://help.gitee.com/webhook/how-to-verify-webhook-keys
|
|
7
|
+
|
|
8
|
+
Signature generation algorithm:
|
|
9
|
+
1. sign_string = timestamp + "\\n" + secret
|
|
10
|
+
2. hmac_sha256 = HmacSHA256(sign_string)
|
|
11
|
+
3. signature = base64(urlEncode(hmac_sha256))
|
|
12
|
+
|
|
13
|
+
Note: The Gitee signature does NOT include the request payload.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
|
|
20
|
+
from gitwebhooks.auth.verifier import SignatureVerifier
|
|
21
|
+
from gitwebhooks.models.result import SignatureVerificationResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GiteeSignatureVerifier(SignatureVerifier):
|
|
25
|
+
"""Gitee HMAC-SHA256/password verifier"""
|
|
26
|
+
|
|
27
|
+
HASH_ALGORITHM = hashlib.sha256
|
|
28
|
+
|
|
29
|
+
def verify(self, payload: bytes, signature: str, secret: str,
|
|
30
|
+
**kwargs) -> SignatureVerificationResult:
|
|
31
|
+
"""Verify Gitee signature or password
|
|
32
|
+
|
|
33
|
+
Gitee sends X-Gitee-Timestamp in both signature and password modes.
|
|
34
|
+
To distinguish between them:
|
|
35
|
+
1. First try password validation (direct comparison)
|
|
36
|
+
2. If password fails and timestamp exists, try signature validation
|
|
37
|
+
|
|
38
|
+
This approach works because:
|
|
39
|
+
- Passwords are typically short plaintext without special chars
|
|
40
|
+
- Signatures are Base64 encoded, containing chars like '+', '/', '='
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
payload: Raw request body bytes (not used in Gitee signature)
|
|
44
|
+
signature: X-Gitee-Token header value
|
|
45
|
+
secret: Webhook secret/password
|
|
46
|
+
**kwargs: May contain 'timestamp' key
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
SignatureVerificationResult instance
|
|
50
|
+
"""
|
|
51
|
+
if signature is None:
|
|
52
|
+
return SignatureVerificationResult.failure('Missing signature or password')
|
|
53
|
+
|
|
54
|
+
# Step 1: Try password validation first (direct comparison)
|
|
55
|
+
if hmac.compare_digest(signature, secret):
|
|
56
|
+
return SignatureVerificationResult.success()
|
|
57
|
+
|
|
58
|
+
# Step 2: If password failed, try signature validation
|
|
59
|
+
timestamp = kwargs.get('timestamp')
|
|
60
|
+
if timestamp is not None:
|
|
61
|
+
# Verify signature
|
|
62
|
+
try:
|
|
63
|
+
timestamp_int = int(timestamp)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return SignatureVerificationResult.failure('Invalid timestamp format')
|
|
66
|
+
|
|
67
|
+
# Gitee signature: timestamp + "\n" + secret
|
|
68
|
+
# Reference: https://help.gitee.com/webhook/how-to-verify-webhook-keys
|
|
69
|
+
sign_string = f'{timestamp_int}\n{secret}'
|
|
70
|
+
secret_bytes = secret.encode('utf-8')
|
|
71
|
+
|
|
72
|
+
signature_bytes = hmac.new(
|
|
73
|
+
secret_bytes,
|
|
74
|
+
sign_string.encode('utf-8'),
|
|
75
|
+
self.HASH_ALGORITHM
|
|
76
|
+
).digest()
|
|
77
|
+
# Gitee sends Base64 encoded signature (NOT URL encoded)
|
|
78
|
+
expected_signature = base64.b64encode(signature_bytes).decode('utf-8')
|
|
79
|
+
|
|
80
|
+
if hmac.compare_digest(signature, expected_signature):
|
|
81
|
+
return SignatureVerificationResult.success()
|
|
82
|
+
else:
|
|
83
|
+
return SignatureVerificationResult.failure('Invalid signature')
|
|
84
|
+
|
|
85
|
+
# Password validation failed and no timestamp for signature validation
|
|
86
|
+
return SignatureVerificationResult.failure('Invalid password')
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""GitHub HMAC-SHA1 signature verifier
|
|
2
|
+
|
|
3
|
+
Verifies HMAC-SHA1 signatures from GitHub webhooks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
|
|
9
|
+
from gitwebhooks.auth.verifier import SignatureVerifier
|
|
10
|
+
from gitwebhooks.models.result import SignatureVerificationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GithubSignatureVerifier(SignatureVerifier):
|
|
14
|
+
"""GitHub HMAC-SHA1 signature verifier"""
|
|
15
|
+
|
|
16
|
+
PREFIX = 'sha1='
|
|
17
|
+
HASH_ALGORITHM = hashlib.sha1
|
|
18
|
+
|
|
19
|
+
def verify(self, payload: bytes, signature: str, secret: str,
|
|
20
|
+
**kwargs) -> SignatureVerificationResult:
|
|
21
|
+
"""Verify GitHub HMAC-SHA1 signature
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
payload: Raw request body bytes
|
|
25
|
+
signature: X-Hub-Signature header value
|
|
26
|
+
secret: Webhook secret
|
|
27
|
+
**kwargs: Unused
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
SignatureVerificationResult instance
|
|
31
|
+
"""
|
|
32
|
+
if signature is None:
|
|
33
|
+
return SignatureVerificationResult.failure('Missing signature')
|
|
34
|
+
|
|
35
|
+
if not secret:
|
|
36
|
+
return SignatureVerificationResult.failure('Secret not configured')
|
|
37
|
+
|
|
38
|
+
if not signature.startswith(self.PREFIX):
|
|
39
|
+
return SignatureVerificationResult.failure('Invalid signature format')
|
|
40
|
+
|
|
41
|
+
secret_bytes = secret.encode('utf-8')
|
|
42
|
+
expected_signature = self.PREFIX + hmac.new(
|
|
43
|
+
secret_bytes,
|
|
44
|
+
payload,
|
|
45
|
+
self.HASH_ALGORITHM
|
|
46
|
+
).hexdigest()
|
|
47
|
+
|
|
48
|
+
# Use constant-time comparison
|
|
49
|
+
if hmac.compare_digest(signature, expected_signature):
|
|
50
|
+
return SignatureVerificationResult.success()
|
|
51
|
+
else:
|
|
52
|
+
return SignatureVerificationResult.failure('Invalid signature')
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""GitLab token verifier
|
|
2
|
+
|
|
3
|
+
Verifies tokens from GitLab webhooks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from gitwebhooks.auth.verifier import SignatureVerifier
|
|
7
|
+
from gitwebhooks.models.result import SignatureVerificationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitlabTokenVerifier(SignatureVerifier):
|
|
11
|
+
"""GitLab token verifier"""
|
|
12
|
+
|
|
13
|
+
def verify(self, payload: bytes, signature: str, secret: str,
|
|
14
|
+
**kwargs) -> SignatureVerificationResult:
|
|
15
|
+
"""Verify GitLab token
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
payload: Raw request body bytes (unused)
|
|
19
|
+
signature: X-Gitlab-Token header value
|
|
20
|
+
secret: Webhook token
|
|
21
|
+
**kwargs: Unused
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
SignatureVerificationResult instance
|
|
25
|
+
"""
|
|
26
|
+
if signature == secret:
|
|
27
|
+
return SignatureVerificationResult.success()
|
|
28
|
+
else:
|
|
29
|
+
return SignatureVerificationResult.failure('Invalid token')
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Signature verifier base class
|
|
2
|
+
|
|
3
|
+
Defines the abstract interface for signature verification.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
|
|
8
|
+
from gitwebhooks.models.result import SignatureVerificationResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignatureVerifier(ABC):
|
|
12
|
+
"""Signature verifier base class
|
|
13
|
+
|
|
14
|
+
All platform-specific verifiers must inherit from this class and implement the verification method.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def verify(self, payload: bytes, signature: str, secret: str,
|
|
19
|
+
**kwargs) -> SignatureVerificationResult:
|
|
20
|
+
"""Verify signature
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
payload: Raw request body bytes
|
|
24
|
+
signature: Signature string from request
|
|
25
|
+
secret: Configured secret key
|
|
26
|
+
**kwargs: Platform-specific additional parameters (e.g., timestamp)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
SignatureVerificationResult instance
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
SignatureValidationError: When verification fails (optional)
|
|
33
|
+
"""
|
|
34
|
+
pass
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI subcommands module
|
|
2
|
+
|
|
3
|
+
Provides subcommand registration and implementation for gitwebhooks-cli.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from gitwebhooks.cli.service import cmd_install, cmd_uninstall
|
|
7
|
+
from gitwebhooks.cli.config import cmd_init, cmd_view
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_subparsers(subparsers):
|
|
11
|
+
"""Register all CLI subparsers
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
subparsers: argparse subparsers object from ArgumentParser.add_subparsers()
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
None
|
|
18
|
+
"""
|
|
19
|
+
# Register service subcommand
|
|
20
|
+
register_service_subparser(subparsers)
|
|
21
|
+
|
|
22
|
+
# Register config subcommand
|
|
23
|
+
register_config_subparser(subparsers)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_service_subparser(subparsers):
|
|
27
|
+
"""Register service subcommand and its actions
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
subparsers: argparse subparsers object
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
None
|
|
34
|
+
"""
|
|
35
|
+
service_parser = subparsers.add_parser(
|
|
36
|
+
'service',
|
|
37
|
+
help='Manage systemd service installation'
|
|
38
|
+
)
|
|
39
|
+
service_subparsers = service_parser.add_subparsers(dest='service_action')
|
|
40
|
+
|
|
41
|
+
# install action
|
|
42
|
+
install_parser = service_subparsers.add_parser('install')
|
|
43
|
+
install_parser.add_argument(
|
|
44
|
+
'--force',
|
|
45
|
+
action='store_true',
|
|
46
|
+
help='Force overwrite existing service'
|
|
47
|
+
)
|
|
48
|
+
install_parser.add_argument(
|
|
49
|
+
'-v', '--verbose',
|
|
50
|
+
action='count',
|
|
51
|
+
default=0,
|
|
52
|
+
help='Increase verbosity (can be used: -v, -vv)'
|
|
53
|
+
)
|
|
54
|
+
install_parser.add_argument(
|
|
55
|
+
'--dry-run',
|
|
56
|
+
action='store_true',
|
|
57
|
+
help='Preview service file without installing'
|
|
58
|
+
)
|
|
59
|
+
install_parser.set_defaults(func=cmd_install)
|
|
60
|
+
|
|
61
|
+
# uninstall action
|
|
62
|
+
uninstall_parser = service_subparsers.add_parser('uninstall')
|
|
63
|
+
uninstall_parser.add_argument(
|
|
64
|
+
'--purge',
|
|
65
|
+
action='store_true',
|
|
66
|
+
help='Also remove configuration file'
|
|
67
|
+
)
|
|
68
|
+
uninstall_parser.set_defaults(func=cmd_uninstall)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_config_subparser(subparsers):
|
|
72
|
+
"""Register config subcommand and its actions
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
subparsers: argparse subparsers object
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
None
|
|
79
|
+
"""
|
|
80
|
+
config_parser = subparsers.add_parser(
|
|
81
|
+
'config',
|
|
82
|
+
help='Manage gitwebhooks configuration'
|
|
83
|
+
)
|
|
84
|
+
config_subparsers = config_parser.add_subparsers(dest='config_action')
|
|
85
|
+
|
|
86
|
+
# init action
|
|
87
|
+
init_parser = config_subparsers.add_parser(
|
|
88
|
+
'init',
|
|
89
|
+
help='Initialize configuration file using interactive wizard'
|
|
90
|
+
)
|
|
91
|
+
init_parser.add_argument(
|
|
92
|
+
'level',
|
|
93
|
+
nargs='?',
|
|
94
|
+
choices=['system', 'local', 'user'],
|
|
95
|
+
help='Configuration level (system/local/user). If not specified, will be prompted.'
|
|
96
|
+
)
|
|
97
|
+
init_parser.set_defaults(func=cmd_init)
|
|
98
|
+
|
|
99
|
+
# view action
|
|
100
|
+
view_parser = config_subparsers.add_parser('view')
|
|
101
|
+
view_parser.add_argument(
|
|
102
|
+
'-c',
|
|
103
|
+
'--config',
|
|
104
|
+
dest='config',
|
|
105
|
+
help='Path to configuration file (default: auto-detect)'
|
|
106
|
+
)
|
|
107
|
+
view_parser.set_defaults(func=cmd_view)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Configuration CLI commands
|
|
2
|
+
|
|
3
|
+
Provides configuration initialization and viewing commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import configparser
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from gitwebhooks.cli.init_wizard import Wizard
|
|
14
|
+
from gitwebhooks.utils.constants import (
|
|
15
|
+
CONFIG_SEARCH_PATHS,
|
|
16
|
+
SENSITIVE_KEYWORDS,
|
|
17
|
+
COLOR_SENSITIVE,
|
|
18
|
+
COLOR_RESET
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
23
|
+
"""Initialize configuration file using interactive wizard
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
args: Parsed command-line arguments
|
|
27
|
+
- level: Configuration level (system/local/user), optional
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Exit code (0 = success, 1 = error)
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
# Create and run wizard
|
|
34
|
+
level = getattr(args, 'level', None)
|
|
35
|
+
wizard = Wizard(level=level)
|
|
36
|
+
config_path = wizard.run()
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
41
|
+
return 1
|
|
42
|
+
except PermissionError as e:
|
|
43
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
except FileExistsError:
|
|
46
|
+
print("操作已取消", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
except KeyboardInterrupt:
|
|
49
|
+
print("\n操作已取消")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def locate_config_file(args: argparse.Namespace) -> Optional[Path]:
|
|
54
|
+
"""Locate configuration file based on arguments or default priority
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
args: Parsed command-line arguments
|
|
58
|
+
- config: User-specified configuration file path (optional)
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Path to configuration file, or None if not found
|
|
62
|
+
"""
|
|
63
|
+
# If user specified a config file with -c, use it
|
|
64
|
+
if hasattr(args, 'config') and args.config:
|
|
65
|
+
config_path = Path(args.config).expanduser()
|
|
66
|
+
if config_path.exists():
|
|
67
|
+
return config_path
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
# Otherwise, search in priority order
|
|
71
|
+
for path_str in CONFIG_SEARCH_PATHS:
|
|
72
|
+
config_path = Path(path_str).expanduser()
|
|
73
|
+
if config_path.exists():
|
|
74
|
+
return config_path
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_view(args: argparse.Namespace) -> int:
|
|
80
|
+
"""View configuration file content
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
args: Parsed command-line arguments
|
|
84
|
+
- config: User-specified configuration file path (optional)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Exit code (0 = success, 1 = error)
|
|
88
|
+
"""
|
|
89
|
+
# Locate configuration file
|
|
90
|
+
config_path = locate_config_file(args)
|
|
91
|
+
|
|
92
|
+
if config_path is None:
|
|
93
|
+
# Handle file not found
|
|
94
|
+
if hasattr(args, 'config') and args.config:
|
|
95
|
+
print(f'Error: Configuration file not found: {args.config}', file=sys.stderr)
|
|
96
|
+
else:
|
|
97
|
+
print('Error: No configuration file found.', file=sys.stderr)
|
|
98
|
+
print('\nSearched paths (in priority order):', file=sys.stderr)
|
|
99
|
+
for i, path_str in enumerate(CONFIG_SEARCH_PATHS, 1):
|
|
100
|
+
expanded = Path(path_str).expanduser()
|
|
101
|
+
print(f' {i}. {expanded}', file=sys.stderr)
|
|
102
|
+
print('\nTo create a configuration file, run:', file=sys.stderr)
|
|
103
|
+
print(' gitwebhooks-cli config init', file=sys.stderr)
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
# Read and parse configuration
|
|
107
|
+
try:
|
|
108
|
+
parser = configparser.ConfigParser()
|
|
109
|
+
parser.read(config_path, encoding='utf-8')
|
|
110
|
+
except configparser.Error as e:
|
|
111
|
+
print(f'Error: Failed to parse configuration file: {config_path}', file=sys.stderr)
|
|
112
|
+
print(f'\nParsing error: {e}', file=sys.stderr)
|
|
113
|
+
print('\nPlease check the file format and fix any syntax errors.', file=sys.stderr)
|
|
114
|
+
return 1
|
|
115
|
+
except PermissionError:
|
|
116
|
+
print(f'Error: Permission denied: {config_path}', file=sys.stderr)
|
|
117
|
+
print('\nPlease check file permissions or run with appropriate privileges.', file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
# Determine source type
|
|
121
|
+
source = 'user-specified' if (hasattr(args, 'config') and args.config) else 'auto-detected'
|
|
122
|
+
|
|
123
|
+
# Format and display output
|
|
124
|
+
print(format_config_header(config_path, source))
|
|
125
|
+
print()
|
|
126
|
+
print(format_config_content(parser))
|
|
127
|
+
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_config_header(config_path: Path, source: str) -> str:
|
|
132
|
+
"""Format configuration file header
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
config_path: Path to configuration file
|
|
136
|
+
source: Source type ('user-specified' or 'auto-detected')
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Formatted header string
|
|
140
|
+
"""
|
|
141
|
+
# Handle symlinks
|
|
142
|
+
display_path = str(config_path)
|
|
143
|
+
if config_path.is_symlink():
|
|
144
|
+
target = config_path.resolve()
|
|
145
|
+
display_path = f'{config_path} -> {target}'
|
|
146
|
+
|
|
147
|
+
return f'Config File: {display_path} (source: {source})'
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_config_content(parser: configparser.ConfigParser) -> str:
|
|
151
|
+
"""Format configuration content by sections
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
parser: ConfigParser instance with loaded configuration
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Formatted configuration content
|
|
158
|
+
"""
|
|
159
|
+
if not parser.sections():
|
|
160
|
+
return '(Configuration file is empty or has no valid sections)'
|
|
161
|
+
|
|
162
|
+
lines = []
|
|
163
|
+
for section in parser.sections():
|
|
164
|
+
lines.append(f'[{section}]')
|
|
165
|
+
for key, value in parser.items(section):
|
|
166
|
+
formatted_value = format_sensitive_field(key, value)
|
|
167
|
+
lines.append(f'{key} = {formatted_value}')
|
|
168
|
+
lines.append('') # Empty line between sections
|
|
169
|
+
|
|
170
|
+
return '\n'.join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def is_sensitive_key(key: str) -> bool:
|
|
174
|
+
"""Check if a configuration key is sensitive
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
key: Configuration key name
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if key contains sensitive keyword, False otherwise
|
|
181
|
+
"""
|
|
182
|
+
key_lower = key.lower()
|
|
183
|
+
return any(keyword in key_lower for keyword in SENSITIVE_KEYWORDS)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def should_use_color() -> bool:
|
|
187
|
+
"""Check if color output should be used
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if color should be used, False otherwise
|
|
191
|
+
"""
|
|
192
|
+
# Check NO_COLOR environment variable
|
|
193
|
+
if os.getenv('NO_COLOR'):
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
# Check TERM environment variable
|
|
197
|
+
term = os.getenv('TERM', '')
|
|
198
|
+
if term == 'dumb':
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def format_sensitive_field(key: str, value: str) -> str:
|
|
205
|
+
"""Format a configuration field with sensitive field highlighting
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
key: Configuration key name
|
|
209
|
+
value: Configuration value
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Formatted value with color if sensitive
|
|
213
|
+
"""
|
|
214
|
+
if is_sensitive_key(key) and should_use_color():
|
|
215
|
+
return f'{COLOR_SENSITIVE}{value}{COLOR_RESET}'
|
|
216
|
+
return value
|