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
20 changes: 17 additions & 3 deletions api.civicpatch.org/src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ async def get_job(request_id: str):
async with pool.connection() as conn, conn.cursor() as cur:
await cur.execute(
"""
SELECT status, progress, arguments_json, result_json, created_at, updated_at FROM jobs
SELECT status, progress, arguments_json, result_json, created_at, updated_at, pull_request_url FROM jobs
WHERE request_id = %s;
""",
(request_id,),
Expand All @@ -625,9 +625,9 @@ async def get_job(request_id: str):
"progress": row[1],
"arguments_json": row[2],
"result_json": row[3],
"pull_request_url": None, # TODO: implement
"created_at": to_iso(row[4]),
"updated_at": to_iso(row[5]),
"pull_request_url": row[6],
}
return None

Expand Down Expand Up @@ -696,6 +696,7 @@ async def update_job_result(request_id: str, result_json: Any):

async def update_job_pull_request_url(request_id: str, pull_request_url: str = None):
async with pool.connection() as conn:
# Update jobs table
result = await conn.execute(
"""
UPDATE jobs
Expand All @@ -706,7 +707,20 @@ async def update_job_pull_request_url(request_id: str, pull_request_url: str = N
(
pull_request_url,
request_id
),
),
)
# Update status table
await conn.execute(
"""
UPDATE status
SET status = %s,
updated_at = CURRENT_TIMESTAMP
WHERE request_id = %s;
""",
(
"OPEN_PULL_REQUEST",
request_id
),
)
if result.rowcount == 0:
return False
Expand Down
63 changes: 59 additions & 4 deletions api.civicpatch.org/src/github_service.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import os
from typing import List
from typing import List, Optional
import json
import yaml
import base64
import httpx

import requests

from schemas import PullRequest
timeout = httpx.Timeout(60.0)

GITHUB_WORKFLOW_TOKEN = os.getenv("GITHUB_WORKFLOW_TOKEN")

Expand Down Expand Up @@ -91,17 +96,24 @@ def trigger_github_data_intake_workflow(
return True


def get_github_file_contents(github_file_path: str) -> str | None:
async def get_github_file_contents(
github_file_path: str,
ref: Optional[str] = None,
) -> str | None:
headers = {
"Authorization": f"Bearer {GITHUB_WORKFLOW_TOKEN}",
"Accept": "application/vnd.github.raw",
"X-GitHub-Api-Version": "2022-11-28",
}
print("Fetching GitHub file:", github_file_path, "ref:", ref)

url = (
f"https://api.github.com/repos/CivicPatch/open-data/contents/{github_file_path}"
)
response = requests.get(url, headers=headers)
if ref:
url += f"?ref={ref}"
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.get(url, headers=headers)

if response.status_code == 200:
file_content = response.text
Expand All @@ -118,7 +130,7 @@ def get_open_pull_requests(github_workflow_token: str) -> List[PullRequest]:
"X-GitHub-Api-Version": "2022-11-28",
}

params = "state=open&per_page=100"
params = "state=open&per_page=100&sort=created&direction=desc"
url = f"https://api.github.com/repos/CivicPatch/open-data/pulls?{params}"
response = requests.get(url, headers=headers)

Expand All @@ -131,3 +143,46 @@ def get_open_pull_requests(github_workflow_token: str) -> List[PullRequest]:
else:
print("Error fetching pull requests:", response.status_code, response.text)
return []

def get_open_pull_request_by_branch_suffix(suffix: str) -> List[PullRequest]:
pull_requests = get_open_pull_requests(GITHUB_WORKFLOW_TOKEN)
matching_prs = [pr for pr in pull_requests if pr.branch_name.endswith(suffix)]
return matching_prs

async def update_pull_request_file(
branch_name: str,
file_path: str,
new_data: str,
commit_message: str = "Automated update via API"
) -> bool:
headers = {
"Authorization": f"Bearer {GITHUB_WORKFLOW_TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}

# Get file SHA
contents_url = f"https://api.github.com/repos/{repo}/contents/{file_path}?ref={branch_name}"
contents_response = await httpx.get(contents_url, headers=headers)
if contents_response.status_code != 200:
print("Error fetching file contents:", contents_response.status_code, contents_response.text)
return False
sha = contents_response.json()["sha"]

# Prepare new content (base64 encoded)
encoded_content = base64.b64encode(new_data.encode("utf-8")).decode("utf-8")
data = {
"message": commit_message,
"content": encoded_content,
"sha": sha,
"branch": branch_name
}

# Update file
update_response = await httpx.put(contents_url, headers=headers, json=data)
if update_response.status_code in [200, 201]:
print("File updated successfully.")
return True
else:
print("Error updating file:", update_response.status_code, update_response.text)
return False
69 changes: 69 additions & 0 deletions api.civicpatch.org/src/routers/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pydantic import BaseModel
from schemas import Identity
from github_service import trigger_people_job_workflow
import github_service as github_service
from services.api_service import can_make_api_request, can_call_request_id
from database import (
get_job,
Expand All @@ -13,9 +14,12 @@
update_job_result,
update_job_pull_request_url
)
import database as database
from utils.auth import get_user
import shared.utils.data_path_utils as data_path_utils
import shared.utils.id_utils
import json
import yaml
from services.memory_pub_sub_service import memory_pubsub

class GetJobResponse(BaseModel):
Expand Down Expand Up @@ -63,6 +67,11 @@ class PostJobResultRequest(BaseModel):
class ErrorResponse(BaseModel):
error: str

class PostJobPullRequestDataRequest(BaseModel):
branch_name: str
file_path: str
data: str

def get_router(api_key_header):
router = APIRouter()

Expand Down Expand Up @@ -247,6 +256,66 @@ async def post_job_result_endpoint(
response = {"request_id": request_id, "errors": errors}
return response

@router.get(
"/people/pull_request/open",
include_in_schema=False
)
async def get_open_people_pull_requests_endpoint(
jurisdiction_ocdid: Optional[str] = None
):
branch_name_suffix = shared.utils.id_utils.jurisdiction_ocdid_to_git_branch(jurisdiction_ocdid)
print("branch_name_suffix:", branch_name_suffix)
open_pull_requests = await github_service.get_open_pull_request_by_branch_suffix("people", branch_name_suffix)
return {"data": open_pull_requests}

@router.get(
"/people/{request_id}/pull_request/data",
include_in_schema=False
)
async def get_job_pull_request_data_endpoint(request_id: str):
# Get the pull request url from the job
job = await database.get_job(request_id)
if not job:
return JSONResponse(
content=ErrorResponse(error="Job not found").model_dump(),
status_code=404
)
jurisdiction_ocdid = job['arguments_json'].get('jurisdiction_ocdid')
print("jurisdiction_ocdid:", jurisdiction_ocdid)
file_path = data_path_utils.get_data_file_path(jurisdiction_ocdid)
# Chop off leading "/app/" from file_path
if file_path.startswith("/app/"):
file_path = file_path[len("/app/"):]
branch_name = shared.utils.id_utils.jurisdiction_ocdid_to_git_branch(
jurisdiction_ocdid,
request_id
)
print("branch_name:", branch_name)
github_response = await github_service.get_github_file_contents(
github_file_path=file_path,
ref=branch_name
)
response = yaml.safe_load(github_response) if github_response else None

return {"request_id": request_id, "data": response}

@router.post(
"/people/{request_id}/pull_request/data",
include_in_schema=False
)
async def post_job_pull_request_data_endpoint(
request_id: str,
request: PostJobPullRequestDataRequest,
user: Identity = Depends(get_user)
):
user_name = user.email
_github_response = await github_service.update_pull_request_file(
branch_name=request.branch_name,
file_path=request.file_path,
new_data=request.data,
commit_message=f"Data update by {user_name}"
)
return {"request_id": request_id, "status": "success"}

@router.delete(
"/people/{request_id}",
Expand Down
4 changes: 2 additions & 2 deletions api.civicpatch.org/src/routers/api/jurisdictions.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,15 @@ async def list_available_jurisdictions_endpoint(
state: str,
num_jurisdictions: int = 10,
):
jurisdictions_file_content = github_service.get_github_file_contents(
jurisdictions_file_content = await github_service.get_github_file_contents(
f"data_source/{state}/jurisdictions_metadata.yml"
)
if jurisdictions_file_content is None:
raise HTTPException(
status_code=404, detail="Could not find jurisdictions file"
)

open_pull_requests = github_service.get_open_pull_requests(
open_pull_requests = await github_service.get_open_pull_requests(
GITHUB_WORKFLOW_TOKEN
)
jurisdictions_data = yaml.safe_load(jurisdictions_file_content)
Expand Down
874 changes: 642 additions & 232 deletions civicpatch/src/frontend/build/bundle.js

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions civicpatch/src/frontend/components/basic/chip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { component, useState, useEffect } from "haunted";
import { html } from "lit-html";

function PicoChipsInput({ value = [], onChange, placeholder = "Add..." }) {
const [chips, setChips] = useState(value);

useEffect(() => {
setChips(value);
}, [value]);

useEffect(() => {
onChange && onChange(chips);
}, [chips]);

const handleAdd = (e) => {
e.preventDefault();
const input = e.target.elements["chip-input"];
const val = input.value.trim();
if (val && !chips.includes(val)) {
setChips([...chips, val]);
}
input.value = "";
};

const handleRemove = (idx) => {
setChips(chips.filter((_, i) => i !== idx));
};

return html`
<style>
.pico-chips-row {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
align-items: center;
margin-bottom: 0.5em;
}
.pico-chip-btn {
display: inline-flex;
align-items: center;
background: var(--pico-primary-background, #e0e8f3);
border: 1px solid var(--pico-primary, #264478);
border-radius: 2em;
padding: 0.18em 0.7em 0.18em 0.7em;
font-size: 0.97em;
font-weight: 500;
cursor: pointer;
}
.pico-chip-btn .close-x {
margin-left: 0.5em;
font-size: 1.1em;
opacity: 0.7;
transition: opacity 0.2s;
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1;
display: flex;
align-items: center;
}
.pico-chip-btn .close-x:hover {
opacity: 1;
}
.pico-chips-row input[type="text"] {
min-width: 100px;
border: 1px solid var(--pico-muted-border-color, #e0e0e0);
padding: 0.25em 0.75em;
font-size: 1em;
outline: none;
transition: border 0.2s;
}
.pico-chips-row input[type="text"]:focus {
border: 1.5px solid var(--pico-primary, #0d6efd);
}
</style>
<div class="pico-chips-row">
${chips.map(
(chip, i) => html`
<button
type="button"
class="pico-chip-btn"
@click=${() => handleRemove(i)}
aria-label="Remove ${chip}"
>
<span>${chip}</span>
<span class="close-x" aria-hidden="true">&times;</span>
</button>
`
)}
<form @submit=${handleAdd} style="display: inline;">
<input
name="chip-input"
type="text"
placeholder=${placeholder}
autocomplete="off"
/>
</form>
</div>
`;
}

customElements.define(
"pico-chips-input",
component(PicoChipsInput, { useShadowDOM: false })
);
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ function Modal({
};

return html`
<style>
dialog[open] {
z-index: 9999 !important;
position: fixed !important;
left: 0; top: 0; right: 0; bottom: 0;
margin: auto;
/* Optional: add a semi-transparent background */
background: rgba(255,255,255,0.98);
}
</style>
<dialog
?open=${open}
tabindex="-1"
Expand Down
Loading
Loading