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.
- git_bug_broker-0.1.0/LICENSE +21 -0
- git_bug_broker-0.1.0/PKG-INFO +140 -0
- git_bug_broker-0.1.0/README.md +124 -0
- git_bug_broker-0.1.0/pyproject.toml +31 -0
- git_bug_broker-0.1.0/setup.cfg +4 -0
- git_bug_broker-0.1.0/src/git_bug_broker/__init__.py +2 -0
- git_bug_broker-0.1.0/src/git_bug_broker/client.py +263 -0
- git_bug_broker-0.1.0/src/git_bug_broker/rules/default.json +23 -0
- git_bug_broker-0.1.0/src/git_bug_broker/rules.py +119 -0
- git_bug_broker-0.1.0/src/git_bug_broker/server.py +115 -0
- git_bug_broker-0.1.0/src/git_bug_broker/start.py +131 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/PKG-INFO +140 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/SOURCES.txt +16 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/dependency_links.txt +1 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/entry_points.txt +3 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/requires.txt +4 -0
- git_bug_broker-0.1.0/src/git_bug_broker.egg-info/top_level.txt +1 -0
- git_bug_broker-0.1.0/tests/test_rules.py +82 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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"]) == ""
|