Skill: myd2002/skill-d-create-issues (version 1.0.0)

--- name: skill-d-create-issues description: "【issue 落地总控】当组织者确认 issue 草稿后触发。触发方式：(A) 组织者在 Gitea 把 draft_issue.md 改名为 confirmed_issue.md，由 webhook 通知 OpenClaw；(B) 组织者在 OpenClaw 中说"确认 YYYY-MM-DD-HHMM 的 issue"。触发关键词：确认issue、创建issue、落地issue、confirmed_issue、批量建issue。不处理会议创建、会前简报、会后纪要等场景。" author: mayidan --- # Skill-D: create_issues — issue 落地总控 ## 定位 本技能是 **事件驱动的 issue 落地流程总控**。由组织者确认 issue 草稿后触发，非 cron。 职责分工： - **OpenClaw** - 被 webhook 或对话触发后，调用 `check` 验证前置条件 - 读取 `confirmed_issue.md` 内容，解析出每条 action item 的结构化字段 - 判断 `meeting_category`（single / cross-project） - 单项目：调用 `create-issues` 批量建 Gitea issue - 调用 `finish` 收尾，获取邮件参数 - 调用 imap-smtp-email 发送正式纪要邮件（全员）+ 个人任务通知（各 assignee） - 跨项目：调用 `finish`（跳过建 issue），调用 imap-smtp-email 向组织者发建议邮件 - **Skill-D（本技能）** - `check`：校验 meta.yaml status 和 confirmed_issue.md 存在性，返回内容与元信息 - `create-issues`：接收 OpenClaw 解析好的 JSON，批量在 Gitea 建 issue + 依赖回填 - `finish`：meta.yaml → minutes-published，写 created_issues 字段，写日志，返回邮件参数 - `webhook.py`：独立常驻进程，监听 Gitea push webhook，检测到 confirmed_issue.md 时向 OpenClaw 发送触发信号 - **imap-smtp-email** - 发送正式纪要邮件（全体参会人） - 发送个人任务通知邮件（各 assignee 单独一封） - 发送跨项目 issue 建议邮件（组织者） --- ## 配置要求 配置文件：`~/.config/skill-d-create-issues/.env` 首次使用前运行： ```bash bash setup.sh ``` --- ## 触发方式 ### 方式 A：Gitea Webhook（推荐） 组织者在 Gitea 网页将 `draft_issue.md` 改名为 `confirmed_issue.md`，触发 push webhook。 **Webhook 配置步骤：** 1. 在每个受管仓库（包括 aifusion-meta）的 Settings → Webhooks → Add Webhook 2. URL：`http://43.156.243.152:<WEBHOOK_PORT>/gitea-webhook`（端口见 .env） 3. Content Type：`application/json` 4. Trigger：`Push Events` **启动 Webhook 监听服务（后台常驻）：** ```bash nohup python3 scripts/webhook.py > ~/.config/skill-d-create-issues/webhook.log 2>&1 & ``` ### 方式 B：OpenClaw 对话 ``` 用户："确认 2026-04-22-1500 的 issue" ``` OpenClaw 识别后，询问用户确认的是哪个仓库（如果上下文不明确），然后进入以下工作流。 --- ## 严格工作流（两种触发方式执行相同流程） --- ### 第一步：调用 check，验证前置条件 ```bash node main.js check \ --repo "HKU-AIFusion/dexterous-hand" \ --meeting-dir "2026-04-22-1500" ``` 返回 JSON，关键字段： - `valid`：true / false（false 时含 `reason` 字段，OpenClaw 直接告知用户） - `category`：`single` | `cross-project` - `topic`、`organizer`、`organizer_email`、`attendees`、`attendee_emails` - `confirmed_issue_content`：confirmed_issue.md 的完整文本 - `minutes_content`：minutes.md 的完整文本（用于正式纪要邮件） - `meeting_dir`、`repo`、`join_url` **`valid` 为 false 时，OpenClaw 向用户说明原因，不继续执行后续步骤。** --- ### 第二步：OpenClaw 解析 confirmed_issue.md **此步骤 OpenClaw 作为 AI 负责全部解析，不调用任何脚本。** 从 `confirmed_issue_content` 中解析每条带勾选框的 action item，输出 JSON 数组，**只输出 JSON，不加任何说明文字或 markdown 标记**： ```json [ { "local_id": "1", "task": "任务描述", "assignee": "sujinze", "due_date": "2026-04-29", "depends_on_local_ids": [], "quote": "原话引用" }, { "local_id": "2", "task": "另一个任务", "assignee": "liuzhaolin", "due_date": "2026-05-06", "depends_on_local_ids": ["1"], "quote": "相关原话" } ] ``` 字段说明： - `local_id`：本次解析的临时编号（"1" "2" ...），用于 depends_on_local_ids 引用 - `assignee`：Gitea 用户名（参考 attendees 列表） - `due_date`：YYYY-MM-DD 格式 - `depends_on_local_ids`：依赖的其他任务 local_id 列表（无依赖则为 []） - `quote`：原话引用（来自 confirmed_issue.md 中的 > "..." 内容） **如果解析结果为空数组（无待办），跳过第三步，直接进入第四步（finish）。** --- ### 第三步（仅单项目会议）：调用 create-issues 批量建 Gitea issue ```bash node main.js create-issues \ --repo "HKU-AIFusion/dexterous-hand" \ --meeting-dir "2026-04-22-1500" \ --topic "v2 设计评审" \ --issues-json '[{"local_id":"1","task":"...","assignee":"sujinze","due_date":"2026-04-29","depends_on_local_ids":[],"quote":"..."}]' ``` 本命令会： 1. 第一轮：为每条 action item 在仓库创建 Gitea issue - Title：任务描述 - Labels：`meeting-action`、`meeting:YYYY-MM-DD-HHMM` - Assignee：指定用户名 - Due Date：截止日期（若 Gitea 版本支持） - Body：包含原话引用 + 会议目录链接 2. 第二轮：回填依赖关系（在 issue body 追加 `Depends-on: #N` 行） 3. 返回 local_id → issue_number 映射，供 finish 写入 meta.yaml 返回 JSON，关键字段： - `success` - `created`：`[{local_id, issue_number, issue_url, assignee, task}]` - `failed`：`[{local_id, task, error}]`（部分失败时非空） **跨项目会议跳过此步骤，直接进入第四步。** --- ### 第四步：调用 finish 收尾 ```bash node main.js finish \ --repo "HKU-AIFusion/dexterous-hand" \ --meeting-dir "2026-04-22-1500" \ --topic "v2 设计评审" \ --category "single" \ --organizer-email "[REDACTED_SECRET]" \ --attendee-emails "[REDACTED_SECRET],[REDACTED_SECRET]" \ [--created-issues-json '[{"local_id":"1","issue_number":42,"issue_url":"...","assignee":"sujinze","task":"..."}]'] \ [--failed-issues-json '[...]'] ``` 本命令会： 1. 将 meta.yaml status → `minutes-published`，写入 `created_issues` 字段（issue 编号列表） 2. 写日志 3. 生成并返回三种邮件参数： - `minutes_email`：正式纪要邮件，发给全体参会人（含 minutes.md 内容摘要 + issue 列表链接） - `assignee_emails`：每位 assignee 的个人任务通知邮件（数组，每人一封） - `cross_email`：跨项目会议时，给组织者的建议邮件（含 confirmed_issue.md 完整内容） --- ### 第五步：调用 imap-smtp-email 发送邮件 **单项目会议：** 1. 发送正式纪要邮件（`minutes_email`）： - 收件人：全体参会人（`minutes_email.to`） - 主题：`minutes_email.subject` - 正文：`minutes_email.html` 2. 逐一发送个人任务通知（`assignee_emails` 数组，每人一封）： - 收件人：单人邮箱（`assignee_emails[i].to`） - 主题：`assignee_emails[i].subject` - 正文：`assignee_emails[i].html` **跨项目会议：** 发送组织者建议邮件（`cross_email`）： - 收件人：`cross_email.to`（组织者邮箱） - 主题：`cross_email.subject` - 正文：`cross_email.html` --- ## 错误处理规则 | 错误场景 | 处理方式 | |----------|----------| | check 发现 status 不符 | 返回 valid=false + reason，OpenClaw 告知用户 | | check 发现 confirmed_issue.md 不存在 | 返回 valid=false，OpenClaw 提示用户先改文件名 | | confirmed_issue.md 解析结果为空 | 跳过 create-issues，finish 时 created_issues=[] | | 某条 issue 创建失败 | 继续创建其他，failed 列表记录，finish 后邮件提醒组织者 | | 依赖回填失败 | 不影响 issue 本身，日志警告 | | finish 写 Gitea 失败 | 报错，OpenClaw 提示用户重试（可再次调用 confirm 触发） | | 邮件发送失败 | 状态已更新为 minutes-published，日志标注 email_failed | --- ## 幂等性说明 - `check`：只读，天然幂等 - `create-issues`：若 meta.yaml 已是 minutes-published，拒绝执行（避免重复建 issue） - `finish`：若 meta.yaml 已是 minutes-published，返回幂等成功（不重复写） - webhook：同一次 push 若重复触发，check 的状态校验会拦截

