Files
Nicholas Tindleandunknown 28d65d4a21 ci(platform): keep the gitbook branch a one-way copy of master's docs (#15165)
### Why / What / How

**Why:** GitBook publishes agpt.co/docs from the `gitbook` branch. That
branch has been kept in step with `dev` by hand-made sync PRs (#14019,
#14171, #14554), and it drifts between them: the live site is currently
three weeks behind. The branch is also writable by people and by GitBook
itself, so changes could be made there that never reach `dev`. The
branch exists so that GitBook never writes to a deployable branch; that
stays.

**What:** `gitbook` becomes a one-way, docs-only copy of `master`'s
`docs/`, written only by automation. The published docs then match what
is deployed. If anything else writes to the branch, the sync reports it
in an issue and puts `master`'s docs back.

**How:** A workflow runs on every push to `master`, daily, and on manual
dispatch. It runs a small stdlib-only script as the `autogpt-batch-bot`
App. The script reads `master`'s `docs/` tree, and if `gitbook` does not
already hold exactly that, writes one commit on top of the branch head
whose tree is only `docs/`. It uses the Git Data API, never
force-pushes, and leaves author and committer unset so GitHub signs the
commit as the App. A repository ruleset (applied by hand afterwards,
recorded here as JSON) restricts updates of `gitbook` to that App.
`GITHUB_TOKEN` cannot be given a ruleset bypass, which is why the App is
used.

### Changes 🏗️

- **`.github/workflows/docs-gitbook-sync.yml`** (new): the workflow. It
only runs in this repository and only from `master`'s copy of itself.
- **`.github/workflows/scripts/gitbook_sync.py`** (new): the sync.
- A commit it wrote carries a `Synced-From: <master sha>` trailer. The
head counts as its own only if it is docs-only and its `docs/` equals
that commit's `docs/`.
- When the head is not its own, it lists the first-parent commits since
its last snapshot and who moved the branch (repository activity log),
reports them in an issue **before** moving the branch, writes a fresh
snapshot on top, and exits non-zero. The foreign commits stay in the
branch history.
- A branch that was rewritten or recreated since a previous sync is
reported the same way. The very first sync, when the App has never
written the branch, is not.
- If it cannot update the branch (or `master` has no `docs/`, or the
branch is missing), it reports that in the same issue and exits
non-zero.
- A report already on the open issue is not posted again, so a problem
that persists does not add a comment per run. Only an issue the App
itself opened is reused.
- Text taken from commits is quoted and stripped of control characters
before it reaches the issue or the job log.
- **`.github/workflows/scripts/test_gitbook_sync.py`** (new): 33 tests
over an in-memory fake of the endpoints used. They run in the existing
PR status checker job.
- **`.github/rulesets/gitbook-sync-only.json`,
`gitbook-no-force-push-or-delete.json`** (new): the two rulesets, as the
request bodies for `POST /repos/{owner}/{repo}/rulesets`. Nothing
applies them automatically.
- **`docs/content/contribute/index.md`, `docs/AGENTS.md`**: docs changes
go to `dev`; never commit to `gitbook` or edit pages in GitBook.

**Configuration:**

- Uses the existing `BATCH_BOT_APP_ID` variable and
`BATCH_BOT_PRIVATE_KEY` secret. The token is requested with `contents:
write` and `issues: write` only.
- Optional new repository variable `GITBOOK_SYNC_NOTIFY`: who to mention
on a report. The default mentions `@Significant-Gravitas/maintainers`
and `@ntindle`; the App cannot see organization teams today, so the team
mention may not notify.

**What happens when this reaches `master`:**

- The release push itself triggers the first run. It writes one commit
on `gitbook` that removes everything except `docs/` (about 6,000 files)
and brings `docs/` to `master`'s. GitBook then imports the Platform and
Integrations spaces; Home is unchanged.
- Nothing on `gitbook` is lost: its tree equals `dev` as of 2026-09-14,
which `master` contains. Anything merged into `gitbook` between now and
that release would be dropped without a report, because the first sync
is silent by design.

**Manual steps after the first successful run (admin):**

1. Apply the two rulesets: `gh api -X POST
repos/Significant-Gravitas/AutoGPT/rulesets --input
.github/rulesets/gitbook-sync-only.json`, then the same with
`gitbook-no-force-push-or-delete.json`.
2. Move #14048 to `dev` and close #15157; PRs into `gitbook` cannot be
merged once the rulesets are on.
3. In GitBook: stop change requests being merged in the three synced
spaces, and remove Git Sync from the two unused spaces that still point
at this branch.

**What this does not do, or has not been proven:**

- GitHub cannot stop a change request being merged inside GitBook. Such
a change goes live on the site, fails to push, and is overwritten by the
next copy.
- Three steps are only exercised by the first real run: the App (which
has no `workflows` permission) writing a commit that removes workflow
files, the App's ruleset bypass applying to an API ref update, and
GitBook importing an update made through the API. A rejected write is
reported and fails the run; the fallback is for an admin to push that
first commit.
- The App's key is a repository-level secret, so anyone who can push a
workflow to any branch could mint its token. The rulesets keep out
GitBook, pull-request merges and ordinary pushes; they are not a barrier
against people with write access.
- A hand-made commit that copies the snapshot format exactly is treated
as the sync's own.

### Agents and large language models used

Claude Code (model: unknown)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] `python -m unittest discover -s .github/workflows/scripts -p
"test_*.py"` passes (54 tests, 33 new), prints no workflow commands and
writes nothing to the step summary
- [x] Dry run against this repository with a read-only token
(`DRY_RUN=1`): reads `master` and `gitbook`, detects a first sync, and
plans one commit on top of `gitbook@fb7c677bd3`
- [x] The single-entry tree built from `master`'s `docs/` is identical
to `master`'s `docs/` (checked locally with `git mktree` and `git diff`)
- [x] Both ruleset files match the shape of the live `batch-rollup
bot-only` ruleset
- [ ] First real run on `master` (only possible after release; see
above)

