diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 13:03:50 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-03-05 13:03:50 +0000 |
| commit | f3fcf863aae000964753f574b00e9fb9f5fcd452 (patch) | |
| tree | 522e1cff8e0b8ab9fcabcf1bc6d229076891542e /src/lib | |
| parent | ad6c39abdc35603f58e9b71993b5632c976deac1 (diff) | |
feat(subject): add pr/issue set-subject via NIP-32 kind-1985 labels
Adds the ability to update the displayed title of a PR or issue after
creation using a kind-1985 label event with the #subject namespace.
Only the author or a repository maintainer may set the subject. The
latest authorised event wins with tiebreak by lexicographically larger
event ID (NIP-1 replaceable event semantics). Branch names and commit
messages are never affected.
- Split get_labels() into process_labels() (additive #t) and
process_subject() (replaceable-style #subject), with a shared
get_labels_and_subject() entry point that processes both from a
single pre-fetched slice of kind-1985 events
- All list/view/JSON display paths apply the subject override silently
- New ngit pr set-subject <id> --subject <text> command
- New ngit issue set-subject <id> --subject <text> command
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/git_events.rs | 133 |
1 files changed, 120 insertions, 13 deletions
diff --git a/src/lib/git_events.rs b/src/lib/git_events.rs index a5aef12..b512e44 100644 --- a/src/lib/git_events.rs +++ b/src/lib/git_events.rs | |||
| @@ -978,30 +978,31 @@ pub fn is_event_proposal_root_for_branch( | |||
| 978 | )) | 978 | )) |
| 979 | } | 979 | } |
| 980 | 980 | ||
| 981 | /// Compute the effective set of labels for `event`. | 981 | /// Process hashtag labels (`#t` namespace) from a pre-fetched set of kind-1985 |
| 982 | /// events. | ||
| 982 | /// | 983 | /// |
| 983 | /// Labels come from two sources, both subject to the same permission check: | 984 | /// Labels come from two sources, both subject to the same permission check: |
| 984 | /// | 985 | /// |
| 985 | /// 1. `t` tags on the event itself (self-reported by the event author). | 986 | /// 1. `t` tags on the event itself (self-reported by the event author). |
| 986 | /// 2. NIP-32 kind-1985 label events in `all_label_events` that reference | 987 | /// 2. NIP-32 kind-1985 label events in `label_events` that reference `event` |
| 987 | /// `event` via a lowercase `e` tag and carry `["L", "#t"]` + | 988 | /// via a lowercase `e` tag and carry `["L", "#t"]` + |
| 988 | /// `["l", "<value>", "#t"]` tags. | 989 | /// `["l", "<value>", "#t"]` tags. |
| 989 | /// | 990 | /// |
| 990 | /// A label is only applied when the author of the source event (the original | 991 | /// A label is only applied when the author of the source event is either the |
| 991 | /// event for inline `t` tags, or the kind-1985 event for external labels) is | 992 | /// author of `event` itself or one of the repository maintainers. |
| 992 | /// either the author of `event` itself or one of the repository maintainers. | 993 | /// |
| 993 | pub fn get_labels( | 994 | /// Labels are additive — all valid label events contribute; there is no |
| 995 | /// "latest wins" replacement semantics. | ||
| 996 | pub fn process_labels( | ||
| 994 | event: &Event, | 997 | event: &Event, |
| 995 | repo_ref: &RepoRef, | 998 | repo_ref: &RepoRef, |
| 996 | all_label_events: &[Event], | 999 | label_events: &[Event], |
| 997 | ) -> Vec<String> { | 1000 | ) -> Vec<String> { |
| 998 | let is_permitted = |pubkey: &PublicKey| -> bool { | 1001 | let is_permitted = |pubkey: &PublicKey| -> bool { |
| 999 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | 1002 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) |
| 1000 | }; | 1003 | }; |
| 1001 | 1004 | ||
| 1002 | // 1. Inline `t` tags on the event itself — only if the event author is | 1005 | // 1. Inline `t` tags on the event itself. |
| 1003 | // permitted (they always are, since they authored the event, but we | ||
| 1004 | // keep the check symmetric with the external-label path). | ||
| 1005 | let mut labels: Vec<String> = if is_permitted(&event.pubkey) { | 1006 | let mut labels: Vec<String> = if is_permitted(&event.pubkey) { |
| 1006 | event | 1007 | event |
| 1007 | .tags | 1008 | .tags |
| @@ -1016,7 +1017,7 @@ pub fn get_labels( | |||
| 1016 | vec![] | 1017 | vec![] |
| 1017 | }; | 1018 | }; |
| 1018 | 1019 | ||
| 1019 | // 2. External NIP-32 kind-1985 label events. | 1020 | // 2. External NIP-32 kind-1985 label events (`#t` namespace). |
| 1020 | // | 1021 | // |
| 1021 | // A valid label event must: | 1022 | // A valid label event must: |
| 1022 | // - be kind 1985 | 1023 | // - be kind 1985 |
| @@ -1025,7 +1026,7 @@ pub fn get_labels( | |||
| 1025 | // - have at least one `["l", "<value>", "#t"]` tag | 1026 | // - have at least one `["l", "<value>", "#t"]` tag |
| 1026 | // - be authored by a permitted pubkey | 1027 | // - be authored by a permitted pubkey |
| 1027 | let event_id_str = event.id.to_string(); | 1028 | let event_id_str = event.id.to_string(); |
| 1028 | for label_event in all_label_events { | 1029 | for label_event in label_events { |
| 1029 | if !label_event.kind.eq(&KIND_LABEL) { | 1030 | if !label_event.kind.eq(&KIND_LABEL) { |
| 1030 | continue; | 1031 | continue; |
| 1031 | } | 1032 | } |
| @@ -1063,6 +1064,112 @@ pub fn get_labels( | |||
| 1063 | labels | 1064 | labels |
| 1064 | } | 1065 | } |
| 1065 | 1066 | ||
| 1067 | /// Process the effective subject/title override for `event` from a pre-fetched | ||
| 1068 | /// set of kind-1985 events. | ||
| 1069 | /// | ||
| 1070 | /// Subject overrides use the `#subject` namespace: | ||
| 1071 | /// `["L", "#subject"]` + `["l", "<new title>", "#subject"]` | ||
| 1072 | /// | ||
| 1073 | /// Unlike hashtag labels, subject overrides are replaceable-style: only the | ||
| 1074 | /// latest authorised event wins, with tiebreak by lexicographically larger | ||
| 1075 | /// event ID (consistent with NIP-1 replaceable event semantics). | ||
| 1076 | /// | ||
| 1077 | /// Only the author of `event` or a repository maintainer may set the subject. | ||
| 1078 | /// Returns `None` when no valid subject override exists. | ||
| 1079 | pub fn process_subject( | ||
| 1080 | event: &Event, | ||
| 1081 | repo_ref: &RepoRef, | ||
| 1082 | label_events: &[Event], | ||
| 1083 | ) -> Option<String> { | ||
| 1084 | let is_permitted = |pubkey: &PublicKey| -> bool { | ||
| 1085 | pubkey.eq(&event.pubkey) || repo_ref.maintainers.contains(pubkey) | ||
| 1086 | }; | ||
| 1087 | |||
| 1088 | let event_id_str = event.id.to_string(); | ||
| 1089 | |||
| 1090 | // Find the winning subject label event: latest created_at, tiebreak by | ||
| 1091 | // lexicographically larger event ID (NIP-1 replaceable event semantics). | ||
| 1092 | let winner = label_events | ||
| 1093 | .iter() | ||
| 1094 | .filter(|le| { | ||
| 1095 | if !le.kind.eq(&KIND_LABEL) { | ||
| 1096 | return false; | ||
| 1097 | } | ||
| 1098 | if !is_permitted(&le.pubkey) { | ||
| 1099 | return false; | ||
| 1100 | } | ||
| 1101 | // Must reference our event via a lowercase `e` tag. | ||
| 1102 | let references_event = le.tags.iter().any(|t| { | ||
| 1103 | let s = t.as_slice(); | ||
| 1104 | s.len() >= 2 && s[0].eq("e") && s[1].eq(&event_id_str) | ||
| 1105 | }); | ||
| 1106 | if !references_event { | ||
| 1107 | return false; | ||
| 1108 | } | ||
| 1109 | // Must declare the `#subject` namespace. | ||
| 1110 | let has_namespace = le.tags.iter().any(|t| { | ||
| 1111 | let s = t.as_slice(); | ||
| 1112 | s.len() >= 2 && s[0].eq("L") && s[1].eq("#subject") | ||
| 1113 | }); | ||
| 1114 | if !has_namespace { | ||
| 1115 | return false; | ||
| 1116 | } | ||
| 1117 | // Must have at least one non-empty `["l", "<value>", "#subject"]` tag. | ||
| 1118 | le.tags.iter().any(|t| { | ||
| 1119 | let s = t.as_slice(); | ||
| 1120 | s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() | ||
| 1121 | }) | ||
| 1122 | }) | ||
| 1123 | .max_by(|a, b| { | ||
| 1124 | // Primary: newer created_at wins. | ||
| 1125 | // Tiebreak: lexicographically larger event ID wins (NIP-1). | ||
| 1126 | a.created_at | ||
| 1127 | .cmp(&b.created_at) | ||
| 1128 | .then_with(|| a.id.to_string().cmp(&b.id.to_string())) | ||
| 1129 | })?; | ||
| 1130 | |||
| 1131 | // Extract the subject value from the winning event. | ||
| 1132 | winner.tags.iter().find_map(|t| { | ||
| 1133 | let s = t.as_slice(); | ||
| 1134 | if s.len() >= 3 && s[0].eq("l") && s[2].eq("#subject") && !s[1].is_empty() { | ||
| 1135 | Some(s[1].clone()) | ||
| 1136 | } else { | ||
| 1137 | None | ||
| 1138 | } | ||
| 1139 | }) | ||
| 1140 | } | ||
| 1141 | |||
| 1142 | /// Compute both the effective hashtag labels and the subject/title override for | ||
| 1143 | /// `event` from a pre-fetched set of kind-1985 events. | ||
| 1144 | /// | ||
| 1145 | /// This is the primary entry point: callers should fetch label events once | ||
| 1146 | /// (covering both `#t` and `#subject` namespaces) and pass them here to get | ||
| 1147 | /// both results in a single pass. | ||
| 1148 | /// | ||
| 1149 | /// Returns `(labels, subject_override)` where `subject_override` is `None` | ||
| 1150 | /// when no authorised `#subject` label exists. | ||
| 1151 | pub fn get_labels_and_subject( | ||
| 1152 | event: &Event, | ||
| 1153 | repo_ref: &RepoRef, | ||
| 1154 | label_events: &[Event], | ||
| 1155 | ) -> (Vec<String>, Option<String>) { | ||
| 1156 | ( | ||
| 1157 | process_labels(event, repo_ref, label_events), | ||
| 1158 | process_subject(event, repo_ref, label_events), | ||
| 1159 | ) | ||
| 1160 | } | ||
| 1161 | |||
| 1162 | /// Compatibility wrapper — returns only the hashtag labels. | ||
| 1163 | /// | ||
| 1164 | /// Prefer [`get_labels_and_subject`] when the subject override is also needed. | ||
| 1165 | pub fn get_labels( | ||
| 1166 | event: &Event, | ||
| 1167 | repo_ref: &RepoRef, | ||
| 1168 | label_events: &[Event], | ||
| 1169 | ) -> Vec<String> { | ||
| 1170 | process_labels(event, repo_ref, label_events) | ||
| 1171 | } | ||
| 1172 | |||
| 1066 | pub fn get_status( | 1173 | pub fn get_status( |
| 1067 | proposal: &Event, | 1174 | proposal: &Event, |
| 1068 | repo_ref: &RepoRef, | 1175 | repo_ref: &RepoRef, |