Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,18 +436,29 @@ def export_github(project_id):
username = user_resp.json().get('login')

# 2. Create the repository
# Visibility defaults to private unless the user explicitly opts in to public.
visibility = (request.form.get("visibility") or "").strip().lower()
if visibility not in ("public", "private"):
visibility = "private"

repo_payload = {
"name": repo_name,
"description": f"Starter code for DevPath project: {project['title']}",
"private": False,
"private": visibility != "public",
"auto_init": False
}

create_resp = requests.post("https://api.github.com/user/repos", json=repo_payload, headers=headers)

if create_resp.status_code == 422:
# 422 usually means the repository already exists
pass
# 422 means the repository already exists. Refuse to blind-push into an
# existing repository the user did not explicitly target.
flash(
f"Repository {repo_name} already exists on your GitHub account. "
"Rename or remove it, then try exporting again.",
"error",
)
return redirect(url_for('main.project_detail', project_id=project_id))
elif create_resp.status_code == 403:
flash("GitHub API rate limit exceeded or lack of permissions. Please try again later.", "error")
return redirect(url_for('main.project_detail', project_id=project_id))
Expand All @@ -458,8 +469,6 @@ def export_github(project_id):
elif create_resp.status_code != 201:
flash(f"Failed to create repository. GitHub API responded with {create_resp.status_code}.", "error")
return redirect(url_for('main.project_detail', project_id=project_id))

# If 422, the repo might already exist, which is fine, we can try to push the file anyway.

# 3. Create the file in the repository
file_payload = {
Expand Down
8 changes: 7 additions & 1 deletion src/templates/project.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,14 @@ <h1 class="detail-title">{{ project.title }}</h1>
Download Starter Code
</a>

<form method="POST" action="/project/{{ project.id }}/export_github" style="margin: 0;">
<form method="POST" action="/project/{{ project.id }}/export_github" style="margin: 0; display: flex; gap: 0.5rem; align-items: center;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<label for="repo-visibility" style="font-size: 0.85rem; color: #555;">Repo:</label>
<select id="repo-visibility" name="visibility" aria-label="Repository visibility"
style="font-size: 0.85rem; padding: 0.35rem 0.5rem; border: 1px solid #ddd; border-radius: 6px; background: #fff; color: #333;">
<option value="private" selected>Private</option>
<option value="public">Public</option>
</select>
<button type="submit" class="btn-download" style="background: #24292e; color: white; border: none; cursor: pointer;" aria-label="Start on GitHub">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<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.222v3.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"/>
Expand Down
74 changes: 71 additions & 3 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,21 +1073,89 @@ def test_export_github_api_failures(mock_put, mock_post, mock_get):
assert response.status_code == 302
assert "/project/1000" in response.headers["Location"]

# Test 422 Conflict (repo exists) but file push succeeds
# Test 422 Conflict (repo exists) must NOT blind-push into the existing repo
with app.test_client() as client:
with client.session_transaction() as sess:
sess['github_token'] = {'access_token': 'dummy_token'}

mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"login": "testuser"}
mock_post.return_value.status_code = 422
mock_put.return_value.status_code = 201

response = client.post("/project/1000/export_github")

assert response.status_code == 302
assert "github.com/testuser/DevPath-Starter-valid-code" in response.headers["Location"]
assert "/project/1000" in response.headers["Location"]
mock_put.assert_not_called()


from unittest.mock import patch

@patch("routes.main_routes.requests.get")
@patch("routes.main_routes.requests.post")
@patch("routes.main_routes.requests.put")
def test_export_github_defaults_to_private_repo(mock_put, mock_post, mock_get):
"""Exporting without a visibility choice must create a private repo (issue #1872)."""
with app.app_context():
from models import db, Project
p = db.session.get(Project, 1000)
if not p:
p = Project(
id=1000, title="Valid Code", level="Beg", interest="Web", time="Low",
description="Desc", starter_code="expense_tracker.py"
)
db.session.add(p)
db.session.commit()

with app.test_client() as client:
with client.session_transaction() as sess:
sess['github_token'] = {'access_token': 'dummy_token'}

mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"login": "testuser"}
mock_post.return_value.status_code = 201
mock_put.return_value.status_code = 201

response = client.post("/project/1000/export_github")

assert response.status_code == 302
repo_payload = mock_post.call_args.kwargs["json"]
assert repo_payload["private"] is True


@patch("routes.main_routes.requests.get")
@patch("routes.main_routes.requests.post")
@patch("routes.main_routes.requests.put")
def test_export_github_public_visibility_opt_in(mock_put, mock_post, mock_get):
"""A repo can only be public when the user explicitly selects public (issue #1872)."""
with app.app_context():
from models import db, Project
p = db.session.get(Project, 1000)
if not p:
p = Project(
id=1000, title="Valid Code", level="Beg", interest="Web", time="Low",
description="Desc", starter_code="expense_tracker.py"
)
db.session.add(p)
db.session.commit()

with app.test_client() as client:
with client.session_transaction() as sess:
sess['github_token'] = {'access_token': 'dummy_token'}

mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"login": "testuser"}
mock_post.return_value.status_code = 201
mock_put.return_value.status_code = 201

response = client.post(
"/project/1000/export_github",
data={"visibility": "public"},
)

assert response.status_code == 302
repo_payload = mock_post.call_args.kwargs["json"]
assert repo_payload["private"] is False


def test_sitemap_includes_compare():
Expand Down
Loading