thth 2.7.0__tar.gz → 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thth-2.7.0 → thth-2.8.0}/PKG-INFO +1 -1
- {thth-2.7.0 → thth-2.8.0}/accounts.example/bluesky.json +1 -0
- {thth-2.7.0 → thth-2.8.0}/accounts.example/mastodon.json +1 -0
- {thth-2.7.0 → thth-2.8.0}/mcp/server.py +3 -3
- {thth-2.7.0 → thth-2.8.0}/tests/test_analytics_comparison.py +21 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_bundle.py +18 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_collect.py +33 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_mastodon_adapter.py +1 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_select.py +14 -1
- {thth-2.7.0 → thth-2.8.0}/tests/test_thread_publish.py +67 -1
- thth-2.8.0/tests/test_v28_tags.py +247 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_where_cli.py +7 -1
- thth-2.8.0/thth/VERSION +1 -0
- {thth-2.7.0 → thth-2.8.0}/thth/adapters/base.py +1 -0
- {thth-2.7.0 → thth-2.8.0}/thth/adapters/bluesky.py +133 -4
- {thth-2.7.0 → thth-2.8.0}/thth/adapters/mastodon.py +96 -2
- {thth-2.7.0 → thth-2.8.0}/thth/analytics_comparison.py +41 -2
- {thth-2.7.0 → thth-2.8.0}/thth/analytics_report.py +2 -2
- {thth-2.7.0 → thth-2.8.0}/thth/approval.py +9 -0
- {thth-2.7.0 → thth-2.8.0}/thth/bundle.py +22 -3
- {thth-2.7.0 → thth-2.8.0}/thth/cli.py +11 -7
- {thth-2.7.0 → thth-2.8.0}/thth/collect.py +12 -0
- {thth-2.7.0 → thth-2.8.0}/thth/core.py +62 -25
- {thth-2.7.0 → thth-2.8.0}/thth/incident.py +3 -3
- {thth-2.7.0 → thth-2.8.0}/thth/lint.py +8 -3
- {thth-2.7.0 → thth-2.8.0}/thth/measured.py +2 -0
- {thth-2.7.0 → thth-2.8.0}/thth/report_service.py +1 -1
- {thth-2.7.0 → thth-2.8.0}/thth/select.py +12 -2
- thth-2.8.0/thth/tags.py +124 -0
- {thth-2.7.0 → thth-2.8.0}/thth/threads_read_cli.py +23 -2
- {thth-2.7.0 → thth-2.8.0}/thth/threadthrow.py +6 -3
- {thth-2.7.0 → thth-2.8.0}/thth/where_cli.py +37 -3
- thth-2.7.0/thth/VERSION +0 -1
- {thth-2.7.0 → thth-2.8.0}/.gitignore +0 -0
- {thth-2.7.0 → thth-2.8.0}/LICENSE +0 -0
- {thth-2.7.0 → thth-2.8.0}/README.md +0 -0
- {thth-2.7.0 → thth-2.8.0}/accounts.example/threads.json +0 -0
- {thth-2.7.0 → thth-2.8.0}/bin/thth +0 -0
- {thth-2.7.0 → thth-2.8.0}/bin/thth-remote +0 -0
- {thth-2.7.0 → thth-2.8.0}/bin/thth-run +0 -0
- {thth-2.7.0 → thth-2.8.0}/pyproject.toml +0 -0
- {thth-2.7.0 → thth-2.8.0}/skills/thth/SKILL.md +0 -0
- {thth-2.7.0 → thth-2.8.0}/systemd/thth@.service +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/__init__.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/conftest.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/__init__.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/hold_lock.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_27_acceptance_fixes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_account_name_check.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_account_report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_accounts_dir.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_adapter_boundary.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_after_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_analytics_report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_api_diagnostic.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_api_error_body.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_app_env_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_approval.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_approve_cmd.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_ask.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_atlas_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_atlas_second_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_atlas_third_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit1_fixes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit2_fixes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_v201.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_audit_v23_details.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_auth.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_auth_identity_required.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_behind_remote.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_board_needs_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_board_running.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_charcount.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_cli_help.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_cli_vm_path.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_collect_engagements.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_collect_runs.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_collect_sent.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_comparability.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_control_chars_refused.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_conversation_paging.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_count_for.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_docs_links.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_doctor.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_doctor_next_step.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_editorial_audit.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_engagements.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_external_acceptance.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_fake_api.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_form_spec.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_form_spec_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_forms.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_fresh_install.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_front_matter_injection.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_healthcheck_notifications.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_hypotheses.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_hypothesis_units.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_impact_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_incident_notifications.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_independent_thread_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_independent_topic_review.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_inflight.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_jst.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_lint.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_list_projections.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_llm_trial.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_lock.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_m2_independent.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_maintain.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_max_per_run.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_mcp.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_mcp_robustness.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_measured.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_media_accounts.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_media_next_steps.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_media_roundtrip.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_no_limits.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_observation_provenance.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_operations_handoff.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_packaging.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_preview.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_production_mode.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_pull_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_queue.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_quiet_hours.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_read_cli_bare_runtimeerror.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_reason_adoption.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_redact.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_refresh.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_refresh_destination.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_rejudge_new.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_replies.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_replies_refresh.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_repo_lock.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_repo_sync.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_report_checkout_import.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_report_http.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_report_isolation.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_report_service.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_reverse_audit.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_evidence_account.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_provenance.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_review_records.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_revoke.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_roundtrip.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_run_missing_env.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_runs_collected.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_runs_read_rows.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_schedule_multiple.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_schedule_view.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_search_engage_targets.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_search_media_routes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_secret_value_redact.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_secrets_grep.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_security_small_fixes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_selfupdate.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_send.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_send_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_send_control_chars.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_send_media.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_send_visibility.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_sent_integrity.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_server_json.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_site.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_skill_doc.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_study_report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_systemd.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_thread_read.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_threadrun.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_threads_read_permissions.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_threadshape.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_thth_root.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_token_exists.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_token_scopes_record.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_token_set.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_advice.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_models.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topic_store.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_topics_tool.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_trial_frictions.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_v31_analysis.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_v31_collection.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_value_domains_shown.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_version.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_who_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_writeback_rebase.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_writeback_validate.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/tests/test_x_metrics.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/__init__.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/__main__.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/account_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/account_report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/accounts.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/adapters/__init__.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/adapters/threads.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/after_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/analytics_shapes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/analytics_threads.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/api_diagnostic.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/appenv.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/ask.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/ask_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/collection_context.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/collection_status.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/doctor.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/engagements.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/forms.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/handoff_cursor.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/healthcheck.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/httpsafe.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/inflight.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/jst.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/lock.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/maintain.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/oauth.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/operations_handoff.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/postid.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/queuefile.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/redact.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/replies.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/report_details.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/report_http.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/report_isolation.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/retract_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/runs.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/scopes.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/secrets_fs.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/selfupdate.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/sent.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/study_report.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/systemd_gen.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/thread_read.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/threadrun.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/threadshape.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/topic_advice.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/topic_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/topic_models.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/topic_store.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/topics.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/who_cli.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/writeback.py +0 -0
- {thth-2.7.0 → thth-2.8.0}/thth/x_metrics.py +0 -0
|
@@ -160,7 +160,7 @@ TOOLS = [
|
|
|
160
160
|
"inputSchema": {
|
|
161
161
|
"type": "object",
|
|
162
162
|
"properties": {
|
|
163
|
-
"by": {"type": "string", "enum": ["kind", "hour_band", "topic"]},
|
|
163
|
+
"by": {"type": "string", "enum": ["kind", "hour_band", "topic", "tag"]},
|
|
164
164
|
"compare_previous": {"type": "boolean", "description": "直前の同じ日数との比較(既定false)"},
|
|
165
165
|
"account": {"type": "string"},
|
|
166
166
|
"project": {"type": "string"},
|
|
@@ -400,8 +400,8 @@ def validate_arguments(name: str, arguments) -> dict:
|
|
|
400
400
|
f"{name}: arguments は object で渡してください"
|
|
401
401
|
f"(受け取った: {type(arguments).__name__})")
|
|
402
402
|
if name == "analytics_report" and "by" in arguments:
|
|
403
|
-
if arguments["by"] not in ("kind", "hour_band", "topic") or arguments.get("compare_previous") is not True:
|
|
404
|
-
raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic が必要です")
|
|
403
|
+
if arguments["by"] not in ("kind", "hour_band", "topic", "tag") or arguments.get("compare_previous") is not True:
|
|
404
|
+
raise ToolInputError("analytics_report: by は compare_previous=true と kind/hour_band/topic/tag が必要です")
|
|
405
405
|
if name == "operations_handoff" and "since_last_read" in arguments and type(arguments["since_last_read"]) is not bool:
|
|
406
406
|
raise ToolInputError("operations_handoff: since_last_read は boolean です")
|
|
407
407
|
props = schema.get("properties") or {}
|
|
@@ -48,6 +48,27 @@ def test_half_open_zero_delta_and_read_only(isolated_account_factory):
|
|
|
48
48
|
assert json.loads("\n".join(s[4:] for s in markdown.splitlines() if s.startswith(" "))) == r
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_tag_strata_use_observed_tags_and_suppress_small_samples(isolated_account_factory):
|
|
52
|
+
a = isolated_account_factory(media="bluesky")
|
|
53
|
+
seed(a, "old", NOW - datetime.timedelta(days=10), value=5,
|
|
54
|
+
extra={"tags": ["茶"], "topic": "予定だけの語"})
|
|
55
|
+
seed(a, "new1", NOW - datetime.timedelta(days=3), value=10,
|
|
56
|
+
extra={"tags": ["茶", "苦味"], "topic": "予定だけの語"})
|
|
57
|
+
seed(a, "new2", NOW - datetime.timedelta(days=2), value=20,
|
|
58
|
+
extra={"tags": ["茶"], "topic": "予定だけの語"})
|
|
59
|
+
seed(a, "untagged", NOW - datetime.timedelta(days=1, hours=3), value=30,
|
|
60
|
+
extra={"tags": [], "topic": "予定だけの語"})
|
|
61
|
+
result = report(a, min_n=2, by="tag")
|
|
62
|
+
group = result["by_account"][a["name"]]["posts"]["stratified"]
|
|
63
|
+
assert group["strata"]["tagged"]["current"]["n_total"] == 2
|
|
64
|
+
assert group["strata"]["untagged"]["current"]["n_total"] == 1
|
|
65
|
+
assert group["strata"]["#茶"]["current"]["metrics"]["views"]["median"] == 15
|
|
66
|
+
assert group["strata"]["#苦味"]["current"]["metrics"]["views"]["median"] is None
|
|
67
|
+
assert group["strata"]["#茶"]["comparison"]["views"]["absolute_median_change"] is None
|
|
68
|
+
assert "#予定だけの語" not in group["strata"]
|
|
69
|
+
assert group["reconciliation"]["current"] == {"sum_n_total": 3, "n_total": 3}
|
|
70
|
+
|
|
71
|
+
|
|
51
72
|
@pytest.mark.parametrize("age,marks,extra,reason", [
|
|
52
73
|
(23.99, [24], {}, "premature_observation"),
|
|
53
74
|
(30, [24], {}, "late_observation"),
|
|
@@ -51,6 +51,24 @@ def account_cfg(**over):
|
|
|
51
51
|
return cfg
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def test_bluesky_bundle_uses_effective_first_segment_and_preserves_body_tags():
|
|
55
|
+
fm = FM.replace("nigamilab-threads", "sample-bluesky")
|
|
56
|
+
body = BODY.replace("## threads", "## bluesky")
|
|
57
|
+
b = bundle.parse_text(make(fm, body), "b.md")
|
|
58
|
+
cfg = account_cfg(media="bluesky", hashtags=True)
|
|
59
|
+
assert not [issue for issue in bundle.check(b, account_cfg=cfg)
|
|
60
|
+
if not issue.startswith("warning:")]
|
|
61
|
+
assert bundle.effective_segments(b.segments, cfg, b.get("topic"))[0].endswith(
|
|
62
|
+
"\n#コーヒー")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mastodon_bundle_disabled_topic_has_one_warning():
|
|
66
|
+
b = bundle.parse_text(make(FM, BODY.replace("## threads", "## mastodon")), "b.md")
|
|
67
|
+
issues = bundle.check(b, account_cfg=account_cfg(media="mastodon", hashtags=False))
|
|
68
|
+
assert [issue for issue in issues if "topic が効きません" in issue] == [
|
|
69
|
+
"warning: 1 段目: この媒体では topic が効きません(hashtags: false)"]
|
|
70
|
+
|
|
71
|
+
|
|
54
72
|
# --- v1 を壊さない ---------------------------------------------------------
|
|
55
73
|
|
|
56
74
|
def test_旧版の解析はthth2を型外にする():
|
|
@@ -89,6 +89,39 @@ def test_刻みを跨いだら1行だけ足す(tmp_path, isolated_account_factor
|
|
|
89
89
|
assert rows[0]["age_hours"] == 2.0
|
|
90
90
|
|
|
91
91
|
|
|
92
|
+
def test_bluesky_observed_tags_are_saved_without_body_or_handle(tmp_path, isolated_account_factory):
|
|
93
|
+
pair = init_git_pair(tmp_path, seed_content=make_queue_text({
|
|
94
|
+
"status": "posted", "post_id": "POST1",
|
|
95
|
+
"posted_at": "2026-09-10T10:00:00+09:00", "topic": "予定だけの語"}))
|
|
96
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True,
|
|
97
|
+
media="bluesky")
|
|
98
|
+
class TaggedAdapter(FakeAdapter):
|
|
99
|
+
def observed_tags(self, post_id):
|
|
100
|
+
assert post_id == "POST1"
|
|
101
|
+
return ["実測の茶"]
|
|
102
|
+
collect_mod.run_collect(account["name"], adapter=TaggedAdapter(), now=NOW,
|
|
103
|
+
log=lambda _line: None)
|
|
104
|
+
row = _rows(pair["work"], "data/sns/insights/posts/POST1.ndjson")[0]
|
|
105
|
+
assert row["tags"] == ["実測の茶"]
|
|
106
|
+
assert row["topic"] == "予定だけの語" # planned metadata stays separate
|
|
107
|
+
assert not {"text", "body", "username", "handle"} & row.keys()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_bluesky_tag_read_failure_is_unknown_not_untagged(tmp_path, isolated_account_factory):
|
|
111
|
+
pair = init_git_pair(tmp_path, seed_content=make_queue_text({
|
|
112
|
+
"status": "posted", "post_id": "POST1",
|
|
113
|
+
"posted_at": "2026-09-10T10:00:00+09:00"}))
|
|
114
|
+
account = isolated_account_factory(repo_dir=pair["work"], production=True,
|
|
115
|
+
media="bluesky")
|
|
116
|
+
class BadTaggedAdapter(FakeAdapter):
|
|
117
|
+
def observed_tags(self, post_id):
|
|
118
|
+
raise ValueError("malformed record")
|
|
119
|
+
collect_mod.run_collect(account["name"], adapter=BadTaggedAdapter(), now=NOW,
|
|
120
|
+
log=lambda _line: None)
|
|
121
|
+
row = _rows(pair["work"], "data/sns/insights/posts/POST1.ndjson")[0]
|
|
122
|
+
assert row["tags"] is None
|
|
123
|
+
|
|
124
|
+
|
|
92
125
|
def test_採取時点の所有accountが行に書かれる(tmp_path, isolated_account_factory):
|
|
93
126
|
"""外部レビュー再判定 R3・2026-09-12: 台帳の行そのものに、採取時点の所有
|
|
94
127
|
`account` を残す。これが無いと、`thth/measured.py` は「いまの原稿の
|
|
@@ -74,6 +74,7 @@ STATUS_FIXTURE = {
|
|
|
74
74
|
"favourites_count": 7,
|
|
75
75
|
"replies_count": 3,
|
|
76
76
|
"reblogs_count": 2,
|
|
77
|
+
"tags": [], # real Status shape: observed_tags can distinguish empty from missing
|
|
77
78
|
"account": {"id": "9000", "acct": "nigamilab"},
|
|
78
79
|
# 本物の応答は必ず持つ(監査 P2-2)。`fetch_post()`(T1-1)の C-1 検査対象。
|
|
79
80
|
"visibility": "public",
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
import datetime
|
|
5
5
|
|
|
6
6
|
from tests.conftest import make_queue_text, parse_verified
|
|
7
|
-
from thth import queuefile, select as select_mod
|
|
7
|
+
from thth import approval, queuefile, select as select_mod
|
|
8
8
|
|
|
9
9
|
ACCOUNT = "nigamilab-threads"
|
|
10
10
|
NOW = datetime.datetime.fromisoformat("2026-09-09T10:00:00+09:00")
|
|
@@ -44,6 +44,19 @@ def select(files, **kwargs):
|
|
|
44
44
|
return select_mod.select_one(files, **kwargs)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def test_bluesky_hashtags_policy_change_stales_old_approval(tmp_path):
|
|
48
|
+
body = "## bluesky\n本文です。\n"
|
|
49
|
+
old_sha = approval.compute_approved_sha(
|
|
50
|
+
section="本文です。", account=ACCOUNT, reply_to=None, topic="茶",
|
|
51
|
+
publish_at="2026-09-09T08:00:00+09:00")
|
|
52
|
+
p = write(tmp_path, "bluesky.md", body=body, media="bluesky",
|
|
53
|
+
fm_overrides={"topic": "茶", "approved_sha": old_sha,
|
|
54
|
+
"publish_at": "2026-09-09T08:00:00+09:00"})
|
|
55
|
+
result = select(parse_all([p]), account_cfg=account_cfg(media="bluesky", hashtags=True))
|
|
56
|
+
assert result.chosen is None
|
|
57
|
+
assert any(r.reason == "approval_stale" for r in result.rejections)
|
|
58
|
+
|
|
59
|
+
|
|
47
60
|
# 1. post_id が入っている → status より先に落とす(approved に戻されても出ない・§3.5)
|
|
48
61
|
def test_1_post_idがあれば承認済みでも出ない(tmp_path):
|
|
49
62
|
p = write(tmp_path, "a.md", fm_overrides={
|
|
@@ -21,7 +21,7 @@ from pathlib import Path
|
|
|
21
21
|
import pytest
|
|
22
22
|
|
|
23
23
|
from tests.conftest import init_git_pair, run_git
|
|
24
|
-
from thth import approval, bundle, inflight, threadrun, threadthrow
|
|
24
|
+
from thth import approval, bundle, cli, inflight, threadrun, threadthrow
|
|
25
25
|
from thth.adapters.base import PublishResult
|
|
26
26
|
|
|
27
27
|
NOW = datetime.datetime.fromisoformat("2026-09-15T19:00:00+09:00")
|
|
@@ -117,6 +117,72 @@ def write_and_push(pair, text):
|
|
|
117
117
|
run_git(pair["work"], ["push"])
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def _bluesky_bundle_text(account: str, *, transformed: bool = True,
|
|
121
|
+
include_body_tag: bool = True) -> str:
|
|
122
|
+
segments = ["本文 #苦味" if include_body_tag else "本文", "続きです"]
|
|
123
|
+
text = bundle_text(account=account, topic="茶", segments=segments).replace(
|
|
124
|
+
"## threads", "## bluesky")
|
|
125
|
+
old = approval.compute_bundle_sha(
|
|
126
|
+
segments=segments, account=account, topic="茶",
|
|
127
|
+
publish_at="2026-09-15T19:00:00+09:00",
|
|
128
|
+
continue_until="2026-09-15T20:00:00+09:00")
|
|
129
|
+
effective = [segments[0] + "\n#茶", "続きです"]
|
|
130
|
+
new = approval.compute_bundle_sha(
|
|
131
|
+
segments=effective, account=account, topic="茶",
|
|
132
|
+
publish_at="2026-09-15T19:00:00+09:00",
|
|
133
|
+
continue_until="2026-09-15T20:00:00+09:00")
|
|
134
|
+
assert text.count("approved_sha: " + old) == 1
|
|
135
|
+
return text.replace("approved_sha: " + old, "approved_sha: " + new) if transformed else text
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_bluesky_bundle_displays_and_publishes_effective_segments(
|
|
139
|
+
tmp_path, isolated_account_factory):
|
|
140
|
+
name = "bluesky-bundle"
|
|
141
|
+
source = _bluesky_bundle_text(name)
|
|
142
|
+
pair = init_git_pair(tmp_path, seed_content=source, seed_name="thread.md")
|
|
143
|
+
account = isolated_account_factory(name=name, repo_dir=pair["work"],
|
|
144
|
+
media="bluesky", hashtags=True,
|
|
145
|
+
production=True, quiet_hours=None,
|
|
146
|
+
min_interval_hours=0)
|
|
147
|
+
path = os.path.join(pair["work"], REL)
|
|
148
|
+
prepared, error = cli._prepare_bundle(path, source)
|
|
149
|
+
assert error is None
|
|
150
|
+
assert prepared["segments"] == ["本文 #苦味\n#茶", "続きです"]
|
|
151
|
+
adapter = FakeAdapter()
|
|
152
|
+
results = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
153
|
+
now=NOW)
|
|
154
|
+
assert [r.action for r in results] == ["published", "published"]
|
|
155
|
+
assert [call["text"] for call in adapter.calls] == prepared["segments"]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_bluesky_bundle_old_published_segment_stays_frozen_after_policy_change(
|
|
159
|
+
tmp_path, isolated_account_factory):
|
|
160
|
+
name = "bluesky-frozen"
|
|
161
|
+
source = _bluesky_bundle_text(name, transformed=False, include_body_tag=False)
|
|
162
|
+
pair = init_git_pair(tmp_path, seed_content=source, seed_name="thread.md")
|
|
163
|
+
account = isolated_account_factory(name=name, repo_dir=pair["work"],
|
|
164
|
+
media="bluesky", hashtags=False,
|
|
165
|
+
production=True, quiet_hours=None,
|
|
166
|
+
min_interval_hours=0)
|
|
167
|
+
adapter = FakeAdapter()
|
|
168
|
+
first = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
169
|
+
now=NOW, max_posts=1)
|
|
170
|
+
assert first[0].action == "published"
|
|
171
|
+
assert adapter.calls[0]["text"] == "本文"
|
|
172
|
+
ledger = Path(account["accounts_dir"]) / f"{name}.json"
|
|
173
|
+
cfg = json.loads(ledger.read_text())
|
|
174
|
+
cfg["hashtags"] = True
|
|
175
|
+
ledger.write_text(json.dumps(cfg))
|
|
176
|
+
path = os.path.join(pair["work"], REL)
|
|
177
|
+
prepared, error = cli._prepare_bundle(path, Path(path).read_text())
|
|
178
|
+
assert prepared is None
|
|
179
|
+
assert "公開済みの段の本文は変えられません" in error
|
|
180
|
+
later = threadthrow.publish_bundle(name, REL, adapter_factory=lambda *_: adapter,
|
|
181
|
+
now=NOW)
|
|
182
|
+
assert all(r.action != "published" for r in later)
|
|
183
|
+
assert len(adapter.calls) == 1
|
|
184
|
+
|
|
185
|
+
|
|
120
186
|
# --- 正常系 -----------------------------------------------------------------
|
|
121
187
|
|
|
122
188
|
def test_束を順に出して親子がつながる(thread_account):
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""2.8.0 tag contracts against local fixtures only."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from thth import approval, cli, lint, tags
|
|
9
|
+
from thth.adapters import base, bluesky, mastodon
|
|
10
|
+
from tests.test_bluesky_adapter import _adapter, fake_bluesky
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_topic_is_the_same_sent_text_and_utf8_facet():
|
|
14
|
+
text = "日本語の本文"
|
|
15
|
+
with fake_bluesky() as service:
|
|
16
|
+
post = base.Post(text=text, topic="苦味", hashtags_allowed=True)
|
|
17
|
+
result = _adapter(service).publish(post, dry_run=False)
|
|
18
|
+
record = service.handler_cls.created[0]["payload"]["record"]
|
|
19
|
+
assert result.error is None
|
|
20
|
+
assert record["text"] == tags.prepared("bluesky", text, "苦味", hashtags=True)
|
|
21
|
+
facet = next(f for f in record["facets"] if f["features"][0]["$type"].endswith("#tag"))
|
|
22
|
+
assert facet["features"] == [{"$type": "app.bsky.richtext.facet#tag", "tag": "苦味"}]
|
|
23
|
+
index = facet["index"]
|
|
24
|
+
assert record["text"].encode("utf-8")[index["byteStart"]:index["byteEnd"]] == "#苦味".encode("utf-8")
|
|
25
|
+
assert index["byteStart"] == len((text + "\n").encode("utf-8"))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_topic_already_present_is_not_appended_twice():
|
|
29
|
+
text = "#苦味 の話"
|
|
30
|
+
assert tags.prepared("bluesky", text, "苦味", hashtags=True) == text
|
|
31
|
+
facet = bluesky.build_facets(text, topic="苦味")[0]
|
|
32
|
+
assert facet["index"] == {"byteStart": 0, "byteEnd": len("#苦味".encode())}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_all_body_tags_have_non_overlapping_byte_facets():
|
|
36
|
+
text = "日本語 #苦味 と #旨味 https://example.invalid/#url_fragment"
|
|
37
|
+
facets = bluesky.build_facets(text, include_tags=True)
|
|
38
|
+
tags_found = [f for f in facets if f["features"][0]["$type"].endswith("#tag")]
|
|
39
|
+
assert [f["features"][0]["tag"] for f in tags_found] == ["苦味", "旨味"]
|
|
40
|
+
for facet, wanted in zip(tags_found, ("#苦味", "#旨味")):
|
|
41
|
+
index = facet["index"]
|
|
42
|
+
assert text.encode()[index["byteStart"]:index["byteEnd"]] == wanted.encode()
|
|
43
|
+
assert len(facets) == 3 # one URL link, two tags
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_url_fragment_does_not_suppress_topic_append():
|
|
47
|
+
text = "記事 https://example.invalid/#個人開発"
|
|
48
|
+
effective = tags.prepared("bluesky", text, "個人開発", hashtags=True)
|
|
49
|
+
assert effective.endswith("\n#個人開発")
|
|
50
|
+
facets = bluesky.build_facets(effective, topic="個人開発", include_tags=True)
|
|
51
|
+
tag_facets = [f for f in facets if f["features"][0]["$type"].endswith("#tag")]
|
|
52
|
+
assert len(tag_facets) == 1
|
|
53
|
+
assert effective.encode()[tag_facets[0]["index"]["byteStart"]:] == "#個人開発".encode()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_punctuation_topic_and_combining_mark_are_single_facets():
|
|
57
|
+
text = "#a.b と #か\u3099"
|
|
58
|
+
assert tags.prepared("bluesky", text, "a.b", hashtags=True) == text
|
|
59
|
+
facets = bluesky.build_facets(text, topic="a.b", include_tags=True)
|
|
60
|
+
tag_facets = [f for f in facets if f["features"][0]["$type"].endswith("#tag")]
|
|
61
|
+
assert [f["features"][0]["tag"] for f in tag_facets] == ["a.b", "か\u3099"]
|
|
62
|
+
assert tags.topic_error("bluesky", "あ" * 64) is None
|
|
63
|
+
assert tags.topic_error("bluesky", "あ" * 65) == "topic_too_long(65)"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_emoji_and_internal_punctuation_are_real_bluesky_tags():
|
|
67
|
+
text = "#🍵 #a.b #茶 #苦味"
|
|
68
|
+
facets = bluesky.build_facets(text, include_tags=True)
|
|
69
|
+
assert [f["features"][0]["tag"] for f in facets] == ["🍵", "a.b", "茶", "苦味"]
|
|
70
|
+
assert "上限 3 個" in tags.errors("bluesky", text, None, {"hashtags": True})[0]
|
|
71
|
+
assert tags.prepared("bluesky", "本文 #a.b", "a.b", hashtags=True) == "本文 #a.b"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_topic_prefix_does_not_relabel_longer_punctuation_tag():
|
|
75
|
+
effective = tags.prepared("bluesky", "#a.b", "a", hashtags=True)
|
|
76
|
+
assert effective == "#a.b\n#a"
|
|
77
|
+
facets = bluesky.build_facets(effective, topic="a", include_tags=True)
|
|
78
|
+
assert [f["features"][0]["tag"] for f in facets] == ["a.b", "a"]
|
|
79
|
+
assert tags.prepared("bluesky", "#茶.味", "茶", hashtags=True) == "#茶.味\n#茶"
|
|
80
|
+
bracketed = tags.prepared("bluesky", "(#a.b)", "a", hashtags=True)
|
|
81
|
+
assert bracketed == "(#a.b)\n#a"
|
|
82
|
+
facets = bluesky.build_facets(bracketed, topic="a", include_tags=True)
|
|
83
|
+
assert [f["features"][0]["tag"] for f in facets] == ["a.b", "a"]
|
|
84
|
+
assert tags.prepared("bluesky", "「#茶.味」", "茶", hashtags=True).endswith("\n#茶")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_mastodon_number_only_topic_is_rejected():
|
|
88
|
+
assert tags.topic_error("mastodon", "123") is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_mastodon_topic_uses_one_final_line(monkeypatch):
|
|
92
|
+
seen = []
|
|
93
|
+
adapter = mastodon.MastodonAdapter(instance="https://example.invalid", access_token="fixture")
|
|
94
|
+
monkeypatch.setattr(adapter, "_request", lambda method, path, **kwargs: seen.append(kwargs) or
|
|
95
|
+
{"id": "123", "url": "https://example.invalid/@u/123"})
|
|
96
|
+
result = adapter.publish(base.Post(text="本文", topic="茶", hashtags_allowed=True), dry_run=False)
|
|
97
|
+
assert result.error is None
|
|
98
|
+
assert seen[0]["data"]["status"] == "本文\n#茶"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_topic_false_warns_and_does_not_append():
|
|
102
|
+
assert tags.prepared("bluesky", "本文", "茶", hashtags=False) == "本文"
|
|
103
|
+
assert tags.errors("bluesky", "本文", "茶", {"hashtags": False}) == [
|
|
104
|
+
"warning: この媒体では topic が効きません(hashtags: false)"]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_topic_limit_and_length_include_appended_tag(tmp_path, monkeypatch):
|
|
108
|
+
path = tmp_path / "draft.md"
|
|
109
|
+
path.write_text("---\nthth: 1\naccount: sample\npublish_at: 2026-09-20T09:00:00+09:00\n"
|
|
110
|
+
"topic: 苦味\n---\n## bluesky\n" + "あ" * 298)
|
|
111
|
+
monkeypatch.setattr(lint, "_account_cfg_or_none", lambda _: {"media": "bluesky", "hashtags": True})
|
|
112
|
+
assert any("length:" in error for error in lint.lint_file(str(path)))
|
|
113
|
+
assert lint.preview_file(str(path)).endswith("\n#苦味")
|
|
114
|
+
assert tags.topic_error("bluesky", "あ" * 65) == "topic_too_long(65)"
|
|
115
|
+
assert tags.topic_error("mastodon", "two words") is not None
|
|
116
|
+
assert tags.errors("bluesky", "🫠" * 800, None, {"hashtags": True})[0].startswith("length:")
|
|
117
|
+
path.write_text(path.read_text().replace("あ" * 298, "本文"))
|
|
118
|
+
monkeypatch.setattr(cli.accounts_mod, "load_account", lambda _: {"media": "bluesky", "hashtags": True})
|
|
119
|
+
one, error = cli._prepare_one(str(path))
|
|
120
|
+
assert error is None
|
|
121
|
+
assert one["text"] == lint.preview_file(str(path))
|
|
122
|
+
assert one["text"].endswith("\n#苦味")
|
|
123
|
+
assert one["approved_sha"] == approval.compute_approved_sha(
|
|
124
|
+
section=one["text"], account="sample", reply_to=None, topic="苦味",
|
|
125
|
+
publish_at="2026-09-20T09:00:00+09:00", location_id=None,
|
|
126
|
+
share_to_instagram=False)
|
|
127
|
+
monkeypatch.setattr(cli.accounts_mod, "load_account", lambda _: {"media": "bluesky", "hashtags": False})
|
|
128
|
+
without, error = cli._prepare_one(str(path))
|
|
129
|
+
assert error is None
|
|
130
|
+
assert without["text"] == "本文"
|
|
131
|
+
assert without["approved_sha"] != one["approved_sha"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_max_hashtags_counts_topic_and_body_with_default_three():
|
|
135
|
+
assert not tags.errors("bluesky", "#茶 #珈琲", "苦味", {"hashtags": True})
|
|
136
|
+
assert "上限 3 個" in tags.errors(
|
|
137
|
+
"bluesky", "#茶 #珈琲 #山菜", "苦味", {"hashtags": True})[0]
|
|
138
|
+
assert "上限 1 個" in tags.errors(
|
|
139
|
+
"mastodon", "#茶", "苦味", {"hashtags": True, "max_hashtags": 1})[0]
|
|
140
|
+
assert tags.errors("bluesky", "本文", None,
|
|
141
|
+
{"hashtags": True, "max_hashtags": True}) == [
|
|
142
|
+
"max_hashtags: 0 以上の整数にしてください"]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_tag_limits_do_not_treat_url_fragment_as_a_hashtag():
|
|
146
|
+
text = "https://example.invalid/#url #茶"
|
|
147
|
+
assert not tags.errors("bluesky", text, None,
|
|
148
|
+
{"hashtags": True, "max_hashtags": 1})
|
|
149
|
+
assert "タグが Bluesky 上限" in tags.errors(
|
|
150
|
+
"bluesky", "#" + "あ" * 65, None, {"hashtags": True})[0]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_bluesky_tag_search_paginates_and_keeps_only_aggregates(monkeypatch):
|
|
154
|
+
adapter = bluesky.BlueskyAdapter(identifier="fixture", app_password="fixture")
|
|
155
|
+
calls = []
|
|
156
|
+
pages = [
|
|
157
|
+
{"posts": [
|
|
158
|
+
{"uri": "at://one", "author": {"did": "did:a", "handle": "secret-user"},
|
|
159
|
+
"record": {"text": "secret-body", "createdAt": "2026-09-19T01:00:00Z",
|
|
160
|
+
"facets": [{"features": [{"$type": "app.bsky.richtext.facet#tag", "tag": "茶"},
|
|
161
|
+
{"$type": "app.bsky.richtext.facet#tag", "tag": "苦味"}]}]}},
|
|
162
|
+
{"uri": "at://two", "author": {"did": "did:a", "handle": "secret-user"},
|
|
163
|
+
"record": {"text": "secret-body", "createdAt": "2026-09-19T02:00:00Z",
|
|
164
|
+
"tags": ["茶", "旨味"]}}], "cursor": "next"},
|
|
165
|
+
{"posts": [{"uri": "at://two"},
|
|
166
|
+
{"uri": "at://three", "author": {"did": "did:b", "handle": "secret-user"},
|
|
167
|
+
"record": {"text": "secret-body", "tags": ["茶", "苦味"]}}]},
|
|
168
|
+
]
|
|
169
|
+
def get(method, nsid, *, params):
|
|
170
|
+
calls.append(params)
|
|
171
|
+
return pages[len(calls) - 1]
|
|
172
|
+
monkeypatch.setattr(adapter, "_request", get)
|
|
173
|
+
result = adapter.tag_search("茶", tags=["茶"], sort="latest", pages=4,
|
|
174
|
+
since="2026-09-18", until="2026-09-20")
|
|
175
|
+
assert calls[0] == {"q": "茶", "tag": ["茶"], "sort": "latest", "limit": 100,
|
|
176
|
+
"since": "2026-09-18", "until": "2026-09-20"}
|
|
177
|
+
assert calls[1]["cursor"] == "next"
|
|
178
|
+
assert result["n"] == 3 and result["distinct_authors"] == 2
|
|
179
|
+
assert result["tagged_n"] == 3 and result["tagged_share"] == 1
|
|
180
|
+
assert result["co_tags"] == [{"tag": "苦味", "n": 2}, {"tag": "旨味", "n": 1}]
|
|
181
|
+
serialized = json.dumps(result, ensure_ascii=False)
|
|
182
|
+
assert "secret-body" not in serialized and "secret-user" not in serialized
|
|
183
|
+
with pytest.raises(Exception, match="q は空"):
|
|
184
|
+
adapter.tag_search("", tags=["茶"])
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_bluesky_latest_at_compares_instants(monkeypatch):
|
|
188
|
+
adapter = bluesky.BlueskyAdapter(identifier="fixture", app_password="fixture")
|
|
189
|
+
monkeypatch.setattr(adapter, "_request", lambda *args, **kwargs: {"posts": [
|
|
190
|
+
{"uri": "at://one", "record": {"createdAt": "2026-09-19T09:00:00+09:00"}},
|
|
191
|
+
{"uri": "at://two", "record": {"createdAt": "2026-09-19T01:00:00Z"}},
|
|
192
|
+
]})
|
|
193
|
+
assert adapter.tag_search("茶", tags=["茶"])["latest_at"] == "2026-09-19T01:00:00Z"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.parametrize("record", [None, {"tags": "broken"}, {"facets": "broken"}])
|
|
197
|
+
def test_bluesky_missing_or_malformed_record_is_unknown_not_untagged(monkeypatch, record):
|
|
198
|
+
adapter = bluesky.BlueskyAdapter(identifier="fixture", app_password="fixture")
|
|
199
|
+
monkeypatch.setattr(adapter, "_post_view", lambda _: {"record": record})
|
|
200
|
+
with pytest.raises(base.AdapterError, match="投稿のタグ"):
|
|
201
|
+
adapter.observed_tags("at://fake")
|
|
202
|
+
monkeypatch.setattr(adapter, "_post_view", lambda _: {"record": {}})
|
|
203
|
+
assert adapter.observed_tags("at://fake") == []
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_mastodon_tag_observation_is_instance_scoped_and_aggregate(monkeypatch):
|
|
207
|
+
adapter = mastodon.MastodonAdapter(instance="https://instance.example", access_token="fixture")
|
|
208
|
+
requested = []
|
|
209
|
+
def get_list(path, what):
|
|
210
|
+
requested.append(path)
|
|
211
|
+
if "/timelines/tag/" in path:
|
|
212
|
+
return [{"id": "1", "visibility": "public", "content": "secret-body",
|
|
213
|
+
"created_at": "2026-09-19T01:00:00Z",
|
|
214
|
+
"account": {"id": "a", "acct": "secret-user"},
|
|
215
|
+
"tags": [{"name": "茶"}, {"name": "苦味"}]},
|
|
216
|
+
{"id": "2", "visibility": "private", "content": "secret-body"}]
|
|
217
|
+
return [{"name": "茶", "history": [{"day": "1789776000", "uses": "7",
|
|
218
|
+
"accounts": "4"}]}]
|
|
219
|
+
def get_json(path, what):
|
|
220
|
+
requested.append(path)
|
|
221
|
+
return {"hashtags": [{"name": "茶", "history": []},
|
|
222
|
+
{"name": "茶道", "history": []}]}
|
|
223
|
+
monkeypatch.setattr(adapter, "_get_list", get_list)
|
|
224
|
+
monkeypatch.setattr(adapter, "_get_json", get_json)
|
|
225
|
+
result = adapter.tag_observation("茶")
|
|
226
|
+
assert any("/api/v1/timelines/tag/%E8%8C%B6" in path for path in requested)
|
|
227
|
+
assert any("type=hashtags" in path for path in requested)
|
|
228
|
+
assert any("/api/v1/trends/tags" in path for path in requested)
|
|
229
|
+
assert result["n"] == result["distinct_authors"] == 1
|
|
230
|
+
assert result["co_tags"] == [{"tag": "苦味", "n": 1}]
|
|
231
|
+
assert result["history"] == [{"day": "1789776000", "uses": 7, "accounts": 4}]
|
|
232
|
+
assert result["observed_from"] == "https://instance.example"
|
|
233
|
+
serialized = json.dumps(result, ensure_ascii=False)
|
|
234
|
+
assert "secret-body" not in serialized and "secret-user" not in serialized
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_mastodon_bad_created_at_and_timezone_order(monkeypatch):
|
|
238
|
+
adapter = mastodon.MastodonAdapter(instance="https://instance.example", access_token="fixture")
|
|
239
|
+
monkeypatch.setattr(adapter, "_get_list", lambda path, what: [
|
|
240
|
+
{"visibility": "public", "created_at": ["bad"]},
|
|
241
|
+
{"visibility": "public", "created_at": "2026-09-19T09:00:00+09:00"},
|
|
242
|
+
{"visibility": "public", "created_at": "2026-09-19T01:00:00Z"},
|
|
243
|
+
] if "timeline" in path else [])
|
|
244
|
+
monkeypatch.setattr(adapter, "_get_json", lambda *args: {"hashtags": []})
|
|
245
|
+
result = adapter.tag_observation("茶", since="2026-09-19T00:00:00Z")
|
|
246
|
+
assert result["n"] == 2
|
|
247
|
+
assert result["latest_at"] == "2026-09-19T01:00:00Z"
|
|
@@ -127,7 +127,7 @@ def test_2媒体をproject単位で並べる(two_media_project):
|
|
|
127
127
|
# **トップレベルに数が無い**(設計「自分の泉」§2.6・T2-2 規約)。
|
|
128
128
|
assert "n" not in result
|
|
129
129
|
assert set(result.keys()) == {"account", "project", "words", "by_account",
|
|
130
|
-
"cannot_say", "provenance"}
|
|
130
|
+
"by_tag", "cannot_say", "provenance"}
|
|
131
131
|
|
|
132
132
|
by_account = result["by_account"]
|
|
133
133
|
assert set(by_account) == {threads_acc["name"], bluesky_acc["name"]}
|
|
@@ -139,6 +139,12 @@ def test_2媒体をproject単位で並べる(two_media_project):
|
|
|
139
139
|
bluesky_node = by_account[bluesky_acc["name"]]
|
|
140
140
|
assert bluesky_node["medium"] == "bluesky"
|
|
141
141
|
assert set(bluesky_node["by_word"]) == {"コーヒー", "苦い"}
|
|
142
|
+
assert [item["tag"] for item in bluesky_node["by_tag"]] == ["コーヒー", "苦い"]
|
|
143
|
+
assert len(result["by_tag"]) == 2
|
|
144
|
+
assert all(item["n"] == 1 and item["observed_from"] for item in result["by_tag"])
|
|
145
|
+
tag_json = json.dumps(result["by_tag"], ensure_ascii=False)
|
|
146
|
+
assert "苦いコーヒーの話" not in tag_json
|
|
147
|
+
assert BSKY_BOB_HANDLE not in tag_json
|
|
142
148
|
|
|
143
149
|
# posts に author_key。
|
|
144
150
|
t_posts = threads_node["by_word"]["コーヒー"]["posts"]
|
thth-2.8.0/thth/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.8.0
|
|
@@ -29,6 +29,7 @@ class Post:
|
|
|
29
29
|
# 壊さないため——ここで loud に断ると、媒体を足した瞬間に既存の queue が
|
|
30
30
|
# 全部止まる。**使わない媒体が受け取っても何も起きない**が正しい。
|
|
31
31
|
topic: str | None = None
|
|
32
|
+
hashtags_allowed: bool = False
|
|
32
33
|
# --- 承認を通る書き込みの口(設計 v2 §4.3・v2.1-B・2026-09-14) ---
|
|
33
34
|
# 場所(Threads の `location_id`)。queue の front-matter `location:`(人が
|
|
34
35
|
# 書く語)と `location_id:`(`thth location search` が引いた id を人が書く)
|