Skip to content

Commit 68b6858

Browse files
committed
* Add support for Github App based authentication
* Return git commit ID as buildlabel in the XML
1 parent f4dd7a9 commit 68b6858

File tree

3 files changed

+146
-92
lines changed

3 files changed

+146
-92
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 3.0.0
2+
3+
* Add support for Github App based authentication
4+
* Return git commit ID as buildlabel in the XML
5+
16
## 2.2.0
27

38
Added /health endpoint

app.py

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
import os
33
import re
44
import logging
5+
import argparse
56
import datetime
7+
import xml.etree.ElementTree as ET
68
from concurrent.futures import ThreadPoolExecutor
79

8-
import xml.etree.ElementTree as ET
10+
import jwt
911
import requests
1012
from flask import Flask, request, make_response, jsonify
1113
from flask_basicauth import BasicAuth
@@ -24,16 +26,61 @@
2426
TIMEOUT = 10
2527

2628
def get_token():
27-
"""Sets the Github API token
29+
"""Sets the GitHub API token based on the selected mode
2830
2931
Returns:
30-
token: Either from the query parameter or the environment variable
32+
token: Either the personal access token or the GitHub App access token
3133
"""
32-
query_token = request.args.get("token")
34+
parser = argparse.ArgumentParser()
35+
parser.add_argument(
36+
"--mode",
37+
choices=[
38+
"pat-auth",
39+
"app-auth"],
40+
default="pat-auth",
41+
help="Authentication mode")
42+
args = parser.parse_args()
43+
44+
token = request.args.get("token")
45+
46+
if token:
47+
return token
48+
elif args.mode == "pat-auth":
49+
token = os.environ.get("GITHUB_TOKEN")
50+
elif args.mode == "app-auth":
51+
app_auth_id = os.environ.get("APP_AUTH_ID")
52+
app_auth_private_key = os.environ.get("APP_AUTH_PRIVATE_KEY")
53+
app_auth_installation_id = os.environ.get("APP_AUTH_INSTALLATION_ID")
54+
app_auth_base_url = "https://api.github.com"
55+
56+
now = datetime.datetime.utcnow()
57+
iat = int((now - datetime.datetime(1970, 1, 1)).total_seconds())
58+
exp = iat + 600
59+
payload = {
60+
"iat": iat,
61+
"exp": exp,
62+
"iss": app_auth_id
63+
}
64+
encoded_jwt = jwt.encode(
65+
payload,
66+
app_auth_private_key,
67+
algorithm="RS256")
68+
headers = {
69+
"Authorization": f"Bearer {encoded_jwt}",
70+
"Accept": "application/vnd.github.v3+json"
71+
}
72+
response = requests.post(
73+
f"{app_auth_base_url}/app/installations/{app_auth_installation_id}/access_tokens",
74+
headers=headers,
75+
timeout=TIMEOUT)
76+
77+
if response.status_code == 201:
78+
token = response.json()["token"]
79+
else:
80+
raise Exception(
81+
f"Failed to obtain access token: {response.status_code} {response.text}")
3382

34-
if query_token:
35-
return query_token
36-
return os.environ.get("GITHUB_TOKEN")
83+
return token
3784

3885

3986
def get_workflows(owner, repo, headers):
@@ -96,7 +143,11 @@ def get_all_workflow_runs(owner, repo, token):
96143

97144
workflows = get_workflows(owner, repo, headers)
98145
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
99-
futures = [executor.submit(get_workflow_runs, workflow, headers) for workflow in workflows]
146+
futures = [
147+
executor.submit(
148+
get_workflow_runs,
149+
workflow,
150+
headers) for workflow in workflows]
100151

101152
results = []
102153
for future in futures:
@@ -124,7 +175,10 @@ def index():
124175

125176
data = get_all_workflow_runs(owner, repo, token)
126177

127-
workflow_runs = sorted(data, key=lambda run: run["updated_at"], reverse=True)
178+
workflow_runs = sorted(
179+
data,
180+
key=lambda run: run["updated_at"],
181+
reverse=True)
128182

129183
root = ET.Element("Projects")
130184
project_names = set() # Set to store unique project names
@@ -152,12 +206,14 @@ def index():
152206
else "Unknown")
153207
project.set("lastBuildTime", run["updated_at"])
154208
project.set("webUrl", run["html_url"])
209+
short_commit_id = run["head_commit"]["id"][:8]
210+
project.set("lastBuildLabel", short_commit_id)
155211

156212
response = make_response(ET.tostring(root).decode())
157213
response.headers['Content-Type'] = 'application/xml'
158214

159-
logger.info("Request URI: %s Response Code: %d", request.path, response.status_code)
160-
215+
logger.info("Request URI: %s Response Code: %d",
216+
request.path, response.status_code)
161217

162218
return response
163219

@@ -172,9 +228,10 @@ def health():
172228
with open('CHANGELOG.md', 'r', encoding='utf-8') as changelog_file:
173229
changelog_content = changelog_file.read()
174230

175-
latest_version_match = re.search(r'##\s*(\d+\.\d+\.\d+)', changelog_content)
176-
latest_version = latest_version_match.group(1) if latest_version_match else 'Unknown'
177-
231+
latest_version_match = re.search(
232+
r'##\s*(\d+\.\d+\.\d+)', changelog_content)
233+
latest_version = latest_version_match.group(
234+
1) if latest_version_match else 'Unknown'
178235

179236
response = {
180237
'status': 'ok',
@@ -183,6 +240,7 @@ def health():
183240

184241
return jsonify(response)
185242

243+
186244
@app.route('/limit')
187245
@basic_auth.required
188246
def limit():
@@ -192,7 +250,6 @@ def limit():
192250
flask.Response: JSON response containing rate limiting information.
193251
"""
194252
token = get_token()
195-
196253
headers = {
197254
'Accept': 'application/vnd.github+json',
198255
"Authorization": f"Bearer {token}",
@@ -205,7 +262,8 @@ def limit():
205262
rate = response.json().get('rate', {})
206263
reset_unix_time = rate.get('reset', 0)
207264
reset_datetime = datetime.datetime.fromtimestamp(reset_unix_time)
208-
reset_cest = reset_datetime.astimezone(datetime.timezone(datetime.timedelta(hours=2)))
265+
reset_cest = reset_datetime.astimezone(
266+
datetime.timezone(datetime.timedelta(hours=2)))
209267
rate['reset_cest'] = reset_cest.strftime('%Y-%m-%d %H:%M:%S %Z%z')
210268

211269
if rate.get('remaining', 0) == 0:
@@ -219,13 +277,12 @@ def limit():
219277
'rate_limit': rate
220278
}
221279
else:
222-
response = {
223-
'status': 'ok',
224-
'rate_limit': {'error': 'Failed to retrieve rate limit information'}
225-
}
280+
response = {'status': 'ok', 'rate_limit': {
281+
'error': 'Failed to retrieve rate limit information'}}
226282

