workledger-cli 0.7.0__tar.gz → 0.8.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.
Files changed (85) hide show
  1. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.github/workflows/tests.yml +12 -0
  2. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.gitignore +3 -0
  3. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/CHANGELOG.md +11 -0
  4. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/PKG-INFO +3 -1
  5. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/README.md +2 -0
  6. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/pyproject.toml +1 -1
  7. workledger_cli-0.8.0/scripts/sonar-pr-issues.py +168 -0
  8. workledger_cli-0.8.0/tests/test_ledger.py +440 -0
  9. workledger_cli-0.8.0/tests/test_sonar_pr_issues.py +128 -0
  10. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/uv.lock +2 -2
  11. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/__init__.py +4 -0
  12. workledger_cli-0.8.0/workledger/cli/_commands/_store.py +292 -0
  13. workledger_cli-0.8.0/workledger/cli/_commands/append.py +66 -0
  14. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/learn.py +9 -0
  15. workledger_cli-0.8.0/workledger/cli/_commands/list.py +56 -0
  16. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/overview.py +2 -0
  17. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/explain/catalog.py +37 -0
  18. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/agent-config/SKILL.md +0 -0
  19. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
  20. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/agent-config/scripts/show.sh +0 -0
  21. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/ask-colleague/SKILL.md +0 -0
  22. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
  23. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/ask-colleague/prompts/review.md +0 -0
  24. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/ask-colleague/prompts/write.md +0 -0
  25. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
  26. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
  27. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
  28. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/challenge/SKILL.md +0 -0
  29. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/SKILL.md +0 -0
  30. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
  31. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
  32. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
  33. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
  34. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/cicd/scripts/workflow.sh +0 -0
  35. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/SKILL.md +0 -0
  36. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
  37. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
  38. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
  39. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
  40. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
  41. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
  42. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/deviate/SKILL.md +0 -0
  43. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
  44. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
  45. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
  46. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
  47. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/recall/SKILL.md +0 -0
  48. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/recall/scripts/recall.sh +0 -0
  49. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/remember/SKILL.md +0 -0
  50. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/remember/scripts/remember.sh +0 -0
  51. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/run-tests/SKILL.md +0 -0
  52. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/run-tests/scripts/test.sh +0 -0
  53. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/scope/SKILL.md +0 -0
  54. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/sonarclaude/SKILL.md +0 -0
  55. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
  56. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/spec-to-plan/SKILL.md +0 -0
  57. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
  58. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/summarize-delivery/SKILL.md +0 -0
  59. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/think/SKILL.md +0 -0
  60. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/think/scripts/think.sh +0 -0
  61. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/version-bump/SKILL.md +0 -0
  62. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills/version-bump/scripts/bump.py +0 -0
  63. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.claude/skills.local.yaml.example +0 -0
  64. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.flake8 +0 -0
  65. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.github/workflows/publish.yml +0 -0
  66. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/.markdownlint-cli2.yaml +0 -0
  67. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/AGENTS.colleague.md +0 -0
  68. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/CLAUDE.md +0 -0
  69. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/LICENSE +0 -0
  70. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/culture.yaml +0 -0
  71. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/docs/skill-sources.md +0 -0
  72. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/sonar-project.properties +0 -0
  73. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/tests/__init__.py +0 -0
  74. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/tests/test_cli.py +0 -0
  75. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/tests/test_cli_introspection.py +0 -0
  76. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/__init__.py +0 -0
  77. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/__main__.py +0 -0
  78. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/__init__.py +0 -0
  79. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/cli.py +0 -0
  80. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/doctor.py +0 -0
  81. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/explain.py +0 -0
  82. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_commands/whoami.py +0 -0
  83. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_errors.py +0 -0
  84. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/cli/_output.py +0 -0
  85. {workledger_cli-0.7.0 → workledger_cli-0.8.0}/workledger/explain/__init__.py +0 -0
@@ -37,6 +37,18 @@ jobs:
37
37
  env:
38
38
  SONAR_HOST_URL: https://sonarcloud.io
39
39
 
