alissa-tools-github-revloop 0.16.10__tar.gz → 0.16.11__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 (32) hide show
  1. {alissa_tools_github_revloop-0.16.10/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.11}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/alissa.py +115 -17
  3. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/loop.py +97 -4
  4. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/state.py +93 -0
  5. alissa_tools_github_revloop-0.16.11/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_revloop-0.16.10/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/config.py +0 -0
  18. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  19. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  20. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  21. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.10 → alissa_tools_github_revloop-0.16.11}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.10
3
+ Version: 0.16.11
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -153,29 +153,127 @@ def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
153
153
  )
154
154
 
155
155
 
156
+ def is_review_task_for(owner: str, repo: str, number: int, task: "Task") -> bool:
157
+ """Whether `task` is THE open CR2 review task for this PR.
158
+
159
+ The one predicate, shared by the search (`find_review_task`, over a whole
160
+ task list) and by the cache check (loop._review_task, over a single task
161
+ read back by ref). They must agree: a cached ref that the search would not
162
+ have returned is a mapping the daemon has to drop, and a divergence here
163
+ would either pin a wrong task forever or re-fetch the corpus every pass
164
+ while disagreeing with itself.
165
+ """
166
+ return bool(_title_pattern(owner, repo, number).match(task.title)) and task.is_open
167
+
168
+
169
+ def _task_from_row(row: object) -> "Task | None":
170
+ """One task out of a CLI payload row, or None when it carries no usable ref.
171
+
172
+ Shared by the list reader and the single-task reader so both agree on which
173
+ field is the resolvable ref.
174
+ """
175
+ if not isinstance(row, dict):
176
+ return None
177
+ # `taskNumber` is the ref the API resolves; `taskSeq` is a display
178
+ # ordinal and 404s as `TASK-<seq>`.
179
+ number = row.get("taskNumber")
180
+ if number is None:
181
+ return None
182
+ title = row.get("title")
183
+ status = row.get("status")
184
+ return Task(
185
+ ref=f"TASK-{number}",
186
+ title=title if isinstance(title, str) else "",
187
+ status=status if isinstance(status, str) else "",
188
+ )
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class TaskDetail:
193
+ """One task as `alissa task get` sees it: the task itself, plus the CR6
194
+ verdict envelopes already on it.
195
+
196
+ The two travel together because they come out of ONE payload. The decide
197
+ path needs both (is this still the PR's open review task? how many rounds
198
+ has it recorded?), and reading them separately would cost two task fetches
199
+ per PR per poll where the CLI already returns everything in one.
200
+ """
201
+
202
+ task: Task
203
+ verdicts: int
204
+
205
+
156
206
  class Alissa:
157
207
  def list_tasks(self) -> list[Task]:
208
+ """EVERY non-terminal task owned by this actor -- the expensive call.
209
+
210
+ `alissa task list` (CLI 0.1.0) exposes no server-side narrowing at all:
211
+ its only flags are `--json` and `--include-terminal`. Omitting the
212
+ latter is therefore the whole of the available filtering, and it is
213
+ already the default here -- validated and cancelled tasks never come
214
+ back. What remains is the actor's live corpus (hundreds of tasks,
215
+ ~250 KB), so the daemon's job is to call this RARELY rather than to
216
+ call it narrowly: see loop._review_task (persisted PR -> task mapping)
217
+ and loop._pass_task_list (at most one fetch per poll pass).
218
+ """
158
219
  data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
159
220
  tasks = []
160
- for row in data:
161
- # `taskNumber` is the ref the API resolves; `taskSeq` is a display
162
- # ordinal and 404s as `TASK-<seq>`.
163
- number = row.get("taskNumber")
164
- if number is None:
165
- continue
166
- tasks.append(
167
- Task(
168
- ref=f"TASK-{number}",
169
- title=row.get("title", ""),
170
- status=row.get("status", ""),
171
- )
172
- )
221
+ for row in data if isinstance(data, list) else []:
222
+ task = _task_from_row(row)
223
+ if task is not None:
224
+ tasks.append(task)
173
225
  return tasks