227283
return jsonify(response)
228284

285+
229286
@app.errorhandler(Exception)
230287
def handle_error(exception):
231288
"""Error handler for handling exceptions raised in the application.

tests/test_app.py

Lines changed: 64 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,71 @@
11
"""Teat Module for the App"""
22
import unittest
3-
from unittest.mock import patch
4-
from app import app
3+
from unittest.mock import patch, MagicMock
4+
from app import get_workflows, get_workflow_runs, app, get_all_workflow_runs
55

66

7-
class TestApp(unittest.TestCase):
7+
class AppTests(unittest.TestCase):
88
"""Test cases for the App module."""
99

1010
def setUp(self):
11-
"""Set up the test environment."""
12-
app.testing = True
13-
app.config['BASIC_AUTH_USERNAME'] = 'user'
14-
app.config['BASIC_AUTH_PASSWORD'] = 'pass'
15-
self.client = app.test_client()
16-
self.headers = {
17-
'Authorization': 'Basic dXNlcjpwYXNz'
18-
}
19-
20-
def test_health_endpoint(self):
21-
"""Test health check status & version in the health route."""
22-
response = self.client.get('/health')
23-
self.assertEqual(response.status_code, 200)
24-
self.assertIn(b'{"status":"ok","version":"2.2.0"}', response.data)
25-
26-
def test_index_missing_parameters(self):
27-
"""Test handling missing parameters in the index route."""
28-
response = self.client.get('/', headers=self.headers)
29-
self.assertEqual(response.status_code, 400)
30-
self.assertIn(b"Missing parameter(s)", response.data)
31-
32-
@patch('app.get_all_workflow_runs')
33-
def test_index_successful_response(self, mock_get_all_workflow_runs):
34-
"""Test successful response in the index route."""
35-
mock_get_all_workflow_runs.return_value = [
36-
{
37-
"name": "CI",
38-
"status": "completed",
39-
"conclusion": "success",
40-
"updated_at": "2021-09-20T18:25:34Z",
41-
"html_url": "https://github.com/owner/repo/actions/runs/1234"
42-
}
43-
]
44-
response = self.client.get('/?owner=owner&repo=repo', headers=self.headers)
45-
self.assertEqual(response.status_code, 200)
46-
self.assertIn(b'<Project', response.data)
47-
self.assertIn(b'name="repo/CI"', response.data)
48-
self.assertIn(b'activity="Sleeping', response.data)
49-
self.assertIn(b'lastBuildStatus="Success"', response.data)
50-
self.assertIn(b'webUrl="https://github.com/owner/repo/actions/runs/1234"', response.data)
51-
52-
@patch('app.get_all_workflow_runs')
53-
def test_index_unknown_build_status(self, mock_get_all_workflow_runs):
54-
"""Test unknown build status in the index route."""
55-
mock_get_all_workflow_runs.return_value = [
56-
{
57-
"name": "CI",
58-
"status": "in_progress",
59-
"conclusion": None,
60-
"updated_at": "2021-09-20T18:25:34Z",
61-
"html_url": "https://github.com/owner/repo/actions/runs/1234"
62-
}
63-
]
64-
response = self.client.get('/?owner=owner&repo=repo', headers=self.headers)
65-
self.assertEqual(response.status_code, 200)
66-
self.assertIn(b'<Project', response.data)
67-
self.assertIn(b'lastBuildStatus="Unknown"', response.data)
68-
69-
@patch('app.get_all_workflow_runs')
70-
def test_index_failed_response(self, mock_get_all_workflow_runs):
71-
"""Test failed response in the index route."""
72-
mock_get_all_workflow_runs.return_value = []
73-
response = self.client.get('/?owner=owner&repo=repo', headers=self.headers)
74-
self.assertEqual(response.status_code, 200)
75-
self.assertIn(b'<Projects />', response.data)
76-
77-
78-
if __name__ == '__main__':
79-
unittest.main()
11+
"""Setup the app"""
12+
self.app = app.test_client()
13+
14+
def test_get_workflows(self):
15+
"""Test case for getting workflows"""
16+
owner = "test_owner"
17+
repo = "test_repo"
18+
headers = {"Authorization": "Bearer test_token",
19+
"Accept": "application/vnd.github.v3+json"}
20+
21+
with patch('requests.get') as mock_get:
22+
mock_response = MagicMock()
23+
mock_response.status_code = 200
24+
mock_response.json.return_value = {
25+
"workflows": ["workflow1", "workflow2"]}
26+
mock_get.return_value = mock_response
27+
28+
workflows = get_workflows(owner, repo, headers)
29+
30+
self.assertEqual(workflows, ["workflow1", "workflow2"])
31+
32+
def test_get_workflow_runs(self):
33+
"""Test case for getting workflow runs"""
34+
workflow = {"url": "test_url"}
35+
headers = {"Authorization": "Bearer test_token",
36+
"Accept": "application/vnd.github.v3+json"}
37+
38+
with patch('requests.get') as mock_get:
39+
mock_response = MagicMock()
40+
mock_response.status_code = 200
41+
mock_response.json.return_value = {
42+
"workflow_runs": ["run1", "run2"]}
43+
mock_get.return_value = mock_response
44+
45+
workflow_runs = get_workflow_runs(workflow, headers)
46+
47+
self.assertEqual(workflow_runs, ["run1", "run2"])
48+
49+
def test_get_all_workflow_runs(self):
50+
"""Test case for getting all workflow runs"""
51+
owner = "test_owner"
52+
repo = "test_repo"
53+
token = "test_token"
54+
headers = {"Authorization": "Bearer test_token",
55+
"Accept": "application/vnd.github.v3+json"}
56+
workflows = [{"url": "workflow1"}, {"url": "workflow2"}]
57+
runs1 = [{"id": 1, "status": "completed", "conclusion": "success"}]
58+
runs2 = [{"id": 2, "status": "completed", "conclusion": "failure"}]
59+
60+
with patch('app.get_workflows') as mock_get_workflows, \
61+
patch('app.get_workflow_runs') as mock_get_workflow_runs:
62+
63+
mock_get_workflows.return_value = workflows
64+
mock_get_workflow_runs.side_effect = [runs1, runs2]
65+
66+
result = get_all_workflow_runs(owner, repo, token)
67+
68+
self.assertEqual(result, runs1 + runs2)
69+
mock_get_workflows.assert_called_once_with(owner, repo, headers)
70+
mock_get_workflow_runs.assert_any_call(workflows[0], headers)
71+
mock_get_workflow_runs.assert_any_call(workflows[1], headers)

0 commit comments

Comments
 (0)