alissa-tools-github-devloop 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.
- alissa_tools_github_devloop-0.1.0/MANIFEST.in +2 -0
- alissa_tools_github_devloop-0.1.0/PKG-INFO +61 -0
- alissa_tools_github_devloop-0.1.0/README.md +44 -0
- alissa_tools_github_devloop-0.1.0/requirements.txt +2 -0
- alissa_tools_github_devloop-0.1.0/setup.cfg +4 -0
- alissa_tools_github_devloop-0.1.0/setup.py +57 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/__init__.py +11 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/__main__.py +217 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/alissa.py +75 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/config.py +359 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/ghclient.py +406 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/loop.py +620 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/proc.py +56 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/state.py +113 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/version +1 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa/tools/github/devloop/version.py +46 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info/PKG-INFO +61 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +19 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +1 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +2 -0
- alissa_tools_github_devloop-0.1.0/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alissa-tools-github-devloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
|
|
5
|
+
Home-page: https://alissa.app
|
|
6
|
+
Author: Fahera
|
|
7
|
+
Author-email: support@alissa.app
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Dynamic: author
|
|
11
|
+
Dynamic: author-email
|
|
12
|
+
Dynamic: description
|
|
13
|
+
Dynamic: description-content-type
|
|
14
|
+
Dynamic: home-page
|
|
15
|
+
Dynamic: requires-python
|
|
16
|
+
Dynamic: summary
|
|
17
|
+
|
|
18
|
+
# alissa-tools-github-devloop
|
|
19
|
+
|
|
20
|
+
The `alissa.tools.github.devloop` module: a GitHub watcher that turns
|
|
21
|
+
`alissa:develop`-labeled issues into fresh developer sessions — the
|
|
22
|
+
developer-side counterpart of
|
|
23
|
+
[`alissa-tools-github-reviewloop`](https://github.com/fahera-mx/alissa-github-review-daemon).
|
|
24
|
+
|
|
25
|
+
This distribution ships **only** that module. Everything above it —
|
|
26
|
+
`alissa`, `alissa.tools`, `alissa.tools.github` — is a
|
|
27
|
+
[PEP 420](https://peps.python.org/pep-0420/) namespace package with no
|
|
28
|
+
`__init__.py`, so other distributions in other repositories can contribute
|
|
29
|
+
their own packages under the same namespace:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
alissa/ ← namespace (no __init__.py)
|
|
33
|
+
└── tools/ ← namespace
|
|
34
|
+
└── github/ ← namespace
|
|
35
|
+
├── devloop/ __init__.py ← THIS distribution
|
|
36
|
+
└── reviewloop/ __init__.py ← ships from alissa-github-review-daemon
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The rule: a distribution owns a leaf package and declares only that subtree
|
|
40
|
+
(`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
|
|
41
|
+
`__init__.py` at any namespace level would claim it for one distribution and
|
|
42
|
+
shadow the others.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
pip install -e ./alissa-tools-github-devloop
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Console scripts
|
|
51
|
+
|
|
52
|
+
| Command | Entry point |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `alissa-devloop` | `alissa.tools.github.devloop.__main__:main` |
|
|
55
|
+
|
|
56
|
+
## Layout
|
|
57
|
+
|
|
58
|
+
`src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
|
|
59
|
+
distribution version lives in the plain-text `version` file next to the module
|
|
60
|
+
it versions (`src/main/alissa/tools/github/devloop/version`), read by both
|
|
61
|
+
`setup.py` and `version.py`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# alissa-tools-github-devloop
|
|
2
|
+
|
|
3
|
+
The `alissa.tools.github.devloop` module: a GitHub watcher that turns
|
|
4
|
+
`alissa:develop`-labeled issues into fresh developer sessions — the
|
|
5
|
+
developer-side counterpart of
|
|
6
|
+
[`alissa-tools-github-reviewloop`](https://github.com/fahera-mx/alissa-github-review-daemon).
|
|
7
|
+
|
|
8
|
+
This distribution ships **only** that module. Everything above it —
|
|
9
|
+
`alissa`, `alissa.tools`, `alissa.tools.github` — is a
|
|
10
|
+
[PEP 420](https://peps.python.org/pep-0420/) namespace package with no
|
|
11
|
+
`__init__.py`, so other distributions in other repositories can contribute
|
|
12
|
+
their own packages under the same namespace:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
alissa/ ← namespace (no __init__.py)
|
|
16
|
+
└── tools/ ← namespace
|
|
17
|
+
└── github/ ← namespace
|
|
18
|
+
├── devloop/ __init__.py ← THIS distribution
|
|
19
|
+
└── reviewloop/ __init__.py ← ships from alissa-github-review-daemon
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The rule: a distribution owns a leaf package and declares only that subtree
|
|
23
|
+
(`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
|
|
24
|
+
`__init__.py` at any namespace level would claim it for one distribution and
|
|
25
|
+
shadow the others.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install -e ./alissa-tools-github-devloop
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Console scripts
|
|
34
|
+
|
|
35
|
+
| Command | Entry point |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `alissa-devloop` | `alissa.tools.github.devloop.__main__:main` |
|
|
38
|
+
|
|
39
|
+
## Layout
|
|
40
|
+
|
|
41
|
+
`src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
|
|
42
|
+
distribution version lives in the plain-text `version` file next to the module
|
|
43
|
+
it versions (`src/main/alissa/tools/github/devloop/version`), read by both
|
|
44
|
+
`setup.py` and `version.py`.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from setuptools import setup, find_namespace_packages
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
CODEBASE_PATH = os.environ.get(
|
|
6
|
+
"CODEBASE_PATH",
|
|
7
|
+
default=os.path.join("src", "main"),
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
# Everything above `devloop` is a PEP 420 namespace package (no __init__.py),
|
|
11
|
+
# so other distributions can ship their own alissa.tools.* / alissa.tools.github.*
|
|
12
|
+
# concrete packages. This distribution declares only its own subtree.
|
|
13
|
+
NAMESPACE = "alissa.tools.github"
|
|
14
|
+
PACKAGE = f"{NAMESPACE}.devloop"
|
|
15
|
+
|
|
16
|
+
with open("requirements.txt", "r") as file:
|
|
17
|
+
requirements = [line for line in file.read().splitlines() if line and not line.startswith("#")]
|
|
18
|
+
|
|
19
|
+
version_filepath = os.path.join(CODEBASE_PATH, *PACKAGE.split("."), "version")
|
|
20
|
+
with open(version_filepath, "r") as file:
|
|
21
|
+
version = file.read().strip()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
with open("README.md") as file:
|
|
25
|
+
readme = file.read()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
setup(
|
|
29
|
+
name="alissa-tools-github-devloop",
|
|
30
|
+
version=version,
|
|
31
|
+
description="ALISSA-TOOLS-GITHUB-DEVLOOP",
|
|
32
|
+
long_description=readme,
|
|
33
|
+
long_description_content_type='text/markdown',
|
|
34
|
+
url="https://alissa.app",
|
|
35
|
+
author="Fahera",
|
|
36
|
+
author_email="support@alissa.app",
|
|
37
|
+
packages=find_namespace_packages(
|
|
38
|
+
where=CODEBASE_PATH,
|
|
39
|
+
include=[PACKAGE, f"{PACKAGE}.*"],
|
|
40
|
+
),
|
|
41
|
+
package_dir={
|
|
42
|
+
"": CODEBASE_PATH
|
|
43
|
+
},
|
|
44
|
+
package_data={
|
|
45
|
+
"": [
|
|
46
|
+
version_filepath,
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
entry_points={
|
|
50
|
+
"console_scripts": [
|
|
51
|
+
"alissa-devloop=alissa.tools.github.devloop.__main__:main",
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
install_requires=requirements,
|
|
55
|
+
include_package_data=True,
|
|
56
|
+
python_requires=">=3.11",
|
|
57
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""devloop — a GitHub watcher that turns `alissa:develop`-labeled issues into
|
|
2
|
+
fresh developer sessions: the developer-side counterpart of reviewloop.
|
|
3
|
+
|
|
4
|
+
The moving parts: `config` (three-layer settings + reviewer rails),
|
|
5
|
+
`ghclient` (gh api access, self-assignment in-flight marker), `state` (the
|
|
6
|
+
attempt ledger), `loop` (the DevWatcher decision state machine and the
|
|
7
|
+
DEV_DIRECTIVE contract), `alissa` (session queue + worker plumbing), and
|
|
8
|
+
`__main__` (the alissa-devloop CLI).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""CLI entry point: alissa-devloop (or python -m alissa.tools.github.devloop)"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import (
|
|
12
|
+
HUB_ADD,
|
|
13
|
+
HUB_SKIP,
|
|
14
|
+
ON_MISSING_SKIP,
|
|
15
|
+
ON_MISSING_SPAWN,
|
|
16
|
+
ON_MISSING_WARN,
|
|
17
|
+
Config,
|
|
18
|
+
load_config_file,
|
|
19
|
+
resolve_config_path,
|
|
20
|
+
resolve_reviewers,
|
|
21
|
+
)
|
|
22
|
+
from .ghclient import IdentityMismatch, RateLimited
|
|
23
|
+
from .loop import DevWatcher
|
|
24
|
+
from .proc import CommandError
|
|
25
|
+
from .version import version
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
p = argparse.ArgumentParser(
|
|
32
|
+
prog="alissa-devloop",
|
|
33
|
+
description="Watch GitHub for alissa:develop-labeled issues and spawn "
|
|
34
|
+
"fresh developer sessions — the developer-side counterpart of "
|
|
35
|
+
"alissa-reviewloop.",
|
|
36
|
+
epilog="Every setting below can also live in the config file; CLI "
|
|
37
|
+
"arguments win, and ALISSA_DEV_REVIEWERS wins over --reviewer. "
|
|
38
|
+
"workspace_root is CLI-only, so one config can drive several daemons "
|
|
39
|
+
"over different workspaces.",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
p.add_argument(
|
|
43
|
+
"--version", action="version", version=f"%(prog)s {version.value}"
|
|
44
|
+
)
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"--workspace-root",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
metavar="PATH",
|
|
50
|
+
help="the Alissa Code Workspace to watch (default: current directory)",
|
|
51
|
+
)
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"-c",
|
|
54
|
+
"--config-path",
|
|
55
|
+
"--config",
|
|
56
|
+
dest="config_path",
|
|
57
|
+
type=Path,
|
|
58
|
+
default=None,
|
|
59
|
+
metavar="PATH",
|
|
60
|
+
help="config file; without it, ./devloop.config.json then "
|
|
61
|
+
"<workspace-root>/devloop.config.json, else defaults only",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
mode = p.add_argument_group("mode")
|
|
65
|
+
mode.add_argument("--once", action="store_true", help="run a single poll pass and exit")
|
|
66
|
+
mode.add_argument("-v", "--verbose", action="store_true")
|
|
67
|
+
|
|
68
|
+
over = p.add_argument_group("config overrides (win over the config file)")
|
|
69
|
+
over.add_argument(
|
|
70
|
+
"--repo",
|
|
71
|
+
dest="repos",
|
|
72
|
+
action="append",
|
|
73
|
+
metavar="OWNER/REPO",
|
|
74
|
+
help="watch this repo for labeled issues; repeatable. When given, "
|
|
75
|
+
"REPLACES the config `repos` list; when absent, the config list "
|
|
76
|
+
"applies. Emptying the allowlist (which turns the daemon off — an "
|
|
77
|
+
"empty allowlist watches NOTHING) is done in the config file.",
|
|
78
|
+
)
|
|
79
|
+
over.add_argument("--poll-interval", type=int, metavar="SECONDS")
|
|
80
|
+
over.add_argument("--label", metavar="LABEL", help="issue label that marks dev-ready work")
|
|
81
|
+
over.add_argument("--hub-template", metavar="TEMPLATE")
|
|
82
|
+
over.add_argument("--agent-profile", metavar="NAME")
|
|
83
|
+
over.add_argument("--developer-login", metavar="LOGIN")
|
|
84
|
+
over.add_argument(
|
|
85
|
+
"--reviewer",
|
|
86
|
+
dest="reviewers",
|
|
87
|
+
action="append",
|
|
88
|
+
metavar="LOGIN",
|
|
89
|
+
help="request this reviewer at the ready-for-review flip; repeatable. "
|
|
90
|
+
"Replaces the config list. ALISSA_DEV_REVIEWERS overrides even this.",
|
|
91
|
+
)
|
|
92
|
+
over.add_argument("--state-path", type=Path, metavar="PATH")
|
|
93
|
+
over.add_argument("--attempt-cap", type=int, metavar="N", help="dev attempts per issue")
|
|
94
|
+
over.add_argument("--stale-minutes", type=int, metavar="MINUTES")
|
|
95
|
+
over.add_argument(
|
|
96
|
+
"--on-missing-origin-task",
|
|
97
|
+
choices=[ON_MISSING_WARN, ON_MISSING_SPAWN, ON_MISSING_SKIP],
|
|
98
|
+
)
|
|
99
|
+
over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
|
|
100
|
+
|
|
101
|
+
dry = over.add_mutually_exclusive_group()
|
|
102
|
+
dry.add_argument(
|
|
103
|
+
"--dry-run",
|
|
104
|
+
dest="dry_run",
|
|
105
|
+
action="store_true",
|
|
106
|
+
default=None,
|
|
107
|
+
help="decide and log, but never spawn a session or touch GitHub state",
|
|
108
|
+
)
|
|
109
|
+
dry.add_argument(
|
|
110
|
+
"--no-dry-run",
|
|
111
|
+
dest="dry_run",
|
|
112
|
+
action="store_false",
|
|
113
|
+
help="act for real even if the config sets dry_run",
|
|
114
|
+
)
|
|
115
|
+
return p
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def overrides_from(args: argparse.Namespace) -> dict:
|
|
119
|
+
"""CLI values, with None meaning 'not specified' so the config file shows
|
|
120
|
+
through. `repos`/`reviewers` become tuples so they match the file form."""
|
|
121
|
+
return {
|
|
122
|
+
"repos": tuple(args.repos) if args.repos else None,
|
|
123
|
+
"poll_interval": args.poll_interval,
|
|
124
|
+
"label": args.label,
|
|
125
|
+
"hub_template": args.hub_template,
|
|
126
|
+
"agent_profile": args.agent_profile,
|
|
127
|
+
"developer_login": args.developer_login,
|
|
128
|
+
"reviewers": tuple(args.reviewers) if args.reviewers else None,
|
|
129
|
+
"state_path": args.state_path,
|
|
130
|
+
"attempt_cap": args.attempt_cap,
|
|
131
|
+
"stale_minutes": args.stale_minutes,
|
|
132
|
+
"on_missing_origin_task": args.on_missing_origin_task,
|
|
133
|
+
"on_missing_hub": args.on_missing_hub,
|
|
134
|
+
"dry_run": args.dry_run,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_config(args: argparse.Namespace) -> Config:
|
|
139
|
+
workspace_root = args.workspace_root or Path.cwd()
|
|
140
|
+
path = resolve_config_path(args.config_path, workspace_root)
|
|
141
|
+
|
|
142
|
+
file_data = load_config_file(path) if path else {}
|
|
143
|
+
log.info("config: %s", path or "none found — defaults + CLI arguments only")
|
|
144
|
+
|
|
145
|
+
config = Config.build(workspace_root, file_data, overrides_from(args))
|
|
146
|
+
return dataclasses.replace(
|
|
147
|
+
config,
|
|
148
|
+
reviewers=resolve_reviewers(config.reviewers, config.manifest_path),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def log_effective_config(config: Config, login: str) -> None:
|
|
153
|
+
"""The resolved settings, defaults and all. INFO carries the decisions an
|
|
154
|
+
operator needs; -v (DEBUG) shows the full surface."""
|
|
155
|
+
log.info("developing as GitHub user %s (from the gh token)", login)
|
|
156
|
+
log.info(
|
|
157
|
+
"watching %s for label %r",
|
|
158
|
+
", ".join(config.repos) if config.repos
|
|
159
|
+
else "NO repos (empty allowlist — set `repos` or pass --repo)",
|
|
160
|
+
config.label,
|
|
161
|
+
)
|
|
162
|
+
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
163
|
+
log.info(
|
|
164
|
+
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min",
|
|
165
|
+
config.poll_interval, config.dry_run, config.attempt_cap, config.stale_minutes,
|
|
166
|
+
)
|
|
167
|
+
log.debug("hub_template: %s", config.hub_template)
|
|
168
|
+
log.debug("agent_profile: %s", config.agent_profile)
|
|
169
|
+
log.debug("developer_login: %s", config.developer_login or f"{login} (auto)")
|
|
170
|
+
log.debug("state_db: %s", config.state_db)
|
|
171
|
+
log.debug("on_missing_origin_task: %s", config.on_missing_origin_task)
|
|
172
|
+
log.debug("on_missing_hub: %s", config.on_missing_hub)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
176
|
+
args = build_parser().parse_args(argv)
|
|
177
|
+
|
|
178
|
+
logging.basicConfig(
|
|
179
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
180
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
181
|
+
datefmt="%H:%M:%S",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
config = resolve_config(args)
|
|
186
|
+
log.info("workspace: %s", config.workspace_root)
|
|
187
|
+
|
|
188
|
+
watcher = DevWatcher(config)
|
|
189
|
+
for warning in watcher.preflight():
|
|
190
|
+
log.warning(warning)
|
|
191
|
+
log_effective_config(config, watcher.github.login)
|
|
192
|
+
|
|
193
|
+
if args.once:
|
|
194
|
+
watcher.poll_once()
|
|
195
|
+
else:
|
|
196
|
+
watcher.run_forever()
|
|
197
|
+
except IdentityMismatch as exc:
|
|
198
|
+
print(f"identity error: {exc}", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
201
|
+
print(f"config error: {exc}", file=sys.stderr)
|
|
202
|
+
return 2
|
|
203
|
+
except RateLimited as exc:
|
|
204
|
+
# Only --once can get here: run_forever absorbs rate limits by
|
|
205
|
+
# backing off, but a single pass has no next pass to wait for.
|
|
206
|
+
print(f"rate limited: {exc}", file=sys.stderr)
|
|
207
|
+
return 1
|
|
208
|
+
except CommandError as exc:
|
|
209
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
except KeyboardInterrupt:
|
|
212
|
+
return 0
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Alissa CLI access: enqueue the fresh developer session and probe the
|
|
2
|
+
worker. The developer's Alissa task is created by the session itself (as a
|
|
3
|
+
downstream of the origin task), so unlike reviewloop there is no task lookup
|
|
4
|
+
here -- the daemon's only Alissa surfaces are the tmux queue and the worker."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .proc import CommandError, run
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Alissa:
|
|
17
|
+
def enqueue_developer(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
session: str,
|
|
21
|
+
directive: str,
|
|
22
|
+
cwd: Path,
|
|
23
|
+
agent: str,
|
|
24
|
+
dry_run: bool = False,
|
|
25
|
+
) -> None:
|
|
26
|
+
argv = [
|
|
27
|
+
"alissa",
|
|
28
|
+
"tmux",
|
|
29
|
+
"queue",
|
|
30
|
+
"add",
|
|
31
|
+
session,
|
|
32
|
+
"--agent",
|
|
33
|
+
agent,
|
|
34
|
+
"--cwd",
|
|
35
|
+
str(cwd),
|
|
36
|
+
directive,
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
if dry_run:
|
|
40
|
+
log.info("[dry-run] would enqueue: %s", " ".join(argv[:-1]) + " <directive>")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
run(argv, timeout=60)
|
|
44
|
+
|
|
45
|
+
# Developers are one-shot per attempt: a finished (or dead) session must
|
|
46
|
+
# never be respawned by the worker -- staleness handling re-enqueues as
|
|
47
|
+
# attempt k+1 with a fresh session name instead. Best-effort: an older
|
|
48
|
+
# CLI without `queue set` should not fail the enqueue.
|
|
49
|
+
try:
|
|
50
|
+
run(["alissa", "tmux", "queue", "set", session, "respawn", "off"],
|
|
51
|
+
timeout=30, check=False)
|
|
52
|
+
except CommandError: # pragma: no cover - defence in depth
|
|
53
|
+
log.warning("could not set respawn off for %s", session)
|
|
54
|
+
|
|
55
|
+
def add_repo_to_workspace(
|
|
56
|
+
self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Hub-ify a repo into the workspace (bare clone + main/ worktree) and
|
|
59
|
+
record it in alissa-workspace.yaml. Idempotent per the CLI's contract."""
|
|
60
|
+
argv = ["alissa", "code", "workspace", "add", f"{owner}/{repo}"]
|
|
61
|
+
if dry_run:
|
|
62
|
+
log.info("[dry-run] would run: %s (cwd=%s)", " ".join(argv), workspace_root)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
log.info("hub-ifying %s/%s into %s", owner, repo, workspace_root)
|
|
66
|
+
# Cloning a repo can be slow; the poll loop tolerates a long pass.
|
|
67
|
+
run(argv, timeout=600, cwd=workspace_root)
|
|
68
|
+
|
|
69
|
+
def worker_running(self) -> bool:
|
|
70
|
+
"""The queue only drains while `alissa worker` reconciles it."""
|
|
71
|
+
try:
|
|
72
|
+
out = run(["alissa", "worker", "status"], timeout=30, check=False)
|
|
73
|
+
except CommandError:
|
|
74
|
+
return False
|
|
75
|
+
return "not running" not in out.lower() and "no worker" not in out.lower()
|