diff --git a/backend/app/app/api/endpoints/abl.py b/backend/app/app/api/endpoints/abl.py
index 0cc2b85a4..c78ccecb2 100644
--- a/backend/app/app/api/endpoints/abl.py
+++ b/backend/app/app/api/endpoints/abl.py
@@ -1,17 +1,18 @@
from typing import List, Optional
from fastapi import APIRouter, Depends
-from httpx import AsyncClient
from sqlalchemy.orm import Session
-from app.core.auth import RequiresRole
+from app.core.auth import RequiresRole, active_user
from app.data_models.models import (
ApprovedBook,
- RequestApproveBook,
+ RequestApproveBooks,
Role,
+ UserSession,
)
from app.db.utils import get_db
-from app.service.abl import add_new_entries, get_abl_info_database
+from app.github.client import github_client
+from app.service.abl import add_to_abl, get_abl_info_database
router = APIRouter()
@@ -28,15 +29,16 @@ async def get_abl_info(
@router.post("/", dependencies=[Depends(RequiresRole(Role.ADMIN))])
-async def add_to_abl(
+async def add_to_abl_request(
*,
+ user: UserSession = Depends(active_user),
db: Session = Depends(get_db),
- info: List[RequestApproveBook],
+ info: RequestApproveBooks,
):
# Creates a new ApprovedBook
# Fetches rex-web release.json
# Removes extraneous ApprovedBook entries for rex-web
# keeps any version that appears in rex-web
# keeps newest version
- async with AsyncClient() as client:
- return await add_new_entries(db, info, client)
+ async with github_client(user) as client:
+ return await add_to_abl(client, db, info)
diff --git a/backend/app/app/api/endpoints/github.py b/backend/app/app/api/endpoints/github.py
index 1672cb7bc..7992eb712 100644
--- a/backend/app/app/api/endpoints/github.py
+++ b/backend/app/app/api/endpoints/github.py
@@ -1,11 +1,13 @@
from typing import List
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.auth import active_user
from app.data_models.models import RepositorySummary, UserSession
from app.db.utils import get_db
+from app.github import get_book_repository
+from app.github.client import github_client
from app.service.user import user_service
router = APIRouter()
@@ -16,3 +18,14 @@ async def repositories(
user: UserSession = Depends(active_user), db: Session = Depends(get_db)
):
return user_service.get_user_repositories(db, user)
+
+
+@router.get("/book-repository/{owner}/{name}")
+async def get_book_repository_endpoint(
+ owner: str,
+ name: str,
+ version: str = Query("main", description="Commit or ref to use"),
+ user: UserSession = Depends(active_user),
+):
+ async with github_client(user) as client:
+ return await get_book_repository(client, name, owner, version)
diff --git a/backend/app/app/core/config.py b/backend/app/app/core/config.py
index 169452182..e6d1595d8 100644
--- a/backend/app/app/core/config.py
+++ b/backend/app/app/core/config.py
@@ -1,4 +1,7 @@
+import json
import os
+import re
+from typing import Any
# DATABASE SETTINGS
POSTGRES_SERVER = os.getenv("POSTGRES_SERVER")
@@ -19,7 +22,11 @@
STACK_NAME = os.getenv("STACK_NAME")
DEPLOYED_AT = os.getenv("DEPLOYED_AT")
-IS_DEV_ENV = STACK_NAME is None or STACK_NAME == "dev"
+IS_PROD = (
+ isinstance(STACK_NAME, str)
+ and re.search(r"[\s:_-]prod(uction)?$", STACK_NAME, re.I) is not None
+)
+IS_DEV_ENV = not IS_PROD
if IS_DEV_ENV:
from dotenv import load_dotenv
@@ -45,3 +52,6 @@
# To encrypt session cookie
SESSION_SECRET = os.getenv("SESSION_SECRET")
ACCESS_TOKEN_EXPIRE_MINUTES = 480
+
+# Optional features
+MAKE_REPO_PUBLIC_ON_APPROVAL = IS_PROD
diff --git a/backend/app/app/data_models/models.py b/backend/app/app/data_models/models.py
index e298e466d..8c2b1a124 100644
--- a/backend/app/app/data_models/models.py
+++ b/backend/app/app/data_models/models.py
@@ -72,11 +72,22 @@ class BaseApprovedBook(BaseModel):
uuid: str
-class RequestApproveBook(BaseApprovedBook):
+class ApprovedBookWithCodeVersion(BaseApprovedBook):
code_version: str
-class ApprovedBook(RequestApproveBook):
+class RepositoryBase(BaseModel):
+ name: str
+ owner: str
+
+
+class RequestApproveBooks(BaseModel):
+ books_to_approve: List[ApprovedBookWithCodeVersion]
+ repository: RepositoryBase
+ make_repo_public: bool = False
+
+
+class ApprovedBook(ApprovedBookWithCodeVersion):
created_at: datetime
committed_at: datetime
repository_name: str
@@ -150,11 +161,6 @@ def get(self, key: str, default: Any = None) -> Any:
return default
-class RepositoryBase(BaseModel):
- name: str
- owner: str
-
-
class Repository(RepositoryBase):
class Config:
orm_mode = True
diff --git a/backend/app/app/github/api.py b/backend/app/app/github/api.py
index 05a7c51f1..b30c519f5 100644
--- a/backend/app/app/github/api.py
+++ b/backend/app/app/github/api.py
@@ -2,7 +2,10 @@
import json
from datetime import datetime
from typing import Any, Dict, List, Tuple
+from urllib.parse import urlencode
+import httpx
+import yaml
from lxml import etree
from app.core.auth import get_user_role
@@ -39,8 +42,7 @@ async def get_book_repository(
query = f"""
query {{
repository(name: "{repo_name}", owner: "{repo_owner}") {{
- databaseId
- viewerPermission
+ {GitHubRepo.graphql_query()}
object(expression: "{version}") {{
oid
... on Commit {{
@@ -59,17 +61,12 @@ async def get_book_repository(
"""
payload = await graphql(client, query)
repository = payload["data"]["repository"]
- repo = GitHubRepo(
- name=repo_name,
- database_id=repository["databaseId"],
- viewer_permission=repository["viewerPermission"],
- )
+ repo = GitHubRepo.from_node(repository)
commit = repository["object"]
if commit is None: # pragma: no cover
raise CustomBaseError(f"Could not find commit '{version}'")
commit_sha = commit["oid"]
commit_timestamp = commit["committedDate"]
- fixed_timestamp = f"{commit_timestamp[:-1]}+00:00"
books_xml = commit["file"]["object"]["text"]
meta = parse_xml_doc(books_xml)
@@ -77,7 +74,7 @@ async def get_book_repository(
{k: get_attr(el, k) for k in ("slug", "style")}
for el in xpath_some(meta, "//*[local-name()='book']")
]
- return (repo, commit_sha, datetime.fromisoformat(fixed_timestamp), books)
+ return (repo, commit_sha, datetime.fromisoformat(commit_timestamp), books)
async def get_collections(
@@ -120,6 +117,66 @@ async def get_collections(
}
+def contents_path(owner: str, repo: str, path: str, version: str | None = None):
+ url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
+ query = {}
+ if version is not None:
+ query["ref"] = version
+ if query:
+ url += f"?{urlencode(query)}"
+ return url
+
+
+async def get_contents(
+ client: AuthenticatedClient, owner: str, repo: str, path: str, version: str
+):
+ response = await client.get(contents_path(owner, repo, path, version))
+ response.raise_for_status()
+ return response.json()
+
+
+async def get_text_contents(
+ client: AuthenticatedClient, owner: str, repo: str, path: str, version: str
+):
+ data = await get_contents(client, owner, repo, path, version)
+ base64content = data["content"]
+ return base64.b64decode(base64content)
+
+
+async def make_repo_public(client: AuthenticatedClient, owner: str, repo: str):
+ branch = "main"
+ path = ".github/settings.yml"
+ commit_message = "Change repository visibility to public"
+ file_exists = True
+ try:
+ contents = await get_contents(client, owner, repo, path, branch)
+ serialized = base64.b64decode(contents["content"]).decode("utf-8")
+ except httpx.HTTPStatusError as hse:
+ # TODO: Maybe use settings from template when it is missing?
+ raise CustomBaseError("Could not get settings file") from hse
+ settings = yaml.load(serialized, yaml.SafeLoader)
+ repository = settings.setdefault("repository", {})
+ if repository.get("private") in (None, True):
+ repository["private"] = False
+ else:
+ raise CustomBaseError(
+ f"{owner}/{repo}: repository may already be public or the "
+ "settings.yml file may contain an invalid value for 'private'"
+ )
+ serialized = yaml.dump(settings, indent=2)
+ await push_to_github(
+ client=client,
+ path=path,
+ content=serialized,
+ owner=owner,
+ repo=repo,
+ branch=branch,
+ commit_message=commit_message,
+ file_exists=file_exists,
+ sha=contents["sha"],
+ )
+
+
async def push_to_github(
client: AuthenticatedClient,
path: str,
@@ -129,9 +186,8 @@ async def push_to_github(
branch: str,
commit_message: str,
file_exists=True,
+ sha: str = "",
):
- url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}"
-
base64content = base64.b64encode(content.encode("utf-8"))
message = {
"message": commit_message,
@@ -140,17 +196,16 @@ async def push_to_github(
}
if file_exists:
- response = await client.get(url + "?ref=" + branch)
- response.raise_for_status()
- data = response.json()
- message["sha"] = data["sha"]
-
- if base64content.decode("utf-8").strip() == data["content"].strip():
- raise CustomBaseError("No changes to push")
+ if sha == "":
+ data = await get_contents(client, owner, repo, path, branch)
+ sha = data["sha"]
+ if base64content.decode("utf-8").strip() == data["content"].strip():
+ raise CustomBaseError("No changes to push")
+ message["sha"] = sha
response = await client.put(
- url,
- data=json.dumps(message),
+ contents_path(owner, repo, path),
+ content=json.dumps(message),
headers={
**client.headers,
"Content-Type": "application/json",
@@ -179,9 +234,7 @@ async def get_user_repositories(
edges {{
node {{
... on Repository {{
- name
- databaseId
- viewerPermission
+ {repo_query}
}}
}}
}}
@@ -196,7 +249,10 @@ async def get_user_repositories(
"https://api.github.com/graphql",
json={
"query": query.format(
- query_args=",".join(":".join(i) for i in query_args.items())
+ query_args=",".join(
+ ":".join(i) for i in query_args.items()
+ ),
+ repo_query=GitHubRepo.graphql_query(),
)
},
)
diff --git a/backend/app/app/github/models.py b/backend/app/app/github/models.py
index 079e91fb1..9832fb4ec 100644
--- a/backend/app/app/github/models.py
+++ b/backend/app/app/github/models.py
@@ -1,8 +1,21 @@
+import re
from enum import Enum
from pydantic import BaseModel
+def snake_to_camel(s: str):
+ return re.sub(r"_[a-z]", lambda match: match.group(0)[-1].upper(), s)
+
+
+def camel_to_snake(s: str):
+ return re.sub(
+ r"[a-z][A-Z]",
+ lambda match: "_".join(c.lower() for c in match.group(0)),
+ s,
+ )
+
+
class RepositoryPermission(int, Enum):
ADMIN = 1
MAINTAIN = 2
@@ -11,21 +24,22 @@ class RepositoryPermission(int, Enum):
WRITE = 5
-class GitHubRepo(BaseModel):
- name: str
- database_id: str
- viewer_permission: str
+class GraphQLModel(BaseModel):
+ @classmethod
+ def graphql_fields(cls):
+ return [snake_to_camel(k) for k in cls.__fields__]
+
+ @classmethod
+ def graphql_query(cls):
+ return "\n".join(cls.graphql_fields())
@classmethod
def from_node(cls, node: dict):
- def to_snake_case(s: str):
- ret = []
- for c in s:
- if c.isupper():
- ret.append("_")
- ret.append(c.lower())
- else:
- ret.append(c)
- return "".join(ret)
-
- return cls(**{to_snake_case(k): v for k, v in node.items()})
+ return cls(**{camel_to_snake(k): v for k, v in node.items()})
+
+
+class GitHubRepo(GraphQLModel):
+ name: str
+ database_id: str
+ viewer_permission: str
+ visibility: str
diff --git a/backend/app/app/service/abl.py b/backend/app/app/service/abl.py
index 7c2b55e6d..8c19af0c1 100644
--- a/backend/app/app/service/abl.py
+++ b/backend/app/app/service/abl.py
@@ -5,9 +5,14 @@
from sqlalchemy import and_, delete, or_, select
from sqlalchemy.orm import Session, lazyload
+import app.github.api as github_api
from app.core import config
from app.core.errors import CustomBaseError
-from app.data_models.models import BaseApprovedBook, RequestApproveBook
+from app.data_models.models import (
+ ApprovedBookWithCodeVersion,
+ BaseApprovedBook,
+ RequestApproveBooks,
+)
from app.db.schema import (
ApprovedBook,
Book,
@@ -16,6 +21,7 @@
Consumer,
Repository,
)
+from app.github.client import AuthenticatedClient
async def get_rex_books(client: AsyncClient):
@@ -51,7 +57,7 @@ def get_rex_book_versions(rex_books: Dict[str, Any], book_uuids: List[str]):
def remove_old_versions(
db: Session,
consumer_id: int,
- to_add: List[RequestApproveBook],
+ to_add: List[ApprovedBookWithCodeVersion],
to_keep: List[BaseApprovedBook] = [],
):
# Default: Remove all previous versions of the books in the set
@@ -110,7 +116,7 @@ def update_versions_by_consumer(
db: Session,
consumer_name: str,
db_books_by_uuid: Dict[str, Book],
- to_add: List[RequestApproveBook],
+ to_add: List[ApprovedBookWithCodeVersion],
to_keep: List[BaseApprovedBook] = [],
):
consumer_id = db.scalars(
@@ -142,7 +148,7 @@ def guess_consumer(book_slug: str) -> str:
async def add_new_entries(
db: Session,
- to_add: List[RequestApproveBook],
+ to_add: List[ApprovedBookWithCodeVersion],
client: AsyncClient,
):
if not to_add: # pragma: no cover
@@ -224,3 +230,16 @@ def get_abl_info_database(
)
return db.scalars(query).all()
+
+
+async def add_to_abl(
+ client: AuthenticatedClient, db: Session, info: RequestApproveBooks
+):
+ if info.make_repo_public:
+ if not config.MAKE_REPO_PUBLIC_ON_APPROVAL:
+ raise CustomBaseError(
+ "ABORT - Cannot make repository public (feature disabled)"
+ )
+ repo = info.repository
+ await github_api.make_repo_public(client, repo.owner, repo.name)
+ return await add_new_entries(db, info.books_to_approve, client)
diff --git a/backend/app/poetry.lock b/backend/app/poetry.lock
index 7bd7ee239..7631ef9f9 100644
--- a/backend/app/poetry.lock
+++ b/backend/app/poetry.lock
@@ -1900,4 +1900,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "dc9a4a9387394fdabb282f6faaa03236bddc0ec1a70481d6dae0fa4d5c3e7a35"
+content-hash = "2294c825e7442149aa980df34a5f1c0b85251daaec923c413800cee3f16873ac"
diff --git a/backend/app/pyproject.toml b/backend/app/pyproject.toml
index fa07ce7e4..1d81795f4 100644
--- a/backend/app/pyproject.toml
+++ b/backend/app/pyproject.toml
@@ -20,6 +20,7 @@ Authlib = "^1.1.0"
itsdangerous = "^2.1.2"
cryptography = "^42.0.4"
ruff = "^0.4.2"
+pyyaml = "^6.0.1"
[tool.poetry.group.dev.dependencies]
pytest = "^6.2.5"
diff --git a/backend/app/tests/unit/conftest.py b/backend/app/tests/unit/conftest.py
index 53bde1ae3..cfb317af7 100644
--- a/backend/app/tests/unit/conftest.py
+++ b/backend/app/tests/unit/conftest.py
@@ -1,5 +1,7 @@
import os
+from typing import Any
+import httpx
import pytest
from fastapi.responses import RedirectResponse
from starlette.testclient import TestClient
@@ -270,39 +272,48 @@ def first(self):
return inner
-@pytest.fixture
-def mock_http_client():
- def inner(get=None, post=None):
- if get is None:
- get = {}
- if post is None:
- post = {}
-
- class MockResponse:
- def __init__(self, expected_result):
- self.expected_result = expected_result
+class MockAsyncClient(httpx.AsyncClient):
+ responses: list[httpx.Response]
- def json(self):
- return self.expected_result
-
- def raise_for_status(self):
- pass
-
- class MockClient:
- def __init__(self):
- self.calls = []
+ def reset_history(self):
+ while self.responses:
+ self.responses.pop()
- async def get(self, url, **kwargs):
- self.calls.append({"type": "get", "kwargs": kwargs, "url": url})
- return MockResponse(get.get(url, None))
- async def post(self, url, **kwargs):
- self.calls.append(
- {"type": "post", "kwargs": kwargs, "url": url}
+@pytest.fixture
+def mock_http_client():
+ def inner(
+ **responses_by_method: dict[httpx.URL, httpx.Response | str | dict],
+ ) -> MockAsyncClient:
+ responses: list[httpx.Response] = []
+ responses_by_method = {
+ k.lower(): v for k, v in responses_by_method.items()
+ }
+
+ def handler(request: httpx.Request):
+ url = request.url
+ method = request.method
+ if isinstance(method, bytearray):
+ method = method.decode()
+ planned_response = responses_by_method.get(method.lower(), {}).get(
+ url
+ )
+ if planned_response is None:
+ response = httpx.Response(404, request=request)
+ elif isinstance(planned_response, httpx.Response):
+ response = planned_response
+ else:
+ response = httpx.Response(
+ 200, json=planned_response, request=request
)
- return MockResponse(post.get(url, None))
+ responses.append(response)
+ return response
- return MockClient()
+ client = MockAsyncClient(
+ mounts={"all://": httpx.MockTransport(handler)}
+ )
+ client.responses = responses
+ return client
return inner
diff --git a/backend/app/tests/unit/data/get_book_repository.yaml b/backend/app/tests/unit/data/get_book_repository.yaml
index 9bb01bb82..7bface202 100644
--- a/backend/app/tests/unit/data/get_book_repository.yaml
+++ b/backend/app/tests/unit/data/get_book_repository.yaml
@@ -1,7 +1,7 @@
interactions:
- request:
body: '{"query": "\n query {\n repository(name: \"tiny-book\",
- owner: \"openstax\") {\n databaseId\n viewerPermission\n object(expression:
+ owner: \"openstax\") {\n name\ndatabaseId\nviewerPermission\nvisibility\n object(expression:
\"main\") {\n oid\n ... on Commit {\n committedDate\n file
(path: \"META-INF/books.xml\") {\n object {\n ...
on Blob {\n text\n }\n }\n }\n }\n }\n }\n }\n "}'
@@ -11,7 +11,7 @@ interactions:
method: POST
uri: https://api.github.com/graphql
response:
- content: '{"data":{"repository":{"databaseId":359188527,"viewerPermission":"WRITE","object":{"oid":"7bc255e285cc4f53debe59b84281cafa002f90cd","committedDate":"2022-09-15T15:41:52Z","file":{"object":{"text":"\n \n\n"}}}}}}'
diff --git a/backend/app/tests/unit/data/get_collections.yaml b/backend/app/tests/unit/data/get_collections.yaml
index 0b5826935..99b320483 100644
--- a/backend/app/tests/unit/data/get_collections.yaml
+++ b/backend/app/tests/unit/data/get_collections.yaml
@@ -17,8 +17,7 @@ interactions:
book\n book-slug1\n en\n col123\n Creative Commons Attribution
License 4.0\n \n \n \n subcollection\n \n We
- still needs titles in the Collxml\n \n \n \n\n"}}]}}}}}}'
+ document=\"m123\" sys:version-at-this-collection-version=\"999.999\"/>\n \n \n \n\n"}}]}}}}}}'
headers:
Content-Type:
- application/json; charset=utf-8
diff --git a/backend/app/tests/unit/data/get_user_repositories.yaml b/backend/app/tests/unit/data/get_user_repositories.yaml
index 9ee716e8f..6c87ce9a6 100644
--- a/backend/app/tests/unit/data/get_user_repositories.yaml
+++ b/backend/app/tests/unit/data/get_user_repositories.yaml
@@ -3,14 +3,14 @@ interactions:
body: '{"query": "\n query {\n search(query:\"org:openstax osbooks
in:name is:public\",first:100,type:REPOSITORY) {\n repositoryCount\n pageInfo
{\n endCursor\n hasNextPage\n }\n edges
- {\n node {\n ... on Repository {\n name\n databaseId\n viewerPermission\n }\n }\n }\n }\n }\n "}'
+ {\n node {\n ... on Repository {\n name\ndatabaseId\nviewerPermission\nvisibility\n }\n }\n }\n }\n }\n "}'
headers:
host:
- api.github.com
method: POST
uri: https://api.github.com/graphql
response:
- content: '{"data":{"search":{"repositoryCount":41,"pageInfo":{"endCursor":"Y3Vyc29yOjQx","hasNextPage":false},"edges":[{"node":{"name":"osbooks-college-physics-bundle","databaseId":424340810,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-writing-guide","databaseId":385349509,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-calculus-bundle","databaseId":420179902,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-sociology","databaseId":447724283,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-fizyka-bundle","databaseId":349214243,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-university-physics-bundle","databaseId":331073080,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-astronomy","databaseId":428806530,"viewerPermission":"TRIAGE"}},{"node":{"name":"template-osbooks-new","databaseId":384528039,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-precalculo","databaseId":419790341,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-entrepreneurship","databaseId":429096449,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-american-government","databaseId":447725992,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-playground","databaseId":381823217,"viewerPermission":"WRITE"}},{"node":{"name":"osbooks-quimica-bundle","databaseId":473020953,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-psychologia","databaseId":428786834,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-psychology","databaseId":447304990,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-statistics","databaseId":349214030,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-us-history","databaseId":424342926,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-business","databaseId":428792524,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-finance","databaseId":406121277,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-political-science","databaseId":402484075,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-microbiology","databaseId":428802781,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-calculo-bundle","databaseId":419763545,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-college-algebra-bundle","databaseId":329400042,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introductory-statistics-bundle","databaseId":414272555,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-business-ethics","databaseId":428793576,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-accounting-bundle","databaseId":428805571,"viewerPermission":"TRIAGE"}},{"node":{"name":"template-osbooks","databaseId":368908485,"viewerPermission":"ADMIN"}},{"node":{"name":"osbooks-physics","databaseId":428807294,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-economics-bundle","databaseId":447297254,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduccion-estadistica-bundle","databaseId":401836524,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-fisica-universitaria-bundle","databaseId":421906193,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-biology-bundle","databaseId":428804196,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-life-liberty-and-pursuit-happiness","databaseId":429093860,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-business-law","databaseId":349213981,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-college-success-bundle","databaseId":428790003,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-principles-of-management-bundle","databaseId":349214008,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-intellectual-property","databaseId":428794418,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-chemistry-bundle","databaseId":439412338,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-prealgebra-bundle","databaseId":447720214,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-anatomy-physiology","databaseId":428812124,"viewerPermission":"TRIAGE"}},{"node":{"name":"osbooks-introduction-anthropology","databaseId":387500800,"viewerPermission":"TRIAGE"}}]}}}'
+ content: '{"data":{"search":{"repositoryCount":49,"pageInfo":{"endCursor":"Y3Vyc29yOjQ5","hasNextPage":false},"edges":[{"node":{"name":"osbooks-university-physics-bundle","databaseId":331073080,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-physics-bundle","databaseId":424340810,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-writing-guide","databaseId":385349509,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-sociology","databaseId":447724283,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-fizyka-bundle","databaseId":349214243,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-economics-bundle","databaseId":447297254,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-astronomy","databaseId":428806530,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-anatomy-physiology","databaseId":428812124,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-fisica-universitaria-bundle","databaseId":421906193,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-american-government","databaseId":447725992,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-precalculo","databaseId":419790341,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-mikroekonomia","databaseId":522451624,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-entrepreneurship","databaseId":429096449,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-playground","databaseId":381823217,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-finance","databaseId":406121277,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-world-history","databaseId":402811523,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-calculus-bundle","databaseId":420179902,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-psychologia","databaseId":428786834,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-quimica-bundle","databaseId":473020953,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-makroekonomia","databaseId":685988600,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-microbiology","databaseId":428802781,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-us-history","databaseId":424342926,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"template-osbooks","databaseId":368908485,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-physics","databaseId":428807294,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"template-osbooks-new","databaseId":384528039,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-business","databaseId":428792524,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-biology-bundle","databaseId":428804196,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-political-science","databaseId":402484075,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-business-ethics","databaseId":428793576,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-calculo-bundle","databaseId":419763545,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-marketing","databaseId":499553497,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-philosophy","databaseId":405996480,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-psychology","databaseId":447304990,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-statistics","databaseId":349214030,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-business-law","databaseId":349213981,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-contemporary-mathematics","databaseId":398311847,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-anthropology","databaseId":387500800,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-organic-chemistry","databaseId":501678967,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-prealgebra-bundle","databaseId":447720214,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-chemistry-bundle","databaseId":439412338,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduccion-estadistica-bundle","databaseId":401836524,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-workplace-software-skills","databaseId":387540868,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-accounting-bundle","databaseId":428805571,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-algebra-bundle","databaseId":329400042,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introductory-statistics-bundle","databaseId":414272555,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-college-success-bundle","databaseId":428790003,"viewerPermission":"ADMIN","visibility":"PUBLIC"}},{"node":{"name":"osbooks-introduction-intellectual-property","databaseId":428794418,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-life-liberty-and-pursuit-happiness","databaseId":429093860,"viewerPermission":"WRITE","visibility":"PUBLIC"}},{"node":{"name":"osbooks-principles-of-management-bundle","databaseId":349214008,"viewerPermission":"WRITE","visibility":"PUBLIC"}}]}}}'
headers:
Content-Type:
- application/json; charset=utf-8
diff --git a/backend/app/tests/unit/data/get_user_teams.yaml b/backend/app/tests/unit/data/get_user_teams.yaml
index 10b3f413e..a683100bb 100644
--- a/backend/app/tests/unit/data/get_user_teams.yaml
+++ b/backend/app/tests/unit/data/get_user_teams.yaml
@@ -10,10 +10,11 @@ interactions:
method: POST
uri: https://api.github.com/graphql
response:
- content: '{"data":{"organization":{"teams":{"totalCount":4,"edges":[{"node":{"name":"all","description":"Openstax
+ content: '{"data":{"organization":{"teams":{"totalCount":6,"edges":[{"node":{"name":"all","description":"Openstax
folks"}},{"node":{"name":"ce-tech","description":"The CE Tech team"}},{"node":{"name":"ce-all","description":"Discussion
- board for the Content Engineering Team"}},{"node":{"name":"ce-be","description":"CE
- Backend developers"}}]}}}}'
+ board for the Content Engineering Team"}},{"node":{"name":"ce-admins","description":"The
+ GitHub Admin team for Content Engineering repositories"}},{"node":{"name":"all-devs","description":"All
+ OpenStax users"}},{"node":{"name":"ce-be","description":"CE Backend developers"}}]}}}}'
headers:
Content-Type:
- application/json; charset=utf-8
diff --git a/backend/app/tests/unit/test_abl.py b/backend/app/tests/unit/test_abl.py
index 762eea6d0..6cf19b375 100644
--- a/backend/app/tests/unit/test_abl.py
+++ b/backend/app/tests/unit/test_abl.py
@@ -1,14 +1,22 @@
import pytest
from app.core import config
-from app.data_models.models import BaseApprovedBook, RequestApproveBook
+from app.core.errors import CustomBaseError
+from app.data_models.models import (
+ ApprovedBookWithCodeVersion,
+ BaseApprovedBook,
+ Repository,
+ RequestApproveBooks,
+)
from app.db.schema import ApprovedBook, Book, CodeVersion
from app.service.abl import (
add_new_entries,
+ add_to_abl,
get_abl_info_database,
get_or_add_code_version,
get_rex_book_versions,
)
+from tests.unit.conftest import MockAsyncClient
@pytest.mark.parametrize(
@@ -58,17 +66,29 @@ def test_get_or_add_code_version(mock_session):
"to_add,to_keep",
[
[
- [RequestApproveBook(commit_sha="a", uuid="b", code_version="42")],
+ [
+ ApprovedBookWithCodeVersion(
+ commit_sha="a", uuid="b", code_version="42"
+ )
+ ],
{},
],
[
- [RequestApproveBook(commit_sha="a", uuid="b", code_version="42")],
+ [
+ ApprovedBookWithCodeVersion(
+ commit_sha="a", uuid="b", code_version="42"
+ )
+ ],
{"b": {"defaultVersion": "something"}},
],
[
[
- RequestApproveBook(commit_sha="a", uuid="b", code_version="42"),
- RequestApproveBook(commit_sha="a", uuid="c", code_version="42"),
+ ApprovedBookWithCodeVersion(
+ commit_sha="a", uuid="b", code_version="42"
+ ),
+ ApprovedBookWithCodeVersion(
+ commit_sha="a", uuid="c", code_version="42"
+ ),
],
{"b": {"defaultVersion": "b"}, "c": {"defaultVersion": "c"}},
],
@@ -95,11 +115,11 @@ def mock_database_logic(session_obj):
return []
db = mock_session(mock_database_logic)
- client = mock_http_client(
+ client: MockAsyncClient = mock_http_client(
get={config.REX_WEB_RELEASE_URL: {"books": to_keep}}
)
await add_new_entries(db, to_add, client)
- assert "headers" in client.calls[-1]["kwargs"]
+ assert "authorization" not in client.responses[-1].request.headers
assert not db.did_rollback
assert db.did_commit
# Twice as many because code version is added each time in the test
@@ -151,3 +171,68 @@ def mock_database_logic(session_obj):
def test_get_rex_book_versions(rex_books, book_uuids, expected):
rex_book_versions = get_rex_book_versions(rex_books, book_uuids)
assert rex_book_versions == expected
+
+
+@pytest.mark.asyncio
+async def test_add_to_abl_abort(mocker):
+ # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL disabled
+ mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", False)
+ add_new_entries_stub = mocker.async_stub()
+ make_repo_public_stub = mocker.async_stub()
+ mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub)
+ mocker.patch(
+ "app.service.abl.github_api.make_repo_public", make_repo_public_stub
+ )
+ repo = Repository(name="test", owner="test")
+ # WHEN: make_repo_public is true (client should not do this)
+ info = RequestApproveBooks(
+ books_to_approve=[], repository=repo, make_repo_public=True
+ )
+ # THEN: An error is raised and the process is aborted
+ with pytest.raises(CustomBaseError) as cbe:
+ await add_to_abl(mocker.stub(), mocker.stub(), info)
+ make_repo_public_stub.assert_not_called()
+ add_new_entries_stub.assert_not_called()
+ assert cbe.match("ABORT")
+
+
+@pytest.mark.asyncio
+async def test_add_to_abl_make_book_public(mocker):
+ # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL enabled
+ mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", True)
+ add_new_entries_stub = mocker.async_stub()
+ make_repo_public_stub = mocker.async_stub()
+ mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub)
+ mocker.patch(
+ "app.service.abl.github_api.make_repo_public", make_repo_public_stub
+ )
+ repo = Repository(name="test", owner="test")
+ # WHEN: make_repo_public is true
+ info = RequestApproveBooks(
+ books_to_approve=[], repository=repo, make_repo_public=True
+ )
+ await add_to_abl(mocker.stub(), mocker.stub(), info)
+ # THEN: make_repo_public and add_new_entries are called
+ make_repo_public_stub.assert_called_once()
+ add_new_entries_stub.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_add_to_abl_do_not_make_book_public(mocker):
+ # GIVEN: MAKE_REPO_PUBLIC_ON_APPROVAL enabled
+ mocker.patch("app.service.abl.config.MAKE_REPO_PUBLIC_ON_APPROVAL", True)
+ add_new_entries_stub = mocker.async_stub()
+ make_repo_public_stub = mocker.async_stub()
+ mocker.patch("app.service.abl.add_new_entries", add_new_entries_stub)
+ mocker.patch(
+ "app.service.abl.github_api.make_repo_public", make_repo_public_stub
+ )
+ repo = Repository(name="test", owner="test")
+ # WHEN: make_repo_public is false
+ info = RequestApproveBooks(
+ books_to_approve=[], repository=repo, make_repo_public=False
+ )
+ await add_to_abl(mocker.stub(), mocker.stub(), info)
+ # THEN: only add_new_entries is called
+ make_repo_public_stub.assert_not_called()
+ add_new_entries_stub.assert_called_once()
diff --git a/backend/app/tests/unit/test_github_api.py b/backend/app/tests/unit/test_github_api.py
index 615d0d930..9be428ede 100644
--- a/backend/app/tests/unit/test_github_api.py
+++ b/backend/app/tests/unit/test_github_api.py
@@ -1,6 +1,19 @@
+import json
+from base64 import b64decode, b64encode
+from datetime import datetime
+from hashlib import sha256
+from typing import cast
+
+import httpx
import pytest
from httpx import AsyncClient
+from app.core.errors import CustomBaseError
+from app.github.api import make_repo_public, push_to_github
+from app.github.client import AuthenticatedClient
+from app.github.models import GitHubRepo
+from tests.unit.conftest import MockAsyncClient
+
@pytest.mark.unit
@pytest.mark.nondestructive
@@ -27,8 +40,6 @@ async def test_get_user_and_teams(monkeypatch, mock_github_api):
@pytest.mark.nondestructive
@pytest.mark.asyncio
async def test_get_book_repository(monkeypatch, mock_github_api):
- from datetime import datetime
-
exc = None
repo = None
books = None
@@ -52,6 +63,11 @@ async def test_get_book_repository(monkeypatch, mock_github_api):
assert isinstance(commit_sha, str)
assert isinstance(timestamp, datetime)
assert isinstance(books, list)
+ assert isinstance(repo, GitHubRepo)
+ assert isinstance(repo.visibility, str)
+ assert datetime.fromisoformat(
+ "2024-06-14T16:26:24.611Z"
+ ) == datetime.fromisoformat("2024-06-14T16:26:24.611+00:00")
assert len(books) > 0
@@ -94,3 +110,280 @@ async def test_get_user_repositories(monkeypatch, mock_github_api):
assert isinstance(repos, list)
assert len(repos) > 0
assert isinstance(repos[0], GitHubRepo)
+
+
+@pytest.mark.unit
+@pytest.mark.nondestructive
+@pytest.mark.asyncio
+async def test_push_to_github(mock_http_client):
+ owner = "test_owner"
+ repo = "test_repo"
+ path = "README.md"
+ content = original_content = "# README"
+ branch = "main"
+ sha = sha256(content.encode()).hexdigest()
+ commit_message = "commit message"
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": {
+ "content": b64encode(content.encode()).decode(),
+ "sha": sha,
+ }
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": "OK"
+ },
+ )
+
+ # GIVEN: No changes to existing file
+ # WHEN: A push is attempted
+ with pytest.raises(CustomBaseError) as hse:
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ True,
+ )
+
+ # THEN: An error is raised before a PUT request is made
+ assert len(client.responses) == 1
+ request: httpx.Request = client.responses[-1].request
+ assert request.method == "GET"
+ assert hse.match("No changes to push")
+
+ # GIVEN: Changes to existing file
+ content = f"{original_content} with changes"
+ new_content = b64encode(content.encode()).decode()
+ client.reset_history()
+
+ # WHEN: A push is attempted
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ True,
+ )
+
+ # THEN: There is a GET and PUT request; the PUT request has correct data
+ assert len(client.responses) == 2
+ assert [r.request.method for r in client.responses] == ["GET", "PUT"]
+ put_request: httpx.Request = client.responses[1].request
+ response_content = put_request.content
+ data = json.loads(response_content)
+ # Sends to new content
+ assert data.get("content") == new_content
+ # Sends the sha of file being updated
+ assert data.get("sha") == sha
+ assert data.get("branch") == branch
+ assert data.get("message") == commit_message
+
+ client.reset_history()
+
+ # GIVEN: Changes to non-existing file
+ content = f"{original_content} with changes"
+ # WHEN: A push is attempted
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ False,
+ )
+
+ # THEN: There is one PUT request; the PUT request has correct data
+ assert len(client.responses) == 1
+ assert client.responses[0].request.method == "PUT"
+ put_request: httpx.Request = client.responses[0].request
+ response_content = put_request.content
+ data = json.loads(response_content)
+ # Sends to new content
+ assert data.get("content") == new_content
+ # No sha because file is new
+ assert data.get("sha") is None
+ assert data.get("branch") == branch
+ assert data.get("message") == commit_message
+
+ client.reset_history()
+ # WHEN: Changes are pushed with a sha
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ False,
+ sha,
+ )
+ # THEN: Only one request is made
+ assert [r.request.method for r in client.responses] == ["PUT"]
+
+ # GIVEN: Changes to non-existing file; an error from github
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": httpx.Response(404)
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": httpx.Response(404)
+ },
+ )
+ content = f"{original_content} with changes"
+ # WHEN: Changes are pushed
+ with pytest.raises(httpx.HTTPStatusError) as hse:
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ False,
+ )
+
+ # THEN: An error is raised when a PUT request is made
+ assert len(client.responses) == 1
+ request: httpx.Request = client.responses[-1].request
+ assert request.method == "PUT"
+ assert hse.match("404")
+
+ client.reset_history()
+ # GIVEN: Changes to existing file; error from github
+ with pytest.raises(httpx.HTTPStatusError) as hse:
+ await push_to_github(
+ cast(AuthenticatedClient, client),
+ path,
+ content,
+ owner,
+ repo,
+ branch,
+ commit_message,
+ True,
+ )
+
+ # THEN: An error is raised when a GET request is made
+ assert len(client.responses) == 1
+ request: httpx.Request = client.responses[-1].request
+ assert request.method == "GET"
+ assert hse.match("404")
+
+
+@pytest.mark.unit
+@pytest.mark.nondestructive
+@pytest.mark.asyncio
+async def test_make_repo_public(mock_http_client):
+ owner = "test_owner"
+ repo = "test_repo"
+ path = ".github/settings.yml"
+ text = b64encode(b"{}").decode()
+ sha = sha256(b"{}").hexdigest()
+ content = {"content": text, "sha": sha}
+ branch = "main"
+ # GIVEN: A repository with a settings yaml file
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": content
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": "OK"
+ },
+ )
+
+ # WHEN: We attempt to make it public
+ await make_repo_public(client, owner, repo)
+ # THEN: There are 2 requests and the expected data is sent
+ assert [r.request.method for r in client.responses] == ["GET", "PUT"]
+ put_request = client.responses[-1].request
+ data = json.loads(put_request.content)
+ assert (
+ b64decode(data.get("content", "")) == b"repository:\n private: false\n"
+ )
+ assert data.get("sha") == sha
+ assert data.get("branch") == branch
+
+ # GIVEN: Private is set to true
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": content
+ | {"content": b64encode(b"repository:\n private: true\n").decode()}
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": "OK"
+ },
+ )
+ # WHEN: We attempt to make it public
+ await make_repo_public(client, owner, repo)
+ # THEN: It works as if private was not set
+ assert [r.request.method for r in client.responses] == ["GET", "PUT"]
+ put_request = client.responses[-1].request
+ data = json.loads(put_request.content)
+ assert (
+ b64decode(data.get("content", "")) == b"repository:\n private: false\n"
+ )
+
+ # GIVEN: Missing settings file
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": httpx.Response(404)
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": httpx.Response(
+ 500, text="This should not happen"
+ )
+ },
+ )
+ # WHEN: We attempt to make it public
+ # THEN: An error about missing settings file is raised
+ with pytest.raises(CustomBaseError) as cbe:
+ await make_repo_public(client, owner, repo)
+ assert cbe.match("Could not get settings file")
+
+ # GIVEN: settings file that is already public
+ client: MockAsyncClient = mock_http_client(
+ get={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}?ref={branch}": (
+ content
+ | {
+ "content": b64encode(
+ b"repository:\n private: false\n"
+ ).decode()
+ }
+ )
+ },
+ put={
+ "https://api.github.com/repos/"
+ f"{owner}/{repo}/contents/{path}": httpx.Response(
+ 500, text="This should not happen"
+ )
+ },
+ )
+ # WHEN: We attempt to make it public
+ # THEN: An error about repo already being public or a possible error in
+ # settings.yml is raised
+ with pytest.raises(CustomBaseError) as cbe:
+ await make_repo_public(client, owner, repo)
+ assert cbe.match("repository may already be public")
+ assert cbe.match("settings.yml")
diff --git a/backend/app/tests/unit/test_github_models.py b/backend/app/tests/unit/test_github_models.py
index 4a50405f2..36b5334d6 100644
--- a/backend/app/tests/unit/test_github_models.py
+++ b/backend/app/tests/unit/test_github_models.py
@@ -7,8 +7,14 @@
@pytest.mark.nondestructive
def test_github_repo_model():
model = GitHubRepo.from_node(
- {"name": "test", "databaseId": 1, "viewerPermission": "WRITE"}
+ {
+ "name": "test",
+ "databaseId": 1,
+ "viewerPermission": "WRITE",
+ "visibility": "PUBLIC",
+ }
)
assert model.name == "test"
assert model.database_id == "1"
assert model.viewer_permission == "WRITE"
+ assert model.visibility == "PUBLIC"
diff --git a/backend/app/tests/unit/test_github_utils.py b/backend/app/tests/unit/test_github_utils.py
index 6d1542906..790074b6d 100644
--- a/backend/app/tests/unit/test_github_utils.py
+++ b/backend/app/tests/unit/test_github_utils.py
@@ -9,7 +9,10 @@
id=1, token="fake", role=Role.ADMIN, avatar_url="", name="TestUser"
)
FAKE_REPO = GitHubRepo(
- name="osbooks-fake-book", database_id="1234", viewer_permission="WRITE"
+ name="osbooks-fake-book",
+ database_id="1234",
+ viewer_permission="WRITE",
+ visibility="PUBLIC",
)
diff --git a/docker-compose.stack.dev.yml b/docker-compose.stack.dev.yml
index 7262cc923..0a59a11ed 100644
--- a/docker-compose.stack.dev.yml
+++ b/docker-compose.stack.dev.yml
@@ -35,6 +35,8 @@ services:
- "traefik.enable=true"
volumes:
- ./frontend:/app
+ environment:
+ - STACK_NAME=${STACK_NAME}
ports:
# This is for the live reloading (maybe add to traefik as a tcp service?)
- '35729:35729'
diff --git a/docker-compose.stack.release.yml b/docker-compose.stack.release.yml
index b22ce844d..9a140b9c4 100644
--- a/docker-compose.stack.release.yml
+++ b/docker-compose.stack.release.yml
@@ -42,6 +42,8 @@ services:
frontend:
image: 'openstax/corgi-${DOCKER_IMAGE_FRONTEND:?}:${TAG-latest}'
+ environment:
+ - STACK_NAME=${STACK_NAME:?}
deploy:
labels:
- "traefik.http.services.${STACK_NAME:?}_frontend.loadbalancer.server.port=80"
@@ -62,7 +64,7 @@ services:
networks:
- ${TRAEFIK_PUBLIC_NETWORK:?}
- default
-
+
networks:
default:
driver: overlay
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4c75d5023..07bd9f735 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -41,6 +41,7 @@
"prettier-plugin-svelte": "^3.1.2",
"rollup": "^2.79.1",
"rollup-plugin-css-only": "^3.1.0",
+ "rollup-plugin-inject-process-env": "^1.3.1",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.1.0",
"svelte": "^3.53.1",
@@ -7162,6 +7163,15 @@
"node": ">= 8.0.0"
}
},
+ "node_modules/rollup-plugin-inject-process-env": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/rollup-plugin-inject-process-env/-/rollup-plugin-inject-process-env-1.3.1.tgz",
+ "integrity": "sha512-kKDoL30IZr0wxbNVJjq+OS92RJSKRbKV6B5eNW4q3mZTFqoWDh6lHy+mPDYuuGuERFNKXkG+AKxvYqC9+DRpKQ==",
+ "dev": true,
+ "dependencies": {
+ "magic-string": "^0.25.7"
+ }
+ },
"node_modules/rollup-plugin-livereload": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/rollup-plugin-livereload/-/rollup-plugin-livereload-2.0.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index f4a6447be..0b709f028 100755
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -40,6 +40,7 @@
"prettier-plugin-svelte": "^3.1.2",
"rollup": "^2.79.1",
"rollup-plugin-css-only": "^3.1.0",
+ "rollup-plugin-inject-process-env": "^1.3.1",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.1.0",
"svelte": "^3.53.1",
diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js
index 9e5955035..12797230a 100755
--- a/frontend/rollup.config.js
+++ b/frontend/rollup.config.js
@@ -6,6 +6,7 @@ import terser from "@rollup/plugin-terser";
import sveltePreprocess from "svelte-preprocess";
import typescript from "@rollup/plugin-typescript";
import css from "rollup-plugin-css-only";
+import injectProcessEnv from "rollup-plugin-inject-process-env";
const production = !process.env.ROLLUP_WATCH;
const hotdog = process.env.DEPLOYED_IN === "hotdog";
@@ -70,6 +71,10 @@ export default {
sourceMap: !production,
inlineSources: !production,
}),
+ injectProcessEnv(
+ { STACK_NAME: process.env.STACK_NAME },
+ { include: "./src/**/*" },
+ ),
// In dev mode, call `npm run start` once
// the bundle has been generated
diff --git a/frontend/specs/abl.spec.ts b/frontend/specs/abl.spec.ts
index 9a952438e..a5d0c2ad4 100644
--- a/frontend/specs/abl.spec.ts
+++ b/frontend/specs/abl.spec.ts
@@ -42,7 +42,13 @@ describe("newABLentry", () => {
version: "1",
}),
code_version: "123",
- expected: [{ uuid: "fake", code_version: "123", commit_sha: "1" }],
+ expected: {
+ books_to_approve: [
+ { code_version: "123", commit_sha: "1", uuid: "fake" },
+ ],
+ repository: { name: "name", owner: "owner" },
+ make_repo_public: false,
+ },
},
{
job: jobFactory.build({
@@ -53,24 +59,31 @@ describe("newABLentry", () => {
version: "1",
}),
code_version: "1234",
- expected: [
- {
- uuid: "fake",
- code_version: "1234",
- commit_sha: "1",
- },
- {
- uuid: "fake2",
- code_version: "1234",
- commit_sha: "1",
+ expected: {
+ books_to_approve: [
+ {
+ uuid: "fake",
+ code_version: "1234",
+ commit_sha: "1",
+ },
+ {
+ uuid: "fake2",
+ code_version: "1234",
+ commit_sha: "1",
+ },
+ ],
+ repository: {
+ name: "name",
+ owner: "owner",
},
- ],
+ make_repo_public: false,
+ },
},
];
testCases.forEach((args) => {
it(`calls fetch with the correct information -> ${args}`, async () => {
const { job, code_version: codeVersion, expected } = args;
- await newABLentry(job, codeVersion);
+ await newABLentry(job, codeVersion, false);
const url: string = (mockFetch.mock.lastCall as any[])[0] as string;
const options = (mockFetch.mock.lastCall as any[])[1];
const body = JSON.parse(options.body);
@@ -87,7 +100,7 @@ describe("newABLentry", () => {
window.fetch = jest
.fn<() => Promise>()
.mockResolvedValue({ status: 403 } as unknown as Response);
- await newABLentry(job, codeVersion);
+ await newABLentry(job, codeVersion, false);
expect(errors.length).toBe(1);
expect(errors[0]).toMatch(/do not.+permission.+entries/i);
});
diff --git a/frontend/specs/config.spec.ts b/frontend/specs/config.spec.ts
new file mode 100644
index 000000000..76ba8b93c
--- /dev/null
+++ b/frontend/specs/config.spec.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "@jest/globals";
+import { ProcessConfig, STACK_NAME_KEY } from "../src/ts/config";
+import { FeatureName } from "../src/ts/types";
+
+describe("config", () => {
+ const getActual = (
+ config: ProcessConfig,
+ ): Record => {
+ return {
+ makeRepoPublicOnApproval: config.isFeatureEnabled(
+ FeatureName.makeRepoPublicOnApproval,
+ ),
+ };
+ };
+ it("sets defaults", () => {
+ const config = ProcessConfig.fromEnv({});
+ expect(config.stackName).toBe(undefined);
+ const expected: Record = {
+ makeRepoPublicOnApproval: false,
+ };
+ const actual = getActual(config);
+ expect(actual).toStrictEqual(expected);
+ });
+ it("has expected values for prod", () => {
+ const config = ProcessConfig.fromEnv({ [STACK_NAME_KEY]: "test_prod" });
+ expect(config.stackName).toBe("test_prod");
+ const expected: Record = {
+ makeRepoPublicOnApproval: true,
+ };
+ const actual = getActual(config);
+ expect(actual).toStrictEqual(expected);
+ });
+});
diff --git a/frontend/specs/repository.spec.ts b/frontend/specs/repository.spec.ts
new file mode 100644
index 000000000..ed9cae583
--- /dev/null
+++ b/frontend/specs/repository.spec.ts
@@ -0,0 +1,43 @@
+import {
+ expect,
+ describe,
+ it,
+ jest,
+ beforeEach,
+ beforeAll,
+ afterAll,
+} from "@jest/globals";
+import { Fetch, mockJSONResponse, mockResponseStatus } from "./spec-helpers";
+import { getBookRepo } from "../src/ts/repository";
+
+const origFetch = window.fetch;
+let fetchSpy: jest.SpiedFunction;
+beforeAll(() => {
+ window.fetch = jest.fn();
+ fetchSpy = jest.spyOn(window, "fetch");
+});
+afterAll(() => {
+ window.fetch = origFetch;
+ jest.restoreAllMocks();
+});
+beforeEach(() => {
+ // Reset history
+ jest.resetAllMocks();
+ mockResponseStatus(fetchSpy, 200);
+});
+
+describe("getBookRepo", () => {
+ it("gets Book Repo", async () => {
+ const mockResponse = ["bookRepo", "ref", "committedAt", "books"];
+ mockJSONResponse(fetchSpy, mockResponse);
+ const fakeResponse = await getBookRepo({ name: "test", owner: "ing" });
+ const expectedURL = "/api/github/book-repository/ing/test";
+ const expectedOptions = undefined;
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ expect(fetchSpy).toHaveBeenCalledWith(expectedURL, expectedOptions);
+ // Should return an object with "bookRepo", "ref", "committedAt", "books"
+ expect(fakeResponse).toStrictEqual(
+ Object.fromEntries(mockResponse.map((v) => [v, v])),
+ );
+ });
+});
diff --git a/frontend/specs/spec-helpers.ts b/frontend/specs/spec-helpers.ts
index 04ef2a51b..8630d617d 100644
--- a/frontend/specs/spec-helpers.ts
+++ b/frontend/specs/spec-helpers.ts
@@ -1,5 +1,24 @@
import * as Factory from "factory.ts";
import type { ApprovedBookWithDate, Book, Job, User } from "../src/ts/types";
+import type { jest } from "@jest/globals";
+
+export type Fetch = (
+ input: RequestInfo | URL,
+ init?: RequestInit | undefined,
+) => Promise;
+
+export type FetchMock = jest.SpiedFunction | jest.Mock;
+
+export const mockResponseStatus = (mock: FetchMock, status: number) => {
+ mock.mockResolvedValue({ status } as unknown as Response);
+};
+
+export const mockJSONResponse = (mock: FetchMock, json: T, status = 200) => {
+ mock.mockResolvedValue({
+ status,
+ json: async () => json,
+ } as unknown as Response);
+};
// sfc32 credit: https://pracrand.sourceforge.net/
function sfc32(a: number, b: number, c: number, d: number) {
diff --git a/frontend/specs/utils.spec.ts b/frontend/specs/utils.spec.ts
index 71c927c8f..ad16c2631 100644
--- a/frontend/specs/utils.spec.ts
+++ b/frontend/specs/utils.spec.ts
@@ -1,5 +1,5 @@
import { expect, describe, it } from "@jest/globals";
-import { sortBy } from "../src/ts/utils";
+import { buildURL, sortBy } from "../src/ts/utils";
describe("sortBy", () => {
it("Does lexicographic sorting", () => {
@@ -76,3 +76,13 @@ describe("sortBy", () => {
expect(sorted).toStrictEqual(expected);
});
});
+
+describe("buildURL", () => {
+ it("builds urls as expected", () => {
+ expect(buildURL("a")).toBe("/a");
+ expect(buildURL("a", {})).toBe("/a");
+ expect(buildURL("a", { q: "t" })).toBe("/a?q=t");
+ expect(buildURL("a", { q: "test=ing" })).toBe("/a?q=test%3Ding");
+ expect(buildURL("a", { q: "1", r: "2" })).toBe("/a?q=1&r=2");
+ });
+});
diff --git a/frontend/src/components/ApproveBook.svelte b/frontend/src/components/ApproveBook.svelte
index 62fb4fb54..43892b26c 100644
--- a/frontend/src/components/ApproveBook.svelte
+++ b/frontend/src/components/ApproveBook.svelte
@@ -2,13 +2,15 @@
import SegmentedButton, { Label, Segment } from "@smui/segmented-button";
import CircularProgress from "@smui/circular-progress";
import { ABLStore } from "../ts/stores";
- import type { Job } from "../ts/types";
+ import { Config, FeatureName, Job } from "../ts/types";
import { getLatestCodeVersionForJob, newABLentry } from "../ts/abl";
import Button from "@smui/button";
import { repoToString } from "../ts/utils";
+ import { isRepoPubic } from "../ts/repository";
export let selectedJob: Job;
- export let open;
+ export let open: boolean;
+ export let config: Config;
let selectedCodeVersion;
let loading = false;
@@ -56,23 +58,43 @@
if (selectedCodeVersion === undefined) {
throw new Error("No code version selected");
}
+ let makeRepoPublic = false;
const message = [
- 'Are you sure you wish to add the following to the ABL?',
- '',
- 'Code Version:',
+ "Are you sure you wish to add the following to the ABL?",
+ "",
+ "Code Version:",
` ${selectedCodeVersion}`,
- 'Repository:',
+ "Repository:",
` ${repoToString(selectedJob.repository)}`,
- 'Commit sha:',
+ "Commit sha:",
` ${selectedJob.version}`,
- 'Books:',
+ "Books:",
` ${selectedJob.books.map((b) => b.slug).join(", ")}`,
- ];
- if (!confirm(message.join("\n"))) {
+ ].join("\n");
+ if (!confirm(message)) {
return;
}
+ if (
+ config.isFeatureEnabled(FeatureName.makeRepoPublicOnApproval) &&
+ !isRepoPubic(selectedJob.repository)
+ ) {
+ const repoFullName = repoToString(selectedJob.repository);
+ const didConfirm = confirm(
+ `Would you like to make ${repoFullName} public?`,
+ );
+ if (didConfirm) {
+ const response = prompt(
+ `WARNING: You are about to make ${repoFullName} public. ` +
+ 'Enter "public" (without double quotes) to continue.',
+ );
+ if (response !== "public") {
+ return;
+ }
+ makeRepoPublic = true;
+ }
+ }
loading = true;
- newABLentry(selectedJob, selectedCodeVersion)
+ newABLentry(selectedJob, selectedCodeVersion, makeRepoPublic)
.then(() => {
void ABLStore.update();
open = false;
diff --git a/frontend/src/components/DetailsDialog.svelte b/frontend/src/components/DetailsDialog.svelte
index 9785fdde1..dcb0b680f 100644
--- a/frontend/src/components/DetailsDialog.svelte
+++ b/frontend/src/components/DetailsDialog.svelte
@@ -4,12 +4,13 @@
import Button from "@smui/button";
import { Label } from "@smui/common";
import { abortJob, repeatJob, getErrorMessage } from "../ts/jobs";
- import type { Job } from "../ts/types";
+ import type { Config, Job } from "../ts/types";
import ApproveBook from "./ApproveBook.svelte";
import { escapeHTML, repoToString } from "../ts/utils";
import BuildArtifacts from "./BuildArtifacts.svelte";
export let selectedJob: Job;
export let open: boolean;
+ export let config: Config;
let isErrorDialog;
$: isErrorDialog = selectedJob?.status.name === "failed";
@@ -55,7 +56,7 @@
{#if selectedJob.status.name === "completed"}
{#if selectedJob.job_type.name === "git-web-hosting-preview"}
-
+
{/if}
{:else if isErrorDialog}
{#await getErrorMessage(selectedJob.id)}
diff --git a/frontend/src/components/JobsTable.svelte b/frontend/src/components/JobsTable.svelte
index d80552d65..f69eec2fe 100755
--- a/frontend/src/components/JobsTable.svelte
+++ b/frontend/src/components/JobsTable.svelte
@@ -29,12 +29,15 @@
import Button from "@smui/button";
import { repoSummariesStore, jobsStore, ABLStore } from "../ts/stores";
- import type { Job, JobType } from "../ts/types";
+ import type { Config, Job, JobType } from "../ts/types";
import DetailsDialog from "./DetailsDialog.svelte";
import { MINUTES, SECONDS } from "../ts/time";
import { hasABLEntry } from "../ts/abl";
import ApprovedBooksDialog from "./ApprovedBooksDialog.svelte";
import VersionLink from "./VersionLink.svelte";
+ import { ProcessConfig } from "../ts/config";
+
+ const config: Config = ProcessConfig.fromEnv(process.env);
let statusStyles = {
queued: "filter-yellow",
@@ -494,7 +497,7 @@
-
+
diff --git a/frontend/src/ts/abl.ts b/frontend/src/ts/abl.ts
index 231aa6b3d..d5fc33ec2 100644
--- a/frontend/src/ts/abl.ts
+++ b/frontend/src/ts/abl.ts
@@ -3,7 +3,11 @@ import type { Job, Book, ApprovedBookWithDate } from "./types";
import { handleError } from "./utils";
import { parseDateTime } from "./utils";
-export async function newABLentry(job: Job, codeVersion: string) {
+export async function newABLentry(
+ job: Job,
+ codeVersion: string,
+ makeRepoPublic: boolean,
+) {
const entries = job.books.map((b) => ({
uuid: b.uuid,
code_version: codeVersion,
@@ -13,7 +17,12 @@ export async function newABLentry(job: Job, codeVersion: string) {
throw new Error("You do not have permission to add ABL entries.");
};
try {
- await RequireAuth.sendJson("/api/abl/", entries, { handleAuthError });
+ const payload = {
+ books_to_approve: entries,
+ repository: job.repository,
+ make_repo_public: makeRepoPublic,
+ };
+ await RequireAuth.sendJson("/api/abl/", payload, { handleAuthError });
} catch (error) {
handleError(error);
}
@@ -60,12 +69,14 @@ export function getLatestCodeVersionForJob(
}
export async function fetchABL(): Promise {
- let abl: any;
+ let abl: ApprovedBookWithDate[] = [];
try {
- abl = await RequireAuth.fetchJson("/api/abl/");
+ const maybeAbl = await RequireAuth.fetchJson("/api/abl/");
+ if (Array.isArray(maybeAbl)) {
+ abl = maybeAbl;
+ }
} catch (error) {
handleError(error);
- abl = [];
}
return abl;
}
diff --git a/frontend/src/ts/config.ts b/frontend/src/ts/config.ts
new file mode 100644
index 000000000..b813ae7eb
--- /dev/null
+++ b/frontend/src/ts/config.ts
@@ -0,0 +1,27 @@
+import { FeatureName, Config } from "./types";
+
+interface Env extends Record {}
+
+const PROD_REGEX = /[\s:_-]prod(uction)?$/i;
+
+export const STACK_NAME_KEY = "STACK_NAME";
+
+export class ProcessConfig implements Config {
+ private readonly isProd: boolean;
+ private readonly featureStates: Record;
+
+ constructor(public readonly stackName: string | undefined) {
+ this.isProd = stackName !== undefined && PROD_REGEX.test(stackName);
+ this.featureStates = {
+ [FeatureName.makeRepoPublicOnApproval]: this.isProd,
+ };
+ }
+
+ isFeatureEnabled(featureName: FeatureName) {
+ return this.featureStates[featureName];
+ }
+
+ static fromEnv(env: Env) {
+ return new ProcessConfig(env[STACK_NAME_KEY]);
+ }
+}
diff --git a/frontend/src/ts/repository.ts b/frontend/src/ts/repository.ts
new file mode 100644
index 000000000..9bbaf1f25
--- /dev/null
+++ b/frontend/src/ts/repository.ts
@@ -0,0 +1,25 @@
+import { RequireAuth } from "./fetch-utils";
+import type { BookRepository, Repository } from "./types";
+import { buildURL } from "./utils";
+
+export async function getBookRepo(
+ repo: Repository,
+ query?: { version?: string },
+): Promise {
+ const url = buildURL(
+ `/api/github/book-repository/${repo.owner}/${repo.name}`,
+ query,
+ );
+ const [bookRepo, ref, committedAt, books] = await RequireAuth.fetchJson(url);
+ return {
+ bookRepo,
+ ref,
+ committedAt,
+ books,
+ };
+}
+
+export async function isRepoPubic(repo: Repository) {
+ const { bookRepo } = await getBookRepo(repo);
+ return bookRepo.visibility === "PUBLIC";
+}
diff --git a/frontend/src/ts/types.ts b/frontend/src/ts/types.ts
index 383166433..19b496db9 100755
--- a/frontend/src/ts/types.ts
+++ b/frontend/src/ts/types.ts
@@ -68,3 +68,24 @@ export interface ApprovedBookWithDate extends ApprovedBook {
slug: string;
consumer: string;
}
+
+export interface BookRepository {
+ bookRepo: {
+ name: string;
+ database_id: string;
+ viewer_permission: string;
+ visibility: string;
+ };
+ ref: string;
+ committedAt: string;
+ books: Array<{ slug: string; style: string }>;
+}
+
+export enum FeatureName {
+ makeRepoPublicOnApproval,
+}
+
+export interface Config {
+ readonly stackName: string | undefined;
+ isFeatureEnabled: (featureName: FeatureName) => boolean;
+}
diff --git a/frontend/src/ts/utils.ts b/frontend/src/ts/utils.ts
index 03d0cabaf..12d6a3e7a 100755
--- a/frontend/src/ts/utils.ts
+++ b/frontend/src/ts/utils.ts
@@ -148,3 +148,14 @@ export function sortBy(
return n;
});
}
+
+export function buildURL(path: string, query?: Record): string {
+ const url = new URL("http://origin-not-used");
+ url.pathname = path;
+ if (query !== undefined) {
+ Object.entries(query).forEach(([k, v]) => {
+ url.searchParams.append(k, v);
+ });
+ }
+ return url.href.slice(url.origin.length);
+}