path: env-example.txt
content: # Gitea
GITEA_BASE_URL=http://43.156.243.152:3000
GITEA_TOKEN_BOT=your_aifusionbot_access_token_here

# 元信息仓库（用于统一日志）
AIFUSION_META_REPO=HKU-AIFusion/aifusion-meta

# Webhook 服务端口（Gitea webhook 回调地址需与此一致）
WEBHOOK_PORT=8765

# Webhook secret（在 Gitea 配置 webhook 时填写的 secret，留空则不验签）
WEBHOOK_SECRET=


path: main.js
content: #!/usr/bin/env node
/**
 * Skill-D: create_issues — CLI entry point
 *
 * 命令：
 *   node main.js check          [options]   校验前置条件，返回 confirmed_issue.md 内容
 *   node main.js create-issues  [options]   批量建 Gitea issue + 依赖回填
 *   node main.js finish         [options]   收尾：更新状态、写日志、返回邮件参数
 *
 * OpenClaw 负责：
 *   - 被 webhook（方式A）或对话（方式B）触发
 *   - 调用 check 验证前置条件
 *   - 解析 confirmed_issue.md 内容，提取结构化 action items
 *   - 判断 single vs cross-project
 *   - 单项目：调用 create-issues 批量建 issue
 *   - 调用 finish 获取邮件参数
 *   - 调用 imap-smtp-email 发送正式纪要邮件 + 个人任务通知
 *   - 跨项目：调用 finish，再调用 imap-smtp-email 发组织者建议邮件
 *
 * Skill-D 只负责：
 *   - 前置条件校验
 *   - Gitea issue 创建与依赖回填
 *   - meta.yaml 状态更新
 *   - 日志写入
 *   - 邮件 HTML 构建与参数封装
 *
 * webhook 监听服务（独立进程）：
 *   python3 scripts/webhook.py
 */

const { spawnSync } = require('child_process');
const path = require('path');

const SCRIPT_DIR = path.join(__dirname, 'scripts');

function runPython(script, args) {
  const result = spawnSync(
    'python3',
    [path.join(SCRIPT_DIR, script), ...args],
    {
      encoding: 'utf8',
      stdio: ['inherit', 'pipe', 'pipe'],
      env: process.env,
    }
  );

  if (result.error) {
    console.error(`Failed to launch ${script}:`, result.error.message);
    process.exit(1);
  }

  if (result.stderr && result.stderr.trim()) {
    process.stderr.write(result.stderr);
  }

  if (result.status !== 0) {
    try {
      const parsed = JSON.parse(result.stdout);
      if (parsed.error) {
        console.error(`Error: ${parsed.error}`);
      } else {
        console.log(result.stdout);
      }
    } catch {
      console.error(
        result.stdout || result.stderr || `${script} exited with code ${result.status}`
      );
    }
    process.exit(result.status);
  }

  return result.stdout;
}

const args = process.argv.slice(2);
const command = args[0];

const USAGE = `
Usage: node main.js <command> [options]

Commands:
  check --repo OWNER/REPO --meeting-dir DIR
    校验前置条件（status==draft-pending-review，confirmed_issue.md 存在）
    返回 confirmed_issue.md 内容、minutes.md 内容、meta 信息

  create-issues --repo OWNER/REPO --meeting-dir DIR --topic TEXT
    --issues-json JSON_ARRAY_STRING
    批量在 Gitea 建 issue，完成依赖回填
    返回 created / failed 列表

  finish --repo OWNER/REPO --meeting-dir DIR --topic TEXT
    --category single|cross-project
    --organizer-email EMAIL --attendee-emails EMAIL_LIST
    [--created-issues-json JSON]
    [--failed-issues-json JSON]
    更新 meta.yaml → minutes-published，写日志
    返回三种邮件参数（minutes_email / assignee_emails / cross_email）
`.trim();

if (!command) {
  console.error(USAGE);
  process.exit(1);
}

const commandMap = {
  'check':          'check.py',
  'create-issues':  'create_issues.py',
  'finish':         'finish.py',
};

if (!commandMap[command]) {
  console.error(`Unknown command: ${command}\n\n${USAGE}`);
  process.exit(1);
}

const output = runPython(commandMap[command], args.slice(1));
process.stdout.write(output);


path: requirements.txt
content: requests>=2.28.0
python-dotenv>=1.0.0
PyYAML>=6.0
pytz>=2023.3
python-dateutil>=2.8.2
flask>=3.0.0


path: setup.sh
content: #!/bin/bash
set -e

SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_DIR="$HOME/.config/skill-d-create-issues"
ENV_FILE="$CONFIG_DIR/.env"

echo "🚀 设置 Skill-D (create_issues)..."
echo ""

mkdir -p "$CONFIG_DIR"

echo "📦 安装 Python 依赖..."
pip install -r "$SKILL_DIR/requirements.txt" --break-system-packages -q
echo "✅ Python 依赖安装完成"

if [ ! -f "$ENV_FILE" ]; then
    cp "$SKILL_DIR/env-example.txt" "$ENV_FILE"
    chmod 600 "$ENV_FILE"
    echo ""
    echo "📝 已创建配置文件：$ENV_FILE"
    echo "请编辑该文件，至少填入："
    echo "  - GITEA_BASE_URL"
    echo "  - GITEA_TOKEN_BOT"
    echo "  - AIFUSION_META_REPO"
    echo "  - WEBHOOK_PORT（默认 8765）"
    exit 0
fi

echo ""
echo "🔍 检查配置..."
set -a; source "$ENV_FILE"; set +a