<details>
  <summary>Example test plan</summary>
  
  - [ ] Create from scratch and execute an agent with at least 3 blocks
- [ ] Import an agent from file upload, and confirm it executes
correctly
  - [ ] Upload agent to marketplace
- [ ] Import an agent from marketplace and confirm it executes correctly
  - [ ] Edit an agent from monitor, and confirm it executes correctly
</details>

#### For configuration changes:

- [x] `.env.default` is updated or already compatible with my changes
- [x] `docker-compose.yml` is updated or already compatible with my
changes
- [x] I have included a list of my configuration changes in the PR
description (under **Changes**)

<details>
  <summary>Examples of configuration changes</summary>

  - Changing ports
  - Adding new services that need to communicate with each other
  - Secrets or environment variable changes
  - New or infrastructure changes such as databases
</details>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> The first run on `master` will rewrite `gitbook` to a docs-only tree
and changes how published docs are updated; misconfiguration or rulesets
not applied could block sync or leave stale docs until fixed.
> 
> **Overview**
> Adds **automated one-way sync** from `master`'s `docs/` to the
`gitbook` branch (what GitBook publishes), replacing manual sync PRs and
direct edits on `gitbook`.
> 
> A new **`docs-gitbook-sync`** workflow runs on pushes to `master`,
daily, and on dispatch. It mints a **batch-bot GitHub App** token and
runs `gitbook_sync.py`, which fast-forwards `gitbook` with a
**docs-only** commit tagged `Synced-From: <master sha>`. If the branch
head was changed by anything else, the script **opens or updates an
issue** (with deduped reports), restores `master`'s docs on top, and
**fails the job** so the drift is visible. Two **ruleset JSON files**
document branch rules: no delete/force-push, and updates allowed only
for the sync App (applied manually by admins).
> 
> Contributor docs in **`docs/AGENTS.md`** and
**`docs/content/contribute/index.md`** now say to PR **`dev`**, not
`gitbook`, and warn that GitBook edits are overwritten.
**`test_gitbook_sync.py`** adds broad unit tests for sync, reporting,
and API retry behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
aeb0d72c4e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: unknown (Claude Code) <[email protected]>
2026-10-03 15:42:26 +00:00

607 lines
21 KiB
Python

