since-dev 0.1.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.
- since_dev/__init__.py +3 -0
- since_dev/__main__.py +23 -0
- since_dev/cli.py +631 -0
- since_dev/init.py +119 -0
- since_dev/local.py +75 -0
- since_dev/realio.py +134 -0
- since_dev/render.py +337 -0
- since_dev-0.1.0.dist-info/METADATA +147 -0
- since_dev-0.1.0.dist-info/RECORD +13 -0
- since_dev-0.1.0.dist-info/WHEEL +5 -0
- since_dev-0.1.0.dist-info/entry_points.txt +3 -0
- since_dev-0.1.0.dist-info/licenses/LICENSE +21 -0
- since_dev-0.1.0.dist-info/top_level.txt +1 -0
since_dev/__init__.py
ADDED
since_dev/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from .cli import run
|
|
4
|
+
from .realio import RealIo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
for stream in (sys.stdout, sys.stderr):
|
|
9
|
+
# A Windows console in a legacy code page would fail on a title outside it; replace what it cannot show.
|
|
10
|
+
if hasattr(stream, "reconfigure"):
|
|
11
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
12
|
+
try:
|
|
13
|
+
code = run(sys.argv[1:], RealIo())
|
|
14
|
+
except KeyboardInterrupt:
|
|
15
|
+
code = 130
|
|
16
|
+
except Exception as error: # noqa: BLE001 - the last line of defense: say what happened in one line
|
|
17
|
+
sys.stderr.write("since: %s\n" % error)
|
|
18
|
+
code = 1
|
|
19
|
+
sys.exit(code)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
main()
|
since_dev/cli.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""The whole CLI: run(argv, io) returns the exit code. Everything it touches (network, files, clock, terminal) comes in
|
|
2
|
+
through io, so the tests replay it from fixtures, and src/cli.mjs prints the same text from the same fixtures.
|
|
3
|
+
|
|
4
|
+
Exit codes: 0 done. `check`: 1 when a finding fails, 2 when the caller has something to fix, 0 when Since could not be
|
|
5
|
+
reached (an outage never fails a build). Every other command: 1 when it could not finish, 2 for a usage error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from . import __version__ as VERSION
|
|
14
|
+
from . import render as text
|
|
15
|
+
from .init import dumps, init, loads
|
|
16
|
+
from .local import MAX_CHARACTERS, check_body, find_manifests, parse_repo
|
|
17
|
+
|
|
18
|
+
DEFAULT_API = "https://api.since.dev"
|
|
19
|
+
SITE = "https://since.dev"
|
|
20
|
+
APP = "https://app.since.dev"
|
|
21
|
+
COMMANDS = ["check", "calendar", "changed", "init", "login", "logout", "help", "version"]
|
|
22
|
+
SWITCHES = ["all", "json", "local", "dry-run", "no-color", "help", "version"]
|
|
23
|
+
OPTIONS = ["fail-on", "within", "provider", "ecosystem", "since", "email", "max-files"]
|
|
24
|
+
FAIL_ON = ["low", "medium", "high", "critical", "none"]
|
|
25
|
+
MAX_PAGES = 10
|
|
26
|
+
MAX_WAIT_MS = 60000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_args(argv):
|
|
30
|
+
args = {"command": None, "positional": [], "flags": {}, "error": None}
|
|
31
|
+
index = 0
|
|
32
|
+
while index < len(argv):
|
|
33
|
+
word = argv[index]
|
|
34
|
+
index += 1
|
|
35
|
+
if word == "-h":
|
|
36
|
+
args["flags"]["help"] = True
|
|
37
|
+
elif word == "-v":
|
|
38
|
+
args["flags"]["version"] = True
|
|
39
|
+
elif word.startswith("--"):
|
|
40
|
+
equals = word.find("=")
|
|
41
|
+
name = word[2:] if equals < 0 else word[2:equals]
|
|
42
|
+
if name in SWITCHES and equals < 0:
|
|
43
|
+
args["flags"][name] = True
|
|
44
|
+
elif name in OPTIONS:
|
|
45
|
+
if equals >= 0:
|
|
46
|
+
value = word[equals + 1:]
|
|
47
|
+
elif index < len(argv):
|
|
48
|
+
value = argv[index]
|
|
49
|
+
index += 1
|
|
50
|
+
else:
|
|
51
|
+
value = None
|
|
52
|
+
if value is None or value == "":
|
|
53
|
+
args["error"] = "--%s needs a value." % name
|
|
54
|
+
return args
|
|
55
|
+
args["flags"][name] = value
|
|
56
|
+
else:
|
|
57
|
+
args["error"] = "Unknown option: %s" % word
|
|
58
|
+
return args
|
|
59
|
+
elif args["command"] is None and not args["positional"] and word in COMMANDS:
|
|
60
|
+
args["command"] = word
|
|
61
|
+
else:
|
|
62
|
+
args["positional"].append(word)
|
|
63
|
+
return args
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def help_text(p):
|
|
67
|
+
usage = [
|
|
68
|
+
["", "check this repository (reads the origin remote)"],
|
|
69
|
+
[" <owner/repo>", "check a public GitHub repository"],
|
|
70
|
+
[" check", "the same for CI: exit 1 when a finding fails"],
|
|
71
|
+
[" calendar", "what ends in the next 30 days"],
|
|
72
|
+
[" changed <package>", "breaking changes and deprecations of a package"],
|
|
73
|
+
[" init", "set up your coding agent to check before it writes"],
|
|
74
|
+
[" login", "sign in, for private repositories"],
|
|
75
|
+
[" logout", "remove the stored key"],
|
|
76
|
+
]
|
|
77
|
+
options = [
|
|
78
|
+
["--all", "show every finding"],
|
|
79
|
+
["--json", "print the answer as JSON"],
|
|
80
|
+
["--local", "check the manifests in this folder, not GitHub"],
|
|
81
|
+
["--fail-on <level>", "check: low, medium, high (default), critical or none"],
|
|
82
|
+
["--within <days>", "only dates within this many days, as 30d"],
|
|
83
|
+
["--provider <name>", "calendar: one provider, as openai"],
|
|
84
|
+
["--ecosystem <name>", "changed: the registry, as npm (default) or pypi"],
|
|
85
|
+
["--since <version>", "changed: only what changed after this version"],
|
|
86
|
+
["--email <address>", "login: the email to sign in with"],
|
|
87
|
+
["--dry-run", "init: show what would be written"],
|
|
88
|
+
["--max-files <n>", "how many manifests to check (default 10)"],
|
|
89
|
+
["--no-color", "plain text"],
|
|
90
|
+
]
|
|
91
|
+
width = max(len(p) + len(words) for words, _ in usage) + 3
|
|
92
|
+
lines = ["since-dev %s: what in this repository is about to stop working" % VERSION, "", "Usage"]
|
|
93
|
+
for words, what in usage:
|
|
94
|
+
lines.append(" %s%s" % ((p + words).ljust(width), what))
|
|
95
|
+
lines.extend(["", "Options"])
|
|
96
|
+
for words, what in options:
|
|
97
|
+
lines.append(" %s%s" % (words.ljust(21), what))
|
|
98
|
+
lines.extend(["", "Docs %s/docs#cli" % SITE, ""])
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def clamp(value, low, high):
|
|
103
|
+
return min(high, max(low, value))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def whole_number(value):
|
|
107
|
+
return int(value) if text.is_number(value) else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def first(*values):
|
|
111
|
+
"""The first value that is not None, as ?? reads in the JavaScript."""
|
|
112
|
+
for value in values:
|
|
113
|
+
if value is not None:
|
|
114
|
+
return value
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_json(content):
|
|
119
|
+
if content is None:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
data = loads(content)
|
|
123
|
+
except ValueError:
|
|
124
|
+
return None
|
|
125
|
+
return data if isinstance(data, dict) else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def credentials_path(io):
|
|
129
|
+
if io.platform == "win32" and io.env.get("APPDATA"):
|
|
130
|
+
return "%s\\since\\credentials.json" % io.env["APPDATA"]
|
|
131
|
+
return "%s/since/credentials.json" % (io.env.get("XDG_CONFIG_HOME") or "%s/.config" % io.home)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_key(io, api):
|
|
135
|
+
"""The key to call with: SINCE_DEV_KEY, or the one `login` stored. A stored key only ever goes to the API it came from."""
|
|
136
|
+
from_env = str(io.env.get("SINCE_DEV_KEY") or "").strip()
|
|
137
|
+
if from_env:
|
|
138
|
+
return {"value": from_env, "source": "env"}
|
|
139
|
+
stored = read_json(io.read_file(credentials_path(io)))
|
|
140
|
+
if stored and isinstance(stored.get("api_key"), str) and stored["api_key"] and (stored.get("api") or DEFAULT_API) == api:
|
|
141
|
+
return {"value": stored["api_key"], "source": "file"}
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def encode_query(query):
|
|
146
|
+
# The characters encodeURIComponent leaves alone, so both languages ask for the same address.
|
|
147
|
+
return "&".join(
|
|
148
|
+
"%s=%s" % (name, quote(str(value), safe="-_.!~*'()"))
|
|
149
|
+
for name, value in query.items()
|
|
150
|
+
if value is not None and value != ""
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def call(ctx, method, path, query=None, body=None, key=None):
|
|
155
|
+
"""One request. status 0 means Since could not be reached; json is the answer when it is a JSON object."""
|
|
156
|
+
search = encode_query(query) if query else ""
|
|
157
|
+
headers = {"User-Agent": "since-cli/%s (%s)" % (VERSION, ctx["io"].runtime), "Accept": "application/json"}
|
|
158
|
+
if body is not None:
|
|
159
|
+
headers["Content-Type"] = "application/json"
|
|
160
|
+
if key:
|
|
161
|
+
headers["Authorization"] = "Bearer %s" % key["value"]
|
|
162
|
+
try:
|
|
163
|
+
answer = ctx["io"].fetch(method, "%s%s%s" % (ctx["api"], path, "?" + search if search else ""), headers, None if body is None else json.dumps(body))
|
|
164
|
+
except Exception as error: # noqa: BLE001 - any failure to reach Since reads the same to the caller
|
|
165
|
+
return {"status": 0, "json": None, "retryAfter": None, "certificate": "CERTIFICATE_VERIFY_FAILED" in str(error)}
|
|
166
|
+
retry = re.match(r"\s*([+-]?[0-9]+)", str(first(answer.get("retryAfter"), "")))
|
|
167
|
+
return {"status": answer["status"], "json": read_json(answer.get("text")), "retryAfter": int(retry.group(1)) if retry else None}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def error_of(answer):
|
|
171
|
+
return answer["json"]["error"] if answer["json"] is not None and isinstance(answer["json"].get("error"), str) else None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def message_of(answer):
|
|
175
|
+
return answer["json"]["message"] if answer["json"] is not None and isinstance(answer["json"].get("message"), str) else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def refuse(ctx, answer):
|
|
179
|
+
"""Say why a request did not answer, and return the exit code that goes with it."""
|
|
180
|
+
io, p, check = ctx["io"], ctx["p"], ctx["check"]
|
|
181
|
+
message = message_of(answer)
|
|
182
|
+
if answer["status"] == 0 or answer["status"] >= 500:
|
|
183
|
+
what = "Since is still busy." if error_of(answer) == "busy" else "Since could not be reached."
|
|
184
|
+
if answer.get("certificate"):
|
|
185
|
+
what = "This Python could not verify Since's certificate (on macOS, run its Install Certificates command)."
|
|
186
|
+
io.err("%s Try again in a minute.%s\n" % (what, " Nothing was checked, so this run passes." if check else ""))
|
|
187
|
+
return 0 if check else 1
|
|
188
|
+
if answer["status"] == 401:
|
|
189
|
+
if ctx["key"] and ctx["key"]["source"] == "env":
|
|
190
|
+
io.err("Since refused the key in SINCE_DEV_KEY. Create a new one at %s/api-keys\n" % APP)
|
|
191
|
+
else:
|
|
192
|
+
io.err("Since refused the stored key. Sign in again: %s login\n" % p)
|
|
193
|
+
elif answer["status"] == 429:
|
|
194
|
+
seconds = first(whole_number(answer["json"].get("retryAfterSec") if answer["json"] is not None else None), answer["retryAfter"])
|
|
195
|
+
# A shared CI address runs out of free scans through no fault of the build, so a limit passes a check like an outage does.
|
|
196
|
+
io.err("%s%s%s\n" % (
|
|
197
|
+
first(message, "Too many requests."),
|
|
198
|
+
" Try again in %s." % text.wait(seconds) if seconds else "",
|
|
199
|
+
" Nothing was checked, so this run passes." if check else "",
|
|
200
|
+
))
|
|
201
|
+
return 0 if check else 1
|
|
202
|
+
else:
|
|
203
|
+
io.err("%s\n" % first(message, "Since answered %d." % answer["status"]))
|
|
204
|
+
return 2 if check else 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def public_scan(ctx, full_name):
|
|
208
|
+
"""The free scan of a public GitHub repository. A busy answer is waited out up to three times."""
|
|
209
|
+
waits = 0
|
|
210
|
+
while True:
|
|
211
|
+
answer = call(ctx, "POST", "/v1/scan", body={"repo": full_name})
|
|
212
|
+
if answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("repository"), dict):
|
|
213
|
+
return {"result": answer["json"]}
|
|
214
|
+
if answer["status"] == 503 and error_of(answer) == "busy" and waits < 3:
|
|
215
|
+
seconds = clamp(first(answer["retryAfter"], 20), 1, 60)
|
|
216
|
+
ctx["io"].err("Since is busy. Trying again in %s.\n" % text.wait(seconds))
|
|
217
|
+
ctx["io"].sleep(seconds * 1000)
|
|
218
|
+
waits += 1
|
|
219
|
+
continue
|
|
220
|
+
return {"answer": answer}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _has_findings(answer):
|
|
224
|
+
return answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("findings"), list)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_all(ctx, body):
|
|
228
|
+
"""One keyed check, whole: waits while a connected repository is still being read, then follows every page (pages are free)."""
|
|
229
|
+
io = ctx["io"]
|
|
230
|
+
started = io.now()
|
|
231
|
+
answer = call(ctx, "POST", "/v1/check", body=body, key=ctx["key"])
|
|
232
|
+
while _has_findings(answer) and answer["json"].get("scanning") and not answer["json"]["findings"]:
|
|
233
|
+
scanning = answer["json"]["scanning"]
|
|
234
|
+
seconds = clamp(first(whole_number(scanning.get("retryAfterSec") if isinstance(scanning, dict) else None), 10), 1, 30)
|
|
235
|
+
if io.now() - started + seconds * 1000 > MAX_WAIT_MS:
|
|
236
|
+
break
|
|
237
|
+
io.err("Since is still reading this repository. Trying again in %s.\n" % text.wait(seconds))
|
|
238
|
+
io.sleep(seconds * 1000)
|
|
239
|
+
answer = call(ctx, "POST", "/v1/check", body=body, key=ctx["key"])
|
|
240
|
+
if not _has_findings(answer):
|
|
241
|
+
return answer
|
|
242
|
+
whole = answer["json"]
|
|
243
|
+
page = 1
|
|
244
|
+
while page < MAX_PAGES and isinstance(whole.get("nextCursor"), str) and whole["nextCursor"]:
|
|
245
|
+
following = call(ctx, "POST", "/v1/check", body=dict(body, cursor=whole["nextCursor"]), key=ctx["key"])
|
|
246
|
+
if not _has_findings(following):
|
|
247
|
+
break
|
|
248
|
+
whole["findings"] = whole["findings"] + following["json"]["findings"]
|
|
249
|
+
whole["nextCursor"] = following["json"].get("nextCursor")
|
|
250
|
+
page += 1
|
|
251
|
+
if whole.get("nextCursor"):
|
|
252
|
+
whole["truncated"] = True
|
|
253
|
+
return answer
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def connected_check(ctx, full_name):
|
|
257
|
+
"""A repository connected to the caller's workspace, checked where Since already has its code."""
|
|
258
|
+
repos = call(ctx, "GET", "/v1/repos", key=ctx["key"])
|
|
259
|
+
if repos["status"] != 200 or repos["json"] is None:
|
|
260
|
+
return {"exit": refuse(ctx, repos)}
|
|
261
|
+
listed = repos["json"].get("repos")
|
|
262
|
+
repo = None
|
|
263
|
+
for entry in listed if isinstance(listed, list) else []:
|
|
264
|
+
if isinstance(entry, dict) and str(entry.get("fullName")).lower() == full_name.lower():
|
|
265
|
+
repo = entry
|
|
266
|
+
break
|
|
267
|
+
if repo is None:
|
|
268
|
+
return {}
|
|
269
|
+
answer = check_all(ctx, {"repo": repo.get("id"), "detail": "summary"})
|
|
270
|
+
if answer["status"] == 403 and error_of(answer) == "impact_api_not_in_plan":
|
|
271
|
+
return {}
|
|
272
|
+
if answer["status"] != 200 or answer["json"] is None:
|
|
273
|
+
return {"exit": refuse(ctx, answer)}
|
|
274
|
+
return {"answer": answer["json"], "repo": repo}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def local_check(ctx, no_origin):
|
|
278
|
+
"""The manifests in this folder, sent one by one as the GitHub Action sends them."""
|
|
279
|
+
io, p, check = ctx["io"], ctx["p"], ctx["check"]
|
|
280
|
+
if not ctx["key"]:
|
|
281
|
+
if no_origin:
|
|
282
|
+
io.err("This folder has no GitHub origin. Name a public repository, as %s owner/repo, or sign in to check the files here: %s login\n" % (p, p))
|
|
283
|
+
else:
|
|
284
|
+
io.err("Checking the files in this folder needs a key. Sign in first: %s login\n" % p)
|
|
285
|
+
return 2 if check else 1
|
|
286
|
+
found = find_manifests(io)
|
|
287
|
+
if not found:
|
|
288
|
+
io.err("No manifests in this folder (package.json, pyproject.toml, go.mod and the like).\n")
|
|
289
|
+
return 2 if check else 1
|
|
290
|
+
files = []
|
|
291
|
+
notes = []
|
|
292
|
+
last = None
|
|
293
|
+
if len(found) > ctx["max_files"]:
|
|
294
|
+
notes.append("%s found, the first %d checked (each one costs credits). Raise it with --max-files." % (text.plural(len(found), "manifest"), ctx["max_files"]))
|
|
295
|
+
for file in found[:ctx["max_files"]]:
|
|
296
|
+
content = io.read_file("%s/%s" % (io.cwd, file))
|
|
297
|
+
if content is None:
|
|
298
|
+
notes.append("%s: could not be read." % file)
|
|
299
|
+
elif len(content) > MAX_CHARACTERS:
|
|
300
|
+
notes.append("%s: over %s characters, not sent." % (file, text.num(MAX_CHARACTERS)))
|
|
301
|
+
else:
|
|
302
|
+
answer = check_all(ctx, check_body(file, content))
|
|
303
|
+
if _has_findings(answer):
|
|
304
|
+
files.append({"path": file, "answer": answer["json"]})
|
|
305
|
+
elif answer["status"] in (401, 402):
|
|
306
|
+
return refuse(ctx, answer)
|
|
307
|
+
else:
|
|
308
|
+
last = answer
|
|
309
|
+
unreachable = answer["status"] == 0 or answer["status"] >= 500
|
|
310
|
+
notes.append("%s: %s" % (file, "Since could not be reached." if unreachable else first(message_of(answer), "Since answered %d." % answer["status"])))
|
|
311
|
+
if not files and last is not None:
|
|
312
|
+
return refuse(ctx, last)
|
|
313
|
+
return report(ctx, {"kind": "local", "files": files, "notes": notes})
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def parse_time(value):
|
|
317
|
+
"""Milliseconds since 1970 from an ISO time, or None."""
|
|
318
|
+
try:
|
|
319
|
+
moment = datetime.datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
320
|
+
except ValueError:
|
|
321
|
+
return None
|
|
322
|
+
if moment.tzinfo is None:
|
|
323
|
+
moment = moment.replace(tzinfo=datetime.timezone.utc)
|
|
324
|
+
return (moment - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)) // datetime.timedelta(milliseconds=1)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def report(ctx, data):
|
|
328
|
+
"""Print what was found and return the exit code."""
|
|
329
|
+
io, args, p, c, check = ctx["io"], ctx["args"], ctx["p"], ctx["c"], ctx["check"]
|
|
330
|
+
show_all = bool(args["flags"].get("all"))
|
|
331
|
+
if data["kind"] == "scan":
|
|
332
|
+
findings = data["result"].get("findings")
|
|
333
|
+
items = text.scan_items(findings if isinstance(findings, list) else [])
|
|
334
|
+
elif data["kind"] == "repo":
|
|
335
|
+
items = text.check_items(data["answer"]["findings"], None, p)
|
|
336
|
+
else:
|
|
337
|
+
items = [item for file in data["files"] for item in text.check_items(file["answer"]["findings"], file["path"], p)]
|
|
338
|
+
if ctx["within"] is not None:
|
|
339
|
+
items = [item for item in items if item["daysLeft"] is not None and item["daysLeft"] <= ctx["within"]]
|
|
340
|
+
items = text.order(items)
|
|
341
|
+
failing = [item for item in items if text.fails(item, ctx["fail_on"])] if check else []
|
|
342
|
+
exit_code = 1 if failing else 0
|
|
343
|
+
if args["flags"].get("json"):
|
|
344
|
+
whole = data["result"] if data["kind"] == "scan" else data["answer"] if data["kind"] == "repo" else {"files": data["files"]}
|
|
345
|
+
io.out("%s\n" % dumps(whole, 2))
|
|
346
|
+
return exit_code
|
|
347
|
+
|
|
348
|
+
lines = [""]
|
|
349
|
+
notes = []
|
|
350
|
+
footer = []
|
|
351
|
+
counts = text.counts_line(items, ctx["within"])
|
|
352
|
+
more = "%s%s%s --all" % (p, " check" if check else "", " %s" % args["positional"][0] if args["positional"] else "")
|
|
353
|
+
if data["kind"] == "scan":
|
|
354
|
+
result = data["result"]
|
|
355
|
+
repository = result["repository"]
|
|
356
|
+
summary = result["summary"] if isinstance(result.get("summary"), dict) else {}
|
|
357
|
+
sha = str(first(repository.get("headSha"), ""))[:7]
|
|
358
|
+
branch = str(first(repository.get("defaultBranch"), ""))
|
|
359
|
+
parsed = parse_time(first(result.get("scannedAt"), ""))
|
|
360
|
+
scanned = text.ago(None if parsed is None else io.now() - parsed)
|
|
361
|
+
head = [c.bold(str(repository.get("fullName"))), "%s@%s" % (branch, sha) if sha else branch, "scanned %s" % scanned if scanned else ""]
|
|
362
|
+
lines.append(" %s" % " ".join(part for part in head if part))
|
|
363
|
+
dependencies = first(whole_number(summary.get("dependencies")), 0)
|
|
364
|
+
files = first(whole_number(summary.get("files")), 0)
|
|
365
|
+
lines.append(" %s %s %s and %s read" % (counts, text.num(dependencies), "dependency" if dependencies == 1 else "dependencies", text.plural(files, "file")))
|
|
366
|
+
if data["fromTree"]:
|
|
367
|
+
lines.append(c.dim(" Read from GitHub (%s), not your working tree." % branch))
|
|
368
|
+
coverage = result.get("coverage")
|
|
369
|
+
for note in coverage["notes"] if isinstance(coverage, dict) and isinstance(coverage.get("notes"), list) else []:
|
|
370
|
+
notes.append(str(note))
|
|
371
|
+
footer.append(" %s%s/scan/%s?utm_source=cli" % ("Full report".ljust(14), SITE, repository.get("fullName")))
|
|
372
|
+
elif data["kind"] == "repo":
|
|
373
|
+
head = [c.bold(str(data["repo"].get("fullName"))), str(first(data["repo"].get("defaultBranch"), "")), "connected repository"]
|
|
374
|
+
lines.append(" %s" % " ".join(part for part in head if part))
|
|
375
|
+
lines.append(" %s %s checked" % (counts, text.num(first(whole_number(data["answer"].get("checkedCount")), 0))))
|
|
376
|
+
notes.extend(text.answer_notes([data["answer"]]))
|
|
377
|
+
else:
|
|
378
|
+
checked = sum(first(whole_number(file["answer"].get("checkedCount")), 0) for file in data["files"])
|
|
379
|
+
lines.append(" %s %s checked" % (c.bold("This folder"), text.plural(len(data["files"]), "manifest")))
|
|
380
|
+
lines.append(" %s %s checked" % (counts, text.num(checked)))
|
|
381
|
+
notes.extend(data["notes"])
|
|
382
|
+
notes.extend(text.answer_notes([file["answer"] for file in data["files"]]))
|
|
383
|
+
notes.append("Only manifests were checked. Connect the repository to check its code too: %s?utm_source=cli" % APP)
|
|
384
|
+
lines.append("")
|
|
385
|
+
lines.extend(text.finding_lines(items, show_all, c, more))
|
|
386
|
+
for note in notes:
|
|
387
|
+
lines.append(" %s" % note)
|
|
388
|
+
if notes:
|
|
389
|
+
lines.append("")
|
|
390
|
+
lines.extend(footer)
|
|
391
|
+
if not check:
|
|
392
|
+
lines.append(" %s%s init your coding agent checks before it writes" % ("Next".ljust(14), p))
|
|
393
|
+
elif ctx["fail_on"] != "none":
|
|
394
|
+
level = "at critical" if ctx["fail_on"] == "critical" else "at %s or above" % ctx["fail_on"]
|
|
395
|
+
if failing:
|
|
396
|
+
lines.append(" %s%s %s" % ("Failed".ljust(14), text.plural(len(failing), "finding"), level))
|
|
397
|
+
else:
|
|
398
|
+
lines.append(" %snothing %s" % ("Passed".ljust(14), level))
|
|
399
|
+
lines.append("")
|
|
400
|
+
io.out("\n".join(lines))
|
|
401
|
+
return exit_code
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def repo_command(ctx):
|
|
405
|
+
"""`since`, `since <owner/repo>` and `since check`: find the repository, then the cheapest way to read it."""
|
|
406
|
+
io, args, p, check = ctx["io"], ctx["args"], ctx["p"], ctx["check"]
|
|
407
|
+
given = args["positional"][0] if args["positional"] else None
|
|
408
|
+
from_tree = given is None
|
|
409
|
+
repo = parse_repo(io.origin() if from_tree else given)
|
|
410
|
+
stop = 2 if check else 1
|
|
411
|
+
if not from_tree and not repo:
|
|
412
|
+
io.err("%s is not a repository. Use owner/repo or a GitHub URL.\n" % given)
|
|
413
|
+
return 2
|
|
414
|
+
if not from_tree and args["flags"].get("local"):
|
|
415
|
+
io.err("--local checks the folder you are in, so it takes no repository name.\n")
|
|
416
|
+
return 2
|
|
417
|
+
# In CI with a key, and whenever --local asks: the checkout itself, not the default branch on GitHub.
|
|
418
|
+
if from_tree and (args["flags"].get("local") or (check and ctx["key"])):
|
|
419
|
+
return local_check(ctx, False)
|
|
420
|
+
if not repo or repo["host"] != "github.com":
|
|
421
|
+
if from_tree:
|
|
422
|
+
return local_check(ctx, True)
|
|
423
|
+
io.err("%s is not on GitHub. Run %s inside its folder to check its manifests.\n" % (given, p))
|
|
424
|
+
return stop
|
|
425
|
+
|
|
426
|
+
full_name = "%s/%s" % (repo["owner"], repo["name"])
|
|
427
|
+
# A first scan takes up to a minute, and a silent terminal reads as a hung one.
|
|
428
|
+
io.err("Reading %s on GitHub. A first scan can take a minute.\n" % full_name)
|
|
429
|
+
scan = public_scan(ctx, full_name)
|
|
430
|
+
if "result" in scan:
|
|
431
|
+
return report(ctx, {"kind": "scan", "result": scan["result"], "fromTree": from_tree})
|
|
432
|
+
# The free scan said no (not public, too big, or today's scans are used up): with a key there is still a way to answer
|
|
433
|
+
# for the folder you are in, so say why and take it. Without one, or when Since itself is down, this is the end.
|
|
434
|
+
refusal = error_of(scan["answer"])
|
|
435
|
+
down = scan["answer"]["status"] == 0 or scan["answer"]["status"] >= 500
|
|
436
|
+
if refusal != "not_found":
|
|
437
|
+
if down or not ctx["key"] or not from_tree:
|
|
438
|
+
return refuse(ctx, scan["answer"])
|
|
439
|
+
why = message_of(scan["answer"])
|
|
440
|
+
io.err("%sChecking with your key instead.\n" % ("%s " % why if why is not None else ""))
|
|
441
|
+
if not ctx["key"]:
|
|
442
|
+
if from_tree:
|
|
443
|
+
io.err("%s is not a public GitHub repository. For a private one, sign in and run this again: %s login\n" % (full_name, p))
|
|
444
|
+
else:
|
|
445
|
+
io.err("GitHub serves no public repository named %s. For a private one, sign in first: %s login\n" % (full_name, p))
|
|
446
|
+
return stop
|
|
447
|
+
connected = connected_check(ctx, full_name)
|
|
448
|
+
if "answer" in connected:
|
|
449
|
+
return report(ctx, {"kind": "repo", "answer": connected["answer"], "repo": connected["repo"]})
|
|
450
|
+
if "exit" in connected:
|
|
451
|
+
return connected["exit"]
|
|
452
|
+
if from_tree:
|
|
453
|
+
return local_check(ctx, False)
|
|
454
|
+
io.err("%s is private and Since cannot read it from here. Run %s inside its folder.\n" % (full_name, p))
|
|
455
|
+
return stop
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def calendar_command(ctx):
|
|
459
|
+
io, args, p, c = ctx["io"], ctx["args"], ctx["p"], ctx["c"]
|
|
460
|
+
within = first(ctx["within"], 30)
|
|
461
|
+
provider = args["flags"].get("provider")
|
|
462
|
+
answer = call(ctx, "GET", "/v1/calendar", query={"within": "%dd" % within, "provider": provider, "limit": 500 if args["flags"].get("all") else 15})
|
|
463
|
+
if answer["status"] != 200 or answer["json"] is None or not isinstance(answer["json"].get("entries"), list):
|
|
464
|
+
return refuse(ctx, answer)
|
|
465
|
+
if args["flags"].get("json"):
|
|
466
|
+
io.out("%s\n" % dumps(answer["json"], 2))
|
|
467
|
+
return 0
|
|
468
|
+
entries = answer["json"]["entries"]
|
|
469
|
+
lines = ["", " %s%s" % (c.bold("Ending in the next %s" % text.plural(within, "day")), " %s" % provider if provider else ""), ""]
|
|
470
|
+
if not entries:
|
|
471
|
+
lines.append(" Nothing on the calendar in that time.")
|
|
472
|
+
lines.extend(text.calendar_lines(entries, SITE, c))
|
|
473
|
+
lines.append("")
|
|
474
|
+
if answer["json"].get("nextCursor"):
|
|
475
|
+
lines.append(" %s%s calendar%s --all" % ("More".ljust(14), p, " --provider %s" % provider if provider else ""))
|
|
476
|
+
lines.extend([" %s%s/calendar?utm_source=cli" % ("Calendar".ljust(14), SITE), ""])
|
|
477
|
+
io.out("\n".join(lines))
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def changed_command(ctx):
|
|
482
|
+
io, args, p, c = ctx["io"], ctx["args"], ctx["p"], ctx["c"]
|
|
483
|
+
name = args["positional"][0]
|
|
484
|
+
if not ctx["key"]:
|
|
485
|
+
io.err("%s changed needs a key. Sign in first: %s login\n" % (p, p))
|
|
486
|
+
return 1
|
|
487
|
+
ecosystem = first(args["flags"].get("ecosystem"), "npm")
|
|
488
|
+
since = args["flags"].get("since")
|
|
489
|
+
answer = call(ctx, "GET", "/v1/changes", query={"package": name, "ecosystem": ecosystem, "since": since, "limit": 200 if args["flags"].get("all") else 20}, key=ctx["key"])
|
|
490
|
+
if answer["status"] != 200 or answer["json"] is None or not isinstance(answer["json"].get("changes"), list):
|
|
491
|
+
return refuse(ctx, answer)
|
|
492
|
+
if args["flags"].get("json"):
|
|
493
|
+
io.out("%s\n" % dumps(answer["json"], 2))
|
|
494
|
+
return 0
|
|
495
|
+
changes = text.unique_changes(answer["json"]["changes"])
|
|
496
|
+
found = answer["json"]["package"] if isinstance(answer["json"].get("package"), dict) else {}
|
|
497
|
+
head = [c.bold(str(first(found.get("name"), name))), str(first(found.get("ecosystem"), ecosystem)), "latest %s" % found["latest"] if found.get("latest") else ""]
|
|
498
|
+
lines = ["", " %s" % " ".join(part for part in head if part)]
|
|
499
|
+
lines.extend([" %s%s%s" % (
|
|
500
|
+
text.plural(len(changes), "change") if changes else "No changes recorded",
|
|
501
|
+
" since %s" % since if since else "",
|
|
502
|
+
", and more" if answer["json"].get("nextCursor") else "",
|
|
503
|
+
), ""])
|
|
504
|
+
lines.extend(text.change_lines(changes, c))
|
|
505
|
+
if changes:
|
|
506
|
+
lines.append("")
|
|
507
|
+
notes = []
|
|
508
|
+
if answer["json"].get("reading"):
|
|
509
|
+
notes.append("Since is reading newer releases of this package now. Run this again in a few minutes to include them.")
|
|
510
|
+
for note in notes:
|
|
511
|
+
lines.append(" %s" % note)
|
|
512
|
+
if notes:
|
|
513
|
+
lines.append("")
|
|
514
|
+
if answer["json"].get("nextCursor"):
|
|
515
|
+
lines.extend([" %s%s changed %s%s%s --all" % ("More".ljust(14), p, name, " --since %s" % since if since else "", " --ecosystem %s" % ecosystem if ecosystem != "npm" else ""), ""])
|
|
516
|
+
io.out("\n".join(lines))
|
|
517
|
+
return 0
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def login_command(ctx):
|
|
521
|
+
"""Sign in without a password: the person approves the link in a browser and the key comes back."""
|
|
522
|
+
io, args, p = ctx["io"], ctx["args"], ctx["p"]
|
|
523
|
+
email = str(first(args["flags"].get("email"), "")).strip()
|
|
524
|
+
if not email and io.tty:
|
|
525
|
+
email = str(io.prompt("Email: ")).strip()
|
|
526
|
+
if not email:
|
|
527
|
+
io.err("Give the email to sign in with: %s login --email you@example.com\n" % p)
|
|
528
|
+
return 2
|
|
529
|
+
start = call(ctx, "POST", "/v1/agent/register", body={"email": email, "client_name": "since-cli"})
|
|
530
|
+
setup = start["json"].get("setup") if start["json"] is not None and isinstance(start["json"].get("setup"), dict) else None
|
|
531
|
+
if start["status"] != 200 or setup is None or not isinstance(start["json"].get("claim_token"), str):
|
|
532
|
+
return refuse(ctx, start)
|
|
533
|
+
link = str(first(setup.get("verification_uri_complete"), setup.get("verification_uri")))
|
|
534
|
+
# The approval page shows an eight-character code in two halves: print it the way the person will see it there.
|
|
535
|
+
code = str(first(setup.get("user_code"), ""))
|
|
536
|
+
io.out("\n Open this link to approve the sign-in:\n %s\n Code: %s\n\n" % (link, "%s-%s" % (code[:4], code[4:]) if len(code) == 8 else code))
|
|
537
|
+
if io.tty:
|
|
538
|
+
io.open(link)
|
|
539
|
+
interval = clamp(first(whole_number(setup.get("interval")), 5), 1, 60)
|
|
540
|
+
deadline = io.now() + clamp(first(whole_number(setup.get("expires_in")), 900), 30, 3600) * 1000
|
|
541
|
+
while io.now() + interval * 1000 <= deadline:
|
|
542
|
+
io.sleep(interval * 1000)
|
|
543
|
+
answer = call(ctx, "POST", "/v1/agent/token", body={"claim_token": start["json"]["claim_token"]})
|
|
544
|
+
if answer["status"] == 200 and answer["json"] is not None and isinstance(answer["json"].get("api_key"), str):
|
|
545
|
+
file = credentials_path(io)
|
|
546
|
+
stored = {"api": ctx["api"], "api_key": answer["json"]["api_key"], "key_name": answer["json"].get("key_name"), "key_prefix": answer["json"].get("key_prefix")}
|
|
547
|
+
io.write_file(file, "%s\n" % dumps(stored, 2), 0o600)
|
|
548
|
+
io.out(" Signed in. The key is stored in %s\n\n" % file)
|
|
549
|
+
return 0
|
|
550
|
+
code = error_of(answer)
|
|
551
|
+
if code == "slow_down":
|
|
552
|
+
interval += 5
|
|
553
|
+
elif code == "expired_token":
|
|
554
|
+
break
|
|
555
|
+
elif code == "access_denied":
|
|
556
|
+
io.err("The sign-in was declined.\n")
|
|
557
|
+
return 1
|
|
558
|
+
elif code != "authorization_pending" and answer["status"] != 0 and answer["status"] < 500:
|
|
559
|
+
return refuse(ctx, answer)
|
|
560
|
+
io.err("The link expired before it was approved. Run %s login again.\n" % p)
|
|
561
|
+
return 1
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def logout_command(ctx):
|
|
565
|
+
io = ctx["io"]
|
|
566
|
+
removed = io.remove(credentials_path(io))
|
|
567
|
+
io.out("Signed out. The key is gone from this computer; delete it for good at %s/api-keys\n" % APP if removed else "No stored key.\n")
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def run(argv, io):
|
|
572
|
+
args = parse_args(argv)
|
|
573
|
+
p = io.program
|
|
574
|
+
|
|
575
|
+
def usage(message):
|
|
576
|
+
io.err("%s\nRun %s --help for the commands.\n" % (message, p))
|
|
577
|
+
return 2
|
|
578
|
+
|
|
579
|
+
if args["error"]:
|
|
580
|
+
return usage(args["error"])
|
|
581
|
+
flags = args["flags"]
|
|
582
|
+
if flags.get("version") or args["command"] == "version":
|
|
583
|
+
io.out("since-dev %s\n" % VERSION)
|
|
584
|
+
return 0
|
|
585
|
+
if flags.get("help") or args["command"] == "help":
|
|
586
|
+
io.out(help_text(p))
|
|
587
|
+
return 0
|
|
588
|
+
command = args["command"]
|
|
589
|
+
words = args["positional"]
|
|
590
|
+
if command == "changed":
|
|
591
|
+
if len(words) != 1:
|
|
592
|
+
return usage("Too many words: %s" % words[1] if words else "%s changed needs a package name, as %s changed react" % (p, p))
|
|
593
|
+
elif command is None or command == "check":
|
|
594
|
+
if len(words) > 1:
|
|
595
|
+
return usage("Too many words: %s" % words[1])
|
|
596
|
+
if command is None and len(words) == 1 and "/" not in words[0]:
|
|
597
|
+
return usage("Unknown command: %s" % words[0])
|
|
598
|
+
elif words:
|
|
599
|
+
return usage("Too many words: %s" % words[0])
|
|
600
|
+
|
|
601
|
+
fail_on = first(flags.get("fail-on"), "high")
|
|
602
|
+
if fail_on not in FAIL_ON:
|
|
603
|
+
return usage("--fail-on takes low, medium, high, critical or none.")
|
|
604
|
+
within = None
|
|
605
|
+
if "within" in flags:
|
|
606
|
+
if not re.fullmatch(r"[0-9]{1,4}d?", flags["within"]) or int(flags["within"].rstrip("d")) < 1:
|
|
607
|
+
return usage("--within takes a number of days, as 30d.")
|
|
608
|
+
within = int(flags["within"].rstrip("d"))
|
|
609
|
+
max_files = 10
|
|
610
|
+
if "max-files" in flags:
|
|
611
|
+
if not re.fullmatch(r"[0-9]{1,3}", flags["max-files"]) or int(flags["max-files"]) < 1:
|
|
612
|
+
return usage("--max-files takes a number from 1 to 999.")
|
|
613
|
+
max_files = int(flags["max-files"])
|
|
614
|
+
|
|
615
|
+
api = re.sub(r"/+$", "", str(io.env.get("SINCE_API_URL") or DEFAULT_API))
|
|
616
|
+
color = bool(io.tty) and not flags.get("no-color") and not io.env.get("NO_COLOR") and io.env.get("TERM") != "dumb"
|
|
617
|
+
ctx = {
|
|
618
|
+
"io": io, "args": args, "p": p, "api": api, "c": text.Palette(color), "check": command == "check",
|
|
619
|
+
"key": read_key(io, api), "fail_on": fail_on, "within": within, "max_files": max_files,
|
|
620
|
+
}
|
|
621
|
+
if command == "calendar":
|
|
622
|
+
return calendar_command(ctx)
|
|
623
|
+
if command == "changed":
|
|
624
|
+
return changed_command(ctx)
|
|
625
|
+
if command == "init":
|
|
626
|
+
return init(io, p, bool(flags.get("dry-run")))
|
|
627
|
+
if command == "login":
|
|
628
|
+
return login_command(ctx)
|
|
629
|
+
if command == "logout":
|
|
630
|
+
return logout_command(ctx)
|
|
631
|
+
return repo_command(ctx)
|