40
+ # No unresolved SonarCloud issue on the PR. A code smell leaves the quality gate
41
+ # green, so this fails the job while the PR has any OPEN or CONFIRMED issue;
42
+ # ACCEPTED and FALSE_POSITIVE ones pass. Cited from culture-rules (#43) via
43
+ # culture-library.
44
+ - name: SonarCloud unresolved issues
45
+ if: env.SONAR_TOKEN != '' && github.event_name == 'pull_request'
46
+ env:
47
+ SONAR_HOST_URL: https://sonarcloud.io
48
+ PR_NUMBER: ${{ github.event.pull_request.number }}
49
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
50
+ run: python3 scripts/sonar-pr-issues.py
51
+
40
52
  lint:
41
53
  runs-on: ubuntu-latest
42
54
  permissions:
@@ -228,3 +228,6 @@ __marimo__/
228
228
 
229
229
  # Per-machine skills config (copy from skills.local.yaml.example)
230
230
  skills.local.yaml
231
+
232
+ # workledger ledger (local evidence store)
233
+ .workledger/
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/). This project
6
6
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.8.0] - 2026-10-27
9
+
10
+ ### Added
11
+
12
+ - `append` verb: append one JSON entry (agent, action, summary; optional refs, data) to an append-only JSONL ledger; `--stdin` and `--json` supported; id and UTC timestamp are generated.
13
+ - `list` verb: read entries with `--agent`, `--action`, `--since`, `--limit` filters; `--json` emits an array.
14
+ - Ledger location: `$WORKLEDGER_PATH`, default `.workledger/ledger.jsonl` under the git toplevel of the cwd (the cwd itself outside a repo). No edit/update/delete verb exists by design. `.workledger/` is git-ignored.
15
+ - Integrity: appends are complete writes (short writes are retried) and are refused with exit 2 if the file does not end in a newline. `append` rejects NaN/Infinity (also `1e999`), lone surrogates and control characters/newlines in `agent`/`action`/`summary` as user errors (exit 1) before touching disk; `--stdin` on an interactive terminal fails fast (exit 1).
16
+ - `list` streams the ledger (bounded memory with `--limit`), validates every line, and reports non-UTF-8 bytes, non-object lines, missing/malformed fields or timestamps, and duplicate ids as a corrupt ledger (exit 2, with the line number). `--since` is normalised once to UTC milliseconds; text output escapes control characters so an entry cannot forge extra rows.
17
+ - CI: a `SonarCloud unresolved issues` step fails a pull request while it has any OPEN or CONFIRMED SonarCloud issue (ACCEPTED and FALSE_POSITIVE pass), via `scripts/sonar-pr-issues.py` cited from culture-rules through culture-library.
18
+
8
19
  ## [0.7.0] - 2026-08-24
9
20
 
10
21
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: workledger-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Tracks work in a ledger so agents can prove what they did. An agent records its work as append-only ledger entries, hands the ledger down to subagents, and gets it back with their entries folded in, so the whole tree of work and its results stays traceable and checkable.
5
5
  Project-URL: Homepage, https://github.com/agentculture/workledger-cli
6
6
  Project-URL: Issues, https://github.com/agentculture/workledger-cli/issues
@@ -50,6 +50,8 @@ uv run teken cli doctor . --strict # the agent-first rubric gate CI runs
50
50
  | `explain <path>` | Markdown docs for any noun/verb path. |
51
51
  | `overview` | Read-only descriptive snapshot of the agent. |
52
52
  | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). |
53
+ | `append <json>` | Append one entry (`agent`, `action`, `summary`; optional `refs`, `data`) to the append-only ledger (`$WORKLEDGER_PATH`, default `.workledger/ledger.jsonl` under the git toplevel of the cwd (the cwd itself outside a repo)). `--stdin` reads JSON from stdin. |
54
+ | `list` | Read entries: `--agent`, `--action`, `--since`, `--limit`. |
53
55
  | `cli overview` | Describe the CLI surface itself. |
54
56
 
55
57
  Every command supports `--json`. Results go to stdout, errors/diagnostics to
@@ -33,6 +33,8 @@ uv run teken cli doctor . --strict # the agent-first rubric gate CI runs
33
33
  | `explain <path>` | Markdown docs for any noun/verb path. |
34
34
  | `overview` | Read-only descriptive snapshot of the agent. |
35
35
  | `doctor` | Check the agent-identity invariants (prompt-file-present, backend-consistency). |
