File size: 9,939 Bytes
f10d14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""Reply study for names.asker_required: six pre-registered cases (eval/asker_study_cases.json) on one throwaway merge request.
The issue is written by the second account (the ASKER); the merge request is opened by the first account (the OTHER person,
also the merge request author). The target branch's .itemator.toml sets names.asker_required = true. The card flow opens one
thread per extra; each case's reply is typed by the account named in the case ('ASKER' = second account with its token from
~/.itemator-second-token, 'OTHER' = the first account); the reaction flow is started once with a mention and every thread is read
afterwards. Raw record: results/asker_study_raw.json, counts: results/asker_study.json.

usage (repository root, glab authenticated; spends Duo credits, about two flow runs):
    python3 eval/asker_study.py --credit-stop 241
    python3 eval/asker_study.py --summarize
The token is read from the file and sent only to gitlab.com; it is never printed or stored.
"""
from __future__ import annotations

import argparse, base64, json, os, pathlib, sys, time, urllib.parse, urllib.request

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import react_study as R                                    # the same helpers and the same platform constants

ROOT = R.ROOT
CASES = ROOT / "eval" / "asker_study_cases.json"
RAW = ROOT / "results" / "asker_study_raw.json"
OUT = ROOT / "results" / "asker_study.json"
BRANCHES = ("probe/as-src", "probe/as-target")
P = R.P
MENTION = R.REACT_MENTION
TOKEN_FILE = pathlib.Path("~/.itemator-second-token").expanduser()


def second(method, path, **fields):
    """One call as the second account. The token never leaves this function."""
    tok = TOKEN_FILE.read_text().strip()
    data = urllib.parse.urlencode(fields).encode() if fields else None
    req = urllib.request.Request(f"https://gitlab.com/api/v4/{path}", data=data, method=method, headers={"PRIVATE-TOKEN": tok})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.loads(r.read() or b"null")


def run(doc):
    cases = doc["cases"]
    rec = {"started": time.strftime("%Y-%m-%dT%H:%M:%S"), "cases": []}
    issue = second("POST", f"projects/{P}/issues", title="Probe: fix a typo in the README (asker study)",
                   description="Fix one typo in the README heading. Nothing else is asked.")
    asker = issue["author"]["username"]
    rec.update(issue_iid=issue["iid"], asker=asker)
    R.api(f"projects/{P}/repository/branches", "POST", branch=BRANCHES[1], ref="main")
    R.api(f"projects/{P}/repository/branches", "POST", branch=BRANCHES[0], ref=BRANCHES[1])

    def read(path, ref):
        return base64.b64decode(R.api(f"projects/{P}/repository/files/{path.replace('/', '%2F')}?ref={ref}")["content"]).decode()
    pol = read(".itemator.toml", BRANCHES[1])
    assert "other_person_required = false" in pol
    R.commit(BRANCHES[1], "Only the issue's author keeps an extra (test target only)",
             [{"action": "update", "file_path": ".itemator.toml",
               "content": pol.replace("other_person_required = false", "other_person_required = false\nasker_required = true", 1)}])
    rd, ci = read("README.md", BRANCHES[0]), read(".gitlab-ci.yml", BRANCHES[0])
    R.commit(BRANCHES[0], "Fix a typo in the README", [
        {"action": "update", "file_path": "README.md", "content": rd.replace("## Related tools\n", "## Related tools:\n", 1)},
        {"action": "create", "file_path": "docker-compose.yml", "content": "services: {}\n"},
        {"action": "create", "file_path": "Dockerfile", "content": "FROM python:3.12-slim\nRUN true\n"},
        {"action": "create", "file_path": "notes.txt", "content": "scratch notes\n"},
        {"action": "create", "file_path": "setup.cfg", "content": "[metadata]\nname = probe\n"},
        {"action": "create", "file_path": "tox.ini", "content": "[tox]\nenvlist = py312\n"},
        {"action": "update", "file_path": ".gitlab-ci.yml", "content": "# probe: ordering note\n" + ci}])
    mr = R.api(f"projects/{P}/merge_requests", "POST", source_branch=BRANCHES[0], target_branch=BRANCHES[1],
               title="Fix a typo in the README", description=f"Closes #{issue['iid']}\n\nFixes the heading.")
    iid = rec["mr_iid"] = mr["iid"]
    t0 = time.time()
    while time.time() - t0 < 720:
        pl = R.api(f"projects/{P}/merge_requests/{iid}/pipelines")
        if pl and pl[0]["status"] in ("success", "failed"):
            rec["pipeline"] = pl[0]["status"]; break
        time.sleep(15)
    try:
        R.trigger("card", True)
        R.api(f"projects/{P}/merge_requests/{iid}", "PUT", reviewer_ids=R.CARD_USER)
        rec["card_done"] = R.wait_session_done(iid, "card", 1200)
    finally:
        R.trigger("card", False)
    time.sleep(5)
    threads = {}
    for d in R.discussions(iid):
        first = d["notes"][0]
        if "card" in first["author"]["username"] and "reply with its exact name" in first["body"]:
            ticks = first["body"].split("`")
            if len(ticks) > 1:
                threads[ticks[1]] = (d["id"], first["body"])
    rec["threads_found"] = sorted(threads)
    rec["asker_sentence_in_every_thread"] = all(f"(@{asker}) can keep this." in b for _, b in threads.values()) if threads else False
    for c in cases:
        d = threads.get(c["name"], (None, ""))[0]
        c["sent"] = [r["text"].replace("{NAME}", c["name"]) for r in c["replies"]]
        if not d:
            continue
        for text in c["sent"]:
            if c["author"] == "ASKER":
                second("POST", f"projects/{P}/merge_requests/{iid}/discussions/{d}/notes", body=text)
            else:
                R.api(f"projects/{P}/merge_requests/{iid}/discussions/{d}/notes", "POST", body=text)
    try:
        R.trigger("react", True)
        R.api(f"projects/{P}/merge_requests/{iid}/notes", "POST", body=f"{MENTION} check")
        rec["react_done"] = R.wait_session_done(iid, "react", 1200)
    finally:
        R.trigger("react", False)
    time.sleep(10)
    by_id = {d["id"]: d for d in R.discussions(iid)}
    for c in cases:
        d = threads.get(c["name"], (None, ""))[0]
        got = R.classify(by_id[d]) if d in by_id else {"settled": None, "resolved": False, "bot_notes": [], "missing_thread": True}
        outcome = got["settled"] or "open"
        if outcome == "open" and got["resolved"]:
            outcome = "resolved silently"
        rec["cases"].append({"n": c["n"], "name": c["name"], "label": c["label"], "author": c["author"], "sent": c["sent"],
                             "expected": c["expected"], "got": outcome,
                             "match": outcome == c["expected"] and not got.get("missing_thread"),
                             "bot_notes": got["bot_notes"], "thread_resolved": got["resolved"]})
    m = R.api(f"projects/{P}/merge_requests/{iid}")
    rec["mr_after"] = {"state": m["state"], "title": m["title"], "labels": m["labels"], "merge_status": m.get("detailed_merge_status")}
    issue_notes = second("GET", f"projects/{P}/issues/{issue['iid']}/notes?per_page=50")
    rec["receipt_on_issue"] = any("Receipt from Itemator" in (n.get("body") or "") for n in issue_notes)
    for step in (lambda: R.api(f"projects/{P}/merge_requests/{iid}", "PUT", state_event="close"),
                 lambda: R.api(f"projects/{P}/issues/{issue['iid']}", "PUT", state_event="close"),
                 lambda: R.api(f"projects/{P}/repository/branches/{BRANCHES[0].replace('/', '%2F')}", "DELETE"),
                 lambda: R.api(f"projects/{P}/repository/branches/{BRANCHES[1].replace('/', '%2F')}", "DELETE")):
        try: step()
        except Exception: pass
    return rec


def summarize(runs):
    rows = [c for r in runs for c in r["cases"]]
    ok = [c for c in rows if c["match"]]
    return {"what": "Six reply cases for names.asker_required (reaction flow), pre-registered in eval/asker_study_cases.json. Counts only.",
            "limits": "Two runs (on two versions of the flow), one merge request each, the same six cases, six cases written by us, two accounts, one model reading. The cases file names the first account as ASKER; the runner (committed before the run, a86e848) used the second account as ASKER and the first as OTHER, the same swap in every case, so the outcomes stand. It shows whether the model applies the username test, not how people answer; a username that differs only in letter case cannot be produced on GitLab, so that clause is covered by unit tests only.",
            "runs": len(runs), "cases": len(rows), "as_expected": len(ok), "misread": len(rows) - len(ok),
            "misread_cases": [{"n": c["n"], "label": c["label"], "expected": c["expected"], "got": c["got"]} for c in rows if not c["match"]],
            "asker_sentence_in_every_thread": all(r.get("asker_sentence_in_every_thread") for r in runs),
            "record_on_issue_posted": any(r.get("receipt_on_issue") for r in runs)}


def main():
    ap = argparse.ArgumentParser(); ap.add_argument("--credit-stop", type=float, default=0.0); ap.add_argument("--summarize", action="store_true")
    a = ap.parse_args()
    runs = json.loads(RAW.read_text()) if RAW.exists() else []
    if a.summarize:
        OUT.write_text(json.dumps(summarize(runs), indent=1) + "\n"); print("wrote", OUT.name); return 0
    m = R.meter(); print("meter", m)
    if a.credit_stop and m is not None and m >= a.credit_stop:
        print("credit stop reached"); return 1
    rec = run(json.loads(CASES.read_text()))
    runs.append(rec); RAW.write_text(json.dumps(runs, indent=1) + "\n")
    for c in rec["cases"]:
        print(f"{c['n']:>2} {c['label'][:52]:52} expected {c['expected']:9} got {c['got']:9} {'OK' if c['match'] else 'MISMATCH'}")
    print("asker sentence in every thread:", rec["asker_sentence_in_every_thread"], "| record on issue:", rec["receipt_on_issue"])
    return 0


if __name__ == "__main__":
    sys.exit(main())