openfactory 0.2.0__py3-none-any.whl
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.
- openfactory/__init__.py +7 -0
- openfactory/actions/__init__.py +344 -0
- openfactory/actions/base.py +334 -0
- openfactory/actions/catalog.py +4872 -0
- openfactory/actions/floor_intents.py +431 -0
- openfactory/adapters/__init__.py +0 -0
- openfactory/adapters/agent/__init__.py +71 -0
- openfactory/adapters/agent/base.py +358 -0
- openfactory/adapters/agent/claude_code.py +1023 -0
- openfactory/adapters/agent/codex.py +350 -0
- openfactory/adapters/agent/kimi.py +291 -0
- openfactory/adapters/agent/opencode.py +432 -0
- openfactory/adapters/agent/registry.py +485 -0
- openfactory/adapters/agent/roles.py +263 -0
- openfactory/adapters/agent/routes.py +255 -0
- openfactory/adapters/agent/session_store.py +221 -0
- openfactory/adapters/agent/stream.py +367 -0
- openfactory/adapters/agent/techlead.py +108 -0
- openfactory/adapters/agent/token_pool.py +67 -0
- openfactory/adapters/azure_devops.py +329 -0
- openfactory/adapters/board/__init__.py +10 -0
- openfactory/adapters/board/azure_devops.py +748 -0
- openfactory/adapters/board/base.py +193 -0
- openfactory/adapters/board/columns.py +68 -0
- openfactory/adapters/board/factory.py +218 -0
- openfactory/adapters/board/jira.py +252 -0
- openfactory/adapters/board/local.py +187 -0
- openfactory/adapters/board_db.py +276 -0
- openfactory/adapters/board_setup/__init__.py +8 -0
- openfactory/adapters/board_setup/base.py +49 -0
- openfactory/adapters/board_setup/local.py +63 -0
- openfactory/adapters/board_setup/registry.py +41 -0
- openfactory/adapters/channel/__init__.py +4 -0
- openfactory/adapters/channel/base.py +100 -0
- openfactory/adapters/channel/panel.py +107 -0
- openfactory/adapters/channel/registry.py +112 -0
- openfactory/adapters/credential/__init__.py +8 -0
- openfactory/adapters/credential/registry.py +164 -0
- openfactory/adapters/environment/__init__.py +30 -0
- openfactory/adapters/environment/azure_pipelines.py +412 -0
- openfactory/adapters/environment/base.py +35 -0
- openfactory/adapters/environment/github_actions.py +97 -0
- openfactory/adapters/environment/none.py +53 -0
- openfactory/adapters/environment/registry.py +160 -0
- openfactory/adapters/forge/__init__.py +30 -0
- openfactory/adapters/forge/azure_devops.py +1528 -0
- openfactory/adapters/forge/base.py +515 -0
- openfactory/adapters/forge/github.py +779 -0
- openfactory/adapters/forge/local.py +660 -0
- openfactory/adapters/forge/registry.py +210 -0
- openfactory/adapters/github_app.py +70 -0
- openfactory/adapters/notify/__init__.py +12 -0
- openfactory/adapters/notify/base.py +45 -0
- openfactory/adapters/notify/panel.py +43 -0
- openfactory/adapters/notify/registry.py +301 -0
- openfactory/adapters/reviewer/__init__.py +30 -0
- openfactory/adapters/reviewer/base.py +34 -0
- openfactory/adapters/reviewer/claude_code.py +140 -0
- openfactory/adapters/reviewer/harness.py +154 -0
- openfactory/adapters/sandbox/__init__.py +31 -0
- openfactory/adapters/sandbox/base.py +410 -0
- openfactory/adapters/sandbox/container.py +550 -0
- openfactory/adapters/sandbox/registry.py +490 -0
- openfactory/adapters/sandbox/timeouts.py +76 -0
- openfactory/adapters/sandbox/worktree.py +381 -0
- openfactory/adapters/tracker/__init__.py +30 -0
- openfactory/adapters/tracker/azure_devops.py +904 -0
- openfactory/adapters/tracker/base.py +549 -0
- openfactory/adapters/tracker/github.py +529 -0
- openfactory/adapters/tracker/github_board_setup.py +216 -0
- openfactory/adapters/tracker/github_project.py +733 -0
- openfactory/adapters/tracker/jira.py +575 -0
- openfactory/adapters/tracker/local.py +379 -0
- openfactory/adapters/tracker/parse.py +231 -0
- openfactory/adapters/tracker/registry.py +200 -0
- openfactory/after_merge.py +51 -0
- openfactory/api/__init__.py +4 -0
- openfactory/api/app.py +2627 -0
- openfactory/api/metrics_view.py +337 -0
- openfactory/api/panel.html +3419 -0
- openfactory/approvals.py +86 -0
- openfactory/box_prove.py +1458 -0
- openfactory/cli.py +3298 -0
- openfactory/cli_refusals.py +169 -0
- openfactory/conformance/__init__.py +19 -0
- openfactory/conformance/adapters.py +622 -0
- openfactory/contracts/__init__.py +65 -0
- openfactory/contracts/bot.py +42 -0
- openfactory/contracts/commands.py +183 -0
- openfactory/contracts/decision.py +286 -0
- openfactory/contracts/manifest.py +713 -0
- openfactory/contracts/product.py +95 -0
- openfactory/contracts/profile.py +214 -0
- openfactory/contracts/project.py +353 -0
- openfactory/contracts/refs.py +155 -0
- openfactory/contracts/review.py +52 -0
- openfactory/contracts/run.py +326 -0
- openfactory/contracts/state.py +64 -0
- openfactory/contracts/ticket.py +113 -0
- openfactory/credentials.py +356 -0
- openfactory/doctor.py +1577 -0
- openfactory/doors.py +238 -0
- openfactory/environ.py +251 -0
- openfactory/factory.py +460 -0
- openfactory/floor/__init__.py +31 -0
- openfactory/floor/ladder.py +655 -0
- openfactory/floor/reading.py +302 -0
- openfactory/identity/__init__.py +14 -0
- openfactory/identity/base.py +95 -0
- openfactory/identity/local.py +186 -0
- openfactory/identity/oidc.py +633 -0
- openfactory/identity/people.py +352 -0
- openfactory/identity/registry.py +91 -0
- openfactory/knowledge/__init__.py +76 -0
- openfactory/knowledge/bundle.py +219 -0
- openfactory/knowledge/check.py +167 -0
- openfactory/knowledge/contracts.py +407 -0
- openfactory/knowledge/experiment.py +118 -0
- openfactory/knowledge/gaps.py +118 -0
- openfactory/knowledge/gate.py +330 -0
- openfactory/knowledge/gather.py +156 -0
- openfactory/knowledge/generator.py +822 -0
- openfactory/knowledge/inventory.py +557 -0
- openfactory/knowledge/okf.py +369 -0
- openfactory/knowledge/pipeline.py +333 -0
- openfactory/knowledge/render.py +146 -0
- openfactory/knowledge/service.py +53 -0
- openfactory/knowledge/staleness.py +112 -0
- openfactory/language/__init__.py +15 -0
- openfactory/language/assent.py +130 -0
- openfactory/loader.py +156 -0
- openfactory/memory/__init__.py +29 -0
- openfactory/memory/ledger.py +215 -0
- openfactory/memory/messages.py +406 -0
- openfactory/memory/recall.py +303 -0
- openfactory/memory/store.py +157 -0
- openfactory/memory/transcript.py +267 -0
- openfactory/namespace.py +258 -0
- openfactory/observability/__init__.py +23 -0
- openfactory/observability/events.py +188 -0
- openfactory/observability/metrics.py +191 -0
- openfactory/observability/query.py +80 -0
- openfactory/observability/registry.py +253 -0
- openfactory/observability/sqlite_metrics.py +203 -0
- openfactory/observability/trajectory.py +243 -0
- openfactory/onboarding/__init__.py +64 -0
- openfactory/onboarding/concepts.py +450 -0
- openfactory/onboarding/context.py +2399 -0
- openfactory/onboarding/cover.py +97 -0
- openfactory/onboarding/deployment.py +807 -0
- openfactory/onboarding/firstrun.py +1637 -0
- openfactory/onboarding/history.py +363 -0
- openfactory/onboarding/infer.py +2736 -0
- openfactory/onboarding/live_ci.py +162 -0
- openfactory/onboarding/onboard.py +1070 -0
- openfactory/onboarding/propose_manifest.py +349 -0
- openfactory/onboarding/questions.py +137 -0
- openfactory/onboarding/readiness.py +1073 -0
- openfactory/onboarding/renew.py +239 -0
- openfactory/onboarding/spend.py +82 -0
- openfactory/ops/__init__.py +6 -0
- openfactory/ops/impediment.py +288 -0
- openfactory/orchestrator/__init__.py +26 -0
- openfactory/orchestrator/context.py +231 -0
- openfactory/orchestrator/errors.py +27 -0
- openfactory/orchestrator/machine.py +2617 -0
- openfactory/orchestrator/merge_policy.py +203 -0
- openfactory/orchestrator/promotion.py +249 -0
- openfactory/orchestrator/risk.py +191 -0
- openfactory/orchestrator/validation.py +172 -0
- openfactory/org_defaults/engineering.md +75 -0
- openfactory/org_defaults/floor.yaml +182 -0
- openfactory/org_defaults/profiles/prototype.yaml +16 -0
- openfactory/org_defaults/profiles/regulated.yaml +35 -0
- openfactory/org_defaults/requirements/0000-template.md +39 -0
- openfactory/org_defaults/roles/coordinator.md +48 -0
- openfactory/org_defaults/roles/executor.md +92 -0
- openfactory/org_defaults/roles/planner.md +117 -0
- openfactory/org_defaults/roles/product.md +71 -0
- openfactory/org_defaults/roles/recovery.md +33 -0
- openfactory/org_defaults/roles/sizer.md +62 -0
- openfactory/org_defaults/roles/techlead.md +61 -0
- openfactory/org_defaults/tdd.md +10 -0
- openfactory/own_work.py +41 -0
- openfactory/paths.py +117 -0
- openfactory/plugins.py +208 -0
- openfactory/policy/__init__.py +10 -0
- openfactory/policy/authz.py +105 -0
- openfactory/policy/census.py +161 -0
- openfactory/policy/conformance.py +300 -0
- openfactory/policy/floor.py +42 -0
- openfactory/policy/presets.py +172 -0
- openfactory/policy/profiles.py +275 -0
- openfactory/policy/protected.py +169 -0
- openfactory/policy/test_work.py +70 -0
- openfactory/preflight.py +577 -0
- openfactory/presets/node.yaml +6 -0
- openfactory/presets/python.yaml +9 -0
- openfactory/presets/security-oss.yaml +26 -0
- openfactory/presets/terraform.yaml +8 -0
- openfactory/product/__init__.py +56 -0
- openfactory/product/asked.py +172 -0
- openfactory/product/authoring.py +1791 -0
- openfactory/product/board.py +484 -0
- openfactory/product/brownfield.py +210 -0
- openfactory/product/case.py +409 -0
- openfactory/product/channel.py +1437 -0
- openfactory/product/config.py +364 -0
- openfactory/product/confirm.py +801 -0
- openfactory/product/conversation.py +49 -0
- openfactory/product/corpus.py +462 -0
- openfactory/product/domain.py +242 -0
- openfactory/product/facts.py +209 -0
- openfactory/product/followup.py +647 -0
- openfactory/product/intents.py +641 -0
- openfactory/product/loader.py +198 -0
- openfactory/product/module.py +3100 -0
- openfactory/product/needs_action.py +198 -0
- openfactory/product/onboard.py +331 -0
- openfactory/product/queue.py +273 -0
- openfactory/product/reading.py +130 -0
- openfactory/product/release.py +153 -0
- openfactory/product/requester.py +55 -0
- openfactory/product/role.py +1356 -0
- openfactory/product/staging.py +477 -0
- openfactory/product/triage.py +277 -0
- openfactory/product/voice.py +2080 -0
- openfactory/product/workspace.py +182 -0
- openfactory/registry.py +526 -0
- openfactory/review/__init__.py +11 -0
- openfactory/review/verdict.py +199 -0
- openfactory/runtime/__init__.py +0 -0
- openfactory/runtime/boxed_job.py +399 -0
- openfactory/runtime/card_repo.py +146 -0
- openfactory/runtime/host.py +91 -0
- openfactory/runtime/repo_cache.py +296 -0
- openfactory/runtime/temporal/__init__.py +26 -0
- openfactory/runtime/temporal/activities.py +5081 -0
- openfactory/runtime/temporal/connection.py +89 -0
- openfactory/runtime/temporal/io.py +639 -0
- openfactory/runtime/temporal/poller.py +185 -0
- openfactory/runtime/temporal/schedule.py +465 -0
- openfactory/runtime/temporal/starter.py +54 -0
- openfactory/runtime/temporal/view.py +884 -0
- openfactory/runtime/temporal/worker.py +325 -0
- openfactory/runtime/temporal/workflow.py +2459 -0
- openfactory/runtime/toolbox.py +163 -0
- openfactory/scheduler.py +75 -0
- openfactory/semver.py +35 -0
- openfactory/techlead/__init__.py +38 -0
- openfactory/techlead/classify.py +427 -0
- openfactory/techlead/conversation.py +1401 -0
- openfactory/techlead/diagnosis.py +251 -0
- openfactory/techlead/memory.py +264 -0
- openfactory/techlead/pack.py +169 -0
- openfactory/techlead/voice.py +679 -0
- openfactory/techlead/watch.py +638 -0
- openfactory/testing/__init__.py +1 -0
- openfactory/testing/local_flow.py +272 -0
- openfactory/util/__init__.py +0 -0
- openfactory/util/bounded.py +77 -0
- openfactory/util/causes.py +125 -0
- openfactory/util/scratch.py +80 -0
- openfactory-0.2.0.dist-info/METADATA +41 -0
- openfactory-0.2.0.dist-info/RECORD +270 -0
- openfactory-0.2.0.dist-info/WHEEL +5 -0
- openfactory-0.2.0.dist-info/entry_points.txt +2 -0
- openfactory-0.2.0.dist-info/licenses/LICENSE +202 -0
- openfactory-0.2.0.dist-info/licenses/NOTICE +22 -0
- openfactory-0.2.0.dist-info/top_level.txt +1 -0
openfactory/__init__.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""The action layer — everything a human can ask the factory to DO, written once (C-23).
|
|
2
|
+
|
|
3
|
+
WHAT WAS ACTUALLY WRONG. The Slack bot never calls the HTTP API: zero httpx, zero requests, zero
|
|
4
|
+
aiohttp. The two front ends were written independently against the same domain, and by the time
|
|
5
|
+
this card was opened they had drifted in ways nobody chose:
|
|
6
|
+
|
|
7
|
+
resume the panel passes the DecisionRequest option key; Slack hard-codes ""
|
|
8
|
+
→ a job parked with a real question can be answered from the panel and only
|
|
9
|
+
resumed blindly from Slack, in a channel where the question was posted
|
|
10
|
+
ack exists in Slack, does not exist in the panel at all
|
|
11
|
+
enable / scan exist in the panel, do not exist in Slack
|
|
12
|
+
approve_prod two implementations — a durable signal and an in-process PromotionRunner —
|
|
13
|
+
reachable from one front end each, under names that suggest they are the same
|
|
14
|
+
validation the panel checks the ref shape; Slack checks nothing
|
|
15
|
+
|
|
16
|
+
None of that is a bug anybody wrote. It is what happens when a capability has no home, and it is
|
|
17
|
+
the manifesto's empty SDK box: there was no layer for a third front end to be written against, so
|
|
18
|
+
a third front end would have made it three.
|
|
19
|
+
|
|
20
|
+
WHAT THIS LAYER IS. A table of actions. Each takes values and `by: Actor`, returns an `Outcome`,
|
|
21
|
+
and speaks no transport: no HTTP status, no mrkdwn, no Slack block, no exception escaping. The
|
|
22
|
+
front ends become mappings — a route or a verb picks a name and renders the result.
|
|
23
|
+
|
|
24
|
+
WHAT IT IS NOT. It is not authorization (C-26 / #55): `Actor.admin` is still decided by whoever
|
|
25
|
+
carried the request, and this layer only carries and records it. It is not the conversational
|
|
26
|
+
tech-lead (C-24 / #52): `ask` and `diagnose` are catalogued here and their brains still live in
|
|
27
|
+
the Slack package until that card moves them. Both are deliberate — putting either in this card
|
|
28
|
+
would mean designing identity or the agent's memory model inside a refactor, and that is how a
|
|
29
|
+
refactor becomes a rewrite.
|
|
30
|
+
|
|
31
|
+
REACHING IT. Two universal transports dispatch the whole catalog by name:
|
|
32
|
+
|
|
33
|
+
POST /api/act/{name} the panel and anything holding its token
|
|
34
|
+
openfactory act <name> … a shell on the host
|
|
35
|
+
|
|
36
|
+
and the named front-end paths (`/api/temporal/act`, the Slack verbs, `/api/projects/{n}/enabled`)
|
|
37
|
+
are mappings onto the same rows. That is the property the guards in
|
|
38
|
+
`tests/test_the_action_layer.py` hold: **every action is reachable from at least two transports and
|
|
39
|
+
implemented by none of them.**
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import re
|
|
46
|
+
|
|
47
|
+
from openfactory.actions.base import (
|
|
48
|
+
CODES,
|
|
49
|
+
CONFLICT,
|
|
50
|
+
DENIED,
|
|
51
|
+
FAILED,
|
|
52
|
+
FLOOR,
|
|
53
|
+
INVALID,
|
|
54
|
+
NOT_FOUND,
|
|
55
|
+
OK,
|
|
56
|
+
PRODUCT,
|
|
57
|
+
SCOPES,
|
|
58
|
+
SYSTEM,
|
|
59
|
+
UNAVAILABLE,
|
|
60
|
+
UNIMPLEMENTED,
|
|
61
|
+
ActionSpec,
|
|
62
|
+
Actor,
|
|
63
|
+
Outcome,
|
|
64
|
+
done,
|
|
65
|
+
not_moved_yet,
|
|
66
|
+
refused,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
log = logging.getLogger("openfactory.actions")
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"CATALOG", "CODES", "CONFLICT", "DENIED", "FAILED", "FLOOR", "INVALID", "NOT_FOUND", "OK",
|
|
73
|
+
"PRODUCT", "SCOPES", "SYSTEM", "UNAVAILABLE", "UNIMPLEMENTED", "ActionSpec", "Actor",
|
|
74
|
+
"Outcome", "done", "names", "not_moved_yet", "perform", "proposable", "refused",
|
|
75
|
+
"run_staged", "spec",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def run_staged(*, project: str, by: Actor, token: str = "") -> Outcome:
|
|
80
|
+
"""Perform what the tech-lead staged for `project` — see `catalog.run_staged`.
|
|
81
|
+
|
|
82
|
+
Re-exported lazily for the same reason `_Catalog` is: importing the implementations costs
|
|
83
|
+
`temporalio`, and the panel is built to serve without the runtime extra."""
|
|
84
|
+
from openfactory.actions.catalog import run_staged as _run
|
|
85
|
+
|
|
86
|
+
return await _run(project=project, by=by, token=token)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _catalog() -> dict[str, ActionSpec]:
|
|
90
|
+
from openfactory.actions.catalog import CATALOG as rows
|
|
91
|
+
|
|
92
|
+
return rows
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _Catalog:
|
|
96
|
+
"""A lazy mapping over `catalog.py`, so importing `openfactory.actions` costs nothing.
|
|
97
|
+
|
|
98
|
+
The catalog's implementations reach Temporal, the forge and the approver store. Importing them
|
|
99
|
+
eagerly would put `temporalio` on the import path of every front end — including the panel,
|
|
100
|
+
which is explicitly built to serve without the `runtime` extra installed. The bodies already
|
|
101
|
+
import lazily; this keeps the module itself lazy too, so the guard tests can walk the table on
|
|
102
|
+
a machine that has none of it."""
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, name: str) -> ActionSpec:
|
|
105
|
+
return _catalog()[name]
|
|
106
|
+
|
|
107
|
+
def __contains__(self, name: object) -> bool:
|
|
108
|
+
return name in _catalog()
|
|
109
|
+
|
|
110
|
+
def __iter__(self):
|
|
111
|
+
return iter(_catalog())
|
|
112
|
+
|
|
113
|
+
def __len__(self) -> int:
|
|
114
|
+
return len(_catalog())
|
|
115
|
+
|
|
116
|
+
def values(self):
|
|
117
|
+
return _catalog().values()
|
|
118
|
+
|
|
119
|
+
def items(self):
|
|
120
|
+
return _catalog().items()
|
|
121
|
+
|
|
122
|
+
def get(self, name: str, default=None):
|
|
123
|
+
return _catalog().get(name, default)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
#: The one table. Iterating it is how a front end lists what it can offer, and how the guards
|
|
127
|
+
#: check that every row is reachable.
|
|
128
|
+
CATALOG = _Catalog()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def names() -> tuple[str, ...]:
|
|
132
|
+
"""Every action, in catalog order — which is grouped by what an operator is doing, not
|
|
133
|
+
alphabetical, because this is what `openfactory act --list` prints."""
|
|
134
|
+
return tuple(_catalog())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def spec(name: str) -> ActionSpec | None:
|
|
138
|
+
return _catalog().get((name or "").strip().lower())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#: What a proposal may CARRY. It was `{project, issue}` — a ticket and nothing else — because the
|
|
142
|
+
#: channel back was `[[SUGGEST verb #NN]]`, with nowhere to put a sentence. That dropped `adjust`,
|
|
143
|
+
#: and the consequence was the shape of the whole role: the tech-lead could propose that you throw
|
|
144
|
+
#: work away (`discard`) or rerun it blind (`resume`), and could not propose the one thing a senior
|
|
145
|
+
#: engineer actually says, which is WHAT TO CHANGE (#170).
|
|
146
|
+
#:
|
|
147
|
+
#: A SECRET IS STILL NOT ADDRESSABLE, and that is the line this set now draws. `_SECRET` below
|
|
148
|
+
#: names what may never travel through a proposal; a parameter outside it is prose a human reads on
|
|
149
|
+
#: the button before pressing it.
|
|
150
|
+
_ADDRESSABLE = frozenset({"project", "issue", "instruction"})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def proposable(by: Actor) -> tuple[str, ...]:
|
|
154
|
+
"""The actions this actor may perform AND the tech-lead may propose, in catalog order (#121).
|
|
155
|
+
|
|
156
|
+
THREE FILTERS, ALL DERIVED, because the alternative is the sentence this replaces: the
|
|
157
|
+
tech-lead's guidance said *"never suggest prod/merge actions"* — a rule written when the
|
|
158
|
+
catalogue had no merge row. #120 added one, gated, reachable from the chat, and the guidance
|
|
159
|
+
went on forbidding it. A hand-written list of verbs in a prompt is a second copy of the
|
|
160
|
+
catalogue that nobody updates.
|
|
161
|
+
|
|
162
|
+
1. **The asker's own credential**, by the SAME two checks `perform` applies and in the same
|
|
163
|
+
order — scope, then admin. A credential that cannot press the button must not be told to
|
|
164
|
+
ask for it; being handed an action and then refused is worse than not being offered it.
|
|
165
|
+
2. **What a human could have typed.** A suggestion is a proposal somebody approves with one
|
|
166
|
+
word, so it must be something they could have said themselves: the operator grammar
|
|
167
|
+
(`contracts/commands.py`, which is `resume`/`skip` and deliberately excludes prod) and the
|
|
168
|
+
floor matcher (`actions/floor_intents.py`, which is merge/discard/adjust). Rows like
|
|
169
|
+
`start`, `diagnose` and `product_release` are addressable by a ticket and are NOT things
|
|
170
|
+
the floor's own grammar accepts — which is exactly the line the old rule was reaching for.
|
|
171
|
+
3. **What a proposal can carry** — see `_ADDRESSABLE`. This used to drop `adjust`, because
|
|
172
|
+
the channel was one verb and one ticket wide. It carries an instruction now (#170), and the
|
|
173
|
+
blast radius is closed by (2): `typeable` is `{adjust, discard, merge, resume, skip, stop}`,
|
|
174
|
+
so relaxing this admits EXACTLY `adjust`. `approve_prod` and `promote` stay out where they
|
|
175
|
+
already were — not in the floor grammar — rather than by this filter's accident.
|
|
176
|
+
|
|
177
|
+
`ack` is deliberately absent from (2). It is a person saying they have seen something, and
|
|
178
|
+
nobody can propose that on somebody else's behalf.
|
|
179
|
+
"""
|
|
180
|
+
from openfactory.actions.floor_intents import FLOOR_ROWS
|
|
181
|
+
from openfactory.contracts.commands import ACTION_OF
|
|
182
|
+
|
|
183
|
+
typeable = set(ACTION_OF.values()) | set(FLOOR_ROWS.values())
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
for name, found in _catalog().items():
|
|
186
|
+
if name not in typeable:
|
|
187
|
+
continue
|
|
188
|
+
if not by.may_enter(found.scope):
|
|
189
|
+
continue
|
|
190
|
+
if found.needs_admin and not by.admin:
|
|
191
|
+
continue
|
|
192
|
+
if set(found.required) - _ADDRESSABLE or "issue" not in found.required:
|
|
193
|
+
continue
|
|
194
|
+
out.append(name)
|
|
195
|
+
return tuple(out)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def perform(name: str, *, by: Actor, **params: object) -> Outcome:
|
|
199
|
+
"""Run one action. Never raises. Always returns an `Outcome`.
|
|
200
|
+
|
|
201
|
+
THE FIVE THINGS THAT HAPPEN HERE RATHER THAN IN THREE FRONT ENDS:
|
|
202
|
+
|
|
203
|
+
1. **The name is resolved**, and an unknown one is refused with the list of what exists —
|
|
204
|
+
the registry rule this codebase already holds everywhere else (`adapters/*/registry.py`).
|
|
205
|
+
2. **Parameters are checked** against the spec, in both directions. A missing one is named; so
|
|
206
|
+
is an unexpected one, because a front end that renames a field and keeps working silently
|
|
207
|
+
is how `image=` came to be omitted at all four job-launch sites (see
|
|
208
|
+
`a-negative-guard-needs-a-positive-twin`).
|
|
209
|
+
3. **A ticket ref is normalised once.** `#189` and `189` are the same ticket, and until now the
|
|
210
|
+
panel knew that and Slack did not.
|
|
211
|
+
4. **`needs_admin` is enforced.** Slack had this gate, the panel had a token, the CLI had
|
|
212
|
+
nothing; now the three answers meet in `Actor.admin` and the decision is made in one place.
|
|
213
|
+
5. **Everything is logged with the actor**, whatever the result. This is the audit line — the
|
|
214
|
+
first time this platform can say who asked for a thing, which is the question an enterprise
|
|
215
|
+
security review opens with.
|
|
216
|
+
|
|
217
|
+
NEVER RAISING IS NOT POLITENESS. Both front ends already have a hard must-always-reply rule,
|
|
218
|
+
and both implemented it by wrapping every call in `except Exception`. Doing it here means an
|
|
219
|
+
action author cannot forget, and means the exception's real message survives — `first_message`
|
|
220
|
+
walks the chain, because an error crossing a Temporal activity boundary otherwise arrives as
|
|
221
|
+
the fixed string "Activity task failed" (#66).
|
|
222
|
+
"""
|
|
223
|
+
from openfactory.util.causes import first_message
|
|
224
|
+
|
|
225
|
+
key = (name or "").strip().lower()
|
|
226
|
+
found = _catalog().get(key)
|
|
227
|
+
if found is None:
|
|
228
|
+
return refused(
|
|
229
|
+
NOT_FOUND,
|
|
230
|
+
f"there is no action called {name!r} — this deployment does: " + ", ".join(names()),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
problem = _check_params(found, params)
|
|
234
|
+
if problem:
|
|
235
|
+
return refused(INVALID, problem)
|
|
236
|
+
|
|
237
|
+
if "issue" in params:
|
|
238
|
+
ref, bad = _clean_ref(str(params["issue"]))
|
|
239
|
+
if bad:
|
|
240
|
+
return refused(INVALID, bad)
|
|
241
|
+
params["issue"] = ref
|
|
242
|
+
|
|
243
|
+
# SCOPE BEFORE ADMIN, because they answer different questions and the order is the point: a
|
|
244
|
+
# business analyst holding a product credential IS an admin of the product area — accepting a
|
|
245
|
+
# requirement is the most consequential act there — and must still be refused `merge` outright.
|
|
246
|
+
# Asking `admin` first would let that credential through on every row it happens to satisfy.
|
|
247
|
+
if not by.may_enter(found.scope):
|
|
248
|
+
log.warning("DENIED_SCOPE %s (%s) by %s (%s)", key, found.scope, by, _loggable(params))
|
|
249
|
+
return refused(
|
|
250
|
+
DENIED,
|
|
251
|
+
f"this credential is scoped to {', '.join(sorted(by.scopes or ())) or 'nothing'} and "
|
|
252
|
+
f"{key} belongs to the {found.scope}. Nothing was done — ask somebody whose "
|
|
253
|
+
f"credential covers the {found.scope}.",
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if found.needs_admin and not by.admin:
|
|
257
|
+
log.warning("DENIED %s by %s (%s)", key, by, _loggable(params))
|
|
258
|
+
return refused(
|
|
259
|
+
DENIED,
|
|
260
|
+
f"{by} is not allowed to {key} — ask somebody listed as an admin for this project.",
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
outcome = await found.run(by=by, **params)
|
|
265
|
+
except Exception as exc: # noqa: BLE001 — an action must never take its caller down
|
|
266
|
+
log.exception("action %s raised for %s", key, by)
|
|
267
|
+
return refused(
|
|
268
|
+
FAILED, f"could not {key}: {first_message(exc)}",
|
|
269
|
+
)
|
|
270
|
+
if not isinstance(outcome, Outcome): # an action that returns None is a silent success (F5)
|
|
271
|
+
log.error("action %s returned %r, not an Outcome", key, type(outcome).__name__)
|
|
272
|
+
return refused(
|
|
273
|
+
FAILED,
|
|
274
|
+
f"'{key}' did not report what it did — treat this as a failure and check the logs "
|
|
275
|
+
f"before assuming it happened.",
|
|
276
|
+
)
|
|
277
|
+
log.info("action=%s by=%s ok=%s code=%s params=%s", key, by, outcome.ok,
|
|
278
|
+
outcome.code or "-", _loggable(params))
|
|
279
|
+
return outcome
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
#: Parameters whose VALUE must never reach a log line. `approve_prod` and `promote` take the
|
|
283
|
+
#: approver's password, and this module writes an audit line on every call including the refusals —
|
|
284
|
+
#: which is precisely the path a wrong password takes.
|
|
285
|
+
_SECRET = frozenset({"password", "token", "secret", "key"})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _loggable(params: dict[str, object]) -> dict[str, object]:
|
|
289
|
+
return {k: ("***" if k in _SECRET else v) for k, v in params.items()}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _check_params(found: ActionSpec, params: dict[str, object]) -> str:
|
|
293
|
+
missing = [p for p in found.required if p not in params or params[p] in (None, "")]
|
|
294
|
+
if missing:
|
|
295
|
+
return (f"'{found.name}' needs {', '.join(missing)} — it takes "
|
|
296
|
+
+ ", ".join(found.parameters) + ".")
|
|
297
|
+
unexpected = [p for p in params if p not in found.parameters]
|
|
298
|
+
if unexpected:
|
|
299
|
+
return (f"'{found.name}' does not take {', '.join(sorted(unexpected))} — it takes "
|
|
300
|
+
+ (", ".join(found.parameters) or "no parameters") + ".")
|
|
301
|
+
return ""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
#: What a tracker actually calls a ticket: `189` (GitHub, optionally `#`-prefixed), `CONT-412`
|
|
305
|
+
#: (Jira), `1234` (Azure DevOps). Letters, digits, one kind of separator, nothing else.
|
|
306
|
+
#:
|
|
307
|
+
#: Lifted verbatim from `api/app.py::_valid_issue`, which is the point: the panel held this and
|
|
308
|
+
#: Slack held nothing, so a ref typed in a channel went straight into a Temporal workflow id. The
|
|
309
|
+
#: bound matters for the same reason it did there — a ref becomes part of a workflow id and, via
|
|
310
|
+
#: `paths.journal_stem`, part of a filename.
|
|
311
|
+
_ISSUE_RE = re.compile(r"^[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$")
|
|
312
|
+
#: …and the same ref carrying the repository it lives in (C-18): `owner/name#189`. One product can
|
|
313
|
+
#: own several repositories, so on a multi-repo board a bare number names no single ticket. Kept as
|
|
314
|
+
#: a SECOND, fully-anchored pattern rather than by loosening the first: `/` and `#` reach a
|
|
315
|
+
#: workflow id and a filename, and the way to allow exactly two segments and one hash is to spell
|
|
316
|
+
#: that out, not to add characters to a class and hope.
|
|
317
|
+
_QUALIFIED_RE = re.compile(
|
|
318
|
+
r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*#[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*$")
|
|
319
|
+
_ISSUE_MAX = 64
|
|
320
|
+
#: The qualified form needs room for `owner/name#` on top of the ref itself. A SEPARATE bound, so
|
|
321
|
+
#: widening it here cannot quietly widen what a bare ref may be — the bound is what keeps a ref
|
|
322
|
+
#: from becoming an unreasonable workflow id or filename, and each shape deserves its own.
|
|
323
|
+
_QUALIFIED_MAX = 160
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _clean_ref(issue: str) -> tuple[str, str]:
|
|
327
|
+
"""`(ref, problem)` — the provider's own ref without the decoration a human types.
|
|
328
|
+
|
|
329
|
+
`#189` and `189` must not be two tickets. The panel already knew that in `_valid_issue`; Slack
|
|
330
|
+
passed whatever was typed straight into a Temporal workflow id, so `skip #250` and `skip 250`
|
|
331
|
+
addressed different workflows and only one of them existed.
|
|
332
|
+
|
|
333
|
+
REJECTING IS STILL WORTH DOING even though `paths.py` neutralises a hostile ref on its own
|
|
334
|
+
(C-06a). This is where there is somebody to tell: a sentence naming the problem beats a job
|
|
335
|
+
launched against a ref no provider will ever recognise, which surfaces minutes later as an
|
|
336
|
+
unexplained 404."""
|
|
337
|
+
ref = (issue or "").strip().lstrip("#").strip()
|
|
338
|
+
bare = bool(ref) and len(ref) <= _ISSUE_MAX and bool(_ISSUE_RE.match(ref))
|
|
339
|
+
qualified = bool(ref) and len(ref) <= _QUALIFIED_MAX and bool(_QUALIFIED_RE.match(ref))
|
|
340
|
+
if not (bare or qualified):
|
|
341
|
+
return "", ("that is not a ticket reference — letters, digits, '-' or '_', optionally "
|
|
342
|
+
"with the repository it lives in "
|
|
343
|
+
f"(e.g. 189, #189, CONT-412, owner/name#189), got {issue!r}")
|
|
344
|
+
return ref, ""
|