git-bug-broker 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JovianIce
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-bug-broker
3
+ Version: 0.1.0
4
+ Summary: MCP server that lets several agents share one git-bug store while the web UI is open
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/JovianIce/git-bug-broker
7
+ Project-URL: Source, https://github.com/JovianIce/git-bug-broker
8
+ Project-URL: Issues, https://github.com/JovianIce/git-bug-broker/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: mcp<3,>=2.2
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # git-bug-broker
18
+
19
+ An MCP server that lets several agents file, label and close issues in one [git-bug](https://github.com/git-bug/git-bug) store (a distributed issue tracker that keeps issues in git refs) while its web UI stays open.
20
+
21
+ ![An issue filed, reproduced, fixed and closed by an agent and its subagents, seen in git-bug's web UI](https://raw.githubusercontent.com/JovianIce/git-bug-broker/main/docs/agent-bug-sample.png)
22
+
23
+ git-bug keeps issues inside the repository as git objects, so agents can track work
24
+ next to the code with no hosted tracker, API token or network. Through this server an
25
+ agent can open an issue for a bug it finds, comment on it as it investigates, label it,
26
+ read what other agents have written, and close it with a note naming the commit that
27
+ fixed it. Issues are versioned like commits and sync through any git remote with
28
+ `git bug push` and `git bug pull`, so the record carries over between sessions and
29
+ between agents.
30
+
31
+ `git-bug webui` holds the search index for as long as it runs, and every CLI command waits on it without saying so. The broker talks to the web UI's GraphQL endpoint instead, takes a file lock for each write, and refuses any write that breaks the project's conventions: claim-style titles, one `area/` and one `kind/` label, required body sections. A rejected write lists every problem at once. Unlike a wrapper around the CLI, it keeps working while `git-bug webui` is open, and concurrent writes don't collide. The client is also a small Python library, so a script can write to the store while the UI is open too.
32
+
33
+ Works with git-bug 0.11. Tested on Windows; the Linux and macOS paths are written but
34
+ untested.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ pip install git-bug-broker
40
+ ```
41
+
42
+ or from a checkout of [the repository](https://github.com/JovianIce/git-bug-broker), `pip install -e .`. Needs Python 3.10+ and `git-bug` on `PATH`.
43
+
44
+ ## Run
45
+
46
+ ```sh
47
+ git-bug-broker-start <repo> # start the web UI (reuses one already running)
48
+ git-bug-broker-start <repo> --status
49
+ git-bug-broker-start <repo> --stop
50
+ ```
51
+
52
+ It prints the web UI's URL. An issue is at `<url>/_/issues/<id>`, where `<id>` is the
53
+ short id the tools return. The issue list starts filtered to `status:open`; clear the
54
+ search box to see closed issues too. `--stop` only stops the process it started.
55
+
56
+ ## Use from Python
57
+
58
+ ```python
59
+ from git_bug_broker.client import Client
60
+
61
+ c = Client(repo="/path/to/repo")
62
+ c.file_entry(
63
+ "The retry loop never backs off after a 429",
64
+ "**What** retry() sleeps a fixed 1 s.
65
+ **Done when** the delay doubles per attempt.",
66
+ ["area/api", "kind/defect"],
67
+ )
68
+ ```
69
+
70
+ ## Connect an MCP client
71
+
72
+ `.mcp.json` for Claude Code:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "git-bug": {
78
+ "command": "git-bug-broker",
79
+ "env": {
80
+ "GITBUG_BROKER_REPO": "/path/to/repo",
81
+ "GITBUG_BROKER_RULES": "/path/to/repo/.git-bug-rules.json"
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Any stdio MCP client takes the same command and env.
89
+
90
+ `GITBUG_BROKER_RULES` is optional. Without it the defaults in
91
+ `src/git_bug_broker/rules/default.json` apply and any area name is accepted.
92
+ `examples/rules.example.json` shows a project file with a fixed area list.
93
+
94
+ `GITBUG_BROKER_URL` points the client at a web UI the broker did not start, for example one on another port.
95
+
96
+ Tools: `file_entry`, `comment`, `edit_body`, `edit_comment`, `relabel`, `set_status`,
97
+ `set_title`, `get`, `query`. A rejected write lists every problem at once.
98
+
99
+ ## Rules
100
+
101
+ What gets checked is in [CONVENTIONS.md](https://github.com/JovianIce/git-bug-broker/blob/main/CONVENTIONS.md). Briefly:
102
+
103
+ - a title is a claim about the code, at most 80 characters, with no leading number
104
+ - exactly one `area/` and one `kind/` label, with a slash, never a colon
105
+ - defects and chores have `What` and `Done when` sections
106
+
107
+ ## Why the lock
108
+
109
+ git-bug 0.11 applies an operation and commits it in two unguarded steps. When two
110
+ writes hit the same issue at once both land, but one caller is told
111
+ `can't commit an entity with no pending operation`, and a retry duplicates the write.
112
+ The broker holds `<repo>/.git/git-bug-broker/write.lock` for each write. Reads don't
113
+ lock.
114
+
115
+ Measured on git-bug 0.11 on Windows: with the lock, 24 parallel writes from 4 server processes, 12 of them to one shared
116
+ issue, finished in 3.7 s with no errors. Without it, 28 of 30 concurrent writes to one
117
+ issue reported failure although all 30 had landed.
118
+
119
+ ## Limitations
120
+
121
+ - The web UI has to be running. If it isn't, every tool says so and nothing is queued.
122
+ - Filing an issue is two mutations, create then label. A crash in between leaves an
123
+ unlabelled issue; the error gives its id.
124
+ - The web UI binds to 127.0.0.1 with no authentication.
125
+ - Edits made in the web UI aren't checked against the rules.
126
+ - Free-text search returns at most 10 results. `title:`, `label:` and `status:`
127
+ filters return everything.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ pip install -e .[dev]
133
+ pytest
134
+ ```
135
+
136
+ `tools/view.py` renders the store as a static HTML page and a JSON Lines snapshot. It reads through the CLI, so run it while the web UI is stopped; anything reading its output needs neither.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,124 @@
1
+ # git-bug-broker
2
+
3
+ An MCP server that lets several agents file, label and close issues in one [git-bug](https://github.com/git-bug/git-bug) store (a distributed issue tracker that keeps issues in git refs) while its web UI stays open.
4
+
5
+ ![An issue filed, reproduced, fixed and closed by an agent and its subagents, seen in git-bug's web UI](https://raw.githubusercontent.com/JovianIce/git-bug-broker/main/docs/agent-bug-sample.png)
6
+
7
+ git-bug keeps issues inside the repository as git objects, so agents can track work
8
+ next to the code with no hosted tracker, API token or network. Through this server an
9
+ agent can open an issue for a bug it finds, comment on it as it investigates, label it,
10
+ read what other agents have written, and close it with a note naming the commit that
11
+ fixed it. Issues are versioned like commits and sync through any git remote with
12
+ `git bug push` and `git bug pull`, so the record carries over between sessions and
13
+ between agents.
14
+
15
+ `git-bug webui` holds the search index for as long as it runs, and every CLI command waits on it without saying so. The broker talks to the web UI's GraphQL endpoint instead, takes a file lock for each write, and refuses any write that breaks the project's conventions: claim-style titles, one `area/` and one `kind/` label, required body sections. A rejected write lists every problem at once. Unlike a wrapper around the CLI, it keeps working while `git-bug webui` is open, and concurrent writes don't collide. The client is also a small Python library, so a script can write to the store while the UI is open too.
16
+
17
+ Works with git-bug 0.11. Tested on Windows; the Linux and macOS paths are written but
18
+ untested.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ pip install git-bug-broker
24
+ ```
25
+
26
+ or from a checkout of [the repository](https://github.com/JovianIce/git-bug-broker), `pip install -e .`. Needs Python 3.10+ and `git-bug` on `PATH`.
27
+
28
+ ## Run
29
+
30
+ ```sh
31
+ git-bug-broker-start <repo> # start the web UI (reuses one already running)
32
+ git-bug-broker-start <repo> --status
33
+ git-bug-broker-start <repo> --stop
34
+ ```
35
+
36
+ It prints the web UI's URL. An issue is at `<url>/_/issues/<id>`, where `<id>` is the
37
+ short id the tools return. The issue list starts filtered to `status:open`; clear the
38
+ search box to see closed issues too. `--stop` only stops the process it started.
39
+
40
+ ## Use from Python
41
+
42
+ ```python
43
+ from git_bug_broker.client import Client
44
+
45
+ c = Client(repo="/path/to/repo")
46
+ c.file_entry(
47
+ "The retry loop never backs off after a 429",
48
+ "**What** retry() sleeps a fixed 1 s.
49
+ **Done when** the delay doubles per attempt.",
50
+ ["area/api", "kind/defect"],
51
+ )
52
+ ```
53
+
54
+ ## Connect an MCP client
55
+
56
+ `.mcp.json` for Claude Code:
57
+
58
+ ```json
59
+ {
60
+ "mcpServers": {
61
+ "git-bug": {
62
+ "command": "git-bug-broker",
63
+ "env": {
64
+ "GITBUG_BROKER_REPO": "/path/to/repo",
65
+ "GITBUG_BROKER_RULES": "/path/to/repo/.git-bug-rules.json"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ Any stdio MCP client takes the same command and env.
73
+
74
+ `GITBUG_BROKER_RULES` is optional. Without it the defaults in
75
+ `src/git_bug_broker/rules/default.json` apply and any area name is accepted.
76
+ `examples/rules.example.json` shows a project file with a fixed area list.
77
+
78
+ `GITBUG_BROKER_URL` points the client at a web UI the broker did not start, for example one on another port.
79
+
80
+ Tools: `file_entry`, `comment`, `edit_body`, `edit_comment`, `relabel`, `set_status`,
81
+ `set_title`, `get`, `query`. A rejected write lists every problem at once.
82
+
83
+ ## Rules
84
+
85
+ What gets checked is in [CONVENTIONS.md](https://github.com/JovianIce/git-bug-broker/blob/main/CONVENTIONS.md). Briefly:
86
+
87
+ - a title is a claim about the code, at most 80 characters, with no leading number
88
+ - exactly one `area/` and one `kind/` label, with a slash, never a colon
89
+ - defects and chores have `What` and `Done when` sections
90
+
91
+ ## Why the lock
92
+
93
+ git-bug 0.11 applies an operation and commits it in two unguarded steps. When two
94
+ writes hit the same issue at once both land, but one caller is told
95
+ `can't commit an entity with no pending operation`, and a retry duplicates the write.
96
+ The broker holds `<repo>/.git/git-bug-broker/write.lock` for each write. Reads don't
97
+ lock.
98
+
99
+ Measured on git-bug 0.11 on Windows: with the lock, 24 parallel writes from 4 server processes, 12 of them to one shared
100
+ issue, finished in 3.7 s with no errors. Without it, 28 of 30 concurrent writes to one
101
+ issue reported failure although all 30 had landed.
102
+
103
+ ## Limitations
104
+
105
+ - The web UI has to be running. If it isn't, every tool says so and nothing is queued.
106
+ - Filing an issue is two mutations, create then label. A crash in between leaves an
107
+ unlabelled issue; the error gives its id.
108
+ - The web UI binds to 127.0.0.1 with no authentication.
109
+ - Edits made in the web UI aren't checked against the rules.
110
+ - Free-text search returns at most 10 results. `title:`, `label:` and `status:`
111
+ filters return everything.
112
+
113
+ ## Development
114
+
115
+ ```sh
116
+ pip install -e .[dev]
117
+ pytest
118
+ ```
119
+
120
+ `tools/view.py` renders the store as a static HTML page and a JSON Lines snapshot. It reads through the CLI, so run it while the web UI is stopped; anything reading its output needs neither.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "git-bug-broker"
7
+ version = "0.1.0"
8
+ description = "MCP server that lets several agents share one git-bug store while the web UI is open"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["mcp>=2.2,<3"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/JovianIce/git-bug-broker"
20
+ Source = "https://github.com/JovianIce/git-bug-broker"
21
+ Issues = "https://github.com/JovianIce/git-bug-broker/issues"
22
+
23
+ [project.scripts]
24
+ git-bug-broker = "git_bug_broker.server:main"
25
+ git-bug-broker-start = "git_bug_broker.start:main"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ git_bug_broker = ["rules/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """git-bug broker: a serialising MCP front end for git-bug webui."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,263 @@
1
+ """Client over the git-bug web UI's GraphQL endpoint.
2
+
3
+ One `git-bug webui` process owns the store and its BoltDB index lock, and every
4
+ read and write goes to it over HTTP, so nothing here runs the git-bug CLI.
5
+
6
+ Writes also take a file lock (`<repo>/.git/git-bug-broker/write.lock`) for the
7
+ length of one logical write. git-bug v0.11's resolvers apply an operation and
8
+ commit it in two steps without holding the entity in between, so when two writes
9
+ hit the same entry both land but one caller gets "can't commit an entity with no
10
+ pending operation". That false failure invites a retry that duplicates the write.
11
+
12
+ Standard library only; the MCP server is the only part with a dependency.
13
+ """
14
+ import contextlib
15
+ import json
16
+ import os
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ from . import rules as R
22
+
23
+ STATE_DIRNAME = "git-bug-broker"
24
+ LOCK_TIMEOUT_S = 30.0
25
+ HTTP_TIMEOUT_S = 30.0
26
+
27
+
28
+ class BrokerDown(RuntimeError):
29
+ pass
30
+
31
+
32
+ class GraphQLError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def state_dir(repo):
37
+ return os.path.join(os.path.abspath(repo), ".git", STATE_DIRNAME)
38
+
39
+
40
+ def read_endpoint(repo):
41
+ path = os.path.join(state_dir(repo), "endpoint.json")
42
+ try:
43
+ with open(path, encoding="utf-8") as f:
44
+ return json.load(f)
45
+ except FileNotFoundError:
46
+ return None
47
+
48
+
49
+ @contextlib.contextmanager
50
+ def _file_lock(path, timeout=LOCK_TIMEOUT_S):
51
+ os.makedirs(os.path.dirname(path), exist_ok=True)
52
+ f = open(path, "a+b")
53
+ deadline = time.monotonic() + timeout
54
+ try:
55
+ if os.name == "nt":
56
+ import msvcrt
57
+ while True:
58
+ try:
59
+ f.seek(0)
60
+ msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
61
+ break
62
+ except OSError:
63
+ if time.monotonic() > deadline:
64
+ raise TimeoutError(f"write lock {path} held for more than {timeout}s")
65
+ time.sleep(0.005)
66
+ try:
67
+ yield
68
+ finally:
69
+ f.seek(0)
70
+ msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
71
+ else:
72
+ import fcntl
73
+ while True:
74
+ try:
75
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
76
+ break
77
+ except OSError:
78
+ if time.monotonic() > deadline:
79
+ raise TimeoutError(f"write lock {path} held for more than {timeout}s")
80
+ time.sleep(0.005)
81
+ try:
82
+ yield
83
+ finally:
84
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
85
+ finally:
86
+ f.close()
87
+
88
+
89
+ BUG_FIELDS = """id humanId status title createdAt lastEdit
90
+ labels { name }
91
+ author { displayName }
92
+ comments(first: 500) { nodes { id message author { displayName } } }"""
93
+
94
+ LIST_FIELDS = "id humanId status title lastEdit labels { name }"
95
+
96
+
97
+ class Client:
98
+ def __init__(self, repo=None, url=None, rules_path=None):
99
+ self.repo = repo or os.environ.get("GITBUG_BROKER_REPO")
100
+ url = url or os.environ.get("GITBUG_BROKER_URL")
101
+ if not url and self.repo:
102
+ ep = read_endpoint(self.repo)
103
+ url = ep and ep.get("url")
104
+ if not url:
105
+ raise BrokerDown("no broker endpoint: set GITBUG_BROKER_REPO to a repository whose "
106
+ "web UI was started with git-bug-broker-start, or GITBUG_BROKER_URL")
107
+ self.url = url.rstrip("/") + "/graphql" if not url.endswith("/graphql") else url
108
+ self.rules = R.load_rules(rules_path)
109
+ lock_home = state_dir(self.repo) if self.repo else os.path.join(
110
+ os.environ.get("TEMP", "/tmp"), STATE_DIRNAME)
111
+ self.lock_path = os.path.join(lock_home, "write.lock")
112
+
113
+ # transport -----------------------------------------------------------
114
+
115
+ def _gql(self, query, variables=None):
116
+ data = json.dumps({"query": query, "variables": variables or {}}).encode()
117
+ req = urllib.request.Request(self.url, data, {"Content-Type": "application/json"})
118
+ try:
119
+ with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_S) as r:
120
+ out = json.load(r)
121
+ except urllib.error.URLError as e:
122
+ raise BrokerDown(f"git-bug web UI at {self.url} is not answering ({e.reason}); "
123
+ "start it with git-bug-broker-start.") from e
124
+ if out.get("errors"):
125
+ raise GraphQLError("; ".join(e.get("message", str(e)) for e in out["errors"]))
126
+ return out["data"]
127
+
128
+ def _write(self, query, variables, verify=None, bug_id=None):
129
+ """Run one mutation under the write lock.
130
+
131
+ If git-bug still reports "no pending operation" (possible when another
132
+ writer, such as the web UI itself, skips the lock), `verify(bug)` checks
133
+ whether the change landed anyway."""
134
+ with _file_lock(self.lock_path):
135
+ try:
136
+ return self._gql(query, variables)
137
+ except GraphQLError as e:
138
+ if "no pending operation" in str(e) and verify is not None:
139
+ bug = self.get(bug_id or variables["i"]["prefix"], _raw=True)
140
+ if verify(bug):
141
+ return {"verified_after_race": True}
142
+ raise
143
+
144
+ # reads ---------------------------------------------------------------
145
+
146
+ def get(self, id_prefix, _raw=False):
147
+ d = self._gql("query($p:String!){repository{bug(prefix:$p){%s}}}" % BUG_FIELDS,
148
+ {"p": id_prefix})
149
+ bug = d["repository"]["bug"]
150
+ if bug is None:
151
+ raise KeyError(f"no entry with id prefix '{id_prefix}'")
152
+ return bug if _raw else _flatten(bug)
153
+
154
+ def query(self, query="status:open", first=100):
155
+ """List entries matching a git-bug query (status:, label:, title:, author:, sort:).
156
+
157
+ Free-text terms go through bleve, which returns at most 10 hits."""
158
+ d = self._gql("query($q:String,$n:Int){repository{allBugs(query:$q,first:$n)"
159
+ "{totalCount nodes{%s}}}}" % LIST_FIELDS, {"q": query, "n": first})
160
+ conn = d["repository"]["allBugs"]
161
+ return {"total": conn["totalCount"],
162
+ "entries": [{"id": n["humanId"], "full_id": n["id"], "status": n["status"].lower(),
163
+ "title": n["title"], "labels": sorted(l["name"] for l in n["labels"]),
164
+ "last_edit": n["lastEdit"]} for n in conn["nodes"]]}
165
+
166
+ # writes --------------------------------------------------------------
167
+
168
+ def file_entry(self, title, body, labels):
169
+ labels = sorted(set(labels))
170
+ R.check_entry(self.rules, title, body, labels)
171
+ with _file_lock(self.lock_path):
172
+ d = self._gql("mutation($i:BugCreateInput!){bugCreate(input:$i){bug{id humanId}}}",
173
+ {"i": {"title": title.strip(), "message": body}})
174
+ bid = d["bugCreate"]["bug"]["id"]
175
+ try:
176
+ self._gql("mutation($i:BugChangeLabelInput){bugChangeLabels(input:$i){results{status}}}",
177
+ {"i": {"prefix": bid, "added": labels}})
178
+ except Exception as e:
179
+ raise GraphQLError(f"entry {bid[:7]} created but labelling failed: {e}") from e
180
+ return {"id": bid[:7], "full_id": bid}
181
+
182
+ def comment(self, id_prefix, message):
183
+ if not message.strip():
184
+ raise R.RuleViolation(["comment is empty"])
185
+ bug = self.get(id_prefix, _raw=True)
186
+ self._write("mutation($i:BugAddCommentInput!){bugAddComment(input:$i){bug{id}}}",
187
+ {"i": {"prefix": bug["id"], "message": message}},
188
+ verify=lambda b: any(c["message"] == message for c in b["comments"]["nodes"]))
189
+ return {"id": bug["humanId"], "comments": len(bug["comments"]["nodes"]) + 1}
190
+
191
+ def edit_body(self, id_prefix, body):
192
+ bug = self.get(id_prefix, _raw=True)
193
+ kind = R.kind_of([l["name"] for l in bug["labels"]])
194
+ problems = R.check_body(self.rules, body, kind)
195
+ if problems:
196
+ raise R.RuleViolation(problems)
197
+ target = bug["comments"]["nodes"][0]["id"]
198
+ self._write("mutation($i:BugEditCommentInput!){bugEditComment(input:$i){bug{id}}}",
199
+ {"i": {"targetPrefix": target, "message": body}},
200
+ verify=lambda b: b["comments"]["nodes"][0]["message"] == body, bug_id=bug["id"])
201
+ return {"id": bug["humanId"], "edited": "body"}
202
+
203
+ def edit_comment(self, comment_id, message):
204
+ self._write("mutation($i:BugEditCommentInput!){bugEditComment(input:$i){bug{id}}}",
205
+ {"i": {"targetPrefix": comment_id, "message": message}})
206
+ return {"comment": comment_id[:12], "edited": True}
207
+
208
+ def relabel(self, id_prefix, add=(), remove=()):
209
+ add, remove = sorted(set(add)), sorted(set(remove))
210
+ with _file_lock(self.lock_path):
211
+ bug = self.get(id_prefix, _raw=True)
212
+ current = {l["name"] for l in bug["labels"]}
213
+ result = (current | set(add)) - set(remove)
214
+ problems = R.check_labels(self.rules, sorted(result))
215
+ if problems:
216
+ raise R.RuleViolation(problems)
217
+ inp = {"prefix": bug["id"]}
218
+ if add:
219
+ inp["added"] = add
220
+ if remove:
221
+ inp["Removed"] = remove # capital R is the v0.11 schema's spelling
222
+ self._gql("mutation($i:BugChangeLabelInput){bugChangeLabels(input:$i)"
223
+ "{results{label{name} status}}}", {"i": inp})
224
+ return {"id": bug["humanId"], "labels": sorted(result)}
225
+
226
+ def set_status(self, id_prefix, status, comment=None):
227
+ status = status.lower()
228
+ if status not in ("open", "closed"):
229
+ raise R.RuleViolation([f"status must be 'open' or 'closed', not '{status}'"])
230
+ bug = self.get(id_prefix, _raw=True)
231
+ want = status.upper()
232
+ verify = lambda b: b["status"] == want # noqa: E731
233
+ if comment:
234
+ m = "bugAddCommentAndClose" if status == "closed" else "bugAddCommentAndReopen"
235
+ t = "BugAddCommentAndCloseInput" if status == "closed" else "BugAddCommentAndReopenInput"
236
+ self._write("mutation($i:%s!){%s(input:$i){bug{status}}}" % (t, m),
237
+ {"i": {"prefix": bug["id"], "message": comment}}, verify=verify)
238
+ elif bug["status"] != want:
239
+ m = "bugStatusClose" if status == "closed" else "bugStatusOpen"
240
+ t = "BugStatusCloseInput" if status == "closed" else "BugStatusOpenInput"
241
+ self._write("mutation($i:%s!){%s(input:$i){bug{status}}}" % (t, m),
242
+ {"i": {"prefix": bug["id"]}}, verify=verify)
243
+ return {"id": bug["humanId"], "status": status}
244
+
245
+ def set_title(self, id_prefix, title):
246
+ bug = self.get(id_prefix, _raw=True)
247
+ problems = R.check_title(self.rules, title, R.kind_of([l["name"] for l in bug["labels"]]))
248
+ if problems:
249
+ raise R.RuleViolation(problems)
250
+ self._write("mutation($i:BugSetTitleInput!){bugSetTitle(input:$i){bug{title}}}",
251
+ {"i": {"prefix": bug["id"], "title": title.strip()}},
252
+ verify=lambda b: b["title"] == title.strip())
253
+ return {"id": bug["humanId"], "title": title.strip()}
254
+
255
+
256
+ def _flatten(bug):
257
+ cs = bug["comments"]["nodes"]
258
+ return {"id": bug["humanId"], "full_id": bug["id"], "status": bug["status"].lower(),
259
+ "title": bug["title"], "labels": sorted(l["name"] for l in bug["labels"]),
260
+ "author": bug["author"]["displayName"], "created": bug["createdAt"],
261
+ "last_edit": bug["lastEdit"], "body": cs[0]["message"] if cs else "",
262
+ "comments": [{"id": c["id"], "author": c["author"]["displayName"],
263
+ "message": c["message"]} for c in cs[1:]]}
@@ -0,0 +1,23 @@
1
+ {
2
+ "_doc": "Default issue conventions; a file named by GITBUG_BROKER_RULES overrides any of these keys.",
3
+ "areas": [],
4
+ "areas_open": true,
5
+ "kinds": ["defect", "chore", "decision", "investigation", "idea"],
6
+ "flags": ["blocks", "needs-provenance"],
7
+ "free_prefixes": ["source/", "filed-by/"],
8
+ "exactly_one": ["area/", "kind/"],
9
+ "label_pattern": "^[a-z0-9][a-z0-9./-]*$",
10
+ "title": {
11
+ "max_chars": 80,
12
+ "leading_number_pattern": "^\\s*(#?\\d+[\\s.):-]|item\\s+\\d+|\\[\\d+\\])",
13
+ "min_words": 4,
14
+ "claim_markers": ["is", "are", "was", "were", "has", "have", "does", "do", "can", "cannot", "can't", "never", "only", "no", "not", "nothing", "lacks", "misses", "fails", "returns", "skips", "ignores", "leaks", "loses"],
15
+ "fix_prefixes": ["add", "fix", "remove", "make", "implement", "refactor", "use", "return", "change", "update", "rename", "move", "support"],
16
+ "fix_prefix_rejected_for": ["defect", "investigation"]
17
+ },
18
+ "body_sections": {
19
+ "defect": ["What", "Done when"],
20
+ "chore": ["What", "Done when"]
21
+ },
22
+ "section_pattern": "(?im)^[ \\t>*_#-]*{name}\\b"
23
+ }
@@ -0,0 +1,119 @@
1
+ """Issue conventions, checked before any write reaches git-bug.
2
+
3
+ The rules are data (rules/*.json). A project supplies its own file through
4
+ GITBUG_BROKER_RULES; it is layered over rules/default.json key by key, so a
5
+ project file only has to name what differs (usually `areas` and `flags`).
6
+ """
7
+ import json
8
+ import os
9
+ import re
10
+
11
+ HERE = os.path.dirname(os.path.abspath(__file__))
12
+
13
+
14
+ class RuleViolation(ValueError):
15
+ """Raised with every violation found, one per line, so a caller fixes all at once."""
16
+
17
+ def __init__(self, problems):
18
+ self.problems = list(problems)
19
+ super().__init__("write refused by the conventions check:\n- " + "\n- ".join(self.problems))
20
+
21
+
22
+ def load_rules(path=None):
23
+ with open(os.path.join(HERE, "rules", "default.json"), encoding="utf-8") as f:
24
+ rules = json.load(f)
25
+ path = path or os.environ.get("GITBUG_BROKER_RULES")
26
+ if path:
27
+ with open(path, encoding="utf-8") as f:
28
+ override = json.load(f)
29
+ for k, v in override.items():
30
+ if isinstance(v, dict) and isinstance(rules.get(k), dict):
31
+ rules[k] = {**rules[k], **v}
32
+ else:
33
+ rules[k] = v
34
+ return rules
35
+
36
+
37
+ def check_title(rules, title, kind=None):
38
+ t = rules["title"]
39
+ out = []
40
+ s = (title or "").strip()
41
+ if not s:
42
+ return ["title is empty"]
43
+ if "\n" in s:
44
+ out.append("title must be one line")
45
+ if len(s) > t["max_chars"]:
46
+ out.append(f"title is {len(s)} characters, limit {t['max_chars']}")
47
+ if re.match(t["leading_number_pattern"], s, re.I):
48
+ out.append("title starts with a number; refer to entries by their git-bug id")
49
+ words = re.findall(r"[\w'`]+", s.lower())
50
+ stripped = [w.strip("`") for w in words]
51
+ if len(words) < t["min_words"] and not set(stripped) & set(t["claim_markers"]):
52
+ out.append(f"title '{s}' reads as a topic; write a present-tense claim about what the "
53
+ "code does (for example 'retry() returns nil when every attempt fails')")
54
+ if kind in t["fix_prefix_rejected_for"] and stripped and stripped[0] in t["fix_prefixes"]:
55
+ out.append(f"a {kind} title states what is wrong, not the fix; '{words[0]} ...' "
56
+ "names the fix")
57
+ return out
58
+
59
+
60
+ def check_labels(rules, labels):
61
+ """Validate a complete label set (what the entry will carry after the write)."""
62
+ out = []
63
+ pat = re.compile(rules["label_pattern"])
64
+ areas, kinds = rules["areas"], rules["kinds"]
65
+ counts = {p: 0 for p in rules["exactly_one"]}
66
+ for lab in labels:
67
+ if ":" in lab:
68
+ out.append(f"label '{lab}' uses a colon; use a slash ('{lab.replace(':', '/')}'), "
69
+ "because git-bug's query language reads a colon as a second qualifier")
70
+ continue
71
+ if not pat.match(lab):
72
+ out.append(f"label '{lab}' does not match {rules['label_pattern']}")
73
+ continue
74
+ for p in counts:
75
+ if lab.startswith(p):
76
+ counts[p] += 1
77
+ if lab.startswith("area/"):
78
+ name = lab[5:]
79
+ if not rules.get("areas_open") and name not in areas:
80
+ out.append(f"unknown area '{name}'; known: {', '.join(areas)}")
81
+ elif lab.startswith("kind/"):
82
+ if lab[5:] not in kinds:
83
+ out.append(f"unknown kind '{lab[5:]}'; known: {', '.join(kinds)}")
84
+ elif lab in rules["flags"]:
85
+ pass
86
+ elif any(lab.startswith(p) and len(lab) > len(p) for p in rules["free_prefixes"]):
87
+ pass
88
+ else:
89
+ out.append(f"label '{lab}' is not a known flag ({', '.join(rules['flags'])}) or "
90
+ f"namespace ({', '.join(rules['exactly_one'] + rules['free_prefixes'])})")
91
+ for p, n in counts.items():
92
+ if n != 1:
93
+ out.append(f"entry must carry exactly one {p} label, has {n}")
94
+ return out
95
+
96
+
97
+ def kind_of(labels):
98
+ for lab in labels:
99
+ if lab.startswith("kind/"):
100
+ return lab[5:]
101
+ return None
102
+
103
+
104
+ def check_body(rules, body, kind):
105
+ out = []
106
+ if not (body or "").strip():
107
+ return ["body is empty"]
108
+ for name in rules["body_sections"].get(kind or "", []):
109
+ rx = rules["section_pattern"].replace("{name}", re.escape(name))
110
+ if not re.search(rx, body):
111
+ out.append(f"a {kind} body needs a '{name}' section (for example '**{name}.** ...')")
112
+ return out
113
+
114
+
115
+ def check_entry(rules, title, body, labels):
116
+ kind = kind_of(labels)
117
+ problems = check_labels(rules, labels) + check_title(rules, title, kind) + check_body(rules, body, kind)
118
+ if problems:
119
+ raise RuleViolation(problems)
@@ -0,0 +1,115 @@
1
+ """stdio MCP server exposing a git-bug store as tools.
2
+
3
+ Configuration comes from the environment:
4
+ GITBUG_BROKER_REPO repository whose web UI git-bug-broker-start launched (required)
5
+ GITBUG_BROKER_RULES project rules file layered over rules/default.json (optional)
6
+
7
+ Every MCP client runs its own copy of this server. They share one web UI, and
8
+ the write lock in client.py serializes their writes.
9
+ """
10
+ from mcp.server.mcpserver import MCPServer
11
+ from mcp.server.mcpserver.exceptions import ToolError
12
+
13
+ from . import client as C
14
+ from . import rules as R
15
+
16
+ mcp = MCPServer(
17
+ "git-bug",
18
+ instructions=(
19
+ "An issue store kept in git-bug. Use these tools instead of the git-bug "
20
+ "CLI. Titles are present-tense claims about what the code does, with no leading number. "
21
+ "Each entry carries exactly one area/<name> and one kind/<name> label; namespaces use a "
22
+ "slash, never a colon. defect and chore bodies need 'What' and 'Done when' sections. "
23
+ "A refused write returns every violation; fix them and retry. Free-text query terms "
24
+ "return at most 10 hits, so filter with label:, status: or title: when completeness "
25
+ "matters."),
26
+ )
27
+
28
+ _client = None
29
+
30
+
31
+ def cl():
32
+ global _client
33
+ if _client is None:
34
+ _client = C.Client()
35
+ return _client
36
+
37
+
38
+ def _run(name, *a, **kw):
39
+ # Only a ToolError's text reaches the caller; the SDK reports any other
40
+ # exception as a bare "Error executing tool". The client is built inside the
41
+ # try so a missing endpoint or rules file is reported like any other failure.
42
+ try:
43
+ return getattr(cl(), name)(*a, **kw)
44
+ except R.RuleViolation as e:
45
+ raise ToolError(str(e)) from None
46
+ except C.BrokerDown as e:
47
+ raise ToolError(f"git-bug web UI unavailable: {e}") from None
48
+ except (C.GraphQLError, KeyError, TimeoutError, FileNotFoundError) as e:
49
+ raise ToolError(f"{type(e).__name__}: {e}") from None
50
+
51
+
52
+ @mcp.tool()
53
+ def file_entry(title: str, body: str, labels: list[str]) -> dict:
54
+ """File a new entry. labels must include exactly one area/<name> and one kind/<name>
55
+ (defect, chore, decision, investigation, idea); add filed-by/<agent> when an agent files it."""
56
+ return _run("file_entry", title, body, labels)
57
+
58
+
59
+ @mcp.tool()
60
+ def comment(id: str, message: str) -> dict:
61
+ """Add a comment to an entry, by id or unique id prefix."""
62
+ return _run("comment", id, message)
63
+
64
+
65
+ @mcp.tool()
66
+ def edit_body(id: str, body: str) -> dict:
67
+ """Replace an entry's body (its first comment). Checked against the entry's kind."""
68
+ return _run("edit_body", id, body)
69
+
70
+
71
+ @mcp.tool()
72
+ def edit_comment(comment_id: str, message: str) -> dict:
73
+ """Replace the text of one comment, by the comment id that get() returns."""
74
+ return _run("edit_comment", comment_id, message)
75
+
76
+
77
+ @mcp.tool()
78
+ def relabel(id: str, add: list[str] | None = None, remove: list[str] | None = None) -> dict:
79
+ """Add and remove labels in one operation. The resulting set must satisfy the standard,
80
+ so swap an area with add=['area/new'], remove=['area/old'] in one call."""
81
+ return _run("relabel", id, add or [], remove or [])
82
+
83
+
84
+ @mcp.tool()
85
+ def set_status(id: str, status: str, comment: str | None = None) -> dict:
86
+ """Open or close an entry ('open' or 'closed'), optionally with a comment in the same
87
+ operation. A closing comment should name the commit and the test that pins the fix."""
88
+ return _run("set_status", id, status, comment)
89
+
90
+
91
+ @mcp.tool()
92
+ def set_title(id: str, title: str) -> dict:
93
+ """Retitle an entry. The new title is checked against the standard."""
94
+ return _run("set_title", id, title)
95
+
96
+
97
+ @mcp.tool()
98
+ def get(id: str) -> dict:
99
+ """Read one entry: title, status, labels, body and comments with their ids."""
100
+ return _run("get", id)
101
+
102
+
103
+ @mcp.tool()
104
+ def query(query: str = "status:open", first: int = 100) -> dict:
105
+ """List entries matching a git-bug query, for example 'status:open label:area/api'
106
+ or 'label:kind/defect sort:edit'. Returns ids, titles, status and labels, not bodies."""
107
+ return _run("query", query, first)
108
+
109
+
110
+ def main():
111
+ mcp.run("stdio")
112
+
113
+
114
+ if __name__ == "__main__":
115
+ main()
@@ -0,0 +1,131 @@
1
+ """Start, stop or inspect the git-bug web UI that owns one repository's store.
2
+
3
+ git-bug-broker-start <repo> [--port 38420] start (idempotent: reuses a live one)
4
+ git-bug-broker-start <repo> --status print the endpoint, exit 1 if down
5
+ git-bug-broker-start <repo> --stop stop it gracefully (Ctrl+Break to its console)
6
+
7
+ Writes <repo>/.git/git-bug-broker/endpoint.json ({url, port, pid, repo, started})
8
+ and logs to webui.log beside it. Never passes --read-only, and never --open unless
9
+ --open is given. Only stops a web UI it recorded itself.
10
+ """
11
+ import argparse
12
+ import datetime
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import urllib.request
19
+
20
+ from . import client as C
21
+
22
+
23
+ def alive(url):
24
+ try:
25
+ req = urllib.request.Request(url.rstrip("/") + "/graphql",
26
+ json.dumps({"query": "{repository{name}}"}).encode(),
27
+ {"Content-Type": "application/json"})
28
+ with urllib.request.urlopen(req, timeout=3) as r:
29
+ return "data" in json.load(r)
30
+ except Exception:
31
+ return False
32
+
33
+
34
+ def start(repo, port, open_browser):
35
+ sd = C.state_dir(repo)
36
+ os.makedirs(sd, exist_ok=True)
37
+ ep = C.read_endpoint(repo)
38
+ if ep and alive(ep["url"]):
39
+ print(json.dumps({**ep, "reused": True}))
40
+ return 0
41
+ args = ["git-bug", "webui", "--port", str(port), "--open" if open_browser else "--no-open",
42
+ "--log-errors"]
43
+ log = open(os.path.join(sd, "webui.log"), "ab")
44
+ kw = {}
45
+ if os.name == "nt":
46
+ # A hidden console of its own, so --stop can send it Ctrl+Break.
47
+ si = subprocess.STARTUPINFO()
48
+ si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
49
+ si.wShowWindow = 0
50
+ kw = {"creationflags": subprocess.CREATE_NEW_CONSOLE, "startupinfo": si}
51
+ else:
52
+ kw = {"start_new_session": True}
53
+ p = subprocess.Popen(args, cwd=repo, stdout=log, stderr=log, stdin=subprocess.DEVNULL, **kw)
54
+ url = f"http://127.0.0.1:{port}"
55
+ for _ in range(100):
56
+ if alive(url):
57
+ break
58
+ if p.poll() is not None:
59
+ print(f"git-bug webui exited with {p.returncode}; see {sd}/webui.log", file=sys.stderr)
60
+ return 1
61
+ time.sleep(0.1)
62
+ else:
63
+ print("web UI did not answer within 10s", file=sys.stderr)
64
+ return 1
65
+ ep = {"url": url, "port": port, "pid": p.pid, "repo": os.path.abspath(repo),
66
+ "started": datetime.datetime.now().isoformat(timespec="seconds")}
67
+ with open(os.path.join(sd, "endpoint.json"), "w", encoding="utf-8") as f:
68
+ json.dump(ep, f, indent=1)
69
+ print(json.dumps(ep))
70
+ return 0
71
+
72
+
73
+ def _ctrl_c_windows(pid):
74
+ # Go delivers Ctrl+Break as os.Interrupt, so git-bug shuts down and closes
75
+ # its index. Ctrl+C would be ignored by a process started on a new console.
76
+ # The helper attaches to that console and dies of the same event, so its exit
77
+ # code means nothing; the caller checks the endpoint instead.
78
+ code = ("import ctypes;k=ctypes.windll.kernel32;k.FreeConsole();"
79
+ f"k.AttachConsole({pid}) and k.GenerateConsoleCtrlEvent(1,0)")
80
+ subprocess.run([sys.executable, "-c", code], creationflags=subprocess.CREATE_NO_WINDOW)
81
+ return True
82
+
83
+
84
+ def stop(repo):
85
+ ep = C.read_endpoint(repo)
86
+ if not ep:
87
+ print("no recorded web UI for this repository")
88
+ return 0
89
+ pid = ep["pid"]
90
+ if os.name == "nt":
91
+ sent = _ctrl_c_windows(pid)
92
+ else:
93
+ import signal
94
+ try:
95
+ os.kill(pid, signal.SIGINT)
96
+ sent = True
97
+ except ProcessLookupError:
98
+ sent = False
99
+ for _ in range(100):
100
+ if not alive(ep["url"]):
101
+ break
102
+ time.sleep(0.1)
103
+ if alive(ep["url"]):
104
+ print(f"web UI pid {pid} still answering after Ctrl+Break (sent={sent}); not killing it",
105
+ file=sys.stderr)
106
+ return 1
107
+ os.remove(os.path.join(C.state_dir(repo), "endpoint.json"))
108
+ print(json.dumps({"stopped": pid, "graceful": sent}))
109
+ return 0
110
+
111
+
112
+ def main():
113
+ ap = argparse.ArgumentParser()
114
+ ap.add_argument("repo")
115
+ ap.add_argument("--port", type=int, default=38420)
116
+ ap.add_argument("--open", action="store_true")
117
+ ap.add_argument("--stop", action="store_true")
118
+ ap.add_argument("--status", action="store_true")
119
+ a = ap.parse_args()
120
+ if a.stop:
121
+ return stop(a.repo)
122
+ if a.status:
123
+ ep = C.read_endpoint(a.repo)
124
+ up = bool(ep and alive(ep["url"]))
125
+ print(json.dumps({**(ep or {}), "alive": up}))
126
+ return 0 if up else 1
127
+ return start(a.repo, a.port, a.open)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-bug-broker
3
+ Version: 0.1.0
4
+ Summary: MCP server that lets several agents share one git-bug store while the web UI is open
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/JovianIce/git-bug-broker
7
+ Project-URL: Source, https://github.com/JovianIce/git-bug-broker
8
+ Project-URL: Issues, https://github.com/JovianIce/git-bug-broker/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: mcp<3,>=2.2
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # git-bug-broker
18
+
19
+ An MCP server that lets several agents file, label and close issues in one [git-bug](https://github.com/git-bug/git-bug) store (a distributed issue tracker that keeps issues in git refs) while its web UI stays open.
20
+
21
+ ![An issue filed, reproduced, fixed and closed by an agent and its subagents, seen in git-bug's web UI](https://raw.githubusercontent.com/JovianIce/git-bug-broker/main/docs/agent-bug-sample.png)
22
+
23
+ git-bug keeps issues inside the repository as git objects, so agents can track work
24
+ next to the code with no hosted tracker, API token or network. Through this server an
25
+ agent can open an issue for a bug it finds, comment on it as it investigates, label it,
26
+ read what other agents have written, and close it with a note naming the commit that
27
+ fixed it. Issues are versioned like commits and sync through any git remote with
28
+ `git bug push` and `git bug pull`, so the record carries over between sessions and
29
+ between agents.
30
+
31
+ `git-bug webui` holds the search index for as long as it runs, and every CLI command waits on it without saying so. The broker talks to the web UI's GraphQL endpoint instead, takes a file lock for each write, and refuses any write that breaks the project's conventions: claim-style titles, one `area/` and one `kind/` label, required body sections. A rejected write lists every problem at once. Unlike a wrapper around the CLI, it keeps working while `git-bug webui` is open, and concurrent writes don't collide. The client is also a small Python library, so a script can write to the store while the UI is open too.
32
+
33
+ Works with git-bug 0.11. Tested on Windows; the Linux and macOS paths are written but
34
+ untested.
35
+
36
+ ## Install
37
+
38
+ ```sh
39
+ pip install git-bug-broker
40
+ ```
41
+
42
+ or from a checkout of [the repository](https://github.com/JovianIce/git-bug-broker), `pip install -e .`. Needs Python 3.10+ and `git-bug` on `PATH`.
43
+
44
+ ## Run
45
+
46
+ ```sh
47
+ git-bug-broker-start <repo> # start the web UI (reuses one already running)
48
+ git-bug-broker-start <repo> --status
49
+ git-bug-broker-start <repo> --stop
50
+ ```
51
+
52
+ It prints the web UI's URL. An issue is at `<url>/_/issues/<id>`, where `<id>` is the
53
+ short id the tools return. The issue list starts filtered to `status:open`; clear the
54
+ search box to see closed issues too. `--stop` only stops the process it started.
55
+
56
+ ## Use from Python
57
+
58
+ ```python
59
+ from git_bug_broker.client import Client
60
+
61
+ c = Client(repo="/path/to/repo")
62
+ c.file_entry(
63
+ "The retry loop never backs off after a 429",
64
+ "**What** retry() sleeps a fixed 1 s.
65
+ **Done when** the delay doubles per attempt.",
66
+ ["area/api", "kind/defect"],
67
+ )
68
+ ```
69
+
70
+ ## Connect an MCP client
71
+
72
+ `.mcp.json` for Claude Code:
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "git-bug": {
78
+ "command": "git-bug-broker",
79
+ "env": {
80
+ "GITBUG_BROKER_REPO": "/path/to/repo",
81
+ "GITBUG_BROKER_RULES": "/path/to/repo/.git-bug-rules.json"
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ Any stdio MCP client takes the same command and env.
89
+
90
+ `GITBUG_BROKER_RULES` is optional. Without it the defaults in
91
+ `src/git_bug_broker/rules/default.json` apply and any area name is accepted.
92
+ `examples/rules.example.json` shows a project file with a fixed area list.
93
+
94
+ `GITBUG_BROKER_URL` points the client at a web UI the broker did not start, for example one on another port.
95
+
96
+ Tools: `file_entry`, `comment`, `edit_body`, `edit_comment`, `relabel`, `set_status`,
97
+ `set_title`, `get`, `query`. A rejected write lists every problem at once.
98
+
99
+ ## Rules
100
+
101
+ What gets checked is in [CONVENTIONS.md](https://github.com/JovianIce/git-bug-broker/blob/main/CONVENTIONS.md). Briefly:
102
+
103
+ - a title is a claim about the code, at most 80 characters, with no leading number
104
+ - exactly one `area/` and one `kind/` label, with a slash, never a colon
105
+ - defects and chores have `What` and `Done when` sections
106
+
107
+ ## Why the lock
108
+
109
+ git-bug 0.11 applies an operation and commits it in two unguarded steps. When two
110
+ writes hit the same issue at once both land, but one caller is told
111
+ `can't commit an entity with no pending operation`, and a retry duplicates the write.
112
+ The broker holds `<repo>/.git/git-bug-broker/write.lock` for each write. Reads don't
113
+ lock.
114
+
115
+ Measured on git-bug 0.11 on Windows: with the lock, 24 parallel writes from 4 server processes, 12 of them to one shared
116
+ issue, finished in 3.7 s with no errors. Without it, 28 of 30 concurrent writes to one
117
+ issue reported failure although all 30 had landed.
118
+
119
+ ## Limitations
120
+
121
+ - The web UI has to be running. If it isn't, every tool says so and nothing is queued.
122
+ - Filing an issue is two mutations, create then label. A crash in between leaves an
123
+ unlabelled issue; the error gives its id.
124
+ - The web UI binds to 127.0.0.1 with no authentication.
125
+ - Edits made in the web UI aren't checked against the rules.
126
+ - Free-text search returns at most 10 results. `title:`, `label:` and `status:`
127
+ filters return everything.
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ pip install -e .[dev]
133
+ pytest
134
+ ```
135
+
136
+ `tools/view.py` renders the store as a static HTML page and a JSON Lines snapshot. It reads through the CLI, so run it while the web UI is stopped; anything reading its output needs neither.
137
+
138
+ ## License
139
+
140
+ MIT
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/git_bug_broker/__init__.py
5
+ src/git_bug_broker/client.py
6
+ src/git_bug_broker/rules.py
7
+ src/git_bug_broker/server.py
8
+ src/git_bug_broker/start.py
9
+ src/git_bug_broker.egg-info/PKG-INFO
10
+ src/git_bug_broker.egg-info/SOURCES.txt
11
+ src/git_bug_broker.egg-info/dependency_links.txt
12
+ src/git_bug_broker.egg-info/entry_points.txt
13
+ src/git_bug_broker.egg-info/requires.txt
14
+ src/git_bug_broker.egg-info/top_level.txt
15
+ src/git_bug_broker/rules/default.json
16
+ tests/test_rules.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ git-bug-broker = git_bug_broker.server:main
3
+ git-bug-broker-start = git_bug_broker.start:main
@@ -0,0 +1,4 @@
1
+ mcp<3,>=2.2
2
+
3
+ [dev]
4
+ pytest
@@ -0,0 +1 @@
1
+ git_bug_broker
@@ -0,0 +1,82 @@
1
+ from git_bug_broker import rules as R
2
+
3
+ BODY = "**What** parseConfig drops the field.\n**Done when** the timeout is honoured."
4
+
5
+
6
+ def problems(title, labels, body=BODY, rules=None):
7
+ try:
8
+ R.check_entry(rules or R.load_rules(), title, body, labels)
9
+ return ""
10
+ except R.RuleViolation as e:
11
+ return str(e)
12
+
13
+
14
+ def test_claim_title_passes():
15
+ assert problems("The retry loop never backs off after a 429", ["area/api", "kind/defect"]) == ""
16
+
17
+
18
+ def test_topic_title_rejected():
19
+ assert "topic" in problems("Retry handling", ["area/api", "kind/defect"])
20
+
21
+
22
+ def test_fix_title_rejected_for_defect():
23
+ assert "fix" in problems("Add backoff to the retry loop", ["area/api", "kind/defect"])
24
+
25
+
26
+ def test_leading_number_rejected():
27
+ assert problems("12. The retry loop never backs off", ["area/api", "kind/defect"]) != ""
28
+
29
+
30
+ def test_colon_label_rejected():
31
+ assert "colon" in problems("The retry loop never backs off after a 429", ["area:api", "kind/defect"])
32
+
33
+
34
+ def test_exactly_one_area_and_kind():
35
+ assert problems("The retry loop never backs off after a 429", ["area/api", "area/ui", "kind/defect"]) != ""
36
+ assert problems("The retry loop never backs off after a 429", ["area/api"]) != ""
37
+
38
+
39
+ def test_defect_body_needs_sections():
40
+ assert "Done when" in problems("The retry loop never backs off after a 429", ["area/api", "kind/defect"], body="It is broken.")
41
+
42
+
43
+ def test_project_file_closes_areas(tmp_path):
44
+ f = tmp_path / "rules.json"
45
+ f.write_text('{"areas": ["api"], "areas_open": false}', encoding="utf-8")
46
+ rules = R.load_rules(str(f))
47
+ assert problems("The retry loop never backs off after a 429", ["area/api", "kind/defect"], rules=rules) == ""
48
+ assert "unknown area" in problems("The retry loop never backs off after a 429", ["area/ui", "kind/defect"], rules=rules)
49
+
50
+
51
+ CLAIM = "The retry loop never backs off after a 429"
52
+
53
+
54
+ def test_long_title_rejected():
55
+ assert "limit 80" in problems("The retry loop " + "never " * 20 + "backs off", ["area/api", "kind/defect"])
56
+
57
+
58
+ def test_multiline_title_rejected():
59
+ assert "one line" in problems("The retry loop never backs off\nafter a 429", ["area/api", "kind/defect"])
60
+
61
+
62
+ def test_unknown_kind_rejected():
63
+ assert "unknown kind" in problems(CLAIM, ["area/api", "kind/bug"])
64
+
65
+
66
+ def test_unknown_flag_or_namespace_rejected():
67
+ assert "not a known flag" in problems(CLAIM, ["area/api", "kind/defect", "urgent"])
68
+ assert "not a known flag" in problems(CLAIM, ["area/api", "kind/defect", "team/web"])
69
+ assert "not a known flag" in problems(CLAIM, ["area/api", "kind/defect", "source/"])
70
+
71
+
72
+ def test_uppercase_label_rejected():
73
+ assert "does not match" in problems(CLAIM, ["area/API", "kind/defect"])
74
+
75
+
76
+ def test_chore_body_needs_sections():
77
+ out = problems("Rename the config loader to settings", ["area/api", "kind/chore"], body="Tidy up.")
78
+ assert "What" in out and "Done when" in out
79
+
80
+
81
+ def test_fix_verb_accepted_for_chore():
82
+ assert problems("Rename the config loader to settings", ["area/api", "kind/chore"]) == ""