@kody-ade/kody-engine 0.4.222 → 0.4.224
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.
- package/README.md +6 -5
- package/dist/bin/kody.js +938 -656
- package/dist/duties/npm-publish/duty.md +17 -0
- package/dist/duties/npm-publish/profile.json +6 -0
- package/dist/executables/goal-scheduler/scheduler.sh +0 -0
- package/dist/executables/npm-publish/profile.json +73 -0
- package/dist/executables/npm-publish/publish.sh +93 -0
- package/dist/executables/preview-health/profile.json +48 -0
- package/dist/executables/preview-health/tick.sh +319 -0
- package/dist/executables/release/prepare.sh +4 -2
- package/dist/executables/release/release.sh +94 -47
- package/dist/executables/release-deploy/deploy.sh +0 -0
- package/dist/executables/release-prepare/prepare.sh +0 -0
- package/dist/executables/release-publish/publish.sh +0 -0
- package/dist/executables/resolve/apply-prefer.sh +0 -0
- package/dist/executables/revert/revert.sh +0 -0
- package/kody.config.schema.json +5 -0
- package/package.json +23 -22
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# NPM Publish
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Publish current `package.json` version to npm from the repository CI sandbox.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Store npm automation token as `NPM_TOKEN` in `.kody/secrets.enc`.
|
|
10
|
+
- Kody must load that secret into the `NPM_TOKEN` environment variable before the executable runs.
|
|
11
|
+
- The executable must not read or decrypt `.kody/secrets.enc` directly.
|
|
12
|
+
- Run only after version in `package.json` is ready to publish.
|
|
13
|
+
|
|
14
|
+
## Instructions
|
|
15
|
+
|
|
16
|
+
Use the `npm-publish` executable for the implementation details.
|
|
17
|
+
The duty owns the public action name and the reason this action exists; the executable owns the method.
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "npm-publish",
|
|
3
|
+
"role": "utility",
|
|
4
|
+
"phase": "shipped",
|
|
5
|
+
"describe": "Publish the current package.json version to npm using NPM_TOKEN. No agent.",
|
|
6
|
+
"inputs": [
|
|
7
|
+
{
|
|
8
|
+
"name": "dry-run",
|
|
9
|
+
"flag": "--dry-run",
|
|
10
|
+
"type": "bool",
|
|
11
|
+
"required": false,
|
|
12
|
+
"describe": "Print the publish plan without requiring NPM_TOKEN or publishing."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "tag",
|
|
16
|
+
"flag": "--tag",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"required": false,
|
|
19
|
+
"describe": "npm dist-tag to publish under. Defaults to latest."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"name": "access",
|
|
23
|
+
"flag": "--access",
|
|
24
|
+
"type": "enum",
|
|
25
|
+
"values": ["public", "restricted"],
|
|
26
|
+
"required": false,
|
|
27
|
+
"describe": "npm package access. Defaults to public."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "issue",
|
|
31
|
+
"flag": "--issue",
|
|
32
|
+
"type": "int",
|
|
33
|
+
"required": false,
|
|
34
|
+
"describe": "Issue/PR number to post terminal notice on."
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"claudeCode": {
|
|
38
|
+
"model": "inherit",
|
|
39
|
+
"permissionMode": "acceptEdits",
|
|
40
|
+
"maxTurns": 0,
|
|
41
|
+
"maxThinkingTokens": null,
|
|
42
|
+
"systemPromptAppend": null,
|
|
43
|
+
"tools": [],
|
|
44
|
+
"hooks": [],
|
|
45
|
+
"skills": [],
|
|
46
|
+
"commands": [],
|
|
47
|
+
"subagents": [],
|
|
48
|
+
"plugins": [],
|
|
49
|
+
"mcpServers": []
|
|
50
|
+
},
|
|
51
|
+
"cliTools": [],
|
|
52
|
+
"inputArtifacts": [],
|
|
53
|
+
"outputArtifacts": [],
|
|
54
|
+
"scripts": {
|
|
55
|
+
"preflight": [
|
|
56
|
+
{ "script": "setCommentTarget", "with": { "type": "issue" } },
|
|
57
|
+
{ "script": "loadTaskState" },
|
|
58
|
+
{ "shell": "publish.sh", "timeoutSec": 900 },
|
|
59
|
+
{ "script": "skipAgent" }
|
|
60
|
+
],
|
|
61
|
+
"postflight": [
|
|
62
|
+
{ "script": "recordOutcome" },
|
|
63
|
+
{ "script": "saveTaskState" },
|
|
64
|
+
{ "script": "notifyTerminal", "with": { "label": "npm publish" } }
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
"output": {
|
|
68
|
+
"actionTypes": [
|
|
69
|
+
"NPM_PUBLISH_COMPLETED",
|
|
70
|
+
"NPM_PUBLISH_FAILED"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# npm-publish: publish the current package.json version to npm.
|
|
4
|
+
#
|
|
5
|
+
# Secrets:
|
|
6
|
+
# - NPM_TOKEN must be present in the environment.
|
|
7
|
+
# Kody loads secrets before executables run; this script must not read or
|
|
8
|
+
# decrypt .kody/secrets.enc directly.
|
|
9
|
+
#
|
|
10
|
+
# Inputs:
|
|
11
|
+
# - KODY_ARG_DRY_RUN true|false
|
|
12
|
+
# - KODY_ARG_TAG npm dist-tag, default latest
|
|
13
|
+
# - KODY_ARG_ACCESS public|restricted, default public
|
|
14
|
+
#
|
|
15
|
+
# Stdout markers:
|
|
16
|
+
# - KODY_REASON=<text>
|
|
17
|
+
# - KODY_SKIP_AGENT=true
|
|
18
|
+
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
dry_run="${KODY_ARG_DRY_RUN:-false}"
|
|
22
|
+
tag="${KODY_ARG_TAG:-latest}"
|
|
23
|
+
access="${KODY_ARG_ACCESS:-public}"
|
|
24
|
+
registry="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org/}"
|
|
25
|
+
|
|
26
|
+
fail() {
|
|
27
|
+
echo "KODY_REASON=$1"
|
|
28
|
+
echo "KODY_SKIP_AGENT=true"
|
|
29
|
+
exit "${2:-1}"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
json_eval() {
|
|
33
|
+
node -e "$1"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[[ -f package.json ]] || fail "npm publish: package.json not found" 99
|
|
37
|
+
|
|
38
|
+
pkg_name="$(json_eval "const p=require('./package.json'); if(!p.name) process.exit(1); console.log(p.name)")" \
|
|
39
|
+
|| fail "npm publish: package.json missing name" 99
|
|
40
|
+
pkg_version="$(json_eval "const p=require('./package.json'); if(!p.version) process.exit(1); console.log(p.version)")" \
|
|
41
|
+
|| fail "npm publish: package.json missing version" 99
|
|
42
|
+
|
|
43
|
+
if [[ "$access" != "public" && "$access" != "restricted" ]]; then
|
|
44
|
+
fail "npm publish: --access must be public or restricted" 64
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
echo "→ npm publish: ${pkg_name}@${pkg_version} tag=${tag} access=${access}"
|
|
48
|
+
|
|
49
|
+
if [[ "$dry_run" == "true" ]]; then
|
|
50
|
+
echo "KODY_REASON=dry-run — would publish ${pkg_name}@${pkg_version} to npm with tag ${tag}"
|
|
51
|
+
echo "KODY_SKIP_AGENT=true"
|
|
52
|
+
exit 0
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
[[ -n "${NPM_TOKEN:-}" ]] || fail "npm publish: missing NPM_TOKEN secret" 64
|
|
56
|
+
|
|
57
|
+
tmp_npmrc="$(mktemp)"
|
|
58
|
+
auth_host="${registry#http://}"
|
|
59
|
+
auth_host="${auth_host#https://}"
|
|
60
|
+
auth_host="${auth_host%/}"
|
|
61
|
+
cleanup() {
|
|
62
|
+
rm -f "$tmp_npmrc"
|
|
63
|
+
}
|
|
64
|
+
trap cleanup EXIT
|
|
65
|
+
|
|
66
|
+
chmod 600 "$tmp_npmrc"
|
|
67
|
+
printf '%s\n' "registry=${registry}" >"$tmp_npmrc"
|
|
68
|
+
printf '%s\n' "//${auth_host}/:_authToken=${NPM_TOKEN}" >>"$tmp_npmrc"
|
|
69
|
+
|
|
70
|
+
export NODE_AUTH_TOKEN="$NPM_TOKEN"
|
|
71
|
+
export NPM_CONFIG_USERCONFIG="$tmp_npmrc"
|
|
72
|
+
export HUSKY=0
|
|
73
|
+
export SKIP_HOOKS=1
|
|
74
|
+
export CI="${CI:-1}"
|
|
75
|
+
|
|
76
|
+
if npm view "${pkg_name}@${pkg_version}" version --registry "$registry" >/dev/null 2>&1; then
|
|
77
|
+
echo "KODY_REASON=${pkg_name}@${pkg_version} is already published"
|
|
78
|
+
echo "KODY_SKIP_AGENT=true"
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
publish_args=(publish --access "$access" --tag "$tag" --registry "$registry")
|
|
83
|
+
|
|
84
|
+
if [[ -f pnpm-lock.yaml && -x "$(command -v pnpm)" ]]; then
|
|
85
|
+
echo " publish: pnpm ${publish_args[*]}"
|
|
86
|
+
pnpm "${publish_args[@]}"
|
|
87
|
+
else
|
|
88
|
+
echo " publish: npm ${publish_args[*]}"
|
|
89
|
+
npm "${publish_args[@]}"
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
echo "KODY_REASON=published ${pkg_name}@${pkg_version} to npm with tag ${tag}"
|
|
93
|
+
echo "KODY_SKIP_AGENT=true"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "preview-health",
|
|
3
|
+
"role": "watch",
|
|
4
|
+
"kind": "oneshot",
|
|
5
|
+
"describe": "Scan open PRs and dispatch mechanical preview repairs.",
|
|
6
|
+
"inputs": [],
|
|
7
|
+
"claudeCode": {
|
|
8
|
+
"model": "inherit",
|
|
9
|
+
"permissionMode": "default",
|
|
10
|
+
"maxTurns": 0,
|
|
11
|
+
"maxThinkingTokens": null,
|
|
12
|
+
"systemPromptAppend": null,
|
|
13
|
+
"tools": [],
|
|
14
|
+
"hooks": [],
|
|
15
|
+
"skills": [],
|
|
16
|
+
"commands": [],
|
|
17
|
+
"subagents": [],
|
|
18
|
+
"plugins": [],
|
|
19
|
+
"mcpServers": []
|
|
20
|
+
},
|
|
21
|
+
"cliTools": [
|
|
22
|
+
{
|
|
23
|
+
"name": "gh",
|
|
24
|
+
"install": {
|
|
25
|
+
"required": true,
|
|
26
|
+
"checkCommand": "command -v gh"
|
|
27
|
+
},
|
|
28
|
+
"verify": "gh auth status",
|
|
29
|
+
"usage": "Reads open PRs, reads the CTO trust ledger, dispatches Kody workflows, and posts PR audit/recommendation comments.",
|
|
30
|
+
"allowedUses": ["pr", "api", "issue", "workflow"]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"inputArtifacts": [],
|
|
34
|
+
"outputArtifacts": [],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"preflight": [
|
|
37
|
+
{
|
|
38
|
+
"shell": "tick.sh",
|
|
39
|
+
"with": {
|
|
40
|
+
"jobsDir": ".kody/duties",
|
|
41
|
+
"duty": "preview-health"
|
|
42
|
+
},
|
|
43
|
+
"timeoutSec": 300
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"postflight": []
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
jobs_dir="${1:-.kody/duties}"
|
|
5
|
+
duty="${2:-preview-health}"
|
|
6
|
+
|
|
7
|
+
export KODY_PREVIEW_HEALTH_JOBS_DIR="$jobs_dir"
|
|
8
|
+
export KODY_PREVIEW_HEALTH_DUTY="$duty"
|
|
9
|
+
|
|
10
|
+
python3 <<'PY'
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
DECISIONS_LABEL = "kody:cto-decisions"
|
|
20
|
+
LEDGER_START = "<!-- kody-cto-decisions:start -->"
|
|
21
|
+
LEDGER_END = "<!-- kody-cto-decisions:end -->"
|
|
22
|
+
VERBS = ("fix-ci", "sync", "resolve")
|
|
23
|
+
AUTO_VERBS = {"resolve"}
|
|
24
|
+
MAX_ACTIONS_PER_TICK = 5
|
|
25
|
+
FAIL_CONCLUSIONS = {"FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"}
|
|
26
|
+
RUNNING_STATUSES = {"IN_PROGRESS", "QUEUED"}
|
|
27
|
+
STALE_THRESHOLD = 10
|
|
28
|
+
STATE_BRANCH = "kody-state"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log(message):
|
|
32
|
+
print(f"[preview-health] {message}", file=sys.stderr)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def now_iso():
|
|
36
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_gh(args, input_text=None, check=True):
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["gh", *args],
|
|
42
|
+
input=input_text,
|
|
43
|
+
text=True,
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
cwd=os.getcwd(),
|
|
47
|
+
)
|
|
48
|
+
if check and result.returncode != 0:
|
|
49
|
+
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"gh {' '.join(args)} failed")
|
|
50
|
+
return result.stdout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def repo_slug():
|
|
54
|
+
owner = os.environ.get("KODY_CFG_GITHUB_OWNER", "").strip()
|
|
55
|
+
repo = os.environ.get("KODY_CFG_GITHUB_REPO", "").strip()
|
|
56
|
+
if owner and repo:
|
|
57
|
+
return f"{owner}/{repo}"
|
|
58
|
+
return run_gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]).strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_branch(slug):
|
|
62
|
+
configured = os.environ.get("KODY_CFG_GIT_DEFAULTBRANCH", "").strip()
|
|
63
|
+
if configured:
|
|
64
|
+
return configured
|
|
65
|
+
return run_gh(["repo", "view", slug, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]).strip()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ensure_state_branch(slug):
|
|
69
|
+
if run_gh(["api", f"/repos/{slug}/git/ref/heads/{STATE_BRANCH}"], check=False).strip():
|
|
70
|
+
return
|
|
71
|
+
branch = default_branch(slug)
|
|
72
|
+
sha = run_gh(["api", f"/repos/{slug}/git/ref/heads/{branch}", "--jq", ".object.sha"]).strip()
|
|
73
|
+
payload = json.dumps({"ref": f"refs/heads/{STATE_BRANCH}", "sha": sha})
|
|
74
|
+
run_gh(["api", "--method", "POST", f"/repos/{slug}/git/refs", "--input", "-"], input_text=payload)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def initial_state():
|
|
78
|
+
return {"version": 1, "rev": 0, "cursor": "seed", "data": {}, "done": False}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_state(slug, path):
|
|
82
|
+
result = run_gh(
|
|
83
|
+
["api", f"/repos/{slug}/contents/{path}?ref={STATE_BRANCH}"],
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
if not result.strip():
|
|
87
|
+
return {"path": path, "sha": None, "state": initial_state(), "created": True}
|
|
88
|
+
obj = json.loads(result)
|
|
89
|
+
raw = base64.b64decode(obj["content"]).decode("utf-8")
|
|
90
|
+
return {"path": path, "sha": obj.get("sha"), "state": json.loads(raw), "created": False}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def state_unchanged(prev, next_state):
|
|
94
|
+
return (
|
|
95
|
+
prev.get("cursor") == next_state.get("cursor")
|
|
96
|
+
and prev.get("done") == next_state.get("done")
|
|
97
|
+
and prev.get("data") == next_state.get("data")
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def save_state(slug, loaded, next_state):
|
|
102
|
+
if not loaded["created"] and state_unchanged(loaded["state"], next_state):
|
|
103
|
+
return False
|
|
104
|
+
ensure_state_branch(slug)
|
|
105
|
+
body = json.dumps(next_state, indent=2) + "\n"
|
|
106
|
+
payload = {
|
|
107
|
+
"message": f"chore(jobs): update state for {os.environ['KODY_PREVIEW_HEALTH_DUTY']} (rev {next_state['rev']})",
|
|
108
|
+
"content": base64.b64encode(body.encode("utf-8")).decode("ascii"),
|
|
109
|
+
"branch": STATE_BRANCH,
|
|
110
|
+
}
|
|
111
|
+
if loaded.get("sha"):
|
|
112
|
+
payload["sha"] = loaded["sha"]
|
|
113
|
+
result = run_gh(
|
|
114
|
+
["api", "--method", "PUT", f"/repos/{slug}/contents/{loaded['path']}", "--input", "-"],
|
|
115
|
+
input_text=json.dumps(payload),
|
|
116
|
+
check=False,
|
|
117
|
+
)
|
|
118
|
+
if result.strip():
|
|
119
|
+
return True
|
|
120
|
+
reloaded = load_state(slug, loaded["path"])
|
|
121
|
+
if not reloaded["created"] and state_unchanged(reloaded["state"], next_state):
|
|
122
|
+
return False
|
|
123
|
+
if reloaded.get("sha"):
|
|
124
|
+
payload["sha"] = reloaded["sha"]
|
|
125
|
+
else:
|
|
126
|
+
payload.pop("sha", None)
|
|
127
|
+
run_gh(["api", "--method", "PUT", f"/repos/{slug}/contents/{loaded['path']}", "--input", "-"], input_text=json.dumps(payload))
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_ledger_modes():
|
|
132
|
+
modes = {verb: "ask" for verb in VERBS}
|
|
133
|
+
try:
|
|
134
|
+
raw = run_gh(["issue", "list", "--label", DECISIONS_LABEL, "--state", "open", "--limit", "20", "--json", "number,body"])
|
|
135
|
+
issues = json.loads(raw or "[]")
|
|
136
|
+
if not issues:
|
|
137
|
+
return modes
|
|
138
|
+
body = sorted(issues, key=lambda x: x.get("number", 10**9))[0].get("body", "")
|
|
139
|
+
if LEDGER_START not in body or LEDGER_END not in body:
|
|
140
|
+
return modes
|
|
141
|
+
inner = body.split(LEDGER_START, 1)[1].split(LEDGER_END, 1)[0]
|
|
142
|
+
match = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", inner)
|
|
143
|
+
if not match:
|
|
144
|
+
return modes
|
|
145
|
+
ledger = json.loads(match.group(1))
|
|
146
|
+
cto = ledger.get("staff", {}).get("cto", {})
|
|
147
|
+
for verb in VERBS:
|
|
148
|
+
if cto.get(verb, {}).get("mode") == "auto":
|
|
149
|
+
modes[verb] = "auto"
|
|
150
|
+
except Exception as err:
|
|
151
|
+
log(f"ledger read failed; treating all verbs as ask: {err}")
|
|
152
|
+
return modes
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def ci_failing(rollup):
|
|
156
|
+
if not isinstance(rollup, list):
|
|
157
|
+
return False
|
|
158
|
+
has_fail = any(str(check.get("conclusion", "")) in FAIL_CONCLUSIONS for check in rollup if isinstance(check, dict))
|
|
159
|
+
running = any(str(check.get("status", "")) in RUNNING_STATUSES for check in rollup if isinstance(check, dict))
|
|
160
|
+
return has_fail and not running
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def behind_by(slug, base, head):
|
|
164
|
+
try:
|
|
165
|
+
raw = run_gh(["api", f"repos/{slug}/compare/{base}...{head}", "--jq", ".behind_by"])
|
|
166
|
+
return int(raw.strip() or "0")
|
|
167
|
+
except Exception as err:
|
|
168
|
+
log(f"compare {base}...{head} failed: {err}")
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def detect_repair(slug, pr):
|
|
173
|
+
mergeable = str(pr.get("mergeable") or "").upper()
|
|
174
|
+
if mergeable == "UNKNOWN":
|
|
175
|
+
return ("defer", f"PR #{pr['number']} mergeability still UNKNOWN; retry next tick.")
|
|
176
|
+
if mergeable == "CONFLICTING":
|
|
177
|
+
return ("resolve", f"PR #{pr['number']} has merge conflicts with `{pr['baseRefName']}`.")
|
|
178
|
+
if ci_failing(pr.get("statusCheckRollup")):
|
|
179
|
+
return ("fix-ci", f"PR #{pr['number']} has failing CI checks.")
|
|
180
|
+
drift = behind_by(slug, pr["baseRefName"], pr["headRefName"])
|
|
181
|
+
if drift >= STALE_THRESHOLD:
|
|
182
|
+
return ("sync", f"PR #{pr['number']}'s branch is {drift} commits behind `{pr['baseRefName']}`.")
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def duty_operator(jobs_dir, duty):
|
|
187
|
+
try:
|
|
188
|
+
with open(os.path.join(os.getcwd(), jobs_dir, duty, "profile.json"), "r", encoding="utf-8") as f:
|
|
189
|
+
profile = json.load(f)
|
|
190
|
+
mentions = profile.get("mentions", [])
|
|
191
|
+
return mentions[0] if isinstance(mentions, list) and mentions else ""
|
|
192
|
+
except Exception:
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def post_comment(pr_number, body):
|
|
197
|
+
if os.environ.get("KODY_DRY_RUN") == "1":
|
|
198
|
+
log(f"[dry-run] would comment on #{pr_number}: {body.splitlines()[0]}")
|
|
199
|
+
return True
|
|
200
|
+
try:
|
|
201
|
+
run_gh(["pr", "comment", str(pr_number), "--body", body])
|
|
202
|
+
return True
|
|
203
|
+
except Exception as err:
|
|
204
|
+
log(f"comment failed on #{pr_number}: {err}")
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def recommend(pr_number, verb, reason, operator):
|
|
209
|
+
mention = f"@{operator} " if operator else ""
|
|
210
|
+
body = (
|
|
211
|
+
f"{mention}**CTO recommendation** - `{verb}`\n\n"
|
|
212
|
+
f"{reason} Recommended action: `{verb}` for PR #{pr_number}.\n\n"
|
|
213
|
+
"_Confirm in dashboard inbox or run the action manually. CTO will not act on its own._"
|
|
214
|
+
)
|
|
215
|
+
return post_comment(pr_number, body)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def auto_run(pr_number, verb, reason):
|
|
219
|
+
if os.environ.get("KODY_DRY_RUN") == "1":
|
|
220
|
+
log(f"[dry-run] would dispatch kody.yml executable={verb} issue_number={pr_number}")
|
|
221
|
+
else:
|
|
222
|
+
try:
|
|
223
|
+
run_gh(["workflow", "run", "kody.yml", "-f", f"executable={verb}", "-f", f"issue_number={pr_number}"])
|
|
224
|
+
except Exception as err:
|
|
225
|
+
log(f"workflow_dispatch failed #{pr_number} ({verb}): {err}")
|
|
226
|
+
return False
|
|
227
|
+
auto_reason = (
|
|
228
|
+
"Policy: preview-health auto-runs `resolve` for merge conflicts."
|
|
229
|
+
if verb in AUTO_VERBS
|
|
230
|
+
else f"Graduated: operator approved `{verb}` repeatedly. A **Reject** on any `{verb}` returns me asking."
|
|
231
|
+
)
|
|
232
|
+
return post_comment(pr_number, f"**CTO action** - `{verb}` dispatched\n\n{reason}\n\n{auto_reason}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def print_row(pr, verb, fp, action, note):
|
|
236
|
+
print(f"| #{pr} | {verb} | {fp} | {action} | {note} |")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main():
|
|
240
|
+
slug = repo_slug()
|
|
241
|
+
jobs_dir = os.environ["KODY_PREVIEW_HEALTH_JOBS_DIR"].rstrip("/")
|
|
242
|
+
duty = os.environ["KODY_PREVIEW_HEALTH_DUTY"]
|
|
243
|
+
state_path = f"{jobs_dir}/{duty}/state.json"
|
|
244
|
+
loaded = load_state(slug, state_path)
|
|
245
|
+
modes = read_ledger_modes()
|
|
246
|
+
operator = duty_operator(jobs_dir, duty)
|
|
247
|
+
|
|
248
|
+
prs = json.loads(
|
|
249
|
+
run_gh([
|
|
250
|
+
"pr",
|
|
251
|
+
"list",
|
|
252
|
+
"--state",
|
|
253
|
+
"open",
|
|
254
|
+
"--limit",
|
|
255
|
+
"100",
|
|
256
|
+
"--json",
|
|
257
|
+
"number,title,headRefName,headRefOid,baseRefName,isDraft,mergeable,statusCheckRollup,updatedAt",
|
|
258
|
+
])
|
|
259
|
+
or "[]"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
prior = loaded["state"].get("data", {}).get("prs", {})
|
|
263
|
+
open_numbers = {str(pr["number"]) for pr in prs}
|
|
264
|
+
next_prs = {key: value for key, value in prior.items() if key in open_numbers and isinstance(value, dict)}
|
|
265
|
+
|
|
266
|
+
print("| PR | verb | fingerprint | action | note |")
|
|
267
|
+
print("|----|------|-------------|--------|------|")
|
|
268
|
+
|
|
269
|
+
priority = {"resolve": 0, "fix-ci": 1, "sync": 2}
|
|
270
|
+
queue = []
|
|
271
|
+
for pr in prs:
|
|
272
|
+
if pr.get("isDraft"):
|
|
273
|
+
print_row(pr["number"], "-", "-", "skip", "draft")
|
|
274
|
+
continue
|
|
275
|
+
repair = detect_repair(slug, pr)
|
|
276
|
+
if not repair:
|
|
277
|
+
print_row(pr["number"], "-", "-", "skip", "healthy")
|
|
278
|
+
continue
|
|
279
|
+
verb, reason = repair
|
|
280
|
+
if verb == "defer":
|
|
281
|
+
print_row(pr["number"], "-", "-", "defer", "mergeable=UNKNOWN")
|
|
282
|
+
continue
|
|
283
|
+
queue.append((priority[verb], pr["number"], pr, verb, reason))
|
|
284
|
+
|
|
285
|
+
actions_taken = 0
|
|
286
|
+
for _, number, pr, verb, reason in sorted(queue):
|
|
287
|
+
key = str(number)
|
|
288
|
+
fp = f"{verb}|{pr.get('headRefOid', '')}"
|
|
289
|
+
graduated = verb in AUTO_VERBS or modes.get(verb) == "auto"
|
|
290
|
+
intended_stage = f"{verb}-auto" if graduated else f"{verb}-recommended"
|
|
291
|
+
prev = next_prs.get(key, {})
|
|
292
|
+
if prev.get("fp") == fp and prev.get("stage") == intended_stage:
|
|
293
|
+
print_row(number, verb, fp[:24], "skip", "dedup")
|
|
294
|
+
continue
|
|
295
|
+
if actions_taken >= MAX_ACTIONS_PER_TICK:
|
|
296
|
+
print_row(number, verb, fp[:24], "defer", "tick cap")
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
ok = auto_run(number, verb, reason) if graduated else recommend(number, verb, reason, operator)
|
|
300
|
+
action = "auto-ran" if graduated and ok else "auto-failed" if graduated else "recommended" if ok else "recommend-failed"
|
|
301
|
+
if ok:
|
|
302
|
+
actions_taken += 1
|
|
303
|
+
next_prs[key] = {"fp": fp, "stage": intended_stage, "lastActAt": now_iso()}
|
|
304
|
+
print_row(number, verb, fp[:24], action, "auto" if graduated else "advisory")
|
|
305
|
+
|
|
306
|
+
next_state = {
|
|
307
|
+
"version": 1,
|
|
308
|
+
"rev": int(loaded["state"].get("rev", 0)) + 1,
|
|
309
|
+
"cursor": "idle",
|
|
310
|
+
"data": {"prs": next_prs, "lastFiredAt": now_iso()},
|
|
311
|
+
"done": False,
|
|
312
|
+
}
|
|
313
|
+
save_state(slug, loaded, next_state)
|
|
314
|
+
log(f"tick complete: {actions_taken} action(s), {len(next_prs)} tracked PR(s)")
|
|
315
|
+
print("KODY_SKIP_AGENT=true")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
main()
|
|
319
|
+
PY
|
|
@@ -261,8 +261,10 @@ open_prepare_pr() {
|
|
|
261
261
|
fi
|
|
262
262
|
|
|
263
263
|
# Bump version files.
|
|
264
|
-
local files
|
|
265
|
-
|
|
264
|
+
local files=()
|
|
265
|
+
while IFS= read -r f; do
|
|
266
|
+
files+=("$f")
|
|
267
|
+
done < <(resolve_version_files)
|
|
266
268
|
local touched=()
|
|
267
269
|
for f in "${files[@]}"; do
|
|
268
270
|
local res
|