forgeo-cli 0.11.0__tar.gz → 0.12.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.
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/CHANGELOG.md +12 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/PKG-INFO +2 -2
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/README.md +1 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/backlog.md +17 -2
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/cli-reference.md +23 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/configuration.md +37 -2
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/getting-started.md +6 -2
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/install.sh +1 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/pyproject.toml +4 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/__init__.py +1 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_github.py +71 -20
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_gitlab.py +64 -17
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_jira.py +105 -26
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/cli.py +642 -1
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/models.py +133 -10
- forgeo_cli-0.12.0/src/forgeo/oauth_common.py +163 -0
- forgeo_cli-0.12.0/src/forgeo/oauth_github.py +486 -0
- forgeo_cli-0.12.0/src/forgeo/oauth_gitlab.py +409 -0
- forgeo_cli-0.12.0/src/forgeo/oauth_jira.py +388 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/setup.py +268 -16
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_github.py +4 -4
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_install.py +2 -2
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/.github/workflows/ci.yml +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/.gitignore +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/CONTRIBUTING.md +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/LICENSE +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/config/nginx-forgeo.conf +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/agent-contract.md +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/console.png +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/demo.gif +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/logo.png +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/og.png +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/img/title.svg +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/index.md +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/docs/web-console-api.md +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/forgeo.spec +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/mkdocs.yml +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/__init__.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/render_homebrew_formula.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/scripts/test-github-backlog-e2e.sh +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/__main__.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/agent.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_http.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/backlog_issue_base.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/central.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/config.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/daemon.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/daemon_control.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/forgeo.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/git.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/instances.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/io.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/notify.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/oauth.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/paths.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/runs.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/update.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/validate.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/central.css +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/central.js +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/index.html +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/instance.html +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/central/login.html +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web/style.css +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/src/forgeo/web_common.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/conftest.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_agent.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_gitlab.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_http.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_backlog_jira.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_cli.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_daemon.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_factory.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_git.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_instances.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_io.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_models.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_oauth.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_paths.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_remote_backlog_cycle.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_render_homebrew.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_runs.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_setup.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_update.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web_common.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/tests/test_web_lock.py +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/www/404.html +0 -0
- {forgeo_cli-0.11.0 → forgeo_cli-0.12.0}/www/index.html +0 -0
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Browser-based OAuth login for GitHub, GitLab, and Jira, with cached tokens and setup guidance.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Stabilized the CI lint configuration.
|
|
19
|
+
|
|
10
20
|
## [0.11.0] - 2026-08-31
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -429,7 +439,8 @@ Initial release of the scheduled, agent-driven software forgeo.
|
|
|
429
439
|
overlapping-run skipping.
|
|
430
440
|
- Dogfooding docs removed; local configs kept out of the repository.
|
|
431
441
|
|
|
432
|
-
[Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.
|
|
442
|
+
[Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.12.0...HEAD
|
|
443
|
+
[0.12.0]: https://github.com/lucaGazzola/forgeo/compare/v0.11.0...v0.12.0
|
|
433
444
|
[0.11.0]: https://github.com/lucaGazzola/forgeo/compare/v0.10.0...v0.11.0
|
|
434
445
|
[0.10.0]: https://github.com/lucaGazzola/forgeo/compare/v0.9.0...v0.10.0
|
|
435
446
|
[0.9.0]: https://github.com/lucaGazzola/forgeo/compare/v0.8.0...v0.9.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: forgeo-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input.
|
|
5
5
|
Project-URL: Homepage, https://forgeo.org
|
|
6
6
|
Project-URL: Documentation, https://forgeo.org
|
|
@@ -99,7 +99,7 @@ pipx install forgeo-cli # Python 3.11+
|
|
|
99
99
|
forgeo init
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github` it auto-detects `owner/repo` and
|
|
102
|
+
Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github`/`gitlab`/`jira` it auto-detects `owner/repo` and offers `PAT` (`GITHUB_TOKEN` → `~/.config/forgeo/`) or browser OAuth (`forgeo auth login` → `~/.config/forgeo/tokens/`).
|
|
103
103
|
|
|
104
104
|
### 3. Fill the backlog
|
|
105
105
|
|
|
@@ -45,7 +45,7 @@ pipx install forgeo-cli # Python 3.11+
|
|
|
45
45
|
forgeo init
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github` it auto-detects `owner/repo` and
|
|
48
|
+
Wizard in your project root. Creates `forgeo.yaml` and `.forgeo/` (backlog, logs, blockers). Prompts for backlog provider (`file` / `github` / `gitlab` / `jira` / `http`) and agent command. For `github`/`gitlab`/`jira` it auto-detects `owner/repo` and offers `PAT` (`GITHUB_TOKEN` → `~/.config/forgeo/`) or browser OAuth (`forgeo auth login` → `~/.config/forgeo/tokens/`).
|
|
49
49
|
|
|
50
50
|
### 3. Fill the backlog
|
|
51
51
|
|
|
@@ -166,6 +166,7 @@ Runtime files (`backlog.lock`, `runs.jsonl`, etc.) go in `state_dir` (defaults t
|
|
|
166
166
|
## A Jira backlog
|
|
167
167
|
|
|
168
168
|
```yaml
|
|
169
|
+
# PAT:
|
|
169
170
|
backlog_provider: jira
|
|
170
171
|
backlog: https://jira.example.com
|
|
171
172
|
jira:
|
|
@@ -173,6 +174,12 @@ jira:
|
|
|
173
174
|
project_key: APP
|
|
174
175
|
auth: {scheme: basic, username_env: JIRA_USER, token_env: JIRA_TOKEN}
|
|
175
176
|
workflow: {open_statuses: ["10000", "10001"], open_status: "10000", running_status: "3", completed_status: "10002"}
|
|
177
|
+
# OAuth 3LO (Cloud):
|
|
178
|
+
# jira:
|
|
179
|
+
# jql: 'project = APP AND labels = forgeo'
|
|
180
|
+
# auth:
|
|
181
|
+
# oauth: {client_id: xxxx, client_secret_env: JIRA_CLIENT_SECRET, scope: "offline_access read:jira-user read:jira-work"}
|
|
182
|
+
# # then: forgeo auth login --provider jira --client-id xxxx
|
|
176
183
|
```
|
|
177
184
|
|
|
178
185
|
- Jira keys → task IDs; `summary`/`description`/`created`/`updated` → task fields.
|
|
@@ -185,9 +192,13 @@ jira:
|
|
|
185
192
|
## A GitHub backlog
|
|
186
193
|
|
|
187
194
|
```yaml
|
|
195
|
+
# PAT:
|
|
188
196
|
backlog_provider: github
|
|
189
197
|
backlog: https://api.github.com # or https://github.example.com/api/v3
|
|
190
|
-
github: {repo: owner/repo, token_env: GITHUB_TOKEN}
|
|
198
|
+
github: {repo: owner/repo, auth: {token_env: GITHUB_TOKEN}}
|
|
199
|
+
# OAuth (browser/device):
|
|
200
|
+
# github: {repo: owner/repo, auth: {oauth: {client_id: Iv1.xxxx, flow: device, scope: repo}}}
|
|
201
|
+
# # then: forgeo auth login --provider github --client-id Iv1.xxxx
|
|
191
202
|
```
|
|
192
203
|
|
|
193
204
|
- Issue numbers → task IDs; `title`/`body`/`created_at`/`updated_at` → task fields.
|
|
@@ -204,9 +215,13 @@ GITHUB_TOKEN=... ./scripts/test-github-backlog-e2e.sh
|
|
|
204
215
|
## A GitLab backlog
|
|
205
216
|
|
|
206
217
|
```yaml
|
|
218
|
+
# PAT:
|
|
207
219
|
backlog_provider: gitlab
|
|
208
220
|
backlog: https://gitlab.example.com # instance root, /api/v4 appended
|
|
209
|
-
gitlab: {repo: group/project, token_env: GITLAB_TOKEN}
|
|
221
|
+
gitlab: {repo: group/project, auth: {token_env: GITLAB_TOKEN}}
|
|
222
|
+
# OAuth (browser PKCE):
|
|
223
|
+
# gitlab: {repo: group/project, auth: {oauth: {client_id: abc, flow: browser, scope: api}}}
|
|
224
|
+
# # then: forgeo auth login --provider gitlab --client-id abc
|
|
210
225
|
```
|
|
211
226
|
|
|
212
227
|
- `iid` → task ID; `opened`/`closed` → `OPEN`/`COMPLETED`; same hidden-block and label mechanics as GitHub.
|
|
@@ -125,6 +125,29 @@ Unregister (never touches config/repo). Errors if unknown.
|
|
|
125
125
|
|
|
126
126
|
Table of all instances: name, daemon state, last outcome (from `runs.jsonl`). Exits `0` with hint if none.
|
|
127
127
|
|
|
128
|
+
## `forgeo auth`
|
|
129
|
+
|
|
130
|
+
Browser/OAuth login for `github`/`gitlab`/`jira` (alternative to `*_TOKEN` PAT). Tokens stored `0600` in `~/.config/forgeo/tokens/` and read by `forgeo validate`/`start`/`once`.
|
|
131
|
+
|
|
132
|
+
### `forgeo auth login --provider <github|gitlab|jira>`
|
|
133
|
+
|
|
134
|
+
| Flag | Description |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `--provider` | `github` (default) / `gitlab` / `jira`. |
|
|
137
|
+
| `--config <file>` | `forgeo.yaml` to read `auth.oauth.client_id` (defaults to `./forgeo.yaml`). |
|
|
138
|
+
| `--client-id <id>` | OAuth client ID (overrides config; required if not in `forgeo.yaml`). |
|
|
139
|
+
| `--flow <device|browser>` | `device` (GitHub CLI) / `browser` (PKCE loopback `127.0.0.1:0/callback`). Defaults: `device` for GitHub, `browser` for GitLab/Jira. |
|
|
140
|
+
| `--scope <scope>` | Scope (default `repo` / `api` / `offline_access read:jira-user read:jira-work`). |
|
|
141
|
+
| `--token-file <path>` | Where to store token (default per-provider per-host `~/.config/forgeo/tokens/<provider>.json`). |
|
|
142
|
+
| `--api-base <url>` | Provider API base (default from `forgeo.yaml` backlog or `https://api.github.com`/`https://gitlab.com`). |
|
|
143
|
+
| `--no-open-browser` | Print URL instead of `webbrowser.open()`. |
|
|
144
|
+
|
|
145
|
+
Device flow: prints `https://github.com/login/device` + `user_code`, polls `…/login/oauth/access_token`. Browser flow: opens `…/oauth/authorize` + PKCE `code_challenge`, listens on loopback, exchanges `code` for `access_token` (`refresh_token` for Jira). `forgeo init` with `browser` writes `auth.oauth` and offers `Run browser login now?`.
|
|
146
|
+
|
|
147
|
+
### `forgeo auth status --provider <p>` / `forgeo auth logout --provider <p>`
|
|
148
|
+
|
|
149
|
+
`status` masks token (`ghp_…1234`), shows `scope`/`expires_in`/`cloud_id` (Jira). `logout` removes the file. Both honour `--token-file`/`--api-base`/`--config`.
|
|
150
|
+
|
|
128
151
|
## `forgeo web`
|
|
129
152
|
|
|
130
153
|
Central dashboard aggregating every registered instance (reads files directly, works whether daemons are running).
|
|
@@ -70,6 +70,7 @@ Add `backlog_auth` for OAuth2-protected HTTP endpoints (see below). A failed HTT
|
|
|
70
70
|
### Jira
|
|
71
71
|
|
|
72
72
|
```yaml
|
|
73
|
+
# PAT / basic (Server/DC):
|
|
73
74
|
backlog_provider: jira
|
|
74
75
|
backlog: https://jira.example.com
|
|
75
76
|
state_dir: .forgeo
|
|
@@ -86,6 +87,16 @@ jira:
|
|
|
86
87
|
open_status: "10000"
|
|
87
88
|
running_status: "3"
|
|
88
89
|
completed_status: "10002"
|
|
90
|
+
# OAuth 3LO (Cloud, browser):
|
|
91
|
+
# jira:
|
|
92
|
+
# jql: 'project = APP AND labels = forgeo'
|
|
93
|
+
# auth:
|
|
94
|
+
# oauth:
|
|
95
|
+
# client_id: xxxx
|
|
96
|
+
# client_secret_env: JIRA_CLIENT_SECRET
|
|
97
|
+
# scope: offline_access read:jira-user read:jira-work
|
|
98
|
+
# token_file: ~/.config/forgeo/tokens/jira.json # optional
|
|
99
|
+
# # then: forgeo auth login --provider jira --client-id xxxx
|
|
89
100
|
```
|
|
90
101
|
|
|
91
102
|
| Key | Default | Description |
|
|
@@ -109,11 +120,23 @@ Status values can be names or IDs (IDs preferred). Jira keys become task IDs. En
|
|
|
109
120
|
### GitHub
|
|
110
121
|
|
|
111
122
|
```yaml
|
|
123
|
+
# PAT:
|
|
112
124
|
backlog_provider: github
|
|
113
125
|
backlog: https://api.github.com # or https://github.example.com/api/v3
|
|
114
126
|
github:
|
|
115
127
|
repo: owner/repo
|
|
116
|
-
|
|
128
|
+
auth:
|
|
129
|
+
token_env: GITHUB_TOKEN
|
|
130
|
+
# OAuth / browser (device flow for CLI, browser PKCE):
|
|
131
|
+
# github:
|
|
132
|
+
# repo: owner/repo
|
|
133
|
+
# auth:
|
|
134
|
+
# oauth:
|
|
135
|
+
# client_id: Iv1.xxxx
|
|
136
|
+
# flow: device # device | browser
|
|
137
|
+
# scope: repo
|
|
138
|
+
# token_file: ~/.config/forgeo/tokens/github.json
|
|
139
|
+
# # then: forgeo auth login --provider github --client-id Iv1.xxxx
|
|
117
140
|
```
|
|
118
141
|
|
|
119
142
|
| Key | Default | Description |
|
|
@@ -134,11 +157,23 @@ Issue numbers become task IDs; `open`/`closed` maps to `OPEN`/`COMPLETED`; `forg
|
|
|
134
157
|
### GitLab
|
|
135
158
|
|
|
136
159
|
```yaml
|
|
160
|
+
# PAT:
|
|
137
161
|
backlog_provider: gitlab
|
|
138
162
|
backlog: https://gitlab.example.com # instance root, /api/v4 appended
|
|
139
163
|
gitlab:
|
|
140
164
|
repo: group/project
|
|
141
|
-
|
|
165
|
+
auth:
|
|
166
|
+
token_env: GITLAB_TOKEN
|
|
167
|
+
# OAuth (browser PKCE, device if enabled):
|
|
168
|
+
# gitlab:
|
|
169
|
+
# repo: group/project
|
|
170
|
+
# auth:
|
|
171
|
+
# oauth:
|
|
172
|
+
# client_id: abc123
|
|
173
|
+
# flow: browser # browser | device
|
|
174
|
+
# scope: api
|
|
175
|
+
# token_file: ~/.config/forgeo/tokens/gitlab.json
|
|
176
|
+
# # then: forgeo auth login --provider gitlab --client-id abc123
|
|
142
177
|
```
|
|
143
178
|
|
|
144
179
|
Same keys as GitHub (`gitlab.*`), including `workflow` and `fields` for the same 7 mappings. Issue `iid` becomes task ID; `opened`/`closed` maps to `OPEN`/`COMPLETED`; hidden `<!-- forgeo: {...} -->` block for engine state.
|
|
@@ -32,7 +32,7 @@ The wizard asks for:
|
|
|
32
32
|
3. **Agent command** — bare command for your agent (default `opencode run --auto`). Forgeo appends the task prompt (`$FORGEO_TASK`); if your command already references `$FORGEO_TASK` it is kept verbatim.
|
|
33
33
|
4. **Refactor prompt** — used when the backlog is empty.
|
|
34
34
|
|
|
35
|
-
Writes `forgeo.yaml`, creates the folder, and appends it to `.gitignore` (opt-out available). With `github`/`gitlab`/`jira` it also sets `backlog_provider`, `backlog` URL, provider block, and `state_dir`.
|
|
35
|
+
Writes `forgeo.yaml`, creates the folder, and appends it to `.gitignore` (opt-out available). With `github`/`gitlab`/`jira` it also sets `backlog_provider`, `backlog` URL, provider block, and `state_dir`. For `github`/`gitlab`/`jira` you can pick `PAT` or `browser` (OAuth) — browser stores a token in `~/.config/forgeo/tokens/`.
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
forgeo init --force # overwrite existing config
|
|
@@ -58,10 +58,14 @@ forgeo init --force # overwrite existing config
|
|
|
58
58
|
|
|
59
59
|
You can also add tasks from the dashboard once Forgeo is running — no file editing needed.
|
|
60
60
|
|
|
61
|
-
**Remote providers** — `forgeo.yaml` already points at the provider.
|
|
61
|
+
**Remote providers** — `forgeo.yaml` already points at the provider. Authenticate with PAT **or** browser:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
+
# PAT:
|
|
64
65
|
export GITHUB_TOKEN=ghp_... # or GITLAB_TOKEN / JIRA_USER + JIRA_TOKEN
|
|
66
|
+
# Browser OAuth (device flow preferred for CLI):
|
|
67
|
+
forgeo auth login --provider github --client-id Iv1.xxx # or gitlab/jira
|
|
68
|
+
forgeo auth status # forgeo auth logout to clear
|
|
65
69
|
forgeo validate
|
|
66
70
|
```
|
|
67
71
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "forgeo-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.12.0"
|
|
8
8
|
description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -61,6 +61,9 @@ asyncio_default_fixture_loop_scope = "function"
|
|
|
61
61
|
line-length = 100
|
|
62
62
|
target-version = "py311"
|
|
63
63
|
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E4", "E7", "E9", "F"]
|
|
66
|
+
|
|
64
67
|
[tool.mypy]
|
|
65
68
|
python_version = "3.11"
|
|
66
69
|
mypy_path = "src"
|
|
@@ -57,11 +57,43 @@ class GithubClient:
|
|
|
57
57
|
def __init__(self, base_url: str, config: GithubBacklogConfig) -> None:
|
|
58
58
|
self.base_url = base_url.rstrip("/")
|
|
59
59
|
self.config = config
|
|
60
|
+
self._oauth_provider: Any | None = None # lazy GithubOAuthTokenProvider when oauth is used
|
|
61
|
+
|
|
62
|
+
def _oauth_token_provider(self) -> Any | None:
|
|
63
|
+
if self._oauth_provider is not None:
|
|
64
|
+
return self._oauth_provider
|
|
65
|
+
auth = self.config.auth
|
|
66
|
+
if auth.oauth is None:
|
|
67
|
+
return None
|
|
68
|
+
from forgeo.oauth_github import GithubOAuthTokenProvider, GithubTokenStore
|
|
69
|
+
|
|
70
|
+
token_file = auth.oauth.token_file
|
|
71
|
+
store = GithubTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else GithubTokenStore(api_base=self.base_url)
|
|
72
|
+
provider = GithubOAuthTokenProvider(store)
|
|
73
|
+
self._oauth_provider = provider
|
|
74
|
+
return provider
|
|
60
75
|
|
|
61
76
|
def _auth_header(self) -> str:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
auth = self.config.auth
|
|
78
|
+
if auth.token_env is not None:
|
|
79
|
+
token = require_env_token(auth.token_env, "GitHub", GithubRequestError)
|
|
80
|
+
return f"Bearer {token}"
|
|
81
|
+
if auth.oauth is not None:
|
|
82
|
+
provider = self._oauth_token_provider()
|
|
83
|
+
assert provider is not None
|
|
84
|
+
try:
|
|
85
|
+
token = provider.token()
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
# Wrap file errors as GithubRequestError so callers surface a clear message
|
|
88
|
+
if isinstance(exc, GithubRequestError):
|
|
89
|
+
raise
|
|
90
|
+
from forgeo.oauth_github import GithubOAuthError
|
|
91
|
+
|
|
92
|
+
if isinstance(exc, GithubOAuthError):
|
|
93
|
+
raise GithubRequestError(str(exc)) from exc
|
|
94
|
+
raise GithubRequestError(str(exc)) from exc
|
|
95
|
+
return f"Bearer {token}"
|
|
96
|
+
raise GithubRequestError("GitHub auth is not configured (token_env or oauth required)")
|
|
65
97
|
|
|
66
98
|
def _api_url(self, path: str, query: dict[str, Any] | None = None) -> str:
|
|
67
99
|
url = f"{self.base_url}{path}"
|
|
@@ -77,23 +109,42 @@ class GithubClient:
|
|
|
77
109
|
query: dict[str, Any] | None = None,
|
|
78
110
|
payload: dict[str, Any] | None = None,
|
|
79
111
|
) -> Any:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
body =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
112
|
+
# Retry once when GitHub rejects an OAuth token (revoked/expired)
|
|
113
|
+
for attempt in (0, 1):
|
|
114
|
+
body = None
|
|
115
|
+
if payload is not None:
|
|
116
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
117
|
+
auth_header = self._auth_header()
|
|
118
|
+
request = urllib.request.Request(
|
|
119
|
+
self._api_url(path, query),
|
|
120
|
+
data=body,
|
|
121
|
+
method=method,
|
|
122
|
+
headers={
|
|
123
|
+
"Accept": "application/vnd.github+json",
|
|
124
|
+
"Authorization": auth_header,
|
|
125
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
126
|
+
**({"Content-Type": "application/json"} if body is not None else {}),
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
try:
|
|
130
|
+
return execute_json_request(
|
|
131
|
+
request, self.config.timeout_seconds, GithubRequestError, method
|
|
132
|
+
)
|
|
133
|
+
except GithubRequestError as exc:
|
|
134
|
+
# On 401/403 with OAuth, invalidate cache and retry once so a freshly
|
|
135
|
+
# written token (e.g. after `forgeo auth login`) is picked up.
|
|
136
|
+
if (
|
|
137
|
+
attempt == 0
|
|
138
|
+
and exc.status in (401, 403)
|
|
139
|
+
and self.config.auth.oauth is not None
|
|
140
|
+
and self._oauth_provider is not None
|
|
141
|
+
):
|
|
142
|
+
try:
|
|
143
|
+
self._oauth_provider.invalidate() # noqa: BLE001
|
|
144
|
+
except Exception: # noqa: BLE001
|
|
145
|
+
pass
|
|
146
|
+
continue
|
|
147
|
+
raise
|
|
97
148
|
|
|
98
149
|
def _repo_path(self) -> str:
|
|
99
150
|
# GitHub API expects owner/repo as two separate path segments;
|
|
@@ -57,11 +57,43 @@ class GitlabClient:
|
|
|
57
57
|
def __init__(self, base_url: str, config: GitlabBacklogConfig) -> None:
|
|
58
58
|
self.base_url = base_url.rstrip("/")
|
|
59
59
|
self.config = config
|
|
60
|
+
self._oauth_provider: Any | None = None
|
|
61
|
+
|
|
62
|
+
def _oauth_token_provider(self) -> Any | None:
|
|
63
|
+
if self._oauth_provider is not None:
|
|
64
|
+
return self._oauth_provider
|
|
65
|
+
auth = self.config.auth
|
|
66
|
+
if auth.oauth is None:
|
|
67
|
+
return None
|
|
68
|
+
from forgeo.oauth_gitlab import GitlabOAuthTokenProvider, GitlabTokenStore
|
|
69
|
+
|
|
70
|
+
token_file = auth.oauth.token_file
|
|
71
|
+
store = GitlabTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else GitlabTokenStore(api_base=self.base_url)
|
|
72
|
+
provider = GitlabOAuthTokenProvider(store)
|
|
73
|
+
self._oauth_provider = provider
|
|
74
|
+
return provider
|
|
60
75
|
|
|
61
76
|
def _auth_headers(self) -> dict[str, str]:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
auth = self.config.auth
|
|
78
|
+
if auth.token_env is not None:
|
|
79
|
+
token = require_env_token(auth.token_env, "GitLab", GitlabRequestError)
|
|
80
|
+
# GitLab prefers PRIVATE-TOKEN, but also accepts Bearer
|
|
81
|
+
return {"PRIVATE-TOKEN": token, "Authorization": f"Bearer {token}"}
|
|
82
|
+
if auth.oauth is not None:
|
|
83
|
+
provider = self._oauth_token_provider()
|
|
84
|
+
assert provider is not None
|
|
85
|
+
try:
|
|
86
|
+
token = provider.token()
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
if isinstance(exc, GitlabRequestError):
|
|
89
|
+
raise
|
|
90
|
+
from forgeo.oauth_gitlab import GitlabOAuthError
|
|
91
|
+
|
|
92
|
+
if isinstance(exc, GitlabOAuthError):
|
|
93
|
+
raise GitlabRequestError(str(exc)) from exc
|
|
94
|
+
raise GitlabRequestError(str(exc)) from exc
|
|
95
|
+
return {"Authorization": f"Bearer {token}"}
|
|
96
|
+
raise GitlabRequestError("GitLab auth is not configured (token_env or oauth required)")
|
|
65
97
|
|
|
66
98
|
def _project_path(self) -> str:
|
|
67
99
|
# GitLab API expects URL-encoded project path or numeric id
|
|
@@ -81,20 +113,35 @@ class GitlabClient:
|
|
|
81
113
|
query: dict[str, Any] | None = None,
|
|
82
114
|
payload: dict[str, Any] | None = None,
|
|
83
115
|
) -> Any:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
116
|
+
for attempt in (0, 1):
|
|
117
|
+
body = None
|
|
118
|
+
if payload is not None:
|
|
119
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
120
|
+
headers = {
|
|
121
|
+
"Accept": "application/json",
|
|
122
|
+
**self._auth_headers(),
|
|
123
|
+
**({"Content-Type": "application/json"} if body is not None else {}),
|
|
124
|
+
}
|
|
125
|
+
request = urllib.request.Request(
|
|
126
|
+
self._api_url(path, query), data=body, method=method, headers=headers
|
|
127
|
+
)
|
|
128
|
+
try:
|
|
129
|
+
return execute_json_request(
|
|
130
|
+
request, self.config.timeout_seconds, GitlabRequestError, method
|
|
131
|
+
)
|
|
132
|
+
except GitlabRequestError as exc:
|
|
133
|
+
if (
|
|
134
|
+
attempt == 0
|
|
135
|
+
and exc.status in (401, 403)
|
|
136
|
+
and self.config.auth.oauth is not None
|
|
137
|
+
and self._oauth_provider is not None
|
|
138
|
+
):
|
|
139
|
+
try:
|
|
140
|
+
self._oauth_provider.invalidate() # noqa: BLE001
|
|
141
|
+
except Exception: # noqa: BLE001
|
|
142
|
+
pass
|
|
143
|
+
continue
|
|
144
|
+
raise
|
|
98
145
|
|
|
99
146
|
def search_issues(self, *, page: int = 1, per_page: int = 20) -> list[dict[str, Any]]:
|
|
100
147
|
path = f"/projects/{self._project_path()}/issues"
|
|
@@ -83,22 +83,85 @@ class JiraClient:
|
|
|
83
83
|
self.base_url = base_url.rstrip("/")
|
|
84
84
|
self.config = config
|
|
85
85
|
self.api_version = config.api_version or DEFAULT_API_VERSION
|
|
86
|
+
self._oauth_provider: Any | None = None
|
|
87
|
+
|
|
88
|
+
def _oauth_token_provider(self) -> Any | None:
|
|
89
|
+
if self._oauth_provider is not None:
|
|
90
|
+
return self._oauth_provider
|
|
91
|
+
auth = self.config.auth
|
|
92
|
+
if auth.oauth is None:
|
|
93
|
+
return None
|
|
94
|
+
from forgeo.oauth_jira import JiraOAuthTokenProvider, JiraTokenStore
|
|
95
|
+
|
|
96
|
+
token_file = auth.oauth.token_file
|
|
97
|
+
store = JiraTokenStore(path=token_file, api_base=self.base_url) if token_file is not None else JiraTokenStore(api_base=self.base_url)
|
|
98
|
+
provider = JiraOAuthTokenProvider(
|
|
99
|
+
store, client_id=auth.oauth.client_id, client_secret_env=auth.oauth.client_secret_env
|
|
100
|
+
)
|
|
101
|
+
self._oauth_provider = provider
|
|
102
|
+
return provider
|
|
86
103
|
|
|
87
104
|
def _api_url(self, path: str, query: dict[str, Any] | None = None) -> str:
|
|
88
|
-
|
|
105
|
+
# When OAuth is used with cloud_id, Jira Cloud API lives at api.atlassian.com/ex/jira/{cloudId}
|
|
106
|
+
base = self.base_url
|
|
107
|
+
auth = self.config.auth
|
|
108
|
+
if auth.oauth is not None:
|
|
109
|
+
# Prefer cloud_id from config oauth, else from token file
|
|
110
|
+
cloud_id: str | None = auth.oauth.cloud_id
|
|
111
|
+
if cloud_id is None and self._oauth_provider is not None:
|
|
112
|
+
try:
|
|
113
|
+
data = self._oauth_provider.store.load()
|
|
114
|
+
if isinstance(data, dict) and isinstance(data.get("cloud_id"), str):
|
|
115
|
+
cloud_id = data["cloud_id"]
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
elif cloud_id is None:
|
|
119
|
+
# Try to load directly via store without provider cache
|
|
120
|
+
try:
|
|
121
|
+
from forgeo.oauth_jira import JiraTokenStore
|
|
122
|
+
|
|
123
|
+
tmp_store = JiraTokenStore(
|
|
124
|
+
path=auth.oauth.token_file, api_base=self.base_url
|
|
125
|
+
) if auth.oauth.token_file is not None else JiraTokenStore(api_base=self.base_url)
|
|
126
|
+
data = tmp_store.load()
|
|
127
|
+
if isinstance(data, dict) and isinstance(data.get("cloud_id"), str):
|
|
128
|
+
cloud_id = data["cloud_id"]
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
if cloud_id:
|
|
132
|
+
base = f"https://api.atlassian.com/ex/jira/{cloud_id}"
|
|
133
|
+
url = f"{base}/rest/api/{self.api_version}{path}"
|
|
89
134
|
if query:
|
|
90
135
|
url += "?" + urlencode(query, doseq=True)
|
|
91
136
|
return url
|
|
92
137
|
|
|
93
138
|
def _auth_header(self) -> str:
|
|
94
|
-
|
|
95
|
-
if
|
|
139
|
+
auth = self.config.auth
|
|
140
|
+
if auth.oauth is not None:
|
|
141
|
+
provider = self._oauth_token_provider()
|
|
142
|
+
assert provider is not None
|
|
143
|
+
try:
|
|
144
|
+
token = provider.token()
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
if isinstance(exc, JiraRequestError):
|
|
147
|
+
raise
|
|
148
|
+
from forgeo.oauth_jira import JiraOAuthError
|
|
149
|
+
|
|
150
|
+
if isinstance(exc, JiraOAuthError):
|
|
151
|
+
raise JiraRequestError(str(exc)) from exc
|
|
152
|
+
raise JiraRequestError(str(exc)) from exc
|
|
153
|
+
return f"Bearer {token}"
|
|
154
|
+
# PAT branch
|
|
155
|
+
if auth.token_env is None or not auth.token_env.strip():
|
|
156
|
+
raise JiraRequestError("Jira token environment variable is not set")
|
|
157
|
+
token = require_env_token(auth.token_env, "Jira", JiraRequestError)
|
|
158
|
+
if auth.scheme == "bearer":
|
|
96
159
|
return f"Bearer {token}"
|
|
97
|
-
username =
|
|
98
|
-
if
|
|
99
|
-
username = os.environ.get(
|
|
160
|
+
username = auth.username
|
|
161
|
+
if auth.username_env is not None:
|
|
162
|
+
username = os.environ.get(auth.username_env)
|
|
100
163
|
if not username:
|
|
101
|
-
source =
|
|
164
|
+
source = auth.username_env or "username"
|
|
102
165
|
raise JiraRequestError(f"Jira username {source!r} is not set")
|
|
103
166
|
encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii")
|
|
104
167
|
return f"Basic {encoded}"
|
|
@@ -112,25 +175,41 @@ class JiraClient:
|
|
|
112
175
|
payload: dict[str, Any] | None = None,
|
|
113
176
|
) -> dict[str, Any]:
|
|
114
177
|
"""Perform one authenticated request and decode its JSON response."""
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
for attempt in (0, 1):
|
|
179
|
+
body = None
|
|
180
|
+
if payload is not None:
|
|
181
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
182
|
+
request = urllib.request.Request(
|
|
183
|
+
self._api_url(path, query),
|
|
184
|
+
data=body,
|
|
185
|
+
method=method,
|
|
186
|
+
headers={
|
|
187
|
+
"Accept": "application/json",
|
|
188
|
+
"Authorization": self._auth_header(),
|
|
189
|
+
**({"Content-Type": "application/json"} if body is not None else {}),
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
data = execute_json_request(
|
|
194
|
+
request, self.config.timeout_seconds, JiraRequestError, method
|
|
195
|
+
)
|
|
196
|
+
except JiraRequestError as exc:
|
|
197
|
+
if (
|
|
198
|
+
attempt == 0
|
|
199
|
+
and exc.status in (401, 403)
|
|
200
|
+
and self.config.auth.oauth is not None
|
|
201
|
+
and self._oauth_provider is not None
|
|
202
|
+
):
|
|
203
|
+
try:
|
|
204
|
+
self._oauth_provider.invalidate() # noqa: BLE001
|
|
205
|
+
except Exception: # noqa: BLE001
|
|
206
|
+
pass
|
|
207
|
+
continue
|
|
208
|
+
raise
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
raise JiraRequestError(f"{method} {request.full_url} returned a non-object JSON body")
|
|
211
|
+
return data
|
|
212
|
+
raise JiraRequestError(f"{method} {self._api_url(path, query)} failed after retry")
|
|
134
213
|
|
|
135
214
|
def search_issues(
|
|
136
215
|
self,
|