import contextlib
import io
import json
import os
import re
import unittest
import urllib.error
import urllib.parse
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, patch
import gitbook_sync
from gitbook_sync import ISSUE_TITLE, ApiError, GitHub, snapshot_message, synced_from
BOT = "docs-sync[bot]"
PERSON = {
"name": "Some Editor",
"email": "[email protected]",
"date": "2026-10-03T00:00:00Z",
}
GITBOOK_BOT = {
"name": "gitbook-bot",
"email": "[email protected]",
"date": "2026-10-03T00:00:00Z",
}
class FakeGitHub:
"""The slice of the GitHub API the sync uses, over in-memory objects."""
repo = "example/repo"
actor = BOT
def __init__(self) -> None:
self.commits: Dict[str, Dict[str, Any]] = {}
self.trees: Dict[str, List[Dict[str, str]]] = {}
self.refs: Dict[str, str] = {}
self.issues: List[Dict[str, Any]] = []
self.comments: List[Dict[str, Any]] = []
self.activity: List[Dict[str, Any]] = []
self.writes: List[str] = []
self.reject_ref_update = False
self.reject_issue_writes = False
self._next = 0
def _sha(self) -> str:
self._next += 1
return f"{self._next:040x}"
def tree(self, **entries: str) -> str:
"""Add a root tree; `docs="x"` makes a docs/ directory with content x."""
sha = self._sha()
self.trees[sha] = [
{"path": path, "type": "tree", "sha": f"{content:0>40}"}
for path, content in entries.items()
]
return sha
def commit(
self,
tree: str,
parents: List[str],
message: str = "a change",
committer: Optional[Dict[str, str]] = None,
) -> str:
sha = self._sha()
self.commits[sha] = {
"sha": sha,
"message": message,
"tree": {"sha": tree},
"parents": [{"sha": p} for p in parents],
"author": PERSON,
"committer": committer or PERSON,
}
return sha
def move(
self, branch: str, sha: str, login: Optional[str], kind: str = "push"
) -> None:
"""Point a branch at a commit the way GitHub records it."""
self.refs[branch] = sha
self.activity.insert(
0,
{
"timestamp": "2026-10-03T00:00:00Z",
"activity_type": kind,
"actor": {"login": login} if login else None,
"ref": f"refs/heads/{branch}",
"after": sha,
},
)
def open_issue(self, title: str, body: str, login: str) -> Dict[str, Any]:
issue = {
"number": len(self.issues) + 1,
"title": title,
"body": body,
"state": "open",
"user": {"login": login},
"html_url": f"https://example.com/issues/{len(self.issues) + 1}",
}
self.issues.append(issue)
return issue
def call(
self,
method: str,
path: str,
body: Optional[Dict[str, Any]] = None,
retry: bool = True,
) -> Any:
if method != "GET":
self.writes.append(f"{method} {path}")
path, _, query = path.partition("?")
params = dict(urllib.parse.parse_qsl(query))
if m := re.fullmatch(r"/git/ref/heads/(.+)", path):
if m[1] not in self.refs:
raise ApiError(method, path, 404, "Not Found")
return {"object": {"sha": self.refs[m[1]]}}
if m := re.fullmatch(r"/git/commits/(.+)", path):
if m[1] not in self.commits:
raise ApiError(method, path, 404, "Not Found")
return self.commits[m[1]]
if m := re.fullmatch(r"/git/trees/(.+)", path):
return {"tree": self.trees[m[1]]}
if (method, path) == ("POST", "/git/trees"):
sha = self._sha()
self.trees[sha] = [
{"path": e["path"], "type": e["type"], "sha": e["sha"]}
for e in body["tree"]
]
return {"sha": sha}
if (method, path) == ("POST", "/git/commits"):
return {"sha": self.commit(body["tree"], body["parents"], body["message"])}
if m := re.fullmatch(r"/git/refs/heads/(.+)", path):
if self.reject_ref_update or body["force"]:
raise ApiError(method, path, 422, "Update is not a fast forward")
self.move(m[1], body["sha"], self.actor)
return {}
if path == "/activity":
return [
a
for a in self.activity
if a["ref"] == params["ref"]
and (
"actor" not in params
or (a["actor"] or {}).get("login") == params["actor"]
)
]
if (method, path) == ("GET", "/issues"):
return [
i
for i in self.issues
if i["state"] == "open" and i["user"]["login"] == params["creator"]
]
if self.reject_issue_writes and path.startswith("/issues"):
raise ApiError(method, path, 503, "Service Unavailable")
if (method, path) == ("POST", "/issues"):
return self.open_issue(body["title"], body["body"], self.actor)
if m := re.fullmatch(r"/issues/(\d+)/comments", path):
if method == "GET":
return [c for c in self.comments if c["issue"] == int(m[1])]
self.comments.append({"issue": int(m[1]), "body": body["body"]})
return {}
raise AssertionError(f"unexpected call: {method} {path}")
def run(
gh: FakeGitHub, dry_run: bool = False, output: Optional[io.StringIO] = None
) -> int:
# Keep the script's workflow commands and job summary out of the CI job
# that runs these tests.
quiet = {k: v for k, v in os.environ.items() if not k.startswith("GITHUB_")}
with patch.dict(os.environ, quiet, clear=True):
with contextlib.redirect_stdout(output or io.StringIO()):
return gitbook_sync.run(
gh, "master", "gitbook", notify="@example/team", dry_run=dry_run
)
class SyncedFromTests(unittest.TestCase):
def test_reads_trailer(self):
sha = "a" * 40
self.assertEqual(synced_from(snapshot_message("master", sha)), sha)
def test_ignores_mentions_that_are_not_a_trailer_line(self):
self.assertIsNone(synced_from(f"Revert the commit Synced-From: {'a' * 40}"))
self.assertIsNone(synced_from("Synced-From: not-a-sha"))
class SyncTests(unittest.TestCase):
def setUp(self):
self.gh = FakeGitHub()
# master carries code and docs; gitbook starts as an old full copy
# with a few commits of its own.
self.master = self.gh.commit(self.gh.tree(docs="1", autogpt_platform="c"), [])
self.gh.move("master", self.master, "releaser")
older = self.gh.commit(self.gh.tree(docs="0", autogpt_platform="a"), [])
self.legacy = self.gh.commit(
self.gh.tree(docs="0", autogpt_platform="b"), [older]
)
self.gh.move("gitbook", self.legacy, "someone")
def head(self) -> Dict[str, Any]:
return self.gh.commits[self.gh.refs["gitbook"]]
def advance_master(self, docs: str) -> str:
self.master = self.gh.commit(
self.gh.tree(docs=docs, autogpt_platform="c"), [self.master]
)
self.gh.move("master", self.master, "releaser")
return self.master
def stray(self, message: str = "a stray edit", docs: str = "edited") -> str:
"""Somebody else commits on top of gitbook."""
sha = self.gh.commit(
self.gh.tree(docs=docs), [self.gh.refs["gitbook"]], message
)
self.gh.move("gitbook", sha, "someone")
return sha
def test_first_sync_writes_docs_only_snapshot_without_reporting(self):
self.assertEqual(run(self.gh), 0)
head = self.head()
self.assertEqual([p["sha"] for p in head["parents"]], [self.legacy])
self.assertEqual(synced_from(head["message"]), self.master)
self.assertEqual(
self.gh.trees[head["tree"]["sha"]],
[{"path": "docs", "type": "tree", "sha": f"{'1':0>40}"}],
)
self.assertEqual(self.gh.issues, [])
def test_nothing_to_do_when_snapshot_is_current(self):
run(self.gh)
self.gh.writes.clear()
self.assertEqual(run(self.gh), 0)
self.assertEqual(self.gh.writes, [])
def test_master_moving_without_docs_changes_writes_nothing(self):
run(self.gh)
self.advance_master(docs="1")
self.gh.writes.clear()
self.assertEqual(run(self.gh), 0)
self.assertEqual(self.gh.writes, [])
def test_docs_change_on_master_fast_forwards_gitbook(self):
run(self.gh)
previous = self.gh.refs["gitbook"]
self.advance_master(docs="2")
self.assertEqual(run(self.gh), 0)
head = self.head()
self.assertEqual([p["sha"] for p in head["parents"]], [previous])
self.assertEqual(synced_from(head["message"]), self.master)
self.assertEqual(self.gh.issues, [])
def test_foreign_commit_is_reported_and_docs_are_restored(self):
run(self.gh)
snapshot = self.gh.refs["gitbook"]
foreign = self.gh.commit(
self.gh.tree(docs="edited"),
[snapshot],
"GITBOOK-101: tweak install steps",
committer=GITBOOK_BOT,
)
self.gh.move("gitbook", foreign, "gitbook-com[bot]")
self.assertEqual(run(self.gh), 1)
head = self.head()
self.assertEqual([p["sha"] for p in head["parents"]], [foreign])
self.assertEqual(self.gh.trees[head["tree"]["sha"]][0]["sha"], f"{'1':0>40}")
self.assertEqual(len(self.gh.issues), 1)
issue = self.gh.issues[0]
self.assertEqual(issue["title"], ISSUE_TITLE)
self.assertIn(foreign, issue["body"])
self.assertIn("GITBOOK-101: tweak install steps", issue["body"])
self.assertIn("committed by gitbook-bot", issue["body"])
self.assertIn("push by `gitbook-com[bot]`", issue["body"])
self.assertIn("cc @example/team", issue["body"])
self.assertNotIn(snapshot, issue["body"])
self.assertNotIn(BOT, issue["body"])
def test_report_is_filed_before_the_branch_moves(self):
"""Otherwise a failed report could never be retried."""
run(self.gh)
stray = self.stray()
self.gh.reject_issue_writes = True
self.assertEqual(run(self.gh), 1)
self.assertEqual(self.gh.refs["gitbook"], stray)
self.gh.reject_issue_writes = False
self.assertEqual(run(self.gh), 1)
self.assertNotEqual(self.gh.refs["gitbook"], stray)
self.assertIn("a stray edit", self.gh.issues[0]["body"])
def test_second_report_comments_on_the_open_issue(self):
run(self.gh)
for subject in ("first stray", "second stray"):
self.stray(subject)
run(self.gh)
self.assertEqual(len(self.gh.issues), 1)
self.assertEqual(len(self.gh.comments), 1)
self.assertIn("second stray", self.gh.comments[0]["body"])
self.assertNotIn("first stray", self.gh.comments[0]["body"])
def test_issue_opened_by_someone_else_is_not_reused(self):
run(self.gh)
self.gh.open_issue(ISSUE_TITLE, "not from the sync", "an-outsider")
self.stray()
run(self.gh)
self.assertEqual(self.gh.comments, [])
self.assertEqual(
[i["user"]["login"] for i in self.gh.issues], ["an-outsider", BOT]
)
def test_merge_is_reported_as_one_commit(self):
run(self.gh)
snapshot = self.gh.refs["gitbook"]
merged_in = self.gh.commit(self.gh.tree(docs="dev"), [], "work from dev")
merge = self.gh.commit(
self.gh.tree(docs="dev", autogpt_platform="d"),
[snapshot, merged_in],
"Merge branch 'dev' into gitbook",
)
self.gh.move("gitbook", merge, "someone", "pr_merge")
run(self.gh)
body = self.gh.issues[0]["body"]
self.assertIn(merge, body)
self.assertNotIn(merged_in, body)
self.assertIn("pr_merge by `someone`", body)
def test_snapshot_with_changed_content_is_foreign(self):
"""An amended snapshot keeps the trailer but not the content."""
run(self.gh)
amended = self.stray(snapshot_message("master", self.master))
run(self.gh)
self.assertIn(amended, self.gh.issues[0]["body"])
def test_snapshot_naming_a_missing_commit_is_foreign(self):
run(self.gh)
forged = self.stray(snapshot_message("master", "f" * 40), docs="1")
run(self.gh)
self.assertIn(forged, self.gh.issues[0]["body"])
def test_trailer_below_the_head_does_not_hide_a_foreign_commit(self):
run(self.gh)
amended = self.stray(snapshot_message("master", self.master))
on_top = self.stray("on top of it")
run(self.gh)
body = self.gh.issues[0]["body"]
self.assertIn(on_top, body)
self.assertIn(amended, body)
def test_branch_replaced_after_a_sync_is_reported(self):
"""Recreated or force-pushed: no snapshot left to compare against."""
run(self.gh)
self.gh.move("gitbook", self.master, "an-admin", "force_push")
self.assertEqual(run(self.gh), 1)
body = self.gh.issues[0]["body"]
self.assertIn(self.master, body)
self.assertIn("rewritten, recreated", body)
self.assertIn("force_push by `an-admin`", body)
self.assertEqual(synced_from(self.head()["message"]), self.master)
def test_more_foreign_commits_than_the_history_limit_are_reported(self):
run(self.gh)
for n in range(5):
self.stray(f"stray {n}")
with patch.object(gitbook_sync, "HISTORY_LIMIT", 3):
self.assertEqual(run(self.gh), 1)
body = self.gh.issues[0]["body"]
self.assertIn("stray 4", body)
self.assertIn("rewritten, recreated", body)
def test_commit_text_cannot_mention_or_format(self):
run(self.gh)
self.stray("fix `x` @someone [link](https://example.com)")
run(self.gh)
self.assertIn(
"`fix 'x' @someone [link](https://example.com)`", self.gh.issues[0]["body"]
)
def test_control_characters_in_a_name_cannot_start_a_new_line(self):
"""A carriage return would begin a workflow command in the job log."""
run(self.gh)
stray = self.gh.commit(
self.gh.tree(docs="edited"), [self.gh.refs["gitbook"]], "a stray edit"
)
self.gh.commits[stray]["author"] = dict(
PERSON, name="Eve\r::error::forged\r\r@victim [l](https://example.com)"
)
self.gh.move("gitbook", stray, "someone")
output = io.StringIO()
run(self.gh, output=output)
self.assertNotIn("\r", output.getvalue())
self.assertNotIn("\r", self.gh.issues[0]["body"])
self.assertIn(
"`Eve ::error::forged @victim [l](https://example.com), "
"committed by Some Editor`",
self.gh.issues[0]["body"],
)
def test_bot_activity_on_another_branch_is_not_a_previous_sync(self):
self.gh.move("batch/rollup", self.master, BOT, "force_push")
self.assertEqual(run(self.gh), 0)
self.assertEqual(self.gh.issues, [])
def test_pull_request_with_the_report_title_is_not_reused(self):
"""The issues endpoint lists pull requests too."""
run(self.gh)
self.gh.open_issue(ISSUE_TITLE, "a pull request", BOT)["pull_request"] = {}
self.stray()
run(self.gh)
self.assertEqual(self.gh.comments, [])
self.assertEqual(len(self.gh.issues), 2)
def test_persistent_failure_is_reported_once(self):
self.gh.reject_ref_update = True
for _ in range(5):
self.assertEqual(run(self.gh), 1)
self.assertEqual(len(self.gh.issues), 1)
self.assertEqual(self.gh.comments, [])
def test_foreign_commits_with_a_failing_write_are_reported_once(self):
run(self.gh)
self.stray()
self.gh.reject_ref_update = True
for _ in range(5):
self.assertEqual(run(self.gh), 1)
self.assertEqual(len(self.gh.issues), 1)
self.assertIn("a stray edit", self.gh.issues[0]["body"])
self.assertEqual(len(self.gh.comments), 1)
self.assertIn("could not update `gitbook`", self.gh.comments[0]["body"])
def test_issue_posts_are_not_retried(self):
"""A retried POST that had succeeded would open a second issue."""
run(self.gh)
self.stray()
calls = []
real_call = self.gh.call
def recording(method, path, body=None, retry=True):
calls.append((method, path.partition("?")[0], retry))
return real_call(method, path, body, retry)
self.gh.call = recording
run(self.gh)
self.assertIn(("POST", "/issues", False), calls)
self.assertIn(("PATCH", "/git/refs/heads/gitbook", True), calls)
def test_activity_without_an_actor_does_not_stop_the_restore(self):
run(self.gh)
stray = self.stray()
self.gh.activity[0]["actor"] = None
self.assertEqual(run(self.gh), 1)
self.assertNotEqual(self.gh.refs["gitbook"], stray)
self.assertIn("push by `unknown`", self.gh.issues[0]["body"])
def test_dry_run_writes_nothing(self):
self.assertEqual(run(self.gh, dry_run=True), 0)
self.assertEqual(self.gh.writes, [])
self.assertEqual(self.gh.refs["gitbook"], self.legacy)
def test_source_without_docs_is_reported_and_fails(self):
self.gh.move(
"master",
self.gh.commit(self.gh.tree(autogpt_platform="c"), [self.master]),
"releaser",
)
self.assertEqual(run(self.gh), 1)
self.assertEqual(self.gh.refs["gitbook"], self.legacy)
self.assertIn("has no docs/ directory", self.gh.issues[0]["body"])
def test_missing_target_branch_is_reported_and_fails(self):
del self.gh.refs["gitbook"]
self.assertEqual(run(self.gh), 1)
self.assertIn("could not update `gitbook`", self.gh.issues[0]["body"])
self.assertIn("HTTP 404", self.gh.issues[0]["body"])
def test_rejected_ref_update_is_reported_and_fails(self):
"""A sync that cannot write must not just leave the site stale."""
self.gh.reject_ref_update = True
self.assertEqual(run(self.gh), 1)
self.assertEqual(self.gh.refs["gitbook"], self.legacy)
self.assertEqual(len(self.gh.issues), 1)
self.assertIn("could not update `gitbook`", self.gh.issues[0]["body"])
self.assertIn("Update is not a fast forward", self.gh.issues[0]["body"])
def response(payload: Any) -> MagicMock:
opened = MagicMock()
opened.__enter__.return_value = io.BytesIO(json.dumps(payload).encode())
return opened
def http_error(status: int) -> urllib.error.HTTPError:
return urllib.error.HTTPError(
"url", status, "error", {}, io.BytesIO(b"upstream\nbroke")
)
class GitHubCallTests(unittest.TestCase):
def setUp(self):
self.gh = GitHub("https://api.example.com", "example/repo", "token", BOT)
self.gh.retry_delay = 0
def test_retries_a_server_error(self):
with patch("urllib.request.urlopen") as urlopen:
urlopen.side_effect = [http_error(502), response({"ok": True})]
self.assertEqual(self.gh.call("GET", "/git/ref/heads/master"), {"ok": True})
self.assertEqual(urlopen.call_count, 2)
self.assertEqual(
urlopen.call_args.args[0].full_url,
"https://api.example.com/repos/example/repo/git/ref/heads/master",
)
def test_does_not_retry_a_rejection(self):
with patch("urllib.request.urlopen") as urlopen:
urlopen.side_effect = [http_error(422), response({})]
with self.assertRaises(ApiError) as raised:
self.gh.call("PATCH", "/git/refs/heads/gitbook", {"sha": "x"})
self.assertEqual(urlopen.call_count, 1)
self.assertEqual(raised.exception.status, 422)
self.assertIn("upstream broke", str(raised.exception))
def test_reply_that_is_not_json_becomes_an_api_error(self):
broken = MagicMock()
broken.__enter__.return_value = io.BytesIO(b"")
with patch("urllib.request.urlopen") as urlopen:
urlopen.return_value = broken
with self.assertRaises(ApiError) as raised:
self.gh.call("GET", "/git/ref/heads/master")
self.assertEqual(raised.exception.status, 0)
def test_unrepeatable_call_is_sent_once(self):
with patch("urllib.request.urlopen") as urlopen:
urlopen.side_effect = [http_error(502), response({})]
with self.assertRaises(ApiError):
self.gh.call("POST", "/issues", {"title": "t"}, retry=False)
self.assertEqual(urlopen.call_count, 1)
def test_network_failure_becomes_an_api_error(self):
with patch("urllib.request.urlopen") as urlopen:
urlopen.side_effect = urllib.error.URLError("connection reset")
with self.assertRaises(ApiError):
self.gh.call("GET", "/git/ref/heads/master")
self.assertEqual(urlopen.call_count, gitbook_sync.ATTEMPTS)
if __name__ == "__main__":
unittest.main()