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
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""What an action IS — the vocabulary the front ends and the catalog share (C-23).
|
|
2
|
+
|
|
3
|
+
Three types and nothing else, because every one of them exists to stop a specific thing from
|
|
4
|
+
leaking across the seam:
|
|
5
|
+
|
|
6
|
+
Actor who asked, so an action never has to guess and a front end never has to decide
|
|
7
|
+
Outcome values out — no HTTP status, no mrkdwn, no emoji, no exception
|
|
8
|
+
ActionSpec a row in a table, so "which actions exist" is data a test can walk
|
|
9
|
+
|
|
10
|
+
**Outcome, not exceptions.** Both front ends already had a hard rule that they must always reply —
|
|
11
|
+
the Slack bot's `act_job` docstring says "NEVER raises (the bot must always reply)" and the panel
|
|
12
|
+
turns everything into an `HTTPException` by hand. Two front ends, two hand-written translations of
|
|
13
|
+
the same failures, and they disagreed: a job that is not parked is a 409 in the panel and the
|
|
14
|
+
sentence "não está parado esperando ação" in Slack, with no shared notion that those are the same
|
|
15
|
+
answer. `code` is that shared notion; rendering it stays each front end's job, which is the only
|
|
16
|
+
part that is genuinely theirs.
|
|
17
|
+
|
|
18
|
+
**`ok` is about the platform, not about the answer.** `scan` finding nothing in TO-DO is `ok=True`
|
|
19
|
+
with a message saying so — nothing went wrong, the queue is empty. `ok=False` means the platform
|
|
20
|
+
could not do the thing it was asked to do. Getting this backwards would make every empty queue
|
|
21
|
+
look like an outage in whatever dashboard reads these.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from types import MappingProxyType
|
|
29
|
+
|
|
30
|
+
# ── the codes a front end maps ───────────────────────────────────────────────────────────────────
|
|
31
|
+
#
|
|
32
|
+
# A CLOSED SET, and `openfactory/api/app.py` holds a status for every member — asserted by a test,
|
|
33
|
+
# because
|
|
34
|
+
# a code with no mapping is a 500 wearing an action's name. Adding one here without adding it there
|
|
35
|
+
# fails the suite rather than production.
|
|
36
|
+
|
|
37
|
+
OK = ""
|
|
38
|
+
#: the caller asked for something malformed — a missing parameter, a ref no tracker could issue
|
|
39
|
+
INVALID = "invalid"
|
|
40
|
+
#: the caller is not allowed to do this
|
|
41
|
+
DENIED = "denied"
|
|
42
|
+
#: the project, ticket or job named does not exist
|
|
43
|
+
NOT_FOUND = "not_found"
|
|
44
|
+
#: the world is not in a state where this makes sense — a job not parked, one already running
|
|
45
|
+
CONFLICT = "conflict"
|
|
46
|
+
#: a dependency the platform needs is not answering — the durable engine, the forge
|
|
47
|
+
UNAVAILABLE = "unavailable"
|
|
48
|
+
#: it was attempted and it did not work
|
|
49
|
+
FAILED = "failed"
|
|
50
|
+
#: the action is catalogued but its implementation has not been moved into the layer yet
|
|
51
|
+
UNIMPLEMENTED = "unimplemented"
|
|
52
|
+
|
|
53
|
+
CODES = frozenset({OK, INVALID, DENIED, NOT_FOUND, CONFLICT, UNAVAILABLE, FAILED, UNIMPLEMENTED})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── where an action lives, and where a credential may go ─────────────────────────────────────────
|
|
57
|
+
#
|
|
58
|
+
# TWO AREAS TODAY, and the split is the one the product owner drew: *"if the person wants to write
|
|
59
|
+
# the tickets and drop them in TO-DO, fine — but the product role is available ON THE PLATFORM"*.
|
|
60
|
+
# A business analyst writing requirements is not an operator of the floor, and the panel's posture
|
|
61
|
+
# until now was that holding its credential made you both.
|
|
62
|
+
|
|
63
|
+
#: Jobs, the board, merges, the engine — the operator's console. The default for every row.
|
|
64
|
+
FLOOR = "floor"
|
|
65
|
+
#: The product role: requirements, their drafting, sign-off and withdrawal.
|
|
66
|
+
PRODUCT = "product"
|
|
67
|
+
|
|
68
|
+
#: Every area a row may declare. A registry rather than a free string, for the reason every other
|
|
69
|
+
#: registry in this codebase exists: a typo'd scope would otherwise be an area nobody can reach and
|
|
70
|
+
#: nothing would say so.
|
|
71
|
+
SCOPES = frozenset({FLOOR, PRODUCT})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ── who asked ────────────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Actor:
|
|
78
|
+
"""The human (or bot) on whose behalf an action runs.
|
|
79
|
+
|
|
80
|
+
EVERY ACTION TAKES ONE, including the read-only ones. Not because they all check it today —
|
|
81
|
+
most do not — but because the parameter is the seam C-26 fills, and a parameter added later to
|
|
82
|
+
forty call sites is a migration, while a parameter carried from the start is a rename. It is
|
|
83
|
+
also already useful: `perform` writes one audit line per action naming this, which is the first
|
|
84
|
+
time this platform can answer *who approved that production release*.
|
|
85
|
+
|
|
86
|
+
`admin` IS DECIDED BY THE TRANSPORT TODAY, and that is a stated limitation rather than a
|
|
87
|
+
design. Slack knows its answer (`project.admins`), the panel's answer is "did they hold the
|
|
88
|
+
panel token", and the CLI's is "they have a shell on the host, which outranks every action
|
|
89
|
+
here". C-26 (#55) moves the decision behind this field into a policy the Core owns; until then
|
|
90
|
+
the field is where the three front ends' three different answers meet, which is already one
|
|
91
|
+
place fewer than before.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
#: Stable id in whatever namespace the transport owns: a Slack user id (`U04…`), a panel
|
|
95
|
+
#: principal, `cli`. Not required to be globally unique across transports — `via` disambiguates.
|
|
96
|
+
id: str
|
|
97
|
+
#: What to write in a note a human reads. Falls back to `id` when the transport has no name.
|
|
98
|
+
display: str = ""
|
|
99
|
+
#: Which front end carried the request: `panel`, `slack`, `cli`, `test`.
|
|
100
|
+
via: str = ""
|
|
101
|
+
#: Whether this actor may run an action marked `needs_admin`. See the caveat above.
|
|
102
|
+
admin: bool = False
|
|
103
|
+
#: WHICH AREAS OF THE PLATFORM this credential may act in at all, or `None` for one that is
|
|
104
|
+
#: not scoped. `None` and `frozenset()` are different answers and the distinction is the whole
|
|
105
|
+
#: point: `None` means nobody restricted this caller (the CLI, a Slack admin, the panel's own
|
|
106
|
+
#: token — every actor that existed before scopes did, which is why the default cannot be
|
|
107
|
+
#: `frozenset()` without denying all of them); `frozenset()` means somebody scoped it to
|
|
108
|
+
#: nothing. The same `None` vs `[]` rule this codebase pays for everywhere else.
|
|
109
|
+
#:
|
|
110
|
+
#: WHY THIS IS NOT `admin`. A business analyst who writes requirements needs `needs_admin` to
|
|
111
|
+
#: be TRUE for `product_accept` — accepting is the most consequential act on that surface — and
|
|
112
|
+
#: needs `merge` to be refused outright. Those are two different questions: *how much authority
|
|
113
|
+
#: within an area*, and *which areas at all*. Folding them into one flag is how handing a BA a
|
|
114
|
+
#: credential to write a requirement also handed them the button that lands a pull request.
|
|
115
|
+
scopes: frozenset[str] | None = None
|
|
116
|
+
#: THE CONVERSATION THIS ACTOR IS IN, when the transport keys one per person (#33). On Slack
|
|
117
|
+
#: a thread comes free and the rows take it as `thread`; on the web nothing did, so every
|
|
118
|
+
#: person who typed into the panel's box wrote into ONE conversation keyed by the project's
|
|
119
|
+
#: name, and the product role read A and B as one person. The panel fills this from the
|
|
120
|
+
#: subject it resolved — `person:<id>` for somebody known, `visitor:<cookie>` for a browser
|
|
121
|
+
#: nobody has identified yet — and a product row uses it when the caller passed no thread.
|
|
122
|
+
#: Empty means the transport keys nothing, which is every actor that predates this.
|
|
123
|
+
conversation: str = ""
|
|
124
|
+
|
|
125
|
+
def may_enter(self, scope: str) -> bool:
|
|
126
|
+
"""Whether this actor may act in `scope`. Unscoped actors may enter anywhere."""
|
|
127
|
+
return self.scopes is None or scope in self.scopes
|
|
128
|
+
|
|
129
|
+
def __str__(self) -> str:
|
|
130
|
+
who = self.display or self.id or "anonymous"
|
|
131
|
+
return f"{who} (via {self.via})" if self.via else who
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
#: For the paths that genuinely have no subject yet — a scheduled poll, an internal call. Kept as a
|
|
135
|
+
#: named constant rather than `Actor("")` at each site so a grep for it finds every place the
|
|
136
|
+
#: platform acts on nobody's behalf, which is exactly the list C-26 will want.
|
|
137
|
+
SYSTEM = Actor(id="system", display="the factory", via="internal", admin=True)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── what comes back ──────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class Outcome:
|
|
144
|
+
"""Values out. No status code, no markup, no vendor.
|
|
145
|
+
|
|
146
|
+
`message` is ONE human sentence in plain text. It may be shown to a client, so it says what
|
|
147
|
+
happened and — when something is wrong — what to do about it, in the register the rest of this
|
|
148
|
+
platform uses. It must not contain mrkdwn, HTML, or a provider's link syntax: the front end
|
|
149
|
+
that knows the surface decorates it, and a message pre-decorated for Slack renders as literal
|
|
150
|
+
asterisks in the panel (which is how the first version of the impediment alert shipped).
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
ok: bool
|
|
154
|
+
message: str
|
|
155
|
+
#: Values for a machine — what a panel renders as a table, what a script greps. Free-form per
|
|
156
|
+
#: action and documented on its spec.
|
|
157
|
+
data: Mapping[str, object] = field(default_factory=dict)
|
|
158
|
+
#: One of CODES. Empty when `ok`.
|
|
159
|
+
code: str = OK
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def done(message: str, **data: object) -> Outcome:
|
|
163
|
+
"""It worked. `data` carries whatever the caller may want to render."""
|
|
164
|
+
return Outcome(ok=True, message=message, data=data, code=OK)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def refused(code: str, message: str, **data: object) -> Outcome:
|
|
168
|
+
"""It did not work, and this is why — in a word a front end can map and a sentence a human can
|
|
169
|
+
read. Both are required: the word alone gives an operator nothing, and the sentence alone makes
|
|
170
|
+
every front end parse prose to decide a status."""
|
|
171
|
+
if code not in CODES or code == OK:
|
|
172
|
+
raise ValueError(f"unknown outcome code {code!r} — one of {sorted(CODES - {OK})}")
|
|
173
|
+
return Outcome(ok=False, message=message, data=data, code=code)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── the table ────────────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
#: The signature every action has. Keyword-only, always async, always returns an Outcome.
|
|
179
|
+
Runner = Callable[..., Awaitable[Outcome]]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
#: WHAT TO PUT IN EACH PARAMETER, keyed by its name — the vocabulary of the action layer (#172).
|
|
183
|
+
#:
|
|
184
|
+
#: SHARED RATHER THAN PER ROW, because `project` is required by 38 of the 40 rows and `issue` by
|
|
185
|
+
#: 12. Forty copies of "the project this concerns" is thirty-nine chances to drift, and the one
|
|
186
|
+
#: that drifts is the one somebody reads. A row that means something DIFFERENT by a shared word
|
|
187
|
+
#: says so on itself, in `ActionSpec.params`, and that override is the signal to a reader that this
|
|
188
|
+
#: row is unusual.
|
|
189
|
+
#:
|
|
190
|
+
#: THE AUDIENCE IS SOMEBODY WHO MUST FILL THE VALUE IN, which is why these say what goes in rather
|
|
191
|
+
#: than what the word denotes. Since #170 that audience includes a model composing a proposal for a
|
|
192
|
+
#: button a human presses: `adjust` required an `instruction` and nothing anywhere said what an
|
|
193
|
+
#: instruction should contain, so the only guidance was the English of the word.
|
|
194
|
+
PARAMS: dict[str, str] = {
|
|
195
|
+
# the two nearly every row takes
|
|
196
|
+
"project": "the project's name in this deployment's registry, e.g. `podbeam`",
|
|
197
|
+
"issue": "the ticket's number on the client's board, digits only, e.g. `87`",
|
|
198
|
+
# confirmation and shape
|
|
199
|
+
"yes": "`true` to actually do it — without it the action reports what it WOULD do",
|
|
200
|
+
"number": "the ticket's number on the client's board, digits only",
|
|
201
|
+
"numbers": "the ticket numbers, comma-separated, e.g. `87,88,91`",
|
|
202
|
+
"limit": "how many rows to return at most; the default is this deployment's",
|
|
203
|
+
"force": "`true` to proceed past a check that would otherwise stop it — say why in `reason`",
|
|
204
|
+
# words a human will read afterwards
|
|
205
|
+
"reason": "one sentence a person will read later, saying WHY — it is stored and shown",
|
|
206
|
+
"comment": "a sentence posted on the pull request as this platform's own voice",
|
|
207
|
+
"instruction": "WHAT TO CHANGE, in one or two concrete sentences the coding pass will work "
|
|
208
|
+
"from: name the file or behaviour and the change wanted, not the symptom. It "
|
|
209
|
+
"is shown on the button a person presses, so it must be readable by them too",
|
|
210
|
+
"message": "what you want to say, in your own words",
|
|
211
|
+
"question": "the question, in one sentence",
|
|
212
|
+
"query": "what to look for — a few words, a card number, a name",
|
|
213
|
+
"answer": "the answer, in your own words",
|
|
214
|
+
"answers": "answers to the questions asked, one per line as `field: value`",
|
|
215
|
+
"choice": "which of the options the parked job offered — its exact label",
|
|
216
|
+
"enabled": "`true` to let this project pick work up, `false` to hold it",
|
|
217
|
+
# the release gate
|
|
218
|
+
"version": "the version being released, exactly as it is tagged",
|
|
219
|
+
"approver": "who is approving, by name — it is written into the release record",
|
|
220
|
+
"password": "the release password this deployment was configured with",
|
|
221
|
+
"promote": "`true` to carry the change on through the promotion chain after it lands",
|
|
222
|
+
"sandbox": "`true` to run the job without letting it write anywhere real",
|
|
223
|
+
"durable": "`true` to run on the durable engine rather than in this process",
|
|
224
|
+
# the product role
|
|
225
|
+
"token": "the token identifying the proposal being answered — copy it from the proposal",
|
|
226
|
+
"thread": "the conversation this turn belongs to, if continuing one",
|
|
227
|
+
"in_favour_of": "the number of the card that STAYS — the one this duplicates",
|
|
228
|
+
"requirement": "the requirement's id in its register, e.g. `REQ-014`",
|
|
229
|
+
"decision": "the decision, stated as what will now be true",
|
|
230
|
+
"term": "the word or phrase being defined, as the business says it",
|
|
231
|
+
"body": "what it means, in the business's own words",
|
|
232
|
+
"restated": "the broken promise, restated as what should happen and what happens instead",
|
|
233
|
+
"title": "what the card is called, in the person's own words — short",
|
|
234
|
+
"column": "the column's name as it is written on the board, e.g. `TO-DO`",
|
|
235
|
+
"violates": "the id of the requirement this breaks, if one is known",
|
|
236
|
+
"severity": "how bad it is: `low`, `medium` or `high`",
|
|
237
|
+
# reading a repository
|
|
238
|
+
"target": "the repository to read, as a URL or an `owner/name`",
|
|
239
|
+
"ask": "a specific question to answer about the repository, if you have one",
|
|
240
|
+
"write": "`true` to write the result into the repository rather than only report it",
|
|
241
|
+
"accept": "which proposed fields to keep, comma-separated — the rest are dropped",
|
|
242
|
+
"out": "where to write the file, if not the default place",
|
|
243
|
+
"pr": "`true` to open a pull request with the change instead of committing directly",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class ActionSpec:
|
|
249
|
+
"""One row of the catalog: a name, what it needs, and the one implementation.
|
|
250
|
+
|
|
251
|
+
`required`/`optional` are declared rather than read off the function signature by
|
|
252
|
+
introspection. Introspection would be shorter and would also mean `perform` cannot tell a
|
|
253
|
+
parameter it forgot to pass from one the action does not want — and the whole reason the
|
|
254
|
+
parameters are checked centrally is that the two front ends validated different things.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
name: str
|
|
258
|
+
#: One line, present tense, for `openfactory act --list` and `GET /api/actions`. This is the
|
|
259
|
+
#: text an operator reads when deciding what to run, so it says what happens, not what it is
|
|
260
|
+
#: called.
|
|
261
|
+
summary: str
|
|
262
|
+
run: Runner
|
|
263
|
+
required: tuple[str, ...] = ()
|
|
264
|
+
optional: tuple[str, ...] = ()
|
|
265
|
+
#: Whether `Actor.admin` is required. Default True: an action exists to CHANGE something, and
|
|
266
|
+
#: the read-only ones say so explicitly. A default of False would mean a new action is
|
|
267
|
+
#: unguarded unless somebody remembers, which is the wrong way round for this class of mistake.
|
|
268
|
+
needs_admin: bool = True
|
|
269
|
+
#: WHICH AREA of the platform this row belongs to — see `Actor.scopes`. Defaults to the floor
|
|
270
|
+
#: (jobs, the board, merges, the engine), so a row added without thinking about scope lands in
|
|
271
|
+
#: the area a scoped credential CANNOT reach. Same direction as `needs_admin`'s default: the
|
|
272
|
+
#: forgetful case is the closed one.
|
|
273
|
+
scope: str = FLOOR
|
|
274
|
+
#: WHEN A PROPOSER SHOULD PICK THIS ROW over its neighbours — the judgment, not the definition
|
|
275
|
+
#: (#172). `summary` says what the row does; this says when it is the right answer, which is a
|
|
276
|
+
#: different question and the one that actually decides. It lived in a private map inside
|
|
277
|
+
#: `techlead/conversation.py`, where it could disagree with the row it described — and did:
|
|
278
|
+
#: `adjust` had no entry at all, so the verb that most needed the guidance had the least.
|
|
279
|
+
#: Empty is honest for the rows nobody may propose; `proposable` is a much smaller set than
|
|
280
|
+
#: the catalogue and only it is offered as a choice.
|
|
281
|
+
choose_when: str = ""
|
|
282
|
+
#: WHAT THIS ROW means by a parameter, when the shared `PARAMS` word is wrong for it. An
|
|
283
|
+
#: override rather than a copy: a row that agrees says nothing, so the diff of this field is a
|
|
284
|
+
#: list of the rows that are unusual.
|
|
285
|
+
params: Mapping[str, str] = MappingProxyType({})
|
|
286
|
+
|
|
287
|
+
def prose_for(self, param: str) -> str:
|
|
288
|
+
"""What to put in `param`, or `""` when nobody has said (#172).
|
|
289
|
+
|
|
290
|
+
THREE ANSWERS COLLAPSED TO TWO ON PURPOSE, unlike the reads elsewhere in the platform: a
|
|
291
|
+
parameter nobody wrote prose for and a parameter that needs no explanation are the same
|
|
292
|
+
thing to the person filling it in. What must NOT happen is inventing a sentence from the
|
|
293
|
+
name, which is what every front end would do on its own."""
|
|
294
|
+
return self.params.get(param) or PARAMS.get(param, "")
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def described(self) -> dict[str, str]:
|
|
298
|
+
"""Every parameter this row takes, each with what to put in it — the shape a front end
|
|
299
|
+
renders and `/api/actions` serves. Includes the ones with no prose, as empty strings: a
|
|
300
|
+
parameter missing from this mapping would read as one the action does not take."""
|
|
301
|
+
return {p: self.prose_for(p) for p in self.parameters}
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def pending(self) -> str:
|
|
305
|
+
"""Where the implementation still lives, or `""` when it has been moved into the layer.
|
|
306
|
+
|
|
307
|
+
Not a boolean: an operator who hits `unimplemented` needs to know which old route still
|
|
308
|
+
works, and a test needs to see the migration shrink."""
|
|
309
|
+
return getattr(self.run, "moved_from", "")
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def parameters(self) -> tuple[str, ...]:
|
|
313
|
+
return self.required + self.optional
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def not_moved_yet(name: str, *, still_in: str) -> Runner:
|
|
317
|
+
"""A placeholder implementation that REFUSES, naming where the working path is.
|
|
318
|
+
|
|
319
|
+
WHY A REFUSING RUNNER RATHER THAN AN ABSENT ROW. The catalog is what the guards walk, what the
|
|
320
|
+
panel lists and what `openfactory act --list` prints. An action left out of it until somebody
|
|
321
|
+
moves the code is invisible: no test can assert it is missing, and the migration has no
|
|
322
|
+
measurable end. A row that refuses is visible in all three places and its refusal is a sentence
|
|
323
|
+
somebody can act on, which is the same standard every other wait in this platform is held to.
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
async def run(**_params: object) -> Outcome:
|
|
327
|
+
return refused(
|
|
328
|
+
UNIMPLEMENTED,
|
|
329
|
+
f"'{name}' is catalogued but its implementation has not moved into the action layer "
|
|
330
|
+
f"yet — it still lives in {still_in}, which is where it works today (C-23).",
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
run.moved_from = still_in # type: ignore[attr-defined]
|
|
334
|
+
return run
|