Repository follow-ups — README + topics + merge-upstream
Three additive endpoints that round out the §2 repos surface. The core repos CRUD (list/single/create/patch/delete) lives in Repositories; this page covers the three that arrived later: rendering-free README fetch, topic replacement, and fork sync.
Scopes:
repo:readon README.repo:writeon topics replace/clear and merge-upstream.
Endpoints
GET /api/v1/repos/{o}/{r}/readme[?ref=]
PUT /api/v1/repos/{o}/{r}/topics
DELETE /api/v1/repos/{o}/{r}/topics
POST /api/v1/repos/{o}/{r}/merge-upstream
README
GET /api/v1/repos/{o}/{r}/readme returns the repo's README at
ref (default branch when omitted). The handler walks the root
tree, picks the first entry whose name starts with readme
(case-insensitive), and prefers .md / .markdown over plain
text so a repo with both README.md and README.rst returns the
markdown variant — matching the HTML code-view's choice.
Response shape:
{
"name": "README.md",
"path": "README.md",
"size": 312,
"encoding": "base64",
"content": "IyBEZW1vIHJlcG8K...",
"download_url": "http://shithub.local/alice/demo/raw/trunk/README.md"
}
content is base64-encoded so binary or UTF-16 READMEs round-trip
cleanly. size matches the decoded byte count. The handler caps
reads at 1 MiB (matching the HTML render cap); larger READMEs are
truncated to that cap but the response still succeeds. Use
download_url to stream the full blob when the cap matters.
Errors:
| Status | Cause |
|---|---|
| 404 | Repo missing, caller lacks read, ref absent, or no README |
The 404 is existence-leak-safe: a private repo the caller can't read returns the same 404 as a public repo with no README.
Topics
PUT /api/v1/repos/{o}/{r}/topics replaces the full topic set
atomically. Body:
{ "names": ["go", "rest-api", "shithub"] }
Response (200):
{ "names": ["go", "rest-api", "shithub"] }
Topics are normalized server-side (lowercased, deduped) and validated:
- Max 20 per repo.
- Each name 1–50 chars, lowercase letters / digits / hyphens only.
Invalid input returns 422 with a JSON error describing the
violated constraint.
DELETE /api/v1/repos/{o}/{r}/topics clears all topics. Returns
204 No Content. Idempotent — clearing an empty set still
returns 204.
Merge-upstream (fork sync)
POST /api/v1/repos/{o}/{r}/merge-upstream fast-forwards a fork's
default branch to its upstream. Mirrors GitHub's "Sync fork"
button.
The handler refuses non-forks with 422. For a real fork it
calls the shared fork.Sync orchestrator, which only proceeds
when the merge is a clean fast-forward — divergent forks return
409 and must be reconciled by the user via their git client.
Successful response:
{
"merged": true,
"old_oid": "a1b2...",
"new_oid": "c3d4...",
"base_branch": "trunk",
"message": "fast-forwarded to upstream"
}
Already-up-to-date (200, not an error):
{
"merged": false,
"message": "already up to date"
}
Errors:
| Status | Cause |
|---|---|
| 409 | Fork has diverged from upstream — sync via your client. |
| 409 | Ref changed concurrently — retry. |
| 409 | Fork still being initialized — retry shortly. |
| 422 | Repo is not a fork. |
| 422 | Source or fork default branch is empty. |
The endpoint is intentionally narrower than GitHub's: we only fast-forward. A "fork sync with merge commit" mode would require the runner to pick an author identity and resolve conflicts, both of which we'd rather the caller do locally.
View source
| 1 | # Repository follow-ups — README + topics + merge-upstream |
| 2 | |
| 3 | Three additive endpoints that round out the §2 repos surface. The |
| 4 | core repos CRUD (list/single/create/patch/delete) lives in |
| 5 | [Repositories](./repos.md); this page covers the three that arrived |
| 6 | later: rendering-free README fetch, topic replacement, and fork |
| 7 | sync. |
| 8 | |
| 9 | Scopes: |
| 10 | |
| 11 | - `repo:read` on README. |
| 12 | - `repo:write` on topics replace/clear and merge-upstream. |
| 13 | |
| 14 | ## Endpoints |
| 15 | |
| 16 | ``` |
| 17 | GET /api/v1/repos/{o}/{r}/readme[?ref=] |
| 18 | PUT /api/v1/repos/{o}/{r}/topics |
| 19 | DELETE /api/v1/repos/{o}/{r}/topics |
| 20 | POST /api/v1/repos/{o}/{r}/merge-upstream |
| 21 | ``` |
| 22 | |
| 23 | ## README |
| 24 | |
| 25 | `GET /api/v1/repos/{o}/{r}/readme` returns the repo's README at |
| 26 | `ref` (default branch when omitted). The handler walks the root |
| 27 | tree, picks the first entry whose name starts with `readme` |
| 28 | (case-insensitive), and prefers `.md` / `.markdown` over plain |
| 29 | text so a repo with both `README.md` and `README.rst` returns the |
| 30 | markdown variant — matching the HTML code-view's choice. |
| 31 | |
| 32 | Response shape: |
| 33 | |
| 34 | ```json |
| 35 | { |
| 36 | "name": "README.md", |
| 37 | "path": "README.md", |
| 38 | "size": 312, |
| 39 | "encoding": "base64", |
| 40 | "content": "IyBEZW1vIHJlcG8K...", |
| 41 | "download_url": "http://shithub.local/alice/demo/raw/trunk/README.md" |
| 42 | } |
| 43 | ``` |
| 44 | |
| 45 | `content` is base64-encoded so binary or UTF-16 READMEs round-trip |
| 46 | cleanly. `size` matches the decoded byte count. The handler caps |
| 47 | reads at 1 MiB (matching the HTML render cap); larger READMEs are |
| 48 | truncated to that cap but the response still succeeds. Use |
| 49 | `download_url` to stream the full blob when the cap matters. |
| 50 | |
| 51 | Errors: |
| 52 | |
| 53 | | Status | Cause | |
| 54 | |------:|-----------------------------------------------------------| |
| 55 | | 404 | Repo missing, caller lacks read, ref absent, or no README | |
| 56 | |
| 57 | The 404 is existence-leak-safe: a private repo the caller can't |
| 58 | read returns the same 404 as a public repo with no README. |
| 59 | |
| 60 | ## Topics |
| 61 | |
| 62 | `PUT /api/v1/repos/{o}/{r}/topics` replaces the full topic set |
| 63 | atomically. Body: |
| 64 | |
| 65 | ```json |
| 66 | { "names": ["go", "rest-api", "shithub"] } |
| 67 | ``` |
| 68 | |
| 69 | Response (200): |
| 70 | |
| 71 | ```json |
| 72 | { "names": ["go", "rest-api", "shithub"] } |
| 73 | ``` |
| 74 | |
| 75 | Topics are normalized server-side (lowercased, deduped) and |
| 76 | validated: |
| 77 | |
| 78 | - Max 20 per repo. |
| 79 | - Each name 1–50 chars, lowercase letters / digits / hyphens |
| 80 | only. |
| 81 | |
| 82 | Invalid input returns `422` with a JSON error describing the |
| 83 | violated constraint. |
| 84 | |
| 85 | `DELETE /api/v1/repos/{o}/{r}/topics` clears all topics. Returns |
| 86 | `204 No Content`. Idempotent — clearing an empty set still |
| 87 | returns 204. |
| 88 | |
| 89 | ## Merge-upstream (fork sync) |
| 90 | |
| 91 | `POST /api/v1/repos/{o}/{r}/merge-upstream` fast-forwards a fork's |
| 92 | default branch to its upstream. Mirrors GitHub's "Sync fork" |
| 93 | button. |
| 94 | |
| 95 | The handler refuses non-forks with `422`. For a real fork it |
| 96 | calls the shared `fork.Sync` orchestrator, which only proceeds |
| 97 | when the merge is a clean fast-forward — divergent forks return |
| 98 | `409` and must be reconciled by the user via their git client. |
| 99 | |
| 100 | Successful response: |
| 101 | |
| 102 | ```json |
| 103 | { |
| 104 | "merged": true, |
| 105 | "old_oid": "a1b2...", |
| 106 | "new_oid": "c3d4...", |
| 107 | "base_branch": "trunk", |
| 108 | "message": "fast-forwarded to upstream" |
| 109 | } |
| 110 | ``` |
| 111 | |
| 112 | Already-up-to-date (200, not an error): |
| 113 | |
| 114 | ```json |
| 115 | { |
| 116 | "merged": false, |
| 117 | "message": "already up to date" |
| 118 | } |
| 119 | ``` |
| 120 | |
| 121 | Errors: |
| 122 | |
| 123 | | Status | Cause | |
| 124 | |------:|-----------------------------------------------------------| |
| 125 | | 409 | Fork has diverged from upstream — sync via your client. | |
| 126 | | 409 | Ref changed concurrently — retry. | |
| 127 | | 409 | Fork still being initialized — retry shortly. | |
| 128 | | 422 | Repo is not a fork. | |
| 129 | | 422 | Source or fork default branch is empty. | |
| 130 | |
| 131 | The endpoint is intentionally narrower than GitHub's: we only |
| 132 | fast-forward. A "fork sync with merge commit" mode would require |
| 133 | the runner to pick an author identity and resolve conflicts, both |
| 134 | of which we'd rather the caller do locally. |