Emma Scharfmann commited on
Commit
e2bb8e8
·
1 Parent(s): 6b1df02
Files changed (8) hide show
  1. .gitignore +1 -0
  2. README.md +40 -8
  3. about.py +13 -0
  4. app.py +191 -0
  5. formatters.py +136 -0
  6. github_pr.py +119 -0
  7. requirements.txt +4 -0
  8. submissions.py +83 -0
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ .env
README.md CHANGED
@@ -1,14 +1,46 @@
1
  ---
2
- title: Request Moderator
3
- emoji: 😻
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
- license: apache-2.0
 
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Hugging Science Requests Review
3
+ emoji: 🔬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: 5.9.1
 
8
  app_file: app.py
9
  pinned: false
10
+ hf_oauth: true
11
+ hf_oauth_scopes:
12
+ - profile
13
  ---
14
 
15
+ # Hugging Science — requests review
16
+
17
+ Standalone moderation tool for huggingscience.co, decoupled from the site's
18
+ own deploy. It works alongside the existing `hugging-science/feedback-api`
19
+ Space (see `../feedback-api/`), not instead of it:
20
+
21
+ 1. `FeedbackWidget` on the site POSTs every submission to
22
+ `hugging-science-feedback-api.hf.space/submit`, same as before.
23
+ 2. That Space now routes `type: "feedback"` to `feedback.jsonl` (unchanged)
24
+ and everything else — `dataset`/`model`/`organization`/`blog`/`challenge`
25
+ — to a new `requests.jsonl`, both in the `hugging-science/request` dataset.
26
+ 3. **This Space** reads `requests.jsonl`, lets the site owner approve or
27
+ reject each pending row, and on approve opens a GitHub pull request
28
+ against [`georgiachanning/huggingscience-dot-co`](https://github.com/georgiachanning/huggingscience-dot-co)
29
+ adding the entry to the right `src/data/*.js` file. The site owner still
30
+ reviews and merges that PR by hand — this tool only removes the manual
31
+ data-entry step, not the final review.
32
+
33
+ ## Required Space secrets
34
+
35
+ | Secret | Purpose |
36
+ | --- | --- |
37
+ | `GITHUB_TOKEN` | Fine-grained PAT scoped to `georgiachanning/huggingscience-dot-co` with `contents:write` + `pull-requests:write`. |
38
+ | `HF_TOKEN` | Write-scoped HF token for the `hugging-science` org, used to read/update `requests.jsonl`. |
39
+ | `ADMIN_USERS` | Comma-separated Hugging Face usernames allowed into the admin UI. |
40
+
41
+ ## Optional Space variables
42
+
43
+ | Variable | Default | Purpose |
44
+ | --- | --- | --- |
45
+ | `HF_DATASET_REPO` | `hugging-science/request` | Dataset repo holding both `feedback.jsonl` and `requests.jsonl`. |
46
+ | `GITHUB_REPO` | `georgiachanning/huggingscience-dot-co` | Repo that approved requests open PRs against. |
about.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ DATASET_REPO = os.environ.get("HF_DATASET_REPO", "hugging-science/request")
8
+ REQUESTS_FILE = "requests.jsonl"
9
+ TOKEN = os.environ.get("HF_TOKEN")
10
+
11
+ ADMIN_USERS = {u.strip() for u in os.environ.get("ADMIN_USERS", "").split(",") if u.strip()}
12
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "georgiachanning/huggingscience-dot-co")
13
+ GITHUB_API = "https://api.github.com"
app.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Science requests review — Gradio admin UI.
2
+
3
+ Ingestion happens in the separate feedback-api Space (/submit), which writes
4
+ non-"feedback" submissions into requests.jsonl in the hugging-science/feedback
5
+ dataset. This Space only reads that file and lets the site owner approve
6
+ (-> opens a GitHub PR) or reject each pending request.
7
+ """
8
+
9
+ import gradio as gr
10
+
11
+ import formatters
12
+ import github_pr
13
+ import submissions
14
+ from about import ADMIN_USERS
15
+
16
+ # Types that map onto a src/data/*.js file and can go through the PR flow.
17
+ PR_TYPES = list(formatters.TARGETS.keys()) # organization, model, dataset, blog
18
+ # Everything else (e.g. "challenge") is acknowledge/reject only.
19
+
20
+ FIELD_VISIBILITY = {
21
+ "organization": {"id", "name", "link", "description", "tags"},
22
+ "model": {"id", "slug", "name", "orgId", "entry_type", "description", "tags"},
23
+ "dataset": {"id", "slug", "orgId", "entry_type", "description", "tags"},
24
+ "blog": {"id", "title", "slug", "orgId", "date", "excerpt", "link", "tags", "featured", "upvotes"},
25
+ }
26
+
27
+ ALL_FIELD_KEYS = [
28
+ "id", "name", "title", "slug", "orgId", "entry_type",
29
+ "description", "excerpt", "link", "date", "featured", "upvotes", "tags",
30
+ ]
31
+
32
+
33
+ def is_admin(profile: gr.OAuthProfile | None) -> bool:
34
+ return profile is not None and profile.username in ADMIN_USERS
35
+
36
+
37
+ def _guess_slug(title: str | None) -> str:
38
+ if title and "/" in title:
39
+ return title.strip()
40
+ return ""
41
+
42
+
43
+ def pending_choices():
44
+ rows = submissions.list_submissions(status="pending")
45
+ return [(f"[{r['type']}] {r['title'] or r['description'][:40]}", r["id"]) for r in rows]
46
+
47
+
48
+ def history_rows():
49
+ rows = [r for r in submissions.list_submissions() if r["status"] != "pending"]
50
+ return [
51
+ [r["type"], r["title"], r["status"], r.get("reviewed_at"), r.get("pr_url") or r.get("reject_reason")]
52
+ for r in rows
53
+ ]
54
+
55
+
56
+ def load_submission(submission_id: str):
57
+ """Populate the detail panel + editable fields for a selected submission."""
58
+ if not submission_id:
59
+ return (gr.update(value=""), *[gr.update(visible=False) for _ in ALL_FIELD_KEYS], gr.update(visible=False))
60
+
61
+ row = submissions.get_submission(submission_id)
62
+ if row is None:
63
+ return (gr.update(value="Not found."), *[gr.update(visible=False) for _ in ALL_FIELD_KEYS], gr.update(visible=False))
64
+
65
+ detail_md = (
66
+ f"**Type:** {row['type']} \n"
67
+ f"**Title/link:** {row['title'] or '_none given_'} \n"
68
+ f"**Submitted:** {row['submitted_at']} via {row['source']} \n\n"
69
+ f"{row['description']}"
70
+ )
71
+
72
+ visible = FIELD_VISIBILITY.get(row["type"], set())
73
+ guessed_slug = _guess_slug(row["title"])
74
+ defaults = {
75
+ "id": guessed_slug.replace("/", "-").lower(),
76
+ "name": row["title"] or "",
77
+ "title": row["title"] or "",
78
+ "slug": guessed_slug,
79
+ "description": row["description"],
80
+ "excerpt": row["description"],
81
+ "link": row["title"] if row["title"] and row["title"].startswith("http") else "",
82
+ }
83
+ field_updates = []
84
+ for key in ALL_FIELD_KEYS:
85
+ if key in visible:
86
+ field_updates.append(gr.update(visible=True, value=defaults.get(key, "" if key != "tags" else [])))
87
+ else:
88
+ field_updates.append(gr.update(visible=False))
89
+
90
+ show_pr_button = row["type"] in PR_TYPES
91
+ return (gr.update(value=detail_md), *field_updates, gr.update(visible=show_pr_button))
92
+
93
+
94
+ with gr.Blocks(title="Hugging Science — requests review") as demo:
95
+ gr.Markdown("## Hugging Science — requests review")
96
+ login_btn = gr.LoginButton()
97
+
98
+ with gr.Column(visible=False) as admin_panel:
99
+ with gr.Tab("Pending"):
100
+ refresh_btn = gr.Button("Refresh")
101
+ pending_dd = gr.Dropdown(label="Pending requests", choices=[], interactive=True)
102
+ detail = gr.Markdown()
103
+
104
+ with gr.Group():
105
+ f_id = gr.Textbox(label="id", visible=False)
106
+ f_name = gr.Textbox(label="name", visible=False)
107
+ f_title = gr.Textbox(label="title", visible=False)
108
+ f_slug = gr.Textbox(label="slug (org/repo)", visible=False)
109
+ f_orgId = gr.Textbox(label="orgId", visible=False)
110
+ f_entry_type = gr.Textbox(label="type (e.g. Genomics, Foundation Model)", visible=False)
111
+ f_description = gr.Textbox(label="description", lines=3, visible=False)
112
+ f_excerpt = gr.Textbox(label="excerpt", lines=3, visible=False)
113
+ f_link = gr.Textbox(label="link", visible=False)
114
+ f_date = gr.Textbox(label="date (YYYY-MM-DD)", visible=False)
115
+ f_featured = gr.Checkbox(label="featured", visible=False)
116
+ f_upvotes = gr.Number(label="upvotes", visible=False)
117
+ f_tags = gr.CheckboxGroup(label="tags", choices=formatters.VALID_TAGS, visible=False)
118
+
119
+ all_fields = [f_id, f_name, f_title, f_slug, f_orgId, f_entry_type,
120
+ f_description, f_excerpt, f_link, f_date, f_featured, f_upvotes, f_tags]
121
+
122
+ with gr.Row():
123
+ approve_btn = gr.Button("Approve → open PR", variant="primary", visible=False)
124
+ reject_reason = gr.Textbox(label="Reject reason (optional)", scale=2)
125
+ reject_btn = gr.Button("Reject", variant="stop")
126
+
127
+ result_md = gr.Markdown()
128
+
129
+ def do_refresh():
130
+ return gr.update(choices=pending_choices(), value=None)
131
+
132
+ refresh_btn.click(do_refresh, outputs=pending_dd)
133
+ demo.load(do_refresh, outputs=pending_dd)
134
+
135
+ pending_dd.change(load_submission, inputs=pending_dd, outputs=[detail, *all_fields, approve_btn])
136
+
137
+ def do_approve(submission_id, id_, name, title, slug, org_id, entry_type,
138
+ description, excerpt, link, date, featured, upvotes, tags):
139
+ row = submissions.get_submission(submission_id)
140
+ if row is None:
141
+ return "Submission not found.", gr.update(choices=pending_choices())
142
+ fields = {
143
+ "id": id_, "name": name, "title": title, "slug": slug, "orgId": org_id or None,
144
+ "type": entry_type, "description": description, "excerpt": excerpt,
145
+ "link": link, "date": date, "featured": featured, "upvotes": upvotes,
146
+ "tags": tags or [],
147
+ }
148
+ missing = formatters.missing_fields(row["type"], fields)
149
+ if missing:
150
+ return f"Missing required fields: {', '.join(missing)}", gr.update()
151
+ try:
152
+ pr_url = github_pr.open_pr_for_submission(row, fields)
153
+ except Exception as exc: # surfaced to the reviewer, not swallowed
154
+ return f"Failed to open PR: {exc}", gr.update()
155
+ submissions.mark_approved(submission_id, pr_url)
156
+ return f"Approved — [PR opened]({pr_url})", gr.update(choices=pending_choices(), value=None)
157
+
158
+ approve_btn.click(
159
+ do_approve,
160
+ inputs=[pending_dd, *all_fields],
161
+ outputs=[result_md, pending_dd],
162
+ )
163
+
164
+ def do_reject(submission_id, reason):
165
+ if not submission_id:
166
+ return "Nothing selected.", gr.update()
167
+ submissions.mark_rejected(submission_id, reason or None)
168
+ return "Rejected.", gr.update(choices=pending_choices(), value=None)
169
+
170
+ reject_btn.click(do_reject, inputs=[pending_dd, reject_reason], outputs=[result_md, pending_dd])
171
+
172
+ with gr.Tab("History"):
173
+ history_refresh = gr.Button("Refresh")
174
+ history_df = gr.Dataframe(
175
+ headers=["type", "title", "status", "reviewed_at", "pr_url / reason"],
176
+ interactive=False,
177
+ )
178
+ history_refresh.click(lambda: history_rows(), outputs=history_df)
179
+ demo.load(lambda: history_rows(), outputs=history_df)
180
+
181
+ unauthorized = gr.Markdown("Log in with an allow-listed Hugging Face account to review requests.")
182
+
183
+ def toggle_panel(profile: gr.OAuthProfile | None):
184
+ admin = is_admin(profile)
185
+ return gr.update(visible=admin), gr.update(visible=not admin)
186
+
187
+ demo.load(toggle_panel, outputs=[admin_panel, unauthorized])
188
+
189
+
190
+ if __name__ == "__main__":
191
+ demo.launch()
formatters.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn an approved submission into a src/data/*.js entry + a text splice.
2
+
3
+ The site's data files (src/data/organizations.js, models.js, datasets.js,
4
+ blogs.js) are hand-formatted flat JS arrays, not JSON — see the real
5
+ examples this module's tests are based on. Rather than parsing/rewriting
6
+ the whole file as an AST, we do a plain text insertion, since the existing
7
+ formatting is simple and consistent: 2-space indent for `{`/`}`, 4-space
8
+ indent for fields, `description`/`excerpt` always on their own line.
9
+ """
10
+
11
+ import json
12
+
13
+ # Canonical tag list — mirrors src/data/themes.js themeIds.
14
+ VALID_TAGS = [
15
+ "biology", "chemistry", "physics", "medicine", "mathematics",
16
+ "engineering", "earth-science", "astronomy", "genomics",
17
+ "biotechnology", "materials-science", "climate", "energy",
18
+ "ecology", "conservation", "benchmark", "scientific-reasoning",
19
+ ]
20
+
21
+ # type -> (file path relative to repo root, exported array name, required fields)
22
+ TARGETS = {
23
+ "organization": ("src/data/organizations.js", "organizations",
24
+ ["id", "name", "link", "tags"]),
25
+ "model": ("src/data/models.js", "models",
26
+ ["id", "slug", "name", "orgId", "type", "description", "tags"]),
27
+ "dataset": ("src/data/datasets.js", "datasets",
28
+ ["id", "slug", "orgId", "type", "description", "tags"]),
29
+ "blog": ("src/data/blogs.js", "blogs",
30
+ ["id", "title", "slug", "orgId", "date", "excerpt", "link", "tags"]),
31
+ }
32
+
33
+
34
+ def target_file(type_: str) -> str:
35
+ return TARGETS[type_][0]
36
+
37
+
38
+ def missing_fields(type_: str, fields: dict) -> list[str]:
39
+ _, _, required = TARGETS[type_]
40
+ return [f for f in required if not fields.get(f)]
41
+
42
+
43
+ def _js_string(value) -> str:
44
+ return json.dumps(value)
45
+
46
+
47
+ def render_entry(type_: str, fields: dict) -> str:
48
+ """Render a single object literal matching the existing file style."""
49
+ if type_ == "organization":
50
+ lines = [
51
+ " {",
52
+ f' id: {_js_string(fields["id"])},',
53
+ f' name: {_js_string(fields["name"])},',
54
+ f' logo: getOrgLogo({_js_string(fields["id"])}),',
55
+ " description:",
56
+ f' {_js_string(fields.get("description", ""))},',
57
+ f' link: {_js_string(fields["link"])},',
58
+ f' tags: {json.dumps(fields["tags"])},',
59
+ " },",
60
+ ]
61
+ elif type_ == "model":
62
+ lines = [
63
+ " {",
64
+ f' id: {_js_string(fields["id"])},',
65
+ f' slug: {_js_string(fields["slug"])},',
66
+ f' name: {_js_string(fields["name"])},',
67
+ f' orgId: {_js_string(fields["orgId"])},',
68
+ f' type: {_js_string(fields["type"])},',
69
+ " description:",
70
+ f' {_js_string(fields["description"])},',
71
+ f' tags: {json.dumps(fields["tags"])},',
72
+ " },",
73
+ ]
74
+ elif type_ == "dataset":
75
+ lines = [
76
+ " {",
77
+ f' id: {_js_string(fields["id"])},',
78
+ f' slug: {_js_string(fields["slug"])},',
79
+ f' orgId: {_js_string(fields["orgId"])},',
80
+ f' type: {_js_string(fields["type"])},',
81
+ " description:",
82
+ f' {_js_string(fields["description"])},',
83
+ f' tags: {json.dumps(fields["tags"])},',
84
+ " },",
85
+ ]
86
+ elif type_ == "blog":
87
+ lines = [
88
+ " {",
89
+ f' id: {_js_string(fields["id"])},',
90
+ f' title: {_js_string(fields["title"])},',
91
+ f' slug: {_js_string(fields["slug"])},',
92
+ f' orgId: {_js_string(fields["orgId"]) if fields.get("orgId") else "null"},',
93
+ f' date: {_js_string(fields["date"])},',
94
+ " excerpt:",
95
+ f' {_js_string(fields["excerpt"])},',
96
+ f' link: {_js_string(fields["link"])},',
97
+ f' tags: {json.dumps(fields["tags"])},',
98
+ f' featured: {"true" if fields.get("featured") else "false"},',
99
+ ]
100
+ if fields.get("upvotes") is not None:
101
+ lines.append(f' upvotes: {int(fields["upvotes"])},')
102
+ lines.append(" },")
103
+ else:
104
+ raise ValueError(f"Unknown submission type: {type_}")
105
+
106
+ return "\n".join(lines)
107
+
108
+
109
+ def insert_entry(file_content: str, type_: str, entry_block: str, org_id: str | None) -> str:
110
+ """Insert entry_block into file_content, grouped near org_id's other
111
+ entries when found, otherwise right after the array's opening line."""
112
+ _, array_name, _ = TARGETS[type_]
113
+ lines = file_content.split("\n")
114
+ opening = f"export const {array_name} = ["
115
+
116
+ insert_at = None
117
+ if org_id:
118
+ for i, line in enumerate(lines):
119
+ if f'orgId: "{org_id}"' in line:
120
+ # walk forward to this object's closing " },"
121
+ for j in range(i + 1, len(lines)):
122
+ if lines[j] == " },":
123
+ insert_at = j + 1
124
+ break
125
+ # keep scanning in case a later group is a better (last) match
126
+ if insert_at is None:
127
+ for i, line in enumerate(lines):
128
+ if line.strip() == opening:
129
+ insert_at = i + 1
130
+ break
131
+
132
+ if insert_at is None:
133
+ raise ValueError(f"Could not find `{opening}` in target file")
134
+
135
+ new_lines = lines[:insert_at] + entry_block.split("\n") + lines[insert_at:]
136
+ return "\n".join(new_lines)
github_pr.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Open a GitHub PR against the website repo for an approved submission.
2
+
3
+ The site owner still reviews and merges the PR by hand — this only removes
4
+ the manual data-entry step of splicing a new object into the right
5
+ src/data/*.js array.
6
+ """
7
+
8
+ import base64
9
+ import os
10
+
11
+ import requests
12
+
13
+ import formatters
14
+ from about import GITHUB_API, GITHUB_REPO
15
+
16
+
17
+ def _headers() -> dict:
18
+ token = os.environ["GITHUB_TOKEN"]
19
+ return {
20
+ "Authorization": f"Bearer {token}",
21
+ "Accept": "application/vnd.github+json",
22
+ "X-GitHub-Api-Version": "2022-11-28",
23
+ }
24
+
25
+
26
+ def _default_branch() -> str:
27
+ resp = requests.get(f"{GITHUB_API}/repos/{GITHUB_REPO}", headers=_headers())
28
+ resp.raise_for_status()
29
+ return resp.json()["default_branch"]
30
+
31
+
32
+ def _get_file(path: str, ref: str) -> tuple[str, str]:
33
+ """Return (content, sha) of a file at ref."""
34
+ resp = requests.get(
35
+ f"{GITHUB_API}/repos/{GITHUB_REPO}/contents/{path}",
36
+ headers=_headers(),
37
+ params={"ref": ref},
38
+ )
39
+ resp.raise_for_status()
40
+ data = resp.json()
41
+ content = base64.b64decode(data["content"]).decode("utf-8")
42
+ return content, data["sha"]
43
+
44
+
45
+ def _create_branch(branch: str, from_sha: str) -> None:
46
+ resp = requests.post(
47
+ f"{GITHUB_API}/repos/{GITHUB_REPO}/git/refs",
48
+ headers=_headers(),
49
+ json={"ref": f"refs/heads/{branch}", "sha": from_sha},
50
+ )
51
+ resp.raise_for_status()
52
+
53
+
54
+ def _branch_sha(branch: str) -> str:
55
+ resp = requests.get(
56
+ f"{GITHUB_API}/repos/{GITHUB_REPO}/git/ref/heads/{branch}", headers=_headers()
57
+ )
58
+ resp.raise_for_status()
59
+ return resp.json()["object"]["sha"]
60
+
61
+
62
+ def _update_file(path: str, branch: str, new_content: str, sha: str, message: str) -> None:
63
+ resp = requests.put(
64
+ f"{GITHUB_API}/repos/{GITHUB_REPO}/contents/{path}",
65
+ headers=_headers(),
66
+ json={
67
+ "message": message,
68
+ "content": base64.b64encode(new_content.encode("utf-8")).decode("ascii"),
69
+ "sha": sha,
70
+ "branch": branch,
71
+ },
72
+ )
73
+ resp.raise_for_status()
74
+
75
+
76
+ def _open_pr(branch: str, base: str, title: str, body: str) -> str:
77
+ resp = requests.post(
78
+ f"{GITHUB_API}/repos/{GITHUB_REPO}/pulls",
79
+ headers=_headers(),
80
+ json={"title": title, "head": branch, "base": base, "body": body},
81
+ )
82
+ resp.raise_for_status()
83
+ return resp.json()["html_url"]
84
+
85
+
86
+ def open_pr_for_submission(submission: dict, fields: dict) -> str:
87
+ """Splice the approved entry into the right data file and open a PR.
88
+
89
+ `submission` is the row from submissions.py; `fields` are the
90
+ reviewer-filled values matching formatters.TARGETS[type_]'s schema.
91
+ """
92
+ type_ = submission["type"]
93
+ file_path = formatters.target_file(type_)
94
+ entry_block = formatters.render_entry(type_, fields)
95
+ org_id = fields.get("orgId")
96
+
97
+ base = _default_branch()
98
+ base_sha = _branch_sha(base)
99
+
100
+ slug = fields.get("id", submission["id"][:8])
101
+ branch = f"add/{type_}-{slug}"
102
+ _create_branch(branch, base_sha)
103
+
104
+ content, sha = _get_file(file_path, branch)
105
+ new_content = formatters.insert_entry(content, type_, entry_block, org_id)
106
+ _update_file(
107
+ file_path, branch, new_content, sha,
108
+ message=f"Add {type_}: {fields.get('name') or fields.get('title') or slug}",
109
+ )
110
+
111
+ title = f"Add {type_}: {fields.get('name') or fields.get('title') or slug}"
112
+ body = (
113
+ f"Auto-generated from a huggingscience.co submission via the review Space.\n\n"
114
+ f"- **Submission id:** `{submission['id']}`\n"
115
+ f"- **Submitted at:** {submission['submitted_at']}\n"
116
+ f"- **Source:** {submission['source']}\n\n"
117
+ f"**Original description:**\n{submission['description']}\n"
118
+ )
119
+ return _open_pr(branch, base, title, body)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio[oauth]>=5.0.0
2
+ huggingface_hub>=0.25.0
3
+ requests>=2.31.0
4
+ dotenv
submissions.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Requests queue backed by the existing hugging-science/feedback HF Dataset.
2
+
3
+ `requests.jsonl` holds rows written by the feedback-api Space's /submit
4
+ endpoint for every non-"feedback" type (dataset/model/organization/blog/
5
+ challenge). Ingestion happens there, not here — this module only reads that
6
+ file and updates a row's status when the reviewer approves/rejects it.
7
+ """
8
+
9
+ import json
10
+ import threading
11
+ from datetime import datetime, timezone
12
+
13
+ from huggingface_hub import HfApi
14
+ from huggingface_hub.utils import EntryNotFoundError
15
+
16
+ from about import DATASET_REPO, REQUESTS_FILE, TOKEN
17
+
18
+ _api = HfApi(token=TOKEN)
19
+ _lock = threading.Lock()
20
+
21
+
22
+ def _now() -> str:
23
+ return datetime.now(timezone.utc).isoformat()
24
+
25
+
26
+ def _read_all() -> list[dict]:
27
+ try:
28
+ path = _api.hf_hub_download(
29
+ repo_id=DATASET_REPO, repo_type="dataset", filename=REQUESTS_FILE
30
+ )
31
+ except EntryNotFoundError:
32
+ return []
33
+ with open(path, encoding="utf-8") as f:
34
+ return [json.loads(line) for line in f if line.strip()]
35
+
36
+
37
+ def _write_all(rows: list[dict]) -> None:
38
+ content = "\n".join(json.dumps(row) for row in rows) + "\n"
39
+ _api.upload_file(
40
+ path_or_fileobj=content.encode("utf-8"),
41
+ path_in_repo=REQUESTS_FILE,
42
+ repo_id=DATASET_REPO,
43
+ repo_type="dataset",
44
+ commit_message="Update requests queue",
45
+ )
46
+
47
+
48
+ def list_submissions(status: str | None = None) -> list[dict]:
49
+ rows = _read_all()
50
+ if status is None:
51
+ return rows
52
+ return [r for r in rows if r["status"] == status]
53
+
54
+
55
+ def get_submission(submission_id: str) -> dict | None:
56
+ for row in _read_all():
57
+ if row["id"] == submission_id:
58
+ return row
59
+ return None
60
+
61
+
62
+ def mark_approved(submission_id: str, pr_url: str) -> None:
63
+ with _lock:
64
+ rows = _read_all()
65
+ for row in rows:
66
+ if row["id"] == submission_id:
67
+ row["status"] = "approved"
68
+ row["reviewed_at"] = _now()
69
+ row["pr_url"] = pr_url
70
+ break
71
+ _write_all(rows)
72
+
73
+
74
+ def mark_rejected(submission_id: str, reason: str | None) -> None:
75
+ with _lock:
76
+ rows = _read_all()
77
+ for row in rows:
78
+ if row["id"] == submission_id:
79
+ row["status"] = "rejected"
80
+ row["reviewed_at"] = _now()
81
+ row["reject_reason"] = reason
82
+ break
83
+ _write_all(rows)