36
+ | `append <json>` | Append one entry (`agent`, `action`, `summary`; optional `refs`, `data`) to the append-only ledger (`$WORKLEDGER_PATH`, default `.workledger/ledger.jsonl` under the git toplevel of the cwd (the cwd itself outside a repo)). `--stdin` reads JSON from stdin. |
37
+ | `list` | Read entries: `--agent`, `--action`, `--since`, `--limit`. |
36
38
  | `cli overview` | Describe the CLI surface itself. |
37
39
 
38
40
  Every command supports `--json`. Results go to stdout, errors/diagnostics to
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workledger-cli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Tracks work in a ledger so agents can prove what they did. An agent records its work as append-only ledger entries, hands the ledger down to subagents, and gets it back with their entries folded in, so the whole tree of work and its results stays traceable and checkable."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ # Cited from agentculture/culture-rules@dcddf88 scripts/sonar-pr-issues.py (cite, don't import).
3
+ """Fail a pull request that has unresolved SonarCloud issues.
4
+
5
+ SonarCloud's quality gate can pass with open issues on new code (a code smell does not
6
+ turn it red), so CI runs this right after the scan: it waits until SonarCloud's analysis
7
+ of the PR is for the PR's head commit, then lists the PR's issues whose status is OPEN or
8
+ CONFIRMED and exits 1 when there is any. ACCEPTED and FALSE_POSITIVE issues are resolved
9
+ by a person and pass; FIXED ones are gone.
10
+
11
+ Environment: ``SONAR_HOST_URL`` (the server; defaults to localhost so nothing outside is
12
+ called by accident), ``SONAR_PROJECT_KEY`` (else ``sonar.projectKey`` from
13
+ ``sonar-project.properties``), ``PR_NUMBER``, ``HEAD_SHA`` and optionally ``SONAR_TOKEN``.
14
+ Exit codes: 0 no unresolved issue, 1 unresolved issues, 2 the analysis or the API could
15
+ not be read. Standard library only.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+ import urllib.parse
26
+ import urllib.request
27
+ from collections.abc import Callable, Mapping
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ UNRESOLVED = ("OPEN", "CONFIRMED")
32
+ PAGE_SIZE = 100
33
+ WAIT_S = 300
34
+ POLL_S = 10
35
+
36
+ Fetch = Callable[[str, Mapping[str, str]], Mapping[str, Any]]
37
+
38
+
39
+ def project_key(env: Mapping[str, str], root: Path) -> str:
40
+ """``SONAR_PROJECT_KEY``, else ``sonar.projectKey`` from ``sonar-project.properties``."""
41
+ if env.get("SONAR_PROJECT_KEY"):
42
+ return env["SONAR_PROJECT_KEY"]
43
+ for line in (root / "sonar-project.properties").read_text().splitlines():
44
+ name, _, value = line.partition("=")
45
+ if name.strip() == "sonar.projectKey" and value.strip():
46
+ return value.strip()
47
+ raise SystemExit("sonar-pr-issues: no SONAR_PROJECT_KEY and no sonar.projectKey")
48
+
49
+
50
+ def http_fetch(host: str, token: str | None) -> Fetch:
51
+ """A GET of ``host + path`` with ``params``, as JSON (token as basic auth, if any)."""
52
+
53
+ def fetch(path: str, params: Mapping[str, str]) -> Mapping[str, Any]:
54
+ url = f"{host.rstrip('/')}{path}?{urllib.parse.urlencode(params)}"
55
+ req = urllib.request.Request(url) # noqa: S310 - the host is the configured server
56
+ if token:
57
+ basic = base64.b64encode(f"{token}:".encode()).decode()
58
+ req.add_header("Authorization", f"Basic {basic}")
59
+ with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310
60
+ return json.load(resp)
61
+
62
+ return fetch
63
+
64
+
65
+ def analysed_commit(fetch: Fetch, project: str, pr: str) -> str | None:
66
+ """The commit SonarCloud last analysed for pull request ``pr``, or ``None``."""
67
+ data = fetch("/api/project_pull_requests/list", {"project": project})
68
+ for item in data.get("pullRequests") or ():
69
+ if str(item.get("key")) == pr:
70
+ commit = item.get("commit") or {}
71
+ sha = commit.get("sha")
72
+ return sha if isinstance(sha, str) else None
73
+ return None
74
+
75
+
76
+ def wait_for_analysis(
77
+ fetch: Fetch,
78
+ project: str,
79
+ pr: str,
80
+ head: str,
81
+ *,
82
+ wait_s: float = WAIT_S,
83
+ poll_s: float = POLL_S,
84
+ sleep: Callable[[float], None] = time.sleep,
85
+ clock: Callable[[], float] = time.monotonic,
86
+ ) -> bool:
87
+ """True once the PR's analysis is for ``head``; False when ``wait_s`` runs out."""
88
+ deadline = clock() + wait_s
89
+ while True:
90
+ if analysed_commit(fetch, project, pr) == head:
91
+ return True
92
+ if clock() >= deadline:
93
+ return False
94
+ sleep(poll_s)
95
+
96
+
97
+ def unresolved_issues(fetch: Fetch, project: str, pr: str) -> list[Mapping[str, Any]]:
98
+ """Every OPEN or CONFIRMED issue of pull request ``pr``, across pages."""
99
+ out: list[Mapping[str, Any]] = []
100
+ page = 1
101
+ while True:
102
+ data = fetch(
103
+ "/api/issues/search",
104
+ {
105
+ "componentKeys": project,
106
+ "pullRequest": pr,
107
+ "issueStatuses": ",".join(UNRESOLVED),
108
+ "ps": str(PAGE_SIZE),
109
+ "p": str(page),
110
+ },
111
+ )
112
+ issues = [i for i in data.get("issues") or () if isinstance(i, Mapping)]
113
+ out.extend(issues)
114
+ total = data.get("total")
115
+ if not issues or not isinstance(total, int) or len(out) >= total:
116
+ return out
117
+ page += 1
118
+
119
+
120
+ def describe(issue: Mapping[str, Any], project: str) -> str:
121
+ """One line: rule, file and line, severity, message."""
122
+ component = str(issue.get("component") or "").removeprefix(f"{project}:")
123
+ line = issue.get("line")
124
+ where = f"{component}:{line}" if line else component
125
+ return (
126
+ f"[{issue.get('rule')}] {where} ({issue.get('severity')}, "
127
+ f"{issue.get('issueStatus') or issue.get('status')}): {issue.get('message')}"
128
+ )
129
+
130
+
131
+ def main(
132
+ env: Mapping[str, str] | None = None,
133
+ fetch: Fetch | None = None,
134
+ root: Path | None = None,
135
+ sleep: Callable[[float], None] = time.sleep,
136
+ ) -> int:
137
+ env = os.environ if env is None else env
138
+ root = Path(__file__).resolve().parents[1] if root is None else root
139
+ pr, head = env.get("PR_NUMBER", ""), env.get("HEAD_SHA", "")
140
+ if not pr or not head:
141
+ print("sonar-pr-issues: PR_NUMBER and HEAD_SHA are required", file=sys.stderr)
142
+ return 2
143
+ project = project_key(env, root)
144
+ if fetch is None:
145
+ host = env.get("SONAR_HOST_URL") or "http://localhost:9000"
146
+ fetch = http_fetch(host, env.get("SONAR_TOKEN") or None)
147
+ try:
148
+ if not wait_for_analysis(fetch, project, pr, head, sleep=sleep):
149
+ print(
150
+ f"sonar-pr-issues: no SonarCloud analysis of PR #{pr} at {head[:12]}",
151
+ file=sys.stderr,
152
+ )
153
+ return 2
154
+ issues = unresolved_issues(fetch, project, pr)
155
+ except (OSError, ValueError) as exc: # URLError is an OSError; bad JSON a ValueError
156
+ print(f"sonar-pr-issues: SonarCloud could not be read: {exc}", file=sys.stderr)
157
+ return 2
158
+ if not issues:
159
+ print(f"SonarCloud: PR #{pr} has no unresolved issue (accepted ones pass)")
160
+ return 0
161
+ print(f"SonarCloud: PR #{pr} has {len(issues)} unresolved issue(s); fix or accept each:")
162
+ for issue in issues:
163
+ print(f" - {describe(issue, project)}")
164
+ return 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())