MISSING=()
for VAR in GITEA_BASE_URL GITEA_TOKEN_BOT AIFUSION_META_REPO; do
    if [ -z "${!VAR}" ]; then
        MISSING+=("$VAR")
    fi
done

if [ ${#MISSING[@]} -gt 0 ]; then
    echo "❌ 以下环境变量未配置，请编辑 $ENV_FILE："
    for V in "${MISSING[@]}"; do echo "   - $V"; done
    exit 1
fi

WEBHOOK_PORT="${WEBHOOK_PORT:-8765}"

echo "✅ 所有必填配置项已就绪"
echo ""
echo "────────────────────────────────────"
echo "🎉 Skill-D 安装完成！"
echo ""
echo "说明："
echo "- confirmed_issue.md 解析由 OpenClaw 负责"
echo "- 邮件发送由 imap-smtp-email 负责"
echo "- Skill-D 只负责 Gitea issue 创建 / 状态更新 / 日志"
echo ""
echo "Webhook 服务启动（方式 A 触发需要）："
echo "  nohup python3 $SKILL_DIR/scripts/webhook.py \\"
echo "    > $CONFIG_DIR/webhook.log 2>&1 &"
echo ""
echo "Gitea 每个受管仓库需配置 webhook："
echo "  URL: http://43.156.243.152:${WEBHOOK_PORT}/gitea-webhook"
echo "  Content-Type: application/json"
echo "  触发事件: Push Events"


path: _meta.json
content: name: skill-d-create-issues
version: 1.0.0
description: 会议全流程自动化 Skill-D：事件驱动，组织者确认 issue 草稿后批量在 Gitea 建 issue，发正式纪要邮件与个人任务通知
author: mayidan

path: scripts/check.py
content: #!/usr/bin/env python3
"""
Skill-D check：校验前置条件，返回 confirmed_issue.md 内容和会议元信息。

校验项：
  1. meta.yaml status == draft-pending-review
  2. confirmed_issue.md 存在

用法：
    python3 check.py \
      --repo "HKU-AIFusion/dexterous-hand" \
      --meeting-dir "2026-04-22-1500"
"""

import os
import sys
import json
import argparse

import yaml
from dotenv import load_dotenv

load_dotenv(os.path.expanduser("~/.config/skill-d-create-issues/.env"))

sys.path.insert(0, os.path.dirname(__file__))
from gitea_utils import (
    get_file_from_repo,
    file_exists_in_repo,
    get_user_email,
    get_repo_member_usernames,
)

GITEA_BASE_URL = os.getenv("GITEA_BASE_URL", "")
GITEA_TOKEN    = os.getenv("GITEA_TOKEN_BOT", "")


def main():
    parser = argparse.ArgumentParser(description="Skill-D check：校验前置条件")
    parser.add_argument("--repo",        required=True)
    parser.add_argument("--meeting-dir", required=True)
    args = parser.parse_args()

    if not GITEA_BASE_URL or not GITEA_TOKEN:
        _invalid("缺少 Gitea 配置，请检查 ~/.config/skill-d-create-issues/.env")

    owner, repo_name = args.repo.split("/", 1)
    d = args.meeting_dir
    meta_path = f"meetings/{d}/meta.yaml"

    # ── 1. 读取 meta.yaml ─────────────────────────────────────────────────────

    raw, _ = get_file_from_repo(owner, repo_name, meta_path, GITEA_TOKEN, GITEA_BASE_URL)
    if raw is None:
        _invalid(f"meta.yaml 不存在，请确认会议目录正确：meetings/{d}/")

    try:
        meta = yaml.safe_load(raw) or {}
    except Exception as e:
        _invalid(f"meta.yaml 解析失败：{e}")

    status = meta.get("status", "")
    if status != "draft-pending-review":
        _invalid(
            f"当前状态为 '{status}'，不是 draft-pending-review。"
            + ("（已完成，无需重复操作）" if status == "minutes-published" else "")
        )

    # ── 2. 确认 confirmed_issue.md 存在 ──────────────────────────────────────

    confirmed_path = f"meetings/{d}/confirmed_issue.md"
    if not file_exists_in_repo(owner, repo_name, confirmed_path, GITEA_TOKEN, GITEA_BASE_URL):
        _invalid(
            "confirmed_issue.md 不存在。\n"
            "请在 Gitea 中将 draft_issue.md 改名为 confirmed_issue.md，"
            "或在 OpenClaw 中说"确认该会议的 issue"。"
        )

    # ── 3. 读取 confirmed_issue.md ────────────────────────────────────────────

    confirmed_content, _ = get_file_from_repo(
        owner, repo_name, confirmed_path, GITEA_TOKEN, GITEA_BASE_URL
    )
    if not confirmed_content:
        _invalid("confirmed_issue.md 内容为空，请检查文件是否正确上传。")

    # ── 4. 读取 minutes.md（可能不存在）──────────────────────────────────────

    minutes_content, _ = get_file_from_repo(
        owner, repo_name, f"meetings/{d}/minutes.md", GITEA_TOKEN, GITEA_BASE_URL
    )

    # ── 5. 解析参会人信息 ─────────────────────────────────────────────────────

    attendees = meta.get("attendees") or get_repo_member_usernames(
        owner, repo_name, GITEA_TOKEN, GITEA_BASE_URL
    )

    attendee_emails = [
        e for e in (get_user_email(u, GITEA_TOKEN, GITEA_BASE_URL) for u in attendees)
        if e
    ]

    organizer       = meta.get("organizer", "")
    organizer_email = get_user_email(organizer, GITEA_TOKEN, GITEA_BASE_URL) if organizer else ""
    if not organizer_email and owner:
        organizer_email = get_user_email(owner, GITEA_TOKEN, GITEA_BASE_URL)

    print(json.dumps({
        "valid":                    True,
        "repo":                     args.repo,
        "meeting_dir":              d,
        "category":                 meta.get("meeting_category", "single"),
        "topic":                    meta.get("topic", ""),
        "scheduled_time":           meta.get("scheduled_time", ""),
        "join_url":                 meta.get("join_url", ""),
        "organizer":                organizer,
        "organizer_email":          organizer_email,
        "attendees":                attendees,
        "attendee_emails":          attendee_emails,
        "confirmed_issue_content":  confirmed_content,
        "minutes_content":          minutes_content or "",
    }, ensure_ascii=False, indent=2))


def _invalid(reason):
    print(json.dumps({"valid": False, "reason": reason}, ensure_ascii=False))
    sys.exit(0)   # 校验失败不是脚本错误，正常退出让 OpenClaw 读 valid=false


if __name__ == "__main__":
    main()


path: scripts/create_issues.py
content: #!/usr/bin/env python3
"""
Skill-D create-issues：接收 OpenClaw 解析好的 action items JSON，
批量在 Gitea 仓库建 issue，完成依赖关系回填。

OpenClaw 负责：解析 confirmed_issue.md，提取结构化 action items
本脚本负责：调用 Gitea API 创建 issue，管理 label，回填 Depends-on

输入 issues-json 格式：
[
  {
    "local_id": "1",
    "task": "任务描述",
    "assignee": "sujinze",
    "due_date": "2026-04-29",
    "depends_on_local_ids": [],
    "quote": "原话引用"
  }
]

用法：
    python3 create_issues.py \
      --repo "HKU-AIFusion/dexterous-hand" \
      --meeting-dir "2026-04-22-1500" \
      --topic "v2 设计评审" \
      --issues-json '[...]'
"""

import os
import sys
import json
import argparse
from datetime import datetime

import pytz
from dotenv import load_dotenv

load_dotenv(os.path.expanduser("~/.config/skill-d-create-issues/.env"))

sys.path.insert(0, os.path.dirname(__file__))
from gitea_utils import (
    ensure_label,
    create_issue,
    update_issue_body,
)

GITEA_BASE_URL = os.getenv("GITEA_BASE_URL", "")
GITEA_TOKEN    = os.getenv("GITEA_TOKEN_BOT", "")
TZ             = pytz.timezone("Asia/Shanghai")

# issue label 颜色
LABEL_MEETING_ACTION_COLOR = "#e11d48"   # 红色：需要行动
LABEL_MEETING_REF_COLOR    = "#6366f1"   # 紫色：来源标记


def build_issue_body(task, assignee, due_date, quote, meeting_dir,
                     repo, gitea_base_url, depends_placeholder=""):
    """构建 Gitea issue 正文 Markdown。"""
    dir_url = f"{gitea_base_url.rstrip('/')}/{repo}/src/branch/main/meetings/{meeting_dir}/"
    try:
        parts      = meeting_dir.split("-")
        time_label = f"{parts[0]}-{parts[1]}-{parts[2]} {parts[3][:2]}:{parts[3][2:]}"
    except Exception:
        time_label = meeting_dir

    lines = [
        f"## 任务描述",
        f"",
        f"{task}",
        f"",
    ]

    if due_date:
        lines += [f"**截止日期**：{due_date}", ""]

    if depends_placeholder:
        lines += [depends_placeholder, ""]

    if quote:
        lines += [
            f"## 原话引用",
            f"",
            f"> {quote}",
            f"",
        ]

    lines += [
        f"## 来源",
        f"",
        f"- 会议：[{time_label}]({dir_url})",
        f"- 负责人：@{assignee}" if assignee else "",
        f"",
        f"---",
        f"*本 issue 由 AIFusion Bot 根据会议决议自动创建*",
    ]

    return "\n".join(line for line in lines)


def main():
    parser = argparse.ArgumentParser(description="Skill-D create-issues：批量建 Gitea issue")
    parser.add_argument("--repo",         required=True)
    parser.add_argument("--meeting-dir",  required=True)
    parser.add_argument("--topic",        required=True)
    parser.add_argument("--issues-json",  required=True, help="JSON 数组字符串")
    args = parser.parse_args()

    if not GITEA_BASE_URL or not GITEA_TOKEN:
        _fail("缺少 Gitea 配置，请检查 ~/.config/skill-d-create-issues/.env")

    try:
        items = json.loads(args.issues_json)
    except Exception as e:
        _fail(f"issues-json 解析失败：{e}")

    if not items:
        print(json.dumps({
            "success": True,
            "created": [],
            "failed":  [],
            "message": "issues-json 为空，无需建 issue",
        }, ensure_ascii=False, indent=2))
        return

    owner, repo_name = args.repo.split("/", 1)
    d = args.meeting_dir

    # ── 确保 label 存在 ───────────────────────────────────────────────────────

    label_action_id = ensure_label(
        owner, repo_name, "meeting-action",
        GITEA_TOKEN, GITEA_BASE_URL,
        color=LABEL_MEETING_ACTION_COLOR,
    )
    label_ref_id = ensure_label(
        owner, repo_name, f"meeting:{d}",
        GITEA_TOKEN, GITEA_BASE_URL,
        color=LABEL_MEETING_REF_COLOR,
    )
    label_ids = [lid for lid in [label_action_id, label_ref_id] if lid is not None]

    # ── 第一轮：创建所有 issue ────────────────────────────────────────────────

    # local_id → {issue_number, issue_url, assignee, task}
    created_map: dict[str, dict] = {}
    created = []
    failed  = []

    for item in items:
        local_id = str(item.get("local_id", ""))
        task     = item.get("task", "（无标题）")
        assignee = item.get("assignee", "")
        due_date = item.get("due_date", "")
        quote    = item.get("quote", "")

        body = build_issue_body(
            task, assignee, due_date, quote,
            d, args.repo, GITEA_BASE_URL,
        )

        assignees = [assignee] if assignee else []

        try:
            issue_number, issue_url = create_issue(
                owner, repo_name,
                title=task,
                body=body,
                assignees=assignees,
                label_ids=label_ids,
                [REDACTED_SECRET],
                base_url=GITEA_BASE_URL,
            )
            created_map[local_id] = {
                "local_id":    local_id,
                "issue_number": issue_number,
                "issue_url":   issue_url,
                "assignee":    assignee,
                "task":        task,
                "due_date":    due_date,
                "quote":       quote,
            }
            created.append(created_map[local_id])
        except Exception as e:
            failed.append({"local_id": local_id, "task": task, "error": str(e)})

    # ── 第二轮：回填依赖关系 ──────────────────────────────────────────────────

    for item in items:
        local_id      = str(item.get("local_id", ""))
        dep_local_ids = [str(x) for x in item.get("depends_on_local_ids", [])]

        if not dep_local_ids:
            continue

        # 本条或依赖项若有任一创建失败则跳过
        if local_id not in created_map:
            continue
        dep_issue_numbers = []
        for dep_id in dep_local_ids:
            if dep_id in created_map:
                dep_issue_numbers.append(created_map[dep_id]["issue_number"])

        if not dep_issue_numbers:
            continue

        this = created_map[local_id]
        depends_line = "**依赖**：" + ", ".join(f"#{n}" for n in dep_issue_numbers)

        # 重新构建正文（带依赖行）
        new_body = build_issue_body(
            this["task"], this["assignee"], this["due_date"], this["quote"],
            d, args.repo, GITEA_BASE_URL,
            depends_placeholder=depends_line,
        )

        try:
            update_issue_body(
                owner, repo_name, this["issue_number"],
                new_body, GITEA_TOKEN, GITEA_BASE_URL,
            )
        except Exception:
            # 依赖回填失败不影响 issue 本身，静默忽略
            pass

    print(json.dumps({
        "success": True,
        "created": created,
        "failed":  failed,
    }, ensure_ascii=False, indent=2))


def _fail(message):
    print(json.dumps({"success": False, "error": message}, ensure_ascii=False))
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/email_utils.py
content: """
Skill-D 邮件 HTML 构建器。
三种邮件：
  1. 正式纪要邮件（全员）
  2. 个人任务通知邮件（每位 assignee 单独一封）
  3. 跨项目建议邮件（组织者）
"""


def _base_style():
    return (
        'font-family: Arial, "PingFang SC", sans-serif; '
        'max-width: 600px; margin: 0 auto; padding: 24px; color: #333;'
    )


def _format_time(meeting_dir):
    try:
        parts = meeting_dir.split("-")
        return f"{parts[0]}-{parts[1]}-{parts[2]} {parts[3][:2]}:{parts[3][2:]}"
    except Exception:
        return meeting_dir


# ─────────────────────────────────────────────────────────────────────────────
# 1. 正式纪要邮件（全员）
# ─────────────────────────────────────────────────────────────────────────────

def build_minutes_html(topic, meeting_dir, repo, organizer,
                       join_url, created_issues, failed_issues,
                       gitea_base_url, minutes_content_summary=""):
    """
    正式纪要邮件，发给全体参会人。
    created_issues: [{local_id, issue_number, issue_url, assignee, task}]
    failed_issues:  [{local_id, task, error}]
    minutes_content_summary: minutes.md 的前 500 字（OpenClaw 提取的摘要文本）
    """
    time_label   = _format_time(meeting_dir)
    dir_url      = f"{gitea_base_url.rstrip('/')}/{repo}/src/branch/main/meetings/{meeting_dir}/"
    minutes_url  = f"{dir_url}minutes.md"

    # 已建 issue 列表
    if created_issues:
        issue_rows = "".join(
            f'<tr>'
            f'<td style="padding:8px 12px;border:1px solid #e0e0e0;">'
            f'<a href="{i["issue_url"]}" style="color:#1a73e8;">#{i["issue_number"]}</a></td>'
            f'<td style="padding:8px 12px;border:1px solid #e0e0e0;">{i["task"]}</td>'
            f'<td style="padding:8px 12px;border:1px solid #e0e0e0;">@{i["assignee"]}</td>'
            f'</tr>'
            for i in created_issues
        )
        issue_section = f"""
<h3 style="color:#34a853;margin:24px 0 12px;">✅ 已创建 Gitea Issue（{len(created_issues)} 条）</h3>
<table style="border-collapse:collapse;width:100%;font-size:13px;">
  <tr style="background:#f8f9fa;">
    <th style="padding:8px 12px;border:1px solid #e0e0e0;text-align:left;width:70px;">#</th>
    <th style="padding:8px 12px;border:1px solid #e0e0e0;text-align:left;">任务</th>
    <th style="padding:8px 12px;border:1px solid #e0e0e0;text-align:left;width:120px;">负责人</th>
  </tr>
  {issue_rows}
</table>"""
    else:
        issue_section = '<p style="color:#666;">本次会议未创建 Gitea Issue。</p>'

    # 失败提示
    failed_section = ""
    if failed_issues:
        failed_list = "".join(
            f'<li>{f["task"]}（{f["error"]}）</li>'
            for f in failed_issues
        )
        failed_section = f"""
<div style="background:#fff3e0;border-left:4px solid #e65100;padding:12px 16px;
            margin:16px 0;border-radius:4px;">
  <p style="margin:0 0 8px;font-weight:bold;">⚠️ 以下任务创建失败，请手动处理：</p>
  <ul style="margin:0;padding-left:20px;">{failed_list}</ul>
</div>"""

    # 纪要摘要
    summary_section = ""
    if minutes_content_summary:
        summary_section = f"""
<h3 style="color:#1a73e8;margin:24px 0 12px;">📝 会议纪要摘要</h3>
<div style="background:#f8f9fa;padding:16px;border-radius:4px;font-size:13px;
            white-space:pre-line;line-height:1.6;">{minutes_content_summary}</div>"""

    return f"""<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"></head>
<body style="{_base_style()}">

  <h2 style="color:#1a73e8;margin-bottom:4px;">📋 会议纪要正式发布</h2>
  <p style="color:#666;margin-top:0;">以下是本次会议的正式纪要与任务安排。</p>

  <table style="border-collapse:collapse;width:100%;margin:16px 0;font-size:14px;">
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 width:110px;border:1px solid #e0e0e0;">会议主题</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{topic}</td>
    </tr>
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 border:1px solid #e0e0e0;">会议时间</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{time_label}</td>
    </tr>
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 border:1px solid #e0e0e0;">组织者</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{organizer}</td>
    </tr>
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 border:1px solid #e0e0e0;">所属项目</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{repo}</td>
    </tr>
  </table>

  {summary_section}
  {issue_section}
  {failed_section}

  <div style="margin:24px 0;">
    <a href="{minutes_url}"
       style="background:#1a73e8;color:white;padding:11px 22px;text-decoration:none;
              border-radius:5px;display:inline-block;font-size:14px;margin-right:10px;">
      📝 查看完整会议纪要
    </a>
    <a href="{dir_url}"
       style="background:#34a853;color:white;padding:11px 22px;text-decoration:none;
              border-radius:5px;display:inline-block;font-size:14px;">
      📁 查看会议目录
    </a>
  </div>

  <hr style="border:none;border-top:1px solid #e8e8e8;margin:24px 0;">
  <p style="color:#999;font-size:12px;margin:0;">本邮件由 AIFusion Bot 自动发送，请勿直接回复。</p>
</body>
</html>"""


# ─────────────────────────────────────────────────────────────────────────────
# 2. 个人任务通知邮件（每位 assignee 单独一封）
# ─────────────────────────────────────────────────────────────────────────────

def build_assignee_html(assignee_username, tasks, topic, meeting_dir,
                        repo, gitea_base_url):
    """
    个人任务通知邮件。
    tasks: [{issue_number, issue_url, task, due_date, depends_on_str, quote}]
    """
    time_label = _format_time(meeting_dir)

    task_cards = ""
    for t in tasks:
        due = t.get("due_date", "")
        due_str = f'<p style="margin:4px 0;color:#666;font-size:13px;">📅 截止日期：{due}</p>' if due else ""
        dep = t.get("depends_on_str", "")
        dep_str = f'<p style="margin:4px 0;color:#666;font-size:13px;">🔗 依赖：{dep}</p>' if dep else ""
        quote = t.get("quote", "")
        quote_str = (
            f'<blockquote style="border-left:3px solid #ccc;margin:8px 0 0;'
            f'padding:6px 12px;color:#666;font-size:12px;">{quote}</blockquote>'
        ) if quote else ""

        task_cards += f"""
<div style="border:1px solid #e0e0e0;border-radius:6px;padding:14px 16px;
            margin:12px 0;background:#fafafa;">
  <p style="margin:0 0 6px;font-weight:bold;">
    <a href="{t.get('issue_url','')}" style="color:#1a73e8;text-decoration:none;">
      #{t.get('issue_number','')}
    </a>
    {t.get('task','')}
  </p>
  {due_str}{dep_str}{quote_str}
</div>"""

    return f"""<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"></head>
<body style="{_base_style()}">

  <h2 style="color:#1a73e8;margin-bottom:4px;">📌 您有新的会议任务</h2>
  <p style="color:#666;margin-top:0;">
    以下任务在 <strong>{topic}</strong>（{time_label}）中分配给您，请及时跟进。
  </p>

  {task_cards}

  <div style="background:#e8f5e9;border-left:4px solid #34a853;padding:12px 16px;
              margin:20px 0;border-radius:4px;">
    <p style="margin:0;font-size:13px;">
      💡 请在 Gitea 中打开对应 issue，更新进度或关闭 issue 以完成任务跟踪。
    </p>
  </div>

  <hr style="border:none;border-top:1px solid #e8e8e8;margin:24px 0;">
  <p style="color:#999;font-size:12px;margin:0;">
    本邮件由 AIFusion Bot 自动发送，请勿直接回复。<br>
    所属项目：{repo}
  </p>
</body>
</html>"""


# ─────────────────────────────────────────────────────────────────────────────
# 3. 跨项目建议邮件（组织者）
# ─────────────────────────────────────────────────────────────────────────────

def build_cross_project_html(topic, meeting_dir, repo, confirmed_issue_content,
                              gitea_base_url):
    """
    跨项目会议不自动建 issue，向组织者发送建议邮件。
    confirmed_issue_content：confirmed_issue.md 的完整文本。
    """
    time_label = _format_time(meeting_dir)
    dir_url    = f"{gitea_base_url.rstrip('/')}/{repo}/src/branch/main/meetings/{meeting_dir}/"

    # 渲染 confirmed_issue 内容（截断过长内容）
    content_preview = confirmed_issue_content[:3000]
    if len(confirmed_issue_content) > 3000:
        content_preview += "\n\n...（内容过长，已截断，请到 Gitea 查看完整版）"

    return f"""<!DOCTYPE html>
<html lang="zh">
<head><meta charset="utf-8"></head>
<body style="{_base_style()}">

  <h2 style="color:#7b1fa2;margin-bottom:4px;">📋 跨项目会议 Issue 建议（需人工处理）</h2>
  <p style="color:#666;margin-top:0;">
    本次会议为跨项目会议，系统不会自动创建 Gitea Issue。
    以下是 AI 从会议转录中提取的 issue 建议，请组织者参考后自行处理。
  </p>

  <table style="border-collapse:collapse;width:100%;margin:16px 0;font-size:14px;">
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 width:110px;border:1px solid #e0e0e0;">会议主题</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{topic}</td>
    </tr>
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 border:1px solid #e0e0e0;">会议时间</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{time_label}</td>
    </tr>
    <tr>
      <td style="padding:10px 14px;background:#f8f9fa;font-weight:bold;
                 border:1px solid #e0e0e0;">存档仓库</td>
      <td style="padding:10px 14px;border:1px solid #e0e0e0;">{repo}</td>
    </tr>
  </table>

  <h3 style="color:#7b1fa2;margin:20px 0 10px;">Issue 建议内容</h3>
  <div style="background:#f8f9fa;padding:16px;border-radius:4px;font-size:13px;
              white-space:pre-line;line-height:1.7;border:1px solid #e0e0e0;">
{content_preview}
  </div>

  <div style="margin:24px 0;">
    <a href="{dir_url}"
       style="background:#7b1fa2;color:white;padding:11px 22px;text-decoration:none;
              border-radius:5px;display:inline-block;font-size:14px;">
      📁 查看会议目录
    </a>
  </div>

  <hr style="border:none;border-top:1px solid #e8e8e8;margin:24px 0;">
  <p style="color:#999;font-size:12px;margin:0;">本邮件由 AIFusion Bot 自动发送，请勿直接回复。</p>
</body>
</html>"""


path: scripts/finish.py
content: #!/usr/bin/env python3
"""
Skill-D finish：收尾步骤。
  1. meta.yaml status → minutes-published，写入 created_issues 字段
  2. 写日志
  3. 生成并返回三种邮件参数，供 OpenClaw 调用 imap-smtp-email 发送

用法（单项目会议，有 issue）：
    python3 finish.py \
      --repo "HKU-AIFusion/dexterous-hand" \
      --meeting-dir "2026-04-22-1500" \
      --topic "v2 设计评审" \
      --category "single" \
      --organizer-email "[REDACTED_SECRET]" \
      --attendee-emails "[REDACTED_SECRET],[REDACTED_SECRET]" \
      --created-issues-json '[{"local_id":"1","issue_number":42,"issue_url":"...","assignee":"sujinze","task":"...","due_date":"...","quote":"..."}]' \
      --failed-issues-json '[]'

用法（跨项目会议，无 issue）：
    python3 finish.py \
      --repo "HKU-AIFusion/aifusion-meta" \
      --meeting-dir "2026-04-22-1500" \
      --topic "跨组全员会议" \
      --category "cross-project" \
      --organizer-email "[REDACTED_SECRET]" \
      --attendee-emails "" \
      --confirmed-issue-content "（confirmed_issue.md 完整文本）"
"""

import os
import sys
import json
import argparse
from datetime import datetime
from collections import defaultdict

import pytz
import yaml
from dotenv import load_dotenv

load_dotenv(os.path.expanduser("~/.config/skill-d-create-issues/.env"))

sys.path.insert(0, os.path.dirname(__file__))
from gitea_utils import get_file_from_repo, update_file_in_repo
from log_utils import write_log
from email_utils import (
    build_minutes_html,
    build_assignee_html,
    build_cross_project_html,
)

GITEA_BASE_URL = os.getenv("GITEA_BASE_URL", "")
GITEA_TOKEN    = os.getenv("GITEA_TOKEN_BOT", "")
META_REPO      = os.getenv("AIFUSION_META_REPO", "")
TZ             = pytz.timezone("Asia/Shanghai")


def _time_label(meeting_dir):
    try:
        parts = meeting_dir.split("-")
        return f"{parts[1]}-{parts[2]} {parts[3][:2]}:{parts[3][2:]}"
    except Exception:
        return meeting_dir


def main():
    parser = argparse.ArgumentParser(description="Skill-D finish：收尾，返回邮件参数")
    parser.add_argument("--repo",                    required=True)
    parser.add_argument("--meeting-dir",             required=True)
    parser.add_argument("--topic",                   required=True)
    parser.add_argument("--category",                default="single",
                        choices=["single", "cross-project"])
    parser.add_argument("--organizer-email",         required=True)
    parser.add_argument("--attendee-emails",         default="",
                        help="逗号分隔的全员邮箱")
    parser.add_argument("--created-issues-json",     default="[]")
    parser.add_argument("--failed-issues-json",      default="[]")
    parser.add_argument("--confirmed-issue-content", default="",
                        help="跨项目会议时传入 confirmed_issue.md 全文")
    parser.add_argument("--minutes-summary",         default="",
                        help="minutes.md 的摘要文本（OpenClaw 提取，用于正式纪要邮件）")
    args = parser.parse_args()

    if not GITEA_BASE_URL or not GITEA_TOKEN:
        _fail("缺少 Gitea 配置，请检查 ~/.config/skill-d-create-issues/.env")

    try:
        created_issues = json.loads(args.created_issues_json) if args.created_issues_json else []
    except Exception as e:
        _fail(f"created-issues-json 解析失败：{e}")

    try:
        failed_issues = json.loads(args.failed_issues_json) if args.failed_issues_json else []
    except Exception as e:
        _fail(f"failed-issues-json 解析失败：{e}")

    owner, repo_name = args.repo.split("/", 1)
    d         = args.meeting_dir
    meta_path = f"meetings/{d}/meta.yaml"

    # ── 读取 meta.yaml ────────────────────────────────────────────────────────

    raw, sha = get_file_from_repo(owner, repo_name, meta_path, GITEA_TOKEN, GITEA_BASE_URL)
    if raw is None:
        _fail(f"meta.yaml 不存在：{meta_path}")

    try:
        meta = yaml.safe_load(raw) or {}
    except Exception as e:
        _fail(f"meta.yaml 解析失败：{e}")

    old_status = meta.get("status", "")

    # 幂等检查
    if old_status == "minutes-published":
        print(json.dumps({
            "success":    True,
            "idempotent": True,
            "message":    "已处于 minutes-published 状态",
        }, ensure_ascii=False, indent=2))
        return

    # ── 更新 meta.yaml ────────────────────────────────────────────────────────

    meta["status"]          = "minutes-published"
    meta["published_at"]    = datetime.now(TZ).isoformat()
    meta["created_issues"]  = [i["issue_number"] for i in created_issues]

    new_content = yaml.dump(meta, allow_unicode=True, default_flow_style=False, sort_keys=False)
    try:
        update_file_in_repo(
            owner, repo_name, meta_path, new_content,
            f"chore(meeting): {old_status} → minutes-published [{d}]",
            sha, GITEA_TOKEN, GITEA_BASE_URL,
        )
    except Exception as e:
        _fail(f"meta.yaml 更新失败：{e}")

    # ── 写日志 ────────────────────────────────────────────────────────────────

    write_log({
        "ts":          datetime.now(TZ).isoformat(),
        "skill":       "skill-d",
        "repo":        args.repo,
        "meeting_dir": d,
        "action":      "minutes-published",
        "status":      "ok",
        "details": {
            "category":          args.category,
            "old_status":        old_status,
            "created_issues":    [i["issue_number"] for i in created_issues],
            "failed_count":      len(failed_issues),
        },
    }, META_REPO, GITEA_TOKEN, GITEA_BASE_URL)

    # ── 构建邮件参数 ──────────────────────────────────────────────────────────

    attendee_emails = [e.strip() for e in args.attendee_emails.split(",") if e.strip()]
    organizer       = meta.get("organizer", "")
    time_label      = _time_label(d)

    result = {
        "success":     True,
        "meeting_dir": d,
        "new_status":  "minutes-published",
        "created_count": len(created_issues),
        "failed_count":  len(failed_issues),
        # 三种邮件（按 category 使用不同的）
        "minutes_email":   None,
        "assignee_emails": [],
        "cross_email":     None,
    }

    if args.category == "single":

        # ── 正式纪要邮件（全员）────────────────────────────────────────────────

        minutes_html = build_minutes_html(
            topic=args.topic,
            meeting_dir=d,
            repo=args.repo,
            organizer=organizer,
            join_url=meta.get("join_url", ""),
            created_issues=created_issues,
            failed_issues=failed_issues,
            gitea_base_url=GITEA_BASE_URL,
            minutes_content_summary=args.minutes_summary,
        )
        result["minutes_email"] = {
            "to":      attendee_emails,
            "subject": f"【会议纪要】{args.topic} · {time_label}",
            "html":    minutes_html,
        }

        # ── 个人任务通知邮件（按 assignee 分组）──────────────────────────────

        # assignee → [task_dict]
        assignee_tasks: dict[str, list] = defaultdict(list)
        for issue in created_issues:
            a = issue.get("assignee", "")
            if a:
                assignee_tasks[a].append({
                    "issue_number": issue["issue_number"],
                    "issue_url":    issue["issue_url"],
                    "task":         issue["task"],
                    "due_date":     issue.get("due_date", ""),
                    "quote":        issue.get("quote", ""),
                    "depends_on_str": "",   # 依赖描述可由 OpenClaw 在 finish 前补充
                })

        for assignee_username, tasks in assignee_tasks.items():
            # 尝试从 attendee_emails 中匹配（根据顺序）；
            # 更精确的方式是 check 时返回 email_map，这里用简化方案：
            # OpenClaw 可以在调用 finish 时额外传入 assignee email map，
            # 或者此处直接用 Gitea API 查询（因为 token 在环境里）。
            from gitea_utils import get_user_email
            a_email = get_user_email(assignee_username, GITEA_TOKEN, GITEA_BASE_URL)
            if not a_email:
                continue

            a_html = build_assignee_html(
                assignee_username=assignee_username,
                tasks=tasks,
                topic=args.topic,
                meeting_dir=d,
                repo=args.repo,
                gitea_base_url=GITEA_BASE_URL,
            )
            result["assignee_emails"].append({
                "to":      [a_email],
                "subject": f"【任务分配】您在 "{args.topic}" 中有 {len(tasks)} 项待办 · {time_label}",
                "html":    a_html,
            })

    else:
        # ── 跨项目会议：仅发组织者建议邮件 ───────────────────────────────────

        cross_html = build_cross_project_html(
            topic=args.topic,
            meeting_dir=d,
            repo=args.repo,
            confirmed_issue_content=args.confirmed_issue_content,
            gitea_base_url=GITEA_BASE_URL,
        )
        result["cross_email"] = {
            "to":      [args.organizer_email] if args.organizer_email else [],
            "subject": f"【跨项目会议 issue 建议】{args.topic} · {time_label}",
            "html":    cross_html,
        }

    print(json.dumps(result, ensure_ascii=False, indent=2))


def _fail(message):
    print(json.dumps({"success": False, "error": message}, ensure_ascii=False))
    sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/gitea_utils.py
content: """
Gitea API 工具函数（与其他 Skill 保持一致，新增 issue 相关操作）
"""

import base64
import requests


def gitea_request(method, path, token, base_url, raise_on_error=True, **kwargs):
    url = f"{base_url.rstrip('/')}/api/v1{path}"
    headers = {
        "Authorization": f"token {token}",
        "Content-Type": "application/json",
    }
    resp = requests.request(method, url, headers=headers, timeout=15, **kwargs)
    if raise_on_error:
        resp.raise_for_status()
    return resp


def get_user_email(username, token, base_url):
    try:
        resp = gitea_request("GET", f"/users/{username}", token, base_url)
        return resp.json().get("email", "")
    except Exception:
        return ""


def get_repo_member_usernames(owner, repo, token, base_url):
    users = []
    seen = set()
    if owner and owner not in seen:
        users.append(owner)
        seen.add(owner)
    try:
        resp = gitea_request("GET", f"/repos/{owner}/{repo}/collaborators", token, base_url)
        for u in resp.json():
            login = u.get("login", "")
            if login and login not in seen:
                users.append(login)
                seen.add(login)
    except Exception:
        pass
    return users


def get_file_from_repo(owner, repo, filepath, token, base_url, branch="main"):
    try:
        resp = gitea_request(
            "GET",
            f"/repos/{owner}/{repo}/contents/{filepath}",
            token, base_url,
            params={"ref": branch},
        )
        data = resp.json()
        content = base64.b64decode(data["content"]).decode("utf-8")
        return content, data["sha"]
    except Exception:
        return None, None


def update_file_in_repo(owner, repo, filepath, new_content, message, sha,
                         token, base_url, branch="main"):
    encoded = base64.b64encode(new_content.encode("utf-8")).decode()
    resp = gitea_request(
        "PUT",
        f"/repos/{owner}/{repo}/contents/{filepath}",
        token, base_url,
        json={"message": message, "content": encoded, "sha": sha, "branch": branch},
    )
    return resp.json()


def file_exists_in_repo(owner, repo, filepath, token, base_url, branch="main"):
    try:
        resp = gitea_request(
            "GET",
            f"/repos/{owner}/{repo}/contents/{filepath}",
            token, base_url,
            params={"ref": branch},
            raise_on_error=False,
        )
        return resp.status_code == 200
    except Exception:
        return False


# ── Gitea Issue API ────────────────────────────────────────────────────────────

def ensure_label(owner, repo, label_name, token, base_url, color="#0075ca"):
    """
    确保仓库中存在指定 label，不存在则创建。
    返回 label id。
    """
    try:
        resp = gitea_request("GET", f"/repos/{owner}/{repo}/labels", token, base_url)
        for lbl in resp.json():
            if lbl["name"] == label_name:
                return lbl["id"]
    except Exception:
        pass

    try:
        resp = gitea_request(
            "POST",
            f"/repos/{owner}/{repo}/labels",
            token, base_url,
            json={"name": label_name, "color": color},
        )
        return resp.json().get("id")
    except Exception:
        return None


def get_user_id(username, token, base_url):
    """获取 Gitea 用户 ID（建 issue 时 assignees 字段用 ID）。"""
    try:
        resp = gitea_request("GET", f"/users/{username}", token, base_url)
        return resp.json().get("id")
    except Exception:
        return None


def create_issue(owner, repo, title, body, assignees, label_ids, token, base_url):
    """
    在仓库创建 issue。
    assignees：Gitea 用户名列表。
    label_ids：label id 列表。
    返回 (issue_number, issue_url)，失败抛异常。
    """
    payload = {
        "title": title,
        "body":  body,
    }
    if assignees:
        payload["assignees"] = assignees
    if label_ids:
        payload["labels"] = [lid for lid in label_ids if lid is not None]

    resp = gitea_request(
        "POST",
        f"/repos/{owner}/{repo}/issues",
        token, base_url,
        json=payload,
    )
    data = resp.json()
    return data["number"], data["html_url"]


def update_issue_body(owner, repo, issue_number, new_body, token, base_url):
    """更新 issue 正文（用于依赖回填）。"""
    gitea_request(
        "PATCH",
        f"/repos/{owner}/{repo}/issues/{issue_number}",
        token, base_url,
        json={"body": new_body},
    )


path: scripts/log_utils.py
content: """
日志工具：将运行事件追加写入 aifusion-meta/logs/YYYY-MM-DD.jsonl
"""

import json
import base64
from datetime import datetime
import requests


def write_log(entry, meta_repo, token, base_url):
    try:
        owner, repo_name = meta_repo.split("/", 1)
        today = datetime.now().strftime("%Y-%m-%d")
        filepath = f"logs/{today}.jsonl"
        api_url = f"{base_url.rstrip('/')}/api/v1/repos/{owner}/{repo_name}/contents/{filepath}"
        headers = {
            "Authorization": f"token {token}",
            "Content-Type": "application/json",
        }

        existing_content = ""
        existing_sha = None
        resp = requests.get(api_url, headers=headers, timeout=10)
        if resp.status_code == 200:
            data = resp.json()
            existing_content = base64.b64decode(data["content"]).decode("utf-8")
            existing_sha = data["sha"]

        new_line = json.dumps(entry, ensure_ascii=False)
        new_content = existing_content + new_line + "\n"
        encoded = base64.b64encode(new_content.encode("utf-8")).decode()

        payload = {
            "message": f"log: {entry.get('skill', 'skill')} {entry.get('action', 'event')}",
            "content": encoded,
            "branch": "main",
        }
        if existing_sha:
            payload["sha"] = existing_sha

        method = "PUT" if existing_sha else "POST"
        requests.request(method, api_url, headers=headers, json=payload, timeout=15)
    except Exception:
        pass


path: scripts/webhook.py
content: #!/usr/bin/env python3
"""
Skill-D webhook.py：监听 Gitea push webhook，
检测到 confirmed_issue.md 新增时，打印触发信号供 OpenClaw 识别。

启动方式（后台常驻）：
    nohup python3 scripts/webhook.py \
      > ~/.config/skill-d-create-issues/webhook.log 2>&1 &

Gitea 每个受管仓库 Settings → Webhooks → Add Webhook:
  URL: http://<server-ip>:<WEBHOOK_PORT>/gitea-webhook
  Content-Type: application/json
  Trigger: Push Events
  Secret: （与 WEBHOOK_SECRET 一致，留空则不验签）

收到触发后，本服务将在 stdout 打印如下格式的 JSON 触发信号：
  SKILL_D_TRIGGER: {"repo": "owner/repo", "meeting_dir": "2026-04-22-1500"}

OpenClaw 通过读取 webhook.log 或直接接管 stdout 来检测触发信号。
"""

import os
import sys
import json
import hashlib
import hmac
from datetime import datetime

import pytz
from flask import Flask, request, abort
from dotenv import load_dotenv

load_dotenv(os.path.expanduser("~/.config/skill-d-create-issues/.env"))

WEBHOOK_PORT   = int(os.getenv("WEBHOOK_PORT", "8765"))
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "").encode()
TZ             = pytz.timezone("Asia/Shanghai")

app = Flask(__name__)


def verify_signature(payload_bytes: bytes, signature_header: str) -> bool:
    """验证 Gitea HMAC-SHA256 签名（WEBHOOK_SECRET 为空时跳过验证）。"""
    if not WEBHOOK_SECRET:
        return True
    if not signature_header:
        return False
    # Gitea 格式：sha256=<hex>
    prefix = "sha256="
    if not signature_header.startswith(prefix):
        return False
    expected = hmac.new(WEBHOOK_SECRET, payload_bytes, hashlib.sha256).hexdigest()
    received = signature_header[len(prefix):]
    return hmac.compare_digest(expected, received)


def extract_triggers(payload: dict) -> list[dict]:
    """
    从 push payload 中提取所有 confirmed_issue.md 新增事件。
    返回 [{"repo": "owner/repo", "meeting_dir": "YYYY-MM-DD-HHMM"}]
    """
    triggers = []

    repo_full_name = payload.get("repository", {}).get("full_name", "")
    commits = payload.get("commits", [])

    for commit in commits:
        for added_file in commit.get("added", []):
            # 匹配 meetings/<dir>/confirmed_issue.md
            parts = added_file.split("/")
            if (
                len(parts) == 3
                and parts[0] == "meetings"
                and parts[2] == "confirmed_issue.md"
                and parts[1] != "archive"
            ):
                meeting_dir = parts[1]
                triggers.append({
                    "repo":        repo_full_name,
                    "meeting_dir": meeting_dir,
                })

    return triggers


@app.route("/gitea-webhook", methods=["POST"])
def gitea_webhook():
    payload_bytes = request.get_data()

    # 签名验证
    sig = request.headers.get("X-Gitea-Signature", "")
    if not verify_signature(payload_bytes, sig):
        abort(403)

    # 只处理 push 事件
    event = request.headers.get("X-Gitea-Event", "")
    if event != "push":
        return "ignored", 200

    try:
        payload = json.loads(payload_bytes)
    except Exception:
        abort(400)

    triggers = extract_triggers(payload)

    for trigger in triggers:
        # 输出触发信号（OpenClaw 通过日志或 stdout 检测）
        signal = {
            "ts":          datetime.now(TZ).isoformat(),
            "event":       "confirmed_issue_detected",
            "repo":        trigger["repo"],
            "meeting_dir": trigger["meeting_dir"],
        }
        line = f"SKILL_D_TRIGGER: {json.dumps(signal, ensure_ascii=False)}"
        print(line, flush=True)
        sys.stdout.flush()

    return "ok", 200


@app.route("/health", methods=["GET"])
def health():
    return json.dumps({"status": "ok", "port": WEBHOOK_PORT}), 200


if __name__ == "__main__":
    print(
        f"[Skill-D webhook] 启动中，监听端口 {WEBHOOK_PORT}...",
        flush=True,
    )
    print(
        f"[Skill-D webhook] 签名验证：{'已启用' if WEBHOOK_SECRET else '未启用（WEBHOOK_SECRET 为空）'}",
        flush=True,
    )
    app.run(host="0.0.0.0", port=WEBHOOK_PORT, debug=False)


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)