174
226
 
175
- def find_review_task(self, owner: str, repo: str, number: int) -> Task | None:
176
- """CR2: exactly one review task per PR. Reuse it across rounds (CR7)."""
177
- pattern = _title_pattern(owner, repo, number)
178
- matches = [t for t in self.list_tasks() if pattern.match(t.title) and t.is_open]
227
+ def get_task(self, ref: str) -> "TaskDetail | None":
228
+ """Read ONE task by ref: title, status and verdict count in one call.
229
+
230
+ This is what makes a cached PR -> review-task mapping usable. Resolving
231
+ the task by ref costs a single-task fetch; resolving it by searching
232
+ titles costs the actor's entire corpus, which is the read this whole
233
+ path exists to stop paying every poll.
234
+
235
+ None means "could not be read" and NOTHING more -- a deleted task and a
236
+ transient CLI failure are indistinguishable from here, so a caller must
237
+ not treat None as proof that a cached mapping is wrong (see
238
+ loop._review_task, which keeps the row and falls back to the search).
239
+ Never raises: the daemon polls forever and this runs inside every pass.
240
+ """
241
+ try:
242
+ data = run_json(["alissa", "task", "get", ref, "--json"], timeout=90)
243
+ except CommandError as exc:
244
+ log.warning("could not read task %s: %s", ref, exc)
245
+ return None
246
+ except Exception: # pragma: no cover - defence in depth
247
+ log.exception("unexpected failure reading task %s", ref)
248
+ return None
249
+
250
+ try:
251
+ task = _task_from_row(data)
252
+ if task is None:
253
+ return None
254
+ return TaskDetail(task=task, verdicts=self._count_verdicts(data))
255
+ except Exception: # pragma: no cover - defence in depth
256
+ log.exception("could not parse task payload for %s", ref)
257
+ return None
258
+
259
+ def find_review_task(
260
+ self,
261
+ owner: str,
262
+ repo: str,
263
+ number: int,
264
+ *,
265
+ tasks: "list[Task] | None" = None,
266
+ ) -> Task | None:
267
+ """CR2: exactly one review task per PR. Reuse it across rounds (CR7).
268
+
269
+ `tasks` supplies a corpus the caller already fetched, so several PRs
270
+ missing the cache in the SAME poll pass share one list call instead of
271
+ issuing an identical one each (the observed 2-4 same-second bursts).
272
+ Omitted -- the console-script path, and any caller with no pass to
273
+ scope to -- fetches its own.
274
+ """
275
+ pool = self.list_tasks() if tasks is None else tasks
276
+ matches = [t for t in pool if is_review_task_for(owner, repo, number, t)]
179
277
 
180
278
  if not matches:
181
279
  return None
@@ -27,6 +27,7 @@ from .alissa import (
27
27
  ManagedSession,
28
28
  SessionRef,
29
29
  Task,
30
+ is_review_task_for,
30
31
  session_repo_slug,
31
32
  )
