@laitszkin/apollo-toolkit 2.11.1 → 2.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/analyse-app-logs/README.md +12 -0
- package/analyse-app-logs/SKILL.md +6 -1
- package/analyse-app-logs/agents/openai.yaml +1 -1
- package/analyse-app-logs/scripts/filter_logs_by_time.py +64 -0
- package/analyse-app-logs/scripts/log_cli_utils.py +112 -0
- package/analyse-app-logs/scripts/search_logs.py +137 -0
- package/analyse-app-logs/tests/test_filter_logs_by_time.py +95 -0
- package/analyse-app-logs/tests/test_search_logs.py +100 -0
- package/commit-and-push/SKILL.md +17 -10
- package/develop-new-features/SKILL.md +17 -4
- package/develop-new-features/references/testing-e2e.md +1 -0
- package/enhance-existing-features/SKILL.md +20 -4
- package/enhance-existing-features/references/e2e-tests.md +1 -0
- package/generate-spec/SKILL.md +18 -3
- package/generate-spec/references/templates/checklist.md +30 -6
- package/generate-spec/references/templates/spec.md +7 -2
- package/maintain-skill-catalog/SKILL.md +3 -1
- package/open-github-issue/README.md +48 -6
- package/open-github-issue/SKILL.md +80 -5
- package/open-github-issue/agents/openai.yaml +2 -2
- package/open-github-issue/scripts/open_github_issue.py +174 -1
- package/open-github-issue/tests/test_open_github_issue.py +79 -0
- package/package.json +1 -1
- package/read-github-issue/SKILL.md +83 -0
- package/read-github-issue/agents/openai.yaml +4 -0
- package/{fix-github-issues/scripts/list_issues.py → read-github-issue/scripts/find_issues.py} +1 -1
- package/read-github-issue/scripts/read_issue.py +108 -0
- package/{fix-github-issues/tests/test_list_issues.py → read-github-issue/tests/test_find_issues.py} +3 -3
- package/read-github-issue/tests/test_read_issue.py +109 -0
- package/fix-github-issues/SKILL.md +0 -105
- package/fix-github-issues/agents/openai.yaml +0 -4
package/AGENTS.md
CHANGED
|
@@ -13,7 +13,7 @@ This repository enables users to install and run a curated set of reusable agent
|
|
|
13
13
|
|
|
14
14
|
- Users can align project documentation with the current codebase.
|
|
15
15
|
- Users can consolidate completed project specs into a standardized README and categorized project documentation set, then archive the consumed planning files.
|
|
16
|
-
- Users can investigate application logs and produce evidence-backed root-cause findings.
|
|
16
|
+
- Users can investigate application logs, slice them to precise time windows, search by keyword or regex, and produce evidence-backed root-cause findings.
|
|
17
17
|
- Users can answer repository-backed questions with additional web research when needed.
|
|
18
18
|
- Users can commit and push local changes without performing version or release work.
|
|
19
19
|
- Users can manage Codex user-preference memory by reviewing the last 24 hours of chats, storing categorized memory documents under `~/.codex/memory`, and syncing a memory index into `~/.codex/AGENTS.md`.
|
|
@@ -28,7 +28,7 @@ This repository enables users to install and run a curated set of reusable agent
|
|
|
28
28
|
- Users can extend existing features in a brownfield codebase with required tests and approvals.
|
|
29
29
|
- Users can propose product features from an existing codebase and publish accepted proposals.
|
|
30
30
|
- Users can discover reproducible edge-case risks and report prioritized hardening gaps.
|
|
31
|
-
- Users can
|
|
31
|
+
- Users can read, filter, and inspect remote GitHub issues before planning follow-up work.
|
|
32
32
|
- Users can run evidence-first application security audits focused on confirmed vulnerabilities.
|
|
33
33
|
- Users can learn new or improved skills from recent Codex conversation history.
|
|
34
34
|
- Users can audit and maintain the skill catalog itself, including dependency classification and shared-skill extraction decisions.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to this repository are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [v2.11.3] - 2026-03-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Add bundled `analyse-app-logs` scripts for filtering logs by bounded time windows and searching by keyword or regex, with focused tests for both helpers.
|
|
11
|
+
- Add `read-github-issue` as a dedicated GitHub issue discovery skill with bundled scripts for finding issue candidates and reading a specific issue with comments.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Expand `open-github-issue` to support structured `performance`, `security`, `docs`, and `observability` issue categories in addition to `problem` and `feature`.
|
|
15
|
+
- Refocus the former `fix-github-issues` workflow into read-only GitHub issue discovery and inspection guidance instead of a hardcoded fixing workflow.
|
|
16
|
+
- Update repository capability docs and agent prompts to reflect the new GitHub issue-reading and log-search workflows.
|
|
17
|
+
|
|
18
|
+
## [v2.11.2] - 2026-03-23
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Update `develop-new-features` and `enhance-existing-features` so small localized work such as bug fixes, pure frontend polish, and simple adjustments can skip spec generation, while non-trivial feature work still uses approval-backed specs.
|
|
22
|
+
- Strengthen `generate-spec` so spec creation must verify relevant official documentation for external dependencies before writing requirements or scope.
|
|
23
|
+
- Refine spec templates so `spec.md` uses dedicated `In Scope` and `Out of Scope` sections, checklist completion uses structured completion records, and E2E versus integration decisions support multiple per-flow records without encouraging false checkbox completion.
|
|
24
|
+
|
|
7
25
|
## [v2.11.1] - 2026-03-23
|
|
8
26
|
|
|
9
27
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A curated skill catalog for Codex, OpenClaw, and Trae with a managed installer t
|
|
|
19
19
|
- enhance-existing-features
|
|
20
20
|
- feature-propose
|
|
21
21
|
- financial-research
|
|
22
|
-
-
|
|
22
|
+
- read-github-issue
|
|
23
23
|
- generate-spec
|
|
24
24
|
- harden-app-security
|
|
25
25
|
- improve-observability
|
|
@@ -142,7 +142,7 @@ Compatibility note:
|
|
|
142
142
|
|
|
143
143
|
- `generate-spec` is a local skill used by `develop-new-features` and `enhance-existing-features`.
|
|
144
144
|
- `maintain-skill-catalog` can conditionally use `find-skills`, but its install source is not verified in this repository, so it is intentionally omitted from the table.
|
|
145
|
-
- `
|
|
145
|
+
- `read-github-issue` uses GitHub CLI (`gh`) directly for remote issue discovery and inspection, so it does not add any extra skill dependency.
|
|
146
146
|
|
|
147
147
|
## Release publishing
|
|
148
148
|
|
|
@@ -11,6 +11,7 @@ This skill helps agents analyze logs end-to-end, correlate runtime signals with
|
|
|
11
11
|
- A strict evidence standard (log lines + code correlation + impact + confidence).
|
|
12
12
|
- A checklist to avoid false conclusions.
|
|
13
13
|
- A pattern catalog for common operational failures (timeouts, retry storms, auth errors, resource pressure, schema mismatch, race conditions, and dependency outages).
|
|
14
|
+
- Bundled CLI helpers for filtering logs to a specific time window and searching by keyword or regex.
|
|
14
15
|
- GitHub issue publication via the `open-github-issue` dependency skill.
|
|
15
16
|
- Issue language selection delegated to `open-github-issue` based on the target repository README: Chinese README -> localized issue body, otherwise English issue body.
|
|
16
17
|
|
|
@@ -20,6 +21,9 @@ This skill helps agents analyze logs end-to-end, correlate runtime signals with
|
|
|
20
21
|
- `agents/openai.yaml`: Agent interface metadata and default prompt.
|
|
21
22
|
- `references/investigation-checklist.md`: Investigation validation checklist.
|
|
22
23
|
- `references/log-signal-patterns.md`: Log signal pattern reference.
|
|
24
|
+
- `scripts/filter_logs_by_time.py`: Time-window log filtering helper.
|
|
25
|
+
- `scripts/search_logs.py`: Keyword / regex search helper with optional context.
|
|
26
|
+
- `scripts/log_cli_utils.py`: Shared timestamp parsing utilities.
|
|
23
27
|
- Dependency skill: `open-github-issue` for deterministic issue publishing.
|
|
24
28
|
|
|
25
29
|
## Installation
|
|
@@ -65,6 +69,14 @@ Best results come from including:
|
|
|
65
69
|
|
|
66
70
|
When the time window is not explicitly provided, the skill should first derive a bounded analysis window from a recent concrete event, such as the last container restart, and inspect only that slice before widening the search.
|
|
67
71
|
|
|
72
|
+
Useful bundled commands:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
python3 scripts/filter_logs_by_time.py app.log --start "2026-03-24T10:00:00Z" --end "2026-03-24T10:30:00Z"
|
|
76
|
+
python3 scripts/search_logs.py app.log --keyword timeout --keyword payment --mode all --after-context 3
|
|
77
|
+
python3 scripts/search_logs.py app.log --regex "request_id=ab12.*ERROR" --start "2026-03-24T10:00:00Z" --end "2026-03-24T10:15:00Z"
|
|
78
|
+
```
|
|
79
|
+
|
|
68
80
|
## Example
|
|
69
81
|
|
|
70
82
|
### Input prompt
|
|
@@ -15,7 +15,7 @@ description: Comprehensive application log investigation workflow that reads log
|
|
|
15
15
|
## Standards
|
|
16
16
|
|
|
17
17
|
- Evidence: Use a bounded investigation window and correlate log lines with code, runtime context, and concrete identifiers.
|
|
18
|
-
- Execution: Scope the incident, build a timeline, validate candidate issues, then prioritize and optionally publish them.
|
|
18
|
+
- Execution: Scope the incident, use the bundled scripts to cut logs down by time window or search terms, build a timeline, validate candidate issues, then prioritize and optionally publish them.
|
|
19
19
|
- Quality: Separate confirmed issues from hypotheses and include time-window, log, code, impact, and confidence evidence for each report.
|
|
20
20
|
- Output: Return incident summary, confirmed issues, hypotheses, monitoring improvements, and publication status.
|
|
21
21
|
|
|
@@ -38,9 +38,11 @@ Use this skill to analyze application logs systematically with the codebase and
|
|
|
38
38
|
- If the user does not provide a trustworthy window, derive one from a concrete runtime boundary first, such as the last container restart, pod recreation, deploy start, worker boot, or first failure after a known healthy state.
|
|
39
39
|
- Prefer analyzing logs only inside that bounded window first (for example, from the last restart until now) to avoid stale logs polluting the diagnosis; widen the window only when the bounded slice cannot explain the symptom.
|
|
40
40
|
- Identify relevant identifiers (trace ID, request ID, user ID, job ID, tx hash).
|
|
41
|
+
- Use `scripts/filter_logs_by_time.py` first when the raw log set is large and the incident window can be bounded.
|
|
41
42
|
2. Build a timeline from logs
|
|
42
43
|
- Extract key events in chronological order within the chosen window: deploys, config changes, warnings, errors, retries, and recoveries.
|
|
43
44
|
- Group repeated symptoms by signature (error type, message prefix, stack frame, endpoint).
|
|
45
|
+
- Use `scripts/search_logs.py` to narrow by error signature, IDs, endpoint names, or repeated keywords before summarizing the timeline.
|
|
44
46
|
3. Correlate across context
|
|
45
47
|
- Link related log lines using identifiers and timestamps.
|
|
46
48
|
- Map stack traces and log messages to exact code locations.
|
|
@@ -118,4 +120,7 @@ Use this structure in responses:
|
|
|
118
120
|
|
|
119
121
|
- `references/investigation-checklist.md`: Step-by-step checklist for evidence-driven log investigations.
|
|
120
122
|
- `references/log-signal-patterns.md`: Common log signatures, likely causes, validation hints, and false-positive guards.
|
|
123
|
+
- `scripts/filter_logs_by_time.py`: Filter raw logs to a bounded incident window from files or stdin.
|
|
124
|
+
- `scripts/search_logs.py`: Search logs by keyword or regex with optional time-window filtering and context lines.
|
|
125
|
+
- `scripts/log_cli_utils.py`: Shared timestamp parsing and stdin/file iteration utilities for the bundled log scripts.
|
|
121
126
|
- Dependency skill: `open-github-issue` for deterministic issue publishing with auth fallback and README language detection.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
interface:
|
|
2
2
|
display_name: "Analyse App Logs"
|
|
3
3
|
short_description: "Analyze logs with code context to detect issues"
|
|
4
|
-
default_prompt: "Use $analyse-app-logs to inspect application logs with code context, first anchor the investigation to a bounded recent time window such as the last container restart or deploy, identify evidence-backed issues, suggest concrete remediation steps, and delegate confirmed GitHub issue publication to $open-github-issue."
|
|
4
|
+
default_prompt: "Use $analyse-app-logs to inspect application logs with code context, first anchor the investigation to a bounded recent time window such as the last container restart or deploy, use the bundled log-filter and log-search scripts to narrow the evidence set precisely, identify evidence-backed issues, suggest concrete remediation steps, and delegate confirmed GitHub issue publication to $open-github-issue."
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from log_cli_utils import ensure_timezone, extract_timestamp, in_window, iter_input_lines, parse_cli_timestamp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_args() -> argparse.Namespace:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Filter log lines by timestamp window from files or stdin."
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("paths", nargs="*", help="Log file paths. Reads stdin when omitted.")
|
|
16
|
+
parser.add_argument("--start", help="Inclusive start timestamp.")
|
|
17
|
+
parser.add_argument("--end", help="Inclusive end timestamp.")
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--assume-timezone",
|
|
20
|
+
default="UTC",
|
|
21
|
+
help="Timezone for naive timestamps and --start/--end values. Default: UTC.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--keep-undated",
|
|
25
|
+
action="store_true",
|
|
26
|
+
help="Keep lines without a parseable timestamp.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--count-only",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="Print only the count of matching lines.",
|
|
32
|
+
)
|
|
33
|
+
return parser.parse_args()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> int:
|
|
37
|
+
args = parse_args()
|
|
38
|
+
assume_timezone = ensure_timezone(args.assume_timezone)
|
|
39
|
+
start = parse_cli_timestamp(args.start, assume_timezone) if args.start else None
|
|
40
|
+
end = parse_cli_timestamp(args.end, assume_timezone) if args.end else None
|
|
41
|
+
|
|
42
|
+
if start and end and start > end:
|
|
43
|
+
print("Error: --start must be earlier than or equal to --end.", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
matches = 0
|
|
47
|
+
for line in iter_input_lines(args.paths):
|
|
48
|
+
timestamp = extract_timestamp(line, assume_timezone)
|
|
49
|
+
if timestamp is None and not args.keep_undated:
|
|
50
|
+
continue
|
|
51
|
+
if timestamp is not None and not in_window(timestamp, start, end):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
matches += 1
|
|
55
|
+
if not args.count_only:
|
|
56
|
+
print(line)
|
|
57
|
+
|
|
58
|
+
if args.count_only:
|
|
59
|
+
print(matches)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Iterator, Sequence
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TIMESTAMP_PATTERN = re.compile(
|
|
13
|
+
r"(?P<timestamp>"
|
|
14
|
+
r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:\d{2})?"
|
|
15
|
+
r")"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
TIMESTAMP_FORMATS = (
|
|
19
|
+
"%Y-%m-%dT%H:%M:%S.%f%z",
|
|
20
|
+
"%Y-%m-%dT%H:%M:%S%z",
|
|
21
|
+
"%Y-%m-%d %H:%M:%S.%f%z",
|
|
22
|
+
"%Y-%m-%d %H:%M:%S%z",
|
|
23
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
24
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
25
|
+
"%Y-%m-%d %H:%M:%S.%f",
|
|
26
|
+
"%Y-%m-%d %H:%M:%S",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normalize_timestamp(raw: str) -> str:
|
|
31
|
+
value = raw.strip().replace(",", ".")
|
|
32
|
+
if value.endswith("Z"):
|
|
33
|
+
return value[:-1] + "+00:00"
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_cli_timestamp(raw: str, assume_timezone: timezone) -> datetime:
|
|
38
|
+
normalized = normalize_timestamp(raw)
|
|
39
|
+
for fmt in TIMESTAMP_FORMATS:
|
|
40
|
+
try:
|
|
41
|
+
parsed = datetime.strptime(normalized, fmt)
|
|
42
|
+
except ValueError:
|
|
43
|
+
continue
|
|
44
|
+
if parsed.tzinfo is None:
|
|
45
|
+
return parsed.replace(tzinfo=assume_timezone)
|
|
46
|
+
return parsed
|
|
47
|
+
raise argparse.ArgumentTypeError(f"invalid timestamp: {raw}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_timestamp(line: str, assume_timezone: timezone) -> datetime | None:
|
|
51
|
+
match = TIMESTAMP_PATTERN.search(line)
|
|
52
|
+
if not match:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
return parse_cli_timestamp(match.group("timestamp"), assume_timezone)
|
|
56
|
+
except argparse.ArgumentTypeError:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_timezone(raw: str) -> timezone:
|
|
61
|
+
if raw.upper() == "UTC":
|
|
62
|
+
return timezone.utc
|
|
63
|
+
|
|
64
|
+
match = re.fullmatch(r"([+-])(\d{2}):(\d{2})", raw)
|
|
65
|
+
if not match:
|
|
66
|
+
raise argparse.ArgumentTypeError("timezone must be UTC or ±HH:MM")
|
|
67
|
+
|
|
68
|
+
sign, hours, minutes = match.groups()
|
|
69
|
+
total_minutes = int(hours) * 60 + int(minutes)
|
|
70
|
+
if sign == "-":
|
|
71
|
+
total_minutes *= -1
|
|
72
|
+
return timezone.utc if total_minutes == 0 else timezone(timedelta(minutes=total_minutes))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def iter_input_lines(paths: Sequence[str]) -> Iterator[str]:
|
|
76
|
+
if not paths:
|
|
77
|
+
import sys
|
|
78
|
+
|
|
79
|
+
for line in sys.stdin:
|
|
80
|
+
yield line.rstrip("\n")
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
for raw_path in paths:
|
|
84
|
+
if raw_path == "-":
|
|
85
|
+
import sys
|
|
86
|
+
|
|
87
|
+
for line in sys.stdin:
|
|
88
|
+
yield line.rstrip("\n")
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
path = Path(raw_path)
|
|
92
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
93
|
+
for line in handle:
|
|
94
|
+
yield line.rstrip("\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def in_window(
|
|
98
|
+
timestamp: datetime | None,
|
|
99
|
+
start: datetime | None,
|
|
100
|
+
end: datetime | None,
|
|
101
|
+
) -> bool:
|
|
102
|
+
if timestamp is None:
|
|
103
|
+
return False
|
|
104
|
+
if start and timestamp < start:
|
|
105
|
+
return False
|
|
106
|
+
if end and timestamp > end:
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ensure_timezone(value: str) -> timezone:
|
|
112
|
+
return build_timezone(value)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
from log_cli_utils import ensure_timezone, extract_timestamp, in_window, iter_input_lines, parse_cli_timestamp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_args() -> argparse.Namespace:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description="Search log lines by keyword or regex, with optional time filtering."
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("paths", nargs="*", help="Log file paths. Reads stdin when omitted.")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--keyword",
|
|
21
|
+
action="append",
|
|
22
|
+
default=[],
|
|
23
|
+
help="Keyword to match. Repeat for multiple values.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--regex",
|
|
27
|
+
action="append",
|
|
28
|
+
default=[],
|
|
29
|
+
help="Regular expression to match. Repeat for multiple values.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--mode",
|
|
33
|
+
choices=["any", "all"],
|
|
34
|
+
default="any",
|
|
35
|
+
help="Require any or all patterns to match. Default: any.",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--ignore-case",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Case-insensitive matching for keywords and regex.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--start", help="Inclusive start timestamp.")
|
|
43
|
+
parser.add_argument("--end", help="Inclusive end timestamp.")
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--assume-timezone",
|
|
46
|
+
default="UTC",
|
|
47
|
+
help="Timezone for naive timestamps and --start/--end values. Default: UTC.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--before-context",
|
|
51
|
+
type=int,
|
|
52
|
+
default=0,
|
|
53
|
+
help="Print N lines of context before each match.",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--after-context",
|
|
57
|
+
type=int,
|
|
58
|
+
default=0,
|
|
59
|
+
help="Print N lines of context after each match.",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--count-only",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="Print only the number of matching lines.",
|
|
65
|
+
)
|
|
66
|
+
return parser.parse_args()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_matchers(args: argparse.Namespace) -> list[Callable[[str], bool]]:
|
|
70
|
+
flags = re.IGNORECASE if args.ignore_case else 0
|
|
71
|
+
matchers: list[Callable[[str], bool]] = []
|
|
72
|
+
|
|
73
|
+
for keyword in args.keyword:
|
|
74
|
+
needle = keyword.lower() if args.ignore_case else keyword
|
|
75
|
+
|
|
76
|
+
def match_keyword(line: str, needle: str = needle) -> bool:
|
|
77
|
+
haystack = line.lower() if args.ignore_case else line
|
|
78
|
+
return needle in haystack
|
|
79
|
+
|
|
80
|
+
matchers.append(match_keyword)
|
|
81
|
+
|
|
82
|
+
for pattern in args.regex:
|
|
83
|
+
compiled = re.compile(pattern, flags)
|
|
84
|
+
matchers.append(lambda line, compiled=compiled: bool(compiled.search(line)))
|
|
85
|
+
|
|
86
|
+
return matchers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def line_matches(line: str, matchers: list[Callable[[str], bool]], mode: str) -> bool:
|
|
90
|
+
if not matchers:
|
|
91
|
+
return True
|
|
92
|
+
results = [matcher(line) for matcher in matchers]
|
|
93
|
+
return any(results) if mode == "any" else all(results)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> int:
|
|
97
|
+
args = parse_args()
|
|
98
|
+
assume_timezone = ensure_timezone(args.assume_timezone)
|
|
99
|
+
start = parse_cli_timestamp(args.start, assume_timezone) if args.start else None
|
|
100
|
+
end = parse_cli_timestamp(args.end, assume_timezone) if args.end else None
|
|
101
|
+
if start and end and start > end:
|
|
102
|
+
print("Error: --start must be earlier than or equal to --end.", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
matchers = build_matchers(args)
|
|
106
|
+
matches = 0
|
|
107
|
+
before_buffer: deque[str] = deque(maxlen=args.before_context)
|
|
108
|
+
after_remaining = 0
|
|
109
|
+
|
|
110
|
+
for line in iter_input_lines(args.paths):
|
|
111
|
+
timestamp = extract_timestamp(line, assume_timezone)
|
|
112
|
+
if (start or end) and not in_window(timestamp, start, end):
|
|
113
|
+
before_buffer.append(line)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
is_match = line_matches(line, matchers, args.mode)
|
|
117
|
+
|
|
118
|
+
if is_match:
|
|
119
|
+
matches += 1
|
|
120
|
+
if not args.count_only:
|
|
121
|
+
while before_buffer:
|
|
122
|
+
print(before_buffer.popleft())
|
|
123
|
+
print(line)
|
|
124
|
+
after_remaining = args.after_context
|
|
125
|
+
elif after_remaining > 0 and not args.count_only:
|
|
126
|
+
print(line)
|
|
127
|
+
after_remaining -= 1
|
|
128
|
+
|
|
129
|
+
before_buffer.append(line)
|
|
130
|
+
|
|
131
|
+
if args.count_only:
|
|
132
|
+
print(matches)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from argparse import Namespace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "filter_logs_by_time.py"
|
|
15
|
+
SCRIPT_DIR = SCRIPT_PATH.parent
|
|
16
|
+
if str(SCRIPT_DIR) not in __import__("sys").path:
|
|
17
|
+
__import__("sys").path.insert(0, str(SCRIPT_DIR))
|
|
18
|
+
SPEC = importlib.util.spec_from_file_location("filter_logs_by_time", SCRIPT_PATH)
|
|
19
|
+
MODULE = importlib.util.module_from_spec(SPEC)
|
|
20
|
+
SPEC.loader.exec_module(MODULE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FilterLogsByTimeTests(unittest.TestCase):
|
|
24
|
+
def test_filters_lines_inside_window(self) -> None:
|
|
25
|
+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
|
|
26
|
+
handle.write(
|
|
27
|
+
"2026-03-24T10:00:00Z INFO boot\n"
|
|
28
|
+
"2026-03-24T10:05:00Z ERROR failed\n"
|
|
29
|
+
"2026-03-24T10:10:00Z INFO recovered\n"
|
|
30
|
+
)
|
|
31
|
+
path = handle.name
|
|
32
|
+
|
|
33
|
+
args = Namespace(
|
|
34
|
+
paths=[path],
|
|
35
|
+
start="2026-03-24T10:01:00Z",
|
|
36
|
+
end="2026-03-24T10:06:00Z",
|
|
37
|
+
assume_timezone="UTC",
|
|
38
|
+
keep_undated=False,
|
|
39
|
+
count_only=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch(
|
|
43
|
+
"sys.stdout", new_callable=io.StringIO
|
|
44
|
+
) as stdout:
|
|
45
|
+
code = MODULE.main()
|
|
46
|
+
|
|
47
|
+
self.assertEqual(code, 0)
|
|
48
|
+
self.assertEqual(stdout.getvalue().strip(), "2026-03-24T10:05:00Z ERROR failed")
|
|
49
|
+
|
|
50
|
+
def test_keep_undated_lines_when_requested(self) -> None:
|
|
51
|
+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
|
|
52
|
+
handle.write(
|
|
53
|
+
"2026-03-24T10:00:00Z INFO boot\n"
|
|
54
|
+
"plain undated line\n"
|
|
55
|
+
)
|
|
56
|
+
path = handle.name
|
|
57
|
+
|
|
58
|
+
args = Namespace(
|
|
59
|
+
paths=[path],
|
|
60
|
+
start=None,
|
|
61
|
+
end=None,
|
|
62
|
+
assume_timezone="UTC",
|
|
63
|
+
keep_undated=True,
|
|
64
|
+
count_only=False,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch(
|
|
68
|
+
"sys.stdout", new_callable=io.StringIO
|
|
69
|
+
) as stdout:
|
|
70
|
+
code = MODULE.main()
|
|
71
|
+
|
|
72
|
+
self.assertEqual(code, 0)
|
|
73
|
+
self.assertIn("plain undated line", stdout.getvalue())
|
|
74
|
+
|
|
75
|
+
def test_rejects_inverted_window(self) -> None:
|
|
76
|
+
args = Namespace(
|
|
77
|
+
paths=[],
|
|
78
|
+
start="2026-03-24T10:10:00Z",
|
|
79
|
+
end="2026-03-24T10:00:00Z",
|
|
80
|
+
assume_timezone="UTC",
|
|
81
|
+
keep_undated=False,
|
|
82
|
+
count_only=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch(
|
|
86
|
+
"sys.stderr", new_callable=io.StringIO
|
|
87
|
+
) as stderr:
|
|
88
|
+
code = MODULE.main()
|
|
89
|
+
|
|
90
|
+
self.assertEqual(code, 1)
|
|
91
|
+
self.assertIn("--start", stderr.getvalue())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
unittest.main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import io
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from argparse import Namespace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "search_logs.py"
|
|
15
|
+
SCRIPT_DIR = SCRIPT_PATH.parent
|
|
16
|
+
if str(SCRIPT_DIR) not in __import__("sys").path:
|
|
17
|
+
__import__("sys").path.insert(0, str(SCRIPT_DIR))
|
|
18
|
+
SPEC = importlib.util.spec_from_file_location("search_logs", SCRIPT_PATH)
|
|
19
|
+
MODULE = importlib.util.module_from_spec(SPEC)
|
|
20
|
+
SPEC.loader.exec_module(MODULE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SearchLogsTests(unittest.TestCase):
|
|
24
|
+
def test_keyword_search_respects_time_window(self) -> None:
|
|
25
|
+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
|
|
26
|
+
handle.write(
|
|
27
|
+
"2026-03-24T10:00:00Z INFO boot\n"
|
|
28
|
+
"2026-03-24T10:05:00Z ERROR payment timeout\n"
|
|
29
|
+
"2026-03-24T10:10:00Z ERROR payment timeout\n"
|
|
30
|
+
)
|
|
31
|
+
path = handle.name
|
|
32
|
+
|
|
33
|
+
args = Namespace(
|
|
34
|
+
paths=[path],
|
|
35
|
+
keyword=["timeout"],
|
|
36
|
+
regex=[],
|
|
37
|
+
mode="any",
|
|
38
|
+
ignore_case=False,
|
|
39
|
+
start="2026-03-24T10:01:00Z",
|
|
40
|
+
end="2026-03-24T10:06:00Z",
|
|
41
|
+
assume_timezone="UTC",
|
|
42
|
+
before_context=0,
|
|
43
|
+
after_context=0,
|
|
44
|
+
count_only=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch(
|
|
48
|
+
"sys.stdout", new_callable=io.StringIO
|
|
49
|
+
) as stdout:
|
|
50
|
+
code = MODULE.main()
|
|
51
|
+
|
|
52
|
+
self.assertEqual(code, 0)
|
|
53
|
+
self.assertEqual(
|
|
54
|
+
stdout.getvalue().strip(),
|
|
55
|
+
"2026-03-24T10:05:00Z ERROR payment timeout",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def test_all_mode_requires_every_matcher(self) -> None:
|
|
59
|
+
matchers = [
|
|
60
|
+
lambda line: "timeout" in line,
|
|
61
|
+
lambda line: "payment" in line,
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
self.assertTrue(MODULE.line_matches("payment timeout", matchers, "all"))
|
|
65
|
+
self.assertFalse(MODULE.line_matches("timeout only", matchers, "all"))
|
|
66
|
+
|
|
67
|
+
def test_count_only_reports_match_total(self) -> None:
|
|
68
|
+
with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
|
|
69
|
+
handle.write(
|
|
70
|
+
"2026-03-24T10:00:00Z INFO boot\n"
|
|
71
|
+
"2026-03-24T10:05:00Z ERROR payment timeout\n"
|
|
72
|
+
"2026-03-24T10:06:00Z WARN retry timeout\n"
|
|
73
|
+
)
|
|
74
|
+
path = handle.name
|
|
75
|
+
|
|
76
|
+
args = Namespace(
|
|
77
|
+
paths=[path],
|
|
78
|
+
keyword=["timeout"],
|
|
79
|
+
regex=[],
|
|
80
|
+
mode="any",
|
|
81
|
+
ignore_case=True,
|
|
82
|
+
start=None,
|
|
83
|
+
end=None,
|
|
84
|
+
assume_timezone="UTC",
|
|
85
|
+
before_context=0,
|
|
86
|
+
after_context=0,
|
|
87
|
+
count_only=True,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
with patch.object(MODULE, "parse_args", return_value=args), patch(
|
|
91
|
+
"sys.stdout", new_callable=io.StringIO
|
|
92
|
+
) as stdout:
|
|
93
|
+
code = MODULE.main()
|
|
94
|
+
|
|
95
|
+
self.assertEqual(code, 0)
|
|
96
|
+
self.assertEqual(stdout.getvalue().strip(), "2")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
unittest.main()
|