alissa-tools-github-orcloop 0.18.0__tar.gz → 0.18.2__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.
- {alissa_tools_github_orcloop-0.18.0/src/main/alissa_tools_github_orcloop.egg-info → alissa_tools_github_orcloop-0.18.2}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/setup.py +4 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/__main__.py +181 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/alissa_client.py +39 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/config.py +419 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/ghclient.py +100 -0
- alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/lever.py +198 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/loop.py +93 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/loop_events.py +25 -2
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/merge.py +403 -37
- alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/seats.py +614 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/sentinel.py +148 -6
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/state.py +76 -0
- alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/version +1 -0
- alissa_tools_github_orcloop-0.18.2/src/main/alissa/tools/github/orcloop/watchdog.py +543 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2/src/main/alissa_tools_github_orcloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa_tools_github_orcloop.egg-info/SOURCES.txt +3 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa_tools_github_orcloop.egg-info/entry_points.txt +1 -0
- alissa_tools_github_orcloop-0.18.0/src/main/alissa/tools/github/orcloop/version +0 -1
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/LICENSE +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/MANIFEST.in +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/NOTICE +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/README.md +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/requirements.txt +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/setup.cfg +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/__init__.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/alissa_cli.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/console.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/fleet_vitals.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/markers.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/proc.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/version.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/workplan.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/workplan_html.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa/tools/github/orcloop/workplan_state.py +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa_tools_github_orcloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_orcloop-0.18.0 → alissa_tools_github_orcloop-0.18.2}/src/main/alissa_tools_github_orcloop.egg-info/top_level.txt +0 -0
|
@@ -56,6 +56,10 @@ setup(
|
|
|
56
56
|
entry_points={
|
|
57
57
|
"console_scripts": [
|
|
58
58
|
"alissa-orcloop=alissa.tools.github.orcloop.__main__:main",
|
|
59
|
+
# The sentinel pass's bounded lever over the seats' consoles
|
|
60
|
+
# (W4, issue #112): reads the passcodes from the environment
|
|
61
|
+
# itself and never prints one.
|
|
62
|
+
"orcloop-lever=alissa.tools.github.orcloop.lever:main",
|
|
59
63
|
]
|
|
60
64
|
},
|
|
61
65
|
install_requires=requirements,
|
|
@@ -36,8 +36,10 @@ from .alissa_client import AlissaClient, AlissaError
|
|
|
36
36
|
from .config import (
|
|
37
37
|
FEED_BOW,
|
|
38
38
|
MERGE_METHODS,
|
|
39
|
+
MERGE_POLICIES,
|
|
39
40
|
RELEASE_FEEDS,
|
|
40
41
|
REPOS_SOURCES,
|
|
42
|
+
WATCHDOG_MODES,
|
|
41
43
|
Config,
|
|
42
44
|
load_config_file,
|
|
43
45
|
resolve_config_path,
|
|
@@ -446,6 +448,130 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
446
448
|
help="merge only: hold a head that reports no checks, even if the config "
|
|
447
449
|
"allows it",
|
|
448
450
|
)
|
|
451
|
+
# --- the merge policy (issue #116) ------------------------------------
|
|
452
|
+
over.add_argument(
|
|
453
|
+
"--merge-policy",
|
|
454
|
+
choices=MERGE_POLICIES,
|
|
455
|
+
help="merge only: strict (auto only), standard (the default: auto only, "
|
|
456
|
+
"today's edge) or yolo (also merge an `operator — <class>: …` approve "
|
|
457
|
+
"whose class is in --merge-yolo-merge-class, after posting the validation "
|
|
458
|
+
"floor on the origin task; unclassed and hold-listed classes hold)",
|
|
459
|
+
)
|
|
460
|
+
over.add_argument(
|
|
461
|
+
"--merge-yolo-merge-class",
|
|
462
|
+
dest="merge_yolo_merge_classes",
|
|
463
|
+
action="append",
|
|
464
|
+
metavar="CLASS",
|
|
465
|
+
help="merge only (yolo): an operator class the policy MERGES; repeatable, "
|
|
466
|
+
"REPLACES the default set (unverified-ux, unverified-runtime)",
|
|
467
|
+
)
|
|
468
|
+
over.add_argument(
|
|
469
|
+
"--merge-yolo-hold-class",
|
|
470
|
+
dest="merge_yolo_hold_classes",
|
|
471
|
+
action="append",
|
|
472
|
+
metavar="CLASS",
|
|
473
|
+
help="merge only (yolo): an operator class the policy HOLDS; repeatable, "
|
|
474
|
+
"REPLACES the default set (schema-migration, data-backfill, secrets-env, "
|
|
475
|
+
"infra-deploy, billing, security, release-act). A class in both lists is "
|
|
476
|
+
"a fatal startup error",
|
|
477
|
+
)
|
|
478
|
+
over.add_argument(
|
|
479
|
+
"--merge-allow-no-checks-repo",
|
|
480
|
+
dest="merge_allow_no_checks_repos",
|
|
481
|
+
action="append",
|
|
482
|
+
metavar="OWNER/REPO",
|
|
483
|
+
help="merge only: a repository whose heads may merge with no check reported "
|
|
484
|
+
"(the per-repo scope of --merge-allow-no-checks); repeatable",
|
|
485
|
+
)
|
|
486
|
+
follow_ups = over.add_mutually_exclusive_group()
|
|
487
|
+
follow_ups.add_argument(
|
|
488
|
+
"--merge-follow-ups",
|
|
489
|
+
dest="merge_follow_ups",
|
|
490
|
+
action="store_true",
|
|
491
|
+
default=None,
|
|
492
|
+
help="merge only: enumerate follow-up pull requests (`Follow-up-To: #k`) as "
|
|
493
|
+
"candidates (default: on under yolo, off otherwise)",
|
|
494
|
+
)
|
|
495
|
+
follow_ups.add_argument(
|
|
496
|
+
"--no-merge-follow-ups",
|
|
497
|
+
dest="merge_follow_ups",
|
|
498
|
+
action="store_false",
|
|
499
|
+
help="merge only: never enumerate follow-up pull requests",
|
|
500
|
+
)
|
|
501
|
+
over.add_argument(
|
|
502
|
+
"--merge-followup-candidates-per-repo",
|
|
503
|
+
type=int,
|
|
504
|
+
metavar="N",
|
|
505
|
+
help="merge only: at most N follow-up candidates per repo per poll (default 5)",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# --- the flow watchdog and the seat consoles (W4, issue #112) --------
|
|
509
|
+
over.add_argument(
|
|
510
|
+
"--seat-consoles-json",
|
|
511
|
+
metavar="JSON",
|
|
512
|
+
help="the seats' consoles, as a JSON list: [{\"seat\": \"devloop\", \"url\": "
|
|
513
|
+
"\"http://dark-devloop-shared.railway.internal:8080\", \"passcodeEnv\": "
|
|
514
|
+
"\"ALISSA_ORC_DEVLOOP_PASSCODE\"}, …] — the passcode itself comes from the "
|
|
515
|
+
"named environment variable, never from here. Prefer "
|
|
516
|
+
"ALISSA_ORC_SEAT_CONSOLES_JSON",
|
|
517
|
+
)
|
|
518
|
+
over.add_argument(
|
|
519
|
+
"--watchdog",
|
|
520
|
+
choices=WATCHDOG_MODES,
|
|
521
|
+
metavar="{" + ",".join(WATCHDOG_MODES) + "}",
|
|
522
|
+
help="the flow watchdog: 'off' reads nothing; 'observe' (the default) reads "
|
|
523
|
+
"every seat console each poll and narrates what it would pull; 'on' climbs "
|
|
524
|
+
"decline → kill → retry, one rung per poll, on sessions a seat reports "
|
|
525
|
+
"waiting past --watchdog-grace-seconds, pages account-level conditions "
|
|
526
|
+
"and holds releases into that seat's repos",
|
|
527
|
+
)
|
|
528
|
+
over.add_argument(
|
|
529
|
+
"--watchdog-grace-seconds",
|
|
530
|
+
type=int,
|
|
531
|
+
metavar="SECONDS",
|
|
532
|
+
help="watchdog only: how long a waiting session is the seat's own responder's "
|
|
533
|
+
"to answer before the orchestrator climbs a rung (default 180; >= 0)",
|
|
534
|
+
)
|
|
535
|
+
over.add_argument(
|
|
536
|
+
"--watchdog-retry-seconds",
|
|
537
|
+
type=int,
|
|
538
|
+
metavar="SECONDS",
|
|
539
|
+
help="watchdog only: after a kill, how long the lane may show no respawn "
|
|
540
|
+
"before the seat's console is asked to retry it (default 600; >= 0)",
|
|
541
|
+
)
|
|
542
|
+
over.add_argument(
|
|
543
|
+
"--watchdog-idle-minutes",
|
|
544
|
+
type=int,
|
|
545
|
+
metavar="MINUTES",
|
|
546
|
+
help="watchdog only: a live, non-waiting session idle longer than this has "
|
|
547
|
+
"its pane read; unclassified idleness is narrated and paged once, never "
|
|
548
|
+
"killed (default 45; >= 1)",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
levers = over.add_mutually_exclusive_group()
|
|
552
|
+
levers.add_argument(
|
|
553
|
+
"--sentinel-levers",
|
|
554
|
+
dest="sentinel_levers",
|
|
555
|
+
action="store_true",
|
|
556
|
+
default=None,
|
|
557
|
+
help="sentinel only: let the pass pull the bounded `orcloop-lever` verbs "
|
|
558
|
+
"(pane, answer decline/escape, kill, retry) after a pane capture, at most "
|
|
559
|
+
"--sentinel-max-levers per pass, every pull listed in its findings (off by "
|
|
560
|
+
"default — with it off the directive is the report-only one, unchanged)",
|
|
561
|
+
)
|
|
562
|
+
levers.add_argument(
|
|
563
|
+
"--no-sentinel-levers",
|
|
564
|
+
dest="sentinel_levers",
|
|
565
|
+
action="store_false",
|
|
566
|
+
help="sentinel only: keep the pass report-only, even if the config enables levers",
|
|
567
|
+
)
|
|
568
|
+
over.add_argument(
|
|
569
|
+
"--sentinel-max-levers",
|
|
570
|
+
type=int,
|
|
571
|
+
metavar="N",
|
|
572
|
+
help="sentinel only: how many lever ACTIONS (decline/escape/kill/retry) one pass "
|
|
573
|
+
"may make — pane captures are not counted (default 3; >= 0)",
|
|
574
|
+
)
|
|
449
575
|
|
|
450
576
|
dry = over.add_mutually_exclusive_group()
|
|
451
577
|
dry.add_argument(
|
|
@@ -508,7 +634,26 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
508
634
|
"merge_hold_label": args.merge_hold_label,
|
|
509
635
|
"merge_maintain_label": args.merge_maintain_label,
|
|
510
636
|
"merge_allow_no_checks": args.merge_allow_no_checks,
|
|
637
|
+
"merge_policy": args.merge_policy,
|
|
638
|
+
"merge_yolo_merge_classes": (
|
|
639
|
+
tuple(args.merge_yolo_merge_classes) if args.merge_yolo_merge_classes else None
|
|
640
|
+
),
|
|
641
|
+
"merge_yolo_hold_classes": (
|
|
642
|
+
tuple(args.merge_yolo_hold_classes) if args.merge_yolo_hold_classes else None
|
|
643
|
+
),
|
|
644
|
+
"merge_allow_no_checks_repos": (
|
|
645
|
+
tuple(args.merge_allow_no_checks_repos) if args.merge_allow_no_checks_repos else None
|
|
646
|
+
),
|
|
647
|
+
"merge_follow_ups": args.merge_follow_ups,
|
|
648
|
+
"merge_followup_candidates_per_repo": args.merge_followup_candidates_per_repo,
|
|
511
649
|
"fleet_vitals_enabled": args.fleet_vitals_enabled,
|
|
650
|
+
"seat_consoles_json": args.seat_consoles_json,
|
|
651
|
+
"watchdog": args.watchdog,
|
|
652
|
+
"watchdog_grace_seconds": args.watchdog_grace_seconds,
|
|
653
|
+
"watchdog_retry_seconds": args.watchdog_retry_seconds,
|
|
654
|
+
"watchdog_idle_minutes": args.watchdog_idle_minutes,
|
|
655
|
+
"sentinel_levers": args.sentinel_levers,
|
|
656
|
+
"sentinel_max_levers": args.sentinel_max_levers,
|
|
512
657
|
}
|
|
513
658
|
|
|
514
659
|
|
|
@@ -647,6 +792,29 @@ def log_effective_config(config: Config) -> None:
|
|
|
647
792
|
)
|
|
648
793
|
else:
|
|
649
794
|
log.debug("sentinel edge: disabled")
|
|
795
|
+
if config.sentinel_enabled and config.sentinel_levers:
|
|
796
|
+
log.info(
|
|
797
|
+
"sentinel levers: ON — a pass may pull at most %d bounded `orcloop-lever` "
|
|
798
|
+
"action(s) after a pane capture (captures not counted)", config.sentinel_max_levers,
|
|
799
|
+
)
|
|
800
|
+
if config.watchdog_active:
|
|
801
|
+
# Seat names, URLs and the passcode VARIABLE names -- never a value.
|
|
802
|
+
log.info(
|
|
803
|
+
"flow watchdog: %s — seats %s; grace %ds, retry after %ds, idle floor %dmin%s",
|
|
804
|
+
config.watchdog.upper(),
|
|
805
|
+
", ".join(
|
|
806
|
+
f"{seat.seat} ({seat.url}, passcode from {seat.passcode_env}"
|
|
807
|
+
+ (f", repos {', '.join(seat.repos)}" if seat.repos else ", every repo") + ")"
|
|
808
|
+
for seat in config.seat_consoles
|
|
809
|
+
),
|
|
810
|
+
config.watchdog_grace_seconds, config.watchdog_retry_seconds,
|
|
811
|
+
config.watchdog_idle_minutes,
|
|
812
|
+
"" if config.watchdog_pulls else " (narrating only, pulling nothing)",
|
|
813
|
+
)
|
|
814
|
+
elif config.seat_consoles:
|
|
815
|
+
log.info("flow watchdog: OFF (%d seat console(s) declared, none read)", len(config.seat_consoles))
|
|
816
|
+
else:
|
|
817
|
+
log.debug("flow watchdog: no seat consoles declared")
|
|
650
818
|
if config.merge_when_ready:
|
|
651
819
|
log.info(
|
|
652
820
|
"merge edge: ON — merging PRs by %s on an approve from %s carrying "
|
|
@@ -656,7 +824,19 @@ def log_effective_config(config: Config) -> None:
|
|
|
656
824
|
", ".join("@" + login for login in config.merge_reviewer_logins),
|
|
657
825
|
config.merge_method, config.merge_hold_label, config.merge_maintain_label,
|
|
658
826
|
", ".join(config.merge_hold_paths) or "(none)",
|
|
659
|
-
"merged" if config.merge_allow_no_checks else
|
|
827
|
+
"merged" if config.merge_allow_no_checks else (
|
|
828
|
+
f"merged in {', '.join(config.merge_allow_no_checks_repos)}, held elsewhere"
|
|
829
|
+
if config.merge_allow_no_checks_repos else "held"
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
log.info(
|
|
833
|
+
"merge policy: %s — yolo merges classes %s, holds classes %s (unclassed and "
|
|
834
|
+
"unlisted hold; hard-stop paths hold in every policy); follow-up PRs %s",
|
|
835
|
+
config.merge_policy,
|
|
836
|
+
", ".join(config.merge_yolo_merge_classes) or "(none)",
|
|
837
|
+
", ".join(config.merge_yolo_hold_classes) or "(none)",
|
|
838
|
+
f"enumerated (at most {config.merge_followup_candidates_per_repo} per repo per poll)"
|
|
839
|
+
if config.merge_follow_ups else "not enumerated",
|
|
660
840
|
)
|
|
661
841
|
else:
|
|
662
842
|
log.debug("merge edge: disabled")
|
|
@@ -638,6 +638,11 @@ class Task:
|
|
|
638
638
|
# Defaults to `full`: a `Task` built by hand carries whatever its author put
|
|
639
639
|
# in it, and treating that as partial would hold releases on nothing.
|
|
640
640
|
view: str = VIEW_FULL
|
|
641
|
+
# The task's `reporterId`, verbatim -- the actor the merge policy's
|
|
642
|
+
# validation floor (issue #116) assigns the "Validate after merge" task
|
|
643
|
+
# to. "" when the payload omitted it, in which case the downstream task
|
|
644
|
+
# defaults to the calling actor and the floor says so.
|
|
645
|
+
reporter_id: str = ""
|
|
641
646
|
|
|
642
647
|
|
|
643
648
|
def _query(params: dict) -> str:
|
|
@@ -1311,6 +1316,7 @@ class AlissaClient:
|
|
|
1311
1316
|
context_count=context_count,
|
|
1312
1317
|
updated_at=updated_at,
|
|
1313
1318
|
view=payload_view,
|
|
1319
|
+
reporter_id=str(task.get("reporterId") or ""),
|
|
1314
1320
|
)
|
|
1315
1321
|
|
|
1316
1322
|
def get_task_context(self, ref: str) -> list[ContextItem]:
|
|
@@ -1399,6 +1405,39 @@ class AlissaClient:
|
|
|
1399
1405
|
{"content": body},
|
|
1400
1406
|
)
|
|
1401
1407
|
|
|
1408
|
+
def create_downstream_task(
|
|
1409
|
+
self, ref: str, *, title: str, definition_of_done: str, description: str,
|
|
1410
|
+
criteria: "Sequence[dict]", assign_to_actor_id: str = "",
|
|
1411
|
+
) -> str:
|
|
1412
|
+
"""Create a task DOWNSTREAM of `ref` (`POST /v1/tasks/:id/downstream`)
|
|
1413
|
+
and return the new task's opaque id -- all the create answers
|
|
1414
|
+
(`{"taskId": …}`; the number is a `get_task` away). The merge policy's
|
|
1415
|
+
validation floor (issue #116) is the one caller: the "Validate after
|
|
1416
|
+
merge" task on the origin task, assigned to the origin's reporter.
|
|
1417
|
+
|
|
1418
|
+
`criteria` is the API's shape verbatim (`{id, type, description}`
|
|
1419
|
+
objects -- plain strings are refused with a 400). Permission is the
|
|
1420
|
+
parent's OWNER, which the daemon's token is on the tasks it released;
|
|
1421
|
+
a refusal comes back as the base `AlissaError` and the caller decides
|
|
1422
|
+
(the floor skips the merge)."""
|
|
1423
|
+
body: dict = {
|
|
1424
|
+
"title": title,
|
|
1425
|
+
"definitionOfDone": definition_of_done,
|
|
1426
|
+
"description": description,
|
|
1427
|
+
"validationCriteria": list(criteria),
|
|
1428
|
+
}
|
|
1429
|
+
if assign_to_actor_id:
|
|
1430
|
+
body["assignToActorId"] = assign_to_actor_id
|
|
1431
|
+
payload = self._request(
|
|
1432
|
+
"POST",
|
|
1433
|
+
f"/v1/tasks/{urllib.parse.quote(str(ref), safe='')}/downstream",
|
|
1434
|
+
body,
|
|
1435
|
+
)
|
|
1436
|
+
task_id = payload.get("taskId") if isinstance(payload, dict) else None
|
|
1437
|
+
if not isinstance(task_id, str) or not task_id:
|
|
1438
|
+
raise AlissaError(0, payload, "downstream create answered no taskId")
|
|
1439
|
+
return task_id
|
|
1440
|
+
|
|
1402
1441
|
def post_loop_events(self, events: "Sequence[dict]") -> dict:
|
|
1403
1442
|
"""Push one batch of loop events (`POST /v1/loop-events`, O34).
|
|
1404
1443
|
|