32
33
  from .config import (
@@ -955,6 +956,16 @@ class ReviewWatcher:
955
956
  # rollup (two API calls) on every poll, forever, for every PR with an
956
957
  # owed approve. In-memory for the same reason _dry_run_drift is.
957
958
  self._dry_run_rollups: dict[tuple[str, int, int, str], str] = {}
959
+ # The task corpus THIS poll pass already fetched, or None until some
960
+ # PR in it misses the review-task cache. `alissa task list` returns
961
+ # every non-terminal task this actor owns (hundreds of rows, ~250 KB)
962
+ # and the list that answers one PR's search answers all of them, so a
963
+ # pass pays for it at most once instead of once per missing PR -- the
964
+ # same-second bursts of 2-4 identical fetches the census caught.
965
+ # In memory and reset per pass on purpose: this is a within-pass
966
+ # de-duplication, and a corpus that outlived its pass would answer the
967
+ # next one from titles and statuses that have since moved.
968
+ self._pass_tasks: list[Task] | None = None
958
969
  # Consecutive passes refused by the ledger gate in poll_once, and when
959
970
  # the refusal began -- the same streak-limit-then-escalate shape the
960
971
  # poll firewall uses, for the same reason: a read-only volume refuses
@@ -964,6 +975,84 @@ class ReviewWatcher:
964
975
  # that cannot be written.
965
976
  self._ledger_streak = Streak()
966
977
 
978
+ # -- the PR -> review-task mapping ---------------------------------------
979
+
980
+ def _pass_task_list(self) -> list[Task]:
981
+ """The actor's task corpus, fetched AT MOST ONCE per poll pass.
982
+
983
+ The fallback behind the review-task cache. Every PR that misses the
984
+ cache in a given pass needs the same corpus, so the first miss pays for
985
+ it and the rest read the memo -- see `_pass_tasks` for why it never
986
+ outlives the pass.
987
+
988
+ A fetch that raises propagates, exactly as the unmemoized call did: the
989
+ pass's caller turns it into a SKIPPED decision for that one PR and the
990
+ memo stays empty, so the next PR retries rather than inheriting a
991
+ failure it never saw.
992
+ """
993
+ if self._pass_tasks is None:
994
+ self._pass_tasks = self.alissa.list_tasks()
995
+ return self._pass_tasks
996
+
997
+ def _review_task(self, pr: PullRequest) -> tuple["Task | None", int]:
998
+ """This PR's open CR2 review task and its verdict count, cheaply.
999
+
1000
+ The decide path needs the review task on every pass of every open
1001
+ round, and the only way to FIND one is to search the actor's whole
1002
+ non-terminal task corpus by title -- a ~250 KB read that the daemon was
1003
+ paying once per candidate PR per poll, forever (issue #66). CR2 gives
1004
+ one review task per PR and CR7 reuses it across rounds, so the answer
1005
+ does not move once known: it is resolved once, persisted, and from then
1006
+ on read back by ref (one small task fetch, which the round count needed
1007
+ anyway).
1008
+
1009
+ Three outcomes, in the order they are tried:
1010
+
1011
+ * a cached ref that still reads as this PR's open review task -- the
1012
+ steady state, and no search at all;
1013
+ * a cached ref that is READABLE and no longer matches (validated,
1014
+ cancelled, retitled, or plain wrong) -- dropped, then searched for;
1015
+ * no cached ref, or one that could not be read -- searched for. An
1016
+ unreadable task is NOT a disproof, so the row survives a transient
1017
+ CLI failure and the pass just degrades to the old behaviour.
1018
+
1019
+ Fail-open is the whole contract: every degradation here lands on "do
1020
+ what the daemon did before the cache existed", and none of them can
1021
+ answer "no review task" unless a successful search actually said so.
1022
+ """
1023
+ cached = self.state.review_task(pr.full_name, pr.number)
1024
+ if cached is not None:
1025
+ detail = self.alissa.get_task(cached)
1026
+ if detail is not None:
1027
+ if is_review_task_for(pr.owner, pr.repo, pr.number, detail.task):
1028
+ return detail.task, detail.verdicts
1029
+ log.info(
1030
+ "%s: cached review task %s no longer matches (title=%r "
1031
+ "status=%s) — re-resolving",
1032
+ pr.slug, cached, detail.task.title, detail.task.status,
1033
+ )
1034
+ self.state.forget_review_task(pr.full_name, pr.number)
1035
+ else:
1036
+ log.warning(
1037
+ "%s: could not read cached review task %s — falling back "
1038
+ "to the task search for this pass (the mapping is kept: "
1039
+ "an unreadable task is not a wrong one)",
1040
+ pr.slug, cached,
1041
+ )
1042
+
1043
+ task = self.alissa.find_review_task(
1044
+ pr.owner, pr.repo, pr.number, tasks=self._pass_task_list()
1045
+ )
1046
+ if task is None:
1047
+ # A search that completed and found nothing IS a disproof: there is
1048
+ # no open review task for this PR, whatever the cache said.
1049
+ if cached is not None:
1050
+ self.state.forget_review_task(pr.full_name, pr.number)
1051
+ return None, 0
1052
+
1053
+ self.state.record_review_task(pr.full_name, pr.number, task.ref)
1054
+ return task, self.alissa.count_verdicts(task.ref)
1055
+
967
1056
  # -- per-PR decision ---------------------------------------------------
968
1057
 
969
1058
  def evaluate(self, owner: str, repo: str, number: int) -> Decision:
@@ -993,11 +1082,9 @@ class ReviewWatcher:
993
1082
  # Fall back to the substantive-review count only before the review task
994
1083
  # exists (round 1). Looked up here (not in _spawn) because both the count
995
1084
  # and convergence need it.
996
- task = self.alissa.find_review_task(owner, repo, number)
1085
+ task, verdicts = self._review_task(pr)
997
1086
  native = countable_rounds(my_reviews)
998
- completed = (
999
- self.alissa.count_verdicts(task.ref) if task is not None else native
1000
- )
1087
+ completed = verdicts if task is not None else native
1001
1088
 
1002
1089
  # A round is not over until its verdict exists as a native review by
1003
1090
  # the reviewer identity (issue #51). An envelope ahead of the native
@@ -2923,6 +3010,12 @@ class ReviewWatcher:
2923
3010
  # -- polling -----------------------------------------------------------
2924
3011
 
2925
3012
  def poll_once(self) -> list[tuple[str, Decision]]:
3013
+ # A new pass sees a new corpus. Cleared FIRST, before any early return
3014
+ # can skip it: a memo left over from the previous pass would be handed
3015
+ # to this one's title searches as if it were current, and it is the one
3016
+ # piece of per-pass state whose staleness would be invisible.
3017
+ self._pass_tasks = None
3018
+
2926
3019
  # THE LEDGER GATE (issue #62, PR #63 round-1 blocker). Nothing below
2927
3020
  # may run when the ledger cannot record what it does.
2928
3021
  #
@@ -9,6 +9,12 @@ was already escalated, and to count the operator re-entry acks that raise a
9
9
  single PR's effective cap. The ledger tolerates sessions dying or being
10
10
  killed behind its back: a reap record is bookkeeping, never a precondition.
11
11
 
12
+ The `review_tasks` table is a cache, not a ledger: it remembers which CR2
13
+ review task each PR resolved to so the decide path can read that one task by
14
+ ref instead of searching the actor's whole task corpus every poll. Every row is
15
+ re-checked on use and dropped when it stops matching, and losing the table
16
+ costs nothing but the search it was avoiding.
17
+
12
18
  The `poll_snapshots` table is a different animal from the ledger above: it
13
19
  records what each poll pass OBSERVED, not what the daemon must remember to
14
20
  avoid double-work. One row per pass carries the timing, the candidate count,
@@ -143,6 +149,22 @@ CREATE TABLE IF NOT EXISTS verdict_posts (
143
149
  PRIMARY KEY (repo, number, round)
144
150
  );
145
151
 
152
+ -- The PR -> CR2 review-task mapping, remembered so the decide path does not
153
+ -- have to search for it. CR2 guarantees one review task per PR and CR7 reuses
154
+ -- it across every round, so the mapping is stable for the PR's whole life --
155
+ -- which is exactly what makes it cacheable. A row here is a HINT, never a
156
+ -- fact: it is re-checked by ref on use and dropped when it stops matching, and
157
+ -- losing the whole table only costs one title search per PR (see
158
+ -- loop._review_task). That is why this is best-effort like the snapshots
159
+ -- above, not a correctness write.
160
+ CREATE TABLE IF NOT EXISTS review_tasks (
161
+ repo TEXT NOT NULL,
162
+ number INTEGER NOT NULL,
163
+ task_ref TEXT NOT NULL,
164
+ resolved_at INTEGER NOT NULL,
165
+ PRIMARY KEY (repo, number)
166
+ );
167
+
146
168
  CREATE TABLE IF NOT EXISTS poll_snapshots (
147
169
  id INTEGER PRIMARY KEY AUTOINCREMENT,
148
170
  ts INTEGER NOT NULL,
@@ -852,6 +874,77 @@ class State:
852
874
 
853
875
  # -- poll snapshots (the console sidecar's exhaust buffer) -------------
854
876
 
877
+ def review_task(self, repo: str, number: int) -> "str | None":
878
+ """The review-task ref last resolved for this PR, or None.
879
+
880
+ None is the FIRST-SIGHTING answer and also the answer after a database
881
+ that could not be written: both mean "search for it", which is what the
882
+ daemon did unconditionally before this table existed. There is nothing
883
+ a caller may conclude from None beyond that.
884
+
885
+ The only READ in this class that absorbs a database error, and for the
886
+ same reason its writes do: this table is an optimization, so a ledger
887
+ that cannot answer must cost the daemon a task search, never a review.
888
+ Everything else here is ledger state whose loss the caller has to see.
889
+ """
890
+ try:
891
+ row = self._db.execute(
892
+ "SELECT task_ref FROM review_tasks WHERE repo=? AND number=?",
893
+ (repo, number),
894
+ ).fetchone()
895
+ except sqlite3.DatabaseError as exc:
896
+ log.warning(
897
+ "state: review-task cache unreadable (%s: %s) — this pass "
898
+ "resolves %s#%d by searching, as it did before the cache",
899
+ type(exc).__name__, exc, repo, number,
900
+ )
901
+ return None
902
+ return None if row is None else str(row["task_ref"])
903
+
904
+ def record_review_task(self, repo: str, number: int, task_ref: str) -> bool:
905
+ """Remember which review task a PR resolved to. Best-effort.
906
+
907
+ REPLACE, not IGNORE: re-resolving is how a mapping is corrected, so the
908
+ newest answer has to win. `resolved_at` is when the mapping was last
909
+ confirmed by a search, which is the audit question worth answering.
910
+
911
+ A database error here is absorbed exactly like a snapshot's: the cache
912
+ is an optimization, and a pass that cannot persist it still decides the
913
+ round correctly -- it just pays the search again next time.
914
+ """
915
+ return self._write_telemetry(
916
+ lambda: self._replace_review_task(repo, number, task_ref),
917
+ "review-task cache write",
918
+ )
919
+
920
+ def _replace_review_task(self, repo: str, number: int, task_ref: str) -> None:
921
+ self._db.execute(
922
+ "INSERT OR REPLACE INTO review_tasks "
923
+ "(repo, number, task_ref, resolved_at) VALUES (?,?,?,?)",
924
+ (repo, number, task_ref, int(time.time())),
925
+ )
926
+ self._db.commit()
927
+
928
+ def forget_review_task(self, repo: str, number: int) -> bool:
929
+ """Drop a mapping that has been DISPROVED. Best-effort.
930
+
931
+ Only ever called on a positive disproof -- the task was read and is no
932
+ longer this PR's open review task, or a fresh search found none. Never
933
+ on a failed read: an unreadable task is not a wrong mapping, and
934
+ forgetting one on a transient CLI error would throw away a good cache
935
+ entry every time the API hiccups.
936
+ """
937
+ return self._write_telemetry(
938
+ lambda: self._delete_review_task(repo, number),
939
+ "review-task cache invalidation",
940
+ )
941
+
942
+ def _delete_review_task(self, repo: str, number: int) -> None:
943
+ self._db.execute(
944
+ "DELETE FROM review_tasks WHERE repo=? AND number=?", (repo, number)
945
+ )
946
+ self._db.commit()
947
+
855
948
  def record_snapshot(
856
949
  self,
857
950
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.10
3
+ Version: 0.16.11
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera